deploy-admin-functions.mjs 985 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995
  1. #!/usr/bin/env node
  2. import { readFileSync } from 'node:fs';
  3. const enrollmentOpsCode = readFileSync(new URL('./cloud/member-enrollment.js', import.meta.url), 'utf8');
  4. const mobileTeachingCode = readFileSync(new URL('./cloud/mobile-teaching.js', import.meta.url), 'utf8');
  5. const enrollmentAppCode = readFileSync(new URL('./cloud/member-enrollment-app.js', import.meta.url), 'utf8');
  6. const payrollIntegrityCode = readFileSync(new URL('./cloud/payroll-integrity.js', import.meta.url), 'utf8');
  7. const operatorAccountsCode = readFileSync(new URL('./cloud/operator-accounts.js', import.meta.url), 'utf8');
  8. const adminRolePolicyCode = readFileSync(new URL('./cloud/admin-role-policy.js', import.meta.url), 'utf8');
  9. const reviewRewardCode = readFileSync(new URL('./cloud/review-rewards.js', import.meta.url), 'utf8');
  10. const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
  11. const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
  12. const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
  13. const validateOnly = process.argv.includes('--validate');
  14. const onlyName = process.argv.find((value) => value.startsWith('--only='))?.slice('--only='.length) || '';
  15. const adminGatewayCode = String.raw`
  16. const CLASS_LABELS = {
  17. _User: '用户管理', Company: '帐套管理', Profile: '员工档案', Group: '用户组', Role: '角色', Permission: '权限', Department: '组织部门',
  18. CourseBinding: '课程绑定', CourseAppointment: '课程预约', LessonRecord: '上课记录', DailyStudyRecord: '每日学习记录', PracticeRecord: '练习记录', MemoryPracticeRecord: '抗遗忘记录',
  19. VocabularyWord: '词库', CommonModel: '通用内容', ContentArticle: '文章内容', ContentPublish: '报刊内容', Node: '栏目节点', NodeAuth: '节点权限', Model: '内容模型', ModelField: '模型字段', PublishNode: '报刊发布节点',
  20. ExamClass: '试题分类', ExamSysQuestions: '考试题库', ExamSysPapers: '考试试卷', ExamType: '考试类型', ExamPoint: '知识点', PaperQuestions: '试卷大题分组',
  21. Datadiccategory: '数据字典分类', Datadic: '数据字典项',
  22. GradeCate: '多级字典分类', Grade: '多级字典选项',
  23. Currency: '货币管理', SysHoliday: '节假日管理',
  24. Product: '商品', StoreProduct: '门店商品', StoreApplication: '门店申请', ShopFareTlp: '商城运费模板', ShopMoneyRegular: '金额规则', PayPlat: '支付平台',
  25. GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科审核', Pub: '互动模块定义', PubTw: '提问互动提交', PubWTHD: '问题回答提交', PubZXDC: '在线调查提交', Role: '系统角色', ARoleAuth: '角色权限', Search: '后台快捷入口', AdZone: '广告位管理', AdInfo: '广告内容管理', FontPicShape: '字体图形素材', FontPicShapeType: '字体图形分类', DesignAsk: '问卷调查', DesignQuestion: '问卷题目', DesignAnswer: '问卷答卷', DesignRes: '可视化设计资源', DesignScence: '可视化场景', DesignTlp: '可视化模板', ServiceSeat: '客服席位', Temp: '客服欢迎语', StoreApplication: '店铺管理', StoreStyle: '店铺样式', PageStyle: '黄页样式', PlatComp: '协同办公企业', ContentTagKey: '内容标签词库', UserLevel: '积分等级', UserMoneyLog: '资金积分流水', UserPromotion: '用户推广关系', CRMSAttr: 'CRM 客户类型', SysLog: '系统日志', MisType: 'OA 流程类型', Agency: '代理机构', DeliveryCenter: '交付中心'
  26. };
  27. const ALLOWED_CLASSES = new Set([
  28. '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'
  29. ]);
  30. const READ_ONLY_CLASSES = new Set(['CourseBinding','CourseAppointment','LessonRecord','UserSIcon','UserUserPoint','UserExpDomP','UserExpHis','_Role','Permission','ARoleAuth','PayPlat','ThirdPlatInfo','GuestBar','DesignAnswer','Baike','ExamSysPapers','ExamSysQuestions','PaperQuestions','ExamType','ContentPublish','PublishNode','SysLog','Pub','PubTw','PubWTHD','PubZXDC']);
  31. 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']);
  32. const HIDDEN_FIELDS = new Set(['password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','apiKey','appSecret','secret','token']);
  33. const GENERIC_WRITE_TYPES = new Set(['String','Number','Boolean','Date','Pointer','Object','Array']);
  34. const CLASS_SYSTEM_FIELDS = {
  35. CommonModel: new Set(['generalId','modelId','nodeId','itemId','tableName','status','isDeleted','sourceKey','orderId']),
  36. Node: new Set(['nodeId','parentId','depth','child','orderId','zstatus','sourceKey','cuser','cuname','editDate']),
  37. Special: new Set(['specId','pid','orderId','sourceKey','cuser','editDate']),
  38. Model: new Set(['modelId','modelType','tableName','sourceKey','nodeId','fromModel','multiFlag','sysModel']),
  39. ModelField: new Set(['fieldId','modelId','fieldName','fieldType','sourceKey','sysType','orderId']),
  40. Guestbook: new Set(['gid','parentid','cateid','userid','status','sourceKey','gdate','ip']),
  41. Guestcate: new Set(['cateid','parentId','gtype','sourceKey','orderId']),
  42. GuestBar: new Set(['id','cateId','pid','replyId','replyUserId','cuser','cuname','cdate','status','sourceKey','orderFlag','postFlag']),
  43. ExamClass: new Set(['cId','cClassid','cOrderBy','cClassType','sourceKey']),
  44. ExamPoint: new Set(['id','tid','orderBy','addUser','addTime','sourceKey']),
  45. QuestionsKnowledge: new Set(['kId','kClassId','kOrderBy','pid','cuser','cdate','sourceKey']),
  46. ExTeacher: new Set(['id','tclsss','addUser','creatTime','sourceKey']),
  47. Datadiccategory: new Set(['diccateid','sourceKey']),
  48. Datadic: new Set(['dicid','diccate','sourceKey']),
  49. GradeCate: new Set(['cateId','sourceKey']),
  50. Grade: new Set(['gradeId','cate','parentId','grade','sourceKey']),
  51. Currency: new Set(['id','sourceKey']),
  52. SysHoliday: new Set(['id','cdate','cadminId','cuserId','sourceKey']),
  53. Search: new Set(['id','type','state','time','adminId','orderId','linkType','linkState','sourceKey']),
  54. AdZone: new Set(['id','cdate','cadmin','image','sourceKey']),
  55. AdInfo: new Set(['id','cdate','zoneId','ztype','extend1','extend2','extend3','sourceKey']),
  56. FontPicShape: new Set(['id','shape','typeId','userId','userName','sourceKey','createTime','updateTime']),
  57. FontPicShapeType: new Set(['id','sourceKey','createTime','updateTime']),
  58. DesignAsk: new Set(['id','cdate','cuser','adminId','sourceKey']),
  59. DesignQuestion: new Set(['id','askId','cdate','cuser','orderId','sourceKey']),
  60. DesignAnswer: new Set(['id','askId','ip','cdate','userId','sourceKey']),
  61. DesignRes: new Set(['id','vpath','previewimg','cdate','userid','sourceKey']),
  62. DesignScence: new Set(['id','guid','comp','page','path','cdate','cuser','tlpId','ztype','scence','siteId','update','company','orderId','labelArr','resource','userName','accessPwd','sourceKey']),
  63. DesignTlp: new Set(['id','cdate','isDef','ztype','company','sourceKey']),
  64. Role: new Set(['roleId','ztype','zstatus','nodeId','auth','auth2','auth3','cadminId','cdate','sourceKey']),
  65. ARoleAuth: new Set(['id','rid','adminId','sourceKey']),
  66. ServiceSeat: new Set(['sId','sAdminId','sRemrk','sDateTime','sourceKey']),
  67. Temp: new Set(['id','useType','str3','str5','str6','describe','cdate','userId','sourceKey']),
  68. StoreApplication: new Set(['id','userId','userName','addTime','storeState','storeCommendState','storeModelId','sourceKey']),
  69. StoreStyle: new Set(['id','cdate','zstatus','sourceKey']),
  70. UserLevel: new Set(['id','cdate','ztype','addon1','addon2','addon3','addon4','addon5','orderId','storeId','zstatus','sourceKey','discountRate']),
  71. CRMSAttr: new Set(['id','ztype','cdate','zstatus','value2','value3','sourceKey']),
  72. ShopFareTlp: new Set(['id','cdate','userId','adminId','isFree','regionBan','ems','mail','sourceKey']),
  73. MisType: new Set(['id','createTime','sourceKey']),
  74. PageStyle: new Set(['pageNodeid','addtime','sourceKey']),
  75. PlatComp: new Set(['id','createUser','status','uppath','sourceKey'])
  76. };
  77. const USER_MONEY_LOG_CLASSES = { 1:'UserExpDomP', 2:'UserSIcon', 3:'UserExpHis', 4:'UserUserPoint', 5:'UserDummyPoint', 6:'UserCredit' };
  78. const USER_MONEY_LOG_LABELS = { 1:'余额', 2:'银币', 3:'经验', 4:'积分', 5:'虚拟币', 6:'信用分' };
  79. const USER_MONEY_BALANCE_FIELDS = { 1:'Purse', 2:'SilverCoin', 3:'UserExp', 4:'UserPoint', 5:'DummyPurse', 6:'UserCreit' };
  80. function inputOf(request) {
  81. const body = request.body || {};
  82. return body.params && typeof body.params === 'object' ? body.params : body;
  83. }
  84. function fail(status, message) { const error = new Error(message); error.status = status; throw error; }
  85. function pointerId(value) { return value && (value.id || value.objectId || (value.__type === 'Pointer' && value.objectId)); }
  86. function escapeRegex(value) { return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); }
  87. function safeValue(value, depth = 0) {
  88. if (value == null || depth > 4) return value;
  89. if (Array.isArray(value)) return value.map((item) => safeValue(item, depth + 1));
  90. if (value instanceof Date) return value.toISOString();
  91. 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) };
  92. if (value && typeof value.toJSON === 'function') return safeValue(value.toJSON(), depth + 1);
  93. if (typeof value === 'object') {
  94. const output = {};
  95. for (const [key, item] of Object.entries(value)) {
  96. if (HIDDEN_FIELDS.has(key) || /(?:password|secret|token|masterkey|privatekey|mch_key|^ak$|^sk$)/i.test(key)) continue;
  97. output[key] = safeValue(item, depth + 1);
  98. }
  99. return output;
  100. }
  101. return value;
  102. }
  103. function normalizeContentTags(value) {
  104. const source = Array.isArray(value) ? value : String(value == null ? '' : value).split(/[|\r\n]+/);
  105. if (source.length > 1000) fail(400, '标签数量不能超过 1000 个');
  106. const tags = source.map((item) => String(item == null ? '' : item).replace(/[\s"\\]/g, '')).filter(Boolean);
  107. if (tags.some((tag) => tag.length > 100 || /[\u0000-\u001f\u007f]/.test(tag))) fail(400, '单个标签长度不能超过 100 个字符且不能包含控制字符');
  108. return tags;
  109. }
  110. async function readContentTags() {
  111. const config = await Parse.Config.get({ useMasterKey: true });
  112. return normalizeContentTags(config.get('legacyContentTags') || []);
  113. }
  114. 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); }
  115. async function requireAdmin(request) {
  116. const current = request.user || (typeof user !== 'undefined' ? user : null);
  117. if (!current) fail(401, '管理员会话已失效');
  118. await current.fetch({ useMasterKey: true });
  119. if (current.get('isDisabled') === true || current.get('isDeleted') === true) fail(403, '管理员账号已停用');
  120. const {roles,isSuperAdmin,isAdmin} = adminRoleSnapshot(current);
  121. if (!isAdmin) fail(403, '当前账号未被授权为后台管理员');
  122. const company = request.company || current.get('company') || null;
  123. if (!company && !isSuperAdmin) fail(403, '管理员账号尚未分配帐套');
  124. return { current, roles, isSuperAdmin, company };
  125. }
  126. function isAdminAccount(target) { return adminRoleSnapshot(target).isAdmin; }
  127. function assertCanManageUser(context, target, action) {
  128. if (action === 'lock' && target.id === context.current.id) fail(400, '不能停用当前登录账号');
  129. if (isAdminAccount(target) && target.id !== context.current.id && !context.isSuperAdmin) fail(403, '只有超级管理员可以管理其他管理员账号');
  130. }
  131. function operationsRoleOf(context) { if (context.isSuperAdmin || context.roles.includes('ops-manager')) return 'ops-manager'; if (context.roles.includes('ops-staff')) return 'ops-staff'; if (context.roles.includes('ops-auditor')) return 'ops-auditor'; return 'denied'; }
  132. async function revokeSessions(targets) {
  133. let revoked = 0;
  134. while (targets.length) {
  135. const query = new Parse.Query('_Session'); query.containedIn('user', targets); query.limit(1000);
  136. const sessions = await query.find({ useMasterKey: true });
  137. if (!sessions.length) break;
  138. await Parse.Object.destroyAll(sessions, { useMasterKey: true }); revoked += sessions.length;
  139. if (sessions.length < 1000) break;
  140. }
  141. return revoked;
  142. }
  143. function assertClass(className) {
  144. if (!ALLOWED_CLASSES.has(className)) fail(400, '不允许访问该数据类');
  145. }
  146. function isSystemField(className, name) { return SYSTEM_FIELDS.has(name) || Boolean(CLASS_SYSTEM_FIELDS[className] && CLASS_SYSTEM_FIELDS[className].has(name)); }
  147. async function schemaFor(className) {
  148. assertClass(className);
  149. const schema = await new Parse.Schema(className).get({ useMasterKey: true });
  150. return schema && schema.fields ? schema.fields : {};
  151. }
  152. function applyTenant(query, fields, context, requestedCompanyId) {
  153. if (!fields.company) return;
  154. if (context.isSuperAdmin && requestedCompanyId) {
  155. query.equalTo('company', Parse.Object.createWithoutData('Company', String(requestedCompanyId)));
  156. } else if (context.company) {
  157. query.equalTo('company', context.company);
  158. }
  159. }
  160. function serializeObject(object) { return safeValue(object.toJSON()); }
  161. function toParseValue(field, value) {
  162. if (value == null || value === '') return value;
  163. if (field.type === 'Pointer') {
  164. const objectId = pointerId(value) || value;
  165. return Parse.Object.createWithoutData(field.targetClass, String(objectId));
  166. }
  167. if (field.type === 'Date') {
  168. const date = new Date(value.iso || value);
  169. if (Number.isNaN(date.getTime())) fail(400, '日期字段格式无效');
  170. return date;
  171. }
  172. if (field.type === 'Number') {
  173. const number = Number(value);
  174. if (!Number.isFinite(number)) fail(400, '数字字段格式无效');
  175. return number;
  176. }
  177. if (field.type === 'Boolean') return value === true || value === 'true';
  178. if (field.type === 'Array') { if (!Array.isArray(value)) fail(400, '数组字段格式无效'); return value; }
  179. if (field.type === 'Object') { if (!value || typeof value !== 'object' || Array.isArray(value)) fail(400, '对象字段格式无效'); return value; }
  180. if (!GENERIC_WRITE_TYPES.has(field.type)) fail(400, '该字段类型不允许通用编辑: ' + field.type);
  181. return value;
  182. }
  183. async function countClass(className, context) {
  184. const fields = await schemaFor(className);
  185. const query = new Parse.Query(className);
  186. applyTenant(query, fields, context);
  187. if (fields.isDeleted) query.notEqualTo('isDeleted', true);
  188. return query.count({ useMasterKey: true });
  189. }
  190. async function audit(context, action, className, objectId) {
  191. try {
  192. const log = new Parse.Object('SysLog');
  193. const schema = await new Parse.Schema('SysLog').get({ useMasterKey: true });
  194. const fields = schema.fields || {};
  195. if (fields.company && context.company) log.set('company', context.company);
  196. if (fields.userId) log.set('userId', context.current.id);
  197. if (fields.userName) log.set('userName', String(context.current.get('username') || ''));
  198. if (fields.remind) log.set('remind', '[AngularAdmin] ' + action + ' ' + className + '/' + objectId);
  199. if (fields.logType) log.set('logType', 'angular-admin');
  200. await log.save(null, { useMasterKey: true });
  201. } catch (_) { /* 审计表字段来自旧系统,失败不能覆盖主操作结果。 */ }
  202. }
  203. async function handler(request, response) {
  204. try {
  205. const input = inputOf(request);
  206. const operation = String(input.operation || 'meta');
  207. const context = await requireAdmin(request);
  208. if (['techDashboard','catalog','apiCatalog','schema','list','get','save','delete'].includes(operation) && !context.isSuperAdmin) fail(403, '只有超级管理员可以使用技术后台和通用数据接口');
  209. if (operation.startsWith('operatorAccounts/')) return response.json({success:true,data:await operatorAccounts(context,input)});
  210. if (operation === 'meta') {
  211. const functionQuery = new Parse.Query('Function');
  212. functionQuery.notEqualTo('enabled', false);
  213. const cloudFunctions = await functionQuery.count({ useMasterKey: true });
  214. return response.json({ success: true, data: {
  215. 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, operationsRole: operationsRoleOf(context) },
  216. classes: ALLOWED_CLASSES.size, cloudFunctions
  217. }});
  218. }
  219. if (operation === 'logout') {
  220. const sessionToken = String(input.sessionToken || '');
  221. if (!sessionToken || sessionToken.length > 512) fail(400, '缺少有效的管理员会话');
  222. const query = new Parse.Query('_Session'); query.equalTo('sessionToken', sessionToken); query.equalTo('user', context.current); query.limit(1);
  223. const session = await query.first({ useMasterKey: true });
  224. if (session) await session.destroy({ useMasterKey: true });
  225. return response.json({ success: true, data: { revoked: Boolean(session) } });
  226. }
  227. if (operation === 'changeOwnPassword') {
  228. const oldPassword = typeof input.oldPassword === 'string' ? input.oldPassword : ''; const newPassword = typeof input.newPassword === 'string' ? input.newPassword : '';
  229. if (!oldPassword || newPassword.length < 8 || newPassword.length > 72) fail(400, '原密码不能为空,新密码长度应为 8 至 72 位'); if (oldPassword === newPassword) fail(400, '新密码不能与原密码相同');
  230. 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, '原密码验证失败');
  231. 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 } });
  232. }
  233. if (operation === 'techDashboard') {
  234. const metrics = [
  235. ['_User','用户总数'],['CourseBinding','课程绑定'],['CourseAppointment','课程预约'],['PracticeRecord','练习记录'],['LessonRecord','上课记录'],['DailyStudyRecord','每日学习'],['VocabularyWord','词库单词'],['CommonModel','内容记录']
  236. ];
  237. const counts = await Promise.all(metrics.map(async ([className, label]) => ({ className, label, count: await countClass(className, context), route: '/tech-admin/resources/' + className })));
  238. const functionQuery = new Parse.Query('Function'); functionQuery.notEqualTo('enabled', false);
  239. let registeredRecords = 195452;
  240. try { const row = await Psql.oneOrNone('SELECT COUNT(*)::int AS count FROM cms.record_registry'); if (row) registeredRecords = Number(row.count); } catch (_) {}
  241. 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, operationsRole: operationsRoleOf(context) }, metrics: counts, migration: { sourceObjects: 338, migratedClasses: 96, registeredRecords, cloudFunctions: await functionQuery.count({ useMasterKey: true }) } } });
  242. }
  243. if (operation === 'catalog') {
  244. const resources = [...ALLOWED_CLASSES].sort().map((className) => ({ className, label: CLASS_LABELS[className] || className, writable: !READ_ONLY_CLASSES.has(className), route: '/tech-admin/resources/' + className }));
  245. return response.json({ success: true, data: { total: resources.length, resources } });
  246. }
  247. if (operation === 'apiCatalog') {
  248. const query = new Parse.Query('Function'); query.ascending('path'); query.limit(1000); const rows = await query.find({ useMasterKey: true });
  249. 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) }));
  250. return response.json({ success: true, data: { total: results.length, results } });
  251. }
  252. if (operation === 'legacyConfigBlocker') {
  253. const action = String(input.action || ''); const blockers = {
  254. APIInfo_Submit: '旧 API_ID/API_Key/API_AdminKey、IP 访问策略与目标 sessionToken/云函数权限模型不等价,且没有能对全部函数生效的站点配置层',
  255. APIInfo_SwaggerClose: '目标云函数引擎没有帐套级 Swagger 实时调试关闭开关',
  256. APIInfo_SwaggerOpen: '目标云函数引擎没有帐套级 Swagger 实时调试开启开关',
  257. LicenceFile_API: '目标 Schema 缺少许可、隐私、协议与版权文档存储,云函数也不能写旧 wwwroot/Config 文件',
  258. close_system: '云函数运行时不存在可通过 app_offline.htm 关闭的单体 ASP.NET 进程',
  259. Hotkey: '目标 Schema 没有 KeyboardConfig/Hotkey 持久化模型,旧 XML 配置文件也不在云函数运行时',
  260. HotkeyAdd: '目标 Schema 没有 KeyboardConfig/Hotkey 持久化模型,无法生成可全局生效的快捷键',
  261. HotkeyAdd_Submit: '目标 Schema 没有 KeyboardConfig/Hotkey 持久化模型,不能伪造保存成功',
  262. Hotkey_API: '目标 Schema 没有 KeyboardConfig/Hotkey 持久化模型,启停、删除、重载和脚本匹配无可操作对象',
  263. Prize: '目标 Schema 没有 PrizeConfig/PrizeTask 配置模型及签到、任务规则数据',
  264. Prize_Submit: '目标 Schema 没有 PrizeConfig/PrizeTask 配置模型,无法持久化奖励积分、重复周期和过期规则'
  265. };
  266. if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧配置动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  267. }
  268. if (operation === 'createUser') {
  269. const username = String(input.username || '').trim();
  270. const password = typeof input.password === 'string' ? input.password : '';
  271. const displayName = String(input.displayName || '').trim();
  272. const mobile = String(input.mobile || '').trim();
  273. const inviteUsername = String(input.inviteUsername || '').trim();
  274. if (username.length < 3 || username.length > 64) fail(400, '账号长度应为 3 至 64 位');
  275. if (password.length < 8 || password.length > 72) fail(400, '密码长度应为 8 至 72 位');
  276. if (displayName.length > 100) fail(400, '显示名不能超过 100 位');
  277. if (mobile.length > 32) fail(400, '手机号不能超过 32 位');
  278. let companyId = pointerId(context.company);
  279. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  280. if (!companyId) fail(400, '开户必须指定帐套');
  281. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  282. const userFields = await schemaFor('_User');
  283. if (!userFields.company) fail(500, '用户表缺少帐套字段');
  284. 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]);
  285. if (duplicate) fail(409, '此用户名已被使用');
  286. let parentId = 0;
  287. if (inviteUsername) {
  288. 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]);
  289. parentId = Number(inviter && inviter.id) || 0;
  290. if (!parentId) fail(400, '邀请账号不存在');
  291. }
  292. const created = new Parse.User();
  293. created.setUsername(username); created.setPassword(password); created.set('company', company);
  294. if (userFields.type) created.set('type', 'user');
  295. if (userFields.isDisabled) created.set('isDisabled', false);
  296. if (userFields.isDeleted) created.set('isDeleted', false);
  297. if (userFields.legacyGroupId) created.set('legacyGroupId', 1);
  298. if (userFields.nickname) created.set('nickname', displayName || username);
  299. if (mobile && userFields.mobile) created.set('mobile', mobile);
  300. if (userFields.isAdmin) created.set('isAdmin', false);
  301. if (userFields.roles) created.set('roles', []);
  302. try { await created.signUp(null, { useMasterKey: true }); }
  303. catch (error) { if (Number(error && error.code) === 202) fail(409, '此用户名已被使用'); throw error; }
  304. try {
  305. const regTime = new Date().toISOString();
  306. 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 };
  307. 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)]);
  308. const legacyUserId = Number(rows[0] && rows[0].id) || 0;
  309. if (!legacyUserId) throw new Error('无法分配旧系统用户 ID');
  310. await created.fetch({ useMasterKey: true });
  311. await audit({ ...context, company }, 'create-user', '_User', created.id);
  312. return response.json({ success: true, data: { ...serializeObject(created), userId: legacyUserId, groupId: 1 } });
  313. } catch (error) {
  314. await created.destroy({ useMasterKey: true }).catch(() => undefined);
  315. throw error;
  316. }
  317. }
  318. if (operation === 'userBatch') {
  319. const action = String(input.action || '');
  320. if (!['lock','unlock','move'].includes(action)) fail(400, '不支持的用户批量操作');
  321. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  322. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  323. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个用户');
  324. const userFields = await schemaFor('_User');
  325. const query = new Parse.Query('_User'); applyTenant(query, userFields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  326. const targets = await query.find({ useMasterKey: true });
  327. if (targets.length !== objectIds.length) fail(404, '部分用户不存在或不属于当前帐套');
  328. for (const target of targets) assertCanManageUser(context, target, action);
  329. let group = null;
  330. let groupId = 0;
  331. if (action === 'move') {
  332. groupId = Number(input.groupId);
  333. if (!Number.isInteger(groupId) || groupId < 1) fail(400, '请选择有效用户组');
  334. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
  335. if (companyIds.length !== 1) fail(400, '批量移动的用户必须属于同一帐套');
  336. const groupRow = await Psql.oneOrNone('SELECT "objectId" FROM "Group" WHERE "company"=$1 AND "groupId"=$2 LIMIT 1', [companyIds[0], groupId]);
  337. if (!groupRow) fail(404, '目标用户组不存在或不属于当前帐套');
  338. group = await new Parse.Query('Group').get(String(groupRow.objectId), { useMasterKey: true });
  339. }
  340. const now = new Date();
  341. for (const target of targets) {
  342. const legacyData = { ...(target.get('legacyUserData') || {}) };
  343. if (action === 'move') {
  344. target.set('legacyGroupId', groupId); legacyData.GroupID = groupId;
  345. } else {
  346. const disabled = action === 'lock'; target.set('isDisabled', disabled); legacyData.State = disabled ? 0 : 1;
  347. if (userFields.statusAction) target.set('statusAction', disabled ? 'admin-lock' : 'admin-unlock');
  348. if (userFields.statusReason) target.set('statusReason', String(input.reason || '').trim().slice(0, 200));
  349. if (userFields.statusUpdatedAt) target.set('statusUpdatedAt', now);
  350. if (userFields.statusUpdatedBy) target.set('statusUpdatedBy', context.current.id);
  351. }
  352. target.set('legacyUserData', legacyData);
  353. }
  354. await Parse.Object.saveAll(targets, { useMasterKey: true });
  355. const revokedSessions = action === 'lock' ? await revokeSessions(targets) : 0;
  356. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'user-' + action, '_User', target.id);
  357. return response.json({ success: true, data: { action, updated: targets.length, revokedSessions, group: group ? serializeObject(group) : null, results: targets.map(serializeObject) } });
  358. }
  359. if (operation === 'saveGroup') {
  360. const objectId = String(input.objectId || '');
  361. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  362. const groupName = String(payload.groupName || '').trim();
  363. const description = String(payload.description || groupName).trim();
  364. const parentGroupId = payload.parentGroupId === undefined || payload.parentGroupId === '' ? 0 : Number(payload.parentGroupId);
  365. if (!groupName || groupName.length > 100) fail(400, '用户组名称为必填项且不能超过 100 位');
  366. if (description.length > 500) fail(400, '用户组说明不能超过 500 位');
  367. if (!Number.isInteger(parentGroupId) || parentGroupId < 0) fail(400, '父用户组编号无效');
  368. let companyId = pointerId(context.company);
  369. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  370. if (!companyId) fail(400, '用户组必须指定帐套');
  371. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  372. const groupFields = await schemaFor('Group');
  373. let target;
  374. let currentGroupId = 0;
  375. if (objectId) {
  376. const query = new Parse.Query('Group'); applyTenant(query, groupFields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  377. currentGroupId = Number(target.get('groupId')) || 0;
  378. } else target = new Parse.Object('Group');
  379. 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]);
  380. if (duplicate) fail(409, '当前帐套已存在同名用户组');
  381. if (parentGroupId) {
  382. let cursor = parentGroupId; const visited = new Set();
  383. while (cursor) {
  384. if (cursor === currentGroupId || visited.has(cursor)) fail(400, '父用户组不能形成循环');
  385. visited.add(cursor);
  386. const parent = await Psql.oneOrNone('SELECT "parentGroupId" AS parent_id FROM "Group" WHERE "company"=$1 AND "groupId"=$2 LIMIT 1', [companyId, cursor]);
  387. if (!parent) fail(404, '父用户组不存在或不属于当前帐套');
  388. cursor = Number(parent.parent_id) || 0;
  389. if (visited.size > 100) fail(400, '用户组层级过深');
  390. }
  391. }
  392. target.set('company', company); target.set('groupName', groupName); target.set('description', description); target.set('parentGroupId', parentGroupId);
  393. const stringFields = ['otherName','enroll','signImg'];
  394. const numberFields = ['orderId','companyGroup','vipgroup','rebateRate','credit','vipnum','upPoint','upSicon','favCount','consumeType','ccountPerDay','upGradeMoney'];
  395. for (const name of stringFields) if (payload[name] !== undefined && groupFields[name]) target.set(name, String(payload[name] || '').trim());
  396. 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); }
  397. if (payload.regSelect !== undefined && groupFields.regSelect) target.set('regSelect', payload.regSelect === true || payload.regSelect === 'true');
  398. if (!objectId && groupFields.sourceKey) target.set('sourceKey', 'cloud:admin-group:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10));
  399. await target.save(null, { useMasterKey: true });
  400. if (!objectId) {
  401. try {
  402. 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]);
  403. currentGroupId = Number(rows[0] && rows[0].id) || 0;
  404. if (!currentGroupId) throw new Error('无法分配用户组编号');
  405. } catch (error) { await target.destroy({ useMasterKey: true }).catch(() => undefined); throw error; }
  406. }
  407. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-group' : 'create-group', 'Group', target.id);
  408. return response.json({ success: true, data: serializeObject(target) });
  409. }
  410. if (operation === 'saveNode') {
  411. const objectId = String(input.objectId || '');
  412. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  413. const nodeName = String(payload.nodeName || '').trim();
  414. const nodeDir = String(payload.nodeDir || '').trim();
  415. const parentId = Number(input.parentId == null ? payload.parentId || 0 : input.parentId);
  416. if (!nodeName || nodeName.length > 100) fail(400, '栏目名称长度应为 1 至 100 位');
  417. if (!Number.isInteger(parentId) || parentId < 0) fail(400, '父节点编号无效');
  418. let companyId = pointerId(context.company);
  419. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  420. if (!companyId) fail(400, '栏目必须指定帐套');
  421. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  422. const fields = await schemaFor('Node');
  423. let target;
  424. let currentNodeId = 0;
  425. if (objectId) {
  426. const query = new Parse.Query('Node'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  427. companyId = pointerId(target.get('company')) || companyId;
  428. currentNodeId = Number(target.get('nodeId')) || 0;
  429. } else target = new Parse.Object('Node');
  430. let parentDepth = 0;
  431. if (parentId > 0) {
  432. 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]);
  433. if (!parent) fail(404, '父节点不存在、已回收或不属于当前帐套');
  434. parentDepth = Number(parent.depth) || 0;
  435. if (currentNodeId) {
  436. 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]);
  437. if (cycle) fail(409, '不能把栏目移动到自身或其下级栏目');
  438. }
  439. }
  440. 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]);
  441. if (duplicate) fail(409, '同一父栏目下的栏目名称或目录名不能重复');
  442. for (const [name, value] of Object.entries(payload)) {
  443. if (!fields[name] || isSystemField('Node', name)) continue;
  444. if (!GENERIC_WRITE_TYPES.has(fields[name].type)) fail(400, '字段不允许编辑: ' + name);
  445. if (value === null) target.unset(name); else target.set(name, toParseValue(fields[name], value));
  446. }
  447. target.set('nodeName', nodeName); target.set('nodeDir', nodeDir); target.set('parentId', parentId); target.set('depth', parentDepth + 1); target.set('company', company);
  448. if (!objectId) {
  449. target.set('sourceKey', 'cloud:admin-node:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10));
  450. target.set('zstatus', 99); target.set('child', 0); target.set('cdate', new Date());
  451. if (fields.cuser) target.set('cuser', Number(context.current.get('legacyUserId')) || 0);
  452. if (fields.cuname) target.set('cuname', String(context.current.get('username') || ''));
  453. }
  454. if (fields.editDate) target.set('editDate', new Date());
  455. try { await target.save(null, { useMasterKey: true }); }
  456. catch (error) { fail(422, '栏目初始写入失败: ' + String(error && error.message || error)); }
  457. try {
  458. if (!objectId) {
  459. 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]);
  460. currentNodeId = Number(rows[0] && rows[0].id) || 0;
  461. if (!currentNodeId) throw new Error('无法分配栏目编号');
  462. }
  463. 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]);
  464. } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '栏目结构写入失败: ' + String(error && error.message || error)); }
  465. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-node' : 'create-node', 'Node', target.id);
  466. return response.json({ success: true, data: serializeObject(target) });
  467. }
  468. if (operation === 'nodeBatch') {
  469. const action = String(input.action || '');
  470. if (!['recycle','recover','move','purge'].includes(action)) fail(400, '不支持的栏目批量操作');
  471. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  472. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  473. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个栏目');
  474. const fields = await schemaFor('Node');
  475. const query = new Parse.Query('Node'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  476. const targets = await query.find({ useMasterKey: true });
  477. if (targets.length !== objectIds.length) fail(404, '部分栏目不存在或不属于当前帐套');
  478. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
  479. if (companyIds.length !== 1) fail(400, '批量操作的栏目必须属于同一帐套');
  480. const companyId = companyIds[0];
  481. const nodeIds = targets.map((target) => Number(target.get('nodeId')) || 0);
  482. if (nodeIds.some((nodeId) => nodeId < 1)) fail(409, '栏目缺少有效旧系统编号');
  483. let parentId = null;
  484. let parentDepth = 0;
  485. if (action === 'move') {
  486. parentId = Number(input.parentId);
  487. if (!Number.isInteger(parentId) || parentId < 0) fail(400, '目标父节点编号无效');
  488. if (parentId > 0) {
  489. 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]);
  490. if (!parent) fail(404, '目标父节点不存在、已回收或不属于当前帐套');
  491. parentDepth = Number(parent.depth) || 0;
  492. for (const nodeId of nodeIds) {
  493. 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]);
  494. if (cycle) fail(409, '不能把栏目移动到自身或其下级栏目');
  495. }
  496. }
  497. for (const target of targets) { target.set('parentId', parentId); target.set('depth', parentDepth + 1); if (fields.editDate) target.set('editDate', new Date()); }
  498. await Parse.Object.saveAll(targets, { useMasterKey: true });
  499. 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]);
  500. } else if (action === 'purge') {
  501. 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]);
  502. if (Number(refs.contents) || Number(refs.children) || Number(refs.auth) || Number(refs.templates)) fail(409, '栏目仍被内容、下级栏目、权限或模板引用,不能永久删除');
  503. await Parse.Object.destroyAll(targets, { useMasterKey: true });
  504. } else {
  505. const status = action === 'recycle' ? -2 : 99;
  506. for (const target of targets) { target.set('zstatus', status); if (fields.editDate) target.set('editDate', new Date()); }
  507. await Parse.Object.saveAll(targets, { useMasterKey: true });
  508. }
  509. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'node-' + action, 'Node', target.id);
  510. return response.json({ success: true, data: { action, updated: targets.length, parentId, results: action === 'purge' ? [] : targets.map(serializeObject) } });
  511. }
  512. if (operation === 'saveSpecial') {
  513. const objectId = String(input.objectId || '');
  514. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  515. const specName = String(payload.specName || '').trim();
  516. const specDir = String(payload.specDir || '').trim();
  517. const pid = Number(input.pid == null ? payload.pid || 0 : input.pid);
  518. if (!specName || specName.length > 100) fail(400, '专题名称长度应为 1 至 100 位');
  519. if (!specDir || specDir.length > 100) fail(400, '专题目录长度应为 1 至 100 位');
  520. if (!Number.isInteger(pid) || pid < 0) fail(400, '父专题编号无效');
  521. let companyId = pointerId(context.company);
  522. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  523. if (!companyId) fail(400, '专题必须指定帐套');
  524. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  525. const fields = await schemaFor('Special');
  526. let target;
  527. let currentSpecId = 0;
  528. if (objectId) {
  529. const query = new Parse.Query('Special'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  530. companyId = pointerId(target.get('company')) || companyId; currentSpecId = Number(target.get('specId')) || 0;
  531. } else target = new Parse.Object('Special');
  532. if (pid > 0) {
  533. const parent = await Psql.oneOrNone('SELECT "specId" FROM "Special" WHERE "company"=$1 AND "specId"=$2 LIMIT 1', [companyId, pid]);
  534. if (!parent) fail(404, '父专题不存在或不属于当前帐套');
  535. if (currentSpecId) {
  536. 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]);
  537. if (cycle) fail(409, '不能把专题移动到自身或其下级专题');
  538. }
  539. }
  540. 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]);
  541. if (duplicate) fail(409, '专题名称或目录不能重复');
  542. for (const [name, value] of Object.entries(payload)) {
  543. if (!fields[name] || isSystemField('Special', name)) continue;
  544. if (!GENERIC_WRITE_TYPES.has(fields[name].type)) fail(400, '字段不允许编辑: ' + name);
  545. if (value === null) target.unset(name); else target.set(name, toParseValue(fields[name], value));
  546. }
  547. target.set('specName', specName); target.set('specDir', specDir); target.set('pid', pid); target.set('company', company); target.set('editDate', new Date());
  548. 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') || '')); }
  549. try { await target.save(null, { useMasterKey: true }); }
  550. catch (error) { fail(422, '专题初始写入失败: ' + String(error && error.message || error)); }
  551. try {
  552. if (!objectId) {
  553. 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]);
  554. currentSpecId = Number(rows[0] && rows[0].id) || 0;
  555. if (!currentSpecId) throw new Error('无法分配专题编号');
  556. }
  557. } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '专题结构写入失败: ' + String(error && error.message || error)); }
  558. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-special' : 'create-special', 'Special', target.id);
  559. return response.json({ success: true, data: serializeObject(target) });
  560. }
  561. if (operation === 'specialBatch') {
  562. const action = String(input.action || '');
  563. if (action === 'merge') fail(501, 'migration_blocked: CommonModel.specialId 仅存在于 Parse Schema、PostgreSQL 物理列缺失,且商品主表未迁移,无法安全合并专题关系');
  564. if (action !== 'move') fail(400, '不支持的专题批量操作');
  565. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  566. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  567. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个专题');
  568. const fields = await schemaFor('Special');
  569. const query = new Parse.Query('Special'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  570. const targets = await query.find({ useMasterKey: true });
  571. if (targets.length !== objectIds.length) fail(404, '部分专题不存在或不属于当前帐套');
  572. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
  573. if (companyIds.length !== 1) fail(400, '批量操作的专题必须属于同一帐套');
  574. const companyId = companyIds[0];
  575. const specIds = targets.map((target) => Number(target.get('specId')) || 0);
  576. const pid = Number(input.pid);
  577. if (!Number.isInteger(pid) || pid < 0) fail(400, '目标父专题编号无效');
  578. if (pid > 0) {
  579. const parent = await Psql.oneOrNone('SELECT "specId" FROM "Special" WHERE "company"=$1 AND "specId"=$2 LIMIT 1', [companyId, pid]);
  580. if (!parent) fail(404, '目标父专题不存在或不属于当前帐套');
  581. for (const specId of specIds) {
  582. 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]);
  583. if (cycle) fail(409, '不能把专题移动到自身或其下级专题');
  584. }
  585. }
  586. for (const target of targets) { target.set('pid', pid); target.set('editDate', new Date()); }
  587. await Parse.Object.saveAll(targets, { useMasterKey: true });
  588. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'special-move', 'Special', target.id);
  589. return response.json({ success: true, data: { action, updated: targets.length, pid, results: targets.map(serializeObject) } });
  590. }
  591. if (operation === 'saveModelMetadata' || operation === 'saveModelFieldMetadata') {
  592. const className = operation === 'saveModelMetadata' ? 'Model' : 'ModelField';
  593. const objectId = String(input.objectId || '');
  594. if (!objectId) fail(501, 'migration_blocked: 新增模型或字段需要同步创建 PostgreSQL 物理表/列及旧模板文件,托管云函数中未开放此 DDL 流程');
  595. const fields = await schemaFor(className);
  596. const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
  597. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  598. const allowed = className === 'Model'
  599. ? new Set(['modelName','itemName','itemUnit','itemIcon','description','islotsize','contentTemplate','thumbnail'])
  600. : new Set(['fieldAlias','fieldTips','description','isNotNull','isSearchForm','content','isShow','isView','showList','showWidth','isCopy','islotsize','isChain']);
  601. if (className === 'Model') {
  602. const modelName = String(payload.modelName == null ? target.get('modelName') || '' : payload.modelName).trim();
  603. if (!modelName || modelName.length > 100) fail(400, '模型名称长度应为 1 至 100 位');
  604. 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]);
  605. if (duplicate) fail(409, '模型名称已存在');
  606. target.set('modelName', modelName);
  607. } else {
  608. const fieldAlias = String(payload.fieldAlias == null ? target.get('fieldAlias') || '' : payload.fieldAlias).trim();
  609. if (!fieldAlias || fieldAlias.length > 100) fail(400, '字段别名长度应为 1 至 100 位');
  610. target.set('fieldAlias', fieldAlias);
  611. }
  612. for (const [name, value] of Object.entries(payload)) {
  613. if (!allowed.has(name) || !fields[name]) continue;
  614. if (value === null) target.unset(name); else target.set(name, toParseValue(fields[name], value));
  615. }
  616. await target.save(null, { useMasterKey: true }); await audit(context, className === 'Model' ? 'update-model-metadata' : 'update-model-field-metadata', className, objectId);
  617. return response.json({ success: true, data: serializeObject(target) });
  618. }
  619. if (operation === 'modelFieldOrder') {
  620. const items = Array.isArray(input.items) ? input.items : [];
  621. if (!items.length || items.length > 100) fail(400, '每次请提交 1 至 100 个字段顺序');
  622. const objectIds = items.map((item) => String(item && item.objectId || '').trim());
  623. const orderIds = items.map((item) => Number(item && item.orderId));
  624. 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, '字段或顺序参数无效/重复');
  625. const fields = await schemaFor('ModelField');
  626. const query = new Parse.Query('ModelField'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  627. const targets = await query.find({ useMasterKey: true });
  628. if (targets.length !== objectIds.length) fail(404, '部分模型字段不存在或不属于当前帐套');
  629. const modelIds = [...new Set(targets.map((target) => Number(target.get('modelId')) || 0))];
  630. if (modelIds.length !== 1) fail(400, '只能对同一模型的字段排序');
  631. const targetMap = new Map(targets.map((target) => [target.id, target]));
  632. for (const item of items) targetMap.get(String(item.objectId)).set('orderId', Number(item.orderId));
  633. await Parse.Object.saveAll(targets, { useMasterKey: true });
  634. for (const target of targets) await audit(context, 'order-model-field', 'ModelField', target.id);
  635. return response.json({ success: true, data: { updated: targets.length, modelId: modelIds[0], results: targets.map(serializeObject) } });
  636. }
  637. if (operation === 'saveGuestbook') {
  638. const objectId = String(input.objectId || '');
  639. if (!objectId) fail(400, '普通留言新增必须走前台留言云函数;后台只能通过专用回复流程新增');
  640. const fields = await schemaFor('Guestbook');
  641. const query = new Parse.Query('Guestbook'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
  642. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  643. const title = String(payload.title == null ? target.get('title') || '' : payload.title).trim();
  644. const content = String(payload.tcontent == null ? target.get('tcontent') || '' : payload.tcontent);
  645. if (!title || title.length > 200) fail(400, '留言标题长度应为 1 至 200 位');
  646. if (content.length > 200000) fail(400, '留言内容过长');
  647. target.set('title', title); target.set('tcontent', content);
  648. await target.save(null, { useMasterKey: true }); await audit(context, 'update-guestbook', 'Guestbook', objectId);
  649. return response.json({ success: true, data: serializeObject(target) });
  650. }
  651. if (operation === 'guestbookReply') {
  652. const parentObjectId = String(input.parentObjectId || '');
  653. const title = String(input.title || '').trim() || '[管理员回复]';
  654. const content = String(input.content || '');
  655. if (!parentObjectId) fail(400, '缺少原留言');
  656. if (title.length > 200 || !content.trim() || content.length > 200000) fail(400, '回复标题或内容无效');
  657. const fields = await schemaFor('Guestbook');
  658. const query = new Parse.Query('Guestbook'); applyTenant(query, fields, context, input.companyId); const parent = await query.get(parentObjectId, { useMasterKey: true });
  659. 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;
  660. if (!companyId || !parentGid) fail(409, '原留言缺少帐套或旧系统编号');
  661. const reply = new Parse.Object('Guestbook');
  662. 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');
  663. try { await reply.save(null, { useMasterKey: true }); }
  664. catch (error) { fail(422, '管理员回复初始写入失败: ' + String(error && error.message || error)); }
  665. try {
  666. 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]);
  667. if (!Number(rows[0] && rows[0].id)) throw new Error('无法分配回复编号');
  668. } catch (error) { await reply.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '管理员回复结构写入失败: ' + String(error && error.message || error)); }
  669. await reply.fetch({ useMasterKey: true }); await audit({ ...context, company }, 'reply-guestbook', 'Guestbook', reply.id);
  670. return response.json({ success: true, data: serializeObject(reply) });
  671. }
  672. if (operation === 'guestbookBatch') {
  673. const action = String(input.action || '');
  674. if (!['audit','unaudit','recycle','recover','purge'].includes(action)) fail(400, '不支持的留言批量操作');
  675. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  676. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  677. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条留言');
  678. const fields = await schemaFor('Guestbook');
  679. const query = new Parse.Query('Guestbook'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  680. const targets = await query.find({ useMasterKey: true });
  681. if (targets.length !== objectIds.length) fail(404, '部分留言不存在或不属于当前帐套');
  682. if (action === 'purge') {
  683. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))]; const gids = targets.map((target) => Number(target.get('gid')) || 0);
  684. if (companyIds.length !== 1 || gids.some((gid) => gid < 1)) fail(409, '留言缺少有效帐套或编号');
  685. 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]);
  686. if (Number(children.count) > 0) fail(409, '留言仍有回复,不能永久删除');
  687. await Parse.Object.destroyAll(targets, { useMasterKey: true });
  688. } else {
  689. const status = action === 'recycle' ? -2 : action === 'unaudit' ? 0 : 99;
  690. for (const target of targets) target.set('status', status);
  691. await Parse.Object.saveAll(targets, { useMasterKey: true });
  692. }
  693. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'guestbook-' + action, 'Guestbook', target.id);
  694. return response.json({ success: true, data: { action, updated: targets.length, results: action === 'purge' ? [] : targets.map(serializeObject) } });
  695. }
  696. if (operation === 'saveGuestCategory') {
  697. const objectId = String(input.objectId || '');
  698. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  699. const catename = String(payload.catename || '').trim();
  700. const parentId = Number(input.parentId == null ? payload.parentId || 0 : input.parentId);
  701. let gtype = Number(input.gtype == null ? payload.gtype || 0 : input.gtype);
  702. if (!catename || catename.length > 100) fail(400, '分类名称长度应为 1 至 100 位');
  703. if (!Number.isInteger(parentId) || parentId < 0 || ![0,1].includes(gtype)) fail(400, '分类类型或父分类编号无效');
  704. let companyId = pointerId(context.company);
  705. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  706. if (!companyId) fail(400, '分类必须指定帐套');
  707. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  708. const fields = await schemaFor('Guestcate');
  709. let target;
  710. let currentCateId = 0;
  711. if (objectId) {
  712. const query = new Parse.Query('Guestcate'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  713. companyId = pointerId(target.get('company')) || companyId; currentCateId = Number(target.get('cateid')) || 0; gtype = Number(target.get('gtype')) || 0;
  714. } else target = new Parse.Object('Guestcate');
  715. if (gtype === 0 && parentId !== 0) fail(400, '留言分类只能建立为顶级分类');
  716. if (parentId > 0) {
  717. 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]);
  718. if (!parent) fail(404, '父分类不存在、类型不一致或不属于当前帐套');
  719. if (currentCateId) {
  720. 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]);
  721. if (cycle) fail(409, '不能把分类移动到自身或其下级分类');
  722. }
  723. }
  724. 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]);
  725. if (duplicate) fail(409, '同一父分类下的分类名称不能重复');
  726. const allowed = new Set(['catename','desc','status','needLog','isShowUnaudit','postAuth','zipImgSize','barImage','barOwner','permibit','sendScore','replyScore','barInfo','isPlat']);
  727. for (const [name, value] of Object.entries(payload)) {
  728. if (!allowed.has(name) || !fields[name]) continue;
  729. if (value === null) target.unset(name); else target.set(name, toParseValue(fields[name], value));
  730. }
  731. target.set('catename', catename); target.set('parentId', parentId); target.set('gtype', gtype); target.set('company', company);
  732. 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); }
  733. try { await target.save(null, { useMasterKey: true }); }
  734. catch (error) { fail(422, '分类初始写入失败: ' + String(error && error.message || error)); }
  735. try {
  736. if (!objectId) {
  737. 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]);
  738. currentCateId = Number(rows[0] && rows[0].id) || 0;
  739. if (!currentCateId) throw new Error('无法分配分类编号');
  740. }
  741. } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '分类结构写入失败: ' + String(error && error.message || error)); }
  742. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-guest-category' : 'create-guest-category', 'Guestcate', target.id);
  743. return response.json({ success: true, data: serializeObject(target) });
  744. }
  745. if (operation === 'guestCategoryBatch') {
  746. const action = String(input.action || '');
  747. if (!['delete','recommend','unrecommend'].includes(action)) fail(400, '不支持的分类批量操作');
  748. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  749. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  750. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个分类');
  751. const fields = await schemaFor('Guestcate');
  752. const query = new Parse.Query('Guestcate'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  753. const targets = await query.find({ useMasterKey: true });
  754. if (targets.length !== objectIds.length) fail(404, '部分分类不存在或不属于当前帐套');
  755. if (action === 'delete') {
  756. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))]; const cateids = targets.map((target) => Number(target.get('cateid')) || 0);
  757. if (companyIds.length !== 1 || cateids.some((id) => id < 1)) fail(409, '分类缺少有效帐套或编号');
  758. 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]);
  759. if (Number(refs.children) || Number(refs.messages) || Number(refs.posts)) fail(409, '分类仍被下级分类、留言或帖子引用,不能删除');
  760. await Parse.Object.destroyAll(targets, { useMasterKey: true });
  761. } else {
  762. for (const target of targets) { if (Number(target.get('gtype')) !== 1) fail(400, '只有贴吧分类支持推荐'); target.set('barInfo', action === 'recommend' ? 'Recommend' : ''); }
  763. await Parse.Object.saveAll(targets, { useMasterKey: true });
  764. }
  765. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'guest-category-' + action, 'Guestcate', target.id);
  766. return response.json({ success: true, data: { action, updated: targets.length, results: action === 'delete' ? [] : targets.map(serializeObject) } });
  767. }
  768. if (operation === 'barAuthorization') {
  769. fail(501, 'migration_blocked: 目标 Schema 缺少旧 ZL_Guest_BarAuth 用户版块权限关系(Uid、BarID、Look、Send、Reply),无法安全读取或保存逐用户浏览、发帖和回复权限');
  770. }
  771. if (operation === 'barMedal') {
  772. fail(501, 'migration_blocked: 目标 Schema 缺少旧 ZL_Guest_Medals 勋章及用户帖子授予关系,无法安全授予或撤销帖子勋章');
  773. }
  774. if (operation === 'saveDictionaryCategory') {
  775. const objectId = String(input.objectId || '');
  776. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  777. const categoryName = String(payload.categoryname || '').trim();
  778. const isUsed = payload.isused == null ? true : payload.isused === true || payload.isused === 'true';
  779. if (!categoryName || categoryName.length > 50) fail(400, '字典分类名称长度应为 1 至 50 位');
  780. let companyId = pointerId(context.company);
  781. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  782. if (!companyId) fail(400, '字典分类必须指定帐套');
  783. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  784. const fields = await schemaFor('Datadiccategory');
  785. let target;
  786. if (objectId) {
  787. const query = new Parse.Query('Datadiccategory'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  788. companyId = pointerId(target.get('company')) || companyId;
  789. } else target = new Parse.Object('Datadiccategory');
  790. const duplicateQuery = new Parse.Query('Datadiccategory'); applyTenant(duplicateQuery, fields, context, input.companyId); duplicateQuery.limit(1000);
  791. const duplicates = await duplicateQuery.find({ useMasterKey: true });
  792. if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('categoryname') || '').trim().toLowerCase() === categoryName.toLowerCase())) fail(409, '同一帐套的字典分类名称不能重复');
  793. target.set('categoryname', categoryName); target.set('isused', isUsed); target.set('company', company);
  794. if (objectId) {
  795. try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '字典分类写入失败: ' + String(error && error.message || error)); }
  796. } else {
  797. let lastError;
  798. for (let attempt = 0; attempt < 5; attempt++) {
  799. const allCategories = new Parse.Query('Datadiccategory'); allCategories.limit(1000);
  800. const allItems = new Parse.Query('Datadic'); allItems.limit(1000);
  801. const [categoryRows, itemRows] = await Promise.all([allCategories.find({ useMasterKey: true }), allItems.find({ useMasterKey: true })]);
  802. const nextId = Math.max(0, ...categoryRows.map((entry) => Number(entry.get('diccateid')) || 0), ...itemRows.map((entry) => Number(entry.get('diccate')) || 0)) + 1;
  803. target.set('diccateid', nextId); target.set('sourceKey', '[["Diccateid",' + nextId + ']]');
  804. try { await target.save(null, { useMasterKey: true }); lastError = null; break; }
  805. catch (error) { lastError = error; if (!(Number(error && error.code) === 137 || /duplicate|unique/i.test(String(error && error.message || '')))) break; }
  806. }
  807. if (lastError || !target.id) fail(422, '字典分类原子编号写入失败: ' + String(lastError && lastError.message || lastError || '无法分配编号'));
  808. }
  809. await audit({ ...context, company }, objectId ? 'update-dictionary-category' : 'create-dictionary-category', 'Datadiccategory', target.id);
  810. return response.json({ success: true, data: serializeObject(target) });
  811. }
  812. if (operation === 'saveDictionaryItem') {
  813. const objectId = String(input.objectId || '');
  814. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  815. const itemName = String(payload.dicname || '').trim();
  816. const categoryId = Number(input.categoryId == null ? payload.diccate || 0 : input.categoryId);
  817. const isUsed = payload.isused == null ? true : payload.isused === true || payload.isused === 'true';
  818. if (!itemName || itemName.length > 50) fail(400, '字典项名称长度应为 1 至 50 位');
  819. if (!Number.isInteger(categoryId) || categoryId < 1) fail(400, '请选择有效字典分类');
  820. let companyId = pointerId(context.company);
  821. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  822. if (!companyId) fail(400, '字典项必须指定帐套');
  823. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  824. const fields = await schemaFor('Datadic');
  825. let target;
  826. if (objectId) {
  827. const query = new Parse.Query('Datadic'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  828. companyId = pointerId(target.get('company')) || companyId;
  829. } else target = new Parse.Object('Datadic');
  830. const categoryFields = await schemaFor('Datadiccategory'); const categoryQuery = new Parse.Query('Datadiccategory'); applyTenant(categoryQuery, categoryFields, context, input.companyId); categoryQuery.equalTo('diccateid', categoryId);
  831. const category = await categoryQuery.first({ useMasterKey: true });
  832. if (!category || pointerId(category.get('company')) !== companyId) fail(404, '字典分类不存在或不属于当前帐套');
  833. const duplicateQuery = new Parse.Query('Datadic'); applyTenant(duplicateQuery, fields, context, input.companyId); duplicateQuery.equalTo('diccate', categoryId); duplicateQuery.limit(1000);
  834. const duplicates = await duplicateQuery.find({ useMasterKey: true });
  835. if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('dicname') || '').trim().toLowerCase() === itemName.toLowerCase())) fail(409, '同一字典分类中的字典项名称不能重复');
  836. target.set('dicname', itemName); target.set('diccate', categoryId); target.set('isused', isUsed); target.set('company', company);
  837. if (objectId) {
  838. try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '字典项写入失败: ' + String(error && error.message || error)); }
  839. } else {
  840. let lastError;
  841. for (let attempt = 0; attempt < 5; attempt++) {
  842. const allItems = new Parse.Query('Datadic'); allItems.limit(1000); const itemRows = await allItems.find({ useMasterKey: true });
  843. const nextId = Math.max(0, ...itemRows.map((entry) => Number(entry.get('dicid')) || 0)) + 1;
  844. target.set('dicid', nextId); target.set('sourceKey', '[["Dicid",' + nextId + ']]');
  845. try { await target.save(null, { useMasterKey: true }); lastError = null; break; }
  846. catch (error) { lastError = error; if (!(Number(error && error.code) === 137 || /duplicate|unique/i.test(String(error && error.message || '')))) break; }
  847. }
  848. if (lastError || !target.id) fail(422, '字典项原子编号写入失败: ' + String(lastError && lastError.message || lastError || '无法分配编号'));
  849. }
  850. await audit({ ...context, company }, objectId ? 'update-dictionary-item' : 'create-dictionary-item', 'Datadic', target.id);
  851. return response.json({ success: true, data: serializeObject(target) });
  852. }
  853. if (operation === 'dictionaryCategories') {
  854. const fields = await schemaFor('Datadiccategory'); const query = new Parse.Query('Datadiccategory'); applyTenant(query, fields, context, input.companyId);
  855. if (input.enabledOnly === true) query.equalTo('isused', true);
  856. query.ascending('diccateid'); query.limit(1000);
  857. const results = await query.find({ useMasterKey: true });
  858. return response.json({ success: true, data: { results: results.map(serializeObject) } });
  859. }
  860. if (operation === 'dictionaryBatch') {
  861. const className = String(input.className || ''); const action = String(input.action || '');
  862. if (!['Datadiccategory','Datadic'].includes(className) || !['enable','disable','delete'].includes(action)) fail(400, '不支持的字典批量操作');
  863. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  864. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  865. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条字典记录');
  866. 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);
  867. const targets = await query.find({ useMasterKey: true });
  868. if (targets.length !== objectIds.length) fail(404, '部分字典记录不存在或不属于当前帐套');
  869. if (action === 'delete' && className === 'Datadiccategory') {
  870. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))]; const categoryIds = targets.map((target) => Number(target.get('diccateid')) || 0);
  871. if (companyIds.length !== 1 || categoryIds.some((id) => id < 1)) fail(409, '字典分类缺少有效帐套或编号');
  872. const refs = await Psql.one('SELECT COUNT(*)::int AS total FROM "Datadic" WHERE "company"=$1 AND "diccate"=ANY($2::numeric[])', [companyIds[0], categoryIds]);
  873. if (Number(refs.total)) fail(409, '字典分类仍有字典项,不能删除');
  874. }
  875. if (action === 'delete') await Parse.Object.destroyAll(targets, { useMasterKey: true });
  876. else { for (const target of targets) target.set('isused', action === 'enable'); await Parse.Object.saveAll(targets, { useMasterKey: true }); }
  877. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'dictionary-' + action, className, target.id);
  878. return response.json({ success: true, data: { action, updated: targets.length, results: action === 'delete' ? [] : targets.map(serializeObject) } });
  879. }
  880. if (operation === 'saveGradeCategory') {
  881. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  882. const cateName = String(payload.cateName || '').trim(); const remark = String(payload.remark || '').trim();
  883. const aliases = String(payload.gradeField || '').split(/\r?\n|\|/).map((value) => value.trim()).filter(Boolean); const gradeField = aliases.join('|');
  884. if (!cateName || cateName.length > 50) fail(400, '多级字典分类名称长度应为 1 至 50 位');
  885. if (remark.length > 255) fail(400, '多级字典分类备注不能超过 255 位');
  886. if (aliases.length < 2 || gradeField.length > 500) fail(400, '层级别名至少填写 2 级,且总长度不能超过 500 位');
  887. let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '多级字典分类必须指定帐套');
  888. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true }); const fields = await schemaFor('GradeCate'); let target;
  889. 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; }
  890. else target = new Parse.Object('GradeCate');
  891. const duplicateQuery = new Parse.Query('GradeCate'); applyTenant(duplicateQuery, fields, context, input.companyId); duplicateQuery.limit(1000); const duplicates = await duplicateQuery.find({ useMasterKey: true });
  892. if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('cateName') || '').trim().toLowerCase() === cateName.toLowerCase())) fail(409, '同一帐套的多级字典分类名称不能重复');
  893. target.set('cateName', cateName); target.set('remark', remark); target.set('gradeField', gradeField); target.set('company', company);
  894. if (objectId) { try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '多级字典分类写入失败: ' + String(error && error.message || error)); } }
  895. else {
  896. let lastError;
  897. for (let attempt = 0; attempt < 5; attempt++) {
  898. const categories = new Parse.Query('GradeCate'); categories.limit(1000); const options = new Parse.Query('Grade'); options.limit(1000);
  899. const [categoryRows, optionRows] = await Promise.all([categories.find({ useMasterKey: true }), options.find({ useMasterKey: true })]);
  900. const nextId = Math.max(0, ...categoryRows.map((entry) => Number(entry.get('cateId')) || 0), ...optionRows.map((entry) => Number(entry.get('cate')) || 0)) + 1;
  901. target.set('cateId', nextId); target.set('sourceKey', '[["CateID",' + nextId + ']]');
  902. try { await target.save(null, { useMasterKey: true }); lastError = null; break; }
  903. catch (error) { lastError = error; if (!(Number(error && error.code) === 137 || /duplicate|unique/i.test(String(error && error.message || '')))) break; }
  904. }
  905. if (lastError || !target.id) fail(422, '多级字典分类原子编号写入失败: ' + String(lastError && lastError.message || lastError || '无法分配编号'));
  906. }
  907. await audit({ ...context, company }, objectId ? 'update-grade-category' : 'create-grade-category', 'GradeCate', target.id);
  908. return response.json({ success: true, data: serializeObject(target) });
  909. }
  910. if (operation === 'saveGradeOption') {
  911. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; const gradeName = String(payload.gradeName || '').trim();
  912. if (!gradeName || gradeName.length > 50) fail(400, '多级字典选项名称长度应为 1 至 50 位');
  913. let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '多级字典选项必须指定帐套');
  914. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true }); const fields = await schemaFor('Grade'); let target; let categoryId; let parentId; let level;
  915. if (objectId) {
  916. 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;
  917. categoryId = Number(target.get('cate')) || 0; parentId = Number(target.get('parentId')) || 0; level = Number(target.get('grade')) || 0;
  918. } 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; }
  919. if (!Number.isInteger(categoryId) || categoryId < 1 || !Number.isInteger(parentId) || parentId < 0) fail(400, '多级字典分类或父选项编号无效');
  920. 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 });
  921. if (!category || pointerId(category.get('company')) !== companyId) fail(404, '多级字典分类不存在或不属于当前帐套');
  922. const aliases = String(category.get('gradeField') || '').split('|').map((value) => value.trim()).filter(Boolean);
  923. 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; }
  924. if (!Number.isInteger(level) || level < 1 || level > aliases.length) fail(409, '当前层级已超过分类定义的层级别名');
  925. 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 });
  926. if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('gradeName') || '').trim().toLowerCase() === gradeName.toLowerCase())) fail(409, '同一父级下的多级字典选项名称不能重复');
  927. target.set('gradeName', gradeName); target.set('cate', categoryId); target.set('parentId', parentId); target.set('grade', level); target.set('company', company);
  928. if (objectId) { try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '多级字典选项写入失败: ' + String(error && error.message || error)); } }
  929. else {
  930. let lastError;
  931. for (let attempt = 0; attempt < 5; attempt++) {
  932. 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;
  933. target.set('gradeId', nextId); target.set('sourceKey', '[["GradeID",' + nextId + ']]');
  934. try { await target.save(null, { useMasterKey: true }); lastError = null; break; }
  935. catch (error) { lastError = error; if (!(Number(error && error.code) === 137 || /duplicate|unique/i.test(String(error && error.message || '')))) break; }
  936. }
  937. if (lastError || !target.id) fail(422, '多级字典选项原子编号写入失败: ' + String(lastError && lastError.message || lastError || '无法分配编号'));
  938. }
  939. await audit({ ...context, company }, objectId ? 'update-grade-option' : 'create-grade-option', 'Grade', target.id);
  940. return response.json({ success: true, data: serializeObject(target) });
  941. }
  942. if (operation === 'gradeCategories') {
  943. 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 });
  944. return response.json({ success: true, data: { results: results.map(serializeObject) } });
  945. }
  946. if (operation === 'gradeOptions') {
  947. 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, '多级字典筛选条件无效');
  948. 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);
  949. 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 });
  950. return response.json({ success: true, data: { className: 'Grade', page, pageSize, total, results: results.map(serializeObject) } });
  951. }
  952. if (operation === 'gradeBatch') {
  953. const className = String(input.className || ''); if (!['GradeCate','Grade'].includes(className) || String(input.action || '') !== 'delete') fail(400, '不支持的多级字典批量操作');
  954. 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 条多级字典记录');
  955. 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, '部分多级字典记录不存在或不属于当前帐套');
  956. 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, '多级字典分类仍有选项,不能删除'); }
  957. 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, '多级字典选项仍有下级选项,不能删除'); }
  958. 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);
  959. return response.json({ success: true, data: { action: 'delete', updated: targets.length, results: [] } });
  960. }
  961. if (operation === 'saveCurrency') {
  962. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  963. 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();
  964. if (!title || title.length > 50) fail(400, '货币名称长度应为 1 至 50 位');
  965. if (!/^[A-Z][A-Z0-9]{1,9}$/.test(currencyName)) fail(400, '货币代码应为 2 至 10 位大写字母或数字');
  966. if (!currencySymbol || currencySymbol.length > 16) fail(400, '货币符号长度应为 1 至 16 位');
  967. if (!Number.isFinite(exchange) || exchange <= 0 || exchange > 1000000000) fail(400, '当前汇率必须是大于 0 的有效数字');
  968. if (remark.length > 255) fail(400, '货币备注不能超过 255 位');
  969. let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '货币必须指定帐套');
  970. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true }); const fields = await schemaFor('Currency'); let target;
  971. if (!objectId) fail(501, 'migration_blocked: Currency.id 与 Parse 对象保留 id 冲突,且 PostgreSQL 物理表缺少可写旧 ID 列,不能安全新增货币');
  972. { 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; }
  973. const duplicateQuery = new Parse.Query('Currency'); applyTenant(duplicateQuery, fields, context, input.companyId); duplicateQuery.limit(1000); const duplicates = await duplicateQuery.find({ useMasterKey: true });
  974. if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('title') || '').trim().toLowerCase() === title.toLowerCase())) fail(409, '同一帐套的货币名称不能重复');
  975. if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('currencyName') || '').trim().toUpperCase() === currencyName)) fail(409, '同一帐套的货币代码不能重复');
  976. target.set('title', title); target.set('currencyName', currencyName); target.set('currencySymbol', currencySymbol); target.set('currentExchange', exchange); target.set('remark', remark); target.set('company', company);
  977. try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '货币写入失败: ' + String(error && error.message || error)); }
  978. 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) });
  979. }
  980. if (operation === 'currencyBatch') {
  981. 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 条货币记录');
  982. 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: [] } });
  983. }
  984. if (operation === 'saveHoliday') {
  985. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  986. if (!objectId) fail(501, 'migration_blocked: SysHoliday.id 与 Parse 对象保留 id 冲突,且 PostgreSQL 物理表缺少可写旧 ID 列,不能安全新增节假日');
  987. const name = String(payload.name || '').trim(); const htype = String(payload.htype || '').trim(); const hdate = String(payload.hdate || '').trim();
  988. const hdateStart = Number(payload.hdateStart); const hdateEnd = Number(payload.hdateEnd); const zstatus = Number(payload.zstatus);
  989. 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() };
  990. if (!name || name.length > 100) fail(400, '节假日名称长度应为 1 至 100 位');
  991. if (!['公历','农历'].includes(htype)) fail(400, '节假日类型只能是公历或农历');
  992. if (!/^(0[1-9]|1[0-2])月(0[1-9]|[12][0-9]|3[01])日$/.test(hdate)) fail(400, '节假日日期必须使用 MM月DD日 格式');
  993. if (!Number.isInteger(hdateStart) || hdateStart < 0 || hdateStart > 365 || !Number.isInteger(hdateEnd) || hdateEnd < 0 || hdateEnd > 365) fail(400, '提前与延后影响天数必须是 0 至 365 的整数');
  994. if (![0,99].includes(zstatus)) fail(400, '节假日状态只能是停用或启用');
  995. if (strings.ztype.length > 100 || strings.himage.length > 500 || strings.remind.length > 1000 || strings.heffect.length > 500 || strings.hcontent.length > 4000) fail(400, '节假日文本字段超过允许长度');
  996. 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 });
  997. const duplicateQuery = new Parse.Query('SysHoliday'); applyTenant(duplicateQuery, fields, context, input.companyId); duplicateQuery.limit(1000); const duplicates = await duplicateQuery.find({ useMasterKey: true });
  998. 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, '同一帐套的节假日名称、类型和日期不能完全重复');
  999. target.set('name', name); target.set('htype', htype); target.set('hdate', hdate); target.set('hdateStart', hdateStart); target.set('hdateEnd', hdateEnd); target.set('zstatus', zstatus);
  1000. for (const [field, value] of Object.entries(strings)) target.set(field, value);
  1001. try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '节假日写入失败: ' + String(error && error.message || error)); }
  1002. 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) });
  1003. }
  1004. if (operation === 'holidayBatch') {
  1005. 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 条节假日记录');
  1006. 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: [] } });
  1007. }
  1008. if (operation === 'searchNavigations') {
  1009. 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);
  1010. if (![1,2].includes(type) || ![-100,1,2].includes(state) || ![-100,0,1].includes(elite)) fail(400, '快捷入口筛选条件无效');
  1011. 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();
  1012. 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)));
  1013. 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);
  1014. return response.json({ success: true, data: { className: 'Search', page, pageSize, total, results: results.map(serializeObject) } });
  1015. }
  1016. if (operation === 'saveSearchNavigation') {
  1017. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  1018. if (!objectId) fail(501, 'migration_blocked: Search.id 与 Parse 对象保留 id 冲突,且 PostgreSQL 物理表缺少可写旧 ID 列,不能安全新增快捷入口');
  1019. 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();
  1020. const openType = Number(payload.openType); const mobile = Number(payload.mobile); const size = Number(payload.size); const eliteLevel = Number(payload.eliteLevel);
  1021. if (!name || name.length > 100) fail(400, '快捷入口名称长度应为 1 至 100 位');
  1022. if (!fileUrl || fileUrl.length > 500 || !fileUrl.startsWith('/') || fileUrl.startsWith('//') || /^[a-z]+:/i.test(fileUrl)) fail(400, '快捷入口地址必须是 1 至 500 位的站内路径');
  1023. if (ico.length > 100 || (bkcolor && !/^#[0-9a-f]{6}$/i.test(bkcolor))) fail(400, '图标或背景颜色格式无效');
  1024. 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, '快捷入口展示参数无效');
  1025. if (userGroup && !/^\d+(,\d+)*$/.test(userGroup)) fail(400, '用户组必须使用逗号分隔的正整数 ID');
  1026. 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 });
  1027. 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);
  1028. try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '快捷入口写入失败: ' + String(error && error.message || error)); }
  1029. 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) });
  1030. }
  1031. if (operation === 'searchNavigationBatch') {
  1032. if (String(input.className || '') !== 'Search') fail(400, '只能处理快捷入口'); const action = String(input.action || '');
  1033. if (action === 'starturl') fail(501, 'migration_blocked: 目标云函数架构没有旧 SiteConfig.SiteOption.Admin_StartUrl 持久层,不能伪造全局后台起始页');
  1034. if (!['enable','disable','elite','unelite','delete','order'].includes(action)) fail(400, '不支持的快捷入口批量操作');
  1035. let requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; let orders = [];
  1036. 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); }
  1037. 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 条唯一快捷入口');
  1038. 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, '部分快捷入口不存在或不属于当前帐套');
  1039. if (action === 'delete') await Parse.Object.destroyAll(targets, { useMasterKey: true });
  1040. 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 }); }
  1041. 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) } });
  1042. }
  1043. if (operation === 'adZones') {
  1044. 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();
  1045. if (![-100,0,99].includes(status) || type.length > 50 || search.length > 100) fail(400, '广告位筛选条件无效');
  1046. 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 });
  1047. 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))));
  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: 'AdZone', page, pageSize, total, results: results.map(serializeObject) } });
  1050. }
  1051. if (operation === 'adInfos') {
  1052. 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();
  1053. if (![-100,0,99].includes(status) || (zoneId && !/^\d+$/.test(zoneId)) || search.length > 100) fail(400, '广告内容筛选条件无效');
  1054. 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 });
  1055. 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))));
  1056. 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);
  1057. return response.json({ success: true, data: { className: 'AdInfo', page, pageSize, total, results: results.map(serializeObject) } });
  1058. }
  1059. if (operation === 'saveAdZone') {
  1060. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  1061. if (!objectId) fail(501, 'migration_blocked: AdZone.id 与 Parse 对象保留 id 冲突,且省略 id 创建不会生成旧广告位编号,不能安全新增广告位');
  1062. 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);
  1063. if (!name || name.length > 100 || remind.length > 2000 || !ztype || ztype.length > 50 || !showType || showType.length > 50 || ![0,99].includes(zstatus)) fail(400, '广告位名称、类型、展示方式、状态或备注无效');
  1064. 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 });
  1065. 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);
  1066. try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '广告位写入失败: ' + String(error && error.message || error)); }
  1067. 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) });
  1068. }
  1069. if (operation === 'saveAdInfo') {
  1070. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  1071. if (!objectId) fail(501, 'migration_blocked: AdInfo.id 与 Parse 对象保留 id 冲突,且省略 id 创建不会生成旧广告编号,不能安全新增广告内容');
  1072. 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);
  1073. 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));
  1074. 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 || '') };
  1075. 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, '广告名称、广告位、权重或状态无效');
  1076. if (Number.isNaN(sdate.getTime()) || Number.isNaN(edate.getTime()) || edate < sdate) fail(400, '广告到期时间必须大于或等于开始时间');
  1077. 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, '广告内容字段超过允许长度');
  1078. 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, '目标广告位不存在或不属于当前帐套');
  1079. 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 });
  1080. 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);
  1081. try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '广告内容写入失败: ' + String(error && error.message || error)); }
  1082. 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) });
  1083. }
  1084. if (operation === 'adZoneBatch') {
  1085. if (String(input.className || '') !== 'AdZone') fail(400, '只能处理广告位'); const action = String(input.action || ''); if (!['active','pause','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('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, '部分广告位不存在或不属于当前帐套');
  1088. 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 }); }
  1089. else { for (const target of targets) target.set('zstatus', action === 'active' ? 99 : 0); await Parse.Object.saveAll(targets, { useMasterKey: true }); }
  1090. 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) } });
  1091. }
  1092. if (operation === 'adInfoBatch') {
  1093. if (String(input.className || '') !== 'AdInfo') fail(400, '只能处理广告内容'); const action = String(input.action || ''); if (!['audit','unaudit','delete'].includes(action)) fail(400, '不支持的广告内容批量操作');
  1094. 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 条广告内容');
  1095. 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, '部分广告内容不存在或不属于当前帐套');
  1096. 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 }); }
  1097. 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) } });
  1098. }
  1099. if (operation === 'adPreview') {
  1100. 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') || '');
  1101. 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));
  1102. return response.json({ success: true, data: { zone: serializeObject(zone), total: results.length, results: results.map(serializeObject) } });
  1103. }
  1104. if (operation === 'fontShapeTypes') {
  1105. 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, '图形分类搜索词过长');
  1106. 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);
  1107. return response.json({ success: true, data: { className: 'FontPicShapeType', page, pageSize, total, results: results.map(serializeObject) } });
  1108. }
  1109. if (operation === 'fontShapes') {
  1110. 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, '图形素材筛选条件无效');
  1111. 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);
  1112. return response.json({ success: true, data: { className: 'FontPicShape', page, pageSize, total, results: results.map(serializeObject) } });
  1113. }
  1114. if (operation === 'saveFontShapeType') {
  1115. 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 创建不会生成旧分类编号,不能安全新增图形分类');
  1116. const name = String(payload.name || '').trim(); const remarks = String(payload.remarks || '').trim(); if (!name || name.length > 100 || remarks.length > 2000) fail(400, '图形分类名称或备注无效');
  1117. 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());
  1118. 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) });
  1119. }
  1120. if (operation === 'saveFontShape') {
  1121. 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 创建不会生成旧素材编号,不能安全新增图形素材');
  1122. if (input.file) fail(501, 'migration_blocked: 当前 Parse Server 的 SDK 与 /files REST 接口均错误调用 MongoDB 文件适配器,而本项目使用 PostgreSQL;修复服务端文件存储配置前不能替换图形文件');
  1123. 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, '图形素材名称、分类或备注无效');
  1124. 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, '目标图形分类不存在或不属于当前帐套');
  1125. 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 });
  1126. 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) });
  1127. }
  1128. if (operation === 'fontShapeTypeBatch') {
  1129. 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 个图形分类');
  1130. 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));
  1131. 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, '图形分类仍有素材引用,不能删除'); }
  1132. 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: [] } });
  1133. }
  1134. if (operation === 'fontShapeBatch') {
  1135. 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 个图形素材');
  1136. 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: [] } });
  1137. }
  1138. if (operation === 'designResources') {
  1139. 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, '设计资源筛选条件无效');
  1140. 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);
  1141. return response.json({ success: true, data: { className: 'DesignRes', page, pageSize, total, results: results.map(serializeObject) } });
  1142. }
  1143. if (operation === 'saveDesignResource') {
  1144. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  1145. if (!objectId) fail(501, 'migration_blocked: 新增设计资源必须上传原文件并分配 DesignRes.id;当前 PostgreSQL Parse 文件适配器配置错误,且 id 与 Parse 保留属性冲突');
  1146. if (input.file || input.cover) fail(501, 'migration_blocked: 当前 Parse Server 的 SDK 与 /files REST 接口均错误调用 MongoDB 文件适配器,本项目使用 PostgreSQL;修复服务端文件存储前不能新增或替换设计资源文件');
  1147. 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);
  1148. 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, '设计资源名称、类型、用途、状态或代码字段无效');
  1149. 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 });
  1150. 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) });
  1151. }
  1152. if (operation === 'designResourceBatch') {
  1153. 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 个设计资源');
  1154. 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: [] } });
  1155. }
  1156. if (operation === 'designScenes') {
  1157. 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 search = String(input.search || '').trim().toLowerCase(); if (![-100,-2].includes(status) || search.length > 100) fail(400, '场景状态或搜索词无效');
  1158. const fields = await schemaFor('DesignScence'); const query = new Parse.Query('DesignScence'); applyTenant(query, fields, context, input.companyId); query.equalTo('ztype', 0); query.equalTo('tlpId', 0); if (status === -2) query.equalTo('status', -2); else query.notEqualTo('status', -2); query.limit(1000); let results = await query.find({ useMasterKey: true }); if (results.length >= 1000) fail(409, '场景数量超过安全分页上限,请先缩小帐套范围'); results = results.filter((entry) => !search || [entry.get('title'),entry.get('userName'),entry.get('remind'),entry.get('path')].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);
  1159. return response.json({ success: true, data: { className: 'DesignScence', page, pageSize, total, status, results: results.map(serializeObject) } });
  1160. }
  1161. if (operation === 'saveDesignScene') {
  1162. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; if (!objectId) fail(501, 'migration_blocked: 旧 SceneAdd 只允许编辑现有场景;新建场景依赖已丢失的可视化设计器初始化协议');
  1163. const title = String(payload.title || '').trim(); const userId = Number(payload.userId); const score = Number(payload.score == null || payload.score === '' ? 0 : payload.score); const status = Number(payload.status == null || payload.status === '' ? 0 : payload.status); const seflag = String(payload.seflag || '').trim(); const previewImg = String(payload.previewImg || '').trim(); const thumbImg = String(payload.thumbImg || '').trim(); const remind = String(payload.remind || '').trim(); const meta = String(payload.meta || '');
  1164. if (title.length > 200 || !Number.isInteger(userId) || userId < 1 || !Number.isFinite(score) || Math.abs(score) > 1000000000 || !Number.isInteger(status) || status < -2 || status > 999 || seflag.length > 255 || previewImg.length > 1000 || thumbImg.length > 1000 || remind.length > 2000 || meta.length > 20000) fail(400, '场景标题、用户、积分、状态、图片路径或元数据无效');
  1165. const fields = await schemaFor('DesignScence'); const query = new Parse.Query('DesignScence'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); if (Number(target.get('ztype')) !== 0 || Number(target.get('tlpId')) !== 0) fail(409, '该记录不是可编辑的普通场景'); const companyId = pointerId(target.get('company')) || pointerId(context.company); if (!companyId) fail(409, '场景缺少帐套'); const owner = await Psql.oneOrNone('SELECT "username" FROM "_User" WHERE "company"=$1 AND (COALESCE("legacyUserId",0)=$2 OR CASE WHEN COALESCE("legacyUserData"->>\'UserID\',\'\') ~ \'^[0-9]+$\' THEN ("legacyUserData"->>\'UserID\')::numeric ELSE 0 END=$2) AND COALESCE("isDeleted",FALSE)=FALSE LIMIT 1', [companyId, userId]); if (!owner) fail(404, '场景所属用户不存在或不属于当前帐套');
  1166. target.set('title', title); target.set('userId', userId); target.set('userName', String(owner.username || '')); target.set('score', score); target.set('status', status); target.set('seflag', seflag); target.set('previewImg', previewImg); target.set('thumbImg', thumbImg); target.set('remind', remind); target.set('meta', meta); target.set('update', 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-design-scene', 'DesignScence', target.id); return response.json({ success: true, data: serializeObject(target) });
  1167. }
  1168. if (operation === 'designSceneBatch') {
  1169. if (String(input.className || '') !== 'DesignScence') fail(400, '场景批量操作的数据类无效'); const action = String(input.action || ''); if (!['recycle','restore','delete'].includes(action)) 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 个场景');
  1170. const fields = await schemaFor('DesignScence'); const query = new Parse.Query('DesignScence'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.equalTo('ztype', 0); query.equalTo('tlpId', 0); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分场景不存在、不属于当前帐套或不是普通场景'); if (action === 'delete') await Parse.Object.destroyAll(targets, { useMasterKey: true }); else { for (const target of targets) { target.set('status', action === 'recycle' ? -2 : 0); target.set('update', new Date()); } await Parse.Object.saveAll(targets, { useMasterKey: true }); } for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'design-scene-' + action, 'DesignScence', target.id); return response.json({ success: true, data: { action, updated: targets.length, results: action === 'delete' ? [] : targets.map(serializeObject) } });
  1171. }
  1172. if (operation === 'createDesignSceneTemplate') {
  1173. const objectId = String(input.objectId || ''); if (!objectId) fail(400, '缺少场景 objectId'); const fields = await schemaFor('DesignScence'); const query = new Parse.Query('DesignScence'); applyTenant(query, fields, context, input.companyId); const source = await query.get(objectId, { useMasterKey: true }); if (Number(source.get('ztype')) !== 0 || Number(source.get('tlpId')) !== 0) fail(409, '只能将普通场景复制为模板'); const company = source.get('company') || context.company; const companyId = pointerId(company); if (!companyId) fail(409, '场景缺少帐套'); const now = new Date(); const suffix = Date.now() + ':' + Math.random().toString(36).slice(2,10); const template = new Parse.Object('DesignTlp'); let clone = null;
  1174. template.set('company', company); template.set('sourceKey', 'cloud:design-template:' + companyId + ':' + suffix); template.set('tlpName', String(source.get('title') || '')); template.set('previewImg', String(source.get('previewImg') || '')); template.set('ztype', 1); template.set('zstatus', 0); template.set('score', 0); template.set('price', 0); template.set('isDef', 0); template.set('cdate', now);
  1175. try {
  1176. await template.save(null, { useMasterKey: true }); const templateRows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-design-template-id:\'||$1))),next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "DesignTlp",lock_row WHERE "company"=$1) UPDATE "DesignTlp" SET "id"=next_id.id,"sourceKey"=\'[["ID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, template.id]); const templateId = Number(templateRows[0] && templateRows[0].id); if (!templateId) throw new Error('无法分配模板编号');
  1177. clone = new Parse.Object('DesignScence'); for (const name of ['comp','meta','page','path','cuser','remind','scence','siteId','orderId','labelArr','resource','thumbImg','userId','userName','previewImg']) { const value = source.get(name); if (value !== undefined && fields[name]) clone.set(name, toParseValue(fields[name], safeValue(value))); } const hex = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).slice(1); clone.set('guid', hex()+hex()+'-'+hex()+'-4'+hex().slice(1)+'-'+hex()+'-'+hex()+hex()+hex()); clone.set('title', String(source.get('title') || '')); clone.set('company', company); clone.set('sourceKey', 'cloud:design-scene-template:' + companyId + ':' + suffix); clone.set('cdate', now); clone.set('update', now); clone.set('tlpId', templateId); clone.set('ztype', 1); clone.set('status', 0); clone.set('score', 0); clone.set('accessPwd', ''); clone.set('seflag', ''); await clone.save(null, { useMasterKey: true }); const sceneRows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-design-scene-id:\'||$1))),next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "DesignScence",lock_row WHERE "company"=$1) UPDATE "DesignScence" SET "company"=$1,"id"=next_id.id,"sourceKey"=\'[["ID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, clone.id]); if (!Number(sceneRows[0] && sceneRows[0].id)) throw new Error('无法分配模板场景编号');
  1178. } catch (error) { if (clone && clone.id) await clone.destroy({ useMasterKey: true }).catch(() => undefined); if (template.id) await template.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '场景复制为模板失败: ' + String(error && error.message || error)); }
  1179. await Promise.all([template.fetch({ useMasterKey: true }), clone.fetch({ useMasterKey: true })]); await audit({ ...context, company }, 'create-template-from-design-scene', 'DesignScence', source.id); return response.json({ success: true, data: { template: serializeObject(template), scene: serializeObject(clone) } });
  1180. }
  1181. if (operation === 'designTemplates') {
  1182. 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 classId = Number(input.classId == null ? -100 : input.classId); const search = String(input.search || '').trim().toLowerCase(); if (![-100,-2].includes(status) || !Number.isInteger(classId) || classId < -100 || search.length > 100) fail(400, '模板状态、分类编号或搜索词无效');
  1183. const fields = await schemaFor('DesignTlp'); const query = new Parse.Query('DesignTlp'); applyTenant(query, fields, context, input.companyId); if (status === -2) query.equalTo('zstatus', -2); else query.notEqualTo('zstatus', -2); if (classId !== -100) query.equalTo('classId', classId); query.limit(1000); let results = await query.find({ useMasterKey: true }); if (results.length >= 1000) fail(409, '模板数量超过安全分页上限,请缩小帐套或分类范围'); results = results.filter((entry) => !search || [entry.get('tlpName'),entry.get('remind'),entry.get('defBy'),entry.get('previewImg')].some((value) => String(value || '').toLowerCase().includes(search))); results.sort((left, right) => Number(right.get('zstatus') || 0) - Number(left.get('zstatus') || 0) || Number(right.get('id') || 0) - Number(left.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize);
  1184. return response.json({ success: true, data: { className: 'DesignTlp', page, pageSize, total, status, classId, results: results.map(serializeObject) } });
  1185. }
  1186. if (operation === 'designTemplate') {
  1187. const objectId = String(input.objectId || ''); if (!objectId) fail(400, '缺少模板 objectId'); const fields = await schemaFor('DesignTlp'); const query = new Parse.Query('DesignTlp'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); const templateId = Number(target.get('id')) || 0; const companyId = pointerId(target.get('company')) || pointerId(context.company); const sceneRow = templateId && companyId ? await Psql.oneOrNone('SELECT "objectId" FROM "DesignScence" WHERE "company"=$1 AND "tlpId"=$2 AND "ztype"=1 ORDER BY "id" ASC LIMIT 1', [companyId, templateId]) : null; const scene = sceneRow ? await new Parse.Query('DesignScence').get(String(sceneRow.objectId), { useMasterKey: true }) : null;
  1188. return response.json({ success: true, data: { ...serializeObject(target), designScene: scene ? serializeObject(scene) : null } });
  1189. }
  1190. if (operation === 'designTemplateEditorTarget') {
  1191. const objectId = String(input.objectId || ''); if (!objectId) fail(400, '缺少模板 objectId'); const fields = await schemaFor('DesignTlp'); const query = new Parse.Query('DesignTlp'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); const templateId = Number(target.get('id')) || 0; if (!templateId) fail(409, '模板缺少旧数字 ID'); const companyId = pointerId(target.get('company')) || pointerId(context.company); const sceneRow = companyId ? await Psql.oneOrNone('SELECT "objectId" FROM "DesignScence" WHERE "company"=$1 AND "tlpId"=$2 AND "ztype"=1 ORDER BY "id" ASC LIMIT 1', [companyId, templateId]) : null; const scene = sceneRow ? await new Parse.Query('DesignScence').get(String(sceneRow.objectId), { useMasterKey: true }) : null; const guid = String(scene && scene.get('guid') || ''); if (!scene || !guid) fail(409, '模板没有可定位的设计场景 GUID');
  1192. return response.json({ success: true, data: { templateObjectId: target.id, templateId, sceneObjectId: scene.id, guid, legacyPath: '/design/default?id=' + encodeURIComponent(guid), available: false, reason: '旧可视化设计器前端资产、布局交互和渲染运行时未迁入 Angular' } });
  1193. }
  1194. if (operation === 'saveDesignTemplate') {
  1195. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; const tlpName = String(payload.tlpName || '').trim(); const previewImg = String(payload.previewImg || '').trim(); const remind = String(payload.remind || '').trim(); const defBy = String(payload.defBy || '').trim(); const classId = Number(payload.classId == null || payload.classId === '' ? 0 : payload.classId); const price = Number(payload.price == null || payload.price === '' ? 0 : payload.price); const score = Number(payload.score == null || payload.score === '' ? 0 : payload.score); const zstatus = Number(payload.zstatus == null || payload.zstatus === '' ? 0 : payload.zstatus);
  1196. if (!tlpName || tlpName.length > 200 || previewImg.length > 1000 || remind.length > 2000 || defBy.length > 100 || !Number.isInteger(classId) || classId < 0 || classId > 1000000000 || !Number.isFinite(price) || price < 0 || price > 1000000000 || !Number.isFinite(score) || Math.abs(score) > 1000000000 || !Number.isInteger(zstatus) || zstatus < -2 || zstatus > 999) fail(400, '模板名称、分类、价格、积分、状态或说明字段无效');
  1197. 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('DesignTlp'); let target; let scene = null; const created = !objectId;
  1198. if (objectId) { const query = new Parse.Query('DesignTlp'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); companyId = pointerId(target.get('company')) || companyId; } else { target = new Parse.Object('DesignTlp'); target.set('company', company); target.set('sourceKey', 'cloud:design-template:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); target.set('cdate', new Date()); target.set('isDef', 0); }
  1199. target.set('tlpName', tlpName); target.set('classId', classId); target.set('ztype', 1); target.set('price', price); target.set('previewImg', previewImg); target.set('score', score); target.set('zstatus', zstatus); target.set('defBy', defBy); target.set('remind', remind);
  1200. try {
  1201. await target.save(null, { useMasterKey: true }); if (created) { const templateRows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-design-template-id:\'||$1))),next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "DesignTlp",lock_row WHERE "company"=$1) UPDATE "DesignTlp" 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]); const templateId = Number(templateRows[0] && templateRows[0].id); if (!templateId) throw new Error('无法分配模板编号'); const now = new Date(); const hex = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).slice(1); scene = new Parse.Object('DesignScence'); scene.set('company', target.get('company') || company); scene.set('sourceKey', 'cloud:design-template-scene:' + companyId + ':' + templateId + ':' + Date.now()); scene.set('tlpId', templateId); scene.set('ztype', 1); scene.set('status', 0); scene.set('previewImg', previewImg); scene.set('guid', hex()+hex()+'-'+hex()+'-4'+hex().slice(1)+'-'+hex()+'-'+hex()+hex()+hex()); scene.set('cdate', now); scene.set('update', now); scene.set('score', 0); scene.set('userId', 0); scene.set('userName', ''); await scene.save(null, { useMasterKey: true }); const sceneRows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-design-scene-id:\'||$1))),next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "DesignScence",lock_row WHERE "company"=$1) UPDATE "DesignScence" SET "company"=$1,"id"=next_id.id,"sourceKey"=\'[["ID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, scene.id]); if (!Number(sceneRows[0] && sceneRows[0].id)) throw new Error('无法分配模板场景编号'); } }
  1202. catch (error) { if (created && scene && scene.id) await scene.destroy({ useMasterKey: true }).catch(() => undefined); if (created && target.id) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '模板保存失败: ' + String(error && error.message || error)); }
  1203. await target.fetch({ useMasterKey: true }); if (scene) await scene.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || company }, created ? 'create-design-template' : 'update-design-template', 'DesignTlp', target.id); return response.json({ success: true, data: { ...serializeObject(target), designScene: scene ? serializeObject(scene) : null } });
  1204. }
  1205. if (operation === 'designTemplateBatch') {
  1206. if (String(input.className || '') !== 'DesignTlp') fail(400, '模板批量操作的数据类无效'); const action = String(input.action || ''); if (!['recycle','restore','delete'].includes(action)) 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('DesignTlp'); const query = new Parse.Query('DesignTlp'); 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, '部分模板不存在或不属于当前帐套'); let deletedScenes = 0;
  1207. if (action === 'delete') { const scenes = []; for (const target of targets) { const templateId = Number(target.get('id')) || 0; const companyId = pointerId(target.get('company')) || pointerId(context.company); if (!templateId) fail(409, '模板缺少旧数字 ID'); if (!companyId) fail(409, '模板缺少帐套'); const linkedRows = await Psql.query('SELECT "objectId" FROM "DesignScence" WHERE "company"=$1 AND "tlpId"=$2 AND "ztype"=1 ORDER BY "id" ASC LIMIT 1000', [companyId, templateId]); if (linkedRows.length >= 1000) fail(409, '模板关联场景超过安全删除上限'); for (const row of linkedRows) scenes.push(await new Parse.Query('DesignScence').get(String(row.objectId), { useMasterKey: true })); } if (scenes.length) { await Parse.Object.destroyAll(scenes, { useMasterKey: true }); deletedScenes = scenes.length; } await Parse.Object.destroyAll(targets, { useMasterKey: true }); } else { for (const target of targets) target.set('zstatus', action === 'recycle' ? -2 : 0); await Parse.Object.saveAll(targets, { useMasterKey: true }); } for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'design-template-' + action, 'DesignTlp', target.id); return response.json({ success: true, data: { action, updated: targets.length, deletedScenes, results: action === 'delete' ? [] : targets.map(serializeObject) } });
  1208. }
  1209. if (operation === 'roles') {
  1210. 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, '角色类型或搜索词无效');
  1211. 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);
  1212. return response.json({ success: true, data: { className: 'Role', page, pageSize, total, results: results.map(serializeObject) } });
  1213. }
  1214. if (operation === 'saveRole') {
  1215. 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, '角色名称、说明或类型无效');
  1216. 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;
  1217. 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; }
  1218. 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);
  1219. 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) });
  1220. }
  1221. if (operation === 'roleAuthorization') {
  1222. 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; const companyId = pointerId(role.get('company')) || pointerId(context.company); if (!roleId || !companyId) fail(409, '角色缺少旧数字 ID 或帐套'); const authMatches = await Psql.query('SELECT "objectId" FROM "ARoleAuth" WHERE "company"=$1 AND "rid"=$2 ORDER BY "objectId" LIMIT 2', [companyId, roleId]); if (authMatches.length > 1) fail(409, '角色存在重复权限记录'); const authRow = authMatches[0] ? await new Parse.Query('ARoleAuth').get(String(authMatches[0].objectId), { useMasterKey: true }) : null; const names = ['model','content','shop','page','exam','user','system','office','portable','sites','other','extend']; const authorization = authRow ? serializeObject(authRow) : { rid: roleId }; for (const name of names) if (!Object.prototype.hasOwnProperty.call(authorization, name)) authorization[name] = '';
  1223. return response.json({ success: true, data: { ...serializeObject(role), authorization } });
  1224. }
  1225. if (operation === 'saveRoleAuthorization') {
  1226. 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, '角色权限码过长');
  1227. const matches = await Psql.query('SELECT "objectId" FROM "ARoleAuth" WHERE "company"=$1 AND "rid"=$2 ORDER BY "objectId" LIMIT 2', [companyId, roleId]); if (matches.length > 1) fail(409, '角色存在重复权限记录'); let target = matches[0] ? await new Parse.Query('ARoleAuth').get(String(matches[0].objectId), { useMasterKey: true }) : null; 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));
  1228. 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) });
  1229. }
  1230. if (operation === 'roleBatch') {
  1231. 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 = [];
  1232. for (const target of targets) { const companyId = pointerId(target.get('company')); const roleId = Number(target.get('roleId')) || 0; if (!companyId || !roleId) fail(409, '角色缺少旧编号或帐套'); if (String(target.get('ztype') || 'admin') === 'admin' && roleId >= 1 && roleId <= 9) 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 authRows = await Psql.query('SELECT "objectId" FROM "ARoleAuth" WHERE "company"=$1 AND "rid"=$2 ORDER BY "objectId" LIMIT 100', [companyId, roleId]); for (const row of authRows) authTargets.push(await new Parse.Query('ARoleAuth').get(String(row.objectId), { useMasterKey: true })); }
  1233. 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 } });
  1234. }
  1235. if (operation === 'legacyFontPicBlocker') {
  1236. 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]);
  1237. }
  1238. if (operation === 'surveys') {
  1239. 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, '问卷筛选条件无效');
  1240. 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) } });
  1241. }
  1242. if (operation === 'surveyQuestions') {
  1243. 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, '问卷题目筛选条件无效');
  1244. 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, '问卷不存在或不属于当前帐套');
  1245. 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) } });
  1246. }
  1247. if (operation === 'surveyResults') {
  1248. 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, '问卷答卷筛选条件无效');
  1249. 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, '问卷不存在或不属于当前帐套');
  1250. 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) } });
  1251. }
  1252. if (operation === 'saveSurvey') {
  1253. 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 创建探测不能还原旧问卷编号,不能安全新增问卷');
  1254. 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) };
  1255. 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, '问卷标题、时间、状态或访问设置无效');
  1256. 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) });
  1257. }
  1258. if (operation === 'saveSurveyQuestion') {
  1259. 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 创建探测不能还原旧题目编号,不能安全新增问卷题目');
  1260. 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, '问卷题目内容或类型无效');
  1261. 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, '单选/多选题选项格式无效'); }
  1262. if (qflag) { let flag; try { flag = JSON.parse(qflag); } catch { fail(400, '题目扩展设置必须是有效 JSON'); } if (!flag || typeof flag !== 'object' || Array.isArray(flag)) fail(400, '题目扩展设置必须是 JSON 对象'); }
  1263. 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, '题目所属问卷已不存在,不能继续编辑');
  1264. 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) });
  1265. }
  1266. if (operation === 'surveyBatch') {
  1267. 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 条问卷记录');
  1268. 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, '部分问卷记录不存在或不属于当前帐套');
  1269. 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 }); }
  1270. 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) } });
  1271. }
  1272. if (operation === 'surveyResultChart') {
  1273. 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, '问卷不存在或不属于当前帐套');
  1274. 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;
  1275. 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); } } }
  1276. 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 } });
  1277. }
  1278. if (operation === 'serviceSeats') {
  1279. 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) } });
  1280. }
  1281. if (operation === 'saveServiceSeat') {
  1282. 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; }
  1283. 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) });
  1284. }
  1285. if (operation === 'serviceCodes') {
  1286. 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) } });
  1287. }
  1288. if (operation === 'saveServiceCode') {
  1289. 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) });
  1290. }
  1291. if (operation === 'serviceBatch') {
  1292. 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: [] } });
  1293. }
  1294. if (operation === 'legacyServiceChatBlocker') {
  1295. 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]);
  1296. }
  1297. if (operation === 'storeApplications') {
  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(); 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) } });
  1299. }
  1300. if (operation === 'storeStyles') {
  1301. 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) } });
  1302. }
  1303. if (operation === 'saveStoreApplication') {
  1304. 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, '店铺样式不存在或不属于当前帐套'); }
  1305. 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) });
  1306. }
  1307. if (operation === 'saveStoreStyle') {
  1308. 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) });
  1309. }
  1310. if (operation === 'storeBatch') {
  1311. 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) } });
  1312. }
  1313. if (operation === 'legacyStoreProductBlocker') fail(501, 'migration_blocked: 旧 Product 页面依赖 ZL_Commodities 商品主表;目标仅有 Product/StoreProduct 扩展碎片,缺少商品名称、店铺归属、审核/销售状态与节点关系,无法等价重建列表');
  1314. if (operation === 'schema' && String(input.className || '') === 'ContentTagKey') {
  1315. 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 } });
  1316. }
  1317. if (operation === 'contentTagKeys' || operation === 'getContentTagKeys') {
  1318. 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));
  1319. if (operation === 'getContentTagKeys') { if (String(input.objectId || '') !== record.objectId) fail(404, '内容标签词库不存在'); return response.json({ success: true, data: record }); }
  1320. return response.json({ success: true, data: { className: 'ContentTagKey', page: 1, pageSize: 20, total: visible ? 1 : 0, results: visible ? [record] : [] } });
  1321. }
  1322. if (operation === 'saveContentTagKeys') {
  1323. 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 } });
  1324. }
  1325. if (operation === 'legacyContentAddonBlocker') {
  1326. const action = String(input.action || ''); const blockers = {
  1327. Comment: '目标 Schema 缺少旧 ZL_Comment 评论主表与历史评论数据',
  1328. Commont_API: '目标 Schema 缺少可审核、取消审核或删除的旧 ZL_Comment 评论记录',
  1329. ConAudit_API: '目标 Schema 缺少 ContentAudit 审核记录、节点审核流程与管理员步骤状态',
  1330. ConAudit_SJ_API: '目标 Schema 缺少 ContentAudit 送审确认/驳回记录和可追溯流程状态',
  1331. ConAudit: '目标 Schema 缺少旧 ZL_Content_Audit 审核列表及历史数据',
  1332. ConAuditDetail: '目标 Schema 缺少内容审核明细、备注、节点和操作人流转记录',
  1333. VerBak: '目标 Schema 缺少旧 ZL_Content_VerBak 内容/商品版本快照与历史数据',
  1334. VerBak_Del: '目标 Schema 缺少可定位和删除的旧 ZL_Content_VerBak 版本记录'
  1335. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的内容附加功能动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1336. }
  1337. if (operation === 'legacyShopVendorBlocker') {
  1338. const action = String(input.action || ''); const blockers = {
  1339. Vendor: '目标 Schema 缺少旧 ZL_Shop_Vendor 供应商主表与历史供应商数据',
  1340. Vendor_API: '目标没有可定位和删除的供应商记录;Agency/DeliveryCenter 与供应商语义不等价',
  1341. VendorAdd: '目标 Schema 没有可承载供应商名称、地址、手机、传真、邮箱和状态的签约数据类',
  1342. VendorAdd_Submit: '按既定数据库设计不能擅自新建 ShopVendor 类,也不能把供应商字段错写到 Agency 或 DeliveryCenter'
  1343. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧供应商动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1344. }
  1345. if (operation === 'userLevels') {
  1346. 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) } });
  1347. }
  1348. if (operation === 'saveUserLevel') {
  1349. 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) });
  1350. }
  1351. if (operation === 'userLevelBatch') {
  1352. 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: [] } });
  1353. }
  1354. if (operation === 'schema' && String(input.className || '') === 'UserMoneyLog') {
  1355. 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 } });
  1356. }
  1357. if (operation === 'userMoneyLogs' || operation === 'getUserMoneyLog') {
  1358. 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 }); }
  1359. 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 } });
  1360. }
  1361. if (operation === 'adjustUserBalance') {
  1362. const type = Number(input.type || 0); const ledgerClass = USER_MONEY_LOG_CLASSES[type]; const balanceField = USER_MONEY_BALANCE_FIELDS[type]; const userId = Number(input.userId || 0); const amount = Number(input.amount || 0); const direction = String(input.direction || 'add'); const detailInput = String(input.detail || '').trim();
  1363. if (!ledgerClass || !balanceField || !Number.isInteger(userId) || userId < 1) fail(400, '请选择有效用户和资金类型'); if (!Number.isFinite(amount) || amount <= 0 || amount > 1000000000) fail(400, '调整数额必须大于 0 且不超过 10 亿'); if (!['add','deduct'].includes(direction)) fail(400, '资金调整方向无效'); if (detailInput.length > 500) fail(400, '调整备注不能超过 500 字');
  1364. let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '资金调整必须指定帐套'); const delta = direction === 'deduct' ? -amount : amount; const adminId = Number(context.current.get('legacyUserId') || (context.current.get('legacyUserData') || {}).UserID || 0); const detail = '管理员' + (direction === 'deduct' ? '扣减' : '增加') + USER_MONEY_LOG_LABELS[type] + (detailInput ? ',备注:' + detailInput : ''); const objectId = Array.from({ length: 20 }, () => 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'[Math.floor(Math.random() * 62)]).join(''); const sourceKey = 'cloud:admin-balance:' + companyId + ':' + userId + ':' + Date.now() + ':' + objectId;
  1365. const sql = 'WITH locked AS (SELECT "objectId",COALESCE("legacyUserId",CASE WHEN COALESCE("legacyUserData"->>\'UserID\',\'\') ~ \'^[0-9]+$\' THEN ("legacyUserData"->>\'UserID\')::numeric END) AS user_id,CASE WHEN COALESCE("legacyUserData"->>$2,\'\') ~ \'^-?[0-9]+(?:\\.[0-9]+)?$\' THEN ("legacyUserData"->>$2)::numeric ELSE 0 END AS before FROM "_User" WHERE "company"=$1 AND (COALESCE("legacyUserId",0)=$3 OR CASE WHEN COALESCE("legacyUserData"->>\'UserID\',\'\') ~ \'^[0-9]+$\' THEN ("legacyUserData"->>\'UserID\')::numeric ELSE 0 END=$3) AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) FOR UPDATE),eligible AS (SELECT *,before+$4::numeric AS after FROM locked WHERE before+$4::numeric>=0),logged AS (INSERT INTO "' + ledgerClass + '" ("objectId","createdAt","updatedAt","company","sourceKey","score","scoreBefore","userId","hisTime","operator","scoreType","detail","remark","operatorIp","type1","type2","type3","attach","extend") SELECT $5,NOW(),NOW(),$1,$6,$4,before,user_id,NOW(),$7,$8,$9,\'管理员操作\',\'cloud-admin\',\'\',\'\',\'\',\'\',\'\' FROM eligible RETURNING "objectId"),updated AS (UPDATE "_User" u SET "legacyUserData"=jsonb_set(COALESCE(u."legacyUserData",\'{}\'::jsonb),ARRAY[$2]::text[],to_jsonb(eligible.after),true),"updatedAt"=NOW() FROM eligible,logged WHERE u."objectId"=eligible."objectId" RETURNING eligible.before,eligible.after,eligible.user_id) SELECT (SELECT COUNT(*)::int FROM locked) AS found,(SELECT COUNT(*)::int FROM eligible) AS eligible,(SELECT "objectId" FROM logged LIMIT 1) AS log_id,(SELECT before FROM updated LIMIT 1) AS before,(SELECT after FROM updated LIMIT 1) AS after,(SELECT user_id FROM updated LIMIT 1) AS user_id';
  1366. const rows = await Psql.query(sql, [companyId,balanceField,userId,delta,objectId,sourceKey,adminId,type,detail]); const row = rows[0] || {}; if (!Number(row.found)) fail(404, '用户不存在或不属于当前帐套'); if (!Number(row.eligible)) fail(409, USER_MONEY_LOG_LABELS[type] + '余额不足,不能扣减为负数'); if (!row.log_id) fail(500, '资金调整事务未写入账本'); await audit(context, 'adjust-user-' + balanceField.toLowerCase(), '_User', String(userId)); return response.json({ success:true, data:{ objectId:ledgerClass + ':' + row.log_id, recordObjectId:String(row.log_id), ledgerClass, moneyType:type, moneyTypeLabel:USER_MONEY_LOG_LABELS[type], userId:Number(row.user_id), score:delta, scoreBefore:Number(row.before), balanceAfter:Number(row.after), detail } });
  1367. }
  1368. if (operation === 'legacyUserRemainderBlocker') {
  1369. const action = String(input.action || ''); const blockers = {
  1370. Output: '旧导出接收浏览器提交的任意表格并依赖 NPOI/WordAdd、SiteConfig 页眉页脚生成 xlsx/docx/md;目标没有这套服务器文档运行时,现有内容 CSV 导出不等价于用户报表',
  1371. GetMd: '旧方法只是 Output 的 Markdown 内部格式器;目标没有对应用户报表提交与下载协议,不能脱离 Output 伪造独立页面动作',
  1372. UserOrder: '目标 Schema 缺少 ZL_OrderInfo 与 ZL_CartPro 订单/订单商品主表,无法还原指定用户订单和金额统计',
  1373. DelUserPost_Btn_Click: '目标 GuestBar 只保留只读迁移数据,缺少可证明完整的用户帖子级联与计数语义,不能按用户直接破坏历史主题树',
  1374. UPClient: '目标 Schema 缺少 CRMS_Client 与模型 48 动态附表,无法把用户资料同步为旧 CRM 客户',
  1375. UserBlontoAdmin: '目标 Profile/_User 均没有旧 ZL_UserBase.Bugle 归属管理员字段及历史关系',
  1376. UserBlontoAdmin_Submit: '目标缺少 Bugle 归属管理员持久层,不能批量写入旧 AdminID',
  1377. UserLogin: '旧动作要求管理员掌握用户明文口令并写 ASP.NET 登录 Cookie;云函数会话不能安全模拟该密码代登流程',
  1378. AdminAdd: '旧 Manager 与当前 Parse _User 管理员会话尚无可靠一对一外键;仅新建 Manager 行不会生成可登录管理员',
  1379. AdminAdd_Submit: '目标缺少 Manager 与 Parse _User 的原子身份绑定、旧 MD5 密码迁移和管理员二级密码消费链,不能伪造开户成功',
  1380. Admin_API: '旧删除/锁定/审核同时操作 Manager、随机重置密码、读取服务器邮件模板并发送 SMTP;当前仅 _User 停用与会话撤销可部分覆盖,完整链未建立',
  1381. SendMailList: '目标 Schema 缺少旧邮件发送队列、投递日志、收件人与状态历史',
  1382. SubscriptListManage: '目标 Schema 缺少旧订阅主表、订阅用户与栏目关系数据',
  1383. Jobsconfig: '旧页面仅剩未交付的 Razor 视图,目标没有可识别的任务配置模型或运行时消费者',
  1384. ZoneConfig: '旧页面仅剩未交付的 Razor 视图,目标没有对应会员空间配置模型或前端消费者',
  1385. WSApi: '目标没有 SiteInfo.APIClose 旧 WebService 总开关及可受其控制的 SOAP/WS 运行时',
  1386. Department: '旧页面依赖未迁入的 ZL_Plat_Group 企业部门、MemberIDS 与 ManageIDS 关系',
  1387. Department_Submit: '目标缺少 Plat_Group 部门树及成员/管理员 ID 集合,不能修改用户部门关系',
  1388. WSApi_Submit: '云函数不能切换不存在的 SiteInfo.APIClose 或旧 SOAP/WS 服务宿主'
  1389. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧用户剩余动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1390. }
  1391. if (operation === 'schema' && String(input.className || '') === 'UserPromotion') {
  1392. const fields = [
  1393. ['objectId','String'],['userId','Number'],['userName','String'],['honeyName','String'],['parentUserId','Number'],['parentUserName','String'],['parentHoneyName','String'],['promotionCount','Number'],['regTime','Date']
  1394. ].map(([name,type]) => ({ name, type, writable: false }));
  1395. return response.json({ success: true, data: { className: 'UserPromotion', label: CLASS_LABELS.UserPromotion, fields, writable: false, creatable: false, supportsSoftDelete: false } });
  1396. }
  1397. if (operation === 'userPromotions' || operation === 'getUserPromotion') {
  1398. 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();
  1399. 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, '推广关系必须指定帐套');
  1400. 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); }
  1401. 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 } });
  1402. }
  1403. if (operation === 'legacyUserAddonBlocker') {
  1404. const action = String(input.action || ''); const blockers = {
  1405. ThirdInfo: '旧 ThirdInfo.config 的 Baidu/QQ/Sina/BaiduTrans/Wechat/Printer 全局配置与历史密钥未迁入目标数据库',
  1406. ThirdInfo_Submit: '目标 ThirdPlatInfo 只有版权印、飞印打印、百度翻译、头条号等不同业务平台,且云函数无旧 XML/OAuth 运行时消费者,不能伪造保存生效',
  1407. SigninList: '目标 Schema 缺少 PrizeTaskLog 及 rids=Global.SignIn 的用户签到任务日志;MisSign 是工作签到配置而非用户签到历史',
  1408. RealNameAuth: '目标 Schema 缺少 UserRealNameAuth 申请主表、审核状态与历史数据',
  1409. RealNameAuthAdd: '目标 Profile 中零散身份字段不等价于实名认证申请,缺少可审阅的申请详情',
  1410. RealNameAuth_API: '目标缺少实名申请记录及 Global.RealNameAuth 任务奖励链,无法安全审核、取消审核或删除'
  1411. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧用户附加功能动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1412. }
  1413. if (operation === 'legacyAskBlocker') {
  1414. const action = String(input.action || ''); const blockers = {
  1415. Default: '目标 Schema 缺少旧 ZL_Ask 问题主表、回收站标记与历史问题数据',
  1416. AskInfo: '目标无可定位的问答问题详情;Baike/Guestbook/GuestBar/DesignQuestion 均非等价问题主表',
  1417. AskAdd: '目标无 Ask 数据类承载 QTitle/Qcontent 与问答状态、精华、回收站关系',
  1418. AskAdd_Submit: '按既定数据库设计不能把问题错写到百科、留言、论坛或问卷题目',
  1419. Ask_API: '目标缺少可回收、恢复、删除、审核或加精的旧问答问题记录',
  1420. AnswerList: '目标 Schema 缺少旧 ZL_GuestAnswer 回答主表及 QueId 问题外键',
  1421. AnswerAdd: '目标无可编辑或新增的问答回答记录与问题归属',
  1422. AnswerAdd_Submit: '目标缺少 GuestAnswer 的 QueId/UserId/UserName/Content 数据模型与历史关系',
  1423. Answer_API: '目标缺少可审核、取消审核或删除的旧问答回答记录',
  1424. Config: '目标没有 GuestConfig.WDOption 问答运行时和 SelGroup/ReplyGroup/QuestGroup 权限消费者',
  1425. Config_Submit: '即使存储积分与用户组参数,目标也没有问答发布/回复/推荐运行时执行这些规则,不能伪造保存生效'
  1426. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧问答动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1427. }
  1428. if (operation === 'legacyBiaoCalcBlocker') {
  1429. const action = String(input.action || ''); const blockers = {
  1430. Default: '目标 Schema 缺少旧 BiaoCalcModel 裱件模型主表、状态与历史记录',
  1431. BiaoDetail: '目标无可定位的裱件详情及其 UserID 用户关系',
  1432. Biao_API: '目标缺少可启用、停用或删除的 BiaoCalcModel 裱件记录',
  1433. UserFiles: '目标 Schema 缺少旧 BiaoCalcUserFiles 用户文件主表、等级、到期时间与历史授权记录',
  1434. UserFiles_API: '目标无 BiaoCalcUserFiles.ExpireTime 可安全执行到期或按年月日续期;账户会员到期时间语义不等价'
  1435. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧裱件计算动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1436. }
  1437. if (operation === 'legacyOfficeChartBlocker') {
  1438. if (String(input.action || '') !== 'Index') fail(400, '不支持的旧 Office 图表动作');
  1439. fail(501, 'migration_blocked: 旧 Office/Chart.Index 只保留了返回视图的空壳控制器,交付包与官方源码均缺少对应 Index.cshtml 视图、数据源和交互定义,无法 1:1 复原');
  1440. }
  1441. if (operation === 'crmClientTypes') {
  1442. 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) } });
  1443. }
  1444. if (operation === 'saveCrmClientType') {
  1445. 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; }
  1446. 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)); }
  1447. 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)); }
  1448. 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) });
  1449. }
  1450. if (operation === 'crmClientTypeBatch') {
  1451. 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 } });
  1452. }
  1453. if (operation === 'legacyCrmBlocker') {
  1454. const action = String(input.action || ''); const blockers = {
  1455. 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 文件运行时,无法执行审核链'
  1456. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 CRM 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1457. }
  1458. if (operation === 'legacyCounterBlocker') {
  1459. const action = String(input.action || ''); const blockers = {
  1460. 'Design/VisitList': '目标 Schema 缺少旧 ZL_Com_VisitCount 访问明细表及 h5 访问记录',
  1461. 'Design/VisitInfoList': '目标没有可按 InfoID 聚合的 h5 访问明细和最近访问记录',
  1462. 'Design/VisitInfoDetail': '目标缺少指定 InfoID 的访问时间、IP、浏览器、系统、地域和来源明细',
  1463. 'Design/Counter_API': '目标没有可定位并删除的旧访问统计记录',
  1464. 'Extend/StatisticalCode': '目标缺少 SiteInfo.AllSiteJS 配置模型及在所有页面注入统计脚本的运行时',
  1465. 'Extend/StatisticalCode_Submit': '云函数不能仅保存任意全站脚本而没有可信的前端注入、CSP 与版本发布链',
  1466. 'Extend/Index': '目标 Schema 缺少 ZL_Com_VisitCount 数据,无法生成旧访问统计首页',
  1467. 'Extend/Site': '目标缺少全站访问明细与总访问计数',
  1468. 'Extend/Month': '目标缺少可按年月聚合的访问日期明细',
  1469. 'Extend/Year': '目标缺少可按年度和月份聚合的访问日期明细',
  1470. 'Extend/Local': '目标缺少访客地域 Address 明细',
  1471. 'Extend/Browser': '目标缺少访客浏览器标识与访问明细',
  1472. 'Extend/Os': '目标缺少访客操作系统标识与访问明细',
  1473. 'Extend/Channel': '目标缺少访问来源渠道与 Referer 明细'
  1474. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧访问统计动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1475. }
  1476. if (operation === 'legacyEChartsBlocker') {
  1477. const action = String(input.action || ''); const blockers = {
  1478. EChartList: '目标 Schema 缺少旧 Content_Chart 图表定义主表与历史图表',
  1479. ChartCite: '目标缺少图表名称、站点 SiteUrl 和 /Common/ShowM 公开渲染链,无法生成旧 iframe/Markdown 引用',
  1480. ShowM: '目标没有 Content_Chart option/package 数据与旧 ECharts ShowM 渲染页面',
  1481. AddChart: '目标缺少 Content_Chart 持久层和旧 Bar/Pie/Dash/Funnel/Scatter/Circle/Map 配置生成运行时',
  1482. AddChart_Submit: '目标无法持久化 TName、ChartTitle、ToolBox、Package、option、SType 与 Tag 图表定义',
  1483. Default: '目标没有可按 SType 筛选分页的旧 Content_Chart 记录'
  1484. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 ECharts 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1485. }
  1486. if (operation === 'legacyExamBlocker') {
  1487. const action = String(input.action || ''); const blockers = {
  1488. AddEngLishQuestion: '旧英语试题页依赖复合小题、音频/图片上传和答案解析器;目标仅保留只读 ExamSysQuestions 记录',
  1489. Setting: '目标 Schema 缺少旧 Exam_Sys_Answer 答卷明细,无法按最近一周统计并重算试题难度',
  1490. VersionList: '目标 Schema 缺少旧 Exam_Version 教材版本/册/章节树与历史数据', AddVersion: '目标无可定位或新建的 Exam_Version 版本树记录',
  1491. SchoolManage: '目标 Schema 缺少旧 School 学校主表与省市县数据', AddSchool: '目标无可编辑或新增的 School 记录',
  1492. ClassRoomManage: '目标 Schema 缺少旧 ClassRoom 班级主表与学校、年级、教师关系', AddClassRoom: '目标无可编辑或新增的 ClassRoom 记录',
  1493. StudentList: '目标 Schema 缺少 ClassRoomStudent 班级学员关系、审核状态与学员类型',
  1494. CourseManage: '目标 Schema 缺少旧 Course 课程主表;CourseBinding 是小树课程绑定,语义不等价', AddCourse: '目标无旧 Course 名称、科目、教师与课程结构记录',
  1495. ToScore: '目标 Schema 缺少旧 Exam_Sys_Answer 主观题答卷与评分状态',
  1496. Papers_Add: '试卷写入必须原子维护 qids/questList、分类、计时、价格与题目分值;当前只读迁移不允许通用表单破坏结构',
  1497. Question_Add: '旧试题写入依赖题型答案解析、复合小题、知识点和上传资源;当前只读迁移不能安全保存',
  1498. Version_GetList: '目标缺少可按 pid 查询的 Exam_Version 版本树', Version_Add: '目标缺少 Exam_Version 版本、册、章节持久层',
  1499. School_Add: '目标缺少 School 主表,无法保存学校类型、地区、校徽和状态', ClassRoom_Add: '目标缺少 ClassRoom 与学校、教师、年级关系持久层', Course_Add: '目标缺少旧 Course 主表和科目树关系',
  1500. Setting_Update: '目标缺少 Exam_Sys_Answer 作答统计,不能执行 CountDiffcult 难度重算',
  1501. PublishDesign: '旧报刊可视化设计器的前端资产、布局协议与渲染运行时未迁入'
  1502. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧考试动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1503. }
  1504. if (operation === 'legacyHelperBlocker') {
  1505. const action = String(input.action || ''); const blockers = {
  1506. Index: '旧动作会读取服务器 DBConfig.json、等待三秒并执行本地 SQL 脚本建表;云函数环境没有该文件运行时,目标数据类也已由受控迁移预置',
  1507. HelpInfo: '目标 Schema 缺少旧 Notification 帮助信息数据,Angular/云函数也没有旧 SignalR 管理员分组与 HelpInfo 推送协议'
  1508. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧帮助动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1509. }
  1510. if (operation === 'legacyHmsBlocker') {
  1511. const action = String(input.action || ''); const blockers = {
  1512. UList: '目标用户数据没有旧 ZL_UserBase.cardType 与 ICQ 到期时间字段,不能列出主题大师 VIP 卡用户',
  1513. VipAdd: '目标用户数据没有旧 cardType 空值状态,不能判定可开通主题大师 VIP 卡的用户',
  1514. VipAddJump: '旧七档主题大师卡与当前小树 VIP 合伙人等级不是同一业务,不能复用 VIP 字段',
  1515. VipUpdate: '目标未保留旧 cardType 与 ICQ 卡片到期时间,无法还原编辑页',
  1516. VipUpdate_Submit: '目标未保留旧 cardType/ICQ,不能安全写入卡种或按原到期日、当前时间重算期限',
  1517. VipRenewal: '目标没有旧主题大师卡到期时间,无法展示续约上下文',
  1518. VipRenewal_Submit: '目标没有旧 cardType/ICQ,不能按卡种天数或指定日期续约',
  1519. VipOverdue: '目标没有旧主题大师卡到期时间,无法展示强制过期上下文',
  1520. VipOverdue_Submit: '目标没有旧 ICQ 到期时间,不能执行强制过期且不能误写当前 VIP 合伙人字段',
  1521. OList: '目标 Schema 缺少旧 ZL_Orderinfo 与 ZL_CartPro,无法查询主题大师 VIP 卡订单'
  1522. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 Hms 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1523. }
  1524. if (operation === 'legacyIndexBlocker') {
  1525. const action = String(input.action || ''); const blockers = {
  1526. Scence: '旧桌面布局与主题写入 SiteConfig.SiteOption.Desk 和 B_Admin.Theme/StructureID;目标没有等价站点配置或管理员主题字段',
  1527. Lockin: '旧锁屏依赖 B_Admin.LockScreen、明文口令再认证和 ASP.NET Session;目标没有持久化锁屏状态,不能把停用账号冒充屏幕锁',
  1528. Lockin_Submit: '旧提交动作为空且锁屏状态依赖 ASP.NET Session/B_Admin,云函数没有可等价提交的数据',
  1529. ASCXLoad: '旧动作按请求名称动态装载服务器 Razor/ASCX 局部视图;Angular 构建产物不允许运行时拼接并执行任意服务端模板路径',
  1530. API: '旧 animate_start/animate_stop 写入全局 SiteConfig.SiteOption.Admin_ShowAnimate;目标没有该站点级持久化配置',
  1531. AccountForm: '旧公开管理员申请受 SiteOption.RegManager 控制并创建默认锁定、高权限角色账号;目标没有审批开关和管理员申请模型,不能开放不安全的自助管理员入口',
  1532. AccountForm_API: '目标没有管理员申请开关与待审批账号模型,不能提供公开管理员用户名/邮箱探测接口',
  1533. AccountForm_Submit: '旧动作生成随机密码并直接授予固定角色 2/4/6/7/8;目标权限模型不等价且没有审批链,不能安全创建管理员申请'
  1534. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧后台入口动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1535. }
  1536. if (operation === 'legacyMarkdownBlocker') {
  1537. const action = String(input.action || ''); const blockers = {
  1538. Index: '目标 Schema 缺少旧 MarkDown 主表、MDName/MDPath/MDStatus/MDPosition 等历史记录', MD: '目标没有可新建或编辑的 MarkDown 记录', Index_submit: '目标缺少 MarkDown 持久层,当前 Parse 文件适配器也不能替代旧 wwwroot/UploadFiles/MarkDown 本地上传目录', Dels: '目标没有 Markdown 记录与对应服务器文件可同步删除', Preview: '目标缺少 MarkDown 文件、SiteInfo.SiteUrl 与旧 /PreView/md 渲染运行时'
  1539. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 Markdown 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1540. }
  1541. if (operation === 'legacyMessageBlocker') {
  1542. const action = String(input.action || ''); const blockers = {
  1543. Message: '目标 Schema 缺少旧 Message 站内信主表与收发历史;MailTemp 仅是邮件模板,语义不等价', MessageSend: '目标没有可编辑的站内信草稿、收件人、抄送和附件记录', Message_Add: '目标缺少站内信发送与草稿持久层,不能保存 Sender/Incept/CCUser/Attachment', MessageRead: '目标没有站内信详情与已读状态记录', Message_API: '目标没有可删除的站内信记录'
  1544. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧站内信动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1545. }
  1546. if (operation === 'legacyMobileBlocker') {
  1547. const action = String(input.action || ''); const blockers = {
  1548. Index: '旧动作只有已丢失的 Mobile Razor 视图,发布包没有页面结构、交互或资源基线可恢复', MobileBrower: '旧移动浏览器预览页只有已丢失的 Razor 视图,目标没有对应渲染运行时'
  1549. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 Mobile 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1550. }
  1551. if (operation === 'legacyNodeRemainderBlocker') {
  1552. const action = String(input.action || ''); const blockers = {
  1553. ZhiDing: '旧动作会把帐套内全部 ZStatus=99 栏目强制设为 depth=1、parentId=0,破坏现有规范化栏目树;现有 saveNode/nodeBatch 已在每次移动时安全维护深度', UnionNode_Merge: '旧合并同时改写 CommonModel 与未迁入的 ZL_Commodities 商品节点,并可删除来源栏目;缺商品主表时不能执行非原子半合并'
  1554. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧栏目剩余动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1555. }
  1556. if (operation === 'platformCompanies') {
  1557. 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 } });
  1558. }
  1559. if (operation === 'savePlatformCompany') {
  1560. 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) });
  1561. }
  1562. if (operation === 'platformCompanyMembers') {
  1563. 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 } });
  1564. }
  1565. if (operation === 'platformCompanyMemberBatch') {
  1566. const companyObjectId = String(input.companyObjectId || '');
  1567. const action = String(input.action || '');
  1568. if (!companyObjectId || !['add','remove'].includes(action)) fail(400, '不支持的企业成员操作');
  1569. const fields = await schemaFor('PlatComp');
  1570. const companyQuery = new Parse.Query('PlatComp');
  1571. applyTenant(companyQuery, fields, context, input.companyId);
  1572. const platformCompany = await companyQuery.get(companyObjectId, { useMasterKey: true });
  1573. const compId = Number(platformCompany.get('id')) || 0;
  1574. const companyId = pointerId(platformCompany.get('company')) || pointerId(context.company);
  1575. if (!compId || !companyId) fail(409, '企业缺少旧编号或帐套');
  1576. let targets = [];
  1577. if (action === 'add') {
  1578. const usernames = [...new Set((Array.isArray(input.usernames) ? input.usernames : [input.username]).map((value) => String(value || '').trim()).filter(Boolean))];
  1579. if (!usernames.length || usernames.length > 100 || usernames.some((value) => !/^[A-Za-z0-9_@.\-\u4e00-\u9fff]{2,64}$/.test(value))) fail(400, '每次请输入 1 至 100 个有效成员账号');
  1580. targets = await Psql.query('SELECT "objectId","username","mobile","avatar","realName","nickname","legacyUserId","legacyUserData","legacyUserPlat" FROM "_User" WHERE "company"=$1 AND LOWER("username")=ANY($2::text[]) AND ("isDeleted" IS NULL OR "isDeleted"=FALSE)', [companyId, usernames.map((value) => value.toLowerCase())]);
  1581. if (targets.length !== usernames.length) fail(404, '部分成员账号不存在或不属于当前帐套');
  1582. for (const target of targets) {
  1583. const legacy = target.legacyUserData || {};
  1584. const userId = Number(target.legacyUserId || legacy.UserID || 0);
  1585. if (!userId) fail(409, '成员账号缺少旧用户 ID');
  1586. const existing = target.legacyUserPlat;
  1587. const platform = existing && typeof existing === 'object' && !Array.isArray(existing) ? { ...existing } : { Post:'', Mobile:String(target.mobile || ''), Status:1, UserID:userId, ATCount:null, TrueName:String(target.realName || target.nickname || legacy.HoneyName || target.username || ''), UserFace:String(target.avatar || ''), Plat_Role:'', CreateTime:new Date().toISOString(), Plat_Group:null };
  1588. platform.UserID = userId;
  1589. platform.CompID = compId;
  1590. if (!platform.CreateTime) platform.CreateTime = new Date().toISOString();
  1591. await Psql.query('UPDATE "_User" SET "legacyUserPlat"=$3::jsonb,"updatedAt"=NOW() WHERE "company"=$1 AND "objectId"=$2', [companyId, target.objectId, JSON.stringify(platform)]);
  1592. }
  1593. } else {
  1594. const objectIds = [...new Set((Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]).map((value) => String(value || '').trim()).filter(Boolean))];
  1595. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个企业成员');
  1596. targets = await Psql.query('SELECT "objectId","legacyUserPlat" FROM "_User" WHERE "company"=$1 AND "objectId"=ANY($2::text[]) AND ("isDeleted" IS NULL OR "isDeleted"=FALSE)', [companyId, objectIds]);
  1597. if (targets.length !== objectIds.length || targets.some((target) => Number((target.legacyUserPlat || {}).CompID || 0) !== compId)) fail(404, '部分成员不存在或不属于该企业');
  1598. await Psql.query('UPDATE "_User" SET "legacyUserPlat"=NULL,"updatedAt"=NOW() WHERE "company"=$1 AND "objectId"=ANY($2::text[])', [companyId, objectIds]);
  1599. }
  1600. for (const target of targets) await audit({ ...context, company: platformCompany.get('company') || context.company }, action + '-platform-company-member', '_User', target.objectId);
  1601. return response.json({ success:true, data:{ action, updated:targets.length } });
  1602. }
  1603. if (operation === 'platformCompanyBatch') {
  1604. 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 } });
  1605. }
  1606. if (operation === 'legacyPlatBlocker') {
  1607. 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]);
  1608. }
  1609. if (operation === 'legacyPrinterBlocker') {
  1610. const action = String(input.action || '');
  1611. if (action !== 'Index') fail(400, '不支持的旧打印动作');
  1612. fail(501, 'migration_blocked: 旧 PrinterController.Index 只返回已丢失的 Razor 视图;目标 110 个 Schema 中没有旧 ZL_Shop_PrintDevice、ZL_Shop_PrintTlp、ZL_Shop_PrintMessage 数据,旧客户端还依赖 my.feyin.net 的明文 HTTP 设备协议,无法安全等价恢复');
  1613. }
  1614. if (operation === 'pageStyles') {
  1615. 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) } });
  1616. }
  1617. if (operation === 'savePageStyle') {
  1618. 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) });
  1619. }
  1620. if (operation === 'pageStyleBatch') {
  1621. 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 } });
  1622. }
  1623. if (operation === 'legacyPageBlocker') {
  1624. const action = String(input.action || ''); const blockers = {
  1625. 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 申请主表、黄页模型附表和用户申请字段'
  1626. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧黄页动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1627. }
  1628. if (operation === 'misTypes') {
  1629. 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) } });
  1630. }
  1631. if (operation === 'saveMisType') {
  1632. 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; }
  1633. 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) });
  1634. }
  1635. if (operation === 'misTypeBatch') {
  1636. 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 } });
  1637. }
  1638. if (operation === 'legacyOABlocker') {
  1639. const action = String(input.action || ''); const blockers = {
  1640. Default: '目标缺少旧 OA_AuditFlow 审核流主表与历史数据', FlowInfo: '目标没有旧审核流名称、模型、模板、角色用户和会签配置记录', FlowInfo_Submit: '目标缺少 OA_AuditFlow 持久层,MisProcedure 是不同版本流程结构', Flow_API: '目标没有可删除的旧审核流记录', SelFlowModel: '目标缺少旧审核流可绑定的 Mis_Model 与套红模板关系', FlowItem: '目标缺少 OA_AuditFlowItem 审核步骤表', FlowItemInfo: '目标没有审核步骤、绑定用户、父步骤、会签、转交和退回记录', FlowItemInfo_Submit: '目标缺少审核步骤持久层', FlowItem_API: '目标没有可删除的审核步骤记录',
  1641. 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 是不等价的考勤时间表'
  1642. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 OA 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1643. }
  1644. if (operation === 'legacyDesignInteractiveBlocker') {
  1645. const action = String(input.action || ''); const blockers = {
  1646. InteractiveList: '目标 Schema 缺少旧 Design_Pub 交互提交表及 sceneid、uid、fname 数据;DesignAnswer 和 Pub 语义不等价',
  1647. Interactive_API: '目标没有可按旧 Design_Pub ID 删除的交互提交记录'
  1648. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧互动提交动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1649. }
  1650. if (operation === 'legacyIServerBlocker') {
  1651. const action = String(input.action || ''); const blockers = {
  1652. Default: '目标 Schema 缺少旧 IServer 服务工单主表与回复表', AddQuestionRecord: '目标无可新增的 IServer 工单、CRM 关联和附件模型', AddQuestionRecord_Submit: '目标缺少工单标题、内容、优先级、类型、来源、状态、附件及 CRM 关系持久层',
  1653. BiServer: '目标没有可按状态、类型和标题筛选的 IServer 工单数据', BiServer_Del: '目标没有可删除的 IServer 工单记录', BiServerInfo: '目标缺少工单详情、阅读计数及回复关系', DelIServer: '目标缺少 IServerReply 回复记录', UpdateIServer: '目标缺少工单状态、优先级、来源、类型、备注和解决时间持久层', BselectiServer: '目标没有可供 CRM 选择的旧服务工单数据', ISReplyAdd: '目标缺少工单回复主表与父工单关系', ISReplyAdd_Submit: '目标缺少管理员工单回复、附件和内容持久层'
  1654. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 iServer 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1655. }
  1656. if (operation === 'legacyLicenceBlocker') {
  1657. const action = String(input.action || ''); const blockers = {
  1658. LicenceFiles: '目标 Schema 缺少 LicenceFiles 协议文件定义与限定用户数据', LicenceFiles_API: '目标没有可删除的协议文件定义', LicenceFilesAdd: '目标无协议模板路径、字段、状态、限制类型和限定用户模型', LicenceFilesAdd_Submit: '目标缺少协议文件定义持久层且云函数不能写旧本地模板文件',
  1659. LicenceSignApplyList: '目标缺少 Common_UserApply 的 user_signlicence 申请数据', LicenceSignApplyDetail: '目标缺少许可签署申请、用户和协议文件关联', LicenceSignApply_API: '目标缺少申请审核、签署记录与服务器 PDF 生成/本地文件哈希归档运行时', LicenceSignList: '目标 Schema 缺少 LicenceUserSign 签约记录', LicenceSignDetail: '目标缺少签约详情、协议文件和用户关联', LicenceSign_API: '目标没有可审核或驳回的签约记录',
  1660. LicenceList: '目标 Schema 缺少 ZL_LicenceFiles 用户协议定义、强制签署类型与限定用户/用户组数据', LicenceAdd: '目标没有可编辑或新增的用户协议、PDF 路径、字段映射和适用范围记录', LicenceAdd_Submit: '目标缺少 LicenceFiles 持久层及协议 PDF 文件,不能只保存失效路径', Licence_API: '目标没有可启用、停用或删除的用户协议定义', LicenceUserSign: '目标 Schema 缺少 ZL_LicenceUserSign 用户签约历史及协议、用户关联'
  1661. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧许可签约动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1662. }
  1663. if (operation === 'legacyUserPromoBlocker') {
  1664. const action = String(input.action || ''); const blockers = {
  1665. 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: '目标没有可删除、审核或取消审核的提现账户记录'
  1666. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧推广提现动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1667. }
  1668. if (operation === 'legacyPubBlocker') {
  1669. const action = String(input.action || ''); const blockers = {
  1670. 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 定义及父提交记录'
  1671. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧互动模块动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1672. }
  1673. if (operation === 'legacyPushBlocker') {
  1674. const action = String(input.action || ''); const blockers = {
  1675. 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 发送历史、受众与结果记录'
  1676. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧推送动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1677. }
  1678. if (operation === 'legacyPWABlocker') {
  1679. const action = String(input.action || ''); const blockers = {
  1680. 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'
  1681. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 PWA 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1682. }
  1683. if (operation === 'legacyQuartzTaskBlocker') {
  1684. const action = String(input.action || ''); const blockers = {
  1685. 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 调度器或可恢复作业'
  1686. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 Quartz 任务动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1687. }
  1688. if (operation === 'legacySafeBlocker') {
  1689. const action = String(input.action || ''); const blockers = {
  1690. Default: '旧默认入口只跳转到 SiteSwitch;目标没有可切换已部署 Angular 站点的安全控制面',
  1691. API: '旧聚合 API 同时依赖缺失 Safe_IP 记录、五秒服务器站点检查局部页与常驻 ContentWordHelper 扫描线程',
  1692. SiteSwitch: '目标没有旧站点开关状态、下线页文件或能控制静态前端发布的运行时',
  1693. SiteIP: '目标 Schema 缺少旧 Safe_IP 白/黑名单、到期时间与管理员历史记录',
  1694. SiteIPAdd: '目标没有可编辑或新建的 Safe_IP IP 规则记录',
  1695. SiteIPAdd_Submit: '目标缺少 Safe_IP 持久层及在请求入口实际消费 IP 规则的中间件',
  1696. ContentProtect: '旧内容保护页仅剩已丢失的 Razor 视图,目标没有签名、防复制或防篡改配置与消费链',
  1697. SiteAssess: '旧站点安全评估页仅剩已丢失视图,交付包没有评分规则、基线或历史结果',
  1698. SiteProtect: '目标没有 SiteOption.Safe_ContentCheck_Models 站点配置模型及内容写入时的扫描消费层',
  1699. SiteProtect_Submit: '仅保存模型 ID 字符串不会产生内容保护;目标缺少旧 SiteConfig.Update 及扫描运行时',
  1700. ContentCheck: '目标没有 ContentWordHelper 常驻扫描任务、进度结果缓存和待检内容模型范围',
  1701. ContentCheckConfig: '云函数运行时没有旧 IllegalWordsVerify.keywordsVPath 非法词文件与编辑视图',
  1702. ContentCheckConfig_Submit: '云函数不能向旧服务器非法词文件执行 SafeC.WriteUnSafeFile,且当前内容接口没有该文件消费链',
  1703. ContentFilter: '旧内容过滤页仅剩已丢失视图,目标没有可证明等价的替换/阻断规则及执行层',
  1704. SiteCheck: '旧站点检查依赖服务器站点文件、五秒后局部视图与丢失的检查规则,云函数无法还原'
  1705. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧站点安全动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1706. }
  1707. if (operation === 'legacyStructureBlocker') {
  1708. const action = String(input.action || ''); const blockers = {
  1709. Default: '旧入口只重定向组织结构树;目标 Schema 缺少 ZL_Structure/Structure 主表,无法提供等价落点',
  1710. StructList: '目标 Schema 缺少 ZL_Structure 的 Name、ParentID、OrderID、UserIDS、ManagerIDS、Auth、Remind 与 Struct_Exp 历史数据',
  1711. order: '目标没有可按父节点读取并连续重排 OrderID 的组织结构记录',
  1712. order_submit: '目标没有可按旧结构 ID 更新 OrderID 的组织结构记录',
  1713. Struct_API: '旧删除、成员增删和管理员设置依赖 ZL_Structure.UserIDS/ManagerIDS 及 ZL_User.StructureID 双向关系;目标两侧均未迁入',
  1714. StructByTree: '目标没有 ParentID 层级、组织成员与管理员关系,不能还原组织结构树',
  1715. Struct_ListByTree: '目标没有可递归展开并按 OrderID 排序的 ZL_Structure 记录',
  1716. StructAdd: '目标没有可编辑或新增的组织名称、上级、提醒、权限、成员和管理员记录',
  1717. StructAdd_Submit: '目标缺少 ZL_Structure 持久层及与用户 StructureID 同步的事务链',
  1718. StructMember: '目标缺少 UserIDS/ManagerIDS 组织成员关系,Profile 与 Group 均不是旧组织结构成员表',
  1719. Structure_update: '目标没有可供树形拖动和批量重排的组织结构记录',
  1720. Structure_Merge: '目标缺少可原子合并 UserIDS、ManagerIDS、Struct_Exp 并可选删除来源结构的数据与用户关系',
  1721. Structure_Move: '目标没有可校验循环并更新 ParentID 的组织结构树',
  1722. Structure_List: '目标 Schema 缺少旧 ZL_Structure 平铺结构列表及其历史记录'
  1723. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧组织架构动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1724. }
  1725. if (operation === 'legacyVueBlocker') {
  1726. const action = String(input.action || ''); const blockers = {
  1727. Index: '目标 Schema 缺少旧 ZL_Vue_Tlp 模板代码表与历史模板记录',
  1728. TlpView: '目标既缺少 ZL_Vue_Tlp.tlp 模板路径,也没有旧 SiteOption.TemplateDir 文件、B_CreateHtml 标签渲染器和 Razor 预览运行时',
  1729. VUE_API: '旧 list/save/del 依赖缺失的 ZL_Vue_Tlp;release/cli 还需服务器模板文件、标签渲染器、vue.tlp 与目录复制权限,云函数无法等价执行',
  1730. Config: '目标没有 SiteOption.VUE_DirCopy 配置模型及使用该目录的服务器发布运行时',
  1731. Config_Submit: '仅保存目录字符串不会产生 Vue 发布能力;云函数不能更新旧 SiteConfig 或复制服务器目录'
  1732. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 Vue 模板动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1733. }
  1734. if (operation === 'legacyWorkloadBlocker') {
  1735. const action = String(input.action || ''); const blockers = {
  1736. Default: '旧 GroupDayCount 对 ZL_CommonModel 与 ZL_Comment 按 GeneralID 做内连接,再按年月日、栏目、模型和录入人聚合内容数、评论数与点击数;目标仅有 CommonModel,完全缺少 Comment 主表与历史评论关系,不能用内容数量伪造原报表'
  1737. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧工作量统计动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1738. }
  1739. if (operation === 'legacyVBookBlocker') {
  1740. const action = String(input.action || ''); const blockers = {
  1741. Default: '旧入口只重定向 H5 电子书列表;目标 Schema 缺少 ZL_H5_Item/H5Item 主表',
  1742. Add: '目标没有可编辑或新增的 H5 电子书标题、封面和模板关系记录',
  1743. Add_Submit: '目标缺少 ZL_H5_Item 持久层,不能保存电子书标题与 coverImage',
  1744. List: '目标没有可按标题和模板筛选的 H5 电子书历史数据',
  1745. API: '目标没有可删除或执行 SetTemplate 的 H5 电子书记录及模板关联',
  1746. Best: '目标没有可按旧 id 更新 isbest 推荐状态的 ZL_H5_Item 记录'
  1747. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧电子书动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1748. }
  1749. if (operation === 'legacyVdesignBlocker') {
  1750. const action = String(input.action || ''); const blockers = {
  1751. Default: '目标 Schema 缺少旧 ZL_Vdesign 应用主表;DesignSiteInfo 属于另一套 ZL_Design_SiteInfo,字段和生命周期不等价',
  1752. SiteAdd: '目标没有可编辑的 ZL_Vdesign 应用名称、评分、状态、所有者和模板关系记录',
  1753. SiteAdd_Submit: '目标缺少 ZL_Vdesign 与 VdesignSiteChangeLog,不能原子保存应用并写变更日志',
  1754. SiteArticle: '目标缺少 ZL_Vdesign_Article 站点—内容绑定表,无法还原应用文章列表',
  1755. SiteShop: '目标同时缺少 ZL_Vdesign_Shop 绑定表和 ZL_Commodities 商品主表',
  1756. SiteMessage: '目标 Schema 缺少 ZL_Vdesign_Message 站点留言及审核状态历史',
  1757. Site_API: '旧启停/删除需同步 Vdesign 变更日志,留言审核/删除和文章/商品解绑还依赖四张未迁入表',
  1758. SiteBoard: '目标 Schema 缺少 ZL_VdesignTemplate 模板主表;DesignTlp 是另一套可视化场景模板',
  1759. SiteBoardAdd: '目标没有可编辑的 Vdesign 模板名称、图片、价格、积分、状态、类型和备注记录',
  1760. SiteBoardAdd_Submit: '目标缺少 ZL_VdesignTemplate 持久层,不能把旧应用模板错写进 DesignTlp',
  1761. Board_API: '目标没有可删除、审核、启停、推荐或换图的 Vdesign 模板记录',
  1762. SiteBoardType: '目标 Schema 缺少 ZL_VdesignTemplateType 模板分类树',
  1763. SiteBoardTypeAdd: '目标没有可编辑或新增的 Vdesign 模板分类记录',
  1764. SiteBoardTypeAdd_Submit: '目标缺少模板分类 Name/ParentID/OrderID/Remarks 持久层',
  1765. BoardType_API: '目标没有可列出或递归删除的 Vdesign 模板分类树',
  1766. SiteChangeLog: '目标 Schema 缺少 ZL_VdesignSiteChangeLog 应用变更历史',
  1767. SiteChangeLog_API: '旧动作虽为空分支,但对应变更日志数据与管理页面均未迁入,不能伪报完整能力',
  1768. TemplateBuyLog: '目标 Schema 缺少 ZL_VdesignTemplateBuyLog 模板购买历史及用户、站点、模板关系',
  1769. TemplateBuyLog_API: '旧动作虽为空分支,但购买日志数据与管理页面均未迁入,不能伪报完整能力'
  1770. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 Vdesign 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1771. }
  1772. if (operation === 'legacySPageBlocker') {
  1773. const action = String(input.action || ''); const blockers = {
  1774. SPage: '目标 Schema 缺少旧 ZL_SPage_Page 单页主表及 PageName/Layouts/PageRes/PageBottom/PageDSLabel/ViewUrl 数据',
  1775. SPageAdd: '目标没有可编辑或新增的 SPage 页面记录;DesignPage 是另一套绝对定位可视化页面,结构不等价',
  1776. SPageAdd_Submit: '目标缺少 ZL_SPage_Page 持久层,不能保存旧单页元数据',
  1777. SPage_API: '目标没有可复制或删除的 SPage 页面记录',
  1778. SPageEdit: '目标缺少 SPage 的 Layouts、PageRes、PageBottom 与数据源标签内容',
  1779. SPageEdit_Submit: '目标没有可按旧页面 ID 保存 Layouts 的 ZL_SPage_Page 记录',
  1780. SPage_AddComponent: '旧组件选择器 Razor 视图和组件目录未交付,目标没有等价组件协议',
  1781. SPage_AddLayout: '旧布局选择器 Razor 视图和布局定义未交付,目标没有等价布局协议',
  1782. SPageDesign: '目标缺少 SPage 数据,并没有 B_CreateHtml 标签解析器、旧编辑器资产和预览路由运行时',
  1783. SPageDesign_API: '旧接口需 Base64 解码任意数据源标签后交给 B_CreateHtml 按内容上下文执行;云函数没有该标签运行时',
  1784. SPageDesign_Nav: '旧导航组件视图与序列化协议未交付',
  1785. SPageDesign_Label: '旧标签组件依赖 Zoomla 标签库、选择视图与 B_CreateHtml,目标均不存在',
  1786. SPageDesign_Content: '旧内容组件视图及动态模型/栏目绑定协议未交付',
  1787. SPageDesign_Carousel: '旧轮播组件视图、素材与序列化协议未交付',
  1788. SPagePreview: '目标缺少 SPage 页面数据和 B_CreateHtml 标签渲染环境,无法生成旧预览'
  1789. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 SPage 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1790. }
  1791. if (operation === 'legacyWritePearlBlocker') {
  1792. const action = String(input.action || ''); const blockers = {
  1793. Default: '目标 Schema 缺少 ZL_Write_Copybook 字帖主表及状态、名称、内容和备注历史',
  1794. CopybookAdd: '目标没有可编辑的旧字帖记录',
  1795. CopybookAdd_Submit: '目标缺少字帖 Name/Content/Remarks 持久层',
  1796. Copybook_API: '目标没有可删除、启用或停用的字帖记录',
  1797. Manuscripts: '目标 Schema 缺少 ZL_Write_Manuscripts 文稿主表及状态历史',
  1798. ManuscriptsAdd: '目标没有可编辑的旧文稿记录',
  1799. ManuscriptsAdd_Submit: '目标缺少文稿 Name/Remarks 持久层',
  1800. Manuscripts_API: '目标缺少文稿与 ZL_Write_Word 字词子表,不能级联删除或启停',
  1801. UserFiles: '目标 Schema 缺少 ZL_Write_UserFiles 用户书写文件授权、等级与 ExpireTime 历史',
  1802. UserFiles_API: '目标没有可到期或按年月日续期的书写文件授权记录;账户会员期限语义不等价'
  1803. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧书法珍珠动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1804. }
  1805. if (operation === 'legacyLogBlocker') {
  1806. const action = String(input.action || ''); const blockers = {
  1807. TxtLog: '云函数运行时没有旧 /Log/{LogType}/ 本地日志目录', TxtLogContent: '云函数不能按文件名读取旧服务器文本日志', TxtLog_Down: '云函数没有旧服务器日志文件可下载',
  1808. TaskList: '现有 SenTask 是空的发送任务类,不含旧 Content_ScheTask 的 TaskName/TaskContent/ExecuteType/Interval/TaskFlag 数据', TaskAdd: '目标缺少旧调度任务模型与 Hangfire 控制面', TaskAdd_API: '目标不能复制旧任务并向 Hangfire 注册 RecurringJob', TaskAdd_Submit: '目标缺少旧任务持久层、本地脚本校验与 Hangfire 调度运行时', Task_API: '目标不能对不存在的 Hangfire 任务执行删除、停用、启用或立即执行', TaskCenter: '云函数环境没有旧 Hangfire Dashboard 管理页面'
  1809. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧日志任务动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1810. }
  1811. if (operation === 'systemLogs') {
  1812. 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();
  1813. 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');
  1814. 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; };
  1815. 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) } });
  1816. }
  1817. if (operation === 'shopFareTemplates') {
  1818. 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) } });
  1819. }
  1820. if (operation === 'saveShopFareTemplate') {
  1821. 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; }
  1822. 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)); }
  1823. 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)); }
  1824. 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) });
  1825. }
  1826. if (operation === 'shopFareTemplateBatch') {
  1827. 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 } });
  1828. }
  1829. if (operation === 'legacyShopExpBlocker') {
  1830. const action = String(input.action || ''); const blockers = {
  1831. OrderSend: '目标 Schema 缺少旧 OrderList 订单主表与 Order_Exp 物流单', OrderSend_Submit: '目标缺少订单物流状态、物流单、用户收件手机、SMSConfig 发货模板和 SafeMobile 日志链',
  1832. Factory: '目标 Schema 缺少旧 Manufacturers 厂商主表与历史厂商数据', FactoryAdd: '目标无可编辑或新增的 Manufacturers 厂商记录', FactoryAdd_Submit: '目标缺少厂商名称、简称、地址、电话、传真、网站、分类、图片和介绍持久层', Factory_API: '目标没有可删除或启用的厂商记录', Factory_select: '目标没有可按 Producername 搜索选择的厂商数据',
  1833. Trademark: '目标 Schema 缺少旧 Trademark 品牌主表与历史品牌数据', TrademarkAdd: '目标无可编辑或新增的 Trademark 品牌记录', Trademark_Submit: '目标缺少品牌名称、厂商、分类、启用/置顶/推荐、图片和介绍持久层', Trademark_API: '目标没有可删除或启用的品牌记录', Trademark_select: '目标没有可按 Trname 搜索选择的品牌数据'
  1834. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧商城配送动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1835. }
  1836. if (operation === 'legacyChatBlocker') {
  1837. const action = String(input.action || ''); const blockers = {
  1838. Default: '目标 Schema 缺少 ChatMsg 消息表,无法还原用户消息数列表',
  1839. UserChatHistory: '目标缺少 ChatMsg、ChatGroup 与个人/群组会话关系,无法聚合聊天对象',
  1840. UserFriend: '目标缺少旧 User_Friend 好友关系与历史数据;UserFriendGroup 只是好友分组,不是好友关系',
  1841. Group: '目标 Schema 缺少 ChatGroup 群组主表与历史群组',
  1842. GroupAdd: '目标无可定位和编辑的 ChatGroup 群组记录',
  1843. GroupAdd_Submit: '目标缺少 ChatGroup/ChatGroupUser 群主、管理员与成员关系,无法保持旧更换群主逻辑',
  1844. GroupUser: '目标 Schema 缺少 ChatGroupUser 群成员和管理员记录',
  1845. MessageHistory: '目标缺少 ChatMsg 个人/群组消息、发送者与接收者关系',
  1846. Config: '目标没有旧 RoomConfig TRTC AppID/SecretKey 配置持久层和聊天运行时',
  1847. Config_Submit: '目标没有消费 TRTC 密钥的聊天服务,保存配置不会生效且会伪造迁移完成',
  1848. Chat_API: '目标缺少 ChatMsg/ChatGroup/ChatGroupUser 可删除、启停或授权的旧聊天记录'
  1849. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧聊天动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1850. }
  1851. if (operation === 'legacyDesignClassBlocker') {
  1852. const action = String(input.action || ''); const blockers = {
  1853. ClassList: '目标 Schema 缺少 DesignTlpClass/Design_TlpClass 模板分类主表与历史分类数据',
  1854. ClassAdd: '目标无可定位或新建的模板分类数据类;DesignTlp 仅保留数字 classId 引用',
  1855. ClassAdd_Submit: '历史 DesignTlp 引用 classId=6,但分类 6 的名称、父级、排序和说明已丢失,不能伪造保存',
  1856. Class_API: '目标无可删除的模板分类记录,且直接处理 classId=6 会使既有 DesignTlp 引用悬空'
  1857. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧模板分类动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1858. }
  1859. if (operation === 'legacyComBlocker') {
  1860. const action = String(input.action || ''); const blockers = {
  1861. SPwdCheck: '目标管理员会话没有旧 Manager.RandNumber 二级密码验证状态和业务消费者,不能伪造 SPWD Session',
  1862. Import: '目标与云函数运行时缺少旧 /Config/Import/{mode}.xml 导入定义',
  1863. Import_DownTlp: '缺少旧 XML 字段别名、隐藏字段、类型与默认值规则,无法等价生成 xlsx 模板',
  1864. Import_Submit: '云函数没有旧 XML+NPOI 动态表导入流水线,也不允许按客户端表名直接插入',
  1865. ImportForContent: '目标缺少旧动态模型附表结构与通用 xlsx 内容导入页',
  1866. ImportForContent_DownTlp: '目标无法从旧模型动态附表还原完整 xlsx 字段模板',
  1867. ImportForContent_Submit: '目标缺少动态模型附表批量写入流程,商品分支还缺少 ZL_Commodities 主表'
  1868. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧通用后台动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1869. }
  1870. if (operation === 'legacyCommonBlocker') {
  1871. const action = String(input.action || ''); const blockers = {
  1872. UserBaseField: '目标缺少旧 B_UserBaseField 动态字段定义与 ZL_UserBase 原始扩展行;Profile 不是等价的动态字段模型',
  1873. ContentField: '目标只保留 CommonModel 主记录与部分规范化副表,缺少按 TableName/ModelID 动态解析任意旧内容附表的数据与渲染层',
  1874. SelUploadFiles: '旧交付包与官方源码均缺少 SelUploadFiles 视图,且当前 PostgreSQL Parse 文件适配器无法完成上传/选择流程'
  1875. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧公共动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1876. }
  1877. if (operation === 'legacySiteConfigBlocker') {
  1878. const action = String(input.action || ''); const blockers = {
  1879. Index: '旧配置首页只跳转 SiteInfo,目标没有签约的统一站点配置模型与运行时',
  1880. SiteInfo: '目标缺少 SiteConfig.SiteInfo/SiteOption 站名、Logo、站长、版权、全站 JS 与后台目录配置模型',
  1881. SiteInfo_Submit: '云函数无法更名 ASP.NET 后台路径或通过 web.config 重启宿主,也没有统一站点配置消费者',
  1882. SiteOption: '目标没有旧 SiteOption/ShopConfig/UserConfig 的域名、模板、上传、审核、积分与安全策略聚合模型',
  1883. SiteOption_Submit: '现有云函数未消费旧 SiteOption 的数十项站点/商城/互动规则,不能伪造保存生效',
  1884. MailConfig: '目标缺少 SiteConfig.MailConfig SMTP 服务器、端口、帐号和收件列表配置模型',
  1885. MailConfig_Submit: '目标无统一 SMTP 邮件发送运行时消费旧密码配置,MailTemp 只是模板而非邮件通道',
  1886. UserConfig: '目标缺少 SiteConfig.UserConfig 注册、审核、验证码、必填字段、奖励与提现规则模型',
  1887. UserConfig_Submit: '当前注册/登录云函数未实现旧 UserConfig 规则消费层,仅存储配置不会生效',
  1888. SetOrderStatus: '目标缺少 OrderConfig 订单/配送/支付状态文案配置,且 ZL_Commodities/订单主链未迁入',
  1889. SetOrderStatus_Submit: '目标无订单运行时消费 21 类旧状态文案,不能伪造保存',
  1890. SMSCfg: '目标缺少 SMSConfig 多供应商帐号、密钥、签名、模板与频率限制模型',
  1891. SMSConfig_Submit: '当前云函数没有可消费旧 SMSConfig 的短信网关,密码重置也因缺短信验证明确阻塞',
  1892. ThumbConfig: '目标缺少 WaterModuleConfig/ThumbsConfig 水印、文字样式、尺寸与缩略图规则模型',
  1893. ThumbConfig_Submit: '当前 PostgreSQL Parse 文件适配器配置错误,也没有图片上传后执行旧水印/缩略图规则的处理器',
  1894. AppConfig: '目标云函数宿主没有 ASP.NET defaultDocument、customErrors、MIME 和扩展名配置界面',
  1895. AppConfig_Submit: '云函数无法改写/重启旧 web.config 宿主,目标部署运行时也不消费这些 ASP.NET 选项',
  1896. APPConfig_ReInstall: '目标没有旧 IsInstalled 状态与 /Install/Index 重新安装流程,云函数不应破坏现有生产库'
  1897. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧站点配置动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1898. }
  1899. if (operation === 'baikeEntries') {
  1900. 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) } });
  1901. }
  1902. if (operation === 'baikeBatch') {
  1903. 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) } });
  1904. }
  1905. if (operation === 'legacyBaikeBlocker') {
  1906. const action = String(input.action || ''); const blockers = {
  1907. BKVersionList: '目标 Schema 缺少旧 BaikeEdit 版本表、待审核版本内容与历史版本数据',
  1908. Version_API: '目标无 BaikeEdit 版本记录,无法审核后 Apply、取消审核、驳回或删除版本',
  1909. BKList: '旧 BKList 按 Flow 读取 BaikeEdit 词条版本链,目标仅保留一条 Baike 主记录',
  1910. Config: '目标没有 GuestConfig.BKOption 百科运行时、用户组权限和积分奖励消费者',
  1911. Config_Submit: '目标没有百科创建/编辑/推荐运行时执行 CreatePoint/EditPoint/RemmPoint 规则,不能伪造配置生效'
  1912. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧百科动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1913. }
  1914. if (operation === 'saveExamClass') {
  1915. const objectId = String(input.objectId || '');
  1916. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  1917. const className = String(payload.cClassName || '').trim();
  1918. const parentId = Number(input.parentId == null ? payload.cClassid || 0 : input.parentId);
  1919. const classType = Number(input.classType == null ? payload.cClassType || 1 : input.classType);
  1920. const orderId = Number(input.orderId == null ? payload.cOrderBy || 0 : input.orderId);
  1921. if (!className || className.length > 255) fail(400, '试题分类名称长度应为 1 至 255 位');
  1922. if (!Number.isInteger(parentId) || parentId < 0) fail(400, '父试题分类编号无效');
  1923. if (![1,2].includes(classType)) fail(400, '试题分类类型只能是答题类型或视频操作类型');
  1924. if (!Number.isInteger(orderId) || orderId < 0) fail(400, '试题分类排序必须是非负整数');
  1925. let companyId = pointerId(context.company);
  1926. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  1927. if (!companyId) fail(400, '试题分类必须指定帐套');
  1928. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  1929. const fields = await schemaFor('ExamClass');
  1930. let target;
  1931. let currentId = 0;
  1932. if (objectId) {
  1933. const query = new Parse.Query('ExamClass'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  1934. companyId = pointerId(target.get('company')) || companyId; currentId = Number(target.get('cId')) || 0;
  1935. } else target = new Parse.Object('ExamClass');
  1936. if (parentId > 0) {
  1937. const parent = await Psql.oneOrNone('SELECT "cId" FROM "ExamClass" WHERE "company"=$1 AND "cId"=$2 LIMIT 1', [companyId, parentId]);
  1938. if (!parent) fail(404, '父试题分类不存在或不属于当前帐套');
  1939. if (currentId) {
  1940. 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]);
  1941. if (cycle) fail(409, '不能把试题分类移动到自身或其下级分类');
  1942. }
  1943. }
  1944. 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]);
  1945. if (duplicate) fail(409, '同一帐套的试题分类名称不能重复');
  1946. target.set('cClassName', className); target.set('cClassid', parentId); target.set('cClassType', classType); target.set('company', company);
  1947. if (objectId) target.set('cOrderBy', orderId); else target.set('sourceKey', 'cloud:admin-exam-class:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10));
  1948. try { await target.save(null, { useMasterKey: true }); }
  1949. catch (error) { fail(422, '试题分类初始写入失败: ' + String(error && error.message || error)); }
  1950. try {
  1951. if (!objectId) {
  1952. 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]);
  1953. currentId = Number(rows[0] && rows[0].id) || 0;
  1954. if (!currentId) throw new Error('无法分配试题分类编号');
  1955. }
  1956. } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '试题分类结构写入失败: ' + String(error && error.message || error)); }
  1957. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-exam-class' : 'create-exam-class', 'ExamClass', target.id);
  1958. return response.json({ success: true, data: serializeObject(target) });
  1959. }
  1960. if (operation === 'examClassChildren') {
  1961. const parentId = Number(input.parentId || 0);
  1962. if (!Number.isInteger(parentId) || parentId < 0) fail(400, '父试题分类编号无效');
  1963. 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);
  1964. const results = await query.find({ useMasterKey: true });
  1965. return response.json({ success: true, data: { parentId, results: results.map(serializeObject) } });
  1966. }
  1967. if (operation === 'saveExamPoint') {
  1968. const objectId = String(input.objectId || '');
  1969. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  1970. const pointName = String(payload.testPoint || '').trim();
  1971. const parentId = Number(input.parentId == null ? payload.tid || 0 : input.parentId);
  1972. const orderId = Number(input.orderId == null ? payload.orderBy || 0 : input.orderId);
  1973. if (!pointName || pointName.length > 50) fail(400, '考点名称长度应为 1 至 50 位');
  1974. if (!Number.isInteger(parentId) || parentId < 0) fail(400, '上级考点编号无效');
  1975. if (!Number.isInteger(orderId) || orderId < 0) fail(400, '考点排序必须是非负整数');
  1976. let companyId = pointerId(context.company);
  1977. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  1978. if (!companyId) fail(400, '考点必须指定帐套');
  1979. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  1980. const fields = await schemaFor('ExamPoint');
  1981. let target;
  1982. let currentId = 0;
  1983. if (objectId) {
  1984. const query = new Parse.Query('ExamPoint'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  1985. companyId = pointerId(target.get('company')) || companyId; currentId = Number(target.get('id')) || 0;
  1986. } else target = new Parse.Object('ExamPoint');
  1987. if (parentId > 0) {
  1988. const parent = await Psql.oneOrNone('SELECT "id" FROM "ExamPoint" WHERE "company"=$1 AND "id"=$2 LIMIT 1', [companyId, parentId]);
  1989. if (!parent) fail(404, '上级考点不存在或不属于当前帐套');
  1990. if (currentId) {
  1991. 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]);
  1992. if (cycle) fail(409, '不能把考点移动到自身或其下级考点');
  1993. }
  1994. }
  1995. 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]);
  1996. if (duplicate) fail(409, '同一帐套的考点名称不能重复');
  1997. target.set('testPoint', pointName); target.set('tid', parentId); target.set('company', company);
  1998. if (objectId) target.set('orderBy', orderId);
  1999. 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()); }
  2000. try { await target.save(null, { useMasterKey: true }); }
  2001. catch (error) { fail(422, '考点初始写入失败: ' + String(error && error.message || error)); }
  2002. try {
  2003. if (!objectId) {
  2004. 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]);
  2005. currentId = Number(rows[0] && rows[0].id) || 0;
  2006. if (!currentId) throw new Error('无法分配考点编号');
  2007. }
  2008. } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '考点结构写入失败: ' + String(error && error.message || error)); }
  2009. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-exam-point' : 'create-exam-point', 'ExamPoint', target.id);
  2010. return response.json({ success: true, data: serializeObject(target) });
  2011. }
  2012. if (operation === 'saveKnowledge') {
  2013. const objectId = String(input.objectId || '');
  2014. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  2015. const knowledgeName = String(payload.kName || '').trim();
  2016. const classId = Number(input.classId == null ? payload.kClassId || 0 : input.classId);
  2017. const parentId = Number(input.parentId == null ? payload.pid || 0 : input.parentId);
  2018. const orderId = Number(input.orderId == null ? payload.kOrderBy || 0 : input.orderId);
  2019. const status = Number(payload.status == null ? 1 : payload.status);
  2020. const grade = Number(payload.grade || 0);
  2021. const isSys = Number(payload.isSys || 0);
  2022. if (!knowledgeName || knowledgeName.length > 200) fail(400, '知识点名称长度应为 1 至 200 位');
  2023. if (!Number.isInteger(classId) || classId < 1) fail(400, '请选择有效试题分类');
  2024. if (!Number.isInteger(parentId) || parentId < 0) fail(400, '父知识点编号无效');
  2025. if (!Number.isInteger(orderId) || orderId < 0) fail(400, '知识点排序必须是非负整数');
  2026. if (![0,1].includes(status) || !Number.isInteger(grade) || grade < 0 || ![0,1].includes(isSys)) fail(400, '知识点状态、年级或系统标记无效');
  2027. let companyId = pointerId(context.company);
  2028. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  2029. if (!companyId) fail(400, '知识点必须指定帐套');
  2030. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  2031. const subject = await Psql.oneOrNone('SELECT "cId" FROM "ExamClass" WHERE "company"=$1 AND "cId"=$2 LIMIT 1', [companyId, classId]);
  2032. if (!subject) fail(404, '试题分类不存在或不属于当前帐套');
  2033. const fields = await schemaFor('QuestionsKnowledge');
  2034. let target;
  2035. let currentId = 0;
  2036. if (objectId) {
  2037. const query = new Parse.Query('QuestionsKnowledge'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  2038. companyId = pointerId(target.get('company')) || companyId; currentId = Number(target.get('kId')) || 0;
  2039. } else target = new Parse.Object('QuestionsKnowledge');
  2040. if (parentId > 0) {
  2041. const parent = await Psql.oneOrNone('SELECT "kId" FROM "QuestionsKnowledge" WHERE "company"=$1 AND "kId"=$2 AND "kClassId"=$3 LIMIT 1', [companyId, parentId, classId]);
  2042. if (!parent) fail(404, '父知识点不存在、科目不一致或不属于当前帐套');
  2043. if (currentId) {
  2044. 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]);
  2045. if (cycle) fail(409, '不能把知识点移动到自身或其下级知识点');
  2046. }
  2047. }
  2048. 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]);
  2049. if (duplicate) fail(409, '同一试题分类中的知识点名称不能重复');
  2050. 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);
  2051. if (objectId) target.set('kOrderBy', orderId);
  2052. 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()); }
  2053. try { await target.save(null, { useMasterKey: true }); }
  2054. catch (error) { fail(422, '知识点初始写入失败: ' + String(error && error.message || error)); }
  2055. try {
  2056. if (!objectId) {
  2057. 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]);
  2058. currentId = Number(rows[0] && rows[0].id) || 0;
  2059. if (!currentId) throw new Error('无法分配知识点编号');
  2060. }
  2061. } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '知识点结构写入失败: ' + String(error && error.message || error)); }
  2062. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-knowledge' : 'create-knowledge', 'QuestionsKnowledge', target.id);
  2063. return response.json({ success: true, data: serializeObject(target) });
  2064. }
  2065. if (operation === 'knowledgeChildren') {
  2066. const parentId = Number(input.parentId || 0); const classId = Number(input.classId || 0);
  2067. if (!Number.isInteger(parentId) || parentId < 0 || !Number.isInteger(classId) || classId < 1) fail(400, '知识点父级或试题分类编号无效');
  2068. 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);
  2069. const results = await query.find({ useMasterKey: true });
  2070. return response.json({ success: true, data: { classId, parentId, results: results.map(serializeObject) } });
  2071. }
  2072. if (operation === 'saveExamTeacher') {
  2073. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  2074. const teacherName = String(payload.tname || '').trim(); const classId = Number(input.classId == null ? payload.tclsss || 0 : input.classId);
  2075. if (!teacherName || teacherName.length > 50) fail(400, '教师姓名长度应为 1 至 50 位');
  2076. if (!Number.isInteger(classId) || classId < 0) fail(400, '任教试题分类编号无效');
  2077. let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '教师必须指定帐套');
  2078. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  2079. 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, '任教试题分类不存在或不属于当前帐套'); }
  2080. const fields = await schemaFor('ExTeacher'); let target;
  2081. 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; }
  2082. else target = new Parse.Object('ExTeacher');
  2083. const limits = { post: 50, teach: 50, fileUpload: 255, remark: 1000 };
  2084. 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); }
  2085. target.set('tname', teacherName); target.set('tclsss', classId); target.set('company', company); target.set('addUser', Number(context.current.get('legacyUserId')) || 0);
  2086. if (!objectId) { target.set('sourceKey', 'cloud:admin-exam-teacher:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); target.set('creatTime', new Date()); }
  2087. try { await target.save(null, { useMasterKey: true }); }
  2088. catch (error) { fail(422, '教师初始写入失败: ' + String(error && error.message || error)); }
  2089. try {
  2090. if (!objectId) {
  2091. 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]);
  2092. if (!(Number(rows[0] && rows[0].id) || 0)) throw new Error('无法分配教师编号');
  2093. }
  2094. } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '教师编号写入失败: ' + String(error && error.message || error)); }
  2095. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-exam-teacher' : 'create-exam-teacher', 'ExTeacher', target.id);
  2096. return response.json({ success: true, data: serializeObject(target) });
  2097. }
  2098. if (operation === 'barBatch') {
  2099. const action = String(input.action || '');
  2100. const actions = ['audit','unaudit','recycle','recover','move','top-global','top-section','top-undo','elite','elite-undo','bottom','bottom-undo'];
  2101. if (!actions.includes(action)) fail(400, '不支持的贴吧帖子批量操作');
  2102. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  2103. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  2104. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条帖子');
  2105. const fields = await schemaFor('GuestBar');
  2106. const query = new Parse.Query('GuestBar'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  2107. const targets = await query.find({ useMasterKey: true });
  2108. if (targets.length !== objectIds.length) fail(404, '部分帖子不存在或不属于当前帐套');
  2109. let cateId = null;
  2110. if (action === 'move') {
  2111. cateId = Number(input.cateId);
  2112. if (!Number.isInteger(cateId) || cateId < 1) fail(400, '目标贴吧分类编号无效');
  2113. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
  2114. if (companyIds.length !== 1) fail(400, '批量移动的帖子必须属于同一帐套');
  2115. 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]);
  2116. if (!category) fail(404, '目标贴吧分类不存在或不属于当前帐套');
  2117. }
  2118. for (const target of targets) {
  2119. if (action === 'audit' || action === 'recover') target.set('status', 99);
  2120. else if (action === 'unaudit') target.set('status', 0);
  2121. else if (action === 'recycle') target.set('status', -2);
  2122. else if (action === 'move') target.set('cateId', cateId);
  2123. else if (action === 'top-global') target.set('orderFlag', 2);
  2124. else if (action === 'top-section') target.set('orderFlag', 1);
  2125. else if (action === 'bottom') target.set('orderFlag', -1);
  2126. else if (action === 'top-undo' || action === 'bottom-undo') target.set('orderFlag', 0);
  2127. else {
  2128. const flags = String(target.get('postFlag') || '').split(',').map((value) => value.trim()).filter(Boolean);
  2129. const next = new Set(flags);
  2130. if (action === 'elite') next.add('Recommend'); else next.delete('Recommend');
  2131. target.set('postFlag', next.size ? ',' + [...next].join(',') + ',' : '');
  2132. }
  2133. }
  2134. await Parse.Object.saveAll(targets, { useMasterKey: true });
  2135. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'bar-' + action, 'GuestBar', target.id);
  2136. return response.json({ success: true, data: { action, updated: targets.length, cateId, results: targets.map(serializeObject) } });
  2137. }
  2138. if (operation === 'legacyContentUtility') {
  2139. const action = String(input.action || '');
  2140. if (action === 'get-md-file') return response.json({ success: true, data: { content: '' } });
  2141. if (action === 'duplicate-title') {
  2142. const title = String(input.title || '').trim();
  2143. if (!title || title.length > 500) fail(400, '标题长度应为 1 至 500 位');
  2144. 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);
  2145. const results = await query.find({ useMasterKey: true });
  2146. 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 }; }) } });
  2147. }
  2148. if (action === 'export') {
  2149. const requestedIds = Array.isArray(input.objectIds) ? [...new Set(input.objectIds.map((value) => String(value || '').trim()).filter(Boolean))] : [];
  2150. if (requestedIds.length > 1000) fail(400, '每次最多导出 1000 条内容');
  2151. 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);
  2152. const results = await query.find({ useMasterKey: true });
  2153. if (requestedIds.length && results.length !== requestedIds.length) fail(404, '部分内容不存在或不属于当前帐套');
  2154. 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')) }));
  2155. return response.json({ success: true, data: { total: rows.length, rows } });
  2156. }
  2157. fail(400, '不支持的旧内容兼容动作');
  2158. }
  2159. if (operation === 'legacyContentBlocker') {
  2160. const action = String(input.action || ''); const blockers = {
  2161. Content_AddToNew: '目标缺少按 CommonModel.tableName 动态复制任意旧模型附表的完整结构与数据',
  2162. AddToSpec: 'CommonModel.specialId 仅存在于 Parse Schema,PostgreSQL 物理查询仍失败,无法安全读取专题关系',
  2163. AddToSpec_Submit: '专题关系物理列不可可靠读写,且商品主表未迁移,不能伪造内容加入专题成功',
  2164. ContentManage_Html: '云函数运行时没有旧模板解析器、站点根目录与静态 HTML 文件宿主',
  2165. ContentRelease: '目标 Schema 缺少 Content_ScheTask 定时内容任务及既有任务记录',
  2166. ContentRelease_Submit: '目标没有 HFHelper 调度器与 ZL_Task_Content 脚本执行运行时',
  2167. CreateHtmlContent: '云函数没有旧节点模板、标签解析器与站点文件系统,无法预览静态生成范围',
  2168. Create_Submit: '目标没有 ContentHtmlHelper 和旧模板标签运行时,无法执行内容静态生成任务',
  2169. CreateHtml: '目标云函数不能写入旧站点 HTML 目录,也没有旧模板渲染环境',
  2170. ConCatch: '目标缺少旧采集项目、采集规则、来源站点与任务历史数据',
  2171. CatchConfig: '目标没有 SiteOption.Coll_Allow/Coll_Key 配置模型及采集运行时消费者',
  2172. CatchConfig_Submit: '仅保存采集开关不会产生可执行采集能力,目标也没有旧 SiteConfig 更新链',
  2173. MarkDown: '目标 Schema 缺少旧 Content_MarkDown 主表与历史 Markdown 记录',
  2174. MarkDown_Del: '目标没有可定位并删除的旧 Content_MarkDown 记录'
  2175. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧内容动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  2176. }
  2177. if (operation === 'contentBatch') {
  2178. const action = String(input.action || '');
  2179. if (!['status','recycle','recover','move','refresh'].includes(action)) fail(400, '不支持的内容批量操作');
  2180. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  2181. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  2182. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条内容');
  2183. const contentFields = await schemaFor('CommonModel');
  2184. const query = new Parse.Query('CommonModel'); applyTenant(query, contentFields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  2185. const targets = await query.find({ useMasterKey: true });
  2186. if (targets.length !== objectIds.length) fail(404, '部分内容不存在或不属于当前帐套');
  2187. let status = null;
  2188. let nodeId = null;
  2189. if (action === 'status') {
  2190. status = Number(input.status);
  2191. if (![-3,-1,0,99].includes(status)) fail(400, '不支持的内容状态');
  2192. } else if (action === 'recycle') status = -2;
  2193. else if (action === 'recover') status = 0;
  2194. else if (action === 'move') {
  2195. nodeId = Number(input.nodeId);
  2196. if (!Number.isInteger(nodeId) || nodeId < 1) fail(400, '请选择有效目标节点');
  2197. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
  2198. if (companyIds.length !== 1) fail(400, '批量移动的内容必须属于同一帐套');
  2199. 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]);
  2200. if (!node) fail(404, '目标节点不存在或不属于当前帐套');
  2201. }
  2202. for (const target of targets) {
  2203. if (status !== null) target.set('status', status);
  2204. if (nodeId !== null) target.set('nodeId', nodeId);
  2205. if (action === 'refresh') { const now = new Date(); target.set('createTime', now); target.set('upDateTime', now); }
  2206. }
  2207. await Parse.Object.saveAll(targets, { useMasterKey: true });
  2208. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'content-' + action, 'CommonModel', target.id);
  2209. return response.json({ success: true, data: { action, updated: targets.length, status, nodeId, results: targets.map(serializeObject) } });
  2210. }
  2211. const className = String(input.className || '');
  2212. assertClass(className);
  2213. const fields = await schemaFor(className);
  2214. const classWritable = !READ_ONLY_CLASSES.has(className);
  2215. if (operation === 'schema') {
  2216. 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) }));
  2217. 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','DesignScence','Temp','StoreApplication','StoreStyle','UserLevel'].includes(className), supportsSoftDelete: Boolean(fields.isDeleted) } });
  2218. }
  2219. if (operation === 'list') {
  2220. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20));
  2221. let query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId);
  2222. const search = String(input.search || '').trim();
  2223. 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'); }
  2224. const sort = fields[input.sort] ? String(input.sort) : fields.updatedAt ? 'updatedAt' : 'createdAt'; if (input.order === 'asc') query.ascending(sort); else query.descending(sort);
  2225. const total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize);
  2226. const results = (await query.find({ useMasterKey: true })).filter(isVisible);
  2227. return response.json({ success: true, data: { className, page, pageSize, total, results: results.map(serializeObject) } });
  2228. }
  2229. const objectId = String(input.objectId || '');
  2230. if (operation === 'get') {
  2231. 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 });
  2232. return response.json({ success: true, data: serializeObject(object) });
  2233. }
  2234. if (operation === 'save') {
  2235. if (!classWritable) fail(403, '该系统类不允许通用编辑');
  2236. if (className === '_User' && !objectId) fail(400, '新增用户必须走专用开户流程');
  2237. if (className === 'Group') fail(400, '用户组必须走专用保存流程');
  2238. if (className === 'Node') fail(400, '栏目必须走专用保存流程');
  2239. if (className === 'Special') fail(400, '专题必须走专用保存流程');
  2240. if (className === 'Model' || className === 'ModelField') fail(400, '模型结构必须走专用元数据流程');
  2241. if (className === 'Guestbook') fail(400, '留言必须走专用编辑或回复流程');
  2242. if (className === 'Guestcate') fail(400, '留言/贴吧分类必须走专用保存流程');
  2243. if (className === 'ExamClass') fail(400, '试题分类必须走专用保存流程');
  2244. if (className === 'ExamPoint') fail(400, '考点必须走专用保存流程');
  2245. if (className === 'QuestionsKnowledge') fail(400, '知识点必须走专用保存流程');
  2246. if (className === 'ExTeacher') fail(400, '考试教师必须走专用保存流程');
  2247. if (className === 'Datadiccategory' || className === 'Datadic') fail(400, '数据字典必须走专用保存流程');
  2248. if (className === 'GradeCate' || className === 'Grade') fail(400, '多级数据字典必须走专用保存流程');
  2249. if (className === 'Currency') fail(400, '货币必须走专用保存流程');
  2250. if (className === 'SysHoliday') fail(400, '节假日必须走专用保存流程');
  2251. if (className === 'Search') fail(400, '快捷入口必须走专用保存流程');
  2252. if (className === 'AdZone' || className === 'AdInfo') fail(400, '广告位与广告内容必须走专用保存流程');
  2253. if (className === 'FontPicShape' || className === 'FontPicShapeType') fail(400, '字体图形素材与分类必须走专用保存流程');
  2254. if (className === 'DesignAsk' || className === 'DesignQuestion' || className === 'DesignAnswer') fail(400, '问卷、题目与答卷必须走专用流程');
  2255. if (className === 'ServiceSeat' || className === 'Temp') fail(400, '客服席位与欢迎语必须走专用保存流程');
  2256. if (className === 'StoreApplication' || className === 'StoreStyle') fail(400, '店铺与店铺样式必须走专用保存流程');
  2257. if (className === 'UserLevel') fail(400, '积分等级必须走专用保存流程');
  2258. if (className === 'CRMSAttr') fail(400, 'CRM 客户类型必须走专用保存流程');
  2259. if (className === 'ShopFareTlp') fail(400, '商城运费模板必须走专用保存流程');
  2260. if (className === 'MisType') fail(400, 'OA 流程类型必须走专用保存流程');
  2261. if (className === 'PageStyle') fail(400, '黄页样式必须走专用保存流程');
  2262. if (className === 'PlatComp') fail(400, '协同办公企业必须走专用保存流程');
  2263. if (className === 'DesignRes') fail(400, '设计资源必须走专用保存流程');
  2264. if (className === 'DesignScence') fail(400, '可视化场景必须走专用保存流程');
  2265. if (className === 'DesignTlp') fail(400, '可视化模板必须走专用保存流程');
  2266. if (className === 'Role') fail(400, '系统角色必须走专用保存流程');
  2267. if (className === 'CommonModel' && !objectId) fail(400, '内容新增必须同时写入模型附表,不能走通用保存');
  2268. let object;
  2269. 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);
  2270. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  2271. 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)); }
  2272. if (fields.company && context.company) object.set('company', context.company); if (fields.isDeleted && !objectId) object.set('isDeleted', false);
  2273. await object.save(null, { useMasterKey: true }); await audit(context, objectId ? 'update' : 'create', className, object.id);
  2274. return response.json({ success: true, data: serializeObject(object) });
  2275. }
  2276. if (operation === 'delete') {
  2277. 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 });
  2278. if (className === 'Model' || className === 'ModelField') fail(501, 'migration_blocked: 删除模型或字段需要同步删除 PostgreSQL 物理表/列并验证历史数据,托管云函数中未开放此 DDL 流程');
  2279. if (className === 'AdZone' || className === 'AdInfo') fail(400, '广告位与广告内容必须走专用批量删除流程');
  2280. if (className === 'FontPicShape' || className === 'FontPicShapeType') fail(400, '字体图形素材与分类必须走专用批量删除流程');
  2281. if (className === 'DesignAsk' || className === 'DesignQuestion' || className === 'DesignAnswer') fail(400, '问卷、题目与答卷必须走专用批量流程');
  2282. if (className === 'ServiceSeat' || className === 'Temp') fail(400, '客服席位与欢迎语必须走专用批量流程');
  2283. if (className === 'StoreApplication' || className === 'StoreStyle') fail(400, '店铺与店铺样式必须走专用批量流程');
  2284. if (className === 'CRMSAttr') fail(400, 'CRM 客户类型必须走专用批量流程');
  2285. if (className === 'ShopFareTlp') fail(400, '商城运费模板必须走专用批量流程');
  2286. if (className === 'MisType') fail(400, 'OA 流程类型必须走专用批量流程');
  2287. if (className === 'PageStyle') fail(400, '黄页样式必须走专用批量流程');
  2288. if (className === 'PlatComp') fail(400, '协同办公企业必须走专用批量流程');
  2289. if (className === 'DesignRes') fail(400, '设计资源必须走专用批量流程');
  2290. if (className === 'DesignScence') fail(400, '可视化场景必须走专用批量流程');
  2291. if (className === 'DesignTlp') fail(400, '可视化模板必须走专用批量流程');
  2292. if (className === 'Role') fail(400, '系统角色必须走专用批量流程');
  2293. 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 } }); }
  2294. 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 } }); }
  2295. 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 } }); }
  2296. 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 } }); }
  2297. 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 } }); }
  2298. 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 } }); }
  2299. if (className === '_User') assertCanManageUser(context, object, 'lock');
  2300. 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, '该用户组仍有下级组,不能删除'); }
  2301. 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 } }); }
  2302. 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 } }); }
  2303. 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 } }); }
  2304. 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 } }); }
  2305. if (fields.isDeleted) { object.set('isDeleted', true); await object.save(null, { useMasterKey: true }); } else await object.destroy({ useMasterKey: true });
  2306. if (className === '_User') await revokeSessions([object]);
  2307. await audit(context, 'delete', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: Boolean(fields.isDeleted) } });
  2308. }
  2309. if (operation === 'resetPassword') {
  2310. if (className !== '_User' || !objectId || typeof input.newPassword !== 'string' || input.newPassword.length < 8) fail(400, '密码至少 8 位');
  2311. 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 } });
  2312. }
  2313. fail(400, '不支持的后台操作');
  2314. } catch (error) {
  2315. const status = Number(error.status || (error.code === 101 ? 404 : 500));
  2316. return response.status(status).json({ success: false, error: status >= 500 ? 'cloud_function_error' : 'request_rejected', message: error.message || '云函数执行失败' });
  2317. }
  2318. }
  2319. `;
  2320. function cmsReadCode(mode) {
  2321. return String.raw`
  2322. const MODE = ${JSON.stringify(mode)};
  2323. const HIDDEN = /(?:password|secret|sessiontoken|masterkey|privatekey|legacyPasswordHash|adminPassword)/i;
  2324. function inputOf(request) { const body = request.body || {}; return body.params && typeof body.params === 'object' ? body.params : body; }
  2325. function fail(status, message) { const error = new Error(message); error.status = status; throw error; }
  2326. 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; }
  2327. 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 }; }
  2328. async function schema(name) { try { return (await new Parse.Schema(name).get({ useMasterKey: true })).fields || {}; } catch (_) { return {}; } }
  2329. function tenant(query, fields, context) { if (fields.company && context.company) query.equalTo('company', context.company); }
  2330. 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(); }
  2331. function escapeRegex(value) { return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); }
  2332. async function handler(request, response) {
  2333. try {
  2334. 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));
  2335. if (MODE === 'content-normalizer') return response.json({ success: true, data: { content: stripHtml(input.content), mode: String(input.mode || 'strip') } });
  2336. if (MODE.startsWith('user-')) {
  2337. 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);
  2338. if (MODE === 'user-extended') { delete data.legacyUserPlat; delete data.legacyWxUser; delete data.wechat; delete data.wxapp; return response.json({ success: true, data }); }
  2339. if (MODE === 'user-platform') return response.json({ success: true, data: { objectId: target.id, platform: safe(target.get('legacyUserPlat') || {}), company: safe(target.get('company')) } });
  2340. return response.json({ success: true, data: { objectId: target.id, wechat: safe(target.get('legacyWxUser') || target.get('wechat') || target.get('wxapp') || {}) } });
  2341. }
  2342. if (MODE === 'exam-classes' || MODE === 'guest-bar') {
  2343. 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) } });
  2344. }
  2345. if (MODE === 'search') {
  2346. 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: [] } });
  2347. 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; }) } });
  2348. }
  2349. fail(400, '不支持的 CMS 查询');
  2350. } 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 || '云函数执行失败' }); }
  2351. }
  2352. `;
  2353. }
  2354. const appGatewayCode = String.raw`
  2355. const DEFAULT_COMPANY_ID = '7pIbDBJmKx';
  2356. const PUBLIC_READ = new Set(['content_list','content_get','content_uphis','node_list','node_get','app_update','vote_ask','vote_question','product_list','product_get','order_comment_list']);
  2357. const BLOCKED = {
  2358. mcode_send: '缺少新短信服务商凭据与验证码存储', user_login_mobile: '缺少新短信验证码服务', user_register_mobile: '缺少新短信验证码服务', user_sync2: '依赖微信容器授权与新版微信凭据',
  2359. user_cash_add: '缺少提现事务账本', user_cash_list: '缺少提现事务账本',
  2360. user_coin_recharge: '缺少支付凭据与事务账本', user_exp_transfer: '缺少积分事务规则确认', user_money_recharge: '缺少支付凭据与事务账本', user_money_transfer: '缺少资金事务规则确认',
  2361. user_update_paypwd: '需要独立支付密码哈希服务',
  2362. user_update_pwd: '旧流程强制校验短信验证码,但新短信服务与验证码存储尚未提供', user_update_pwdall: '同时依赖登录密码与支付密码写入,目标系统缺少独立支付密码哈希服务',
  2363. user_rnauth_add: '实名认证需新的合规审核与敏感数据存储', user_rnauth_get: '实名认证需新的合规审核与敏感数据存储', user_rnauth_upd: '实名认证需新的合规审核与敏感数据存储',
  2364. };
  2365. const PARTIAL = {
  2366. payment_cart: '已完成学习豆余额原子结算;微信/支付宝支付通道尚未配置',
  2367. payment_cart_again: '已完成学习豆已支付订单幂等确认;微信/支付宝补支付尚未配置',
  2368. payment_success: '已完成新订单支付结果幂等核验;外部支付回调签名验证尚未配置',
  2369. };
  2370. const SENSITIVE = /(?:password|pwd|secret|sessiontoken|masterkey|privatekey|legacyPasswordHash|adminPassword|payPassword)/i;
  2371. function inputOf(request) { const body = request.body || {}; return body.params && typeof body.params === 'object' ? body.params : body; }
  2372. function envelope(result, addon, page) { const value = { retcode: 0, retmsg: '', result }; if (addon !== undefined) value.addon = addon; if (page) value.page = page; return value; }
  2373. function reject(message) { return { retcode: -1, retmsg: message, result: null }; }
  2374. function fail(status, message) { const error = new Error(message); error.status = status; throw error; }
  2375. 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; }
  2376. function number(value, fallback = 0) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; }
  2377. 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))) }; }
  2378. function companyPointer() { return Parse.Object.createWithoutData('Company', DEFAULT_COMPANY_ID); }
  2379. async function fieldsOf(className) { return (await new Parse.Schema(className).get({ useMasterKey: true })).fields || {}; }
  2380. function tenant(query, fields) { if (fields.company) query.equalTo('company', companyPointer()); }
  2381. 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); }
  2382. function legacyAliases(value, className) {
  2383. const row = safe(value); const maps = {
  2384. 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' },
  2385. 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' },
  2386. App: { ID:'id' }
  2387. }; 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;
  2388. }
  2389. 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 }); if (current.get('isDisabled') === true || current.get('isDeleted') === true) { await revokeUserSessions(current).catch(() => undefined); fail(403, '账号已停用'); } const company = current.get('company'); if (company && company.id !== DEFAULT_COMPANY_ID) fail(403, '用户不属于小树英语帐套'); return current; }
  2390. 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; }
  2391. function objectValue(source, key) { return source && typeof source.get === 'function' ? source.get(key) : source && source[key]; }
  2392. function legacyData(source) { const value = objectValue(source, 'legacyUserData'); return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
  2393. function firstValue(...values) { return values.find((value) => value !== undefined && value !== null && value !== ''); }
  2394. function legacyUser(current, withToken = false) {
  2395. 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')));
  2396. 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'),'') || '') };
  2397. if (withToken) value.sessionToken = current.getSessionToken(); return value;
  2398. }
  2399. function legacyUserAddon(current) {
  2400. 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;
  2401. }
  2402. async function registerUser(input) {
  2403. 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,'邀请码不存在'); }
  2404. 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; }
  2405. 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; }
  2406. }
  2407. async function updateUserProfile(input,current) {
  2408. 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];
  2409. 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; }
  2410. current.set('legacyUserData',data); await current.save(null,{useMasterKey:true}); return { result:legacyUser(current),addon:legacyUserAddon(current) };
  2411. }
  2412. function legacyUserRow(source) {
  2413. 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,'') || '') };
  2414. 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 };
  2415. }
  2416. 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; }
  2417. 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 } }; }
  2418. const CONTENT_ADDONS = {
  2419. 2: { className:'ContentArticle', fields:['id','ico','mp4','edit','pages','author','kPages','source','content','synopsis'] },
  2420. 52: { className:'VocabularyWord', fields:['id','lj','sy','yb','yp'] },
  2421. 53: { className:'PracticeRecord', fields:['id','kcid','scid','xxcs','yhid','jrscb'] },
  2422. 54: { className:'CourseAppointment', fields:['id','pl','bxrq','dslx','dszt','fxpl','jffs','jssj','kcid','kssj','plxm','scsj','sdsd','szmd','szyh','yysj','yykcid'] },
  2423. 56: { className:'DailyStudyRecord', fields:['id','pl','con','djq','ygg','dqrq','dsid','fxrl','xxqs','szmdid','userId','learned'] },
  2424. 58: { className:'CourseBinding', fields:['id','yxx','cksl','kcid','syjd','yhid'] },
  2425. 59: { className:'LessonRecord', fields:['id','pf','jffs','jsmz','kcid','kclx','kcmc','kzsj','pjnr','pldp','xymz','yyds','plpjsj','szmdid'] },
  2426. 60: { className:'MemoryPracticeRecord', fields:['id','fxzt','kcid','plid','wcsj','yhid','kywrq','kywsj','xxjlid','orderId'] },
  2427. 61: { className:'AssessmentProfile', fields:['id','df','askid','wrong','userId','answerid','dontKnow','prevScore','totalScore'] }
  2428. };
  2429. const NODE_MODELS = { 28:58, 29:54, 32:53, 291:56, 296:59, 327:52, 388:60, 389:61 };
  2430. const PRIVATE_CONTENT_MODELS = new Set([53,54,56,58,59,60,61]);
  2431. const CONTENT_WRITE_AUTH = {
  2432. 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 },
  2433. 59: { subjects:['xymz'], actors:['jsmz'], tableName:'ZL_C_skjl', nodeId:296 }, 60: { subjects:['yhid'], actors:['plid'], tableName:'ZL_C_gywjl', nodeId:388 }, 61: { subjects:['userId'], actors:[], tableName:'ZL_C_cpda', nodeId:389 }
  2434. };
  2435. 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); }
  2436. function requestedContentModel(input) { const nodes = String(input.nodeid || input.nid || input.nodes || '').split(',').map(number).filter(Boolean); return contentModel(input, nodes); }
  2437. function legacyFilterPairs(input) {
  2438. const source = [input.myfield, input.myfield2].filter(Boolean).join('|'); if (!source) return [];
  2439. 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] }; });
  2440. }
  2441. function addonField(config, requested) { return config && config.fields.find((field) => field.toLowerCase() === String(requested).toLowerCase()); }
  2442. function contentQueryRequiresAuth(action, input) { return (action === 'content_list' || action === 'content_list_llk') && (PRIVATE_CONTENT_MODELS.has(requestedContentModel(input)) || legacyFilterPairs(input).length > 0); }
  2443. async function authorizeContentAccess(current, input) {
  2444. 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));
  2445. }
  2446. 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'); }
  2447. async function authorizeOwnerPairs(current, pairs) {
  2448. if (isAdminUser(current)) return; const owners = pairs.filter((pair) => ['userid','yhid','xymz','jsmz','szyh','pl','plid','fxpl'].includes(pair.name)); if (!owners.length) fail(403, '私有内容查询必须限定用户');
  2449. const ownId = number(current.get('legacyUserId') || current.get('userid') || current.get('num')); if (owners.some((pair) => pair.value === ownId)) return; const subjects = owners.filter((pair) => ['userid','yhid','xymz','szyh'].includes(pair.name));
  2450. 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, '无权查看该学员的内容记录'); }
  2451. }
  2452. 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); }
  2453. function contentOrder(input, config) {
  2454. const raw = String(input.orders || '').trim(); if (!raw) return 'c."updatedAt" DESC'; if (/^NEWID\(\)$/i.test(raw)) return 'RANDOM()';
  2455. 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();
  2456. 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';
  2457. }
  2458. async function contentPage(input, publicOnly = false) {
  2459. const paging = pageInput(input); const values = [DEFAULT_COMPANY_ID]; const clauses = ['c."company" = $1'];
  2460. if (input.__publishedOnly === true) clauses.push('CAST(COALESCE(c."status",99) AS text) = \'99\'');
  2461. if (input.objectId) { values.push(String(input.objectId)); clauses.push('c."objectId" = $' + values.length); }
  2462. if (input.generalId) { values.push(String(input.generalId)); clauses.push('CAST(c."generalId" AS text) = $' + values.length); }
  2463. const nodeIds = String(input.nodeid || input.nid || input.nodes || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0');
  2464. if (nodeIds.length) { values.push(nodeIds); clauses.push('CAST(c."nodeId" AS text) = ANY($' + values.length + '::text[])'); }
  2465. const modelId = contentModel(input, nodeIds); const config = CONTENT_ADDONS[modelId] || null; if(modelId===58)clauses.push("a.\"enrollmentStatus\"='active'"); if (modelId) { values.push(String(modelId)); clauses.push('CAST(c."modelId" AS text) = $' + values.length); }
  2466. if (publicOnly && !modelId) { values.push([...PRIVATE_CONTENT_MODELS].map(String)); clauses.push('CAST(c."modelId" AS text) <> ALL($' + values.length + '::text[])'); }
  2467. const filters = legacyFilterPairs(input); if (filters.length && !config) fail(501, '该内容模型的 addon 联表尚未映射');
  2468. 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); }
  2469. if (input.skey) { values.push('%' + String(input.skey).replace(/[%_]/g, '') + '%'); clauses.push('c."title" ILIKE $' + values.length); }
  2470. 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 ');
  2471. 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]);
  2472. 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);
  2473. 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;if(modelId===58)row.bindingId=addon.objectId; return legacyAliases(row, 'CommonModel'); }); const total = number(countRow.total);
  2474. return { rows: normalized, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
  2475. }
  2476. async function sqlPage(className, input, filters = []) {
  2477. if (!['App','Node'].includes(className)) fail(400, '不允许查询该类'); const paging = pageInput(input); const values = [DEFAULT_COMPANY_ID]; const clauses = ['"company" = $1'];
  2478. for (const filter of filters) { values.push(String(filter.value)); clauses.push('CAST("' + filter.field + '" AS text) = $' + values.length); }
  2479. 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]);
  2480. 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);
  2481. 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 } };
  2482. }
  2483. async function nodePage(input) {
  2484. 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} };
  2485. }
  2486. async function nodeUnitPage(input,current) {
  2487. const uid = await authorizeRequestedUser(current,input.userId || input.uid); const paging = pageInput(input); const bindingId = String(input.bindingId || '').trim(); let parentId = number(input.pid); let binding = null;
  2488. const bindingRows=await Psql.query('SELECT b.* FROM "CourseBinding" b JOIN "CommonModel" c ON c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" WHERE b."company"=$1 AND b."yhid"::text=$2 AND b."enrollmentStatus"=\'active\' AND c."status"=99 AND (($3<>\'\' AND (b."id"::text=$3 OR b."objectId"=$3)) OR ($3=\'\' AND b."kcid"::text=$4))',[DEFAULT_COMPANY_ID,String(uid),bindingId,String(parentId)]);
  2489. if(bindingRows.length>1)fail(409,'该内容有多个开课分类,请选择具体开课记录');binding=bindingRows[0]||null;
  2490. if (!binding) fail(403,'该课程尚未分配给当前学员'); parentId = number(binding.kcid,parentId);
  2491. if (parentId) {
  2492. 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 total = number(countRow.total);
  2493. if (total) { 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 CASE WHEN CAST(p."xxcs" AS text) ~ \'^[0-9]+(?:\\.[0-9]+)?$\' THEN CAST(p."xxcs" AS numeric) ELSE 0 END>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]); 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} }; }
  2494. }
  2495. const assigned = courseAssignedUnits(binding.syjd); const total = assigned.length; if (!total) return {rows:[],page:{itemCount:0,pageCount:0,pageIndex:paging.index,pageSize:paging.size}}; const pageUnits = assigned.slice((paging.index - 1) * paging.size,paging.index * paging.size),unitIds=pageUnits.map((unit)=>unit.id);
  2496. const wordRows = await Psql.query('SELECT CAST(c."nodeId" AS text) AS id,COUNT(*)::int AS total FROM "CommonModel" c WHERE c."company"=$1 AND CAST(c."modelId" AS text)=\'52\' AND CAST(c."nodeId" AS text)=ANY($2::text[]) GROUP BY CAST(c."nodeId" AS text)',[DEFAULT_COMPANY_ID,unitIds]); const learnedRows = await Psql.query('SELECT CAST(c."nodeId" AS text) AS id,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) WHERE p."company"=$1 AND CAST(p."yhid" AS text)=$2 AND CAST(c."nodeId" AS text)=ANY($3::text[]) AND CASE WHEN CAST(p."xxcs" AS text) ~ \'^[0-9]+(?:\\.[0-9]+)?$\' THEN CAST(p."xxcs" AS numeric) ELSE 0 END>0 GROUP BY CAST(c."nodeId" AS text)',[DEFAULT_COMPANY_ID,String(uid),unitIds]); const totals=new Map(wordRows.map((row)=>[String(row.id),number(row.total)])),learned=new Map(learnedRows.map((row)=>[String(row.id),number(row.learned)])); const rows=pageUnits.map((unit)=>{const count=totals.get(unit.id)||0,done=learned.get(unit.id)||0;return legacyAliases({nodeId:number(unit.id),nodeName:unit.name,parentId,total:count,yx_word:done,process:count?Math.round(done/count*100):0},'Node');}); return {rows,page:{itemCount:total,pageCount:Math.ceil(total/paging.size),pageIndex:paging.index,pageSize:paging.size}};
  2497. }
  2498. 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; }
  2499. 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 []; } }
  2500. function courseAssignedUnits(value) { let source=value;if(typeof source==='string'&&source.trim()){try{source=JSON.parse(source);}catch(_){return[];}}const entries=Array.isArray(source)?source.map((item,index)=>[String(index),item]):source&&typeof source==='object'?Object.entries(source):[],seen=new Set(),units=[];for(const [key,item] of entries){const object=item&&typeof item==='object'&&!Array.isArray(item)?item:null,text=String(object?firstValue(object.name,object.title,object.NodeName,''):item||''),parts=text.split('@'),id=String(firstValue(object&&firstValue(object.id,object.nodeId,object.NodeID),/^\d+$/.test(key)?key:parts[0],'')||'').trim();if(!/^\d+$/.test(id)||seen.has(id))continue;seen.add(id);const name=String(firstValue(parts[1],object&&firstValue(object.name,object.title,object.NodeName),parts[0],'学习单元 '+id)||'').trim();units.push({id,name:name||'学习单元 '+id});}return units; }
  2501. 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))) }; }
  2502. function ownLegacyId(current) { return number(current && (current.get('legacyUserId') || current.get('userid') || current.get('num'))); }
  2503. async function legacyUserObject(legacyId) { const fields = await fieldsOf('_User'); return findByLegacyId('_User', fields, legacyId, ['legacyUserId','userid','num']); }
  2504. async function chainContains(startId, expectedAncestorId) {
  2505. let cursor = number(startId); const expected = number(expectedAncestorId); const visited = new Set();
  2506. 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); }
  2507. return false;
  2508. }
  2509. async function authorizeTeamUser(current, requestedId) {
  2510. const ownId = ownLegacyId(current); const targetId = number(requestedId || ownId); if (!targetId) fail(400, '缺少用户 ID'); if (isAdminUser(current) || targetId === ownId) return targetId;
  2511. if (await chainContains(targetId, ownId)) return targetId; if (await chainContains(ownId, targetId)) return targetId; fail(403, '无权查看该团队成员');
  2512. }
  2513. 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"'; }
  2514. async function teamUserPage(input, current) {
  2515. const paging = pageInput(input); const parentId = number(input.puid || ownLegacyId(current)); if (!parentId && !isAdminUser(current)) fail(400, '团队列表必须指定上级用户'); if (parentId) await authorizeTeamUser(current,parentId);
  2516. 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); }
  2517. 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[])'); }
  2518. 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,' : '';
  2519. 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);
  2520. return { rows:rows.map(legacyUserRow), page:{ itemCount:total, pageCount:Math.ceil(total / paging.size), pageIndex:paging.index, pageSize:paging.size } };
  2521. }
  2522. async function teamStats(input,current) {
  2523. 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;
  2524. }
  2525. async function coachStudentPage(input,current) {
  2526. 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 } };
  2527. }
  2528. const POINT_HISTORY_CLASSES = { 1:'UserExpDomP',2:'UserSIcon',3:'UserExpHis',4:'UserUserPoint',5:'UserDummyPoint',6:'UserCredit' };
  2529. 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; }
  2530. async function pointHistoryPage(input,current) {
  2531. 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 } : {} };
  2532. }
  2533. async function createGuestbook(input,current) {
  2534. 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 };
  2535. }
  2536. async function authorizeRequestedUser(current, requestedId) {
  2537. const targetId = number(requestedId || ownLegacyId(current)); if (!targetId) fail(400, '缺少用户 ID'); if (isAdminUser(current) || targetId === ownLegacyId(current)) return targetId;
  2538. 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) return targetId;
  2539. return appLearningTarget(current, targetId);
  2540. }
  2541. async function authorizeBusinessRow(current, row, subjectFields, actorFields = []) {
  2542. 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;
  2543. 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, '无权查看该业务记录');
  2544. }
  2545. async function authorizeLearningRecordAccess(current, row) {
  2546. if (isAdminUser(current)) return; const ownId=ownLegacyId(current),ownerId=number(row.userId??row.UserID),coachId=number(row.pl);if([ownerId,coachId].filter(Boolean).includes(ownId))return;if(!ownerId)fail(403,'学习记录缺少可验证的学员');await appLearningTarget(current,ownerId);
  2547. }
  2548. function normalizeWordRow(source) { const addon = source.__addon || {}; const merged = { ...source, ...addon }; delete merged.__addon; return legacyAliases(merged, 'CommonModel'); }
  2549. async function orderDetailRow(inputId, current, authorize = true) {
  2550. 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]);
  2551. 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 || '课程预约' }; }
  2552. 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.cancelled=number(source.status)===-2; 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;
  2553. }
  2554. async function recordDetailData(input, current) {
  2555. 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]);
  2556. if (!rows.length) return null; const source = rows[0]; const recordAddon = source.__addon || {}; delete source.__addon; const record = legacyAliases({ ...source, ...recordAddon }, 'CommonModel'); await authorizeLearningRecordAccess(current, record); const storedWords = parseArray(recordAddon.xxqs); const ids = [...new Set(storedWords.map((item) => String(item && (item.GeneralID ?? item.generalId ?? item.id) || '')).filter(Boolean))]; let wordRows = [];
  2557. 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]);
  2558. 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] }; });
  2559. const order = recordAddon.dsid ? await orderDetailRow(recordAddon.dsid, current, false) : null; return { words, addon: order ? [order] : [], record };
  2560. }
  2561. async function newWordPage(input, current) {
  2562. 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]));
  2563. 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 } };
  2564. }
  2565. async function courseWordPage(input, current) {
  2566. 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(500, Math.max(1, number(input.psize || input.pageSize, 100))) }; const values = [DEFAULT_COMPANY_ID, String(uid), nodes]; const where = 'c."company" = $1 AND c."modelId" = 52 AND CAST(c."nodeId" AS text) = ANY($3::text[])';
  2567. const countPromise = Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c WHERE ' + where, values);
  2568. const rowPromise = Psql.query('SELECT c.*, row_to_json(v) AS "__addon", COALESCE(p."learned",0) AS "__learned", COALESCE(p."newWord",0) AS "__newWord" FROM "CommonModel" c LEFT JOIN "VocabularyWord" v ON v."company" = $1 AND v."id" = c."itemId" LEFT JOIN (SELECT CAST(pr."scid" AS text) AS "wordId", MAX(CASE WHEN CAST(pr."xxcs" AS text) ~ \'^[0-9]+(?:\\.[0-9]+)?$\' THEN CAST(pr."xxcs" AS numeric) ELSE 0 END)::int AS "learned", MAX(CASE WHEN LOWER(CAST(pr."jrscb" AS text)) IN (\'1\',\'true\') THEN 1 ELSE 0 END)::int AS "newWord" FROM "PracticeRecord" pr WHERE pr."company" = $1 AND CAST(pr."yhid" AS text) = $2 GROUP BY CAST(pr."scid" AS text)) p ON p."wordId" = CAST(c."generalId" AS text) WHERE ' + where + ' ORDER BY c."orderId" ASC, c."generalId" ASC LIMIT $4 OFFSET $5', values.concat([paging.size, (paging.index - 1) * paging.size]));
  2569. const [countRow, rows] = await Promise.all([countPromise, rowPromise]); 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 } };
  2570. }
  2571. async function updateLearningWords(input, current) {
  2572. 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);
  2573. 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(','));
  2574. 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;
  2575. 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); }
  2576. const operations=changed.map(item=>{const data=mobileObjectData(item);if(saveLearning)data.xxcs=1;return{table:'PracticeRecord',data,match:{company:DEFAULT_COMPANY_ID,yhid:uid,scid:number(item.get('scid'))},increment:saveLearning?['xxcs']:[]};});
  2577. const saved=await mobileAtomicWrite(input,current,operations,String(uid));return{affected:saved.length,created,learnedIncremented:saveLearning?saved.length:0,newWordState:newWordFlag===null?undefined:number(newWordFlag),objectIds:saved.map(item=>item.objectId)};
  2578. }
  2579. 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 + '格式错误'); }
  2580. function cleanText(value, max) { return String(value == null ? '' : value).trim().slice(0, max); }
  2581. 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; }
  2582. async function upsertStudyRecord(input, current) {
  2583. 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) || '学习记录';
  2584. 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;
  2585. if (found) { record = await new Parse.Query('DailyStudyRecord').get(found.recordObjectId, { useMasterKey: true }); common = await new Parse.Query('CommonModel').get(found.commonObjectId, { useMasterKey: true }); }
  2586. 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]))); } };
  2587. const recordId=Math.floor(100000000000000+Math.random()*800000000000000);
  2588. if(!record){record=new Parse.Object('DailyStudyRecord');record.set('company',current.get('company'));record.set('sourceKey','cloud:stu_record:'+uid+':'+orderId);}
  2589. applyFields(record);
  2590. if(!common){common=new Parse.Object('CommonModel');common.set('company',current.get('company'));common.set('sourceKey','cloud:stu_record:'+uid+':'+orderId);common.set('generalId',recordId+1);common.set('itemId',recordId);common.set('modelId',56);common.set('nodeId',291);common.set('tableName','ZL_C_ss');common.set('status',99);}
  2591. common.set('title',title);common.set('inputer',inputer);common.set('hits',number(record.get('learned')));
  2592. const saved=await mobileAtomicWrite(input,current,[{table:'DailyStudyRecord',data:{...mobileObjectData(record),id:recordId},match:{company:DEFAULT_COMPANY_ID,userId:uid,dsid:orderId}},{table:'CommonModel',data:mobileObjectData(common),match:{company:DEFAULT_COMPANY_ID,modelId:56,itemId:recordId},reference:{itemId:'id'}}],String(uid));
  2593. return{GeneralID:number(saved[1].generalId),generalId:number(saved[1].generalId),objectId:saved[1].objectId,recordObjectId:saved[0].objectId,created:!found};
  2594. }
  2595. function reviewSchedule() {
  2596. 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');
  2597. 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(',');
  2598. }
  2599. async function createStudyContent(input, current) {
  2600. 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');
  2601. 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;
  2602. 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', addon.createReviewPlan === false ? '' : 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)));
  2603. 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));const linked=number(addon.dsid)>0?await orderDetailRow(String(addon.dsid),current,false):null;if(number(addon.dsid)>0&&(!linked||number(linked.szyh)!==uid))fail(403,'学习记录与预约学员不匹配');if(linked?.bindingId)record.set('bindingId',String(linked.bindingId));} if (addon.szmdid !== undefined) record.set('szmdid', cleanText(addon.szmdid, 100));
  2604. if(addon.bindingId&&!record.get('bindingId')){const binding=await Psql.oneOrNone('SELECT b."objectId" FROM "CourseBinding" b JOIN "CommonModel" c ON c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" WHERE b."company"=$1 AND b."yhid"::text=$2 AND (b."objectId"=$3 OR b."id"::text=$3) AND b."enrollmentStatus"=\'active\' AND c."status"=99',[DEFAULT_COMPANY_ID,String(uid),String(addon.bindingId)]);if(!binding)fail(409,'开课记录已失效');record.set('bindingId',binding.objectId);}
  2605. 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)); const saved=await mobileAtomicWrite(input,current,[{table:'DailyStudyRecord',data:{...mobileObjectData(record),id:recordId},match:{company:DEFAULT_COMPANY_ID,sourceKey:record.get('sourceKey')}},{table:'CommonModel',data:mobileObjectData(common),match:{company:DEFAULT_COMPANY_ID,sourceKey:common.get('sourceKey')},reference:{itemId:'id'}}],String(uid));
  2606. return String(saved[1].generalId);
  2607. }
  2608. 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); }
  2609. function writeValue(value, definition) {
  2610. if (!definition) return value; if (definition.type === 'Number') { const parsed = Number(value); if (!Number.isFinite(parsed)) fail(400, '数值字段格式错误'); return parsed; }
  2611. 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; }
  2612. if (definition.type === 'String') return typeof value === 'string' ? value.slice(0, 50000) : JSON.stringify(value).slice(0, 50000); fail(400, '不支持写入字段类型 ' + definition.type);
  2613. }
  2614. 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; }
  2615. function restoreFields(object, snapshot) { for (const [field,state] of Object.entries(snapshot)) { if (state.exists) object.set(field, state.value); else object.unset(field); } }
  2616. async function updateContentPair(input, current) {
  2617. 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');
  2618. const resolved = await resolveContent({ id: generalId }, true); const common = resolved.object; if (!common) fail(404, '指定内容不存在'); const modelId = number(common.get('modelId')); if(modelId===58&&Object.keys(parseObject(input.addon,'附表内容')).some(key=>!['syjd','yxx'].includes(key.toLowerCase())))fail(403,'开课内容和分类只能在管理后台修改');if(modelId===54&&Object.keys(parseObject(input.addon,'附表内容')).some(key=>['kcid','yykcid','szyh','dslx','dszt','pl','fxpl'].includes(key.toLowerCase())))fail(403,'请通过排课管理修改课程和课时规则'); if(modelId===59&&Object.keys(addonInput).some(key=>['yyds','kcid','kclx','xymz','jsmz'].includes(key.toLowerCase())))fail(403,'不能修改完课记录的预约、课程和参与人'); 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');
  2619. 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); if(modelId===59&&!isAdminUser(current)){const actor=ownLegacyId(current),allowed=actor===number(existing.jsmz)?['pldp','plpjsj']:actor===number(existing.xymz)?['pf','pjnr']:[];if(Object.keys(addonInput).some(k=>!allowed.includes(k.toLowerCase())))fail(403,'只能保存本人负责的课程评价');}
  2620. if (modelId === 60) { if (number(addonInput.fxzt) !== 1 || Object.keys(addonInput).some((key)=>!['fxzt','wcsj'].includes(key))) fail(400,'复习任务仅支持完成操作'); await Psql.one('SELECT xiaoshu_complete_review($1,$2)',[DEFAULT_COMPANY_ID,addon.id]); return String(common.get('generalId')); }
  2621. 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); }
  2622. 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'); }
  2623. if (!addonChanged.length && commonChanged.length === 1) fail(400, '没有可更新的内容字段'); if([56,59,61].includes(modelId)){await mobileAtomicWrite(input,current,[{table:config.className,data:Object.fromEntries(Object.entries(mobileObjectData(addon)).filter(([key])=>addonChanged.includes(key)||key==='objectId')),match:{objectId:addon.id}},{table:'CommonModel',data:Object.fromEntries(Object.entries(mobileObjectData(common)).filter(([key])=>commonChanged.includes(key)||key==='objectId')),match:{objectId:common.id}}],String(existing[auth.subjects[0]]));return String(common.get('generalId'));} 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'));
  2624. }
  2625. async function createContentPair(input, current) {
  2626. const content = parseObject(input.content, '内容'); const addonInput = parseObject(input.addon, '附表内容'); const modelId = number(content.ModelID ?? content.modelId); if([54,58,59].includes(modelId))fail(403,'请通过管理后台开课排课或正式完课流程创建业务记录'); 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');
  2627. 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;
  2628. 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])); }
  2629. 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); }
  2630. if([56,61].includes(modelId)){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)); const saved=await mobileAtomicWrite(input,current,[{table:config.className,data:{...mobileObjectData(addon),company:DEFAULT_COMPANY_ID,id:recordId},match:{company:DEFAULT_COMPANY_ID,sourceKey:addon.get('sourceKey')}},{table:'CommonModel',data:mobileObjectData(common),match:{company:DEFAULT_COMPANY_ID,sourceKey:common.get('sourceKey')},reference:{itemId:'id'}}],String(ownerId));return String(saved[1].generalId);}
  2631. 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; }
  2632. return String(generalId);
  2633. }
  2634. 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; }
  2635. 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; }
  2636. async function deductOrderPeriod(student, uid, type, orderId, detail) {
  2637. 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 };
  2638. }
  2639. async function rollbackOrderPeriod(studentId, config, periodKey, pointKey) {
  2640. 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]);
  2641. }
  2642. const CONFIGURED_CREDIT_META={Purse:{ledger:'UserExpDomP',scoreType:1,label:'30 分钟课时'},SilverCoin:{ledger:'UserSIcon',scoreType:2,label:'60 分钟课时'},UserExp:{ledger:'UserExpHis',scoreType:3,label:'体验课时'},UserPoint:{ledger:'UserUserPoint',scoreType:4,label:'陪练时长'}};
  2643. function configuredCreditCosts(appointment,order){const raw=appointment&&appointment.get('creditCostsSnapshot');return(Array.isArray(raw)?raw:Array.isArray(order&&order.creditCostsSnapshot)?order.creditCostsSnapshot:[]).map((item)=>({account:String(item&&item.account||''),amount:number(item&&item.amount)})).filter((item)=>CONFIGURED_CREDIT_META[item.account]&&item.amount>0);}
  2644. async function ensureConfiguredCreditBalance(student,costs){const data=legacyData(student),shortages=costs.filter((cost)=>number(data[cost.account])<cost.amount);if(shortages.length)fail(409,'可用课时不足:'+shortages.map((cost)=>CONFIGURED_CREDIT_META[cost.account].label+'还需 '+String(Math.max(0,cost.amount-number(data[cost.account])))).join(',')+'。请先充值或联系管理员调账');return data;}
  2645. async function deductConfiguredCredits(student,uid,costs,orderId,detail){const keys=costs.map((cost)=>'cloud:e_order_update:'+orderId+':configured:'+cost.account),existing=[];for(let index=0;index<costs.length;index+=1){const meta=CONFIGURED_CREDIT_META[costs[index].account],row=await Psql.oneOrNone('SELECT "objectId" FROM "'+meta.ledger+'" WHERE "company"=$1 AND "sourceKey"=$2 LIMIT 1',[DEFAULT_COMPANY_ID,keys[index]]);existing.push(Boolean(row));}if(existing.every(Boolean))return{claimed:false,configured:true,logs:[]};if(existing.some(Boolean))fail(409,'该订单的课时流水不完整,请联系管理员核对');const before=await ensureConfiguredCreditBalance(student,costs),after={...before};for(const cost of costs)after[cost.account]=number(after[cost.account])-cost.amount;student.set('legacyUserData',after);await student.save(null,{useMasterKey:true});const logs=[];try{for(let index=0;index<costs.length;index+=1){const cost=costs[index],meta=CONFIGURED_CREDIT_META[cost.account],log=new Parse.Object(meta.ledger);log.set('company',companyPointer());log.set('sourceKey',keys[index]);log.set('score',-cost.amount);log.set('scoreBefore',number(before[cost.account]));log.set('userId',uid);log.set('hisTime',new Date());log.set('operator',2);log.set('scoreType',meta.scoreType);log.set('detail',detail+',扣减'+meta.label+' '+cost.amount);log.set('remark','教师端配置化完课');await log.save(null,{useMasterKey:true});logs.push(log);}return{claimed:true,configured:true,logs,before};}catch(error){for(const log of logs.reverse())await log.destroy({useMasterKey:true}).catch(()=>undefined);student.set('legacyUserData',before);await student.save(null,{useMasterKey:true}).catch(()=>undefined);throw error;}}
  2646. async function rollbackConfiguredCredits(student,period){if(!period||!period.claimed)return;for(const log of(period.logs||[]).reverse())await log.destroy({useMasterKey:true}).catch(()=>undefined);student.set('legacyUserData',period.before||{});await student.save(null,{useMasterKey:true});}
  2647. async function markConfiguredReservation(appointment,state){const row=await Psql.oneOrNone('SELECT "objectId" FROM "CourseCreditReservation" WHERE "company"=$1 AND "appointmentId"=$2 LIMIT 1',[DEFAULT_COMPANY_ID,appointment.id]).catch(()=>null);if(!row)return;const reservation=await new Parse.Query('CourseCreditReservation').get(row.objectId,{useMasterKey:true});reservation.set('state',state);if(state==='consumed')reservation.set('consumedAt',new Date());await reservation.save(null,{useMasterKey:true});}
  2648. async function memoryReviewsForOrder(orderId, order, current) {
  2649. 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 = [];
  2650. 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; }
  2651. }
  2652. 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; }
  2653. async function updateOrderStatus(input, current) {
  2654. 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:[20,30]})[status]; if (!allowed.includes(previous)) fail(409,'预约状态不能从 '+previous+' 更新为 '+status); if (previous === status) return { GeneralID:orderId,previousStatus:previous,status,unchanged:true };
  2655. if(status===10||status===11)return enrollmentOrderTransition(input,current,order,orderId,status);
  2656. const result=await Psql.one('SELECT xs_mobile_confirm_appointment($1,$2,$3,$4,$5) AS result',[DEFAULT_COMPANY_ID,orderId,status,actorId,isAdminUser(current)]);return result.result;
  2657. }
  2658. async function ensureLessonForCompletion(orderId,order,current,feedback){const found=await Psql.oneOrNone('SELECT c."objectId" AS "commonObjectId",c."generalId",l."objectId" AS "lessonObjectId" FROM "LessonRecord" l LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=59 AND c."itemId"=l."id" WHERE l."company"=$1 AND CAST(l."yyds" AS text)=$2 ORDER BY l."updatedAt" DESC LIMIT 1',[DEFAULT_COMPANY_ID,String(orderId)]);if(found&&found.lessonObjectId)return{lessonId:String(found.generalId||found.lessonObjectId),created:false};const recordId=Math.floor(100000000000000+Math.random()*800000000000000),generalId=recordId+1,lesson=new Parse.Object('LessonRecord'),common=new Parse.Object('CommonModel'),now=new Date();lesson.set('company',current.get('company'));lesson.set('sourceKey','cloud:lesson-completion:'+orderId);lesson.set('jsmz',String(number(order.pl)));lesson.set('xymz',String(number(order.szyh)));lesson.set('kcid',String(number(order.kcid)));lesson.set('kclx',String(number(order.dslx)));lesson.set('kcmc',cleanText(order.kcmc||order.yykcmc,200));lesson.set('yyds',String(orderId));lesson.set('jffs',cleanText(order.jffs,30));lesson.set('szmdid',cleanText(order.szmd,100));lesson.set('kzsj','[]');lesson.set('pldp',cleanText(feedback.notes,1000));lesson.set('plpjsj',now.toISOString());await lesson.save(null,{useMasterKey:true});try{await Psql.none('UPDATE "LessonRecord" SET "id"=$1 WHERE "objectId"=$2',[recordId,lesson.id]);common.set('company',current.get('company'));common.set('sourceKey','cloud:lesson-completion:'+orderId);common.set('generalId',generalId);common.set('itemId',recordId);common.set('modelId',59);common.set('nodeId',296);common.set('tableName','ZL_C_skjl');common.set('title',cleanText(order.HoneyName||'学员',100)+' '+cleanText(order.kcmc||order.yykcmc||'课程',100));common.set('inputer',cleanText(current.get('nickname')||current.get('username'),100));common.set('status',99);await common.save(null,{useMasterKey:true});return{lessonId:String(generalId),created:true};}catch(error){await lesson.destroy({useMasterKey:true}).catch(()=>undefined);throw error;}}
  2659. async function completeOrderWithFeedback(input,current){const orderId=String(input.orderId||input.id||'').trim(),feedback=parseObject(input.feedback,'完课反馈');if(!orderId)fail(400,'缺少预约 ID');feedback.contentSummary=cleanText(feedback.contentSummary,1000);feedback.reviewStatus=cleanText(feedback.reviewStatus,30);feedback.reviewSummary=cleanText(feedback.reviewSummary,1000);feedback.trainingStatus=cleanText(feedback.trainingStatus,30);feedback.trainingSummary=cleanText(feedback.trainingSummary,1000);feedback.homework=cleanText(feedback.homework,1000);feedback.notes=cleanText(feedback.notes,1000);feedback.noHomework=feedback.noHomework===true;if(!feedback.contentSummary)fail(400,'请填写本次学习内容');if(!['完成','部分完成','未复习','无复习任务'].includes(feedback.reviewStatus))fail(400,'请选择有效的复习情况');if(!['正常进行','需要巩固','已完成','暂停'].includes(feedback.trainingStatus)||!feedback.trainingSummary)fail(400,'请选择训练状态并填写进度说明');if(!feedback.noHomework&&!feedback.homework)fail(400,'请填写作业安排,或选择无作业');const sourceKey='cloud:lesson-feedback:'+DEFAULT_COMPANY_ID+':'+orderId,existing=await Psql.oneOrNone('SELECT * FROM "LessonCompletionReport" WHERE "company"=$1 AND "sourceKey"=$2 LIMIT 1',[DEFAULT_COMPANY_ID,sourceKey]).catch(()=>null);const order=await orderDetailRow(orderId,current,false);if(!order)fail(404,'预约记录不存在');const actorId=ownLegacyId(current);if(!isAdminUser(current)&&actorId!==number(order.pl))fail(403,'仅预约陪练可以提交完课反馈');if(existing)return{GeneralID:orderId,status:11,unchanged:true,feedback:safe(existing)};const transition=await updateOrderStatus({...input,status:11,content:input.content||JSON.stringify({GeneralID:orderId})},current),appointment=await appointmentObject(orderId),lesson=await ensureLessonForCompletion(orderId,order,current,feedback),study=await Psql.oneOrNone('SELECT "xxqs" FROM "DailyStudyRecord" WHERE "company"=$1 AND CAST("userId" AS text)=$2 AND CAST("dsid" AS text)=$3 ORDER BY "updatedAt" DESC LIMIT 1',[DEFAULT_COMPANY_ID,String(number(order.szyh)),orderId]),words=parseArray(study&&study.xxqs),anti=await Psql.one('SELECT COUNT(*)::int AS total,COUNT(*) FILTER(WHERE CAST("fxzt" AS text)=\'1\')::int AS completed FROM "MemoryPracticeRecord" WHERE "company"=$1 AND CAST("orderId" AS text)=$2',[DEFAULT_COMPANY_ID,orderId]).catch(()=>({total:0,completed:0})),report=new Parse.Object('LessonCompletionReport'),now=new Date();report.set('company',current.get('company'));report.set('sourceKey',sourceKey);report.set('appointmentId',appointment?appointment.id:'');report.set('appointmentGeneralId',number(orderId));report.set('lessonId',lesson.lessonId);report.set('studentId',number(order.szyh));report.set('coachId',number(order.pl));report.set('courseCategoryKey',cleanText(order.courseCategoryKey,80));report.set('courseCategoryName',cleanText(order.courseCategoryName,100));report.set('deliveryMode',cleanText(order.deliveryMode||order.jffs,30));report.set('durationMinutes',number(order.durationMinutes,number(order.dslx)===1?30:60));const start=new Date(order.kssj||now),end=new Date(order.jssj||now);if(!Number.isNaN(start.getTime()))report.set('actualStartAt',start);if(!Number.isNaN(end.getTime()))report.set('actualEndAt',end);report.set('creditCosts',configuredCreditCosts(appointment,order));report.set('contentSummary',feedback.contentSummary);report.set('vocabularyCount',words.length);report.set('reviewStatus',feedback.reviewStatus);report.set('reviewSummary',feedback.reviewSummary);report.set('trainingStatus',feedback.trainingStatus);report.set('trainingSummary',feedback.trainingSummary);report.set('antiForgetSummary',{total:number(anti.total),completed:number(anti.completed),pending:Math.max(0,number(anti.total)-number(anti.completed))});report.set('homework',feedback.noHomework?'无作业':feedback.homework);report.set('noHomework',feedback.noHomework);report.set('notes',feedback.notes);report.set('submittedBy',cleanText(current.get('nickname')||current.get('username'),100));report.set('submittedAt',now);const saved=await mobileAtomicWrite({...input,requestKey:'feedback:'+orderId},current,[{table:'LessonCompletionReport',data:{...mobileObjectData(report),company:DEFAULT_COMPANY_ID},match:{company:DEFAULT_COMPANY_ID,sourceKey}}],String(number(order.szyh)));return{...transition,lessonId:lesson.lessonId,vocabularyCount:words.length,antiForgetSummary:report.get('antiForgetSummary'),feedbackId:saved[0].objectId};}
  2660. async function orderStatistics(input, current) {
  2661. 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;
  2662. 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; }
  2663. 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; }
  2664. return { t30:String(t30), t60:String(t60), t_tiyan:String(tTiyan), t_shichang:String(duration), t_total:String(total), t_yongji:String(commission) };
  2665. }
  2666. function appOverviewDateKey(value) {
  2667. const raw=String(value||'').trim(),compact=raw.match(/^(\d{4})(\d{2})(\d{2})(?:\D|$)/),dashed=raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
  2668. if(compact)return compact[1]+'-'+compact[2]+'-'+compact[3];if(dashed)return dashed[1]+'-'+dashed[2].padStart(2,'0')+'-'+dashed[3].padStart(2,'0');return '';
  2669. }
  2670. function appOverviewToday() {
  2671. const parts=new Intl.DateTimeFormat('en-US',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit'}).formatToParts(new Date()),values={};for(const part of parts)values[part.type]=part.value;return values.year+'-'+values.month+'-'+values.day;
  2672. }
  2673. function appOverviewScheduleRow(source) {
  2674. return { objectId:String(source.objectId||source.appointmentObjectId||''),GeneralID:number(source.generalId||source.id),ID:number(source.generalId||source.id),Title:String(source.title||source.courseTitle||'课程预约'),Title2:String(source.courseTitle||''),CreateTime:firstValue(source.createTime,source.createdAt),cancelled:number(source.commonStatus)===-2,status:number(source.commonStatus,99),bindingId:source.bindingId,courseCategoryKey:source.courseCategoryKey,courseCategoryName:source.courseCategoryName,pl:source.pl,bxrq:source.bxrq,dslx:source.dslx,dszt:source.dszt,fxpl:source.fxpl,jffs:source.jffs,jssj:source.jssj,kcid:source.kcid,kssj:source.kssj,plxm:source.plxm,scsj:source.scsj,sdsd:source.sdsd,szmd:source.szmd,szyh:source.szyh,yysj:source.yysj,yykcid:source.yykcid };
  2675. }
  2676. function appOverviewCourseRow(source) {
  2677. return { objectId:String(source.objectId||source.bindingObjectId||''),GeneralID:number(source.generalId||source.id),ID:number(source.generalId||source.id),Title:String(source.title||source.courseTitle||'课程内容'),Title2:String(source.courseTitle||''),CreateTime:firstValue(source.createTime,source.createdAt),bindingId:String(source.bindingObjectId||source.objectId||''),categoryKey:source.categoryKey,categoryName:source.categoryName,id:source.id,yxx:source.yxx,cksl:source.cksl,kcid:source.kcid,syjd:source.syjd,yhid:source.yhid };
  2678. }
  2679. function appOverviewStudyRow(row) {
  2680. if(!row)return null;return{objectId:String(row.objectId||''),GeneralID:number(row.generalId||row.id),ID:number(row.generalId||row.id),Title:String(row.title||row.con||'学习记录'),CreateTime:firstValue(row.createTime,row.createdAt),pl:row.pl,con:row.con,djq:row.djq,ygg:row.ygg,dqrq:row.dqrq,dsid:row.dsid,fxrl:row.fxrl,xxqs:row.xxqs,szmdid:row.szmdid,UserID:row.userId,userId:row.userId,learned:row.learned};
  2681. }
  2682. async function appOverviewCompletedReadingMap(records,targetId) {
  2683. const studyIds=[...new Set(records.map((row)=>String(row&&row.GeneralID||'').trim()).filter(Boolean))];if(!studyIds.length)return new Map();
  2684. const learner=await legacyUserObject(targetId);if(!learner)return new Map();const ownerIds=[learner.id,'_User$'+learner.id];
  2685. const rows=await Psql.query('SELECT DISTINCT ON (a."createOptions"#>>\'{params,sourceStudyId}\') a."createOptions"#>>\'{params,sourceStudyId}\' AS "sourceStudyId",a."objectId" AS "readingArticleId",a."title" AS "readingTitle",completed."completedAt" AS "readingCompletedAt" FROM "SurveyItem" a JOIN LATERAL (SELECT COALESCE(l."createdAt",l."updatedAt") AS "completedAt" FROM "SurveyLog" l WHERE CAST(l."surveyItem" AS text)=ANY(ARRAY[CAST(a."objectId" AS text),\'SurveyItem$\'||CAST(a."objectId" AS text)]) AND CAST(l."user" AS text)=ANY($1::text[]) ORDER BY COALESCE(l."createdAt",l."updatedAt") DESC,l."objectId" DESC LIMIT 1) completed ON TRUE WHERE CAST(a."user" AS text)=ANY($1::text[]) AND a."type"=\'reading\' AND COALESCE(a."isDeleted",FALSE)=FALSE AND a."createOptions"#>>\'{params,sourceStudyId}\'=ANY($2::text[]) ORDER BY a."createOptions"#>>\'{params,sourceStudyId}\',completed."completedAt" DESC,COALESCE(a."createdAt",a."updatedAt") DESC',[ownerIds,studyIds]);
  2686. const map=new Map();for(const row of rows)map.set(String(row.sourceStudyId),{readingArticleId:String(row.readingArticleId||''),readingTitle:String(row.readingTitle||''),readingCompletedAt:row.readingCompletedAt});return map;
  2687. }
  2688. async function appLearningTarget(current,requestedId) {
  2689. const ownId=ownLegacyId(current),targetId=number(requestedId||ownId);if(!targetId)fail(400,'缺少学员 ID');if(isAdminUser(current)||targetId===ownId)return targetId;
  2690. if(number(current.get('legacyGroupId')||current.get('groupId'))!==3)fail(403,'仅关联老师可以查看学员学习记录');
  2691. const linked=await Psql.oneOrNone('SELECT 1 AS found FROM "CourseAppointment" WHERE "company"=$1 AND "pl"=$2 AND "szyh"=$3 LIMIT 1',[DEFAULT_COMPANY_ID,String(ownId),String(targetId)]);if(!linked)fail(403,'无权查看该学员的学习记录');return targetId;
  2692. }
  2693. const LEGACY_LEARNING_PULLS=new Map();
  2694. function legacyLearningIso(value){const raw=String(value||'').trim();if(!raw)return'';const normalized=/^\d{4}-\d{1,2}-\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?)?$/.test(raw)?raw.replace(/^(\d{4})-(\d)(?=-)/,'$1-0$2').replace(/-(\d)(?=[ T])/,'-0$1').replace(' ','T')+(raw.includes('T')||raw.includes(' ')?'+08:00':'T00:00:00+08:00'):raw;const parsed=new Date(normalized);return Number.isNaN(parsed.getTime())?'':parsed.toISOString();}
  2695. function legacyLearningJson(value){if(value===undefined||value===null||value==='')return'';if(typeof value==='string'){try{return JSON.stringify(JSON.parse(value));}catch(_){return value;}}try{return JSON.stringify(value);}catch(_){return String(value);}}
  2696. function legacyLearningRecord(source,targetId){const generalId=number(source&&firstValue(source.GeneralID,source.generalId)),userId=number(source&&firstValue(source.UserID,source.userId));if(!generalId||userId!==number(targetId))return null;const words=legacyLearningJson(source.xxqs),learned=number(source.learned,parseArray(words).length),createdAt=legacyLearningIso(firstValue(source.CreateTime,source.createTime,source.UpDateTime,source.updatedAt))||new Date().toISOString();return{generalId,itemId:generalId,sourceKey:'legacy:model:56:general:'+generalId,createdAt,common:{generalId,itemId:generalId,modelId:56,nodeId:number(firstValue(source.NodeID,source.nodeId),291)||291,tableName:'ZL_C_ss',title:String(firstValue(source.Title,source.title,source.con,'学习记录')||'学习记录').slice(0,200),subtitle:String(firstValue(source.Subtitle,source.subtitle,'')||'').slice(0,500),inputer:String(firstValue(source.Inputer,source.inputer,'')||'').slice(0,100),hits:number(firstValue(source.Hits,source.hits,learned)),status:number(firstValue(source.Status,source.status),99),createTime:createdAt},addon:{pl:String(source.pl||''),con:String(source.con||'').slice(0,1000),djq:number(source.djq),ygg:number(source.ygg),dqrq:String(source.dqrq||'').replace(/\D/g,'').slice(0,8),dsid:String(source.dsid||'').slice(0,100),fxrl:String(source.fxrl||'').slice(0,1000),xxqs:words,szmdid:String(source.szmdid||'').slice(0,100),userId,learned}};}
  2697. function legacyLearningComparable(row){if(!row)return null;return{title:String(row.title||''),subtitle:String(row.subtitle||''),inputer:String(row.inputer||''),hits:number(row.hits),status:number(row.status,99),pl:String(row.pl||''),con:String(row.con||''),djq:number(row.djq),ygg:number(row.ygg),dqrq:String(row.dqrq||''),dsid:String(row.dsid||''),fxrl:String(row.fxrl||''),xxqs:legacyLearningJson(row.xxqs),szmdid:String(row.szmdid||''),userId:number(row.userId),learned:number(row.learned)};}
  2698. function legacyLearningSignature(row){const comparable=legacyLearningComparable(row);return comparable?JSON.stringify(comparable):'';}
  2699. async function legacyLearningPage(config,targetId,page,pageSize){const params=new URLSearchParams({action:'content_list',modelId:'56',nid:'291',myfield2:'UserId='+targetId,orders:'CreateTime=desc',cpage:String(page),psize:String(pageSize),apiId:config.apiId,apiKey:config.apiKey}),url=config.url+(config.url.includes('?')?'&':'?')+params.toString();let response,raw,payload;try{response=await fetch(url,{headers:{accept:'application/json'},signal:typeof AbortSignal!=='undefined'&&AbortSignal.timeout?AbortSignal.timeout(12000):undefined});raw=await response.text();payload=JSON.parse(raw);}catch(error){throw new Error('旧系统学习记录读取失败');}if(!response.ok||number(payload&&payload.retcode)===-1)throw new Error(String(payload&&payload.retmsg||'旧系统学习记录读取失败'));let result=payload&&payload.result;if(typeof result==='string')try{result=JSON.parse(result);}catch(_){result=[];}const rows=Array.isArray(result)?result:Array.isArray(result&&result.items)?result.items:[],meta=payload&&payload.page||result&&result.page||{};return{rows,pageCount:Math.max(1,number(meta.pageCount,1)),total:number(meta.itemCount,rows.length)};}
  2700. async function performLegacyLearningSync(targetId,current){const config=await appLegacyConfig();if(!config.url||!config.apiId||!config.apiKey)throw new Error('旧系统学习同步尚未配置');const pageSize=500,first=await legacyLearningPage(config,targetId,1,pageSize),pageCount=Math.min(40,first.pageCount),pages=[first];for(let page=2;page<=pageCount;page++)pages.push(await legacyLearningPage(config,targetId,page,pageSize));const byId=new Map();for(const source of pages.flatMap((item)=>item.rows)){const row=legacyLearningRecord(source,targetId);if(row)byId.set(row.generalId,row);}const records=[...byId.values()],ids=records.map((row)=>String(row.generalId));let existing=[];if(ids.length)existing=await Psql.query('SELECT c."generalId",c."itemId",c."title",c."subtitle",c."inputer",c."hits",c."status",c."createTime",d."pl",d."con",d."djq",d."ygg",d."dqrq",d."dsid",d."fxrl",d."xxqs",d."szmdid",d."userId",d."learned" FROM "CommonModel" c LEFT JOIN "DailyStudyRecord" d ON d."company"=c."company" AND d."id"=c."itemId" WHERE c."company"=$1 AND c."modelId"=56 AND CAST(c."generalId" AS text)=ANY($2::text[])',[DEFAULT_COMPANY_ID,ids]);const existingById=new Map(existing.map((row)=>[number(row.generalId),row])),usedIds=new Set((await Psql.query('SELECT CAST("id" AS text) AS id FROM "DailyStudyRecord" WHERE "company"=$1 AND CAST("id" AS text)=ANY($2::text[])',[DEFAULT_COMPANY_ID,ids])).map((row)=>String(row.id))),changed=records.filter((row)=>legacyLearningSignature(existingById.get(row.generalId))!==legacyLearningSignature({...row.common,...row.addon}));let written=0;for(let index=0;index<changed.length;index+=50){const chunk=changed.slice(index,index+50),operations=[];for(const row of chunk){const known=existingById.get(row.generalId),preferred=known?number(known.itemId):row.generalId,itemId=!known&&usedIds.has(String(preferred))?900000000000+row.generalId*100+56:preferred;usedIds.add(String(itemId));const common={objectId:randomObjectId(),createdAt:row.createdAt,company:DEFAULT_COMPANY_ID,sourceKey:row.sourceKey,...row.common,itemId},addon={objectId:randomObjectId(),createdAt:row.createdAt,company:DEFAULT_COMPANY_ID,sourceKey:row.sourceKey,id:itemId,...row.addon};operations.push({table:'DailyStudyRecord',data:addon,match:{company:DEFAULT_COMPANY_ID,id:itemId}},{table:'CommonModel',data:common,match:{company:DEFAULT_COMPANY_ID,modelId:56,generalId:row.generalId},reference:{itemId:'id'}});}if(operations.length){await mobileAtomicWrite({requestKey:'legacy-learning:'+targetId+':'+Date.now()+':'+index},current,operations,String(targetId));written+=chunk.length;}}
  2701. const completedAt=new Date().toISOString();return{state:'current',lastSuccessAt:completedAt,lagSeconds:0,pending:0,pulled:records.length,written,sourceTotal:first.total,complete:first.pageCount<=40};
  2702. }
  2703. async function syncLegacyLearning(targetId,current,force=false){const key=String(targetId),now=Date.now(),cached=LEGACY_LEARNING_PULLS.get(key);if(!force&&cached&&now-cached.startedAt<4000)return cached.promise;const promise=performLegacyLearningSync(targetId,current).catch((error)=>({state:'unavailable',lastSuccessAt:'',lagSeconds:null,pending:0,pulled:0,written:0,message:String(error&&error.message||'旧系统学习记录同步失败')}));LEGACY_LEARNING_PULLS.set(key,{startedAt:now,promise});return promise;}
  2704. const LEGACY_REVIEW_PULLS=new Map();
  2705. function legacyReviewRecord(source,targetId){const generalId=number(source&&firstValue(source.GeneralID,source.generalId)),studentId=number(source&&firstValue(source.yhid,source.UserID,source.userId));if(!generalId||studentId!==number(targetId))return null;const createdAt=legacyLearningIso(firstValue(source.CreateTime,source.createTime,source.UpDateTime,source.updatedAt))||new Date().toISOString();return{generalId,itemId:generalId,sourceKey:'legacy:model:60:general:'+generalId,createdAt,common:{generalId,itemId:generalId,modelId:60,nodeId:number(firstValue(source.NodeID,source.nodeId),388)||388,tableName:'ZL_C_gywjl',title:String(firstValue(source.Title,source.title,'抗遗忘复习')||'抗遗忘复习').slice(0,200),subtitle:String(firstValue(source.Subtitle,source.subtitle,'')||'').slice(0,500),inputer:String(firstValue(source.Inputer,source.inputer,'')||'').slice(0,100),hits:number(firstValue(source.Hits,source.hits)),status:number(firstValue(source.Status,source.status),99),createTime:createdAt},addon:{fxzt:String(firstValue(source.fxzt,'0')||'0'),kcid:number(source.kcid),plid:number(source.plid),wcsj:String(source.wcsj||''),yhid:studentId,kywrq:String(source.kywrq||'').slice(0,20),kywsj:String(source.kywsj||'').slice(0,40),xxjlid:number(source.xxjlid),orderId:number(source.orderId)}};}
  2706. function legacyReviewSignature(row){if(!row)return'';return JSON.stringify({title:String(row.title||''),subtitle:String(row.subtitle||''),inputer:String(row.inputer||''),hits:number(row.hits),status:number(row.status,99),fxzt:String(row.fxzt||'0'),kcid:number(row.kcid),plid:number(row.plid),wcsj:String(row.wcsj||''),yhid:number(row.yhid),kywrq:String(row.kywrq||''),kywsj:String(row.kywsj||''),xxjlid:number(row.xxjlid),orderId:number(row.orderId)});}
  2707. async function legacyReviewPage(config,targetId,page,pageSize){const params=new URLSearchParams({action:'content_list',modelId:'60',nid:'388',myfield2:'yhid='+targetId,orders:'CreateTime=desc',cpage:String(page),psize:String(pageSize),apiId:config.apiId,apiKey:config.apiKey}),url=config.url+(config.url.includes('?')?'&':'?')+params.toString();let response,raw,payload;try{response=await fetch(url,{headers:{accept:'application/json'},signal:typeof AbortSignal!=='undefined'&&AbortSignal.timeout?AbortSignal.timeout(12000):undefined});raw=await response.text();payload=JSON.parse(raw);}catch(error){throw new Error('旧系统抗遗忘记录读取失败');}if(!response.ok||number(payload&&payload.retcode)===-1)throw new Error(String(payload&&payload.retmsg||'旧系统抗遗忘记录读取失败'));let result=payload&&payload.result;if(typeof result==='string')try{result=JSON.parse(result);}catch(_){result=[];}const rows=Array.isArray(result)?result:Array.isArray(result&&result.items)?result.items:[],meta=payload&&payload.page||result&&result.page||{};return{rows,pageCount:Math.max(1,number(meta.pageCount,1)),total:number(meta.itemCount,rows.length)};}
  2708. async function performLegacyReviewSync(targetId,current,force=false){const config=await appLegacyConfig();if(!config.url||!config.apiId||!config.apiKey)throw new Error('旧系统抗遗忘同步尚未配置');const pageSize=500,first=await legacyReviewPage(config,targetId,1,pageSize),targetCount=await Psql.one('SELECT COUNT(*)::int AS total FROM "MemoryPracticeRecord" WHERE "company"=$1 AND "yhid"=$2',[DEFAULT_COMPANY_ID,targetId]),needsFull=force||number(targetCount.total)<first.total,pageCount=needsFull?Math.min(40,first.pageCount):1,pages=[first];if(pageCount>1)pages.push(...await Promise.all(Array.from({length:pageCount-1},(_,index)=>legacyReviewPage(config,targetId,index+2,pageSize))));const byId=new Map();for(const source of pages.flatMap((item)=>item.rows)){const row=legacyReviewRecord(source,targetId);if(row)byId.set(row.generalId,row);}const records=[...byId.values()],ids=records.map((row)=>String(row.generalId));let existing=[];if(ids.length)existing=await Psql.query('SELECT c."generalId",c."itemId",c."title",c."subtitle",c."inputer",c."hits",c."status",m."fxzt",m."kcid",m."plid",m."wcsj",m."yhid",m."kywrq",m."kywsj",m."xxjlid",m."orderId" FROM "CommonModel" c LEFT JOIN "MemoryPracticeRecord" m ON m."company"=c."company" AND m."id"=c."itemId" WHERE c."company"=$1 AND c."modelId"=60 AND CAST(c."generalId" AS text)=ANY($2::text[])',[DEFAULT_COMPANY_ID,ids]);const existingById=new Map(existing.map((row)=>[number(row.generalId),row])),usedIds=new Set((await Psql.query('SELECT CAST("id" AS text) AS id FROM "MemoryPracticeRecord" WHERE "company"=$1 AND CAST("id" AS text)=ANY($2::text[])',[DEFAULT_COMPANY_ID,ids])).map((row)=>String(row.id))),changed=records.filter((row)=>legacyReviewSignature(existingById.get(row.generalId))!==legacyReviewSignature({...row.common,...row.addon}));let written=0;for(let index=0;index<changed.length;index+=50){const chunk=changed.slice(index,index+50),operations=[];for(const row of chunk){const known=existingById.get(row.generalId),preferred=known?number(known.itemId):row.generalId,itemId=!known&&usedIds.has(String(preferred))?900000000000+row.generalId*100+60:preferred;usedIds.add(String(itemId));const common={objectId:randomObjectId(),createdAt:row.createdAt,company:DEFAULT_COMPANY_ID,sourceKey:row.sourceKey,...row.common,itemId},addon={objectId:randomObjectId(),createdAt:row.createdAt,company:DEFAULT_COMPANY_ID,sourceKey:row.sourceKey,id:itemId,...row.addon};operations.push({table:'MemoryPracticeRecord',data:addon,match:{company:DEFAULT_COMPANY_ID,id:itemId}},{table:'CommonModel',data:common,match:{company:DEFAULT_COMPANY_ID,modelId:60,generalId:row.generalId},reference:{itemId:'id'}});}if(operations.length){await mobileAtomicWrite({requestKey:'legacy-review:'+targetId+':'+Date.now()+':'+index},current,operations,String(targetId));written+=chunk.length;}}const completedAt=new Date().toISOString();return{state:'current',lastSuccessAt:completedAt,lagSeconds:0,pending:0,pulled:records.length,written,sourceTotal:first.total,complete:pageCount===first.pageCount};}
  2709. async function syncLegacyReviews(targetId,current,force=false){const key=String(targetId),now=Date.now(),cached=LEGACY_REVIEW_PULLS.get(key);if(!force&&cached&&now-cached.startedAt<10000)return cached.promise;const promise=performLegacyReviewSync(targetId,current,force).catch((error)=>({state:'unavailable',lastSuccessAt:'',lagSeconds:null,pending:0,pulled:0,written:0,message:String(error&&error.message||'旧系统抗遗忘记录同步失败')}));LEGACY_REVIEW_PULLS.set(key,{startedAt:now,promise});return promise;}
  2710. const LEGACY_APPOINTMENT_PULLS=new Map(),LEGACY_APPOINTMENT_VERSIONS=new Map();
  2711. function legacyAppointmentRecord(source,field,targetId){const generalId=number(source&&firstValue(source.GeneralID,source.generalId)),studentId=number(source&&source.szyh),coachId=number(source&&firstValue(source.pl,source.fxpl));if(!generalId||(field==='szyh'?studentId:coachId)!==number(targetId))return null;const commonStatus=number(firstValue(source.Status,source.status),99),row=appOverviewScheduleRow({...source,objectId:'legacy-live:'+generalId,generalId,title:String(firstValue(source.Title,source.title,'课程预约')),courseTitle:String(firstValue(source.Subtitle,source.subtitle,'')),commonStatus,createTime:firstValue(source.CreateTime,source.createTime)});return{...row,source:'legacy-live',readOnly:true};}
  2712. async function legacyAppointmentPage(config,field,targetId,page,pageSize,orders='yysj=asc'){if(!['szyh','pl'].includes(field))throw new Error('旧系统预约筛选字段无效');const params=new URLSearchParams({action:'content_list',nid:'29',myfield2:field+'='+targetId,orders,cpage:String(page),psize:String(pageSize),apiId:config.apiId,apiKey:config.apiKey}),url=config.url+(config.url.includes('?')?'&':'?')+params.toString();let response,payload;try{response=await fetch(url,{headers:{accept:'application/json'},signal:typeof AbortSignal!=='undefined'&&AbortSignal.timeout?AbortSignal.timeout(12000):undefined});payload=JSON.parse(await response.text());}catch(_){throw new Error('旧系统预约读取失败');}if(!response.ok||number(payload&&payload.retcode)===-1)throw new Error(String(payload&&payload.retmsg||'旧系统预约读取失败'));let result=payload&&payload.result;if(typeof result==='string')try{result=JSON.parse(result);}catch(_){result=[];}const rows=Array.isArray(result)?result:Array.isArray(result&&result.items)?result.items:[],meta=payload&&payload.page||result&&result.page||{};return{rows,pageCount:Math.max(1,number(meta.pageCount,1)),total:number(meta.itemCount,rows.length)};}
  2713. function legacyAppointmentVersion(row,total){return[String(total),number(row&&firstValue(row.GeneralID,row.generalId)),String(firstValue(row&&row.UpDateTime,row&&row.CreateTime,'')),String(row&&row.dszt||''),String(firstValue(row&&row.yysj,row&&row.bxrq,''))].join(':');}
  2714. async function performLegacyAppointmentSync(field,targetId){const config=await appLegacyConfig();if(!config.url||!config.apiId||!config.apiKey)throw new Error('旧系统预约同步尚未配置');const pageSize=500,first=await legacyAppointmentPage(config,field,targetId,1,pageSize),pageCount=Math.min(40,first.pageCount),pages=[first];if(pageCount>1)pages.push(...await Promise.all(Array.from({length:pageCount-1},(_,index)=>legacyAppointmentPage(config,field,targetId,index+2,pageSize))));const byId=new Map();for(const source of pages.flatMap((page)=>page.rows)){const row=legacyAppointmentRecord(source,field,targetId);if(row)byId.set(String(row.GeneralID),row);}const items=[...byId.values()],completedAt=new Date().toISOString();return{state:'current',lastSuccessAt:completedAt,lagSeconds:0,pending:0,pulled:items.length,sourceTotal:first.total,complete:pageCount===first.pageCount,items,version:legacyAppointmentVersion(first.rows[0],first.total)};}
  2715. async function syncLegacyAppointments(field,targetId,force=false){const key=field+':'+targetId,now=Date.now(),cached=LEGACY_APPOINTMENT_PULLS.get(key);if(!force&&cached&&now-cached.startedAt<4000)return cached.promise;const promise=performLegacyAppointmentSync(field,targetId).catch((error)=>({state:'unavailable',lastSuccessAt:'',lagSeconds:null,pending:0,pulled:0,sourceTotal:0,complete:false,items:[],version:'',message:String(error&&error.message||'旧系统预约同步失败')}));LEGACY_APPOINTMENT_PULLS.set(key,{startedAt:now,promise});return promise;}
  2716. async function syncLegacyAppointmentVersion(field,targetId){const key=field+':'+targetId,now=Date.now(),cached=LEGACY_APPOINTMENT_VERSIONS.get(key);if(cached&&now-cached.startedAt<4000)return cached.promise;const promise=(async()=>{const config=await appLegacyConfig();if(!config.url||!config.apiId||!config.apiKey)throw new Error('旧系统预约同步尚未配置');const page=await legacyAppointmentPage(config,field,targetId,1,1,'UpDateTime=desc');return{state:'current',lastSuccessAt:new Date().toISOString(),lagSeconds:0,pending:0,sourceTotal:page.total,version:legacyAppointmentVersion(page.rows[0],page.total)};})().catch((error)=>({state:'unavailable',lastSuccessAt:'',lagSeconds:null,pending:0,sourceTotal:0,version:'',message:String(error&&error.message||'旧系统预约版本读取失败')}));LEGACY_APPOINTMENT_VERSIONS.set(key,{startedAt:now,promise});return promise;}
  2717. function companionDateKey(row){const raw=String(firstValue(row&&row.bxrq,row&&row.yysj,'')).trim().replace(/\//g,'-');if(/^\d{8}$/.test(raw))return raw.slice(0,4)+'-'+raw.slice(4,6)+'-'+raw.slice(6,8);return raw.slice(0,10);}
  2718. function mergeCompanionRows(stored,live){const merged=new Map();for(const row of stored||[]){const key=String(firstValue(row&&row.GeneralID,row&&row.ID,row&&row.id,row&&row.objectId,''));if(key)merged.set(key,row);}for(const row of live||[]){const key=String(firstValue(row&&row.GeneralID,row&&row.ID,row&&row.id,row&&row.objectId,''));if(key)merged.set(key,row);}return[...merged.values()];}
  2719. function withLegacyAppointmentVersion(snapshot,scopes,sync){if(!sync||sync.state!=='current'||!sync.version)return snapshot;const scopeVersions={...snapshot.scopeVersions,appointments:sync.version},version=scopes.map((scope)=>scope+'='+(scopeVersions[scope]||'')).join('|');return{...snapshot,version,scopeVersions,legacyAppointmentSync:{state:sync.state,lastSuccessAt:sync.lastSuccessAt,lagSeconds:sync.lagSeconds,pending:sync.pending,pulled:sync.pulled,sourceTotal:sync.sourceTotal,complete:sync.complete}};}
  2720. const APP_LIVE_SCOPES=new Set(['learning','courses','appointments','reading','review','account']);
  2721. function appLiveVersion(row){const changed=legacyLearningIso(row&&row.changed_at)||'';return changed+':'+number(row&&row.total);}
  2722. async function appLiveScopeVersion(scope,targetId,current){
  2723. const companyId=DEFAULT_COMPANY_ID,uid=String(targetId);
  2724. if(scope==='learning')return appLiveVersion(await Psql.one('SELECT MAX(GREATEST(COALESCE(d."updatedAt",d."createdAt"),COALESCE(c."updatedAt",c."createdAt"))) AS changed_at,COUNT(*)::int AS total FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=d."company" AND c."modelId"=56 AND c."itemId"=d."id" WHERE d."company"=$1 AND d."userId"=$2',[companyId,targetId]));
  2725. if(scope==='courses')return appLiveVersion(await Psql.one('SELECT MAX(GREATEST(COALESCE(b."updatedAt",b."createdAt"),COALESCE(c."updatedAt",c."createdAt"),COALESCE(n."updatedAt",n."createdAt"))) AS changed_at,COUNT(*)::int AS total FROM "CourseBinding" b LEFT JOIN "CommonModel" c ON c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" LEFT JOIN "Node" n ON n."company"=b."company" AND CAST(n."nodeId" AS text)=CAST(b."kcid" AS text) WHERE b."company"=$1 AND CAST(b."yhid" AS text)=$2',[companyId,uid]));
  2726. if(scope==='appointments')return appLiveVersion(await Psql.one('SELECT MAX(GREATEST(COALESCE(a."updatedAt",a."createdAt"),COALESCE(c."updatedAt",c."createdAt"))) AS changed_at,COUNT(*)::int AS total FROM "CourseAppointment" a LEFT JOIN "CommonModel" c ON c."company"=a."company" AND c."modelId"=54 AND c."itemId"=a."id" WHERE a."company"=$1 AND (CAST(a."szyh" AS text)=$2 OR CAST(a."pl" AS text)=$2 OR CAST(a."fxpl" AS text)=$2)',[companyId,uid]));
  2727. if(scope==='reading'){
  2728. const learner=await legacyUserObject(targetId),ownerIds=learner?[learner.id,'_User$'+learner.id]:[];if(!ownerIds.length)return ':0';
  2729. return appLiveVersion(await Psql.one('SELECT MAX(GREATEST(COALESCE(a."updatedAt",a."createdAt"),COALESCE(l."updatedAt",l."createdAt"))) AS changed_at,COUNT(DISTINCT a."objectId")::int+COUNT(DISTINCT l."objectId")::int AS total FROM "SurveyItem" a LEFT JOIN "SurveyLog" l ON CAST(l."surveyItem" AS text)=ANY(ARRAY[CAST(a."objectId" AS text),\'SurveyItem$\'||CAST(a."objectId" AS text)]) AND CAST(l."user" AS text)=ANY($1::text[]) WHERE CAST(a."user" AS text)=ANY($1::text[]) AND a."type"=\'reading\' AND COALESCE(a."isDeleted",FALSE)=FALSE',[ownerIds]));
  2730. }
  2731. if(scope==='review')return appLiveVersion(await Psql.one('SELECT MAX(COALESCE("updatedAt","createdAt")) AS changed_at,COUNT(*)::int AS total FROM "MemoryPracticeRecord" WHERE "company"=$1 AND (CAST("yhid" AS text)=$2 OR CAST("plid" AS text)=$2)',[companyId,uid]));
  2732. if(scope==='account')return appLiveVersion(await Psql.one('SELECT MAX(changed_at) AS changed_at,SUM(total)::int AS total FROM (SELECT MAX(COALESCE("updatedAt","createdAt")) changed_at,COUNT(*)::int total FROM "_User" WHERE "company"=$1 AND "legacyUserId"=$2 UNION ALL SELECT MAX(COALESCE("updatedAt","createdAt")),COUNT(*)::int FROM "LessonRecord" WHERE "company"=$1 AND (CAST("xymz" AS text)=$3 OR CAST("jsmz" AS text)=$3) UNION ALL SELECT MAX(COALESCE("updatedAt","createdAt")),COUNT(*)::int FROM "CourseCreditReservation" WHERE "company"=$1 AND "studentId"=$2) versions',[companyId,targetId,uid]));
  2733. return ':0';
  2734. }
  2735. async function appLiveSyncStatus(scopes,directLearningSync){
  2736. if(scopes.includes('learning')&&directLearningSync&&directLearningSync.state==='current')return{state:'current',lastSuccessAt:directLearningSync.lastSuccessAt,lagSeconds:0,pending:0,learningSource:'legacy-pull',learningPulled:number(directLearningSync.pulled),learningWritten:number(directLearningSync.written),partial:scopes.some((scope)=>!['learning','reading'].includes(scope))};
  2737. const env=typeof process!=='undefined'&&process.env?process.env:{},configured=Boolean(String(env.XIAOSHU_LEGACY_SYNC_BASE_URL||'').trim()&&String(env.XIAOSHU_LEGACY_SYNC_KEY_ID||'').trim()&&String(env.XIAOSHU_LEGACY_SYNC_SECRET||'').trim());if(!configured)return{state:'unavailable',lastSuccessAt:'',lagSeconds:null,pending:0};
  2738. const datasetByScope={learning:'learning-records',courses:'course-bindings',appointments:'appointments',review:'memory-records',account:'lessons'},datasets=[...new Set(scopes.map((scope)=>datasetByScope[scope]).filter(Boolean))];if(!datasets.length)return{state:'current',lastSuccessAt:'',lagSeconds:0,pending:0};
  2739. try{const rows=await Psql.query('SELECT "dataset","state","lastSuccessAt" FROM "LegacySyncCheckpoint" WHERE "company"=$1 AND "dataset"=ANY($2::text[])',[DEFAULT_COMPANY_ID,datasets]),pendingRow=await Psql.one('SELECT COUNT(*)::int AS total FROM "LegacySyncDeadLetter" WHERE "company"=$1 AND "dataset"=ANY($2::text[]) AND COALESCE("resolved",FALSE)=FALSE',[DEFAULT_COMPANY_ID,datasets]).catch(()=>({total:0})),lastSuccessAt=rows.map((row)=>legacyLearningIso(row.lastSuccessAt)).filter(Boolean).sort()[0]||'',lagSeconds=lastSuccessAt?Math.max(0,Math.floor((Date.now()-new Date(lastSuccessAt).getTime())/1000)):null,pending=number(pendingRow.total),healthy=rows.length===datasets.length&&rows.every((row)=>['healthy','catching_up'].includes(String(row.state||'').trim()))&&lagSeconds!==null&&lagSeconds<=10&&!pending;return{state:healthy?'current':'delayed',lastSuccessAt,lagSeconds,pending};}catch(_){return{state:'unavailable',lastSuccessAt:'',lagSeconds:null,pending:0};}
  2740. }
  2741. async function appLiveVersionSnapshot(targetId,requestedScopes,current,directLearningSync){const scopes=[...new Set((Array.isArray(requestedScopes)?requestedScopes:[]).map((value)=>String(value||'').trim()).filter((scope)=>APP_LIVE_SCOPES.has(scope)))];if(!scopes.length)scopes.push('learning');const values=await Promise.all(scopes.map(async(scope)=>[scope,await appLiveScopeVersion(scope,targetId,current)])),scopeVersions=Object.fromEntries(values),syncStatus=await appLiveSyncStatus(scopes,directLearningSync),refreshedAt=new Date().toISOString();return{version:values.map(([scope,value])=>scope+'='+value).join('|'),scopeVersions,refreshedAt,syncStatus};}
  2742. async function appLiveVersions(input,current){const targetId=await appLearningTarget(current,input.studentId||input.uid),scopes=Array.isArray(input.scopes)?input.scopes:[],coach=number(current.get('legacyGroupId'))===3,selectedStudent=coach&&number(input.studentId),appointmentField=coach&&!selectedStudent?'pl':'szyh',appointmentTarget=coach&&!selectedStudent?ownLegacyId(current):targetId,[learningSync,reviewSync,appointmentSync]=await Promise.all([scopes.includes('learning')?syncLegacyLearning(targetId,current):undefined,scopes.includes('review')&&(!coach||selectedStudent)?syncLegacyReviews(targetId,current):undefined,scopes.includes('appointments')?syncLegacyAppointmentVersion(appointmentField,appointmentTarget):undefined]),snapshot=withLegacyAppointmentVersion(await appLiveVersionSnapshot(targetId,scopes,current,learningSync),scopes,appointmentSync);return reviewSync?{...snapshot,legacyReviewSync:reviewSync}:snapshot;}
  2743. async function appLearningOverview(input,current) {
  2744. const paging=mobilePage(input); const targetId=await appLearningTarget(current,input.studentId||input.uid),legacyLearningSync=await syncLegacyLearning(targetId,current,input.refresh===true),selected=String(input.date||'').replace(/\D/g,'').slice(0,8),calendarStart=String(input.calendarStart||'').replace(/\D/g,'').slice(0,8),calendarEnd=String(input.calendarEnd||'').replace(/\D/g,'').slice(0,8),recordValues=[DEFAULT_COMPANY_ID,targetId],recordWhere=['d."company"=$1','d."userId"=$2'];if(selected){recordValues.push(selected);recordWhere.push('d."dqrq"=$3');}
  2745. const recordSql='SELECT c."objectId",c."generalId",c."title",c."createTime",c."createdAt",d."id",d."pl",d."con",d."djq",d."ygg",d."dqrq",d."dsid",d."fxrl",d."xxqs",d."szmdid",d."userId",d."learned" FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=56 AND c."itemId"=d."id" WHERE '+recordWhere.join(' AND ')+' ORDER BY COALESCE(c."createTime",c."createdAt",d."updatedAt",d."createdAt") DESC,c.\"generalId\" DESC LIMIT '+paging.size+' OFFSET '+((paging.index-1)*paging.size);
  2746. const scheduleValues=[DEFAULT_COMPANY_ID,String(targetId)];let schedulePromise=Promise.resolve([]);if(selected)schedulePromise=Psql.query('SELECT c."objectId",c."generalId",c."title",c."createTime",c."createdAt",a.*,n."nodeName" AS "courseTitle" FROM "CourseAppointment" a LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=54 AND c."itemId"=a."id" LEFT JOIN "Node" n ON n."company"=$1 AND n."nodeId"=CASE WHEN COALESCE(a."kcid",\'\')~\'^\\d+(?:\\.0+)?$\' THEN a."kcid"::numeric ELSE -1 END WHERE a."company"=$1 AND a."szyh"=$2 AND COALESCE(c."status",99)<>-2 AND (REPLACE(LEFT(COALESCE(NULLIF(a."bxrq",\'\'),a."yysj"),10),\'-\',\'\')=$3 OR REPLACE(LEFT(COALESCE(NULLIF(a."yysj",\'\'),a."bxrq"),10),\'-\',\'\')=$3) ORDER BY COALESCE(NULLIF(a."yysj",\'\'),a."bxrq") ASC',scheduleValues.concat([selected]));
  2747. const dateValues=[DEFAULT_COMPANY_ID,targetId],dateWhere=['"company"=$1','"userId"=$2'];if(calendarStart&&calendarEnd){dateValues.push(calendarStart,calendarEnd);dateWhere.push('"dqrq">=$3 AND "dqrq"<=$4');}
  2748. const groupValues=[DEFAULT_COMPANY_ID,String(targetId)],groupWhere=['"company"=$1','"szyh"=$2'];if(calendarStart&&calendarEnd){groupValues.push(calendarStart,calendarEnd);groupWhere.push('REPLACE(LEFT(COALESCE(NULLIF("bxrq",\'\'),"yysj"),10),\'-\',\'\')>=$3 AND REPLACE(LEFT(COALESCE(NULLIF("bxrq",\'\'),"yysj"),10),\'-\',\'\')<=$4');}
  2749. const [records,dateRows,scheduleGroups,schedules,courses,latestReading]=await Promise.all([
  2750. Psql.query(recordSql,recordValues),
  2751. Psql.query('SELECT "dqrq" AS date,COUNT(*)::int AS count FROM "DailyStudyRecord" WHERE '+dateWhere.join(' AND ')+' GROUP BY "dqrq"',dateValues),
  2752. Psql.query('SELECT "bxrq","yysj","dszt",COUNT(*)::int AS count FROM "CourseAppointment" WHERE NOT EXISTS (SELECT 1 FROM "CommonModel" cancelled WHERE cancelled."company"="CourseAppointment"."company" AND cancelled."modelId"=54 AND cancelled."itemId"="CourseAppointment"."id" AND cancelled."status"=-2) AND '+groupWhere.join(' AND ')+' GROUP BY "bxrq","yysj","dszt"',groupValues),
  2753. schedulePromise,
  2754. Psql.query('SELECT c."objectId",c."generalId",c."title",c."createTime",c."createdAt",b.*,b."objectId" AS "bindingObjectId",n."nodeName" AS "courseTitle" FROM "CourseBinding" b LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=58 AND c."itemId"=b."id" LEFT JOIN "Node" n ON n."company"=$1 AND n."nodeId"=CASE WHEN COALESCE(b."kcid",\'\')~\'^\\d+(?:\\.0+)?$\' THEN b."kcid"::numeric ELSE -1 END WHERE b."company"=$1 AND b."yhid"=$2 AND b."enrollmentStatus"=\'active\' AND c."status"=99 AND COALESCE(n."zstatus",99)=99 ORDER BY b."id" DESC',[DEFAULT_COMPANY_ID,String(targetId)]),
  2755. Psql.oneOrNone('SELECT c."objectId",c."generalId",c."title",c."createTime",c."createdAt",d."id",d."pl",d."con",d."djq",d."ygg",d."dqrq",d."dsid",d."fxrl",d."xxqs",d."szmdid",d."userId",d."learned" FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=56 AND c."itemId"=d."id" WHERE d."company"=$1 AND d."userId"=$2 AND COALESCE(NULLIF(d."xxqs",\'\'),\'[]\')<>\'[]\' ORDER BY COALESCE(c."createTime",c."createdAt",d."updatedAt",d."createdAt") DESC LIMIT 1',[DEFAULT_COMPANY_ID,targetId])
  2756. ]);
  2757. const normalizedRecords=records.map(appOverviewStudyRow),normalizedLatestReading=appOverviewStudyRow(latestReading),completedReadingMap=await appOverviewCompletedReadingMap(normalizedLatestReading?[...normalizedRecords,normalizedLatestReading]:normalizedRecords,targetId),attachCompletedReading=(row)=>{if(!row)return row;const reading=completedReadingMap.get(String(row.GeneralID));return reading?{...row,...reading}:row;};
  2758. const recordDateCounts={},scheduleDateCounts={},learnedDates=new Set();for(const row of dateRows){const date=appOverviewDateKey(row.date);if(date){recordDateCounts[date]=number(row.count);learnedDates.add(date);}}
  2759. const today=appOverviewToday();for(const row of scheduleGroups){const date=appOverviewDateKey(row.bxrq||row.yysj);if(!date||learnedDates.has(date))continue;const count=number(row.count),status=number(row.dszt),value=scheduleDateCounts[date]||{planned:0,missed:0};if(status<30&&date<today)value.missed+=count;else value.planned+=count;scheduleDateCounts[date]=value;}
  2760. const total=await Psql.one('SELECT COUNT(*)::int total FROM "DailyStudyRecord" d WHERE '+recordWhere.join(' AND '),recordValues);
  2761. const live=await appLiveVersionSnapshot(targetId,['learning','courses','appointments','reading'],current,legacyLearningSync);return {page:mobilePageMeta(total.total,paging),...mobileSource(),records:normalizedRecords.map(attachCompletedReading),latestReadingRecord:attachCompletedReading(normalizedLatestReading),recordDateCounts,scheduleDateCounts,schedules:schedules.map(appOverviewScheduleRow),courses:courses.map(appOverviewCourseRow),legacyLearningSync,...live};
  2762. }
  2763. async function appStudentCompanionOverview(input,current,uid,p) {
  2764. const history=input.history===true,legacyAppointmentSync=await syncLegacyAppointments('szyh',uid,input.refresh===true);
  2765. const stored=await Psql.query('SELECT c."generalId",c."title",c."createTime",c."status" AS "commonStatus",a.*,n."nodeName" AS "courseTitle" FROM "CourseAppointment" a LEFT JOIN "CommonModel" c ON c."company"=a."company" AND c."modelId"=54 AND c."itemId"=a."id" LEFT JOIN "Node" n ON n."company"=a."company" AND n."nodeId"::text=a."kcid" WHERE a."company"=$1 AND a."szyh"=$2 ORDER BY a."yysj" ASC,a."id" DESC LIMIT 5000',[DEFAULT_COMPANY_ID,String(uid)]),liveRows=legacyAppointmentSync.state==='current'?legacyAppointmentSync.items:[],merged=mergeCompanionRows(stored.map(appOverviewScheduleRow),liveRows),search=String(input.search||'').trim().toLocaleLowerCase('zh-CN'),month=/^\d{4}-\d{2}$/.test(String(input.month||''))?String(input.month):'',today=appOverviewToday();
  2766. let base=merged.filter((row)=>history?(row.cancelled===true||number(row.dszt)>=30):row.cancelled!==true);if(search)base=base.filter((row)=>(String(row.Title||'')+' '+String(row.Title2||'')+' '+String(row.plxm||'')).toLocaleLowerCase('zh-CN').includes(search));if(month)base=base.filter((row)=>companionDateKey(row).startsWith(month+'-'));
  2767. const dateMap=new Map();for(const row of base){const date=companionDateKey(row);if(!date)continue;const value=dateMap.get(date)||{date,finished:0,planned:0};if(number(row.dszt)>=30&&row.cancelled!==true)value.finished++;else if(row.cancelled!==true)value.planned++;dateMap.set(date,value);}const dates=[...dateMap.values()].sort((left,right)=>left.date.localeCompare(right.date));
  2768. let filtered=base;if(input.dateStart)filtered=filtered.filter((row)=>companionDateKey(row)>=String(input.dateStart));if(input.dateEnd)filtered=filtered.filter((row)=>companionDateKey(row)<=String(input.dateEnd));if(input.filter==='finished')filtered=filtered.filter((row)=>number(row.dszt)>=30&&row.cancelled!==true);if(input.filter==='pending')filtered=filtered.filter((row)=>number(row.dszt)<30&&row.cancelled!==true&&companionDateKey(row)<today);filtered.sort((left,right)=>{const order=(companionDateKey(left)+' '+String(left.yysj||left.sdsd||'')).localeCompare(companionDateKey(right)+' '+String(right.yysj||right.sdsd||''));return history?-order:order;});
  2769. const total=filtered.length,offset=(p.index-1)*p.size,schedules=filtered.slice(offset,offset+p.size),courses=await Psql.query('SELECT c."generalId",c."title",b.*,b."objectId" AS "bindingObjectId",n."nodeName" AS "courseTitle" FROM "CourseBinding" b JOIN "CommonModel" c ON c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" LEFT JOIN "Node" n ON n."company"=b."company" AND n."nodeId"::text=b."kcid" WHERE b."company"=$1 AND b."yhid"=$2 AND b."enrollmentStatus"=\'active\' AND c."status"=99 AND COALESCE(n."zstatus",99)=99 ORDER BY b."id" DESC',[DEFAULT_COMPANY_ID,String(uid)]),scopes=['courses','appointments'],live=withLegacyAppointmentVersion(await appLiveVersionSnapshot(uid,scopes,current),scopes,legacyAppointmentSync);
  2770. return{schedules,courses:courses.map(appOverviewCourseRow),page:mobilePageMeta(total,p),dates,studentOptions:[],...mobileSource(),...live};
  2771. }
  2772. async function appCompanionOverview(input,current) {
  2773. const uid=await authorizeRequestedUser(current,input.uid),coach=number(current.get('legacyGroupId'))===3,history=input.history===true,p=mobilePage(input);
  2774. if(!coach)return appStudentCompanionOverview(input,current,uid,p);
  2775. const args=[DEFAULT_COMPANY_ID,String(uid)],clauses=['a."company"=$1','a."'+(coach?'pl':'szyh')+'"=$2'];
  2776. const date="LEFT(COALESCE(NULLIF(a.\"bxrq\",''),a.\"yysj\"),10)";
  2777. const add=(sql,value)=>{args.push(value);clauses.push(sql.replace('?', '$'+args.length));};
  2778. if(history)clauses.push('(a."dszt"::numeric>=30 OR c."status"=-2)');else clauses.push('COALESCE(c."status",99)<>-2');
  2779. if(coach&&number(input.studentId))add('a."szyh"=?',String(input.studentId));
  2780. if(input.search)add('c."title" ILIKE ?','%'+String(input.search).trim().slice(0,100)+'%');
  2781. if(input.month&&/^\d{4}-\d{2}$/.test(input.month))add('LEFT('+date+',7)=?',input.month);
  2782. const join=' FROM "CourseAppointment" a LEFT JOIN "CommonModel" c ON c."company"=a."company" AND c."modelId"=54 AND c."itemId"=a."id"';
  2783. const summaryWhere=clauses.join(' AND '),summaryArgs=[...args];
  2784. const dates=await Psql.query('SELECT '+date+' AS date,COUNT(*) FILTER(WHERE a."dszt"::numeric>=30 AND COALESCE(c."status",99)<>-2)::int finished,COUNT(*) FILTER(WHERE a."dszt"::numeric<30 AND COALESCE(c."status",99)<>-2)::int planned'+join+' WHERE '+summaryWhere+' GROUP BY '+date,summaryArgs);
  2785. const studentOptions=coach?await Psql.query('SELECT a."szyh" AS id,MAX('+companionStudentNameSql()+') AS name,COUNT(*)::int count'+join+' LEFT JOIN "_User" u ON u."company"=a."company" AND u."legacyUserId"::text=a."szyh" WHERE a."company"=$1 AND a."pl"=$2 AND COALESCE(c."status",99)<>-2 GROUP BY a."szyh" ORDER BY name',[DEFAULT_COMPANY_ID,String(uid)]):[];
  2786. if(input.dateStart)add(date+'>=?',String(input.dateStart));
  2787. if(input.dateEnd)add(date+'<=?',String(input.dateEnd));
  2788. if(input.filter==='finished')clauses.push('a."dszt"::numeric>=30 AND COALESCE(c."status",99)<>-2');
  2789. if(input.filter==='pending'){clauses.push('a."dszt"::numeric<30 AND COALESCE(c."status",99)<>-2');add(date+'<?',appOverviewToday());}
  2790. const where=clauses.join(' AND '),count=await Psql.one('SELECT COUNT(*)::int total'+join+' WHERE '+where,args);
  2791. const rows=await Psql.query('SELECT c."generalId",c."title",c."createTime",c."status" AS "commonStatus",a.*,n."nodeName" AS "courseTitle"'+join+' LEFT JOIN "Node" n ON n."company"=a."company" AND n."nodeId"::text=a."kcid" WHERE '+where+' ORDER BY a."yysj" '+(history?'DESC':'ASC')+',a."id" DESC LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]);
  2792. const courses=coach?[]:await Psql.query('SELECT c."generalId",c."title",b.*,b."objectId" AS "bindingObjectId",n."nodeName" AS "courseTitle" FROM "CourseBinding" b JOIN "CommonModel" c ON c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" LEFT JOIN "Node" n ON n."company"=b."company" AND n."nodeId"::text=b."kcid" WHERE b."company"=$1 AND b."yhid"=$2 AND b."enrollmentStatus"=\'active\' AND c."status"=99 AND COALESCE(n."zstatus",99)=99 ORDER BY b."id" DESC',[DEFAULT_COMPANY_ID,String(uid)]);
  2793. const live=await appLiveVersionSnapshot(uid,['courses','appointments'],current);return {schedules:rows.map(appOverviewScheduleRow),courses:courses.map(appOverviewCourseRow),page:mobilePageMeta(count.total,p),dates,studentOptions,...mobileSource(),...live};
  2794. }
  2795. async function appAccountOverview(input,current) {
  2796. const uid=await authorizeRequestedUser(current,input.uid),groupId=number(current.get('legacyGroupId')||legacyData(current).GroupID),profile={...legacyUser(current),...legacyUserAddon(current)},today=appOverviewToday(),todayCompact=today.replace(/-/g,'');
  2797. if(groupId===3){const [lessons,schedules,reviews]=await Promise.all([
  2798. Psql.one('SELECT COUNT(*)::int AS total,COUNT(*) FILTER (WHERE "kclx"=\'1\')::int AS t30,COUNT(*) FILTER (WHERE "kclx"=\'2\')::int AS t60,COUNT(*) FILTER (WHERE "kclx"=\'3\')::int AS trial FROM "LessonRecord" WHERE "company"=$1 AND "jsmz"=$2',[DEFAULT_COMPANY_ID,String(uid)]),
  2799. Psql.one('SELECT COUNT(*) FILTER (WHERE REPLACE(LEFT(COALESCE(NULLIF("bxrq",\'\'),"yysj"),10),\'-\',\'\')=$3)::int AS today,COUNT(*) FILTER (WHERE "dszt"~\'^\\d+(?:\\.0+)?$\' AND "dszt"::numeric>=30)::int AS completed FROM "CourseAppointment" WHERE NOT EXISTS (SELECT 1 FROM "CommonModel" cancelled WHERE cancelled."company"="CourseAppointment"."company" AND cancelled."modelId"=54 AND cancelled."itemId"="CourseAppointment"."id" AND cancelled."status"=-2) AND "company"=$1 AND "pl"=$2',[DEFAULT_COMPANY_ID,String(uid),todayCompact]),
  2800. Psql.one('SELECT COUNT(*)::int AS total,COUNT(*) FILTER (WHERE "fxzt"=\'1\')::int AS completed FROM "MemoryPracticeRecord" WHERE "company"=$1 AND "plid"=$2',[DEFAULT_COMPANY_ID,uid])
  2801. ]);const t30=number(lessons.t30),t60=number(lessons.t60),trial=number(lessons.trial),live=await appLiveVersionSnapshot(uid,['appointments','review','account'],current);return{profile,coachPanel:{todaySchedules:number(schedules.today),completedLessons:number(schedules.completed),reviewIncome:await reviewIncomeTotal(DEFAULT_COMPANY_ID,uid),reviewRecords:number(reviews.total),totalLessons:number(lessons.total),totalHours:t30*.5+t60+trial,course30:t30,course60:t60,trialLessons:trial},...live};}
  2802. const [legacyReviewSync,legacyAppointmentSync]=await Promise.all([syncLegacyReviews(uid,current,input.refresh===true),syncLegacyAppointments('szyh',uid,input.refresh===true)]),[companion,memory]=await Promise.all([
  2803. Psql.one('SELECT COUNT(*) FILTER (WHERE "dszt"~\'^\\d+(?:\\.0+)?$\' AND "dszt"::numeric>=30)::int AS completed,COALESCE(SUM(CASE WHEN "dszt"~\'^\\d+(?:\\.0+)?$\' AND "dszt"::numeric>=30 THEN CASE WHEN "dslx"=\'1\' THEN 30 WHEN "dslx" IN (\'2\',\'3\') THEN 60 ELSE 0 END ELSE 0 END),0)::int AS minutes FROM "CourseAppointment" WHERE NOT EXISTS (SELECT 1 FROM "CommonModel" cancelled WHERE cancelled."company"="CourseAppointment"."company" AND cancelled."modelId"=54 AND cancelled."itemId"="CourseAppointment"."id" AND cancelled."status"=-2) AND "company"=$1 AND "szyh"=$2',[DEFAULT_COMPANY_ID,String(uid)]),
  2804. Psql.one('SELECT COUNT(*)::int AS total,COUNT(*) FILTER (WHERE "fxzt"=\'1\')::int AS completed,COUNT(*) FILTER (WHERE LEFT(COALESCE(NULLIF("kywrq",\'\'),"kywsj"),10)=$3)::int AS today_total,COUNT(*) FILTER (WHERE LEFT(COALESCE(NULLIF("kywrq",\'\'),"kywsj"),10)=$3 AND "fxzt"=\'1\')::int AS today_completed,COUNT(*) FILTER (WHERE COALESCE("fxzt",\'0\')<>\'1\' AND LEFT(COALESCE(NULLIF("kywrq",\'\'),"kywsj"),10)<=$3)::int AS due_pending,COUNT(*) FILTER (WHERE COALESCE("fxzt",\'0\')<>\'1\' AND LEFT(COALESCE(NULLIF("kywrq",\'\'),"kywsj"),10)>$3)::int AS scheduled,COUNT(DISTINCT LEFT(COALESCE(NULLIF("kywrq",\'\'),"kywsj"),10))::int AS scheduled_days FROM "MemoryPracticeRecord" WHERE "company"=$1 AND "yhid"=$2',[DEFAULT_COMPANY_ID,uid,today])
  2805. ]);const total=number(memory.total),completed=number(memory.completed),legacyCompleted=legacyAppointmentSync.state==='current'?legacyAppointmentSync.items.filter((row)=>row.cancelled!==true&&number(row.dszt)>=30):null,completedSessions=legacyCompleted?legacyCompleted.length:number(companion.completed),completedMinutes=legacyCompleted?legacyCompleted.reduce((sum,row)=>sum+(number(row.dslx)===1?30:[2,3].includes(number(row.dslx))?60:0),0):number(companion.minutes),scopes=['appointments','review','account'],live=withLegacyAppointmentVersion(await appLiveVersionSnapshot(uid,scopes,current),scopes,legacyAppointmentSync);return{profile,companionSummary:{completedSessions,completedMinutes},antiForgetSummary:{total,completed,progress:total?Math.round(completed/total*100):0,todayTotal:number(memory.today_total),todayCompleted:number(memory.today_completed),duePending:number(memory.due_pending),scheduled:number(memory.scheduled),scheduledDays:number(memory.scheduled_days)},legacyReviewSync,legacyAppointmentSync,...live};
  2806. }
  2807. 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); }
  2808. function learnedAt(row) { for (const value of [row.studyCommonCreateTime,row.studyCommonCreatedAt,row.studyRecordCreatedAt,row.studyRecordUpdatedAt]) { if (!value) continue; const date = value instanceof Date ? value : new Date(value); if (!Number.isNaN(date.getTime()) && date.getUTCFullYear() > 1970) return date.toISOString(); } return learnedDate(row); }
  2809. async function memoryPage(input, current) {
  2810. 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', 'c."modelId" = 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 countPromise = Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c JOIN "MemoryPracticeRecord" m ON m."company" = $1 AND m."id" = c."itemId" WHERE ' + where, values); const rowValues = values.concat([paging.size, (paging.index - 1) * paging.size]);
  2811. const rowPromise = Psql.query('SELECT c.*, row_to_json(m) AS "__addon", d."learned", d."dqrq", dc."createTime" AS "studyCommonCreateTime", dc."createdAt" AS "studyCommonCreatedAt", d."createdAt" AS "studyRecordCreatedAt", d."updatedAt" AS "studyRecordUpdatedAt", n."nodeName" AS "kc_title" FROM "CommonModel" c JOIN "MemoryPracticeRecord" m ON m."company" = $1 AND m."id" = c."itemId" LEFT JOIN "CommonModel" dc ON dc."company" = $1 AND dc."modelId" = 56 AND CAST(dc."generalId" AS text) = CAST(m."xxjlid" AS text) LEFT JOIN "DailyStudyRecord" d ON d."company" = $1 AND d."id" = dc."itemId" 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 [countRow, rows] = await Promise.all([countPromise, rowPromise]); const rewardRows = income && rows.length ? await Psql.query('SELECT "taskId","amount","status" FROM "ReviewReward" WHERE "company"=$1 AND "taskId"=ANY($2::text[])',[DEFAULT_COMPANY_ID,rows.map((row)=>String(row.__addon.objectId))]) : []; const rewardMap = new Map(rewardRows.map((row)=>[row.taskId,row])); const result = rows.map((source) => { const addon = source.__addon || {}; delete source.__addon; const row = legacyAliases({ ...source, ...addon }, 'CommonModel'); row.learned_date = learnedDate(row); row.learned_at = learnedAt(row); delete row.studyCommonCreateTime; delete row.studyCommonCreatedAt; delete row.studyRecordCreatedAt; delete row.studyRecordUpdatedAt; if (income) { const reward = rewardMap.get(String(addon.objectId)); row.money = reward ? Number(reward.amount) : 0; row.rewardStatus = reward ? reward.status : 'pending'; row.incomeRuleUnavailable = false; } 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 } };
  2812. }
  2813. async function memoryCompletedCount(input,current){ const uid=await authorizeRequestedUser(current,input.uid);return[{num:await reviewIncomeTotal(DEFAULT_COMPANY_ID,uid)}]; }
  2814. 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 }; }
  2815. async function appLegacyConfig() { const config=await Parse.Config.get({useMasterKey:true});return{url:String(config.get('legacyScheduleApiUrl')||''),apiId:String(config.get('legacyScheduleApiId')||''),apiKey:String(config.get('legacyScheduleApiKey')||'')}; }
  2816. function legacyLoginResult(payload) { let result=payload&&payload.result;if(typeof result==='string'){try{result=JSON.parse(result);}catch(_){result={};}}if(Array.isArray(result))result=result[0]||{};return result&&typeof result==='object'?result:{}; }
  2817. async function verifyLegacyPassword(username,password) { const config=await appLegacyConfig();if(!config.url||!config.apiId||!config.apiKey)fail(503,'旧账号登录迁移服务尚未配置');const query=Object.entries({action:'user_login_passwd',apiId:config.apiId,apiKey:config.apiKey}).map(([key,value])=>encodeURIComponent(key)+'='+encodeURIComponent(String(value))).join('&'),url=config.url+(config.url.includes('?')?'&':'?')+query,body='name='+encodeURIComponent(username)+'&passwd='+encodeURIComponent(password);let payload;try{if(typeof fetch==='function'){const response=await fetch(url,{method:'POST',headers:{accept:'application/json','content-type':'application/x-www-form-urlencoded'},body,signal:typeof AbortSignal!=='undefined'&&AbortSignal.timeout?AbortSignal.timeout(12000):undefined}),raw=await response.text();payload=JSON.parse(raw);if(!response.ok)throw new Error('旧账号登录服务返回 HTTP '+response.status);}else if(Parse.Cloud&&typeof Parse.Cloud.httpRequest==='function'){const response=await Parse.Cloud.httpRequest({url,method:'POST',headers:{accept:'application/json','content-type':'application/x-www-form-urlencoded'},body});payload=typeof response.data==='object'?response.data:JSON.parse(String(response.text||''));}else throw new Error('云函数运行环境不支持外部请求');}catch(_){fail(503,'旧账号登录迁移服务暂时不可用');}if(number(payload&&payload.retcode)===-1)fail(401,'账号或密码错误');return payload; }
  2818. async function migratedLoginTarget(login,verifiedPayload=null) { const verified=legacyLoginResult(verifiedPayload),verifiedId=number(firstValue(verified.userId,verified.UserID,verified.uid,verified.ID)),verifiedName=String(firstValue(verified.userName,verified.UserName,verified.username,verified.name,'')||'').trim();const rows=await Psql.query('SELECT "objectId","username" FROM "_User" WHERE "company"=$1 AND COALESCE("isDeleted",FALSE)=FALSE AND (($2::numeric>0 AND COALESCE("legacyUserId",CASE WHEN COALESCE("legacyUserData"->>\'UserID\',\'\') ~ \'^[0-9]+$\' THEN ("legacyUserData"->>\'UserID\')::numeric ELSE 0 END)=$2::numeric) OR LOWER(COALESCE("username",\'\'))=LOWER($3) OR LOWER(COALESCE("mobile","phone","legacyUserData"->>\'Mobile\',\'\'))=LOWER($3) OR LOWER(COALESCE("legacyUserData"->>\'UserName\',\'\'))=LOWER($3) OR ($4<>\'\' AND (LOWER(COALESCE("username",\'\'))=LOWER($4) OR LOWER(COALESCE("legacyUserData"->>\'UserName\',\'\'))=LOWER($4)))) ORDER BY CASE WHEN $2::numeric>0 AND COALESCE("legacyUserId",CASE WHEN COALESCE("legacyUserData"->>\'UserID\',\'\') ~ \'^[0-9]+$\' THEN ("legacyUserData"->>\'UserID\')::numeric ELSE 0 END)=$2::numeric THEN 0 WHEN LOWER(COALESCE("username",\'\'))=LOWER($3) THEN 1 ELSE 2 END,"objectId" LIMIT 3',[DEFAULT_COMPANY_ID,verifiedId,String(login).trim(),verifiedName]);if(!rows.length)return null;if(rows.length>1&&!verifiedId&&rows.filter((row)=>String(row.username||'').toLowerCase()===String(login).trim().toLowerCase()).length!==1)fail(409,'账号或手机号对应多条迁移数据,请联系管理员处理');const chosen=verifiedId?rows[0]:(rows.find((row)=>String(row.username||'').toLowerCase()===String(login).trim().toLowerCase())||rows[0]);return new Parse.Query('_User').get(String(chosen.objectId),{useMasterKey:true}); }
  2819. async function migrateLegacyLogin(username,password) { const verified=await verifyLegacyPassword(username,password),target=await migratedLoginTarget(username,verified);if(!target)fail(404,'账号尚未迁移到新数据库');if(target.get('isDisabled')===true||target.get('isDeleted')===true)fail(403,'账号已停用');const canonical=String(target.get('username')||'').trim();if(!canonical)fail(409,'迁移账号缺少登录名,请联系管理员处理');const fields=await fieldsOf('_User');target.setPassword(password);if(fields.passwordResetRequired)target.set('passwordResetRequired',false);await target.save(null,{useMasterKey:true});const loggedIn=await Parse.User.logIn(canonical,password);if(!String(loggedIn.getSessionToken()||'').startsWith('r:'))fail(503,'账号迁移登录未生成有效会话');return loggedIn; }
  2820. const READING_CLASSES=new Set(['SurveyItem','SurveyLog','PromptTemplate']);const READING_FIELDS={SurveyItem:new Set(['type','title','content','createOptions','parent','index','difficulty','answer','options','isDeleted']),SurveyLog:new Set(['surveyItem','answer','right','wrong','grade','type','isDeleted']),PromptTemplate:new Set(['code'])};
  2821. function readingClass(value,write=false){const className=String(value||'');if(!READING_CLASSES.has(className)||(write&&className==='PromptTemplate'))fail(400,'阅读数据类型不允许');return className;}
  2822. function readingValue(value,depth=0){if(depth>6)fail(400,'阅读数据嵌套过深');if(Array.isArray(value))return value.map((item)=>readingValue(item,depth+1));if(value&&typeof value==='object'){if(value.__type==='Pointer'){const className=String(value.className||'');if(!['_User','SurveyItem'].includes(className)||!/^[A-Za-z0-9_-]{10,40}$/.test(String(value.objectId||'')))fail(400,'阅读数据关联无效');return Parse.Object.createWithoutData(className,String(value.objectId));}const output={};for(const [key,item] of Object.entries(value)){if(!/^[A-Za-z0-9_$.-]{1,80}$/.test(key))fail(400,'阅读数据字段无效');output[key]=readingValue(item,depth+1);}return output;}return value;}
  2823. function readingClean(value,depth=0){if(value==null||depth>7)return value;if(Array.isArray(value))return value.map((item)=>readingClean(item,depth+1));if(value&&typeof value==='object'){if(depth>0&&value.objectId&&value.className){const className=String(value.className),rawId=String(value.objectId),prefix=className+'$',objectId=rawId.startsWith(prefix)?rawId.slice(prefix.length):rawId;return{__type:'Pointer',className,objectId};}const output={};for(const [key,item] of Object.entries(value)){if(key==='ACL'||SENSITIVE.test(key))continue;output[key]=readingClean(item,depth+1);}return output;}return value;}
  2824. function readingRecord(row){return readingClean(row&&typeof row.toJSON==='function'?row.toJSON():row);}
  2825. async function readingOwned(className,objectId,current){if(!/^[A-Za-z0-9_-]{10,40}$/.test(String(objectId||'')))fail(400,'阅读记录 ID 无效');const row=await new Parse.Query(className).get(String(objectId),{useMasterKey:true}),owner=row.get('user'),ownerId=String(owner&&(owner.id||owner.objectId)||owner||'').replace(/^_User\$/,'');if(className!=='PromptTemplate'&&ownerId!==String(current.id))fail(403,'无权访问该阅读记录');return row;}
  2826. async function readingQuery(input,current){
  2827. const className=readingClass(input.className),where=input.where&&typeof input.where==='object'?input.where:{},query=new Parse.Query(className),allowed=READING_FIELDS[className];let pointerIds=null;
  2828. for(const [key,raw] of Object.entries(where)){
  2829. if(key==='user')continue;const root=key.split('.')[0];if(!allowed.has(root))fail(400,'阅读筛选字段不允许:'+key);
  2830. if(['parent','surveyItem'].includes(key)&&raw&&raw.__type==='Pointer'){
  2831. if(raw.className!=='SurveyItem'||!/^[A-Za-z0-9_-]{10,40}$/.test(String(raw.objectId||'')))fail(400,'阅读关联记录无效');
  2832. const linked=await Psql.query('SELECT "objectId" FROM "'+className+'" WHERE "'+key+'" IN ($1,$2) LIMIT 5000',[String(raw.objectId),'SurveyItem$'+String(raw.objectId)]),ids=linked.map((row)=>String(row.objectId));pointerIds=pointerIds===null?ids:pointerIds.filter((id)=>ids.includes(id));continue;
  2833. }
  2834. if(raw&&typeof raw==='object'&&!Array.isArray(raw)&&!raw.__type){const operators=Object.keys(raw);if(operators.length!==1||!['$ne','$in'].includes(operators[0]))fail(400,'阅读筛选操作不允许');const value=readingValue(raw[operators[0]]);if(operators[0]==='$ne')query.notEqualTo(key,value);else query.containedIn(key,Array.isArray(value)?value:[value]);}else query.equalTo(key,readingValue(raw));
  2835. }
  2836. if(className!=='PromptTemplate'){const owned=await Psql.query('SELECT "objectId" FROM "'+className+'" WHERE "user" IN ($1,$2) LIMIT 5000',[current.id,'_User$'+current.id]),ownedIds=owned.map((row)=>String(row.objectId)),ids=pointerIds===null?ownedIds:ownedIds.filter((id)=>pointerIds.includes(id));if(!ids.length)return[];query.containedIn('objectId',ids);}else if(pointerIds!==null){if(!pointerIds.length)return[];query.containedIn('objectId',pointerIds);}
  2837. const order=String(input.order||'');if(order){const descending=order.startsWith('-'),field=order.replace(/^[-+]/,'');if(!['createdAt','updatedAt','index'].includes(field))fail(400,'阅读排序字段不允许');descending?query.descending(field):query.ascending(field);}query.limit(Math.min(100,Math.max(1,number(input.limit,20))));query.skip(Math.min(5000,Math.max(0,number(input.skip))));return(await query.find({useMasterKey:true})).map(readingRecord);
  2838. }
  2839. async function readingCreate(input,current,actor=current){const className=readingClass(input.className,true),value=input.value&&typeof input.value==='object'?input.value:{},allowed=READING_FIELDS[className],row=new Parse.Object(className),deferred=value.createOptions===undefined?undefined:{...value.createOptions,operatorId:actor.id,learnerId:current.id},relations=[];for(const [key,item] of Object.entries(value)){if(key==='user'||key==='createOptions'||!allowed.has(key))continue;if(key==='parent'||key==='surveyItem'){const expected='SurveyItem',pointer=item&&typeof item==='object'?item:{};if(pointer.__type!=='Pointer'||pointer.className!==expected||!/^[A-Za-z0-9_-]{10,40}$/.test(String(pointer.objectId||'')))fail(400,'阅读关联记录无效');await readingOwned(expected,pointer.objectId,current);relations.push([key,expected+'$'+pointer.objectId]);continue;}row.set(key,readingValue(item));}const data=mobileObjectData(row);data.user='_User$'+current.id;data.operatorId=actor.id;data.learnerLegacyId=ownLegacyId(current);data.sourceStudyId=String(input.sourceStudyId||value.createOptions?.params?.sourceStudyId||'');if(deferred!==undefined)data.createOptions=deferred;for(const[field,pointer]of relations)data[field]=pointer;const saved=await mobileAtomicWrite(input,actor,[{table:className,data,match:{objectId:data.objectId}}],String(ownLegacyId(current)));return readingRecord(saved[0]);}
  2840. async function readingUpdate(input,current){const className=readingClass(input.className,true),row=await readingOwned(className,input.objectId,current),patch=input.patch&&typeof input.patch==='object'?input.patch:{},allowed=READING_FIELDS[className];for(const [key,item] of Object.entries(patch)){if(['user','parent','surveyItem','createOptions'].includes(key)||!allowed.has(key))continue;row.set(key,readingValue(item));}await row.save(null,{useMasterKey:true});return readingRecord(row);}
  2841. async function readingUpdateUser(input,current){const patch=input.patch&&typeof input.patch==='object'?input.patch:{};for(const key of ['nickname','level'])if(patch[key]!==undefined)current.set(key,String(patch[key]).slice(0,100));await current.save(null,{useMasterKey:true});return readingRecord(current);}
  2842. async function readingAi(input,current){
  2843. if(!current)fail(401,'阅读学习需要登录');
  2844. const prompt=String(input.prompt||'').trim();if(!prompt)fail(400,'阅读生成内容不能为空');if(prompt.length>60000)fail(400,'阅读生成内容过长');
  2845. const config=await Parse.Config.get({useMasterKey:true}),token=String((typeof process!=='undefined'&&process.env&&process.env.FMODE_API_TOKEN)||config.get('fmodeApiToken')||'').trim();if(!token)fail(503,'阅读生成服务尚未配置');
  2846. const body={model:'doubao-seed-2-0-lite-260215',messages:[{role:'user',content:prompt}],stream:false,max_tokens:32768,thinking:{type:'disabled'}};if(input.jsonMode===true)body.response_format={type:'json_object'};
  2847. let response,payload;try{response=await fetch('https://api.fmode.cn/v1/chat/completions',{method:'POST',headers:{Authorization:'Bearer '+token,'Content-Type':'application/json'},body:JSON.stringify(body),signal:typeof AbortSignal!=='undefined'&&AbortSignal.timeout?AbortSignal.timeout(120000):undefined});const raw=await response.text();try{payload=raw?JSON.parse(raw):{};}catch(_){payload={};}}catch(_){fail(502,'阅读生成服务暂时不可用');}
  2848. if(!response||!response.ok)fail(502,'阅读生成服务暂时不可用'+(response?'('+response.status+')':''));const content=String(payload&&payload.choices&&payload.choices[0]&&payload.choices[0].message&&payload.choices[0].message.content||'');if(!content.trim())fail(502,'阅读生成服务未返回内容');return{content};
  2849. }
  2850. async function uploadAppMedia(input) { const fileName=String(input.fileName||'upload.bin').replace(/[^a-zA-Z0-9._-]/g,'_'),contentType=String(input.contentType||'').toLowerCase(),base64=String(input.base64||'').replace(/^data:[^;]+;base64,/i,''),allowed=new Set(['image/jpeg','image/png','image/webp','image/gif']);if(!allowed.has(contentType)||!fileName||!base64||base64.length>7200000||!/^[a-zA-Z0-9+/=]+$/.test(base64))fail(400,'上传文件类型或大小不符合要求');const signatures={'image/jpeg':'/9j/','image/png':'iVBOR','image/webp':'UklGR','image/gif':'R0lG'};if(!base64.startsWith(signatures[contentType]))fail(400,'上传文件内容与类型不一致');const config=await appLegacyConfig();if(!config.url||!config.apiId||!config.apiKey)fail(503,'媒体上传服务尚未配置');let bytes;try{const binary=atob(base64);bytes=new Uint8Array(binary.length);for(let index=0;index<binary.length;index++)bytes[index]=binary.charCodeAt(index);}catch(_){fail(400,'上传文件编码无效');}const query=Object.entries({action:'upload',apiId:config.apiId,apiKey:config.apiKey}).map(([key,value])=>encodeURIComponent(key)+'='+encodeURIComponent(String(value))).join('&'),url=config.url+(config.url.includes('?')?'&':'?')+query,form=new FormData();form.append('file',new Blob([bytes],{type:contentType}),fileName);let payload;try{const response=await fetch(url,{method:'POST',body:form,signal:typeof AbortSignal!=='undefined'&&AbortSignal.timeout?AbortSignal.timeout(20000):undefined}),raw=await response.text();payload=JSON.parse(raw);if(!response.ok||number(payload&&payload.retcode)===-1)throw new Error(String(payload&&payload.retmsg||'媒体上传失败'));}catch(_){fail(502,'媒体上传服务暂时不可用');}let publicUrl=String(payload&&payload.result||'').trim();if(!publicUrl)fail(502,'媒体上传服务未返回文件地址');if(!/^https?:\/\//i.test(publicUrl)){const match=config.url.match(/^(https?:\/\/[^/]+)/i),origin=match?match[1]:'';publicUrl=origin+(publicUrl.startsWith('/')?'':'/')+publicUrl;}return publicUrl; }
  2851. const MEMBER_RESOURCES={
  2852. address:{className:'ReceivingAddress',fields:{receiverName:'userName',mobile:'telNumber',provinceName:'provinceName',cityName:'cityName',countyName:'countyName',detailInfo:'detailInfo',postalCode:'postalCode'}},
  2853. invoice:{className:'InvoiceProfile',fields:{invoiceType:'aud',title:'name',taxId:'idnum',mobile:'mobile',email:'email',address:'address',bankName:'bankname',bankAccount:'bankusr'}},
  2854. bank:{className:'UserBankAccount',fields:{cardType:'CardType',bankName:'BankName',accountName:'PeopleName',accountNumber:'CardNum',remark:'Remark'}}
  2855. };
  2856. function privateTail(value,head=3,tail=4){const raw=String(value||'');if(raw.length<=head+tail)return raw?'***':'';return raw.slice(0,head)+'****'+raw.slice(-tail);}
  2857. function memberPointerId(value,className){const raw=String(value&&(value.id||value.objectId)||value||''),prefix=className+'$';return raw.startsWith(prefix)?raw.slice(prefix.length):raw;}
  2858. function memberResourceJson(kind,row){const base={objectId:row.id,ID:row.id,id:row.id,UserID:number(row.get('legacyUserId')),CreateTime:row.createdAt&&row.createdAt.toISOString(),UpdateTime:row.updatedAt&&row.updatedAt.toISOString()};if(kind==='address'){const province=String(row.get('provinceName')||''),city=String(row.get('cityName')||''),county=String(row.get('countyName')||'');return{...base,ReceivName:String(row.get('receiverName')||''),MobileNum:String(row.get('mobile')||''),Provinces:[province,city,county].filter(Boolean).join(' '),Street:String(row.get('detailInfo')||''),Zipcode:String(row.get('postalCode')||''),provinceName:province,cityName:city,countyName:county,userName:String(row.get('receiverName')||''),telNumber:String(row.get('mobile')||''),detailInfo:String(row.get('detailInfo')||''),postalCode:String(row.get('postalCode')||'')};}if(kind==='invoice')return{...base,InvType:number(row.get('invoiceType')),InvHead:String(row.get('title')||''),UserCode:String(row.get('taxId')||''),UserMobile:String(row.get('mobile')||''),UserEmail:String(row.get('email')||''),UserAddress:String(row.get('address')||''),UserBankName:String(row.get('bankName')||''),UserBankAccount:String(row.get('bankAccount')||'')};return{...base,CardType:String(row.get('cardType')||''),BankName:String(row.get('bankName')||''),PeopleName:String(row.get('accountName')||''),CardNum:String(row.get('accountNumber')||''),Remark:String(row.get('remark')||'')};}
  2859. function memberResourceAuditJson(kind,value){const copy={...value};if(copy.MobileNum)copy.MobileNum=privateTail(copy.MobileNum);if(copy.telNumber)copy.telNumber=privateTail(copy.telNumber);if(copy.UserMobile)copy.UserMobile=privateTail(copy.UserMobile);if(copy.UserCode)copy.UserCode=privateTail(copy.UserCode,2,2);if(copy.UserBankAccount)copy.UserBankAccount=privateTail(copy.UserBankAccount);if(copy.CardNum)copy.CardNum=privateTail(copy.CardNum);return copy;}
  2860. async function memberResourceOwner(current,input){const uid=await authorizeRequestedUser(current,input.uid||ownLegacyId(current));const target=uid===ownLegacyId(current)?current:await legacyUserObject(uid);if(!target)fail(404,'用户不存在');return{uid,target};}
  2861. async function memberResourceObject(kind,id,owner){if(!/^[A-Za-z0-9_-]{10,40}$/.test(String(id||'')))fail(400,'记录 ID 无效');let row;try{row=await new Parse.Query(MEMBER_RESOURCES[kind].className).get(String(id),{useMasterKey:true});}catch(_){fail(404,'记录不存在');}if(memberPointerId(row.get('company'),'Company')!==DEFAULT_COMPANY_ID||memberPointerId(row.get('user'),'_User')!==owner.target.id||row.get('isDeleted')===true)fail(404,'记录不存在');return row;}
  2862. async function memberResourceAudit(current,action,kind,row,before,after,request){try{let audit=new Parse.Object('AppAuditLog');audit.set('legacyUserId',ownLegacyId(current));audit.set('action',action);audit.set('targetClass',MEMBER_RESOURCES[kind].className);audit.set('targetId',row.id);audit.set('before',memberResourceAuditJson(kind,before||{}));audit.set('after',memberResourceAuditJson(kind,after||{}));audit.set('ip',String(request.ip||request.headers&&request.headers['x-forwarded-for']||'').slice(0,100));audit.set('sourceKey','app:'+action+':'+row.id+':'+Date.now());await audit.save(null,{useMasterKey:true});await Psql.none('UPDATE "AppAuditLog" SET "company"=$1,"user"=$2,"updatedAt"=CURRENT_TIMESTAMP WHERE "objectId"=$3',['Company$'+DEFAULT_COMPANY_ID,'_User$'+current.id,audit.id]);}catch(_){} }
  2863. async function memberResourceList(kind,input,current){const owner=await memberResourceOwner(current,input),config=MEMBER_RESOURCES[kind],paging=pageInput(input),values=['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID,'_User$'+owner.target.id,owner.target.id,paging.size,(paging.index-1)*paging.size],count=await Psql.one('SELECT COUNT(*)::int AS total FROM "'+config.className+'" WHERE CAST("company" AS text)=ANY($1::text[]) AND CAST("user" AS text)=ANY($2::text[]) AND COALESCE("isDeleted",FALSE)=FALSE',[[values[0],values[1]],[values[2],values[3]]]),ids=await Psql.query('SELECT "objectId" FROM "'+config.className+'" WHERE CAST("company" AS text)=ANY($1::text[]) AND CAST("user" AS text)=ANY($2::text[]) AND COALESCE("isDeleted",FALSE)=FALSE ORDER BY "updatedAt" DESC LIMIT $3 OFFSET $4',[[values[0],values[1]],[values[2],values[3]],values[4],values[5]]),rows=[];for(const item of ids)rows.push(await new Parse.Query(config.className).get(String(item.objectId),{useMasterKey:true}));const total=number(count.total);return{rows:rows.map((row)=>memberResourceJson(kind,row)),page:{itemCount:total,pageCount:Math.ceil(total/paging.size),pageIndex:paging.index,pageSize:paging.size}};}
  2864. async function memberResourceGet(kind,input,current){const owner=await memberResourceOwner(current,input);return memberResourceJson(kind,await memberResourceObject(kind,input.id,owner));}
  2865. function memberResourceValidate(kind,input){if(kind==='address'){if(!String(input.userName||'').trim())fail(400,'请填写收货人');if(String(input.telNumber||'').replace(/\D/g,'').length<6)fail(400,'请填写正确的手机号码');if(!String(input.detailInfo||'').trim())fail(400,'请填写详细地址');if(input.postalCode&&!/^\d{6}$/.test(String(input.postalCode)))fail(400,'邮政编码格式不正确');}else if(kind==='invoice'){if(!String(input.name||'').trim())fail(400,'请填写发票抬头');if(!String(input.idnum||'').trim())fail(400,'请填写身份证或信用代码');if(input.email&&!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(input.email)))fail(400,'联系邮箱格式不正确');}else{if(!String(input.CardType||'').trim()||!String(input.PeopleName||'').trim()||!String(input.CardNum||'').trim())fail(400,'请完整填写提现账户信息');}}
  2866. async function memberResourceSave(kind,input,current,request){memberResourceValidate(kind,input);const owner=await memberResourceOwner(current,input),config=MEMBER_RESOURCES[kind],creating=!input.id;let row=creating?new Parse.Object(config.className):await memberResourceObject(kind,input.id,owner),before=creating?{}:memberResourceJson(kind,row);for(const [field,key] of Object.entries(config.fields)){let value=input[key];if(value===undefined)continue;if(field==='invoiceType')row.set(field,number(value));else row.set(field,String(value||'').trim().slice(0,field==='detailInfo'||field==='remark'?1000:300));}row.set('legacyUserId',owner.uid);row.set('isDeleted',false);if(!row.get('sourceKey'))row.set('sourceKey','app:'+kind+':'+owner.uid+':'+Date.now()+':'+Math.random().toString(36).slice(2));try{await row.save(null,{useMasterKey:true});if(creating){await Psql.none('UPDATE "'+config.className+'" SET "company"=$1,"user"=$2,"updatedAt"=CURRENT_TIMESTAMP WHERE "objectId"=$3',['Company$'+DEFAULT_COMPANY_ID,'_User$'+owner.target.id,row.id]);row=await new Parse.Query(config.className).get(row.id,{useMasterKey:true});}}catch(error){if(creating&&row.id)await row.destroy({useMasterKey:true}).catch(()=>undefined);fail(422,'会员资料保存失败:'+String(error&&error.message||'数据结构不可用').slice(0,180));}const after=memberResourceJson(kind,row);await memberResourceAudit(current,creating?'create':'update',kind,row,before,after,request);return after;}
  2867. async function memberResourceDelete(kind,input,current,request){const owner=await memberResourceOwner(current,input),row=await memberResourceObject(kind,input.id,owner),before=memberResourceJson(kind,row);row.set('isDeleted',true);await row.save(null,{useMasterKey:true});await memberResourceAudit(current,'delete',kind,row,before,{},request);return{ID:row.id,id:row.id,deleted:true};}
  2868. function voteAskJson(row){return{objectId:String(row.objectId||''),ID:number(row.id),Title:String(row.title||''),Remind:String(row.remind||''),Category:String(row.category||''),ZType:number(row.ztype),ZStatus:number(row.zstatus),StartDate:row.startDate,EndDate:row.endDate,IsNeedLogin:number(row.isNeedLogin),IsShowResult:number(row.isShowResult)};}
  2869. function voteQuestionJson(row){return{objectId:String(row.objectId||''),ID:number(row.id),AskID:number(row.askId),QTitle:String(row.qtitle||''),QContent:String(row.qcontent||''),QOption:String(row.qoption||'[]'),QType:String(row.qtype||''),QFlag:String(row.qflag||''),Required:number(row.required),OrderID:number(row.orderId)};}
  2870. async function voteAsk(input){const askId=number(input.id||input.askid);if(!askId)fail(400,'缺少问卷 ID');const rows=await Psql.query('SELECT * FROM "DesignAsk" WHERE CAST("company" AS text)=ANY($1::text[]) AND CAST("id" AS text)=$2 AND COALESCE(CAST("zstatus" AS text),\'99\')=\'99\' LIMIT 1',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],String(askId)]);return rows.map(voteAskJson);}
  2871. async function voteQuestions(input){const askId=number(input.askid||input.id);if(!askId)fail(400,'缺少问卷 ID');const rows=await Psql.query('SELECT * FROM "DesignQuestion" WHERE CAST("company" AS text)=ANY($1::text[]) AND CAST("askId" AS text)=$2 ORDER BY CASE WHEN CAST("orderId" AS text)~\'^-?[0-9]+$\' THEN CAST("orderId" AS numeric) ELSE 0 END ASC,CASE WHEN CAST("id" AS text)~\'^-?[0-9]+$\' THEN CAST("id" AS numeric) ELSE 0 END ASC',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],String(askId)]);return rows.map(voteQuestionJson);}
  2872. async function simpleAppAudit(current,action,targetClass,targetId,after){try{const audit=new Parse.Object('AppAuditLog');audit.set('legacyUserId',ownLegacyId(current));audit.set('action',action);audit.set('targetClass',targetClass);audit.set('targetId',String(targetId));audit.set('before',{});audit.set('after',safe(after||{}));audit.set('sourceKey','app:'+action+':'+targetId+':'+Date.now());await audit.save(null,{useMasterKey:true});await Psql.none('UPDATE "AppAuditLog" SET "company"=$1,"user"=$2,"updatedAt"=CURRENT_TIMESTAMP WHERE "objectId"=$3',['Company$'+DEFAULT_COMPANY_ID,'_User$'+current.id,audit.id]);}catch(_){} }
  2873. async function voteAnswer(input,current){const model=parseObject(input.mu,'答题数据'),uid=await authorizeRequestedUser(current,model.UserID||model.userId||ownLegacyId(current)),askId=number(model.AskID||model.askId);if(!askId)fail(400,'缺少问卷 ID');const ask=await voteAsk({id:askId});if(!ask.length)fail(404,'问卷不存在或已停用');const answers=parseArray(model.Answer||model.answer);if(!answers.length||answers.length>200)fail(400,'答题内容为空或数量异常');const questionRows=await Psql.query('SELECT CAST("id" AS text) AS id FROM "DesignQuestion" WHERE CAST("company" AS text)=ANY($1::text[]) AND CAST("askId" AS text)=$2',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],String(askId)]),allowed=new Set(questionRows.map((row)=>String(row.id)));for(const answer of answers){if(!allowed.has(String(answer&&answer.qid||'')))fail(400,'答题内容包含不属于当前问卷的题目');if(![0,1,2].includes(number(answer&&answer.result,-1)))fail(400,'答题结果状态无效');}const stamp=String(model.CDate||model.cdate||'').trim(),sourceKey='app:vote:'+uid+':'+askId+':'+(stamp||Date.now());const existing=await Psql.query('SELECT "objectId","id" FROM "DesignAnswer" WHERE "sourceKey"=$1 LIMIT 1',[sourceKey]);if(existing[0])return number(existing[0].id);let row=new Parse.Object('DesignAnswer');row.set('askId',askId);row.set('userId',uid);row.set('answer',JSON.stringify(answers));row.set('remind',String(model.Remin||model.Remind||'').slice(0,500));row.set('ztype',number(model.ZType));row.set('zstatus',number(model.Zstatus));row.set('source',String(model.Source||'').slice(0,200));row.set('ip','');row.set('cdate',stamp&&!Number.isNaN(new Date(stamp).getTime())?new Date(stamp):new Date());row.set('sourceKey',sourceKey);const data={...mobileObjectData(row),id:Math.floor(100000000000000+Math.random()*800000000000000),company:'Company$'+DEFAULT_COMPANY_ID};const saved=await mobileAtomicWrite(input,current,[{table:'DesignAnswer',data,match:{sourceKey}}],String(uid));return number(saved[0].id);}
  2874. async function unitRecordUpdate(input,current){const uid=await authorizeRequestedUser(current,input.userId||input.uid),bookId=number(input.bookId),nodeIds=[...new Set(String(input.nids||'').split(',').map(number).filter(Boolean))].slice(0,100),words=parseObject(input.wordIds,'单元学习记录');if(!bookId||!nodeIds.length)fail(400,'缺少教材或单元 ID');const validRows=await Psql.query('SELECT CAST("nodeId" AS text) AS id FROM "Node" WHERE CAST("company" AS text)=ANY($1::text[]) AND CAST("nodeId" AS text)=ANY($2::text[])',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],nodeIds.map(String)]),valid=new Set(validRows.map((row)=>String(row.id)));if(valid.size!==nodeIds.length)fail(400,'单元范围包含不存在的栏目');const owner=uid===ownLegacyId(current)?current:await legacyUserObject(uid),results=[],operations=[];if(!owner)fail(404,'用户不存在');for(const nodeId of nodeIds){const ids=[...new Set(String(words[nodeId]||words[String(nodeId)]||'').split(',').map((value)=>String(value).trim()).filter((value)=>/^\d+$/.test(value)))].slice(0,5000),sourceKey='app:unit:'+uid+':'+bookId+':'+nodeId,existing=await Psql.query('SELECT "objectId" FROM "UnitLearningRecord" WHERE "sourceKey"=$1 LIMIT 1',[sourceKey]);const data={objectId:randomObjectId(),createdAt:new Date().toISOString(),company:'Company$'+DEFAULT_COMPANY_ID,user:'_User$'+owner.id,legacyUserId:uid,bookId,nodeId,wordIds:ids,learnedCount:ids.length,isDeleted:false,sourceKey};operations.push({table:'UnitLearningRecord',data,match:{sourceKey},union:['wordIds']});}const saved=await mobileAtomicWrite(input,current,operations,String(uid));return{bookId,updated:saved.length,items:saved.map(row=>({nodeId:row.nodeId,learnedCount:row.learnedCount,objectId:row.objectId}))};}
  2875. function favoriteKey(uid,input){const targetId=String(input.id||input.targetId||'').trim(),type=number(input.type,2);if(!targetId||targetId.length>100)fail(400,'收藏目标 ID 无效');return{targetId,type,sourceKey:'app:favorite:'+uid+':'+type+':'+targetId};}
  2876. async function favoriteState(input,current){const uid=await authorizeRequestedUser(current,input.uid),key=favoriteKey(uid,input),row=await Psql.query('SELECT "objectId","isDeleted" FROM "UserFavorite" WHERE "sourceKey"=$1 LIMIT 1',[key.sourceKey]);return row[0]&&row[0].isDeleted!==true?'1':'0';}
  2877. async function favoriteAdd(input,current){const uid=await authorizeRequestedUser(current,input.uid),owner=uid===ownLegacyId(current)?current:await legacyUserObject(uid),key=favoriteKey(uid,input);if(!owner)fail(404,'用户不存在');const existing=await Psql.query('SELECT "objectId" FROM "UserFavorite" WHERE "sourceKey"=$1 LIMIT 1',[key.sourceKey]);let row=existing[0]?await new Parse.Query('UserFavorite').get(String(existing[0].objectId),{useMasterKey:true}):new Parse.Object('UserFavorite'),creating=!existing[0];row.set('legacyUserId',uid);row.set('targetId',key.targetId);row.set('targetType',key.type);row.set('title',String(input.title||'').slice(0,300));row.set('url',String(input.url||'').slice(0,1000));row.set('isDeleted',false);row.set('sourceKey',key.sourceKey);await row.save(null,{useMasterKey:true});if(creating)await Psql.none('UPDATE "UserFavorite" SET "company"=$1,"user"=$2,"updatedAt"=CURRENT_TIMESTAMP WHERE "objectId"=$3',['Company$'+DEFAULT_COMPANY_ID,'_User$'+owner.id,row.id]);await simpleAppAudit(current,'favorite-add','UserFavorite',row.id,{targetId:key.targetId,targetType:key.type});return 1;}
  2878. async function favoriteDelete(input,current){const uid=await authorizeRequestedUser(current,input.uid),key=favoriteKey(uid,input),existing=await Psql.query('SELECT "objectId" FROM "UserFavorite" WHERE "sourceKey"=$1 LIMIT 1',[key.sourceKey]);if(existing[0]){const row=await new Parse.Query('UserFavorite').get(String(existing[0].objectId),{useMasterKey:true});row.set('isDeleted',true);await row.save(null,{useMasterKey:true});await simpleAppAudit(current,'favorite-delete','UserFavorite',row.id,{targetId:key.targetId,targetType:key.type});}return 1;}
  2879. const SERVICE_REQUEST_TYPES=new Map([[7,'account-destruction'],[8,'supplier-application'],[9,'supplier-invitation'],[10,'order-return'],[11,'order-exchange'],[12,'merchant-service'],[13,'merchant-settlement']]);
  2880. function serviceRequestJson(row){const payload=safe(row.get('payload')||{}),created=row.createdAt&&row.createdAt.toISOString(),updated=row.updatedAt&&row.updatedAt.toISOString();return{...payload,objectId:row.id,ID:row.id,id:row.id,PubID:number(row.get('pubId')),PubUserID:number(row.get('legacyUserId')),PubTitle:String(row.get('title')||''),PubContent:String(row.get('content')||''),Concat:String(row.get('contact')||''),PubIsTrue:number(payload.PubIsTrue,row.get('status')==='submitted'?1:0),PubAddTime:created,CreateTime:created,UpdateTime:updated,Status:String(row.get('status')||'submitted'),Category:String(row.get('category')||'')};}
  2881. async function serviceRequestAdd(input,current){const uid=await authorizeRequestedUser(current,input.uid),pubId=number(input.pubid||input.pubId);if(!SERVICE_REQUEST_TYPES.has(pubId))fail(400,'不支持的申请类型');const owner=uid===ownLegacyId(current)?current:await legacyUserObject(uid);if(!owner)fail(404,'用户不存在');const model=parseObject(input.model,'申请内容'),payload=safe(model);if(payload.PubContentid!==undefined)payload.PubContentid=String(payload.PubContentid);if(JSON.stringify(payload).length>100000)fail(400,'申请内容过大');const title=cleanText(model.PubTitle||model.Title||SERVICE_REQUEST_TYPES.get(pubId),300),content=cleanText(model.PubContent||model.reason||model.remark||'',50000),contact=cleanText(model.Concat||model.concat||model.mobile||'',300);if(!title&&!content)fail(400,'申请标题和内容不能同时为空');let row=new Parse.Object('ServiceRequest');row.set('legacyUserId',uid);row.set('pubId',pubId);row.set('category',SERVICE_REQUEST_TYPES.get(pubId));row.set('title',title);row.set('content',content);row.set('contact',contact);row.set('payload',payload);row.set('status','submitted');row.set('isDeleted',false);row.set('sourceKey','app:service-request:'+uid+':'+pubId+':'+Date.now()+':'+Math.random().toString(36).slice(2));try{await row.save(null,{useMasterKey:true});await Psql.none('UPDATE "ServiceRequest" SET "company"=$1,"user"=$2,"updatedAt"=CURRENT_TIMESTAMP WHERE "objectId"=$3',['Company$'+DEFAULT_COMPANY_ID,'_User$'+owner.id,row.id]);row=await new Parse.Query('ServiceRequest').get(row.id,{useMasterKey:true});}catch(error){if(row.id)await row.destroy({useMasterKey:true}).catch(()=>undefined);fail(422,'申请保存失败:'+String(error&&error.message||'数据结构不可用').slice(0,160));}const result=serviceRequestJson(row);await simpleAppAudit(current,'service-request-create','ServiceRequest',row.id,{pubId,category:result.Category,title:result.PubTitle});return result;}
  2882. async function serviceRequestList(input,current){const uid=await authorizeRequestedUser(current,input.uid),pubId=number(input.pubid||input.pubId);if(!SERVICE_REQUEST_TYPES.has(pubId))fail(400,'不支持的申请类型');const paging=pageInput(input),conditions=['CAST("company" AS text)=ANY($1::text[])','"legacyUserId"=$2','"pubId"=$3','COALESCE("isDeleted",FALSE)=FALSE'],params=[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],uid,pubId];if(input.cid!==undefined&&input.cid!==''){params.push(String(input.cid));conditions.push('CAST("payload"->>\'PubContentid\' AS text)=$'+params.length);}if(input.status){params.push(String(input.status));conditions.push('"status"=$'+params.length);}const where=conditions.join(' AND '),count=await Psql.one('SELECT COUNT(*)::int AS total FROM "ServiceRequest" WHERE '+where,params),limitIndex=params.length+1,offsetIndex=params.length+2,ids=await Psql.query('SELECT "objectId" FROM "ServiceRequest" WHERE '+where+' ORDER BY "createdAt" DESC LIMIT $'+limitIndex+' OFFSET $'+offsetIndex,[...params,paging.size,(paging.index-1)*paging.size]),rows=[];for(const item of ids)rows.push(await new Parse.Query('ServiceRequest').get(String(item.objectId),{useMasterKey:true}));const total=number(count.total);return{rows:rows.map(serviceRequestJson),page:{itemCount:total,pageCount:Math.ceil(total/paging.size),pageIndex:paging.index,pageSize:paging.size}};}
  2883. function normalizeProductGallery(value){let source=value;if(typeof source==='string'){try{source=JSON.parse(source);}catch(_){source=source.split('|');}}if(!Array.isArray(source))return[];const output=[];for(const item of source){const url=cleanText(item&&typeof item==='object'?item.url:item,1000);if(url&&!output.includes(url))output.push(url);if(output.length>=20)break;}return output;}
  2884. function commerceProductJson(row){const gallery=Array.isArray(row.gallery)?row.gallery:normalizeProductGallery(row.gallery),created=row.createdAt instanceof Date?row.createdAt.toISOString():String(row.createdAt||''),updated=row.updatedAt instanceof Date?row.updatedAt.toISOString():String(row.updatedAt||'');return{objectId:String(row.objectId||''),ID:number(row.productId),id:number(row.productId),Proname:String(row.name||''),ProName:String(row.name||''),NodeID:number(row.nodeId),Nodeid:number(row.nodeId),LinPrice:number(row.retailPrice),ShiPrice:number(row.marketPrice),ProUnit:String(row.unit||'件'),Stock:number(row.stock),Thumbnails:String(row.thumbnail||''),Proinfo:String(row.summary||''),Procontent:String(row.content||''),pics:JSON.stringify(gallery.map((url)=>({url,desc:''}))),UserID:number(row.legacyOwnerId),Sales:number(row.sales),hot:Boolean(row.hot),Status:String(row.status||'draft'),AddTime:created,CreateTime:created,UpdateTime:updated};}
  2885. async function commerceProductRow(id){const key=String(id||'').trim();if(!key)fail(400,'缺少商品 ID');const rows=await Psql.query('SELECT * FROM "CommerceProduct" WHERE CAST("company" AS text)=ANY($1::text[]) AND (CAST("productId" AS text)=$2 OR CAST("objectId" AS text)=$2) AND COALESCE("isDeleted",FALSE)=FALSE LIMIT 1',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],key]);return rows[0]||null;}
  2886. function currentOwnsProduct(current,row){return Boolean(current)&&number(row.legacyOwnerId)===ownLegacyId(current);}
  2887. async function productList(input,current){const paging=pageInput(input),conditions=['CAST("company" AS text)=ANY($1::text[])','COALESCE("isDeleted",FALSE)=FALSE'],params=[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID]],requestedOwner=number(input.uids||input.uid);if(requestedOwner){if(!current)fail(401,'需要登录');await authorizeRequestedUser(current,requestedOwner);params.push(requestedOwner);conditions.push('"legacyOwnerId"=$'+params.length);const mode=String(input.mode||'normal');params.push(mode==='unaudit'?'draft':mode==='unsales'?'paused':'published');conditions.push('"status"=$'+params.length);}else{conditions.push('"status"=\'published\'');}const nodes=String(input.nodes||input.nid||'').split(',').map(number).filter(Boolean);if(nodes.length){params.push(nodes);conditions.push('"nodeId"=ANY($'+params.length+'::numeric[])');}if(number(input.hot)===1)conditions.push('COALESCE("hot",FALSE)=TRUE');const keyword=cleanText(input.skey||input.search,100);if(keyword){params.push('%'+keyword.replace(/[\\%_]/g,'\\$&')+'%');conditions.push('"name" ILIKE $'+params.length+' ESCAPE \'\\\'');}const where=conditions.join(' AND '),count=await Psql.one('SELECT COUNT(*)::int AS total FROM "CommerceProduct" WHERE '+where,params),rows=await Psql.query('SELECT * FROM "CommerceProduct" WHERE '+where+' ORDER BY COALESCE("hot",FALSE) DESC,"updatedAt" DESC,"productId" DESC LIMIT $'+(params.length+1)+' OFFSET $'+(params.length+2),[...params,paging.size,(paging.index-1)*paging.size]),total=number(count.total);return{rows:rows.map(commerceProductJson),page:{itemCount:total,pageCount:Math.ceil(total/paging.size),pageIndex:paging.index,pageSize:paging.size}};}
  2888. async function productGet(input,current){const row=await commerceProductRow(input.id||input.proid);if(!row)fail(404,'商品不存在');if(String(row.status)!=='published'&&!currentOwnsProduct(current,row)&&!(current&&isAdminUser(current)))fail(404,'商品不存在或尚未发布');return[commerceProductJson(row)];}
  2889. function applyCommerceProduct(row,model,addon,creating){const name=model.Proname??model.ProName??model.name,nodeId=model.Nodeid??model.NodeID??model.nodeId,retail=model.LinPrice??model.retailPrice,market=model.ShiPrice??model.marketPrice,unit=model.ProUnit??model.unit,stock=model.Stock??model.stock,thumbnail=model.Thumbnails??model.thumbnail,summary=model.Proinfo??model.summary,content=model.Procontent??model.content,gallery=addon.pics??model.pics;if(creating&&(!String(name||'').trim()||!number(nodeId)||number(retail)<=0||number(market)<=0||!String(unit||'').trim()||number(stock,-1)<0))fail(400,'请完整填写商品名称、分类、价格、单位和库存');if(name!==undefined){if(!String(name).trim())fail(400,'商品名称不能为空');row.set('name',cleanText(name,100));}if(nodeId!==undefined){if(!number(nodeId))fail(400,'商品分类无效');row.set('nodeId',number(nodeId));}if(retail!==undefined){if(number(retail)<=0)fail(400,'零售价必须大于0');row.set('retailPrice',number(retail));}if(market!==undefined){if(number(market)<=0)fail(400,'市场价必须大于0');row.set('marketPrice',number(market));}if(unit!==undefined)row.set('unit',cleanText(unit,30)||'件');if(stock!==undefined){const value=number(stock,-1);if(!Number.isInteger(value)||value<0)fail(400,'库存必须为非负整数');if(creating)row.set('stock',value);}if(thumbnail!==undefined)row.set('thumbnail',cleanText(thumbnail,1000));if(summary!==undefined)row.set('summary',cleanText(summary,1000));if(content!==undefined)row.set('content',cleanText(content,50000));if(gallery!==undefined)row.set('gallery',normalizeProductGallery(gallery));}
  2890. async function productSave(input,current){const uid=await authorizeRequestedUser(current,input.uid),owner=uid===ownLegacyId(current)?current:await legacyUserObject(uid);if(!owner)fail(404,'用户不存在');const model=parseObject(input.model,'商品资料'),addon=parseObject(input.addon,'商品附表'),requestedId=input.id||model.ID,creating=!requestedId;let object,before={};if(creating){object=new Parse.Object('CommerceProduct');object.set('legacyOwnerId',uid);object.set('sales',0);object.set('hot',false);object.set('status','draft');object.set('isDeleted',false);object.set('sourceKey','app:product:'+uid+':'+Date.now()+':'+Math.random().toString(36).slice(2));}else{const existing=await commerceProductRow(requestedId);if(!existing)fail(404,'商品不存在');if(!currentOwnsProduct(current,existing)&&!isAdminUser(current))fail(403,'无权编辑该商品');before=commerceProductJson(existing);object=await new Parse.Query('CommerceProduct').get(String(existing.objectId),{useMasterKey:true});}applyCommerceProduct(object,model,addon,creating);try{await object.save(null,{useMasterKey:true});if(creating){const assigned=await Psql.one('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-commerce-product-id\'))),next_id AS MATERIALIZED (SELECT COALESCE(MAX("productId"),0)+1 AS id FROM "CommerceProduct",lock_row) UPDATE "CommerceProduct" SET "company"=$1,"owner"=$2,"productId"=next_id.id,"updatedAt"=CURRENT_TIMESTAMP FROM next_id WHERE "objectId"=$3 RETURNING next_id.id',['Company$'+DEFAULT_COMPANY_ID,'_User$'+owner.id,object.id]);object=await new Parse.Query('CommerceProduct').get(object.id,{useMasterKey:true});if(!number(assigned.id))throw new Error('商品编号分配失败');}}catch(error){if(creating&&object.id)await object.destroy({useMasterKey:true}).catch(()=>undefined);fail(422,'商品保存失败:'+String(error&&error.message||'数据结构不可用').slice(0,160));}const fresh=await commerceProductRow(object.id),result=commerceProductJson(fresh);await simpleAppAudit(current,creating?'product-create':'product-update','CommerceProduct',object.id,{before,after:{ID:result.ID,Proname:result.Proname,Status:result.Status}});return result.ID;}
  2891. async function productDelete(input,current){const uid=await authorizeRequestedUser(current,input.uid),row=await commerceProductRow(input.id);if(!row)fail(404,'商品不存在');if(number(row.legacyOwnerId)!==uid&&!isAdminUser(current))fail(403,'无权删除该商品');await Psql.none('UPDATE "CommerceProduct" SET "isDeleted"=TRUE,"status"=\'recycled\',"updatedAt"=CURRENT_TIMESTAMP WHERE "objectId"=$1',[row.objectId]);await simpleAppAudit(current,'product-recycle','CommerceProduct',row.objectId,{ID:number(row.productId)});return 1;}
  2892. async function productSaleChange(input,current){const uid=await authorizeRequestedUser(current,input.uid),row=await commerceProductRow(input.id);if(!row)fail(404,'商品不存在');if(number(row.legacyOwnerId)!==uid&&!isAdminUser(current))fail(403,'无权修改该商品');const enable=number(input.status)===1;if(enable&&String(row.status)==='draft'&&!isAdminUser(current))fail(409,'待审核商品不能直接上架');const status=enable?'published':'paused';await Psql.none('UPDATE "CommerceProduct" SET "status"=$1,"updatedAt"=CURRENT_TIMESTAMP WHERE "objectId"=$2',[status,row.objectId]);await simpleAppAudit(current,'product-sale-status','CommerceProduct',row.objectId,{ID:number(row.productId),status});return 1;}
  2893. async function productStockChange(input,current){const uid=await authorizeRequestedUser(current,input.uid),row=await commerceProductRow(input.proid||input.id);if(!row)fail(404,'商品不存在');if(number(row.legacyOwnerId)!==uid&&!isAdminUser(current))fail(403,'无权管理该商品库存');const delta=number(input.num);if(!Number.isInteger(delta)||delta===0||Math.abs(delta)>1000000)fail(400,'库存变动数量无效');const objectId=randomObjectId(),sourceKey='app:stock:'+number(row.productId)+':'+Date.now()+':'+Math.random().toString(36).slice(2),result=await Psql.one('WITH locked AS (SELECT "objectId",COALESCE("stock",0)::numeric AS before FROM "CommerceProduct" WHERE "objectId"=$1 FOR UPDATE),updated AS (UPDATE "CommerceProduct" p SET "stock"=locked.before+$2,"updatedAt"=CURRENT_TIMESTAMP FROM locked WHERE p."objectId"=locked."objectId" AND locked.before+$2>=0 RETURNING locked.before AS before,p."stock" AS after),logged AS (INSERT INTO "ProductStockLedger" ("objectId","createdAt","updatedAt","company","user","legacyUserId","product","productId","delta","stockBefore","stockAfter","remark","sourceKey") SELECT $3,NOW(),NOW(),$4,$5,$6,$7,$8,$2,updated.before,updated.after,$9,$10 FROM updated RETURNING "objectId") SELECT (SELECT before FROM updated) AS before,(SELECT after FROM updated) AS after,(SELECT "objectId" FROM logged) AS "ledgerId"',[row.objectId,delta,objectId,'Company$'+DEFAULT_COMPANY_ID,'_User$'+current.id,uid,'CommerceProduct$'+row.objectId,number(row.productId),cleanText(input.remark,500),sourceKey]);if(result.after==null)fail(409,'库存不足');await simpleAppAudit(current,'product-stock-change','CommerceProduct',row.objectId,{ID:number(row.productId),delta,before:number(result.before),after:number(result.after)});return{ID:String(result.ledgerId),ProID:number(row.productId),Num:delta,Stock:number(result.after)};}
  2894. async function productStockList(input,current){const row=await commerceProductRow(input.proid||input.id);if(!row)fail(404,'商品不存在');if(!currentOwnsProduct(current,row)&&!isAdminUser(current))fail(403,'无权查看该商品库存');const paging=pageInput(input),count=await Psql.one('SELECT COUNT(*)::int AS total FROM "ProductStockLedger" WHERE CAST("company" AS text)=ANY($1::text[]) AND "productId"=$2',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],number(row.productId)]),rows=await Psql.query('SELECT * FROM "ProductStockLedger" WHERE CAST("company" AS text)=ANY($1::text[]) AND "productId"=$2 ORDER BY "createdAt" DESC LIMIT $3 OFFSET $4',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],number(row.productId),paging.size,(paging.index-1)*paging.size]),total=number(count.total);return{rows:rows.map((item)=>({objectId:String(item.objectId),ID:String(item.objectId),ProID:number(item.productId),Pronum:number(item.delta),Stock:number(item.stockBefore),StockNow:number(item.stockAfter),Remind:String(item.remark||''),CDate:item.createdAt})),page:{itemCount:total,pageCount:Math.ceil(total/paging.size),pageIndex:paging.index,pageSize:paging.size}};}
  2895. async function cartAdd(input,current){const uid=await authorizeRequestedUser(current,input.uid),owner=uid===ownLegacyId(current)?current:await legacyUserObject(uid);if(!owner)fail(404,'用户不存在');const product=await commerceProductRow(input.proID||input.proid||input.id);if(!product||String(product.status)!=='published')fail(404,'商品不存在或已下架');const quantity=number(input.proNum||input.pronum,1);if(!Number.isInteger(quantity)||quantity<1||quantity>999)fail(400,'商品数量无效');const sourceKey='app:cart:'+uid+':'+number(product.productId),existing=await Psql.query('SELECT "objectId" FROM "ShoppingCartItem" WHERE "sourceKey"=$1 LIMIT 1',[sourceKey]);let row;if(existing[0]){row=await new Parse.Query('ShoppingCartItem').get(String(existing[0].objectId),{useMasterKey:true});row.set('quantity',Math.min(999,Math.max(0,number(row.get('quantity')))+quantity));row.set('isDeleted',false);await row.save(null,{useMasterKey:true});}else{row=new Parse.Object('ShoppingCartItem');row.set('legacyUserId',uid);row.set('productId',number(product.productId));row.set('quantity',quantity);row.set('selected',true);row.set('isDeleted',false);row.set('sourceKey',sourceKey);try{await row.save(null,{useMasterKey:true});await Psql.none('UPDATE "ShoppingCartItem" SET "company"=$1,"user"=$2,"product"=$3,"updatedAt"=CURRENT_TIMESTAMP WHERE "objectId"=$4',['Company$'+DEFAULT_COMPANY_ID,'_User$'+owner.id,'CommerceProduct$'+product.objectId,row.id]);}catch(error){if(row.id)await row.destroy({useMasterKey:true}).catch(()=>undefined);fail(422,'购物车保存失败');}}await simpleAppAudit(current,'cart-add','ShoppingCartItem',row.id,{productId:number(product.productId),quantity:number(row.get('quantity'))});return 1;}
  2896. async function cartList(input,current){const uid=await authorizeRequestedUser(current,input.uid),ids=String(input.ids||'').split(',').map((value)=>value.trim()).filter((value)=>value&&value!=='0').slice(0,200),conditions=['CAST(c."company" AS text)=ANY($1::text[])','c."legacyUserId"=$2','COALESCE(c."isDeleted",FALSE)=FALSE','COALESCE(p."isDeleted",FALSE)=FALSE','p."status"=\'published\''],params=[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],uid];if(ids.length){params.push(ids);conditions.push('CAST(c."objectId" AS text)=ANY($'+params.length+'::text[])');}const rows=await Psql.query('SELECT c."objectId" AS "cartObjectId",c."quantity",c."productId",p.* FROM "ShoppingCartItem" c JOIN "CommerceProduct" p ON CAST(p."company" AS text)=ANY($1::text[]) AND p."productId"=c."productId" WHERE '+conditions.join(' AND ')+' ORDER BY c."updatedAt" DESC',params),items=rows.map((item)=>({objectId:String(item.cartObjectId),ID:String(item.cartObjectId),id:String(item.cartObjectId),ProID:number(item.productId),Pronum:number(item.quantity),pronum:number(item.quantity),Proname:String(item.name||''),Nodeid:number(item.nodeId),NodeID:number(item.nodeId),LinPrice:number(item.retailPrice),ShiPrice:number(item.marketPrice),proimg:String(item.thumbnail||''),Thumbnails:String(item.thumbnail||''),Stock:number(item.stock),AllMoney:number(item.retailPrice)*number(item.quantity),ProInfo:JSON.stringify({Nodeid:number(item.nodeId),Thumbnails:String(item.thumbnail||'')})}));return{CartDT:items,TotalMoney:items.reduce((sum,item)=>sum+number(item.AllMoney),0),TotalCount:items.reduce((sum,item)=>sum+number(item.Pronum),0)};}
  2897. function jsonObject(value,label){if(value==null||value==='')return{};if(value&&typeof value==='object'&&!Array.isArray(value))return safe(value);try{const parsed=JSON.parse(String(value));if(parsed&&typeof parsed==='object'&&!Array.isArray(parsed))return safe(parsed);}catch(_){}fail(400,(label||'JSON')+'格式无效');}
  2898. function orderStatusCode(status){return status==='completed'?2:status==='shipped'?1:0;}
  2899. function commerceOrderJson(row){const created=row.createdAt instanceof Date?row.createdAt.toISOString():String(row.createdAt||''),status=String(row.status||'pending'),receiver=row.receiver&&typeof row.receiver==='object'?row.receiver:{},invoice=row.invoice&&typeof row.invoice==='object'?row.invoice:{};return{objectId:String(row.objectId||''),ID:number(row.orderId),id:number(row.orderId),OrderNo:String(row.orderNo||''),Userid:number(row.buyerLegacyId),UserID:number(row.buyerLegacyId),Ordersamount:number(row.total),Orderamount:number(row.total),Subtotal:number(row.subtotal),Discount:number(row.discount),Fare:number(row.shipping),Paymentstatus:status==='pending'?0:1,StateLogistics:orderStatusCode(status),Signed:status==='completed'?1:0,Aside:row.isDeleted?1:0,OrderStatus:orderStatusCode(status),Jiedao:String(receiver.detailInfo||''),ReceivName:String(receiver.userName||''),MobileNum:String(receiver.telNumber||''),Address:[receiver.provinceName,receiver.cityName,receiver.countyName,receiver.detailInfo].filter(Boolean).join(' '),Invoiceneeds:Object.keys(invoice).length?1:0,AddTime:created,CreateTime:created,UpdateTime:row.updatedAt,Status:status};}
  2900. function commerceOrderItemJson(row){return{objectId:String(row.objectId||''),ID:String(row.objectId||''),OrderID:number(row.orderId),ProID:number(row.productId),Proname:String(row.name||''),ProName:String(row.name||''),Pronum:number(row.quantity),LinPrice:number(row.unitPrice),AllMoney:number(row.amount),Thumbnails:String(row.thumbnail||''),proimg:String(row.thumbnail||''),UserID:number(row.sellerLegacyId),ProInfo:JSON.stringify(row.productSnapshot||{})};}
  2901. async function commerceOrderRow(id){const key=String(id||'').trim();if(!key)fail(400,'缺少订单 ID');const rows=await Psql.query('SELECT * FROM "CommerceOrder" WHERE CAST("company" AS text)=ANY($1::text[]) AND (CAST("orderId" AS text)=$2 OR CAST("objectId" AS text)=$2 OR CAST("orderNo" AS text)=$2) AND COALESCE("isDeleted",FALSE)=FALSE LIMIT 1',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],key]);return rows[0]||null;}
  2902. async function commerceOrderItems(orderId){return Psql.query('SELECT * FROM "CommerceOrderItem" WHERE CAST("company" AS text)=ANY($1::text[]) AND "orderId"=$2 ORDER BY "createdAt" ASC',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],number(orderId)]);}
  2903. async function orderAccess(row,current,mode){const uid=ownLegacyId(current);if(number(row.buyerLegacyId)===uid||isAdminUser(current))return true;const count=await Psql.one('SELECT COUNT(*)::int AS total FROM "CommerceOrderItem" WHERE CAST("company" AS text)=ANY($1::text[]) AND "orderId"=$2 AND "sellerLegacyId"=$3',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],number(row.orderId),uid]);if(number(count.total)>0)return true;if(mode==='buyer')fail(403,'仅订单买家可执行该操作');fail(403,'无权查看该订单');}
  2904. async function orderGet(input,current){const row=await commerceOrderRow(input.id||input.orderId);if(!row)fail(404,'订单不存在');await orderAccess(row,current,'read');const items=await commerceOrderItems(row.orderId),payments=await Psql.query('SELECT * FROM "CommercePayment" WHERE CAST("company" AS text)=ANY($1::text[]) AND "orderId"=$2 ORDER BY "createdAt" DESC LIMIT 1',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],number(row.orderId)]),payment=payments[0]||{},invoice=row.invoice&&typeof row.invoice==='object'?row.invoice:{},express={ID:row.expressNo?1:0,ExpComp:String(row.expressCompany||''),ExpNo:String(row.expressNo||''),ExpRemind:String(row.expressRemark||'')};return{result:commerceOrderJson(row),addon:{payment:{ID:String(payment.objectId||''),OrderID:number(row.orderId),PayMoney:number(payment.amount),PayPlat:String(payment.mode||''),Status:String(payment.status||''),PayTime:payment.paidAt,ArriveMoney:number(row.discount)},cart:items.map(commerceOrderItemJson),invoice:{...invoice,InvType:number(invoice.aud),InvHead:String(invoice.name||''),UserCode:String(invoice.idnum||''),UserEmail:String(invoice.email||'')},express}};}
  2905. function applyOrderFast(conditions,params,fast,alias){const prefix=alias||'o';if(fast==='unpaid'){conditions.push(prefix+'."status"=\'pending\'');}else if(fast==='paid'){conditions.push(prefix+'."status"=\'paid\'');}else if(fast==='issued'){conditions.push(prefix+'."status"=\'shipped\'');}else if(fast==='signed'){conditions.push(prefix+'."status"=\'completed\'');}}
  2906. async function orderList(input,current,sellerMode){const uid=await authorizeRequestedUser(current,input.uid),paging=pageInput(input),params=[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],uid],conditions=['CAST(o."company" AS text)=ANY($1::text[])','COALESCE(o."isDeleted",FALSE)=FALSE'];let join='';if(sellerMode){join=' JOIN (SELECT DISTINCT "orderId" FROM "CommerceOrderItem" WHERE CAST("company" AS text)=ANY($1::text[]) AND "sellerLegacyId"=$2) own ON own."orderId"=o."orderId"';}else conditions.push('o."buyerLegacyId"=$2');applyOrderFast(conditions,params,String(input.fast||'all'),'o');const where=conditions.join(' AND '),count=await Psql.one('SELECT COUNT(*)::int AS total FROM "CommerceOrder" o'+join+' WHERE '+where,params),rows=await Psql.query('SELECT o.* FROM "CommerceOrder" o'+join+' WHERE '+where+' ORDER BY o."createdAt" DESC,o."orderId" DESC LIMIT $3 OFFSET $4',[...params,paging.size,(paging.index-1)*paging.size]),total=number(count.total);return{rows:rows.map(commerceOrderJson),page:{itemCount:total,pageCount:Math.ceil(total/paging.size),pageIndex:paging.index,pageSize:paging.size}};}
  2907. async function orderDelivery(input,current){const row=await commerceOrderRow(input.id||input.orderId);if(!row)fail(404,'订单不存在');if(String(row.status)!=='paid')fail(409,'只有已支付待发货订单可以发货');const uid=ownLegacyId(current),owned=await Psql.one('SELECT COUNT(*)::int AS total FROM "CommerceOrderItem" WHERE CAST("company" AS text)=ANY($1::text[]) AND "orderId"=$2 AND "sellerLegacyId"=$3',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],number(row.orderId),uid]);if(!isAdminUser(current)&&number(owned.total)===0)fail(403,'仅商品所属商户可以发货');const company=cleanText(input.expComp,100),no=cleanText(input.expNo,100),remark=cleanText(input.expRemind,500);if(!company||!no)fail(400,'请填写快递公司和快递单号');await Psql.none('UPDATE "CommerceOrder" SET "status"=\'shipped\',"shippedAt"=NOW(),"expressCompany"=$1,"expressNo"=$2,"expressRemark"=$3,"updatedAt"=NOW() WHERE "objectId"=$4',[company,no,remark,row.objectId]);await simpleAppAudit(current,'order-delivery','CommerceOrder',row.objectId,{orderId:number(row.orderId),expressCompany:company,expressNo:no});return 1;}
  2908. async function orderSignFor(input,current){const row=await commerceOrderRow(input.id||input.orderId);if(!row)fail(404,'订单不存在');if(number(row.buyerLegacyId)!==ownLegacyId(current)&&!isAdminUser(current))fail(403,'仅订单买家可以确认收货');if(String(row.status)==='completed')return 1;if(String(row.status)!=='shipped')fail(409,'订单尚未发货');await Psql.none('UPDATE "CommerceOrder" SET "status"=\'completed\',"signedAt"=NOW(),"updatedAt"=NOW() WHERE "objectId"=$1',[row.objectId]);await simpleAppAudit(current,'order-sign-for','CommerceOrder',row.objectId,{orderId:number(row.orderId)});return 1;}
  2909. function couponDefinitionJson(row){return{objectId:String(row.objectId||''),ID:number(row.couponId),id:number(row.couponId),Flow:number(row.couponId),CouponName:String(row.name||''),Title:String(row.name||''),Amount:number(row.amount),MinAmount:number(row.minAmount),MaxAmount:number(row.maxAmount)||Number.MAX_SAFE_INTEGER,StartTime:row.startsAt,EndTime:row.expiresAt,Status:String(row.status||'active'),TotalNum:number(row.totalQuantity),GetNum:number(row.claimedQuantity)};}
  2910. function userCouponJson(row){const base=couponDefinitionJson(row),now=Date.now(),expires=row.userExpiresAt||row.expiresAt,status=String(row.userStatus||'idle'),expired=expires&&new Date(expires).getTime()<now;return{...base,objectId:String(row.userCouponObjectId||''),ID:String(row.userCouponObjectId||''),id:String(row.userCouponObjectId||''),Flow:number(row.couponId),Status:expired?'expired':status,UseTime:row.usedAt,GetTime:row.userCreatedAt,EndTime:expires};}
  2911. async function couponList(input,current){await authorizeRequestedUser(current,input.uid);const paging=pageInput(input),rows=await Psql.query('SELECT d.* FROM "CouponDefinition" d WHERE CAST(d."company" AS text)=ANY($1::text[]) AND d."status"=\'active\' AND (d."startsAt" IS NULL OR d."startsAt"<=NOW()) AND (d."expiresAt" IS NULL OR d."expiresAt">NOW()) AND (COALESCE(d."totalQuantity",0)=0 OR COALESCE(d."claimedQuantity",0)<d."totalQuantity") ORDER BY d."amount" DESC,d."createdAt" DESC LIMIT $2 OFFSET $3',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],paging.size,(paging.index-1)*paging.size]),count=await Psql.one('SELECT COUNT(*)::int AS total FROM "CouponDefinition" d WHERE CAST(d."company" AS text)=ANY($1::text[]) AND d."status"=\'active\' AND (d."startsAt" IS NULL OR d."startsAt"<=NOW()) AND (d."expiresAt" IS NULL OR d."expiresAt">NOW()) AND (COALESCE(d."totalQuantity",0)=0 OR COALESCE(d."claimedQuantity",0)<d."totalQuantity")',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID]]),total=number(count.total);return{rows:rows.map(couponDefinitionJson),page:{itemCount:total,pageCount:Math.ceil(total/paging.size),pageIndex:paging.index,pageSize:paging.size}};}
  2912. async function couponClaim(input,current){const uid=await authorizeRequestedUser(current,input.uid),couponId=number(input.flowid||input.couponId||input.id);if(!couponId)fail(400,'缺少优惠券 ID');const owner=uid===ownLegacyId(current)?current:await legacyUserObject(uid);if(!owner)fail(404,'用户不存在');const sourceKey='app:user-coupon:'+uid+':'+couponId,existing=await Psql.query('SELECT u.*,d.*,u."objectId" AS "userCouponObjectId",u."status" AS "userStatus",u."expiresAt" AS "userExpiresAt",u."createdAt" AS "userCreatedAt" FROM "UserCoupon" u JOIN "CouponDefinition" d ON d."couponId"=u."couponId" AND CAST(d."company" AS text)=ANY($1::text[]) WHERE u."sourceKey"=$2 AND COALESCE(u."isDeleted",FALSE)=FALSE LIMIT 1',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],sourceKey]);if(existing[0])return userCouponJson(existing[0]);const objectId=randomObjectId(),result=await Psql.one('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext($1))),d AS MATERIALIZED (SELECT * FROM "CouponDefinition",lock_row WHERE CAST("company" AS text)=ANY($2::text[]) AND "couponId"=$3 AND "status"=\'active\' AND ("startsAt" IS NULL OR "startsAt"<=NOW()) AND ("expiresAt" IS NULL OR "expiresAt">NOW()) AND (COALESCE("totalQuantity",0)=0 OR COALESCE("claimedQuantity",0)<"totalQuantity") FOR UPDATE),ins AS (INSERT INTO "UserCoupon" ("objectId","createdAt","updatedAt","company","user","legacyUserId","isDeleted","coupon","couponId","status","expiresAt","sourceKey") SELECT $4,NOW(),NOW(),$5,$6,$7,FALSE,\'CouponDefinition$\'||d."objectId",d."couponId",\'idle\',d."expiresAt",$1 FROM d WHERE NOT EXISTS (SELECT 1 FROM "UserCoupon" WHERE "sourceKey"=$1 AND COALESCE("isDeleted",FALSE)=FALSE) RETURNING "objectId"),upd AS (UPDATE "CouponDefinition" SET "claimedQuantity"=COALESCE("claimedQuantity",0)+1,"updatedAt"=NOW() WHERE "objectId"=(SELECT "objectId" FROM d) AND EXISTS(SELECT 1 FROM ins) RETURNING "objectId") SELECT (SELECT "objectId" FROM ins) AS "objectId"',[sourceKey,['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],couponId,objectId,'Company$'+DEFAULT_COMPANY_ID,'_User$'+owner.id,uid]);if(!result.objectId)fail(409,'优惠券已领完或不在领取时间');await simpleAppAudit(current,'coupon-claim','UserCoupon',result.objectId,{couponId});const fresh=await Psql.query('SELECT u.*,d.*,u."objectId" AS "userCouponObjectId",u."status" AS "userStatus",u."expiresAt" AS "userExpiresAt",u."createdAt" AS "userCreatedAt" FROM "UserCoupon" u JOIN "CouponDefinition" d ON d."couponId"=u."couponId" AND CAST(d."company" AS text)=ANY($1::text[]) WHERE u."objectId"=$2 LIMIT 1',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],String(result.objectId)]);return userCouponJson(fresh[0]);}
  2913. async function userCouponList(input,current){const uid=await authorizeRequestedUser(current,input.uid),paging=pageInput(input),fast=String(input.fast||'idle'),conditions=['CAST(u."company" AS text)=ANY($1::text[])','u."legacyUserId"=$2','COALESCE(u."isDeleted",FALSE)=FALSE'],params=[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],uid];if(fast==='used')conditions.push('u."status"=\'used\'');else if(fast==='expired')conditions.push('u."status"<>\'used\' AND COALESCE(u."expiresAt",d."expiresAt")<NOW()');else conditions.push('u."status"=\'idle\' AND (COALESCE(u."expiresAt",d."expiresAt") IS NULL OR COALESCE(u."expiresAt",d."expiresAt")>=NOW())');const where=conditions.join(' AND '),count=await Psql.one('SELECT COUNT(*)::int AS total FROM "UserCoupon" u JOIN "CouponDefinition" d ON d."couponId"=u."couponId" AND CAST(d."company" AS text)=ANY($1::text[]) WHERE '+where,params),rows=await Psql.query('SELECT u.*,d.*,u."objectId" AS "userCouponObjectId",u."status" AS "userStatus",u."expiresAt" AS "userExpiresAt",u."createdAt" AS "userCreatedAt" FROM "UserCoupon" u JOIN "CouponDefinition" d ON d."couponId"=u."couponId" AND CAST(d."company" AS text)=ANY($1::text[]) WHERE '+where+' ORDER BY u."createdAt" DESC LIMIT $3 OFFSET $4',[...params,paging.size,(paging.index-1)*paging.size]),total=number(count.total);return{rows:rows.map(userCouponJson),page:{itemCount:total,pageCount:Math.ceil(total/paging.size),pageIndex:paging.index,pageSize:paging.size}};}
  2914. async function checkoutPayment(input,current){const uid=await authorizeRequestedUser(current,input.uid);if(number(input.use_sicon)!==1)fail(501,'外部支付通道尚未配置,当前仅支持学习豆余额支付');const cartIds=String(input.cartIds||'').split(',').map((value)=>value.trim()).filter(Boolean).slice(0,200);if(!cartIds.length)fail(400,'缺少购物车记录');const unique=[...new Set(cartIds)].sort(),sourceKey='app:checkout:'+uid+':'+unique.join('-'),existing=await Psql.query('SELECT "orderId" FROM "CommerceOrder" WHERE "sourceKey"=$1 LIMIT 1',[sourceKey]);if(existing[0])return{orderIds:number(existing[0].orderId),cartIds:unique.join(',')};const receiver=jsonObject(input.rece,'收货信息'),invoice=jsonObject(input.invoice,'发票信息'),fare=parseArray(input.fareDT),shipping=fare.reduce((sum,item)=>sum+number(item&&item.total),0);let couponObjectId='',discount=0,couponId='';if(input.couponid){const coupons=await Psql.query('SELECT u."objectId",u."couponId",u."status",COALESCE(u."expiresAt",d."expiresAt") AS expires,d."amount",d."minAmount",d."maxAmount" FROM "UserCoupon" u JOIN "CouponDefinition" d ON d."couponId"=u."couponId" AND CAST(d."company" AS text)=ANY($1::text[]) WHERE CAST(u."company" AS text)=ANY($1::text[]) AND u."legacyUserId"=$2 AND (CAST(u."objectId" AS text)=$3 OR CAST(u."couponId" AS text)=$3) AND COALESCE(u."isDeleted",FALSE)=FALSE LIMIT 1',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],uid,String(input.couponid)]);if(!coupons[0]||String(coupons[0].status)!=='idle'||(coupons[0].expires&&new Date(coupons[0].expires).getTime()<Date.now()))fail(409,'优惠券不可用');couponObjectId=String(coupons[0].objectId);couponId=String(coupons[0].couponId);discount=number(coupons[0].amount);const cartValue=await Psql.one('SELECT COALESCE(SUM(p."retailPrice"*c."quantity"),0)::numeric AS subtotal FROM "ShoppingCartItem" c JOIN "CommerceProduct" p ON p."productId"=c."productId" AND CAST(p."company" AS text)=ANY($1::text[]) WHERE CAST(c."company" AS text)=ANY($1::text[]) AND c."legacyUserId"=$2 AND CAST(c."objectId" AS text)=ANY($3::text[]) AND COALESCE(c."isDeleted",FALSE)=FALSE',[['Company'+'$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],uid,unique]);const couponSubtotal=number(cartValue.subtotal),couponMin=number(coupons[0].minAmount),couponMax=number(coupons[0].maxAmount);if(couponSubtotal<couponMin||(couponMax>0&&couponSubtotal>couponMax))fail(409,'订单金额不满足优惠券使用条件');}
  2915. const orderObjectId=randomObjectId(),paymentObjectId=randomObjectId(),orderNo='XS'+new Date().toISOString().replace(/\D/g,'').slice(0,14)+Math.floor(Math.random()*10000).toString().padStart(4,'0'),result=await Psql.one('WITH lock_id AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext($1))),selected AS MATERIALIZED (SELECT c."objectId" AS cart_id,c."quantity",p.* FROM "ShoppingCartItem" c JOIN "CommerceProduct" p ON p."productId"=c."productId" AND CAST(p."company" AS text)=ANY($2::text[]) WHERE CAST(c."company" AS text)=ANY($2::text[]) AND c."legacyUserId"=$3 AND CAST(c."objectId" AS text)=ANY($4::text[]) AND COALESCE(c."isDeleted",FALSE)=FALSE FOR UPDATE OF c,p),metrics AS MATERIALIZED (SELECT COUNT(*)::int AS found,COALESCE(SUM("retailPrice"*"quantity"),0)::numeric AS subtotal,BOOL_AND("status"=\'published\' AND COALESCE("isDeleted",FALSE)=FALSE AND COALESCE("stock",0)>="quantity") AS valid_stock FROM selected),locked_user AS MATERIALIZED (SELECT "objectId",CASE WHEN COALESCE("legacyUserData"->>\'SilverCoin\',\'0\') ~ \'^-?[0-9]+(?:\\.[0-9]+)?$\' THEN ("legacyUserData"->>\'SilverCoin\')::numeric ELSE 0 END AS balance FROM "_User",lock_id WHERE "objectId"=$5 FOR UPDATE),coupon_use AS (UPDATE "UserCoupon" SET "status"=\'used\',"usedAt"=NOW(),"updatedAt"=NOW() WHERE $6<>\'\' AND "objectId"=$6 AND "status"=\'idle\' AND EXISTS(SELECT 1 FROM metrics m CROSS JOIN locked_user u WHERE m.found=$9 AND COALESCE(m.valid_stock,FALSE)=TRUE AND u.balance>=GREATEST(0,m.subtotal-$7)+$8) RETURNING "objectId"),valid AS MATERIALIZED (SELECT m.*,GREATEST(0,m.subtotal-$7)+$8 AS total,u.balance FROM metrics m CROSS JOIN locked_user u WHERE m.found=$9 AND COALESCE(m.valid_stock,FALSE)=TRUE AND u.balance>=GREATEST(0,m.subtotal-$7)+$8 AND ($6=\'\' OR EXISTS(SELECT 1 FROM coupon_use))),next_id AS MATERIALIZED (SELECT COALESCE(MAX("orderId"),0)+1 AS id FROM "CommerceOrder",lock_id),balance_update AS (UPDATE "_User" u SET "legacyUserData"=jsonb_set(COALESCE(u."legacyUserData",\'{}\'::jsonb),\'{SilverCoin}\',to_jsonb(v.balance-v.total),TRUE),"updatedAt"=NOW() FROM valid v WHERE u."objectId"=$5 RETURNING v.*),order_insert AS (INSERT INTO "CommerceOrder" ("objectId","createdAt","updatedAt","company","user","buyerLegacyId","orderId","orderNo","status","subtotal","discount","shipping","total","receiver","invoice","couponId","paymentMode","paidAt","isDeleted","sourceKey") SELECT $10,NOW(),NOW(),$11,$12,$3,n.id,$13,\'paid\',b.subtotal,$7,$8,b.total,$14::jsonb,$15::jsonb,$16,\'sicon\',NOW(),FALSE,$1 FROM balance_update b CROSS JOIN next_id n RETURNING *),item_insert AS (INSERT INTO "CommerceOrderItem" ("objectId","createdAt","updatedAt","company","order","orderId","product","productId","seller","sellerLegacyId","name","unitPrice","quantity","amount","thumbnail","productSnapshot","sourceKey") SELECT substr(md5(random()::text||clock_timestamp()::text||s."productId"::text),1,20),NOW(),NOW(),$11,\'CommerceOrder$\'||o."objectId",o."orderId",\'CommerceProduct$\'||s."objectId",s."productId",s."owner",s."legacyOwnerId",s."name",s."retailPrice",s."quantity",s."retailPrice"*s."quantity",s."thumbnail",jsonb_build_object(\'Nodeid\',s."nodeId",\'Thumbnails\',s."thumbnail"),$1||\':item:\'||s."productId" FROM selected s CROSS JOIN order_insert o RETURNING "objectId"),stock_update AS (UPDATE "CommerceProduct" p SET "stock"=p."stock"-q.qty,"sales"=COALESCE(p."sales",0)+q.qty,"updatedAt"=NOW() FROM (SELECT "productId",SUM("quantity")::numeric AS qty FROM selected GROUP BY "productId") q,order_insert o WHERE p."productId"=q."productId" AND CAST(p."company" AS text)=ANY($2::text[]) RETURNING p."objectId",p."productId",q.qty,p."stock"+q.qty AS before,p."stock" AS after),stock_log AS (INSERT INTO "ProductStockLedger" ("objectId","createdAt","updatedAt","company","user","legacyUserId","product","productId","delta","stockBefore","stockAfter","remark","sourceKey") SELECT substr(md5(random()::text||clock_timestamp()::text||s."productId"::text),1,20),NOW(),NOW(),$11,$12,$3,\'CommerceProduct$\'||s."objectId",s."productId",-s.qty,s.before,s.after,\'订单出库 \'||$13,$1||\':stock:\'||s."productId" FROM stock_update s RETURNING "objectId"),cart_update AS (UPDATE "ShoppingCartItem" SET "isDeleted"=TRUE,"updatedAt"=NOW() WHERE CAST("objectId" AS text)=ANY($4::text[]) AND "legacyUserId"=$3 AND EXISTS(SELECT 1 FROM order_insert) RETURNING "objectId"),payment_insert AS (INSERT INTO "CommercePayment" ("objectId","createdAt","updatedAt","company","user","legacyUserId","order","orderId","amount","mode","status","transactionNo","paidAt","sourceKey") SELECT $17,NOW(),NOW(),$11,$12,$3,\'CommerceOrder$\'||o."objectId",o."orderId",o."total",\'sicon\',\'paid\',$18,NOW(),$1||\':payment\' FROM order_insert o RETURNING "objectId","orderId"),audit AS (SELECT (SELECT COUNT(*) FROM item_insert) items,(SELECT COUNT(*) FROM stock_log) logs,(SELECT COUNT(*) FROM cart_update) carts) SELECT p."orderId",(SELECT subtotal FROM valid) AS subtotal,(SELECT total FROM valid) AS total,(SELECT balance FROM valid) AS before_balance,(SELECT balance-total FROM valid) AS after_balance,(SELECT items FROM audit) AS items,(SELECT carts FROM audit) AS carts FROM payment_insert p',[sourceKey,['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],uid,unique,current.id,couponObjectId,discount,shipping,unique.length,orderObjectId,'Company$'+DEFAULT_COMPANY_ID,'_User$'+current.id,orderNo,JSON.stringify(receiver),JSON.stringify(invoice),couponId,paymentObjectId,'SICON-'+orderNo]);if(!result.orderId)fail(409,'订单结算失败:购物车已变化、库存不足、优惠券失效或学习豆余额不足');await simpleAppAudit(current,'order-paid','CommerceOrder',orderObjectId,{orderId:number(result.orderId),subtotal:number(result.subtotal),total:number(result.total),balanceBefore:number(result.before_balance),balanceAfter:number(result.after_balance),items:number(result.items)});return{orderIds:number(result.orderId),cartIds:unique.join(',')};}
  2916. async function paymentAgain(input,current){const uid=await authorizeRequestedUser(current,input.uid),mode=number(input.mode);if(mode!==1)fail(501,'微信或支付宝支付通道尚未配置');const row=await commerceOrderRow(input.orderId||input.id);if(!row)fail(404,'订单不存在');if(number(row.buyerLegacyId)!==uid&&!isAdminUser(current))fail(403,'无权支付该订单');if(String(row.status)!=='pending')return{orderIds:number(row.orderId)};fail(409,'该未支付订单不能脱离购物车重新扣款,请重新提交结算');}
  2917. async function paymentSuccess(input,current){const uid=await authorizeRequestedUser(current,input.uid),ids=String(input.orderIds||input.orderId||input.id||'').split(',').map(number).filter(Boolean);if(!ids.length)fail(400,'缺少订单 ID');const rows=await Psql.query('SELECT "orderId","buyerLegacyId","status" FROM "CommerceOrder" WHERE CAST("company" AS text)=ANY($1::text[]) AND "orderId"=ANY($2::numeric[])',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],ids]);if(rows.length!==ids.length)fail(404,'订单不存在');for(const row of rows){if(number(row.buyerLegacyId)!==uid&&!isAdminUser(current))fail(403,'无权确认该订单支付');if(String(row.status)==='pending')fail(409,'订单尚未完成真实支付');}return 1;}
  2918. async function orderCommentAdd(input,current){const uid=await authorizeRequestedUser(current,input.uid||input.userId),order=await commerceOrderRow(input.orderId||input.id);if(!order)fail(404,'订单不存在');if(number(order.buyerLegacyId)!==uid&&!isAdminUser(current))fail(403,'无权评价该订单');if(String(order.status)!=='completed')fail(409,'确认收货后才能评价');const productId=number(input.proId||input.productId),items=await Psql.query('SELECT * FROM "CommerceOrderItem" WHERE CAST("company" AS text)=ANY($1::text[]) AND "orderId"=$2 AND "productId"=$3 LIMIT 1',[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],number(order.orderId),productId]);if(!items[0])fail(404,'订单中没有该商品');const sourceKey='app:product-comment:'+number(order.orderId)+':'+productId+':'+uid,existing=await Psql.query('SELECT "objectId" FROM "ProductComment" WHERE "sourceKey"=$1 AND COALESCE("isDeleted",FALSE)=FALSE LIMIT 1',[sourceKey]);if(existing[0])fail(409,'该商品已经评价');const score=Math.max(1,Math.min(5,number(input.score,5))),images=normalizeProductGallery(input.images),row=new Parse.Object('ProductComment');row.set('legacyUserId',uid);row.set('productId',productId);row.set('orderId',String(order.orderId));row.set('title',cleanText(input.title,200));row.set('score',score);row.set('content',cleanText(input.content,5000));row.set('images',images);row.set('anonymous',input.anonymous===true||number(input.anonymous)===1);row.set('status','published');row.set('isDeleted',false);row.set('sourceKey',sourceKey);await row.save(null,{useMasterKey:true});await Psql.none('UPDATE "ProductComment" SET "company"=$1,"user"=$2,"product"=$3,"updatedAt"=NOW() WHERE "objectId"=$4',['Company$'+DEFAULT_COMPANY_ID,'_User$'+current.id,'CommerceProduct$'+items[0].product.split('$').pop(),row.id]);await simpleAppAudit(current,'order-comment-add','ProductComment',row.id,{orderId:number(order.orderId),productId,score});return 1;}
  2919. async function orderCommentList(input,current){const paging=pageInput(input),params=[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID]],conditions=['CAST(c."company" AS text)=ANY($1::text[])','COALESCE(c."isDeleted",FALSE)=FALSE','c."status"=\'published\''];if(input.proId||input.productId){params.push(number(input.proId||input.productId));conditions.push('c."productId"=$'+params.length);}if(input.orderId){if(!current)fail(401,'需要登录');const row=await commerceOrderRow(input.orderId);if(!row)fail(404,'订单不存在');await orderAccess(row,current,'read');params.push(String(row.orderId));conditions.push('c."orderId"=$'+params.length);}const rate=String(input.rate||'all');if(rate==='good')conditions.push('c."score">=4');else if(rate==='bad')conditions.push('c."score"<=2');if(number(input.hasImgOnly)===1)conditions.push("COALESCE(CAST(c.images AS text),'[]') NOT IN ('[]','null','')");const where=conditions.join(' AND '),count=await Psql.one('SELECT COUNT(*)::int AS total FROM "ProductComment" c WHERE '+where,params),rows=await Psql.query('SELECT c.*,u."username",u."nickname",u."realName",u."avatar" FROM "ProductComment" c LEFT JOIN "_User" u ON CAST(u."legacyUserId" AS text)=CAST(c."legacyUserId" AS text) AND CAST(u."company" AS text)=ANY($1::text[]) WHERE '+where+' ORDER BY c."createdAt" DESC LIMIT $'+(params.length+1)+' OFFSET $'+(params.length+2),[...params,paging.size,(paging.index-1)*paging.size]),total=number(count.total),items=rows.map((row)=>({objectId:String(row.objectId),ID:String(row.objectId),OrderID:number(row.orderId),ProID:number(row.productId),Title:String(row.title||''),Content:String(row.content||''),Score:number(row.score),Images:Array.isArray(row.images)?row.images:[],UserName:row.anonymous?'匿名用户':String(firstValue(row.nickname,row.realName,row.username,'会员')),UserFace:row.anonymous?'':String(row.avatar||''),CreateTime:row.createdAt}));return{rows:items,page:{itemCount:total,pageCount:Math.ceil(total/paging.size),pageIndex:paging.index,pageSize:paging.size}};}
  2920. async function userShopSales(input,current){const uid=await authorizeRequestedUser(current,input.uid),fast=String(input.fast||'all'),params=[['Company$'+DEFAULT_COMPANY_ID,DEFAULT_COMPANY_ID],uid],time=fast==='week'?' AND o."paidAt">=date_trunc(\'week\',NOW())':fast==='month'?' AND o."paidAt">=date_trunc(\'month\',NOW())':'',rows=await Psql.query('SELECT p.*,SUM(i."quantity")::numeric AS sold,SUM(i."amount")::numeric AS revenue FROM "CommerceOrderItem" i JOIN "CommerceOrder" o ON o."orderId"=i."orderId" AND CAST(o."company" AS text)=ANY($1::text[]) JOIN "CommerceProduct" p ON p."productId"=i."productId" AND CAST(p."company" AS text)=ANY($1::text[]) WHERE CAST(i."company" AS text)=ANY($1::text[]) AND i."sellerLegacyId"=$2 AND o."status"<>\'pending\' AND COALESCE(o."isDeleted",FALSE)=FALSE'+time+' GROUP BY p."objectId" ORDER BY revenue DESC LIMIT 5',params),sum=await Psql.one('SELECT COALESCE(SUM(i."amount"),0)::numeric AS total FROM "CommerceOrderItem" i JOIN "CommerceOrder" o ON o."orderId"=i."orderId" AND CAST(o."company" AS text)=ANY($1::text[]) WHERE CAST(i."company" AS text)=ANY($1::text[]) AND i."sellerLegacyId"=$2 AND o."status"<>\'pending\' AND COALESCE(o."isDeleted",FALSE)=FALSE'+time,params);return{rows:rows.map((row)=>({...commerceProductJson(row),Sales:number(row.sold),Revenue:number(row.revenue)})),addon:number(sum.total),addon2:number(sum.total)};}
  2921. async function membershipStockChange(input,current,type){const operatorId=await authorizeRequestedUser(current,input.uid),targetId=number(input.upusr);if(!targetId||targetId===operatorId)fail(400,'操作对象无效');const target=await legacyUserObject(targetId);if(!target)fail(404,'团队成员不存在');const targetData=legacyData(target);if(number(targetData.ParentUserID)!==operatorId&&!isAdminUser(current))fail(403,'只能操作直属团队成员');const groupAfter=type==='upgrade'?number(input.gid):number(firstValue(target.get('legacyGroupId'),targetData.GroupID)),quantity=type==='upgrade'?(groupAfter===3?3000:groupAfter===2?1000:0):number(input.munber||input.number);if(!Number.isInteger(quantity)||quantity<=0)fail(400,type==='upgrade'?'代理等级无效':'批发数量必须为正整数');const objectId=randomObjectId(),idempotency=cleanText(input.idempotencyKey,120)||Date.now()+':'+Math.random().toString(36).slice(2),sourceKey='app:membership-stock:'+operatorId+':'+targetId+':'+type+':'+idempotency,prior=await Psql.query('SELECT "objectId" FROM "MembershipStockLedger" WHERE "sourceKey"=$1 LIMIT 1',[sourceKey]);if(prior[0])return 1;const result=await Psql.one('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext($1))),u AS MATERIALIZED (SELECT "objectId",COALESCE("legacyGroupId",CASE WHEN COALESCE("legacyUserData"->>\'GroupID\',\'0\') ~ \'^[0-9]+$\' THEN ("legacyUserData"->>\'GroupID\')::numeric ELSE 0 END) AS group_id,CASE WHEN COALESCE("legacyUserData"->>\'boffExp\',\'0\') ~ \'^-?[0-9]+(?:\\.[0-9]+)?$\' THEN ("legacyUserData"->>\'boffExp\')::numeric ELSE 0 END AS stock FROM "_User",lock_row WHERE "objectId"=$2 FOR UPDATE),t AS MATERIALIZED (SELECT "objectId",COALESCE("legacyGroupId",CASE WHEN COALESCE("legacyUserData"->>\'GroupID\',\'0\') ~ \'^[0-9]+$\' THEN ("legacyUserData"->>\'GroupID\')::numeric ELSE 0 END) AS group_id,CASE WHEN COALESCE("legacyUserData"->>\'boffExp\',\'0\') ~ \'^-?[0-9]+(?:\\.[0-9]+)?$\' THEN ("legacyUserData"->>\'boffExp\')::numeric ELSE 0 END AS stock,COALESCE("legacyUserData"->>\'ParentUserID\',\'0\') AS parent_id FROM "_User",lock_row WHERE "objectId"=$3 FOR UPDATE),valid AS MATERIALIZED (SELECT u.group_id AS operator_group,u.stock AS operator_before,t.group_id AS target_group,t.stock AS target_before FROM u,t WHERE u.group_id>=3 AND u.stock>=$4 AND ($5=\'upgrade\' AND t.group_id=1 AND $6 IN (2,3) AND ($6<>3 OR u.group_id>=4) OR $5=\'supply\' AND t.group_id<>1 AND u.group_id>t.group_id) AND ($7=TRUE OR t.parent_id=$8)),operator_update AS (UPDATE "_User" x SET "legacyUserData"=jsonb_set(COALESCE(x."legacyUserData",\'{}\'::jsonb),\'{boffExp}\',to_jsonb(v.operator_before-$4),TRUE),"updatedAt"=NOW() FROM valid v WHERE x."objectId"=$2 RETURNING v.*),target_update AS (UPDATE "_User" x SET "legacyGroupId"=CASE WHEN $5=\'upgrade\' THEN $6 ELSE x."legacyGroupId" END,"legacyUserData"=jsonb_set(jsonb_set(COALESCE(x."legacyUserData",\'{}\'::jsonb),\'{boffExp}\',to_jsonb(v.target_before+$4),TRUE),\'{GroupID}\',to_jsonb(CASE WHEN $5=\'upgrade\' THEN $6 ELSE v.target_group END),TRUE),"updatedAt"=NOW() FROM valid v WHERE x."objectId"=$3 AND EXISTS(SELECT 1 FROM operator_update) RETURNING v.*),ledger AS (INSERT INTO "MembershipStockLedger" ("objectId","createdAt","updatedAt","company","operator","operatorLegacyId","target","targetLegacyId","type","groupBefore","groupAfter","quantity","operatorStockBefore","operatorStockAfter","targetStockBefore","targetStockAfter","remark","sourceKey") SELECT $9,NOW(),NOW(),$10,$11,$12,$13,$14,$5,v.target_group,CASE WHEN $5=\'upgrade\' THEN $6 ELSE v.target_group END,$4,v.operator_before,v.operator_before-$4,v.target_before,v.target_before+$4,$15,$1 FROM target_update v WHERE NOT EXISTS(SELECT 1 FROM "MembershipStockLedger" WHERE "sourceKey"=$1) RETURNING *) SELECT * FROM ledger',[sourceKey,current.id,target.id,quantity,type,groupAfter,isAdminUser(current),String(operatorId),objectId,'Company$'+DEFAULT_COMPANY_ID,'_User$'+current.id,operatorId,'_User$'+target.id,targetId,cleanText(input.remark,500)]);if(!result.objectId){const duplicate=await Psql.query('SELECT * FROM "MembershipStockLedger" WHERE "sourceKey"=$1 LIMIT 1',[sourceKey]);if(duplicate[0])return 1;fail(409,'库存不足、代理等级不符或成员关系已变化');}await simpleAppAudit(current,type==='upgrade'?'member-agent-upgrade':'member-stock-supply','MembershipStockLedger',result.objectId,{targetId,quantity,groupBefore:number(result.groupBefore),groupAfter:number(result.groupAfter)});return 1;}
  2922. async function handler(request, response) {
  2923. try {
  2924. const input = inputOf(request); const action = String(input.action || ''); if (!action) return response.status(400).json(reject('缺少 action'));
  2925. if (action === 'migration_status') return response.json(envelope({ capabilities:{teaching:true,commerce:false,sms:false,identityVerification:false}, version:'2026-09-mobile-teaching-v1', implemented: ['app_live_versions','app_credit_overview','app_review_page','app_lesson_report','app_update','app_account_overview','app_companion_overview','app_learning_overview','upload','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_get_21list_tj','e_order_detail','e_order_tongji','e_order_update_v2','e_order_complete_feedback','e_record_detail','e_words_list','stu_record_update_v2','user_point_list','guestbook_add','pub_add','pub_list','product_list','product_get','product_add','product_upd','product_del','product_sale_change','product_stock_change','product_stock_list','cart_add','cart_list','coupon_list','coupon_usrgot_add','coupon_usrgot_list','order_comment_add','order_comment_list','order_delivery','order_get','order_list','order_signfor','payment_cart','payment_cart_again','payment_success','user_group_usr_supply','user_group_usr_upgrade','user_shop_order','user_shop_sales','receaddr_add','receaddr_del','receaddr_get','receaddr_list','receaddr_upd','invoice_add','invoice_del','invoice_get','invoice_list','invoice_upd','user_bank_add','user_bank_del','user_bank_get','user_bank_list','vote_add','vote_ask','vote_question','unit_record_update','user_star_add','user_star_del','user_star_is','reading/ai','reading/me','reading/user/update','reading/query','reading/get','reading/create','reading/update'], partial: PARTIAL, disabledActions:[...MOBILE_DISABLED_ACTIONS], blocked:{...BLOCKED,ranklist_1:'排行榜暂未开放',ranklist_2:'排行榜暂未开放',ranklist_3:'排行榜暂未开放',ranklist_4:'排行榜暂未开放',ranklist_5:'排行榜暂未开放'} }));
  2926. if (MOBILE_DISABLED_ACTIONS.has(action)) return response.status(503).json(reject('该功能暂未开放;课时仅用于教学,不支持商城支付或转赠'));
  2927. if (BLOCKED[action]) return response.status(501).json(reject('migration_blocked: ' + BLOCKED[action]));
  2928. if (action === 'user_login_passwd') {
  2929. const username = String(input.name || input.username || '').trim(); const password = String(input.passwd || input.password || ''); if (!username || !password) return response.status(400).json(reject('请输入账号和密码'));
  2930. let loginName=username;const knownTarget=await migratedLoginTarget(username);if(knownTarget&&knownTarget.get('username'))loginName=String(knownTarget.get('username'));let loggedIn;try { loggedIn=await Parse.User.logIn(loginName,password); } catch (_) { loggedIn=await migrateLegacyLogin(username,password); }
  2931. if (loggedIn.get('isDisabled') === true || loggedIn.get('isDeleted') === true) { await revokeUserSessions(loggedIn).catch(() => undefined); return response.status(403).json(reject('账号已停用')); } return response.json(envelope(legacyUser(loggedIn, true), { State: 1 }));
  2932. }
  2933. if (action === 'user_register') { const registered = await registerUser(input); return response.json(envelope(registered.result,registered.addon)); }
  2934. 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('用户不存在')); }
  2935. 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);
  2936. if (action === 'app_credit_overview') return response.json(envelope(await appCreditOverview(input,current)));
  2937. if (action === 'app_review_page') return response.json(envelope(await appReviewPage(input,current)));
  2938. if (action === 'app_lesson_report') return response.json(envelope(await appLessonReport(input,current)));
  2939. if (action === 'upload') return response.json(envelope(await uploadAppMedia(input)));
  2940. if (action === 'vote_ask') return response.json(envelope(await voteAsk(input)));
  2941. if (action === 'vote_question') return response.json(envelope(await voteQuestions(input)));
  2942. if (action === 'vote_add') return response.json(envelope(await voteAnswer(input,current)));
  2943. if (action === 'unit_record_update') return response.json(envelope(await unitRecordUpdate(input,current)));
  2944. if (action === 'user_star_is') return response.json(envelope(await favoriteState(input,current)));
  2945. if (action === 'user_star_add') return response.json(envelope(await favoriteAdd(input,current)));
  2946. if (action === 'user_star_del') return response.json(envelope(await favoriteDelete(input,current)));
  2947. if (action === 'pub_add') return response.json(envelope(await serviceRequestAdd(input,current)));
  2948. if (action === 'pub_list') { const result=await serviceRequestList(input,current);return response.json(envelope(result.rows,undefined,result.page)); }
  2949. if (action === 'product_list') { const result=await productList(input,current);return response.json(envelope(result.rows,undefined,result.page)); }
  2950. if (action === 'product_get') return response.json(envelope(await productGet(input,current)));
  2951. if (action === 'product_add' || action === 'product_upd') return response.json(envelope(await productSave(input,current)));
  2952. if (action === 'product_del') return response.json(envelope(await productDelete(input,current)));
  2953. if (action === 'product_sale_change') return response.json(envelope(await productSaleChange(input,current)));
  2954. if (action === 'product_stock_change') return response.json(envelope(await productStockChange(input,current)));
  2955. if (action === 'product_stock_list') { const result=await productStockList(input,current);return response.json(envelope(result.rows,undefined,result.page)); }
  2956. if (action === 'cart_add') return response.json(envelope(await cartAdd(input,current)));
  2957. if (action === 'cart_list') return response.json(envelope(await cartList(input,current)));
  2958. if (action === 'coupon_list') { const result=await couponList(input,current);return response.json(envelope(result.rows,undefined,result.page)); }
  2959. if (action === 'coupon_usrgot_add') return response.json(envelope(await couponClaim(input,current)));
  2960. if (action === 'coupon_usrgot_list') { const result=await userCouponList(input,current);return response.json(envelope(result.rows,undefined,result.page)); }
  2961. if (action === 'payment_cart') return response.json(envelope(1,await checkoutPayment(input,current)));
  2962. if (action === 'payment_cart_again') return response.json(envelope(await paymentAgain(input,current)));
  2963. if (action === 'payment_success') return response.json(envelope(await paymentSuccess(input,current)));
  2964. if (action === 'order_get') { const result=await orderGet(input,current);return response.json(envelope(result.result,result.addon)); }
  2965. if (action === 'order_list') { const result=await orderList(input,current,false);return response.json(envelope(result.rows,undefined,result.page)); }
  2966. if (action === 'user_shop_order') { const result=await orderList(input,current,true);return response.json(envelope(result.rows,undefined,result.page)); }
  2967. if (action === 'order_delivery') return response.json(envelope(await orderDelivery(input,current)));
  2968. if (action === 'order_signfor') return response.json(envelope(await orderSignFor(input,current)));
  2969. if (action === 'order_comment_add') return response.json(envelope(await orderCommentAdd(input,current)));
  2970. if (action === 'order_comment_list') { const result=await orderCommentList(input,current);return response.json(envelope(result.rows,undefined,result.page)); }
  2971. if (action === 'user_shop_sales') { const result=await userShopSales(input,current);const value=envelope(result.rows,result.addon);value.addon2=result.addon2;return response.json(value); }
  2972. if (action === 'user_group_usr_upgrade') return response.json(envelope(await membershipStockChange(input,current,'upgrade')));
  2973. if (action === 'user_group_usr_supply') return response.json(envelope(await membershipStockChange(input,current,'supply')));
  2974. if (action.startsWith('reading/')) {
  2975. const learnerId=await authorizeRequestedUser(current,input.studentId||ownLegacyId(current));
  2976. const learner=learnerId===ownLegacyId(current)?current:await legacyUserObject(learnerId);
  2977. if(!learner)fail(404,'阅读学员不存在');
  2978. if(action==='reading/me')return response.json(envelope({objectId:learner.id,username:String(learner.get('username')||''),nickname:String(learner.get('nickname')||learner.get('realName')||''),level:String(learner.get('level')||'')}));
  2979. if(action==='reading/ai')return response.json(envelope(await readingAi(input,current)));
  2980. if(action==='reading/user/update')return response.json(envelope(await readingUpdateUser(input,learner)));
  2981. if(action==='reading/query')return response.json(envelope(await readingQuery(input,learner)));
  2982. if(action==='reading/get')return response.json(envelope(readingRecord(await readingOwned(readingClass(input.className),input.objectId,learner))));
  2983. if(action==='reading/create')return response.json(envelope(await readingCreate(input,learner,current)));
  2984. if(action==='reading/update')return response.json(envelope(await readingUpdate(input,learner)));
  2985. }
  2986. const resourceAction={receaddr_list:['address','list'],receaddr_get:['address','get'],receaddr_add:['address','save'],receaddr_upd:['address','save'],receaddr_del:['address','delete'],invoice_list:['invoice','list'],invoice_get:['invoice','get'],invoice_add:['invoice','save'],invoice_upd:['invoice','save'],invoice_del:['invoice','delete'],user_bank_list:['bank','list'],user_bank_get:['bank','get'],user_bank_add:['bank','save'],user_bank_del:['bank','delete']}[action];
  2987. if(resourceAction){const kind=resourceAction[0],operation=resourceAction[1];if(operation==='list'){const result=await memberResourceList(kind,input,current);return response.json(envelope(result.rows,undefined,result.page));}if(operation==='get')return response.json(envelope(await memberResourceGet(kind,input,current)));if(operation==='save')return response.json(envelope(await memberResourceSave(kind,input,current,request)));return response.json(envelope(await memberResourceDelete(kind,input,current,request)));}
  2988. 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))); }
  2989. if (action === 'user_list') { const result = await teamUserPage(input,current); return response.json(envelope(result.rows,undefined,result.page)); }
  2990. if (action === 'user_dept') return response.json(envelope(await teamStats(input,current)));
  2991. if (action === 'e_user_list') { const result = await coachStudentPage(input,current); return response.json(envelope(result.rows,undefined,result.page)); }
  2992. if (action === 'user_update') { const updated = await updateUserProfile(input,current); return response.json(envelope(updated.result,updated.addon)); }
  2993. if (action === 'app_account_overview') return response.json(envelope(await appAccountOverview(input,current)));
  2994. if (action === 'app_companion_overview') return response.json(envelope(await appCompanionOverview(input,current)));
  2995. if (action === 'app_live_versions') return response.json(envelope(await appLiveVersions(input,current)));
  2996. if (action === 'app_learning_overview') return response.json(envelope(await appLearningOverview(input,current)));
  2997. if (action === 'app_update') { const result = await sqlPage('App', { page: 1, pageSize: 1 }); return response.json(envelope(legacyAppRow(result.rows[0]))); }
  2998. if (action === 'content_list' || action === 'content_list_llk') { const result = await contentPage({ ...input, __publishedOnly:true }, !current); return response.json(envelope(result.rows, undefined, result.page)); }
  2999. 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, __publishedOnly:true, ...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, __publishedOnly:true, ...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])); }
  3000. 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) })); }
  3001. if (action === 'content_add') return response.json(envelope(await createContentPair(input, current)));
  3002. if (action === 'content_update') return response.json(envelope(await updateContentPair(input, current)));
  3003. if (action === 'node_list') { const result = privateNodeProgress ? await nodeUnitPage(input,current) : await nodePage(input); return response.json(envelope(result.rows, undefined, result.page)); }
  3004. 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('栏目不存在')); }
  3005. const legacyId = ownLegacyId(current);
  3006. if (action === 'content_add_zt') return response.json(envelope(await createStudyContent(input, current)));
  3007. if (action === 'e_order_update_v2') return response.json(envelope(await updateOrderStatus(input,current)));
  3008. if (action === 'e_order_complete_feedback') return response.json(envelope(await completeOrderWithFeedback(input,current)));
  3009. if (action === 'e_order_tongji') return response.json(envelope(await orderStatistics(input, current)));
  3010. if (action === 'stu_record_update_v2') return response.json(envelope(await upsertStudyRecord(input, current)));
  3011. if (action === 'e_add_words') return response.json(envelope(await updateLearningWords(input, current)));
  3012. if (action === 'e_ck_list') { const result = await courseWordPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
  3013. if (action === 'e_get_21list') { const result = await memoryPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
  3014. if (action === 'e_get_21list_tj') return response.json(envelope(await memoryCompletedCount(input,current)));
  3015. if (action === 'e_words_list') { const result = await newWordPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
  3016. if (action === 'e_order_detail') { const found = await orderDetailRow(input.id, current); if(found) found.feedback=await Psql.oneOrNone('SELECT \"contentSummary\",\"reviewSummary\",\"trainingSummary\",\"homework\",\"noHomework\",\"notes\" FROM \"LessonCompletionReport\" WHERE \"company\"=$1 AND \"appointmentGeneralId\"::text=$2 LIMIT 1',[DEFAULT_COMPANY_ID,String(found.GeneralID||input.id)]); return found ? response.json(envelope([found])) : response.status(404).json(reject('预约记录不存在')); }
  3017. 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('学习记录不存在')); }
  3018. if (action === 'user_point_list') { const result = await pointHistoryPage(input,current); return response.json(envelope(result.rows,result.addon,result.page)); }
  3019. if (action === 'guestbook_add') return response.json(envelope(await createGuestbook(input,current)));
  3020. return response.status(501).json(reject('migration_blocked: 尚未完成动作映射 ' + action));
  3021. } 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)); }
  3022. }
  3023. `;
  3024. const operationsGatewayCode = String.raw`
  3025. function inputOf(request) { const body = request.body || {}; return body.params && typeof body.params === 'object' ? body.params : body; }
  3026. function fail(status, message) { const error = new Error(message); error.status = status; throw error; }
  3027. function pointerId(value) { return value && (value.id || value.objectId || (value.__type === 'Pointer' && value.objectId)); }
  3028. function number(value, fallback = 0) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; }
  3029. function text(value) { return String(value == null ? '' : value).trim(); }
  3030. function dateIso(value) { if (!value || Array.isArray(value)) return ''; if (value instanceof Date) return Number.isNaN(value.getTime()) ? '' : value.toISOString(); const raw = value.iso || value; if (raw instanceof Date) return Number.isNaN(raw.getTime()) ? '' : raw.toISOString(); if (raw && typeof raw === 'object') return ''; const date = new Date(raw); return Number.isNaN(date.getTime()) ? '' : date.toISOString(); }
  3031. function firstDateIso(...values) { for (const value of values) { const iso=dateIso(value); if (iso) return iso; } return ''; }
  3032. function legacyDateIso(value) { const raw=text(value); if(!raw) return ''; let normalized=raw; if(/^\d{4}[\/-]\d{1,2}[\/-]\d{1,2}(?:\s+\d{1,2}:\d{1,2}(?::\d{1,2})?)?$/.test(raw)){const parts=raw.replaceAll('/','-').split(/\s+/);normalized=parts[0]+'T'+(parts[1]||'00:00:00')+'+08:00';} return dateIso(normalized); }
  3033. function registeredAtOf(row) { const legacy=legacyOf(row); return legacyDateIso(legacy.RegTime)||dateIso(row.registeredAt)||dateIso(row.createdAt); }
  3034. function randomObjectId() { const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; return Array.from({ length:20 }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join(''); }
  3035. function maskMobile(value) { const mobile = text(value); if (mobile.length < 7) return mobile ? mobile.slice(0,2) + '***' : ''; return mobile.slice(0,3) + '****' + mobile.slice(-4); }
  3036. function roleOf(context) { if (context.isSuperAdmin || context.roles.includes('ops-manager')) return 'ops-manager'; if (context.roles.includes('ops-staff')) return 'ops-staff'; if (context.roles.includes('ops-auditor')) return 'ops-auditor'; return 'denied'; }
  3037. function requireRole(context, allowed) { const role = roleOf(context); if (!allowed.includes(role)) fail(403, '当前运营角色无权执行该操作'); return role; }
  3038. function companyIdOf(context, input) { if (context.isSuperAdmin && input.companyId) return String(input.companyId); return pointerId(context.company) || null; }
  3039. function companyPointer(context, input) { const id = companyIdOf(context, input); if(!id)return null;const pointer=new Parse.Object('Company');pointer.id=id;return pointer; }
  3040. 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 instanceof Date) return value.toISOString(); if (value && typeof value.toJSON === 'function') return safe(value.toJSON(), depth + 1); if (typeof value === 'object') { const out = {}; for (const [key,item] of Object.entries(value)) if (!/(?:password|secret|token|hash|masterkey|privatekey)/i.test(key)) out[key] = safe(item, depth + 1); return out; } return value; }
  3041. async function auth(request) {
  3042. const current = request.user || (typeof user !== 'undefined' ? user : null); if (!current) fail(401, '管理员会话已失效'); await current.fetch({ useMasterKey:true });
  3043. if (current.get('isDisabled') === true || current.get('isDeleted') === true) fail(403, '管理员账号已停用');
  3044. const {roles,isSuperAdmin,isAdmin} = adminRoleSnapshot(current);
  3045. if (!isAdmin) fail(403, '当前账号未获运营后台权限');
  3046. const company = request.company || current.get('company') || null; if (!company && !isSuperAdmin) fail(403, '管理员账号尚未分配帐套'); return { current, roles, isSuperAdmin, company };
  3047. }
  3048. async function fieldsOf(className) { return (await new Parse.Schema(className).get({ useMasterKey:true })).fields || {}; }
  3049. function tenant(query, fields, context, input) { if (!fields.company) return; const company=companyPointer(context,input);if(company)query.equalTo('company',company); }
  3050. async function scopedObject(className, objectId, context, input) { if (!objectId) fail(400, '缺少目标记录'); const fields = await fieldsOf(className); const query = new Parse.Query(className); tenant(query, fields, context, input); return query.get(objectId, { useMasterKey:true }); }
  3051. function assertVersion(target, expected) { if (!expected) return; const actual = target.updatedAt ? target.updatedAt.toISOString() : ''; const wanted = dateIso(expected); if (actual && wanted && actual !== wanted) fail(409, '记录已被其他管理员修改,请刷新后重试'); }
  3052. function reasonOf(input) { const reason = text(input.reason); if (reason.length < 2 || reason.length > 500) fail(400, '操作原因必须为 2 至 500 个字符'); return reason; }
  3053. async function audit(context, action, className, objectId, reason, before, after) {
  3054. try { const fields = await fieldsOf('SysLog'); const log = new Parse.Object('SysLog'); if (fields.company && context.company) log.set('company', context.company); if (fields.userId) log.set('userId', context.current.id); if (fields.userName) log.set('userName', text(context.current.get('username'))); const summary = '[Operations] ' + action + ' ' + className + '/' + objectId + ',原因:' + reason; if (fields.remind) log.set('remind', summary.slice(0,2000)); const detail = JSON.stringify({ action, className, objectId, reason, before:safe(before), after:safe(after) }).slice(0,12000); if (fields.detail) log.set('detail', detail); if (fields.content) log.set('content', detail); if (fields.logType) log.set('logType', 'operations-admin'); await log.save(null, { useMasterKey:true }); } catch (_) {}
  3055. }
  3056. function legacyOf(row) { let value = row.legacyUserData || row.legacyuserdata || {}; if (typeof value === 'string') { try { value = JSON.parse(value); } catch (_) { value = {}; } } return value && typeof value === 'object' ? value : {}; }
  3057. function displayName(row) { const legacy = legacyOf(row); return text(row.realName || row.realname || row.nickname || row.nickName || row.nickname2 || legacy.HoneyName || row.username) || '未命名用户'; }
  3058. function mobileOf(row) { const legacy = legacyOf(row); const direct = text(row.mobile || row.phone || legacy.Mobile || legacy.Phone); if (direct) return direct; const match = text(row.username).match(/1\d{10}$/); return match ? match[0] : ''; }
  3059. function learningState(row, groupId, periods30, periods60, trialPeriods) {
  3060. const activeCourseCount=number(row.activeCourseCount),completedCourseCount=number(row.completedCourseCount),studyRecordCount=number(row.studyRecordCount),appointmentCount=number(row.appointmentCount),unfinishedAppointmentCount=number(row.unfinishedAppointmentCount),paidLessonCount=number(row.paidLessonCount),trialActivityCount=number(row.trialActivityCount),userPoint=number(row.userPointBalance);
  3061. if(groupId!==1) return { learningStatus:'not_applicable',learningStatusLabel:'非学员账号',learningStatusReason:groupId===2?'门店/代理账号':groupId===3?'陪练账号':'非普通学员组',activeCourseCount,completedCourseCount,studyRecordCount,appointmentCount };
  3062. const unfinishedCourses=Math.max(0,activeCourseCount-completedCourseCount); const hasFormalBalance=periods30>0||periods60>0||userPoint>0; const hasFormalHistory=paidLessonCount>0;
  3063. if(unfinishedCourses>0||unfinishedAppointmentCount>0||hasFormalBalance) return { learningStatus:'studying',learningStatusLabel:'学习中',learningStatusReason:unfinishedAppointmentCount>0?'有 '+unfinishedAppointmentCount+' 个未结束预约':unfinishedCourses>0?'有 '+unfinishedCourses+' 个未完成课程':'仍有正式课时待使用',activeCourseCount,completedCourseCount,studyRecordCount,appointmentCount };
  3064. if(hasFormalHistory||activeCourseCount>0) return { learningStatus:'completed',learningStatusLabel:'已学完',learningStatusReason:'有正式上课记录,且没有未完成课程、预约或剩余正式课时',activeCourseCount,completedCourseCount,studyRecordCount,appointmentCount };
  3065. if(trialPeriods>0||trialActivityCount>0) return { learningStatus:'trial',learningStatusLabel:'体验用户',learningStatusReason:'只有体验课余额或体验课记录,暂无正式课程',activeCourseCount,completedCourseCount,studyRecordCount,appointmentCount };
  3066. return { learningStatus:'not_started',learningStatusLabel:'待开始',learningStatusReason:'暂无课程、预约或上课记录',activeCourseCount,completedCourseCount,studyRecordCount,appointmentCount };
  3067. }
  3068. function normalizeUser(row, context) {
  3069. const legacy = legacyOf(row); const mobile = mobileOf(row); const full = roleOf(context) === 'ops-manager'; const parentName = text(row.parentRealName || row.parentNickname || row.parentHoneyName || row.parentUsername); const sourceGroupId = number(row.legacyGroupId || legacy.GroupID,1) || 1,groupId=text(row.identityType)==='member'?1:sourceGroupId; const periods30=number(legacy.Purse),periods60=number(legacy.SilverCoin),trialPeriods=number(legacy.UserExp); row.userPointBalance=number(legacy.UserPoint); const learning=learningState(row,groupId,periods30,periods60,trialPeriods); const riskFlags=[]; const lastLessonAt=dateIso(row.lastLessonAt),nextAppointmentAt=dateIso(row.nextAppointmentAt); if(learning.learningStatus==='studying'&&!nextAppointmentAt)riskFlags.push('待排课'); if(periods30+periods60<=2&&learning.learningStatus==='studying')riskFlags.push('低课时'); if(lastLessonAt&&Date.now()-new Date(lastLessonAt).getTime()>30*86400000&&learning.learningStatus==='studying')riskFlags.push('长期未上课'); if(number(row.abnormalAppointmentCount)>0)riskFlags.push('预约异常'); return {
  3070. objectId:text(row.objectId), userObjectId:text(row.objectId), userId:number(row.legacyUserId || legacy.UserID), displayName:displayName(row), nickname:text(row.nickname || legacy.HoneyName), username:text(row.username), mobile:full ? mobile : '', mobileMasked:maskMobile(mobile), email:text(row.email), groupId, groupName:text(row.groupName) || (groupId===1 ? '用户' : '用户组 ' + groupId), parentUserId:number(legacy.ParentUserID), parentDisplayName:parentName, periods30, periods60, trialPeriods, userPoint:number(legacy.UserPoint), ...learning, lastLessonAt,nextAppointmentAt,riskFlags,registeredAt:registeredAtOf(row), lastLoginAt:firstDateIso(legacy.LastLoginTime,legacy.LastLogin,row.lastLoginAt), loginCount:number(legacy.LoginTimes || legacy.LoginCount), status:row.isDisabled === true ? 'disabled' : 'active', statusLabel:row.isDisabled === true ? '已停用' : '正常', canRevealMobile:full&&row.writeReady!==false, updatedAt:dateIso(row.updatedAt), identityType:'member',gender:text(row.gender||legacy.Sex||legacy.UserSex),grade:text(row.grade||legacy.Position||(legacy.UserBase||{}).Position),noteCount:number(row.noteCount), liveOnly:row.liveOnly===true,writeReady:row.writeReady!==false,dataSource:text(row.dataSource)||'parse',readOnlyReason:row.writeReady===false?(row.liveOnly===true?'该账号来自旧系统实时投影;持续同步桥落库前仅支持查看,不允许在新库单边修改。':'旧系统实时课时与新库快照尚未同步一致;当前仅支持查看,避免排课时错误扣减课时。'):'', balances:{ purse:periods30, silverCoin:periods60, userExp:trialPeriods, userPoint:number(legacy.UserPoint), dummyPurse:number(legacy.DummyPurse), credit:number(legacy.UserCreit) }
  3071. };
  3072. }
  3073. function legacyMemberWhitelist(row){return{userId:number(row.UserID),username:text(row.UserName),nickname:text(row.HoneyName),realName:text(row.TrueName),companyName:text(row.CompanyName),mobile:text(row.Mobile),email:text(row.Email),groupId:number(row.GroupID),parentUserId:number(row.ParentUserID),registeredAt:firstDateIso(row.RegTime,row.JoinTime,row.CreateTime,row.UpdateTime),lastLoginAt:firstDateIso(row.LastLoginTime,row.LastLogin),loginCount:number(row.LoginTimes||row.LoginCount),state:number(row.State,1),purse:number(row.Purse),silverCoin:number(row.SilverCoin),userExp:number(row.UserExp),userPoint:number(row.UserPoint),dummyPurse:number(row.DummyPurse),credit:number(row.UserCreit),gender:text(row.UserSex),grade:text(row.Position),updatedAt:firstDateIso(row.UpdateTime,row.RegTime)};}
  3074. async function legacyMemberIdentitySnapshot(context,input){
  3075. const cacheKey='member-identity-v2',cached=await realtimeCacheRead(context,input,cacheKey),maxAge=6*3600000;if(cached&&cached.age<maxAge)return{...cached.payload,refreshedAt:cached.refreshedAt,cacheHit:true};const config=await legacyScheduleConfig();if(!config.url||!config.apiId||!config.apiKey)return{available:false,message:'旧系统账号身份源尚未配置',coachIds:[],studentIds:[],studentStats:[],totalAppointments:0,refreshedAt:''};
  3076. try{const first=await legacyScheduleRequest(config,{action:'content_list',nid:29,dateFilter:'2000-01-01',psize:1000,cpage:1}),total=number(first.page.itemCount,first.items.length),pageCount=Math.max(1,Math.min(50,number(first.page.pageCount,Math.ceil(total/1000)))),coachIds=new Set(),studentIds=new Set(),stats=new Map();let processed=0;const consume=(items)=>{for(const row of items){processed++;const coachId=number(row.pl||row.fxpl),studentId=number(row.szyh),status=number(row.dszt),type=number(row.dslx),at=appointmentDate(row.yysj,row.bxrq,row.sdsd),atIso=Number.isNaN(at.getTime())?'':at.toISOString();if(coachId)coachIds.add(coachId);if(!studentId)continue;studentIds.add(studentId);let stat=stats.get(studentId);if(!stat){stat={userId:studentId,appointmentCount:0,unfinishedAppointmentCount:0,trialActivityCount:0,abnormalAppointmentCount:0,completedCount:0,nextAppointmentAt:'',lastLessonAt:''};stats.set(studentId,stat);}stat.appointmentCount++;if([0,10,11,20].includes(status))stat.unfinishedAppointmentCount++;if(type===3)stat.trialActivityCount++;if(![0,10,11,20,30,40].includes(status))stat.abnormalAppointmentCount++;if([30,40].includes(status)){stat.completedCount++;if(atIso&&atIso>stat.lastLessonAt)stat.lastLessonAt=atIso;}if(status===0&&atIso&&at.getTime()>=Date.now()&&(!stat.nextAppointmentAt||atIso<stat.nextAppointmentAt))stat.nextAppointmentAt=atIso;}};consume(first.items);for(let page=2;page<=pageCount;page++)consume((await legacyScheduleRequest(config,{action:'content_list',nid:29,dateFilter:'2000-01-01',psize:1000,cpage:page})).items);const refreshedAt=new Date().toISOString(),payload={available:true,message:'旧系统全量预约身份证据',coachIds:[...coachIds],studentIds:[...studentIds],studentStats:[...stats.values()],totalAppointments:Math.max(total,processed),refreshedAt};await realtimeCacheWrite(context,input,cacheKey,payload,refreshedAt);return{...payload,cacheHit:false};}catch(error){if(cached)return{...cached.payload,available:true,message:'旧系统暂时不可用,使用最近身份快照',refreshedAt:cached.refreshedAt,cacheHit:true,stale:true};return{available:false,message:'旧系统账号身份读取失败:'+text(error&&error.message),coachIds:[],studentIds:[],studentStats:[],totalAppointments:0,refreshedAt:''};}
  3077. }
  3078. async function legacyMemberSnapshot(context,input){
  3079. const cacheKey='member-accounts-v3',cached=await realtimeCacheRead(context,input,cacheKey);if(cached&&input.refresh!==true&&cached.age<REALTIME_CACHE_TTL_MS){const payload=cached.payload||{};return{...payload,studentStats:new Map((payload.studentStats||[]).map((row)=>[number(row.userId),row])),refreshedAt:cached.refreshedAt,cacheHit:true};}const config=await legacyScheduleConfig();if(!config.url||!config.apiId||!config.apiKey)return{available:false,message:'旧系统会员接口尚未配置',items:[],coachIds:[],studentIds:[],studentStats:new Map(),total:0,storeCount:0,refreshedAt:''};
  3080. try{const [source,identity]=await Promise.all([legacyScheduleRequest(config,{action:'user_list',uid:0,cpage:1,psize:1000}),legacyMemberIdentitySnapshot(context,input)]);if(!identity.available)throw new Error(identity.message);const items=source.items.map(legacyMemberWhitelist).filter((row)=>row.userId),byId=new Map(items.map((row)=>[row.userId,row]));for(const row of items){const parent=byId.get(row.parentUserId);row.parentDisplayName=parent?text(parent.companyName||parent.nickname||parent.realName||parent.username):'';}const refreshedAt=new Date().toISOString(),payload={available:true,message:identity.stale?'账号余额为实时数据,身份使用最近快照':'已读取旧系统实时账号、课时与身份',items,coachIds:identity.coachIds,studentIds:identity.studentIds,studentStats:identity.studentStats,total:number(source.page.itemCount,items.length),storeCount:items.filter((row)=>row.groupId===2).length,identityRefreshedAt:identity.refreshedAt,refreshedAt,staleIdentity:identity.stale===true};await realtimeCacheWrite(context,input,cacheKey,payload,refreshedAt);return{...payload,studentStats:new Map(identity.studentStats.map((row)=>[number(row.userId),row])),cacheHit:false};}catch(error){if(cached){const payload=cached.payload||{};return{...payload,available:true,message:'旧系统暂时不可用,显示最近账号快照',studentStats:new Map((payload.studentStats||[]).map((row)=>[number(row.userId),row])),refreshedAt:cached.refreshedAt,cacheHit:true,stale:true};}return{available:false,message:'旧系统实时会员暂时不可用:'+text(error&&error.message),items:[],coachIds:[],studentIds:[],studentStats:new Map(),total:0,storeCount:0,refreshedAt:''};}
  3081. }
  3082. async function optionalLegacyMemberSnapshot(context,input){if(input.legacyRefresh===true)return legacyMemberSnapshot(context,input);return{available:false,mode:'parse',message:'新数据库账号投影',items:[],coachIds:[],studentIds:[],studentStats:new Map(),total:0,storeCount:0,refreshedAt:new Date().toISOString()};}
  3083. async function queryUsersLegacy(context, input) {
  3084. const companyId = companyIdOf(context, input); const rows = await Psql.query('SELECT u.*,g."groupName" AS "groupName",p."username" AS "parentUsername",p."realName" AS "parentRealName",p."nickname" AS "parentNickname",p."legacyUserData"->>\'HoneyName\' AS "parentHoneyName",COALESCE(study.active_count,0)::int AS "activeCourseCount",COALESCE(study.completed_count,0)::int AS "completedCourseCount",COALESCE(daily.record_count,0)::int AS "studyRecordCount",COALESCE(appt.appointment_count,0)::int AS "appointmentCount" FROM "_User" u LEFT JOIN "Group" g ON g."company"=u."company" AND CAST(g."groupId" AS text)=COALESCE(CAST(u."legacyGroupId" AS text),u."legacyUserData"->>\'GroupID\') LEFT JOIN "_User" p ON p."company"=u."company" AND COALESCE(CAST(p."legacyUserId" AS text),p."legacyUserData"->>\'UserID\')=COALESCE(u."legacyUserData"->>\'ParentUserID\',\'0\') LEFT JOIN LATERAL (SELECT COUNT(*) FILTER (WHERE c."objectId" IS NOT NULL AND CASE WHEN CAST(c."status" AS text) ~ \'^-?[0-9]+$\' THEN CAST(c."status" AS numeric) ELSE 99 END<>-2) AS active_count,COUNT(*) FILTER (WHERE c."objectId" IS NOT NULL AND CASE WHEN CAST(c."status" AS text) ~ \'^-?[0-9]+$\' THEN CAST(c."status" AS numeric) ELSE 99 END<>-2 AND CASE WHEN CAST(b."cksl" AS text) ~ \'^[0-9]+(?:\\.[0-9]+)?$\' THEN CAST(b."cksl" AS numeric) ELSE 0 END>0 AND CASE WHEN CAST(b."yxx" AS text) ~ \'^[0-9]+(?:\\.[0-9]+)?$\' THEN CAST(b."yxx" AS numeric) ELSE 0 END>=CASE WHEN CAST(b."cksl" AS text) ~ \'^[0-9]+(?:\\.[0-9]+)?$\' THEN CAST(b."cksl" AS numeric) ELSE 0 END) AS completed_count FROM "CourseBinding" b LEFT JOIN "CommonModel" c ON c."company"=b."company" AND CAST(c."modelId" AS text)=\'58\' AND CAST(c."itemId" AS text)=CAST(b."id" AS text) WHERE b."company"=u."company" AND CAST(b."yhid" AS text)=COALESCE(CAST(u."legacyUserId" AS text),u."legacyUserData"->>\'UserID\')) study ON TRUE LEFT JOIN LATERAL (SELECT COUNT(*) AS record_count FROM "DailyStudyRecord" d WHERE d."company"=u."company" AND CAST(d."userId" AS text)=COALESCE(CAST(u."legacyUserId" AS text),u."legacyUserData"->>\'UserID\')) daily ON TRUE LEFT JOIN LATERAL (SELECT COUNT(*) AS appointment_count FROM "CourseAppointment" a WHERE a."company"=u."company" AND CAST(a."szyh" AS text)=COALESCE(CAST(u."legacyUserId" AS text),u."legacyUserData"->>\'UserID\')) appt ON TRUE WHERE ($1::text IS NULL OR u."company"=$1) AND COALESCE(CAST(u."legacyGroupId" AS text),u."legacyUserData"->>\'GroupID\',\'1\') IN (\'1\',\'3\') AND (u."isDeleted" IS NULL OR u."isDeleted"=FALSE) AND COALESCE(u."isAdmin",FALSE)=FALSE AND COALESCE(u."adminRoleKey",\'\')=\'\' AND LOWER(COALESCE(u."role",\'\'))<>\'admin\' AND NOT (COALESCE(u."roles",\'[]\'::jsonb) ?| ARRAY[\'admin\',\'super-admin\',\'ops-manager\',\'ops-staff\',\'ops-auditor\']) ORDER BY u."createdAt" DESC LIMIT 5000', [companyId]);
  3085. const managerRows=await Psql.query('SELECT CAST("addUserId" AS text) AS uid,LOWER(COALESCE("userName","adminName",\'\')) AS username FROM "Manager" WHERE ($1::text IS NULL OR "company"=$1)',[companyId]).catch(()=>[]),managerIds=new Set(managerRows.map((row)=>text(row.uid)).filter(Boolean)),managerNames=new Set(managerRows.map((row)=>text(row.username).toLowerCase()).filter(Boolean));
  3086. const coachEvidence=await Psql.query('SELECT DISTINCT uid FROM (SELECT NULLIF(CAST("pl" AS text),\'\') AS uid FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("fxpl" AS text),\'\') AS uid FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("jsmz" AS text),\'\') AS uid FROM "LessonRecord" WHERE ($1::text IS NULL OR "company"=$1)) coach_ids WHERE uid IS NOT NULL AND uid<>\'\'',[companyId]).catch(()=>[]),coachIds=new Set(coachEvidence.map((row)=>text(row.uid)).filter(Boolean));
  3087. const studentEvidence=await Psql.query('SELECT DISTINCT uid FROM (SELECT NULLIF(CAST("szyh" AS text),\'\') uid FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("userId" AS text),\'\') FROM "DailyStudyRecord" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("yhid" AS text),\'\') FROM "CourseBinding" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("yhid" AS text),\'\') FROM "PracticeRecord" WHERE ($1::text IS NULL OR "company"=$1)) student_ids WHERE uid IS NOT NULL AND uid<>\'\'',[companyId]).catch(()=>[]),studentIds=new Set(studentEvidence.map((row)=>text(row.uid)).filter(Boolean));
  3088. const liveMembers=input.legacyRefresh===true?await legacyMemberSnapshot(context,input).catch(()=>({available:false,items:[],coachIds:[],studentIds:[],studentStats:new Map(),message:'旧系统实时会员读取失败',refreshedAt:''})):{available:false,items:[],coachIds:[],studentIds:[],studentStats:new Map(),message:'新数据库会员投影',refreshedAt:new Date().toISOString(),mode:'parse'};for(const uid of liveMembers.coachIds||[])coachIds.add(text(uid));for(const uid of liveMembers.studentIds||[])studentIds.add(text(uid));
  3089. const memberRows=rows.filter((row)=>{const legacy=legacyOf(row),uid=text(row.legacyUserId||legacy.UserID),username=text(row.username).toLowerCase(),groupId=number(row.legacyGroupId||legacy.GroupID),coach=Boolean(uid)&&coachIds.has(uid),student=Boolean(uid)&&studentIds.has(uid),projected=text(row.identityType),nameMarkedCoach=/陪练/.test(displayName(row)),memberEvidence=projected==='member'||student||groupId===1||groupId===0;return !managerIds.has(uid)&&!managerNames.has(username)&&groupId!==2&&!coach&&!nameMarkedCoach&&projected!=='coach'&&projected!=='store'&&projected!=='conflict'&&memberEvidence;});
  3090. const appointmentStats=await memberAppointmentStats(companyId);
  3091. const lessonStats=await Psql.query('SELECT CAST(l."xymz" AS text) AS uid,MAX(COALESCE(CAST(l."lessonAt" AS text),NULLIF(l."plpjsj",\'\'),CAST(c."createdAt" AS text),NULLIF(a."jssj",\'\'))) AS last_at,COUNT(*) FILTER (WHERE CAST(l."kclx" AS text) IN (\'1\',\'2\',\'4\'))::int AS paid_count,COUNT(*) FILTER (WHERE CAST(l."kclx" AS text)=\'3\')::int AS trial_count FROM "LessonRecord" l LEFT JOIN "CommonModel" c ON c."company"=l."company" AND CAST(c."modelId" AS text)=\'59\' AND CAST(c."itemId" AS text)=CAST(l."id" AS text) LEFT JOIN "CommonModel" ac ON ac."company"=l."company" AND CAST(ac."modelId" AS text)=\'54\' AND CAST(ac."generalId" AS text)=CAST(l."yyds" AS text) LEFT JOIN "CourseAppointment" a ON a."company"=l."company" AND CAST(a."id" AS text)=CAST(ac."itemId" AS text) WHERE ($1::text IS NULL OR l."company"=$1) GROUP BY CAST(l."xymz" AS text)',[companyId]);
  3092. const apptMap=new Map(appointmentStats.map((row)=>[text(row.uid),row])),lessonMap=new Map(lessonStats.map((row)=>[text(row.uid),row])),parseById=new Map(rows.map((row)=>[number(row.legacyUserId||legacyOf(row).UserID),row]));let projectedRows=memberRows;
  3093. if(liveMembers.available){const liveCoachIds=new Set(liveMembers.coachIds.map(number)),liveStudentIds=new Set(liveMembers.studentIds.map(number));projectedRows=liveMembers.items.filter((live)=>live.groupId!==2&&!liveCoachIds.has(live.userId)&&liveStudentIds.has(live.userId)&&!managerIds.has(String(live.userId))&&!managerNames.has(live.username.toLowerCase())).map((live)=>{const stored=parseById.get(live.userId),storedLegacy=stored?legacyOf(stored):{},balanceInSync=Boolean(stored)&&number(storedLegacy.Purse)===live.purse&&number(storedLegacy.SilverCoin)===live.silverCoin&&number(storedLegacy.UserExp)===live.userExp&&number(storedLegacy.UserPoint)===live.userPoint,legacy={...storedLegacy,UserID:live.userId,UserName:live.username,HoneyName:live.nickname,TrueName:live.realName,Mobile:live.mobile,Email:live.email,GroupID:live.groupId,ParentUserID:live.parentUserId,RegTime:live.registeredAt,LastLoginTime:live.lastLoginAt,LoginTimes:live.loginCount,State:live.state,Purse:live.purse,SilverCoin:live.silverCoin,UserExp:live.userExp,UserPoint:live.userPoint,DummyPurse:live.dummyPurse,UserCreit:live.credit,UserSex:live.gender,Position:live.grade},fact=liveMembers.studentStats.get(live.userId)||{};return{...(stored||{}),objectId:stored?text(stored.objectId):'legacy-live-member:'+live.userId,legacyUserId:live.userId,legacyGroupId:live.groupId,legacyUserData:legacy,username:live.username,realName:live.realName,nickname:live.nickname,mobile:live.mobile,email:live.email,registeredAt:live.registeredAt,lastLoginAt:live.lastLoginAt,updatedAt:live.updatedAt||liveMembers.refreshedAt,isDisabled:live.state===0,parentRealName:live.parentDisplayName,identityType:'member',appointmentCount:number(fact.appointmentCount),unfinishedAppointmentCount:number(fact.unfinishedAppointmentCount),trialActivityCount:number(fact.trialActivityCount),nextAppointmentAt:fact.nextAppointmentAt,abnormalAppointmentCount:number(fact.abnormalAppointmentCount),paidLessonCount:number(fact.paidLessonCount||fact.completedCount),lastLessonAt:fact.lastLessonAt,liveOnly:!stored,writeReady:balanceInSync,dataSource:'legacy-live'};});}
  3094. const normalized=projectedRows.map((row)=>{const legacy=legacyOf(row),uid=text(row.legacyUserId||legacy.UserID),appt=apptMap.get(uid)||{},lesson=lessonMap.get(uid)||{};return normalizeUser({...row,unfinishedAppointmentCount:row.unfinishedAppointmentCount??number(appt.unfinished),trialActivityCount:row.trialActivityCount??number(appt.trial_count)+number(lesson.trial_count),nextAppointmentAt:row.nextAppointmentAt||appt.next_at,abnormalAppointmentCount:row.abnormalAppointmentCount??number(appt.abnormal),paidLessonCount:row.paidLessonCount??number(lesson.paid_count),lastLessonAt:row.lastLessonAt||lesson.last_at},context);});normalized.sourceStatus={available:true,mode:liveMembers.available?'live':'parse',message:liveMembers.available?liveMembers.message:'新数据库会员投影',total:liveMembers.total||normalized.length,memberCount:normalized.length,coachEvidenceCount:liveMembers.available?(liveMembers.coachIds||[]).length:coachIds.size,storeCount:number(liveMembers.storeCount),unknownCount:liveMembers.available?Math.max(0,liveMembers.total-normalized.length-(liveMembers.coachIds||[]).length-number(liveMembers.storeCount)):0,refreshedAt:liveMembers.refreshedAt||new Date().toISOString()};return normalized;
  3095. }
  3096. async function memberAppointmentStats(companyId){
  3097. return Psql.query("SELECT a.\"szyh\"::text AS uid,COUNT(*)::int AS appointment_count,COUNT(*) FILTER(WHERE active AND COALESCE(a.\"dszt\",'0')::text IN ('0','10','11','20'))::int AS unfinished,COUNT(*) FILTER(WHERE active AND a.\"dslx\"::text='3')::int AS trial_count,MIN(CASE WHEN active AND COALESCE(a.\"dszt\",'0')::text='0' AND COALESCE(a.\"yysj\",a.\"bxrq\")>=to_char(CURRENT_DATE,'YYYY-MM-DD') THEN COALESCE(a.\"yysj\",a.\"bxrq\") END) AS next_at,COUNT(*) FILTER(WHERE active AND COALESCE(a.\"dszt\",'0')::text NOT IN ('0','10','11','20','30','40'))::int AS abnormal FROM (SELECT source.*,NOT EXISTS(SELECT 1 FROM \"CommonModel\" c WHERE c.\"company\"=source.\"company\" AND c.\"modelId\"::text='54' AND c.\"itemId\"::text=source.\"id\"::text AND c.\"status\"::text='-2') AS active FROM \"CourseAppointment\" source WHERE ($1::text IS NULL OR source.\"company\"=$1)) a GROUP BY a.\"szyh\"::text",[companyId]);
  3098. }
  3099. async function queryUsers(context,input){
  3100. const companyId=companyIdOf(context,input),[rows,courseStats,dailyStats,appointmentStats,lessonStats,identityCounts]=await Promise.all([
  3101. Psql.query('SELECT u.*,g."groupName" AS "groupName",p."username" AS "parentUsername",p."realName" AS "parentRealName",p."nickname" AS "parentNickname",p."legacyUserData"->>\'HoneyName\' AS "parentHoneyName" FROM "_User" u LEFT JOIN "Group" g ON g."company"=u."company" AND CAST(g."groupId" AS text)=COALESCE(CAST(u."legacyGroupId" AS text),u."legacyUserData"->>\'GroupID\') LEFT JOIN "_User" p ON p."company"=u."company" AND COALESCE(CAST(p."legacyUserId" AS text),p."legacyUserData"->>\'UserID\')=COALESCE(u."legacyUserData"->>\'ParentUserID\',\'0\') WHERE ($1::text IS NULL OR u."company"=$1) AND u."identityType"=\'member\' AND (u."isDeleted" IS NULL OR u."isDeleted"=FALSE) AND COALESCE(u."isAdmin",FALSE)=FALSE AND NOT EXISTS(SELECT 1 FROM "Manager" m WHERE m."company"=u."company" AND (CAST(m."addUserId" AS text)=COALESCE(CAST(u."legacyUserId" AS text),u."legacyUserData"->>\'UserID\') OR LOWER(COALESCE(m."userName",m."adminName",\'\'))=LOWER(COALESCE(u."username",\'\')))) ORDER BY COALESCE(u."registeredAt",u."createdAt") DESC,u."legacyUserId" DESC',[companyId]),
  3102. Psql.query('SELECT CAST(b."yhid" AS text) AS uid,COUNT(*) FILTER (WHERE COALESCE(CAST(c."status" AS text),\'99\')<>\'-2\')::int AS active_count,COUNT(*) FILTER (WHERE COALESCE(CAST(c."status" AS text),\'99\')<>\'-2\' AND CASE WHEN CAST(b."cksl" AS text)~\'^[0-9]+(?:\\.[0-9]+)?$\' THEN CAST(b."cksl" AS numeric) ELSE 0 END>0 AND CASE WHEN CAST(b."yxx" AS text)~\'^[0-9]+(?:\\.[0-9]+)?$\' THEN CAST(b."yxx" AS numeric) ELSE 0 END>=CASE WHEN CAST(b."cksl" AS text)~\'^[0-9]+(?:\\.[0-9]+)?$\' THEN CAST(b."cksl" AS numeric) ELSE 0 END)::int AS completed_count FROM "CourseBinding" b LEFT JOIN "CommonModel" c ON c."company"=b."company" AND CAST(c."modelId" AS text)=\'58\' AND CAST(c."itemId" AS text)=CAST(b."id" AS text) WHERE ($1::text IS NULL OR b."company"=$1) GROUP BY CAST(b."yhid" AS text)',[companyId]),
  3103. Psql.query('SELECT CAST("userId" AS text) AS uid,COUNT(*)::int AS record_count FROM "DailyStudyRecord" WHERE ($1::text IS NULL OR "company"=$1) GROUP BY CAST("userId" AS text)',[companyId]),
  3104. memberAppointmentStats(companyId),
  3105. Psql.query('SELECT CAST(l."xymz" AS text) AS uid,MAX(COALESCE(CAST(l."lessonAt" AS text),NULLIF(l."plpjsj",\'\'),CAST(c."createdAt" AS text),NULLIF(a."jssj",\'\'))) AS last_at,COUNT(*) FILTER (WHERE CAST(l."kclx" AS text) IN (\'1\',\'2\',\'4\'))::int AS paid_count,COUNT(*) FILTER (WHERE CAST(l."kclx" AS text)=\'3\')::int AS trial_count FROM "LessonRecord" l LEFT JOIN "CommonModel" c ON c."company"=l."company" AND CAST(c."modelId" AS text)=\'59\' AND CAST(c."itemId" AS text)=CAST(l."id" AS text) LEFT JOIN "CommonModel" ac ON ac."company"=l."company" AND CAST(ac."modelId" AS text)=\'54\' AND CAST(ac."generalId" AS text)=CAST(l."yyds" AS text) LEFT JOIN "CourseAppointment" a ON a."company"=l."company" AND CAST(a."id" AS text)=CAST(ac."itemId" AS text) WHERE ($1::text IS NULL OR l."company"=$1) GROUP BY CAST(l."xymz" AS text)',[companyId]),
  3106. Psql.one('SELECT COUNT(*) FILTER (WHERE "identityType"=\'coach\')::int AS coaches,COUNT(*) FILTER (WHERE "identityType"=\'store\')::int AS stores,COUNT(*) FILTER (WHERE "identityType"=\'conflict\')::int AS conflicts,COUNT(*) FILTER (WHERE COALESCE("identityType",\'unknown\')=\'unknown\')::int AS unknown FROM "_User" WHERE ($1::text IS NULL OR "company"=$1) AND ("isDeleted" IS NULL OR "isDeleted"=FALSE)',[companyId]),
  3107. ]),courseMap=new Map(courseStats.map((row)=>[text(row.uid),row])),dailyMap=new Map(dailyStats.map((row)=>[text(row.uid),row])),appointmentMap=new Map(appointmentStats.map((row)=>[text(row.uid),row])),lessonMap=new Map(lessonStats.map((row)=>[text(row.uid),row])),normalized=rows.filter((row)=>!/陪练/.test(displayName(row))).map((row)=>{const legacy=legacyOf(row),uid=text(row.legacyUserId||legacy.UserID),course=courseMap.get(uid)||{},daily=dailyMap.get(uid)||{},appointment=appointmentMap.get(uid)||{},lesson=lessonMap.get(uid)||{};return normalizeUser({...row,activeCourseCount:number(course.active_count),completedCourseCount:number(course.completed_count),studyRecordCount:number(daily.record_count),appointmentCount:number(appointment.appointment_count),unfinishedAppointmentCount:number(appointment.unfinished),trialActivityCount:number(appointment.trial_count)+number(lesson.trial_count),nextAppointmentAt:appointment.next_at,abnormalAppointmentCount:number(appointment.abnormal),paidLessonCount:number(lesson.paid_count),lastLessonAt:lesson.last_at},context);});
  3108. normalized.sourceStatus={available:true,mode:'parse',message:'新数据库会员身份与业务投影',total:normalized.length,memberCount:normalized.length,coachEvidenceCount:number(identityCounts.coaches),storeCount:number(identityCounts.stores),unknownCount:number(identityCounts.unknown),conflictCount:number(identityCounts.conflicts),refreshedAt:new Date().toISOString()};return normalized;
  3109. }
  3110. function pageInput(input) { return { page:Math.max(1,number(input.page,1)), pageSize:Math.min(100,Math.max(1,number(input.pageSize,20))), search:text(input.search).toLowerCase(), filters:input.filters && typeof input.filters === 'object' ? input.filters : {} }; }
  3111. function pageRows(rows, input) { const paging = pageInput(input); const total = rows.length; return { page:paging.page, pageSize:paging.pageSize, total, refreshedAt:new Date().toISOString(), items:rows.slice((paging.page-1)*paging.pageSize,paging.page*paging.pageSize) }; }
  3112. function memberSort(rows,input){const sort=input.sort&&typeof input.sort==='object'?input.sort:{},field=['registeredAt','lastLoginAt','lastLessonAt','nextAppointmentAt','remainingPeriods'].includes(text(sort.field))?text(sort.field):'registeredAt',direction=text(sort.direction)==='asc'?1:-1,value=(row)=>field==='remainingPeriods'?number(row.periods30)+number(row.periods60)+number(row.trialPeriods):new Date(row[field]||0).getTime()||0;return rows.slice().sort((a,b)=>{const delta=value(a)-value(b);if(delta)return delta*direction;return(number(a.userId)-number(b.userId))*direction;});}
  3113. function filterMembers(rows,input){const paging=pageInput(input),status=text(paging.filters.status||'all'),learningStatus=text(paging.filters.learningStatus||'all'),risk=text(paging.filters.risk||'all'),parentUserId=number(paging.filters.parentUserId),registeredFrom=text(paging.filters.registeredFrom),registeredTo=text(paging.filters.registeredTo);return rows.filter((row)=>(status==='all'||row.status===status)&&(learningStatus==='all'||row.learningStatus===learningStatus)&&(risk==='all'||(risk==='none'?!row.riskFlags.length:row.riskFlags.includes(risk)))&&(!parentUserId||row.parentUserId===parentUserId)&&(!registeredFrom||row.registeredAt.slice(0,10)>=registeredFrom)&&(!registeredTo||row.registeredAt.slice(0,10)<=registeredTo)&&(!paging.search||[row.userId,row.displayName,row.nickname,row.username,row.mobile,row.mobileMasked,row.parentDisplayName,row.parentUserId,row.learningStatusLabel,row.learningStatusReason].some((value)=>String(value||'').toLowerCase().includes(paging.search))));}
  3114. function memberSummary(rows){const today=businessDate(),sevenDaysAgo=new Date(Date.now()-6*86400000).toISOString().slice(0,10);return{total:rows.length,todayNew:rows.filter((row)=>row.registeredAt&&businessDate(row.registeredAt)===today).length,sevenDayNew:rows.filter((row)=>row.registeredAt&&businessDate(row.registeredAt)>=sevenDaysAgo).length,studying:rows.filter((row)=>row.learningStatus==='studying').length,followUp:rows.filter((row)=>row.riskFlags.length>0||row.learningStatus==='not_started').length};}
  3115. async function memberAgents(context,input){const companyId=companyIdOf(context,input),rows=await Psql.query('SELECT "objectId","username","realName","nickname","legacyUserId","legacyUserData" FROM "_User" WHERE ($1::text IS NULL OR "company"=$1) AND COALESCE(CAST("legacyGroupId" AS text),"legacyUserData"->>\'GroupID\')=\'2\' AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) ORDER BY COALESCE(NULLIF("realName",\'\'),NULLIF("nickname",\'\'),"legacyUserData"->>\'HoneyName\',"username") LIMIT 1000',[companyId]);return rows.map((row)=>({objectId:text(row.objectId),userId:number(row.legacyUserId||legacyOf(row).UserID),displayName:displayName(row)})).filter((row)=>row.userId);}
  3116. async function usersPage(context,input){const allRows=await queryUsers(context,input),rows=memberSort(filterMembers(allRows,input),input),page=pageRows(rows,input);return{...page,summary:memberSummary(allRows),agents:await memberAgents(context,input),sourceStatus:allRows.sourceStatus,sync:await operationalSyncIndicator(context,input)};}
  3117. async function identityConflictsPage(context,input){requireRole(context,['ops-manager']);const companyId=companyIdOf(context,input),rows=await Psql.query('SELECT "objectId","username","realName","nickname","legacyUserId","legacyGroupId","legacyUserData","createdAt","updatedAt" FROM "_User" WHERE ($1::text IS NULL OR "company"=$1) AND COALESCE(CAST("legacyGroupId" AS text),"legacyUserData"->>\'GroupID\')=\'1\' AND ("isDeleted" IS NULL OR "isDeleted"=FALSE)',[companyId]),evidence=await Psql.query('SELECT DISTINCT uid FROM (SELECT NULLIF(CAST("pl" AS text),\'\') AS uid FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("fxpl" AS text),\'\') AS uid FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("jsmz" AS text),\'\') AS uid FROM "LessonRecord" WHERE ($1::text IS NULL OR "company"=$1)) coach_ids WHERE uid IS NOT NULL AND uid<>\'\'',[companyId]).catch(()=>[]),ids=new Set(evidence.map((row)=>text(row.uid))),live=await liveAppointmentRows(input).catch(()=>({allItems:[]}));for(const item of live.allItems||[])if(number(item.coachId))ids.add(String(number(item.coachId)));const conflicts=rows.filter((row)=>ids.has(text(row.legacyUserId||legacyOf(row).UserID))||/陪练/.test(displayName(row))).map((row)=>({objectId:text(row.objectId),userId:number(row.legacyUserId||legacyOf(row).UserID),displayName:displayName(row),username:text(row.username),currentGroupId:1,recommendedGroupId:3,evidence:ids.has(text(row.legacyUserId||legacyOf(row).UserID))?'排课或上课记录中的陪练身份':'名称包含陪练标记',registeredAt:registeredAtOf(row),updatedAt:dateIso(row.updatedAt)}));return pageRows(memberSort(searchRows(conflicts,input),input),input);}
  3118. function statusLabel(value) { return ({'-2':'已取消','0':'待开始','10':'上课中','11':'陪练已结束','20':'学员已确认','30':'已完课','40':'已点评','99':'正常'})[String(value)] || '状态 ' + String(value == null ? '' : value); }
  3119. function classType(value) { return ({'1':'30 分钟','2':'60 分钟','3':'体验课','4':'陪练'})[String(value)] || (text(value) || '未设置'); }
  3120. function billingType(value) { return ({'1':'30 分钟课时','2':'60 分钟课时','3':'体验课时','4':'积分'})[String(value)] || (text(value) || '未设置'); }
  3121. function progressLabel(value) { let parsed=value; if(typeof value==='string') { const raw=text(value); if(!raw) return '未设置'; try { parsed=JSON.parse(raw); } catch (_) { return /^[{[]/.test(raw) ? '已记录' : raw; } } if(parsed&&typeof parsed==='object') { const first=Object.values(parsed).find((item)=>text(item)); const parts=text(first).split('@').filter(Boolean); if(parts.length>1) return parts.slice(1).join(' / '); return text(first)||'已记录'; } return text(parsed)||'未设置'; }
  3122. function searchRows(rows, input) { const keyword = pageInput(input).search; return keyword ? rows.filter((row) => Object.values(row).some((value) => typeof value !== 'object' && String(value == null ? '' : value).toLowerCase().includes(keyword))) : rows; }
  3123. async function businessRows(kind, context, input) {
  3124. const companyId = companyIdOf(context,input); let rows = [];
  3125. if (kind === 'course-bindings') {
  3126. const raw = await Psql.query('SELECT c."objectId" AS "targetId",c."generalId",c."status",c."createdAt" AS "commonCreatedAt",c."updatedAt" AS "commonUpdatedAt",b.*,u."objectId" AS "studentObjectId",u."username" AS "studentUsername",u."realName" AS "studentRealName",u."nickname" AS "studentNickname",u."legacyUserData" AS "studentLegacy",n."nodeName" AS "courseTitle" FROM "CommonModel" c JOIN "CourseBinding" b ON b."company"=c."company" AND CAST(b."id" AS text)=CAST(c."itemId" AS text) LEFT JOIN "_User" u ON u."company"=c."company" AND COALESCE(CAST(u."legacyUserId" AS text),u."legacyUserData"->>\'UserID\')=CAST(b."yhid" AS text) LEFT JOIN "Node" n ON n."company"=c."company" AND CAST(n."nodeId" AS text)=CAST(b."kcid" AS text) WHERE ($1::text IS NULL OR c."company"=$1) AND CAST(c."modelId" AS text)=\'58\' AND ($2::text=\'\' OR b."yhid"::text=$2) ORDER BY c."updatedAt" DESC LIMIT 5000',[companyId,text(input.studentId)]);
  3127. rows = raw.map((row) => { const legacy = legacyOf({ legacyUserData:row.studentLegacy }); const status = number(row.status,99); return { bindingId:text(row.objectId), categoryKey:text(row.categoryKey), categoryName:TEACHING_CATEGORY_LABELS.get(text(row.categoryKey))||'待确认', enrollmentStatus:text(row.enrollmentStatus), targetId:text(row.targetId), generalId:number(row.generalId), studentObjectId:text(row.studentObjectId), studentId:number(row.yhid), studentName:text(row.studentRealName || row.studentNickname || legacy.HoneyName || row.studentUsername) || '用户 ' + number(row.yhid), courseId:number(row.kcid), courseName:text(row.courseTitle) || '课程 ' + number(row.kcid), learnedCount:number(row.yxx), openedCount:number(row.cksl), progressStage:progressLabel(row.syjd), status, statusLabel:statusLabel(status), createdAt:dateIso(row.commonCreatedAt), updatedAt:dateIso(row.commonUpdatedAt) }; });
  3128. } else if (kind === 'appointments') {
  3129. const raw = await Psql.query('SELECT c."objectId" AS "targetId",c."generalId",c."status" AS "commonStatus",c."createdAt" AS "commonCreatedAt",c."updatedAt" AS "commonUpdatedAt",a.*,s."objectId" AS "studentObjectId",s."realName" AS "studentRealName",s."nickname" AS "studentNickname",s."username" AS "studentUsername",s."legacyUserData" AS "studentLegacy",p."objectId" AS "coachObjectId",p."realName" AS "coachRealName",p."nickname" AS "coachNickname",p."username" AS "coachUsername",p."legacyUserData" AS "coachLegacy",n."nodeName" AS "courseTitle" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND CAST(a."id" AS text)=CAST(c."itemId" AS text) LEFT JOIN "_User" s ON s."company"=c."company" AND COALESCE(CAST(s."legacyUserId" AS text),s."legacyUserData"->>\'UserID\')=CAST(a."szyh" AS text) LEFT JOIN "_User" p ON p."company"=c."company" AND COALESCE(CAST(p."legacyUserId" AS text),p."legacyUserData"->>\'UserID\')=COALESCE(NULLIF(CAST(a."pl" AS text),\'\'),CAST(a."fxpl" AS text)) LEFT JOIN "Node" n ON n."company"=c."company" AND CAST(n."nodeId" AS text)=CAST(a."kcid" AS text) WHERE ($1::text IS NULL OR c."company"=$1) AND CAST(c."modelId" AS text)=\'54\' AND ($2::text=\'\' OR a."szyh"::text=$2) ORDER BY c."updatedAt" DESC LIMIT 5000',[companyId,text(input.studentId)]);
  3130. rows = raw.map((row) => { const projection=appointmentProjection(row); return {...projection,appointmentAt:projection.startsAt,billingTypeLabel:billingType(row.jffs),createdAt:dateIso(row.commonCreatedAt)}; });
  3131. } else if (kind === 'lessons') {
  3132. const raw = await Psql.query('SELECT l.*,p."realName" AS "coachRealName",p."nickname" AS "coachNickname",p."username" AS "coachUsername",p."legacyUserData" AS "coachLegacy",c."objectId" AS "targetId",c."generalId",c."createdAt" AS "commonCreatedAt",c."updatedAt",a."jssj" AS "appointmentEnd",a."yysj" AS "appointmentStart",n."nodeName" AS "courseTitle" FROM "LessonRecord" l LEFT JOIN "_User" p ON p."company"=l."company" AND p."legacyUserId"::text=l."jsmz"::text LEFT JOIN "CommonModel" c ON c."company"=l."company" AND CAST(c."modelId" AS text)=\'59\' AND CAST(c."itemId" AS text)=CAST(l."id" AS text) LEFT JOIN "CommonModel" ac ON ac."company"=l."company" AND CAST(ac."modelId" AS text)=\'54\' AND CAST(ac."generalId" AS text)=CAST(l."yyds" AS text) LEFT JOIN "CourseAppointment" a ON a."company"=l."company" AND CAST(a."id" AS text)=CAST(ac."itemId" AS text) LEFT JOIN "Node" n ON n."company"=l."company" AND CAST(n."nodeId" AS text)=CAST(l."kcid" AS text) WHERE ($1::text IS NULL OR l."company"=$1) AND ($2::text=\'\' OR l."xymz"::text=$2) ORDER BY COALESCE(c."updatedAt",l."updatedAt") DESC LIMIT 5000',[companyId,text(input.studentId)]); rows = raw.map((row) => ({ studentId:number(row.xymz), targetId:text(row.targetId || row.objectId), generalId:number(row.generalId || row.id), studentName:text(row.xymz) || '未记录', coachName:text(row.coachRealName || row.coachNickname || legacyOf({legacyUserData:row.coachLegacy}).HoneyName || row.coachUsername || row.coachName) || (text(row.jsmz)?'历史陪练 '+text(row.jsmz):'未记录'), courseId:number(row.kcid), courseName:text(row.kcmc || row.courseTitle) || '课程 ' + number(row.kcid), bindingId:text(row.bindingId),courseCategoryKey:text(row.courseCategoryKey),courseCategoryName:text(row.courseCategoryName),classTypeLabel:text(row.courseCategoryName)||classType(row.kclx), score:number(row.pf), comment:text(row.pjnr || row.pldp) || '—', lessonAt:payrollLessonAt(row) }));
  3133. } else if (kind === 'learning-records') {
  3134. const raw = await Psql.query('SELECT d.*,c."objectId" AS "targetId",c."generalId",c."title",c."createdAt",c."updatedAt",u."objectId" AS "studentObjectId",u."realName" AS "studentRealName",u."nickname" AS "studentNickname",u."username" AS "studentUsername",u."legacyUserData" AS "studentLegacy",n."nodeName" AS "courseTitle" FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=d."company" AND CAST(c."modelId" AS text)=\'56\' AND CAST(c."itemId" AS text)=CAST(d."id" AS text) LEFT JOIN "_User" u ON u."company"=d."company" AND COALESCE(CAST(u."legacyUserId" AS text),u."legacyUserData"->>\'UserID\')=CAST(d."userId" AS text) LEFT JOIN "CommonModel" ac ON ac."company"=d."company" AND CAST(ac."modelId" AS text)=\'54\' AND CAST(ac."generalId" AS text)=CAST(d."dsid" AS text) LEFT JOIN "CourseAppointment" a ON a."company"=d."company" AND CAST(a."id" AS text)=CAST(ac."itemId" AS text) LEFT JOIN "Node" n ON n."company"=d."company" AND CAST(n."nodeId" AS text)=CAST(a."kcid" AS text) WHERE ($1::text IS NULL OR d."company"=$1) AND ($2::text=\'\' OR d."userId"::text=$2) ORDER BY COALESCE(c."updatedAt",d."updatedAt") DESC LIMIT 5000',[companyId,text(input.studentId)]); rows = raw.map((row) => { const legacy = legacyOf({legacyUserData:row.studentLegacy}); const review = number(row.djq); return { targetId:text(row.targetId || row.objectId), generalId:number(row.generalId || row.id), studentObjectId:text(row.studentObjectId), studentId:number(row.userId), studentName:text(row.studentRealName || row.studentNickname || legacy.HoneyName || row.studentUsername || row.title) || '用户 ' + number(row.userId), courseName:text(row.courseTitle) || '未关联课程', studyDate:text(row.dqrq || row.createdAt), learned:number(row.learned), mastered:number(row.ygg), reviewDue:review, statusLabel:review > 0 ? '待复习' : '正常' }; });
  3135. }
  3136. if(input.studentId) rows=rows.filter(row=>Number(row.studentId||row.userId)===Number(input.studentId));
  3137. return pageRows(searchRows(rows,input),input);
  3138. }
  3139. function learningReportWordList(value){
  3140. if(Array.isArray(value))return{items:value.filter((item)=>item&&typeof item==='object'),malformed:false};
  3141. const raw=text(value);if(!raw)return{items:[],malformed:false};
  3142. try{const parsed=JSON.parse(raw);return Array.isArray(parsed)?{items:parsed.filter((item)=>item&&typeof item==='object'),malformed:false}:{items:[],malformed:true};}catch(_){return{items:[],malformed:true};}
  3143. }
  3144. function learningReportDate(value){const raw=text(value);if(/^\d{8}$/.test(raw))return raw.slice(0,4)+'-'+raw.slice(4,6)+'-'+raw.slice(6,8);const iso=dateIso(raw);return iso?iso.slice(0,10):'';}
  3145. function learningReportStatus(row,words){if(!words.items.length||words.malformed)return{status:'missing',statusLabel:'明细缺失',canExport:false};if(!text(row.appointmentObjectId))return{status:'historical',statusLabel:'历史记录',canExport:true};if(number(row.appointmentStatus)<11)return{status:'incomplete',statusLabel:'学习未完成',canExport:false};return{status:'available',statusLabel:'可生成',canExport:true};}
  3146. function learningReportCounts(words){let mastered=0,review=0,unmarked=0;for(const item of words.items){const check=number(item.check??item.Check);if(check===1)mastered++;else if(check===2)review++;else unmarked++;}const learned=words.items.length;return{learnedCount:learned,masteredCount:mastered,reviewDueCount:review,unmarkedCount:unmarked,masteryRate:learned?Math.round(mastered*1000/learned)/10:0};}
  3147. function learningReportProjection(row){const words=learningReportWordList(row.xxqs),counts=learningReportCounts(words),state=learningReportStatus(row,words),student=legacyOf({legacyUserData:row.studentLegacy}),coach=legacyOf({legacyUserData:row.coachLegacy}),studyDate=learningReportDate(row.dqrq||row.studyAt||row.commonCreatedAt),appointmentAt=firstDateIso(row.appointmentAt,row.appointmentDate),studyAt=appointmentAt||firstDateIso(row.commonCreateTime,row.commonCreatedAt,row.recordCreatedAt);return{targetId:text(row.targetId||row.recordObjectId),generalId:number(row.generalId||row.recordId),studentObjectId:text(row.studentObjectId),studentId:number(row.userId),studentName:text(row.studentRealName||row.studentNickname||student.HoneyName||row.studentUsername||row.commonTitle)||'用户 '+number(row.userId),studentMobileMasked:maskMobile(text(row.studentMobile||student.Mobile)),coachId:number(row.pl),coachName:text(row.coachRealName||row.coachNickname||coach.HoneyName||row.coachUsername)||(number(row.pl)?'陪练 '+number(row.pl):'未关联'),courseId:number(row.courseId),courseName:text(row.courseTitle)||(text(row.appointmentObjectId)?'课程 '+number(row.courseId):'未关联课程'),appointmentId:number(row.dsid),appointmentAt,studyAt,studyDate,classTypeLabel:classType(row.classType),...counts,...state,updatedAt:dateIso(row.commonUpdatedAt||row.recordUpdatedAt)};}
  3148. async function learningReportSourceStatus(context,input,syncMessage='',refreshResult=null){
  3149. if(!syncBridgeConfigured()){
  3150. const cached=refreshResult&&refreshResult.source==='legacy-api'?{payload:refreshResult,refreshedAt:refreshResult.refreshedAt,age:0}:await realtimeCacheRead(context,input,'learning-reports-source'),config=await legacyScheduleConfig();
  3151. if(cached&&cached.payload&&cached.payload.available){const lastSuccessAt=dateIso(cached.refreshedAt||cached.payload.refreshedAt),lagSeconds=lastSuccessAt?Math.max(0,Math.floor((Date.now()-new Date(lastSuccessAt).getTime())/1000)):null,healthy=!syncMessage&&!number(cached.payload.failures)&&number(cached.payload.sourceTotal)===number(cached.payload.targetTotal)&&lagSeconds!==null&&lagSeconds<=600;return{state:healthy?'synced':'delayed',label:healthy?'学习记录已同步':'学习记录同步延迟',message:syncMessage||cached.payload.message||('旧系统 '+number(cached.payload.sourceTotal)+' 条,新数据库已补齐至 '+number(cached.payload.targetTotal)+' 条'),lastSuccessAt,lagSeconds};}
  3152. if(config.url&&config.apiId&&config.apiKey)return{state:'delayed',label:'学习记录待同步',message:syncMessage||'学习记录增量通道可用,请刷新获取旧系统最新数据',lastSuccessAt:'',lagSeconds:null};
  3153. return{state:'not_configured',label:'持续同步未配置',message:syncMessage||'当前展示新数据库已有学习记录',lastSuccessAt:'',lagSeconds:null};
  3154. }
  3155. const query=new Parse.Query('LegacySyncCheckpoint'),company=companyPointer(context,input);if(company)query.equalTo('company',company);query.equalTo('dataset','learning-records');const checkpoint=await query.first({useMasterKey:true}).catch(()=>null),lastSuccessAt=checkpoint?dateIso(checkpoint.get('lastSuccessAt')):'',lagSeconds=lastSuccessAt?Math.max(0,Math.floor((Date.now()-new Date(lastSuccessAt).getTime())/1000)):null,healthy=!syncMessage&&checkpoint&&['healthy','catching_up'].includes(text(checkpoint.get('state')))&&lagSeconds!==null&&lagSeconds<=120;return{state:healthy?'synced':'delayed',label:healthy?'学习记录已同步':'学习记录同步延迟',message:syncMessage||(!lastSuccessAt?'学习记录尚未完成首次持续同步':lagSeconds!==null?'最后成功同步距今 '+lagSeconds+' 秒':'学习记录同步状态未知'),lastSuccessAt,lagSeconds};
  3156. }
  3157. async function refreshLearningRecordsFromLegacyApi(context,input){
  3158. const config=await legacyScheduleConfig(),company=context.current.get('company')||companyPointer(context,input),companyId=companyIdOf(context,input);if(!config.url||!config.apiId||!config.apiKey||!company||!companyId)return null;
  3159. const storedBefore=await Psql.one('SELECT COUNT(*)::int AS total FROM "DailyStudyRecord" WHERE "company"=$1',[companyId]),fetched=[];let sourceTotal=0;for(let page=1;page<=3;page++){const result=await legacyScheduleRequest(config,{nid:291,modelId:56,psize:500,cpage:page});sourceTotal=Math.max(sourceTotal,number(result.page.itemCount));fetched.push(...result.items);if(result.items.length<500)break;const ids=result.items.map((row)=>String(number(row.GeneralID||row.generalId))).filter((id)=>id!=='0'),known=ids.length?await Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" WHERE "company"=$1 AND CAST("modelId" AS text)=\'56\' AND CAST("generalId" AS text)=ANY($2::text[])',[companyId,ids]):{total:0};if(number(known.total)===ids.length&&number(storedBefore.total)>=sourceTotal)break;}
  3160. const rows=[],seen=new Set();for(const source of fetched){const generalId=number(source.GeneralID||source.generalId);if(!generalId||seen.has(generalId))continue;seen.add(generalId);rows.push({...source,generalId});}
  3161. const ids=rows.map((row)=>String(row.generalId)),existingRows=ids.length?await Psql.query('SELECT CAST("generalId" AS text) AS id FROM "CommonModel" WHERE "company"=$1 AND CAST("modelId" AS text)=\'56\' AND CAST("generalId" AS text)=ANY($2::text[])',[companyId,ids]):[],existing=new Set(existingRows.map((row)=>text(row.id))),missing=rows.filter((row)=>!existing.has(String(row.generalId))),base=await Psql.one('SELECT COALESCE(MAX(CASE WHEN CAST(COALESCE("id",0) AS text)~\'^\\d+(?:\\.0+)?$\' THEN CAST("id" AS numeric) ELSE 0 END),0)::bigint AS id FROM "DailyStudyRecord" WHERE "company"=$1',[companyId]);let itemId=number(base.id),created=0,failures=0,lastError='';
  3162. for(const source of missing){const sourceKey='legacy-sync:learning-records:'+source.generalId,duplicate=await Psql.oneOrNone('SELECT "objectId" FROM "CommonModel" WHERE "company"=$1 AND ("sourceKey"=$2 OR (CAST("modelId" AS text)=\'56\' AND CAST("generalId" AS text)=$3)) LIMIT 1',[companyId,sourceKey,String(source.generalId)]);if(duplicate)continue;itemId+=1;const record=new Parse.Object('DailyStudyRecord'),createdAt=new Date(text(source.CreateTime));record.set('company',company);record.set('sourceKey',sourceKey);record.set('userId',number(source.UserID||source.userId));record.set('pl',text(source.pl));record.set('con',text(source.con).slice(0,5000));record.set('dqrq',text(source.dqrq));record.set('dsid',text(source.dsid));record.set('xxqs',text(source.xxqs));record.set('learned',Math.max(0,number(source.learned)));record.set('ygg',Math.max(0,number(source.ygg)));record.set('djq',Math.max(0,number(source.djq)));record.set('fxrl',text(source.fxrl).slice(0,2000));record.set('szmdid',text(source.szmdid).slice(0,500));let common=null,stage='record-save';try{await record.save(null,{useMasterKey:true});stage='record-id';await Psql.none('UPDATE "DailyStudyRecord" SET "id"=$1,"createdAt"=COALESCE($3::timestamptz,"createdAt"),"updatedAt"=NOW() WHERE "objectId"=$2',[itemId,record.id,Number.isNaN(createdAt.getTime())?null:createdAt.toISOString()]);stage='common-save';common=new Parse.Object('CommonModel');common.set('company',company);common.set('sourceKey',sourceKey);common.set('generalId',source.generalId);common.set('itemId',itemId);common.set('modelId',56);common.set('nodeId',291);common.set('tableName','ZL_C_ss');common.set('title',text(source.Title)||('学习记录 '+source.generalId));common.set('subtitle',text(source.Subtitle));common.set('inputer',text(source.Inputer));common.set('hits',number(source.Hits,number(source.learned)));common.set('status',99);if(!Number.isNaN(createdAt.getTime()))common.set('createTime',createdAt);await common.save(null,{useMasterKey:true});stage='common-time';if(!Number.isNaN(createdAt.getTime()))await Psql.none('UPDATE "CommonModel" SET "createdAt"=$2::timestamptz,"updatedAt"=NOW() WHERE "objectId"=$1',[common.id,createdAt.toISOString()]);created++;}catch(error){failures++;lastError=stage+': '+text(error&&error.message||error).slice(0,450);if(common&&common.id)await common.destroy({useMasterKey:true}).catch(()=>undefined);if(record.id)await record.destroy({useMasterKey:true}).catch(()=>undefined);}}
  3163. const target=await Psql.one('SELECT COUNT(*)::int AS total,MAX(CAST(COALESCE(NULLIF("dqrq",\'\'),\'0\') AS text)) AS latest FROM "DailyStudyRecord" WHERE "company"=$1',[companyId]),targetTotal=number(target.total),gap=Math.max(0,sourceTotal-targetTotal),refreshedAt=new Date().toISOString(),payload={source:'legacy-api',available:true,processed:rows.length,created,updated:0,deleted:0,failures,hasMore:gap>0,sourceTotal,targetTotal,latestStudyDate:learningReportDate(target.latest),refreshedAt,message:failures?'已补齐 '+created+' 条,另有 '+failures+' 条将在下次刷新重试'+(lastError?':'+lastError:''):gap?'已补齐 '+created+' 条,仍有 '+gap+' 条将在下次刷新继续同步':'旧系统 '+sourceTotal+' 条,新数据库已补齐至 '+targetTotal+' 条'};await realtimeCacheWrite(context,input,'learning-reports-source',payload,refreshedAt);return payload;
  3164. }
  3165. async function refreshLearningRecordsForReports(context,input){
  3166. requireRole(context,['ops-manager','ops-staff']);if(!syncBridgeConfigured())return refreshLearningRecordsFromLegacyApi(context,input);let aggregate={processed:0,created:0,updated:0,deleted:0,failures:0,hasMore:false,source:'sync-bridge'};for(let index=0;index<3;index++){const page=await runSyncPage(context,{...input,dataset:'learning-records',full:false,pageSize:500},true);aggregate={...aggregate,processed:aggregate.processed+page.processed,created:aggregate.created+page.created,updated:aggregate.updated+page.updated,deleted:aggregate.deleted+page.deleted,failures:aggregate.failures+page.failures,hasMore:page.hasMore};if(page.failures||!page.hasMore)break;}return aggregate;
  3167. }
  3168. function learningReportQueryParts(context,input){
  3169. const companyId=companyIdOf(context,input),paging=pageInput(input),filters=paging.filters||{},values=[companyId],where=['($1::text IS NULL OR d."company"=$1)'];
  3170. const add=(clause,value)=>{values.push(value);where.push(clause.replace('?', '$'+values.length));};
  3171. const month=text(filters.month),dateFrom=text(filters.dateFrom),dateTo=text(filters.dateTo);if(/^\d{4}-\d{2}$/.test(month)){add('CAST(d."dqrq" AS text)>=?',month.replace('-','')+'01');add('CAST(d."dqrq" AS text)<=?',month.replace('-','')+'31');}if(/^\d{4}-\d{2}-\d{2}$/.test(dateFrom))add('CAST(d."dqrq" AS text)>=?',dateFrom.replaceAll('-',''));if(/^\d{4}-\d{2}-\d{2}$/.test(dateTo))add('CAST(d."dqrq" AS text)<=?',dateTo.replaceAll('-',''));if(text(filters.studentObjectId))add('s."objectId"=?',text(filters.studentObjectId));if(number(filters.studentId))add('CAST(d."userId" AS text)=?',String(number(filters.studentId)));if(number(filters.coachId))add('CAST(d."pl" AS text)=?',String(number(filters.coachId)));if(number(filters.courseId))add('CAST(a."kcid" AS text)=?',String(number(filters.courseId)));
  3172. if(text(filters.coachSearch)){const keyword='%'+text(filters.coachSearch).toLowerCase()+'%';values.push(keyword);const pos='$'+values.length;where.push('(LOWER(CONCAT_WS(\' \',p."realName",p."nickname",p."username",p."mobile",p."legacyUserData"->>\'HoneyName\',p."legacyUserData"->>\'Mobile\')) LIKE '+pos+' OR CAST(d."pl" AS text) LIKE '+pos+')');}
  3173. if(text(filters.courseSearch)){const keyword='%'+text(filters.courseSearch).toLowerCase()+'%';values.push(keyword);const pos='$'+values.length;where.push('(LOWER(COALESCE(n."nodeName",\'\')) LIKE '+pos+' OR CAST(a."kcid" AS text) LIKE '+pos+')');}
  3174. if(paging.search){const keyword='%'+paging.search+'%';values.push(keyword);const pos='$'+values.length;where.push('(LOWER(CONCAT_WS(\' \',s."realName",s."nickname",s."username",s."mobile",s."legacyUserData"->>\'HoneyName\',s."legacyUserData"->>\'Mobile\')) LIKE '+pos+' OR CAST(d."userId" AS text) LIKE '+pos+')');}
  3175. const hasWords='COALESCE(NULLIF(BTRIM(CAST(d."xxqs" AS text)),\'\'),\'[]\') NOT IN (\'[]\',\'null\')',status=text(filters.status),appointmentStatus='CASE WHEN CAST(COALESCE(a."dszt",\'0\') AS text)~\'^-?[0-9]+(?:\\.0+)?$\' THEN CAST(a."dszt" AS numeric) ELSE 0 END';if(status==='missing')where.push('NOT ('+hasWords+')');else if(status==='historical')where.push(hasWords+' AND a."objectId" IS NULL');else if(status==='incomplete')where.push(hasWords+' AND a."objectId" IS NOT NULL AND '+appointmentStatus+'<11');else if(status==='available')where.push(hasWords+' AND a."objectId" IS NOT NULL AND '+appointmentStatus+'>=11');
  3176. const from=' FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=d."company" AND CAST(c."modelId" AS text)=\'56\' AND CAST(c."itemId" AS text)=CAST(d."id" AS text) LEFT JOIN "_User" s ON s."company"=d."company" AND COALESCE(CAST(s."legacyUserId" AS text),s."legacyUserData"->>\'UserID\')=CAST(d."userId" AS text) LEFT JOIN "_User" p ON p."company"=d."company" AND COALESCE(CAST(p."legacyUserId" AS text),p."legacyUserData"->>\'UserID\')=CAST(d."pl" AS text) LEFT JOIN "CommonModel" ac ON ac."company"=d."company" AND CAST(ac."modelId" AS text)=\'54\' AND CAST(ac."generalId" AS text)=CAST(d."dsid" AS text) LEFT JOIN "CourseAppointment" a ON a."company"=d."company" AND CAST(a."id" AS text)=CAST(ac."itemId" AS text) LEFT JOIN "Node" n ON n."company"=d."company" AND CAST(n."nodeId" AS text)=CAST(a."kcid" AS text)';return{paging,values,where:' WHERE '+where.join(' AND '),from};
  3177. }
  3178. async function learningReportPage(context,input){
  3179. requireRole(context,['ops-manager','ops-staff','ops-auditor']);let syncMessage='',refreshResult=null;if(input.refresh===true){try{refreshResult=await refreshLearningRecordsForReports(context,input);}catch(error){syncMessage=text(error.message)||'学习记录同步失败,当前继续展示缓存';}}
  3180. const query=learningReportQueryParts(context,input),offset=(query.paging.page-1)*query.paging.pageSize,select='SELECT c."objectId" AS "targetId",c."generalId",c."title" AS "commonTitle",c."createTime" AS "commonCreateTime",c."createdAt" AS "commonCreatedAt",c."updatedAt" AS "commonUpdatedAt",d."objectId" AS "recordObjectId",d."id" AS "recordId",d."userId",d."pl",d."dqrq",d."dsid",d."xxqs",d."learned",d."ygg",d."djq",d."fxrl",d."createdAt" AS "recordCreatedAt",d."updatedAt" AS "recordUpdatedAt",s."objectId" AS "studentObjectId",s."realName" AS "studentRealName",s."nickname" AS "studentNickname",s."username" AS "studentUsername",s."mobile" AS "studentMobile",s."legacyUserData" AS "studentLegacy",p."realName" AS "coachRealName",p."nickname" AS "coachNickname",p."username" AS "coachUsername",p."legacyUserData" AS "coachLegacy",a."objectId" AS "appointmentObjectId",a."dszt" AS "appointmentStatus",a."kcid" AS "courseId",a."dslx" AS "classType",a."yysj" AS "appointmentAt",a."bxrq" AS "appointmentDate",n."nodeName" AS "courseTitle"',summarySql='SELECT COUNT(*)::int AS total,COUNT(DISTINCT CAST(d."userId" AS text))::int AS students,COALESCE(SUM(CASE WHEN CAST(COALESCE(d."learned",\'0\') AS text)~\'^\\d+(?:\\.0+)?$\' THEN CAST(d."learned" AS numeric) ELSE 0 END),0)::int AS learned,COALESCE(SUM(CASE WHEN CAST(COALESCE(d."ygg",\'0\') AS text)~\'^\\d+(?:\\.0+)?$\' THEN CAST(d."ygg" AS numeric) ELSE 0 END),0)::int AS mastered,COALESCE(SUM(CASE WHEN CAST(COALESCE(d."djq",\'0\') AS text)~\'^\\d+(?:\\.0+)?$\' THEN CAST(d."djq" AS numeric) ELSE 0 END),0)::int AS review'+query.from+query.where;
  3181. const [summary,raw]=await Promise.all([Psql.one(summarySql,query.values),Psql.query(select+query.from+query.where+' ORDER BY CAST(COALESCE(NULLIF(d."dqrq",\'\'),\'0\') AS text) DESC,COALESCE(c."updatedAt",d."updatedAt") DESC LIMIT $'+(query.values.length+1)+' OFFSET $'+(query.values.length+2),query.values.concat([query.paging.pageSize,offset]))]),learned=number(summary.learned),mastered=number(summary.mastered),sourceStatus=await learningReportSourceStatus(context,input,syncMessage,refreshResult);return{page:query.paging.page,pageSize:query.paging.pageSize,total:number(summary.total),items:raw.map(learningReportProjection),summary:{reportCount:number(summary.total),studentCount:number(summary.students),learnedCount:learned,masteredCount:mastered,reviewDueCount:number(summary.review),masteryRate:learned?Math.round(mastered*1000/learned)/10:0},sourceStatus,refreshedAt:new Date().toISOString()};
  3182. }
  3183. async function learningReportDetail(context,input,forExport=false){
  3184. requireRole(context,['ops-manager','ops-staff','ops-auditor']);const companyId=companyIdOf(context,input),targetId=text(input.targetId);if(!targetId)fail(400,'缺少学习记录 ID');const rows=await Psql.query('SELECT c."objectId" AS "targetId",c."generalId",c."title" AS "commonTitle",c."createTime" AS "commonCreateTime",c."createdAt" AS "commonCreatedAt",c."updatedAt" AS "commonUpdatedAt",d."objectId" AS "recordObjectId",d."id" AS "recordId",d."userId",d."pl",d."con",d."dqrq",d."dsid",d."xxqs",d."learned",d."ygg",d."djq",d."fxrl",d."createdAt" AS "recordCreatedAt",d."updatedAt" AS "recordUpdatedAt",s."objectId" AS "studentObjectId",s."realName" AS "studentRealName",s."nickname" AS "studentNickname",s."username" AS "studentUsername",s."mobile" AS "studentMobile",s."legacyUserData" AS "studentLegacy",p."realName" AS "coachRealName",p."nickname" AS "coachNickname",p."username" AS "coachUsername",p."legacyUserData" AS "coachLegacy",a."objectId" AS "appointmentObjectId",a."dszt" AS "appointmentStatus",a."kcid" AS "courseId",a."dslx" AS "classType",a."jffs" AS "deliveryMethod",a."yysj" AS "appointmentAt",a."bxrq" AS "appointmentDate",a."kssj" AS "actualStartAt",a."jssj" AS "actualEndAt",n."nodeName" AS "courseTitle",l."pf" AS "score",l."pjnr" AS "studentComment",l."pldp" AS "coachComment",l."kcmc" AS "lessonCourseName",l."kclx" AS "lessonClassType" FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=d."company" AND CAST(c."modelId" AS text)=\'56\' AND CAST(c."itemId" AS text)=CAST(d."id" AS text) LEFT JOIN "_User" s ON s."company"=d."company" AND COALESCE(CAST(s."legacyUserId" AS text),s."legacyUserData"->>\'UserID\')=CAST(d."userId" AS text) LEFT JOIN "_User" p ON p."company"=d."company" AND COALESCE(CAST(p."legacyUserId" AS text),p."legacyUserData"->>\'UserID\')=CAST(d."pl" AS text) LEFT JOIN "CommonModel" ac ON ac."company"=d."company" AND CAST(ac."modelId" AS text)=\'54\' AND CAST(ac."generalId" AS text)=CAST(d."dsid" AS text) LEFT JOIN "CourseAppointment" a ON a."company"=d."company" AND CAST(a."id" AS text)=CAST(ac."itemId" AS text) LEFT JOIN "Node" n ON n."company"=d."company" AND CAST(n."nodeId" AS text)=CAST(a."kcid" AS text) LEFT JOIN LATERAL (SELECT source.* FROM "LessonRecord" source WHERE source."company"=d."company" AND CAST(source."yyds" AS text)=CAST(d."dsid" AS text) ORDER BY source."updatedAt" DESC LIMIT 1) l ON TRUE WHERE ($1::text IS NULL OR d."company"=$1) AND (c."objectId"=$2 OR CAST(c."generalId" AS text)=$2 OR d."objectId"=$2) LIMIT 1',[companyId,targetId]);if(!rows.length)fail(404,'学习报表不存在');const row=rows[0],parsed=learningReportWordList(row.xxqs),base=learningReportProjection({...row,classType:row.lessonClassType||row.classType,courseTitle:row.lessonCourseName||row.courseTitle});if(forExport&&!base.canExport)fail(409,base.status==='incomplete'?'学习尚未完成,暂不能生成报表':'学习记录缺少单词明细,暂不能生成报表');const ids=[...new Set(parsed.items.map((item)=>text(item.GeneralID??item.generalId??item.id)).filter(Boolean))],wordRows=ids.length?await Psql.query('SELECT c."generalId",c."title",v."sy",v."yb",v."lj",v."yp" FROM "CommonModel" c LEFT JOIN "VocabularyWord" v ON v."company"=c."company" AND CAST(v."id" AS text)=CAST(c."itemId" AS text) WHERE ($1::text IS NULL OR c."company"=$1) AND CAST(c."modelId" AS text)=\'52\' AND CAST(c."generalId" AS text)=ANY($2::text[])',[companyId,ids]):[],details=new Map(wordRows.map((item)=>[text(item.generalId),item])),words=parsed.items.map((item,index)=>{const id=text(item.GeneralID??item.generalId??item.id),detail=details.get(id)||{},check=number(item.check??item.Check),result=check===1?'mastered':check===2?'review':'unmarked';return{index:index+1,generalId:number(id),word:text(item.Title??item.title??detail.title)||'未命名单词',phonetic:text(detail.yb),meaning:text(detail.sy),example:text(detail.lj),result,resultLabel:result==='mastered'?'已掌握':result==='review'?'待复习':'未标记'};}),days=[1,2,3,5,7,9,11,14,17,21],dates=text(row.fxrl).split(',').map(learningReportDate).filter(Boolean),reviewSchedule=dates.slice(0,days.length).map((date,index)=>({day:days[index]||index+1,date})),sourceStatus=await learningReportSourceStatus(context,input),generatedAt=new Date().toISOString(),detail={...base,words,reviewSchedule,score:number(row.score),studentComment:text(row.studentComment),coachComment:text(row.coachComment||row.con),generatedAt,sourceStatus,countMismatch:number(row.learned)!==words.length||number(row.ygg)!==base.masteredCount||number(row.djq)!==base.reviewDueCount};if(forExport)await audit(context,'download-learning-report','DailyStudyRecord',text(row.recordObjectId),'运营后台按需生成并下载单词学习报表',null,{generalId:base.generalId,studentId:base.studentId,wordCount:words.length,generatedAt});return detail;
  3185. }
  3186. async function moneyRows(context,input) {
  3187. const companyId = companyIdOf(context,input); const specs = [[1,'UserExpDomP','30 分钟课时'],[2,'UserSIcon','60 分钟课时'],[3,'UserExpHis','体验课时'],[4,'UserUserPoint','陪练时长']]; const parts = specs.map((item) => 'SELECT '+item[0]+' AS money_type,\''+item[2]+'\' AS money_label,l.* FROM "'+item[1]+'" l WHERE ($1::text IS NULL OR l."company"=$1) AND ($2::text=\'\' OR l."userId"::text=$2)'); const raw = await Psql.query('SELECT x.*,u."objectId" AS user_object_id,u."realName" AS user_real_name,u."nickname" AS user_nickname,u."username" AS user_username,u."legacyUserData" AS user_legacy FROM ('+parts.join(' UNION ALL ')+') x LEFT JOIN "_User" u ON u."company"=x."company" AND COALESCE(CAST(u."legacyUserId" AS text),u."legacyUserData"->>\'UserID\')=CAST(x."userId" AS text) ORDER BY x."hisTime" DESC NULLS LAST LIMIT 5000',[companyId,text(input.studentId)]); const rows = raw.map((row) => { const legacy=legacyOf({legacyUserData:row.user_legacy}); return { targetId:String(row.money_type)+':'+text(row.objectId), userObjectId:text(row.user_object_id), userId:number(row.userId), userName:text(row.user_real_name || row.user_nickname || legacy.HoneyName || row.user_username) || '用户 '+number(row.userId), moneyType:number(row.money_type), moneyTypeLabel:text(row.money_label), score:number(row.score), scoreBefore:number(row.scoreBefore),balanceAfter:number(row.scoreBefore)+number(row.score),happenedAt:dateIso(row.hisTime||row.createdAt), detail:text(row.detail || row.remark) || '—', occurredAt:dateIso(row.hisTime || row.createdAt) }; });
  3188. return pageRows(searchRows(input.studentId?rows.filter(row=>Number(row.userId)===Number(input.studentId)):rows,input),input);
  3189. }
  3190. async function dashboard(context,input,providedPayroll=null) {
  3191. const today=businessDate(),month=today.slice(0,7),payroll=providedPayroll||await payrollOverviewData(context,{...input,month,storeId:0,search:'',refresh:true}),users=await queryUsers(context,input),appointments=await allAppointmentRows(context,input),coaches=(await coachRows(context,{...input,page:1,pageSize:100,search:'',filters:{status:'active'}})).items,payrolls=(await payrollPage(context,{...input,page:1,pageSize:100})).items,active=users.filter((row)=>row.status==='active'),todayRows=appointments.filter((row)=>!row.cancelled&&row.startsAt&&businessDate(row.startsAt)===today),unassigned=todayRows.filter((row)=>!row.coachId).length,low=active.filter((row)=>row.riskFlags.includes('低课时')).length,abnormal=active.filter((row)=>row.riskFlags.includes('预约异常')).length,pendingPayroll=payrolls.filter((row)=>row.status==='draft'||row.status==='reviewed').length,estimatedPay=number(payroll.payableAmount),payrollLive=payroll.sourceStatus&&payroll.sourceStatus.mode==='live';
  3192. return { identity:{ objectId:context.current.id,username:text(context.current.get('username')),displayName:text(context.current.get('realName')||context.current.get('realname')||context.current.get('nickname')||context.current.get('username')),company:safe(context.company),roles:context.roles,isSuperAdmin:context.isSuperAdmin,operationsRole:roleOf(context) },date:today,metrics:[{key:'members',label:'在学会员',value:active.filter((row)=>row.learningStatus==='studying').length,hint:'当前有正式学习任务',route:'/admin/members',tone:'blue'},{key:'today',label:'今日课程',value:todayRows.length,hint:'今天计划进行的课程',route:'/admin/schedule',tone:'amber'},{key:'coaches',label:'在岗陪练',value:coaches.length,hint:'当前可用陪练账号',route:'/admin/coaches',tone:'green'},{key:'pay',label:'本月预估工资',value:estimatedPay,hint:'与工资结算同口径 · '+(payrollLive?'实时上课记录':'缓存上课记录'),route:'/admin/payroll',tone:'rose'}],alerts:[{key:'unassigned',label:'今日待分配预约',value:unassigned,route:'/admin/schedule'},{key:'low',label:'低课时会员',value:low,route:'/admin/members'},{key:'abnormal',label:'预约异常会员',value:abnormal,route:'/admin/members'},{key:'payroll',label:'待处理工资批次',value:pendingPayroll,route:'/admin/payroll'}],payroll:{month,coachCount:number(payroll.coachCount),lessonCount:number(payroll.lessonCount),payableAmount:estimatedPay,sourceStatus:payroll.sourceStatus,refreshedAt:text(payroll.refreshedAt||payroll.sourceStatus&&payroll.sourceStatus.refreshedAt)} };
  3193. }
  3194. async function findAddon(className, company, id) { const fields=await fieldsOf(className); const query=new Parse.Query(className); if(fields.company&&company) query.equalTo('company',company); query.equalTo('id',number(id)); return query.first({useMasterKey:true}); }
  3195. function durationMinutes(type) { return number(type)===2||number(type)===3?60:30; }
  3196. function durationHours(type) { return durationMinutes(type)/60; }
  3197. function appointmentDate(value,date,time) { const raw=text(value); if(raw) { const normalized=raw.includes('T')?raw:raw.replace(' ','T')+(raw.includes('+')||raw.endsWith('Z')?'':'+08:00'); const parsed=new Date(normalized); if(!Number.isNaN(parsed.getTime())) return parsed; } const fallback=new Date(text(date)+'T'+(text(time)||'00:00')+':00+08:00'); return fallback; }
  3198. function appointmentProjection(row) { const start=appointmentDate(row.yysj,row.bxrq,row.sdsd),type=number(row.dslx),minutes=number(row.durationMinutes)||durationMinutes(type),end=new Date(start.getTime()+minutes*60000),student=legacyOf({legacyUserData:row.studentLegacy}),coach=legacyOf({legacyUserData:row.coachLegacy}),cancelled=number(row.commonStatus)===-2,coachId=number(row.pl||row.fxpl),coachObjectId=text(row.coachObjectId),startsAt=Number.isNaN(start.getTime())?'':start.toISOString(),endsAt=Number.isNaN(end.getTime())?'':end.toISOString(),source=text(row.source)||'parse',categoryName=text(row.courseCategoryName),creditHoldStatus=text(row.creditHoldStatus),creditHoldReason=text(row.creditHoldReason),holdLabel=creditHoldStatus&&creditHoldStatus!=='ready'?({insufficient:'待补课时',unmapped:'课程未映射',missing_rule:'规则缺失',missing_student:'学员缺失'})[creditHoldStatus]||'课时待处理':''; return { bindingId:text(row.bindingId),targetId:text(row.targetId),generalId:number(row.generalId),studentObjectId:text(row.studentObjectId),studentId:number(row.szyh),studentName:text(row.studentRealName||row.studentNickname||student.HoneyName||row.studentUsername)||'用户 '+number(row.szyh),coachObjectId,coachId,coachName:text(row.coachRealName||row.coachNickname||coach.HoneyName||row.coachUsername||row.plxm)||(coachId?'历史陪练 '+coachId:'未分配'),courseId:number(row.kcid),courseName:text(row.courseTitle)||'课程 '+number(row.kcid),classType:type,classTypeLabel:categoryName?categoryName+'·'+minutes+'分钟':classType(type),deliveryMethod:text(row.jffs)||deliveryLabel(row.deliveryMode),courseCategoryKey:text(row.courseCategoryKey),courseCategoryName:categoryName,durationMinutes:minutes,deliveryMode:deliveryMode(row.deliveryMode||row.jffs),teachingRuleId:text(row.teachingRuleId),teacherPaySnapshot:number(row.teacherPaySnapshot),creditCostsSnapshot:Array.isArray(row.creditCostsSnapshot)?row.creditCostsSnapshot:[],creditHoldStatus,creditHoldReason,startsAt,endsAt,statusCode:number(row.dszt),statusLabel:cancelled?'已取消':holdLabel||statusLabel(row.dszt),cancelled,cancellationReason:text(row.cancellationReason),coachAccountMissing:source==='legacy-live'?false:Boolean(coachId&&!coachObjectId),availabilityWarning:row.availabilityConfigured===false,source,sourceLabel:source==='legacy-live'?'实时业务系统':'迁移数据库',readOnly:source==='legacy-live',updatedAt:dateIso(row.commonUpdatedAt||row.updatedAt||row.CreateTime) }; }
  3199. async function allAppointmentRows(context,input) { const companyId=companyIdOf(context,input); const raw=await Psql.query('SELECT c."objectId" AS "targetId",c."generalId",c."status" AS "commonStatus",c."updatedAt" AS "commonUpdatedAt",a.*,s."objectId" AS "studentObjectId",s."realName" AS "studentRealName",s."nickname" AS "studentNickname",s."username" AS "studentUsername",s."legacyUserData" AS "studentLegacy",p."objectId" AS "coachObjectId",p."realName" AS "coachRealName",p."nickname" AS "coachNickname",p."username" AS "coachUsername",p."legacyUserData" AS "coachLegacy",n."nodeName" AS "courseTitle" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND CAST(a."id" AS text)=CAST(c."itemId" AS text) LEFT JOIN "_User" s ON s."company"=c."company" AND COALESCE(CAST(s."legacyUserId" AS text),s."legacyUserData"->>\'UserID\')=CAST(a."szyh" AS text) LEFT JOIN "_User" p ON p."company"=c."company" AND COALESCE(CAST(p."legacyUserId" AS text),p."legacyUserData"->>\'UserID\')=COALESCE(NULLIF(CAST(a."pl" AS text),\'\'),CAST(a."fxpl" AS text)) LEFT JOIN "Node" n ON n."company"=c."company" AND CAST(n."nodeId" AS text)=CAST(a."kcid" AS text) WHERE ($1::text IS NULL OR c."company"=$1) AND CAST(c."modelId" AS text)=\'54\' ORDER BY c."updatedAt" DESC LIMIT 10000',[companyId]); return raw.map(appointmentProjection); }
  3200. async function appointmentRowsInRange(context,input,dateFrom,dateTo) { const companyId=companyIdOf(context,input),from=text(dateFrom),to=text(dateTo);if(!/^\d{4}-\d{2}-\d{2}$/.test(from)||!/^\d{4}-\d{2}-\d{2}$/.test(to)||from>to)fail(400,'排课日期范围无效');const raw=await Psql.query('SELECT c."objectId" AS "targetId",c."generalId",c."status" AS "commonStatus",c."updatedAt" AS "commonUpdatedAt",a.*,s."objectId" AS "studentObjectId",s."realName" AS "studentRealName",s."nickname" AS "studentNickname",s."username" AS "studentUsername",s."legacyUserData" AS "studentLegacy",p."objectId" AS "coachObjectId",p."realName" AS "coachRealName",p."nickname" AS "coachNickname",p."username" AS "coachUsername",p."legacyUserData" AS "coachLegacy",n."nodeName" AS "courseTitle" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND CAST(a."id" AS text)=CAST(c."itemId" AS text) LEFT JOIN "_User" s ON s."company"=c."company" AND COALESCE(CAST(s."legacyUserId" AS text),s."legacyUserData"->>\'UserID\')=CAST(a."szyh" AS text) LEFT JOIN "_User" p ON p."company"=c."company" AND COALESCE(CAST(p."legacyUserId" AS text),p."legacyUserData"->>\'UserID\')=COALESCE(NULLIF(CAST(a."pl" AS text),\'\'),CAST(a."fxpl" AS text)) LEFT JOIN "Node" n ON n."company"=c."company" AND CAST(n."nodeId" AS text)=CAST(a."kcid" AS text) WHERE ($1::text IS NULL OR c."company"=$1) AND CAST(c."modelId" AS text)=\'54\' AND LEFT(REPLACE(COALESCE(NULLIF(CAST(a."yysj" AS text),\'\'),NULLIF(CAST(a."bxrq" AS text),\'\')),\'/\',\'-\'),10) BETWEEN $2 AND $3 ORDER BY COALESCE(NULLIF(CAST(a."yysj" AS text),\'\'),NULLIF(CAST(a."bxrq" AS text),\'\')) ASC,c."updatedAt" DESC LIMIT 10000',[companyId,from,to]);return raw.map(appointmentProjection);}
  3201. async function storedAppointmentSummary(context,input) { const companyId=companyIdOf(context,input),row=await Psql.one('SELECT COUNT(*)::int AS total,MIN(LEFT(REPLACE(COALESCE(NULLIF(CAST(a."yysj" AS text),\'\'),NULLIF(CAST(a."bxrq" AS text),\'\')),\'/\',\'-\'),10)) AS date_from,MAX(LEFT(REPLACE(COALESCE(NULLIF(CAST(a."yysj" AS text),\'\'),NULLIF(CAST(a."bxrq" AS text),\'\')),\'/\',\'-\'),10)) AS date_to FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND CAST(a."id" AS text)=CAST(c."itemId" AS text) WHERE ($1::text IS NULL OR c."company"=$1) AND CAST(c."modelId" AS text)=\'54\'',[companyId]);return{totalAppointments:number(row.total),dataDateFrom:text(row.date_from),dataDateTo:text(row.date_to)};}
  3202. function parseLegacyPayload(value) { let parsed=value; if(typeof parsed==='string'){try{parsed=JSON.parse(parsed);}catch(_){return[];}} return Array.isArray(parsed)?parsed:[]; }
  3203. async function legacyScheduleConfig() { const config=await Parse.Config.get({useMasterKey:true}); return { url:text(config.get('legacyScheduleApiUrl')),apiId:text(config.get('legacyScheduleApiId')),apiKey:text(config.get('legacyScheduleApiKey')) }; }
  3204. async function legacyScheduleRequest(config,params) {
  3205. const query=Object.entries({action:'content_list',...params,apiId:config.apiId,apiKey:config.apiKey}).map(([key,value])=>encodeURIComponent(key)+'='+encodeURIComponent(String(value))).join('&'),url=config.url+(config.url.includes('?')?'&':'?')+query;
  3206. let payload;
  3207. if(typeof fetch==='function'){const response=await fetch(url,{method:'GET',headers:{accept:'application/json'},signal:typeof AbortSignal!=='undefined'&&AbortSignal.timeout?AbortSignal.timeout(12000):undefined});if(!response.ok)throw new Error('实时排课接口返回 HTTP '+response.status);payload=await response.json();}
  3208. else if(Parse.Cloud&&typeof Parse.Cloud.httpRequest==='function'){const response=await Parse.Cloud.httpRequest({url,method:'GET',headers:{accept:'application/json'}});payload=typeof response.data==='object'?response.data:JSON.parse(text(response.text));}
  3209. else throw new Error('云函数运行环境不支持外部请求');
  3210. if(number(payload&&payload.retcode)===-1)throw new Error(text(payload.retmsg)||'实时排课接口拒绝请求');
  3211. return {items:parseLegacyPayload(payload&&payload.result),page:payload&&payload.page&&typeof payload.page==='object'?payload.page:{}};
  3212. }
  3213. const REALTIME_CACHE_TTL_MS=10*60*1000;
  3214. function realtimeCacheKey(context,input,key){return (companyIdOf(context,input)||'global')+':'+key;}
  3215. async function realtimeCacheRead(context,input,key){
  3216. try{
  3217. const query=new Parse.Query('OpsRealtimeCache');query.equalTo('cacheKey',realtimeCacheKey(context,input,key));query.descending('updatedAt');query.limit(1);const row=await query.first({useMasterKey:true});if(!row)return null;let payload=row.get('payload');if(typeof payload==='string'){try{payload=JSON.parse(payload);}catch(_){return null;}}if(!payload||typeof payload!=='object')return null;const refreshedAt=dateIso(row.get('sourceRefreshedAt')||row.updatedAt),age=refreshedAt?Date.now()-new Date(refreshedAt).getTime():Number.MAX_SAFE_INTEGER;return{payload,refreshedAt,age};
  3218. }catch(_){return null;}
  3219. }
  3220. async function realtimeCacheWrite(context,input,key,payload,refreshedAt){
  3221. try{
  3222. const cacheKey=realtimeCacheKey(context,input,key),query=new Parse.Query('OpsRealtimeCache');query.equalTo('cacheKey',cacheKey);query.descending('updatedAt');query.limit(1);let row=await query.first({useMasterKey:true});if(!row){row=new Parse.Object('OpsRealtimeCache');row.set('cacheKey',cacheKey);}const clean=JSON.parse(JSON.stringify(safe(payload)));row.set('payload',clean);row.set('sourceRefreshedAt',new Date(refreshedAt||Date.now()));row.set('expiresAt',new Date(Date.now()+REALTIME_CACHE_TTL_MS));await row.save(null,{useMasterKey:true});
  3223. }catch(_){}
  3224. }
  3225. async function liveAppointmentRows(input) {
  3226. const from=text(input.dateFrom||new Date().toISOString().slice(0,10)),config=await legacyScheduleConfig();
  3227. if(!config.url||!config.apiId||!config.apiKey)return{available:false,message:'实时排课数据源尚未配置',items:[],total:0,allItems:[]};
  3228. try {
  3229. const overall=await legacyScheduleRequest(config,{nid:29,dateFilter:'2000-01-01',psize:1,cpage:1}),overallProjection=overall.items.length?appointmentProjection({...overall.items[0],targetId:'legacy-latest',generalId:number(overall.items[0].GeneralID||overall.items[0].generalId),studentRealName:overall.items[0].Title,courseTitle:overall.items[0].Subtitle,commonStatus:99,commonUpdatedAt:overall.items[0].CreateTime,source:'legacy-live'}):null,raw=[],pageSize=1000;let total=number(overall.page.itemCount),pageCount=1;
  3230. for(let page=1;page<=Math.min(20,pageCount);page++){const result=await legacyScheduleRequest(config,{nid:29,dateFilter:from,psize:pageSize,cpage:page});raw.push(...result.items);total=Math.max(total,number(result.page.itemCount),raw.length);pageCount=Math.max(1,number(result.page.pageCount,Math.ceil(total/pageSize)));if(result.items.length<pageSize&&page>=pageCount)break;}
  3231. const seen=new Set(),allItems=[];
  3232. for(const row of raw){const generalId=number(row.GeneralID||row.generalId),key=generalId?'general:'+generalId:[row.yysj,row.szyh,row.pl,row.kcid].map(text).join('|');if(seen.has(key))continue;seen.add(key);allItems.push(appointmentProjection({...row,targetId:'legacy-live:'+generalId,generalId,studentRealName:row.Title,courseTitle:row.Subtitle,commonStatus:99,commonUpdatedAt:row.CreateTime,source:'legacy-live'}));}
  3233. return{available:true,message:'',items:allItems,total,allItems,latestAt:text(overallProjection&&overallProjection.startsAt)};
  3234. } catch(error) { return{available:false,message:'实时排课数据暂时读取失败,当前显示迁移数据库中的备用数据',items:[],total:0,allItems:[]}; }
  3235. }
  3236. async function cachedLiveAppointmentRows(context,input,dateFrom,force=false){
  3237. const key='appointments:'+dateFrom,cached=await realtimeCacheRead(context,input,key);if(cached&&!force&&cached.age<REALTIME_CACHE_TTL_MS)return{...cached.payload,available:true,message:'旧系统排课快照',refreshedAt:cached.refreshedAt,cacheHit:true};
  3238. const live=await liveAppointmentRows({dateFrom});if(live.available){const refreshedAt=new Date().toISOString(),payload={...live,refreshedAt};await realtimeCacheWrite(context,input,key,payload,refreshedAt);return{...payload,cacheHit:false};}
  3239. if(cached)return{...cached.payload,available:true,message:'旧系统暂时不可用,显示最近排课快照',refreshedAt:cached.refreshedAt,cacheHit:true,stale:true};return live;
  3240. }
  3241. function payrollDateIso(value) {
  3242. const raw=text(value);if(!raw||raw.startsWith('{')||raw.startsWith('[')||!/^\d{4}-\d{1,2}-\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?/.test(raw))return'';
  3243. const normalized=raw.replace(/^(\d{4})-(\d)(?=-)/,'$1-0$2').replace(/-(\d)(?=[ T])/,'-0$1').replace(' ','T');
  3244. const parsed=new Date(normalized+(normalized.includes('T')&&!/[zZ]|[+-]\d{2}:?\d{2}$/.test(normalized)?'+08:00':''));
  3245. return Number.isNaN(parsed.getTime())?'':parsed.toISOString();
  3246. }
  3247. function payrollLessonAt(row) { return payrollDateIso(row.lessonAt)||payrollDateIso(row.plpjsj)||payrollDateIso(row.sourceCreatedAt)||payrollDateIso(row.commonCreatedAt)||payrollDateIso(row.CreateTime)||payrollDateIso(row.appointmentEnd); }
  3248. function payrollSourceProjection(row,source='legacy-live') {
  3249. const legacyGeneralId=number(row.legacyGeneralId||row.GeneralID||row.generalId),lessonAt=payrollLessonAt(row),coachId=number(row.coachId||row.jsmz),studentName=text(row.studentName||row.Title||row.xymz)||'未记录学员',coachName=text(row.coachName||row.plxm||row.jsmc)||(coachId?'陪练 '+coachId:'未识别陪练'),courseName=text(row.courseName||row.Subtitle||row.kcmc)||'未记录课程';
  3250. return {objectId:source==='legacy-live'?'legacy:'+legacyGeneralId:text(row.lessonObjectId||row.objectId),legacyGeneralId,lessonAt,sourceCreatedAt:payrollDateIso(row.sourceCreatedAt||row.commonCreatedAt||row.CreateTime),studentName,studentId:number(row.studentId||row.xymz),coachId,coachName,coachObjectId:text(row.coachObjectId),courseId:number(row.courseId||row.kcid),courseName,storeId:number(row.storeId||row.szmdid),classType:number(row.classType||row.kclx),appointmentGeneralId:number(row.appointmentGeneralId||row.yyds),score:number(row.score||row.pf),comment:text(row.comment||row.pjnr||row.pldp),courseCategoryKey:text(row.courseCategoryKey),courseCategoryName:text(row.courseCategoryName),durationMinutes:number(row.durationMinutes),deliveryMode:deliveryMode(row.deliveryMode||row.appointmentDeliveryMode||row.jffs),teachingRuleId:text(row.teachingRuleId),teacherPaySnapshot:row.teacherPaySnapshot==null?null:number(row.teacherPaySnapshot),source};
  3251. }
  3252. async function livePayrollLessons(month) {
  3253. const config=await legacyScheduleConfig();if(!config.url||!config.apiId||!config.apiKey)return{available:false,message:'旧系统上课记录接口尚未配置',items:[],total:0,refreshedAt:''};
  3254. try{
  3255. const raw=[],pageSize=1000;let action='e_plrecord_list',first;try{first=await legacyScheduleRequest(config,{action,nid:296,myfield:'plpjsj='+month,psize:pageSize,cpage:1});}catch(_){action='content_list';first=await legacyScheduleRequest(config,{action,nid:296,myfield:'plpjsj='+month,psize:pageSize,cpage:1});}const total=number(first.page.itemCount,first.items.length),pageCount=Math.max(1,number(first.page.pageCount,Math.ceil(total/pageSize)));raw.push(...first.items);
  3256. for(let page=2;page<=pageCount;page++){const result=await legacyScheduleRequest(config,{action,nid:296,myfield:'plpjsj='+month,psize:pageSize,cpage:page});raw.push(...result.items);if(result.items.length<pageSize&&page>=pageCount)break;}
  3257. const seen=new Set(),items=[];for(const row of raw){const item=payrollSourceProjection(row);if(!item.legacyGeneralId||seen.has(item.legacyGeneralId))continue;seen.add(item.legacyGeneralId);if(item.lessonAt.slice(0,7)===month)items.push(item);}
  3258. return{available:true,message:'旧系统实时上课记录已同步',items,total,refreshedAt:new Date().toISOString()};
  3259. }catch(error){return{available:false,message:'旧系统实时上课记录暂时不可用:'+text(error&&error.message),items:[],total:0,refreshedAt:''};}
  3260. }
  3261. async function cachedLivePayrollLessons(context,input,month,force=false){
  3262. const key='payroll:'+month,cached=await realtimeCacheRead(context,input,key);if(cached&&!force&&cached.age<REALTIME_CACHE_TTL_MS)return{...cached.payload,available:true,message:'旧系统实时上课记录快照',refreshedAt:cached.refreshedAt,cacheHit:true};
  3263. const live=await livePayrollLessons(month);if(live.available){await realtimeCacheWrite(context,input,key,live,live.refreshedAt);return{...live,cacheHit:false};}
  3264. if(cached)return{...cached.payload,available:true,message:'旧系统暂时不可用,显示最近上课记录快照',refreshedAt:cached.refreshedAt,cacheHit:true,stale:true};return live;
  3265. }
  3266. async function cachedPayrollLessons(context,input,month) {
  3267. const companyId=companyIdOf(context,input),raw=await Psql.query('SELECT l.*,c."objectId" AS "lessonObjectId",c."generalId",c."createdAt" AS "commonCreatedAt",a."jssj" AS "appointmentEnd",a."courseCategoryKey",a."courseCategoryName",a."durationMinutes",a."deliveryMode" AS "appointmentDeliveryMode",a."teachingRuleId",a."teacherPaySnapshot",a."jffs" AS "appointmentJffs",u."realName" AS "coachRealName",u."nickname" AS "coachNickname",u."username" AS "coachUsername",u."legacyUserData" AS "coachLegacy" FROM "LessonRecordFact" l LEFT JOIN "CommonModel" c ON c."company"=l."company" AND CAST(c."modelId" AS text)=\'59\' AND CAST(c."generalId" AS text)=CAST(l."legacyGeneralId" AS text) LEFT JOIN "CommonModel" ac ON ac."company"=l."company" AND CAST(ac."modelId" AS text)=\'54\' AND CAST(ac."generalId" AS text)=CAST(l."yyds" AS text) LEFT JOIN "CourseAppointment" a ON a."company"=l."company" AND CAST(a."id" AS text)=CAST(ac."itemId" AS text) LEFT JOIN "_User" u ON u."company"=l."company" AND COALESCE(CAST(u."legacyUserId" AS text),u."legacyUserData"->>\'UserID\')=CAST(l."jsmz" AS text) WHERE ($1::text IS NULL OR l."company"=$1)',[companyId]);
  3268. return raw.map((row)=>{const legacy=legacyOf({legacyUserData:row.coachLegacy});return payrollSourceProjection({...row,legacyGeneralId:row.legacyGeneralId||row.generalId,coachName:row.coachName||row.coachRealName||row.coachNickname||legacy.HoneyName||row.coachUsername,studentName:row.studentName||row.xymz,courseName:row.courseName||row.kcmc});},'cache').filter((row)=>row.lessonAt.slice(0,7)===month);
  3269. }
  3270. async function payrollSourceRows(context,input,month,requireLive=false) { const parseItems=await cachedPayrollLessons(context,input,month),live=requireLive?await cachedLivePayrollLessons(context,input,month,true):null,items=live&&live.available?live.items:parseItems;if(requireLive&&(!live||!live.available||live.stale))fail(503,'旧系统增量同步尚未可验证,当前禁止生成或审核工资单');const companyId=companyIdOf(context,input),users=await Psql.query('SELECT u.*,p."realName" AS "storeRealName",p."nickname" AS "storeNickname",p."username" AS "storeUsername",p."legacyUserData" AS "storeLegacy" FROM "_User" u LEFT JOIN "_User" p ON p."company"=u."company" AND COALESCE(CAST(p."legacyUserId" AS text),p."legacyUserData"->>\'UserID\')=COALESCE(u."legacyUserData"->>\'ParentUserID\',\'0\') WHERE ($1::text IS NULL OR u."company"=$1)',[companyId]).catch(()=>[]),userMap=new Map(users.map((row)=>[number(row.legacyUserId||legacyOf(row).UserID),row]));for(const item of items){const coach=userMap.get(item.coachId);if(!coach)continue;const legacy=legacyOf(coach),storeLegacy=legacyOf({legacyUserData:coach.storeLegacy});item.coachObjectId=text(coach.objectId);item.coachName=displayName(coach);item.coachMobile=mobileOf(coach);item.storeId=item.storeId||number(legacy.ParentUserID);item.storeName=text(coach.storeRealName||coach.storeNickname||storeLegacy.HoneyName||coach.storeUsername)||'未绑定门店';}const sourceStatus=live&&live.available?{available:true,mode:'live',message:live.message,total:live.total,refreshedAt:live.refreshedAt,cacheHit:live.cacheHit===true,stale:live.stale===true}:{available:true,mode:'parse',message:'新数据库上课记录投影',total:items.length,refreshedAt:new Date().toISOString(),cacheHit:false,stale:false};return{items,sourceStatus}; }
  3271. async function newClassObjects(className,context,input,limit=1000) { try { const fields=await fieldsOf(className).catch(()=>({})); if(!Object.keys(fields).length)return[]; const query=new Parse.Query(className); const company=companyPointer(context,input); if(company&&fields.company) query.equalTo('company',company); query.limit(limit); query.descending(fields.createdAt?'createdAt':'objectId'); return await query.find({useMasterKey:true}); } catch(error) { if(error.code===100||error.code===103||/class|schema|column .* does not exist/i.test(text(error.message))) return []; throw error; } }
  3272. async function availabilityRows(context,input,coachId=0) { const rows=await newClassObjects('CoachAvailability',context,input,1000); return rows.filter((row)=>!coachId||number(row.get('coachId'))===coachId).map((row)=>({objectId:row.id,coachId:number(row.get('coachId')),ruleType:text(row.get('ruleType'))||'weekly',weekday:number(row.get('weekday')),date:text(row.get('date')),startTime:text(row.get('startTime')),endTime:text(row.get('endTime')),available:row.get('available')!==false,reason:text(row.get('reason')),updatedAt:dateIso(row.updatedAt)})); }
  3273. const TEACHING_STAGE_DEFAULTS=[{key:'primary',name:'小学'},{key:'middle',name:'初中'},{key:'high',name:'高中'}];
  3274. const TEACHING_CATEGORY_DEFAULTS=[
  3275. {key:'word',name:'单词课',sortOrder:10},{key:'trial',name:'体验课',sortOrder:20},{key:'primary_writing',name:'小学语法写作课',sortOrder:30,stageKey:'primary'},{key:'middle_writing',name:'中学语法写作课',sortOrder:40,stageKey:'middle'},{key:'high_writing',name:'高中语法写作课',sortOrder:50,stageKey:'high'},{key:'word_self_study',name:'单词自学课',sortOrder:60}
  3276. ];
  3277. const TEACHING_CATEGORY_LABELS=new Map(TEACHING_CATEGORY_DEFAULTS.map((row)=>[row.key,row.name]));
  3278. const TEACHING_RETIRED_CATEGORIES=new Set(['primary_intensive','middle_intensive','high_intensive','review']);
  3279. const TEACHING_CREDIT_LABELS={Purse:'30分钟课时',SilverCoin:'60分钟课时',UserExp:'体验课时',UserPoint:'陪练时长'};
  3280. function deliveryMode(value){const raw=text(value).toLowerCase();return raw==='offline'||/\u7ebf\u4e0b|\u5230\u5e97|\u9762\u6388/.test(raw)?'offline':'online';}
  3281. function deliveryLabel(value){return deliveryMode(value)==='offline'?'线下':'线上';}
  3282. function teachingCredits(categoryKey,duration){if(categoryKey==='trial')return[{account:'UserExp',label:TEACHING_CREDIT_LABELS.UserExp,amount:1}];if(categoryKey==='review')return[{account:'UserPoint',label:TEACHING_CREDIT_LABELS.UserPoint,amount:duration===30?.5:1}];return duration===30?[{account:'Purse',label:TEACHING_CREDIT_LABELS.Purse,amount:1},{account:'UserPoint',label:TEACHING_CREDIT_LABELS.UserPoint,amount:.5}]:[{account:'SilverCoin',label:TEACHING_CREDIT_LABELS.SilverCoin,amount:1},{account:'UserPoint',label:TEACHING_CREDIT_LABELS.UserPoint,amount:1}];}
  3283. function defaultTeachingRules(){const versions=[{effectiveFrom:'2026-09-01',rows:[['word',30,15,20],['word',60,30,40],['trial',60,40,40],['primary_writing',60,40,50],['middle_writing',60,50,60],['high_writing',60,65,75]]},{effectiveFrom:'2026-09-09',rows:[['word',30,15,20],['word',60,30,40],['trial',60,30,40],['primary_writing',30,20,25],['middle_writing',30,25,30],['high_writing',30,32.5,37.5],['primary_writing',60,40,50],['middle_writing',60,50,60],['high_writing',60,65,75],['word_self_study',60,20,30]]}],result=[];for(const version of versions)for(const [categoryKey,duration,online,offline] of version.rows)for(const [mode,pay] of [['online',online],['offline',offline]])result.push({objectId:'seed:'+categoryKey+':'+duration+':'+mode+':'+version.effectiveFrom,categoryKey,categoryName:TEACHING_CATEGORY_LABELS.get(categoryKey)||categoryKey,durationMinutes:duration,deliveryMode:mode,deliveryLabel:deliveryLabel(mode),teacherPay:pay,creditCosts:teachingCredits(categoryKey,duration),effectiveFrom:version.effectiveFrom,active:true,isDefault:true,updatedAt:''});return result;}
  3284. async function teachingCategoryRows(context,input){const stored=await newClassObjects('TeachingCourseCategory',context,input,1000),byKey=new Map(stored.map((row)=>[text(row.get('key')),row]));return TEACHING_CATEGORY_DEFAULTS.map((seed)=>{const row=byKey.get(seed.key);return{objectId:row?row.id:'seed-category:'+seed.key,key:seed.key,name:seed.name,sortOrder:seed.sortOrder,active:true,system:true,updatedAt:row?dateIso(row.updatedAt):''};});}
  3285. function teachingRuleProjection(row,categoryNames){const source=row&&typeof row.get==='function'?row.toJSON():row,categoryKey=text(source.categoryKey),mode=deliveryMode(source.deliveryMode),duration=number(source.durationMinutes),costs=Array.isArray(source.creditCosts)?source.creditCosts.map((cost)=>({account:text(cost.account),label:TEACHING_CREDIT_LABELS[text(cost.account)]||text(cost.label)||text(cost.account),amount:number(cost.amount)})).filter((cost)=>cost.account&&cost.amount>0):teachingCredits(categoryKey,duration);return{objectId:row.id||text(source.objectId),categoryKey,categoryName:categoryNames.get(categoryKey)||TEACHING_CATEGORY_LABELS.get(categoryKey)||text(source.categoryName)||categoryKey,durationMinutes:duration,deliveryMode:mode,deliveryLabel:deliveryLabel(mode),teacherPay:number(source.teacherPay),creditCosts:costs,effectiveFrom:text(source.effectiveFrom),active:source.active!==false,isDefault:source.isDefault===true,updatedAt:dateIso(row.updatedAt||source.updatedAt)};}
  3286. async function teachingRuleRows(context,input){const categories=await teachingCategoryRows(context,input),names=new Map(categories.map((row)=>[row.key,row.name])),allowed=new Set(categories.map((row)=>row.key)),stored=(await newClassObjects('TeachingCompensationRule',context,input,2000)).map((row)=>teachingRuleProjection(row,names)).filter((row)=>allowed.has(row.categoryKey)),storedKeys=new Set(stored.map((row)=>[row.categoryKey,row.durationMinutes,row.deliveryMode,row.effectiveFrom].join(':'))),seed=defaultTeachingRules().map((row)=>teachingRuleProjection(row,names)).filter((row)=>!storedKeys.has([row.categoryKey,row.durationMinutes,row.deliveryMode,row.effectiveFrom].join(':')));return seed.concat(stored).sort((a,b)=>a.categoryName.localeCompare(b.categoryName,'zh-CN')||a.durationMinutes-b.durationMinutes||a.deliveryMode.localeCompare(b.deliveryMode)||b.effectiveFrom.localeCompare(a.effectiveFrom));}
  3287. function teachingRuleFor(rules,categoryKey,duration,mode,date){return rules.filter((row)=>row.active&&row.categoryKey===categoryKey&&row.durationMinutes===number(duration)&&row.deliveryMode===deliveryMode(mode)&&row.effectiveFrom<=date.slice(0,10)).sort((a,b)=>b.effectiveFrom.localeCompare(a.effectiveFrom)||Number(a.isDefault)-Number(b.isDefault))[0]||null;}
  3288. function teachingStageFromDatabase(names){const value=(Array.isArray(names)?names:[names]).map(text).join(' ');if(/\u9ad8\u4e2d|\u9ad8[\u4e00\u4e8c\u4e09]|\u5341[\u4e00\u4e8c]\u5e74\u7ea7/.test(value))return'high';if(/\u521d\u4e2d|\u521d[\u4e00\u4e8c\u4e09]|[\u4e03\u516b\u4e5d]\u5e74\u7ea7/.test(value))return'middle';if(/\u5c0f\u5b66|[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d]\u5e74\u7ea7/.test(value))return'primary';return'';}
  3289. function courseStageFromDatabase(names){const values=(Array.isArray(names)?names:[names]).map(text).filter(Boolean),key=teachingStageFromDatabase(values),canonical=new Map(TEACHING_STAGE_DEFAULTS.map((row)=>[row.key,row.name]));if(key)return{key,name:canonical.get(key)||''};const parentName=values[1]||'';return parentName?{key:'other',name:parentName}:{key:'',name:''};}
  3290. function isWritingCourse(name){return /语法|写作|作文|强化/.test(text(name));}
  3291. function suggestedTeachingCategory(name,stageKey){const value=text(name);if(/自学/.test(value))return'word_self_study';if(/体验/.test(value))return'trial';if(isWritingCourse(value))return ['primary','middle','high'].includes(stageKey)?stageKey+'_writing':'';return'word';}
  3292. function categoryMatchesStage(categoryKey,stageKey){const required=TEACHING_CATEGORY_DEFAULTS.find((row)=>row.key===categoryKey)?.stageKey;return !required||required===stageKey;}
  3293. async function courseTeachingRows(context,input){const companyId=companyIdOf(context,input),courseSql='WITH RECURSIVE courses AS (SELECT DISTINCT n."company",n."nodeId",n."parentId",n."nodeName" FROM "Node" n WHERE ($1::text IS NULL OR n."company"=$1) AND (CAST(n."nodeId" AS text)=\'398\' OR EXISTS(SELECT 1 FROM "CourseBinding" b WHERE b."company"=n."company" AND CAST(b."kcid" AS text)=CAST(n."nodeId" AS text)) OR EXISTS(SELECT 1 FROM "CourseAppointment" a WHERE a."company"=n."company" AND CAST(a."kcid" AS text)=CAST(n."nodeId" AS text)))), ancestors AS (SELECT c."company",c."nodeId" AS course_id,c."nodeId",c."parentId",c."nodeName",0 AS depth FROM courses c UNION ALL SELECT a."company",a.course_id,p."nodeId",p."parentId",p."nodeName",a.depth+1 FROM ancestors a JOIN "Node" p ON p."company"=a."company" AND CAST(p."nodeId" AS text)=CAST(a."parentId" AS text) WHERE a.depth<12) SELECT CAST(c."nodeId" AS text) AS id,c."nodeName" AS name,COALESCE(ARRAY_AGG(a."nodeName" ORDER BY a.depth) FILTER(WHERE COALESCE(a."nodeName",\'\')<>\'\'),ARRAY[]::text[]) AS ancestor_names FROM courses c LEFT JOIN ancestors a ON a."company"=c."company" AND a.course_id=c."nodeId" GROUP BY c."nodeId",c."nodeName" ORDER BY c."nodeName" LIMIT 2000',[categories,stored,courses,legacyCourses]=await Promise.all([teachingCategoryRows(context,input),newClassObjects('CourseTeachingProfile',context,input,2000),Psql.query(courseSql,[companyId]),Psql.query("SELECT CAST(a.\"kcid\" AS text) AS id,MAX(COALESCE(NULLIF(c.\"subtitle\",''),'课程 '||a.\"kcid\"::text)) AS name,ARRAY[MAX(COALESCE(NULLIF(c.\"subtitle\",''),'课程 '||a.\"kcid\"::text))] AS ancestor_names FROM \"CommonModel\" c JOIN \"CourseAppointment\" a ON a.\"company\"=c.\"company\" AND a.\"id\"::text=c.\"itemId\"::text WHERE ($1::text IS NULL OR c.\"company\"=$1) AND c.\"modelId\"=54 AND COALESCE(c.\"status\",99)<>-2 AND COALESCE(a.\"kcid\"::text,'') ~ '^[0-9]+$' AND a.\"kcid\"::text<>'0' AND NOT EXISTS(SELECT 1 FROM \"Node\" n WHERE n.\"company\"=a.\"company\" AND n.\"nodeId\"::text=a.\"kcid\"::text) GROUP BY a.\"kcid\" ORDER BY a.\"kcid\" LIMIT 2000",[companyId])]),names=new Map(categories.map((row)=>[row.key,row.name])),activeCategories=new Set(categories.map((row)=>row.key)),profileByCourse=new Map(stored.map((row)=>[number(row.get('courseId')),row]));return [...courses,...legacyCourses].map((course)=>{const courseId=number(course.id),profile=profileByCourse.get(courseId),stage=courseStageFromDatabase(course.ancestor_names),stageKey=stage.key,suggestedCategoryKey=suggestedTeachingCategory(course.name,stageKey),categoryKey=profile?text(profile.get('categoryKey')):'',categoryConfirmed=Boolean(profile&&profile.get('confirmed')===true&&activeCategories.has(categoryKey)),stageConfirmed=Boolean(stageKey&&stage.name),confirmed=categoryConfirmed&&categoryMatchesStage(categoryKey,stageKey)&&(!isWritingCourse(course.name)||stageConfirmed);return{objectId:profile?profile.id:'',courseId,courseName:text(course.name)||'课程 '+courseId,stageKey,stageName:stage.name,categoryKey,categoryName:names.get(categoryKey)||'',suggestedCategoryKey,suggestedCategoryName:names.get(suggestedCategoryKey)||suggestedCategoryKey,categoryConfirmed,stageConfirmed,confirmed,updatedAt:profile?dateIso(profile.updatedAt):''};});}
  3294. async function teachingStandardsData(context,input){requireRole(context,['ops-manager','ops-staff','ops-auditor']);const [categories,rules,mappings]=await Promise.all([teachingCategoryRows(context,input),teachingRuleRows(context,input),courseTeachingRows(context,input)]);return{categories,rules,mappings,summary:{courseCount:mappings.length,confirmedCount:mappings.filter((row)=>row.confirmed).length,unmappedCount:mappings.filter((row)=>!row.confirmed).length,activeRuleCount:rules.filter((row)=>row.active).length},refreshedAt:new Date().toISOString()};}
  3295. async function saveTeachingCategory(context,input,payload){requireRole(context,['ops-manager']);const key=text(payload.key),seed=TEACHING_CATEGORY_DEFAULTS.find((row)=>row.key===key);if(!seed||cleanText(payload.name,80)!==seed.name||payload.active===false)fail(400,'课程分类由当前价目表固定,不能新增、改名或停用');return teachingCategoryRows(context,input).then((rows)=>rows.find((row)=>row.key===key));}
  3296. async function saveTeachingRule(context,input,payload){requireRole(context,['ops-manager']);const reason=reasonOf(input),categoryKey=text(payload.categoryKey),duration=number(payload.durationMinutes),mode=deliveryMode(payload.deliveryMode),pay=number(payload.teacherPay,-1),effectiveFrom=text(payload.effectiveFrom),categories=await teachingCategoryRows(context,input);if(TEACHING_RETIRED_CATEGORIES.has(categoryKey)||!categories.some((row)=>row.key===categoryKey&&row.active))fail(400,'课程分类无效或已停用');const allowedDurations=new Map([['word',[30,60]],['trial',[60]],['primary_writing',[30,60]],['middle_writing',[30,60]],['high_writing',[30,60]],['word_self_study',[60]]]);if(!(allowedDurations.get(categoryKey)||[]).includes(duration)||pay<0||!/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(effectiveFrom))fail(400,'该课程分类不支持所选时长,或工资、生效日期无效');const allowed=new Set(['Purse','SilverCoin','UserExp','UserPoint']),creditCosts=(Array.isArray(payload.creditCosts)?payload.creditCosts:teachingCredits(categoryKey,duration)).map((cost)=>({account:text(cost.account),amount:number(cost.amount)})).filter((cost)=>allowed.has(cost.account)&&cost.amount>0);if(!creditCosts.length)fail(400,'至少配置一项课时扣减');const company=companyPointer(context,input),rows=await newClassObjects('TeachingCompensationRule',context,input,2000),existing=rows.find((row)=>text(row.get('categoryKey'))===categoryKey&&number(row.get('durationMinutes'))===duration&&deliveryMode(row.get('deliveryMode'))===mode&&text(row.get('effectiveFrom'))===effectiveFrom),target=existing||new Parse.Object('TeachingCompensationRule'),before=existing?safe(existing):null;target.set('company',company);target.set('sourceKey','cloud:teaching-rule:'+companyIdOf(context,input)+':'+categoryKey+':'+duration+':'+mode+':'+effectiveFrom);target.set('categoryKey',categoryKey);target.set('durationMinutes',duration);target.set('deliveryMode',mode);target.set('teacherPay',pay);target.set('creditCosts',creditCosts);target.set('effectiveFrom',effectiveFrom);target.set('active',payload.active!==false);await target.save(null,{useMasterKey:true});await audit(context,'save-teaching-rule','TeachingCompensationRule',target.id,reason,before,safe(target));return teachingRuleProjection(target,new Map(categories.map((row)=>[row.key,row.name])));}
  3297. async function deactivateTeachingRule(context,input){requireRole(context,['ops-manager']);const reason=reasonOf(input),target=await scopedObject('TeachingCompensationRule',text(input.targetId),context,input);assertVersion(target,input.expectedUpdatedAt);const before=safe(target);target.set('active',false);await target.save(null,{useMasterKey:true});await audit(context,'deactivate-teaching-rule','TeachingCompensationRule',target.id,reason,before,safe(target));return{objectId:target.id,active:false,updatedAt:dateIso(target.updatedAt)};}
  3298. async function syncConfirmedCourseBindings(context,input,courseId,categoryKey,categoryName){const companyId=companyIdOf(context,input),sql='WITH candidates AS (SELECT b."objectId",EXISTS(SELECT 1 FROM "CommonModel" c WHERE c."company"=b."company" AND c."modelId"=58 AND CAST(c."itemId" AS text)=CAST(b."id" AS text) AND c."status"=99) AND EXISTS(SELECT 1 FROM "Node" n WHERE n."company"=b."company" AND CAST(n."nodeId" AS text)=CAST(b."kcid" AS text) AND COALESCE(n."zstatus",99)=99) AND EXISTS(SELECT 1 FROM "_User" u WHERE u."company"=b."company" AND CAST(u."legacyUserId" AS text)=CAST(b."yhid" AS text) AND COALESCE(u."isDisabled",FALSE)=FALSE AND COALESCE(u."isDeleted",FALSE)=FALSE) AND NOT EXISTS(SELECT 1 FROM "CourseBinding" other WHERE other."company"=b."company" AND CAST(other."yhid" AS text)=CAST(b."yhid" AS text) AND CAST(other."kcid" AS text)=CAST(b."kcid" AS text) AND other."objectId"<>b."objectId" AND COALESCE(other."enrollmentStatus",\'\')<>\'recycled\' AND (other."categoryKey" IS NULL OR other."categoryKey"=$3)) AS can_activate FROM "CourseBinding" b WHERE b."company"=$1 AND CAST(b."kcid" AS text)=CAST($2 AS text) AND (b."categoryKey" IS NULL OR b."categoryKey"=$3) AND COALESCE(b."enrollmentStatus",\'pending\')=\'pending\') UPDATE "CourseBinding" b SET "categoryKey"=$3,"categoryName"=$4,"enrollmentStatus"=CASE WHEN candidates.can_activate THEN \'active\' ELSE \'pending\' END,"updatedAt"=NOW() FROM candidates WHERE b."objectId"=candidates."objectId" RETURNING b."objectId",b."enrollmentStatus"';return Psql.query(sql,[companyId,courseId,categoryKey,categoryName]);}
  3299. async function commitCourseMappings(context,input,payload){requireRole(context,['ops-manager']);const reason=reasonOf(input),items=Array.isArray(payload.items)?payload.items:[],categories=await teachingCategoryRows(context,input),allowed=new Set(categories.map((row)=>row.key)),courses=await courseTeachingRows(context,input),courseById=new Map(courses.map((row)=>[row.courseId,row])),company=companyPointer(context,input),existing=await newClassObjects('CourseTeachingProfile',context,input,2000),byCourse=new Map(existing.map((row)=>[number(row.get('courseId')),row])),saved=[],activatedBindings=[];if(!items.length)fail(400,'请选择要确认的课程映射');for(const item of items){const courseId=number(item.courseId),categoryKey=text(item.categoryKey),course=courseById.get(courseId),stageKey=text(course&&course.stageKey);if(!courseId||!allowed.has(categoryKey))fail(400,'课程或课程分类无效');if(!course)fail(400,'未找到该课程');if(!stageKey&&isWritingCourse(course.courseName))fail(409,'语法写作课程缺少学段,请按学员学段分别确认预约分类');if(!categoryMatchesStage(categoryKey,stageKey))fail(400,'语法写作分类必须与课程数据库中的大类一致');const target=byCourse.get(courseId)||new Parse.Object('CourseTeachingProfile'),before=target.id?safe(target):null,categoryName=categories.find((row)=>row.key===categoryKey)?.name||categoryKey;target.set('company',company);target.set('sourceKey','cloud:course-teaching:'+companyIdOf(context,input)+':'+courseId);target.set('courseId',courseId);target.set('stageKey',stageKey);target.set('categoryKey',categoryKey);target.set('confirmed',true);target.set('confirmedBy',context.current.id);target.set('confirmedAt',new Date());await target.save(null,{useMasterKey:true});const synced=await syncConfirmedCourseBindings(context,input,courseId,categoryKey,categoryName);await audit(context,'confirm-course-teaching','CourseTeachingProfile',target.id,reason,before,{...safe(target),bindingsActivated:synced.filter((row)=>row.enrollmentStatus==='active').length});saved.push(target.id);activatedBindings.push(...synced.filter((row)=>row.enrollmentStatus==='active').map((row)=>text(row.objectId)));}return{saved:saved.length,objectIds:saved,activatedBindings};}
  3300. async function historicalReservationPreviewLegacy(context,input){requireRole(context,['ops-manager','ops-staff','ops-auditor']);const companyId=companyIdOf(context,input),[rows,mappings,rules,users,reservations]=await Promise.all([Psql.query('SELECT c."objectId" AS "appointmentId",c."generalId",c."title",c."subtitle",a.* FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND CAST(a."id" AS text)=CAST(c."itemId" AS text) WHERE ($1::text IS NULL OR c."company"=$1) AND c."modelId"=54 AND COALESCE(c."status",99)<>-2 AND CAST(COALESCE(a."dszt",\'0\') AS text) IN (\'0\',\'10\') AND (CAST(COALESCE(a."dszt",\'0\') AS text)=\'10\' OR (COALESCE(a."yysj",\'\') ~ \'^\\d{4}-\\d{2}-\\d{2}[ T]\\d{2}:\\d{2}\' AND to_timestamp(LEFT(a."yysj",16),\'YYYY-MM-DD HH24:MI\')>=NOW())) AND NOT EXISTS(SELECT 1 FROM "CourseCreditReservation" r WHERE r."company"=c."company" AND r."appointmentId"=c."objectId" AND r."state" IN (\'reserved\',\'consumed\')) ORDER BY COALESCE(a."yysj",a."bxrq") ASC',[companyId]),courseTeachingRows(context,input),teachingRuleRows(context,input),Psql.query('SELECT * FROM "_User" WHERE ($1::text IS NULL OR "company"=$1)',[companyId]),Psql.query('SELECT "studentId","creditCosts" FROM "CourseCreditReservation" WHERE ($1::text IS NULL OR "company"=$1) AND "state"=\'reserved\'',[companyId]).catch(()=>[])]),mappingByCourse=new Map(mappings.filter((row)=>row.confirmed).map((row)=>[row.courseId,row])),userById=new Map(users.map((row)=>[number(row.legacyUserId||legacyOf(row).UserID),row])),usedByStudent=new Map();for(const reservation of reservations){const studentId=number(reservation.studentId),used=usedByStudent.get(studentId)||{Purse:0,SilverCoin:0,UserExp:0,UserPoint:0};for(const cost of(Array.isArray(reservation.creditCosts)?reservation.creditCosts:[])){const account=text(cost.account);if(Object.prototype.hasOwnProperty.call(used,account))used[account]+=number(cost.amount);}usedByStudent.set(studentId,used);}const items=[];for(const row of rows){const studentId=number(row.szyh),courseId=number(row.kcid||row.yykcid),mapping=mappingByCourse.get(courseId),duration=number(row.durationMinutes)||durationMinutes(row.dslx),mode=deliveryMode(row.deliveryMode||row.jffs),startsAt=text(row.yysj||row.bxrq),date=(startsAt||new Date().toISOString()).slice(0,10),categoryKey=text(row.courseCategoryKey)||text(mapping&&mapping.categoryKey),categoryName=text(row.courseCategoryName)||text(mapping&&mapping.categoryName),storedCosts=Array.isArray(row.creditCostsSnapshot)?row.creditCostsSnapshot:[],rule=storedCosts.length?{objectId:text(row.teachingRuleId)||'legacy-snapshot',teacherPay:number(row.teacherPaySnapshot),creditCosts:storedCosts}:teachingRuleFor(rules,categoryKey,duration,mode,date),student=userById.get(studentId),used=usedByStudent.get(studentId)||{Purse:0,SilverCoin:0,UserExp:0,UserPoint:0},legacy=student?legacyOf(student):{},creditCosts=(rule&&Array.isArray(rule.creditCosts)?rule.creditCosts:[]).map((cost)=>({account:text(cost.account),label:TEACHING_CREDIT_LABELS[text(cost.account)]||text(cost.account),amount:number(cost.amount)})),shortages=creditCosts.filter((cost)=>balanceForAccount(legacy,cost.account)-number(used[cost.account])<cost.amount);let status='ready',reason='可补建预留';if(!student){status='missing_student';reason='未找到有效学员账号';}else if(!categoryKey){status='unmapped';reason='课程尚未确认标准分类';}else if(!rule||!creditCosts.length){status='missing_rule';reason='缺少适用的工资与课时规则';}else if(shortages.length){status='insufficient';reason='可用课时不足:'+shortages.map((cost)=>cost.label+'缺少 '+Math.max(0,cost.amount-(balanceForAccount(legacy,cost.account)-number(used[cost.account]))).toFixed(2).replace(/\\.00$/,'')).join(',');}if(rule&&creditCosts.length)for(const cost of creditCosts)used[cost.account]=number(used[cost.account])+cost.amount;usedByStudent.set(studentId,used);items.push({appointmentId:text(row.appointmentId),appointmentGeneralId:number(row.generalId),studentId,studentName:student?displayName(student):text(row.title)||'未识别学员',courseId,courseName:text(row.subtitle)||'课程 '+courseId,startsAt,categoryKey,categoryName,durationMinutes:duration,deliveryMode:mode,deliveryLabel:deliveryLabel(mode),teachingRuleId:text(rule&&rule.objectId),teacherPay:number(rule&&rule.teacherPay),creditCosts,status,statusLabel:({ready:'可补建',insufficient:'待补课时',unmapped:'课程未映射',missing_rule:'规则缺失',missing_student:'学员缺失'})[status],reason});}return{items,summary:{total:items.length,ready:items.filter((item)=>item.status==='ready').length,insufficient:items.filter((item)=>item.status==='insufficient').length,exceptions:items.filter((item)=>!['ready','insufficient'].includes(item.status)).length},refreshedAt:new Date().toISOString()};}
  3301. async function commitHistoricalReservations(context,input,payload){requireRole(context,['ops-manager']);const reason=reasonOf(input),preview=await historicalReservationPreview(context,input),selected=new Set((Array.isArray(payload.appointmentIds)?payload.appointmentIds:[]).map(text)),items=preview.items.filter((item)=>!selected.size||selected.has(item.appointmentId)||selected.has(String(item.appointmentGeneralId))),saved=[],held=[];if(!items.length)fail(400,'没有可处理的历史预约');for(const item of items){const pair=await appointmentPair(context,input,item.appointmentId),before=safe(pair.addon);pair.addon.set('creditHoldStatus',item.status);pair.addon.set('creditHoldReason',item.reason);if(item.categoryKey){pair.addon.set('courseCategoryKey',item.categoryKey);pair.addon.set('courseCategoryName',item.categoryName);pair.addon.set('durationMinutes',item.durationMinutes);pair.addon.set('deliveryMode',item.deliveryMode);}if(item.teachingRuleId&&item.creditCosts.length){pair.addon.set('teachingRuleId',item.teachingRuleId);pair.addon.set('teacherPaySnapshot',item.teacherPay);pair.addon.set('creditCostsSnapshot',item.creditCosts);}await pair.addon.save(null,{useMasterKey:true});if(item.status==='ready'){await reserveAppointmentCredits(context,input,pair.common,pair.addon,{objectId:item.teachingRuleId,creditCosts:item.creditCosts});saved.push(item);}else held.push(item);await audit(context,'reconcile-appointment-reservation','CourseAppointment',pair.addon.id,reason,before,{creditHoldStatus:item.status,creditHoldReason:item.reason,teachingRuleId:item.teachingRuleId,creditCosts:item.creditCosts});}return{reserved:saved.length,held:held.length,items:[...saved,...held]};}
  3302. async function lessonFeedbackDetail(context,input){requireRole(context,['ops-manager','ops-staff','ops-auditor']);const companyId=companyIdOf(context,input),target=text(input.targetId||input.appointmentId||input.lessonId);if(!target)fail(400,'缺少预约或上课记录 ID');const row=await Psql.oneOrNone('SELECT * FROM "LessonCompletionReport" WHERE ($1::text IS NULL OR "company"=$1) AND ("objectId"=$2 OR "appointmentId"=$2 OR CAST("appointmentGeneralId" AS text)=$2 OR "lessonId"=$2) ORDER BY "updatedAt" DESC LIMIT 1',[companyId,target]);if(!row)fail(404,'尚未提交配置化完课反馈');return{...safe(row),deliveryLabel:deliveryLabel(row.deliveryMode),creditCosts:(Array.isArray(row.creditCosts)?row.creditCosts:[]).map((cost)=>({account:text(cost.account),label:TEACHING_CREDIT_LABELS[text(cost.account)]||text(cost.account),amount:number(cost.amount)}))};}
  3303. async function creditReconciliationPreview(context,input){requireRole(context,['ops-manager','ops-staff','ops-auditor']);const companyId=companyIdOf(context,input),rows=await Psql.query('SELECT c."objectId" AS "appointmentId",c."generalId",a."szyh" AS "studentId",a."yysj",a."durationMinutes",a."creditCostsSnapshot",COALESCE((SELECT SUM(ABS(l."score")) FROM "UserUserPoint" l WHERE l."company"=c."company" AND (l."sourceKey"=\'cloud:e_order_update:\'||CAST(c."generalId" AS text)||\':period\' OR l."sourceKey"=\'cloud:e_order_update:\'||CAST(c."generalId" AS text)||\':configured:UserPoint\' OR l."sourceKey"=\'cloud:ops-complete:\'||c."objectId"||\':point\')),0) AS charged FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND CAST(a."id" AS text)=CAST(c."itemId" AS text) WHERE ($1::text IS NULL OR c."company"=$1) AND c."modelId"=54 AND a."courseCategoryKey"=\'review\' AND LEFT(COALESCE(a."yysj",a."bxrq"),10)>=\'2026-09-01\' AND CAST(COALESCE(a."dszt",\'0\') AS numeric)>=11 ORDER BY COALESCE(a."yysj",a."bxrq") DESC',[companyId]),items=rows.map((row)=>{const expected=(Array.isArray(row.creditCostsSnapshot)?row.creditCostsSnapshot:[]).filter((cost)=>text(cost.account)==='UserPoint').reduce((sum,cost)=>sum+number(cost.amount),0)||((number(row.durationMinutes)||60)===30?.5:1),charged=number(row.charged),adjustment=Math.max(0,charged-expected);return{appointmentId:text(row.appointmentId),appointmentGeneralId:number(row.generalId),studentId:number(row.studentId),lessonAt:text(row.yysj),durationMinutes:number(row.durationMinutes)||60,expected,charged,adjustment,status:adjustment>0?'待退回':'无差异'};});return{items,summary:{completedReviewCount:items.length,differenceCount:items.filter((item)=>item.adjustment>0).length,refundTotal:items.reduce((sum,item)=>sum+item.adjustment,0)},refreshedAt:new Date().toISOString()};}
  3304. async function commitCreditReconciliation(context,input,payload){requireRole(context,['ops-manager']);const reason=reasonOf(input),preview=await creditReconciliationPreview(context,input),selected=new Set((Array.isArray(payload.appointmentIds)?payload.appointmentIds:[]).map(text)),items=preview.items.filter((item)=>item.adjustment>0&&(!selected.size||selected.has(item.appointmentId)||selected.has(String(item.appointmentGeneralId))));if(!items.length)fail(400,'没有可确认的复习课课时差异');const company=companyPointer(context,input),saved=[];for(const item of items){const sourceKey='cloud:credit-reconciliation:'+companyIdOf(context,input)+':'+item.appointmentGeneralId,existing=await Psql.oneOrNone('SELECT "objectId" FROM "UserUserPoint" WHERE "company"=$1 AND "sourceKey"=$2 LIMIT 1',[companyIdOf(context,input),sourceKey]);if(existing)continue;const row=await userByLegacyId(context,input,item.studentId,1);if(!row)continue;const user=await new Parse.Query('_User').get(row.objectId,{useMasterKey:true}),legacy={...(user.get('legacyUserData')||{})},before=number(legacy.UserPoint),log=new Parse.Object('UserUserPoint');log.set('company',company);log.set('sourceKey',sourceKey);log.set('score',item.adjustment);log.set('scoreBefore',before);log.set('userId',item.studentId);log.set('hisTime',new Date());log.set('operator',number(context.current.get('legacyUserId')||(context.current.get('legacyUserData')||{}).UserID));log.set('scoreType',4);log.set('detail','复习课课时差异退回,预约 '+item.appointmentGeneralId+',原因:'+reason);log.set('remark','运营后台课时核对');await log.save(null,{useMasterKey:true});try{legacy.UserPoint=before+item.adjustment;user.set('legacyUserData',legacy);await user.save(null,{useMasterKey:true});}catch(error){await log.destroy({useMasterKey:true}).catch(()=>undefined);throw error;}saved.push(item);await audit(context,'credit-reconciliation','CourseAppointment',item.appointmentId,reason,{charged:item.charged,expected:item.expected},{refund:item.adjustment,balanceBefore:before,balanceAfter:legacy.UserPoint});}return{committed:saved.length,refundTotal:saved.reduce((sum,item)=>sum+item.adjustment,0),items:saved};}
  3305. function defaultPayRates() { return [{objectId:'default-1',classType:1,classTypeLabel:classType(1),amount:20,durationHours:.5,effectiveFrom:'1970-01-01',active:true,isDefault:true},{objectId:'default-2',classType:2,classTypeLabel:classType(2),amount:40,durationHours:1,effectiveFrom:'1970-01-01',active:true,isDefault:true},{objectId:'default-3',classType:3,classTypeLabel:classType(3),amount:40,durationHours:1,effectiveFrom:'1970-01-01',active:true,isDefault:true}]; }
  3306. async function payRateRows(context,input) { const stored=(await newClassObjects('CoachPayRate',context,input,1000)).map((row)=>({objectId:row.id,classType:number(row.get('classType')),classTypeLabel:classType(row.get('classType')),amount:number(row.get('amount')),durationHours:number(row.get('durationHours'),durationHours(row.get('classType'))),effectiveFrom:text(row.get('effectiveFrom')),active:row.get('active')!==false,isDefault:false,updatedAt:dateIso(row.updatedAt)})); return defaultPayRates().concat(stored).sort((a,b)=>String(b.effectiveFrom).localeCompare(String(a.effectiveFrom))); }
  3307. function rateFor(rates,type,date) { return rates.filter((row)=>row.active&&row.classType===number(type)&&row.effectiveFrom<=date.slice(0,10)).sort((a,b)=>b.effectiveFrom.localeCompare(a.effectiveFrom))[0]||null; }
  3308. async function coachRows(context,input,paginate=true) {
  3309. const companyId=companyIdOf(context,input),today=new Date().toISOString().slice(0,10),month=today.slice(0,7);
  3310. const users=await Psql.query('SELECT u.*,p."realName" AS "storeRealName",p."nickname" AS "storeNickname",p."username" AS "storeUsername",p."legacyUserData" AS "storeLegacy" FROM "_User" u LEFT JOIN "_User" p ON p."company"=u."company" AND COALESCE(CAST(p."legacyUserId" AS text),p."legacyUserData"->>\'UserID\')=COALESCE(u."legacyUserData"->>\'ParentUserID\',\'0\') WHERE ($1::text IS NULL OR u."company"=$1) AND u."identityType"=\'coach\' AND (u."isDeleted" IS NULL OR u."isDeleted"=FALSE) ORDER BY u."createdAt" DESC',[companyId]);
  3311. const appts=await Psql.query('SELECT CAST("pl" AS text) AS uid,COUNT(*) FILTER (WHERE COALESCE("yysj","bxrq") LIKE $2||\'%\')::int AS today_count,COUNT(*) FILTER (WHERE CAST(COALESCE("dszt",\'0\') AS text) IN (\'0\',\'10\'))::int AS pending_count FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) GROUP BY CAST("pl" AS text)',[companyId,today]);
  3312. const lessonFacts=await Psql.query('SELECT CAST(l."jsmz" AS text) AS uid,l."kclx",l."pf",l."plpjsj",l."lessonAt",c."createdAt" AS "commonCreatedAt",a."jssj" AS "appointmentEnd" FROM "LessonRecord" l LEFT JOIN "CommonModel" c ON c."company"=l."company" AND CAST(c."modelId" AS text)=\'59\' AND CAST(c."itemId" AS text)=CAST(l."id" AS text) LEFT JOIN "CommonModel" ac ON ac."company"=l."company" AND CAST(ac."modelId" AS text)=\'54\' AND CAST(ac."generalId" AS text)=CAST(l."yyds" AS text) LEFT JOIN "CourseAppointment" a ON a."company"=l."company" AND CAST(a."id" AS text)=CAST(ac."itemId" AS text) WHERE ($1::text IS NULL OR l."company"=$1)',[companyId]);
  3313. const lessonMap=new Map();
  3314. for(const row of lessonFacts){const uid=text(row.uid);if(!uid)continue;const lessonAt=payrollLessonAt(row),type=number(row.kclx),score=number(row.pf),entry=lessonMap.get(uid)||{month_count:0,month_hours:0,estimated_pay:0,score_total:0,score_count:0,average_score:0,last_at:''};if(lessonAt&&lessonAt.slice(0,7)===month){entry.month_count++;entry.month_hours+=type===4?0:durationHours(type);entry.estimated_pay+=type===1?20:[2,3].includes(type)?40:0;}if(score>0){entry.score_total+=score;entry.score_count++;entry.average_score=entry.score_total/entry.score_count;}if(lessonAt&&lessonAt>entry.last_at)entry.last_at=lessonAt;lessonMap.set(uid,entry);}
  3315. const availability=new Set((await availabilityRows(context,input)).map((row)=>row.coachId)),apptMap=new Map(appts.map((row)=>[text(row.uid),row]));
  3316. let normalized=users.map((row)=>{const legacy=legacyOf(row),uid=number(row.legacyUserId||legacy.UserID),storeLegacy=legacyOf({legacyUserData:row.storeLegacy}),a=apptMap.get(String(uid))||{},l=lessonMap.get(String(uid))||{},mobile=mobileOf(row);return{objectId:text(row.objectId),userId:uid,username:text(row.username),displayName:displayName(row),mobile:roleOf(context)==='ops-manager'?mobile:'',mobileMasked:maskMobile(mobile),parentUserId:number(legacy.ParentUserID),storeName:text(row.storeRealName||row.storeNickname||storeLegacy.HoneyName||row.storeUsername)||'未绑定',status:row.isDisabled===true?'disabled':'active',statusLabel:row.isDisabled===true?'已停用':'正常',todayAppointments:number(a.today_count),pendingAppointments:number(a.pending_count),monthLessons:number(l.month_count),monthHours:number(l.month_hours),estimatedPay:number(l.estimated_pay),averageScore:number(l.average_score),lastLessonAt:dateIso(l.last_at),availabilityConfigured:availability.has(uid),updatedAt:dateIso(row.updatedAt)}});
  3317. const paging=pageInput(input),status=text(paging.filters.status||'all');if(status!=='all')normalized=normalized.filter((row)=>row.status===status);const filtered=searchRows(normalized,input);return paginate?pageRows(filtered,input):filtered;
  3318. }
  3319. async function scheduleData(context,input) { const from=text(input.dateFrom)||new Date().toISOString().slice(0,10),to=text(input.dateTo)||from,coachId=number((input.filters||{}).coachId),[coachPage,rangeRows,summary]=await Promise.all([coachRows(context,{...input,page:1,pageSize:100,search:'',filters:{status:'active'}}),appointmentRowsInRange(context,input,from,to),storedAppointmentSummary(context,input)]),appointments=rangeRows.filter((row)=>!coachId||row.coachId===coachId),unassigned=appointments.filter((row)=>!row.coachId&&!row.cancelled);return{dateFrom:from,dateTo:to,refreshedAt:new Date().toISOString(),coaches:coachPage.items,appointments,unassigned,...summary}; }
  3320. function appointmentDuration(payload){const explicit=number(payload.durationMinutes);return[30,60].includes(explicit)?explicit:durationMinutes(payload.classType);}
  3321. function legacyClassTypeFor(categoryKey,duration){if(categoryKey==='trial')return 3;if(categoryKey==='review')return 4;return duration===60?2:1;}
  3322. function recurrenceCandidates(payload) { const count=Math.min(60,Math.max(1,number(payload.occurrences,1))),base=appointmentDate('',payload.date,payload.startTime),minutes=appointmentDuration(payload),mode=text(payload.recurrence)||'once',items=[]; for(let index=0;index<count;index++){const start=new Date(base); if(mode==='daily')start.setDate(start.getDate()+index);else if(mode==='alternate')start.setDate(start.getDate()+index*2);else if(mode==='weekly')start.setDate(start.getDate()+index*7);else if(mode==='same_day_30')start.setMinutes(start.getMinutes()+index*30);else if(mode==='same_day_60')start.setMinutes(start.getMinutes()+index*60);else if(index>0)break; items.push({index,start,end:new Date(start.getTime()+minutes*60000)});} return items; }
  3323. function overlap(aStart,aEnd,bStart,bEnd) { return aStart<bEnd&&bStart<aEnd; }
  3324. async function teachingContextForCourse(context,input,courseId,duration,mode,date){const mappings=await courseTeachingRows(context,input),mapping=mappings.find((row)=>row.courseId===number(courseId));if(!mapping||!mapping.confirmed||!mapping.categoryKey)fail(409,'课程尚未确认标准分类,请先到“排课与工资标准”完成课程映射');const rules=await teachingRuleRows(context,input),rule=teachingRuleFor(rules,mapping.categoryKey,duration,mode,date);if(!rule)fail(409,'当前课程分类、时长和授课方式没有生效的工资与课时规则');return{mapping,rule};}
  3325. function balanceForAccount(legacy,account){return number(legacy[account]);}
  3326. async function releaseExpiredReservations(context,input){const companyId=companyIdOf(context,input);try{return await Psql.query('UPDATE "CourseCreditReservation" r SET "state"=\'released\',"releasedAt"=NOW(),"releaseReason"=\'cancelled-or-plan-not-completed\',"updatedAt"=NOW() FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND CAST(a."id" AS text)=CAST(c."itemId" AS text) WHERE r."company"=c."company" AND r."appointmentId"=c."objectId" AND ($1::text IS NULL OR r."company"=$1) AND r."state"=\'reserved\' AND (c."status"=-2 OR CAST(COALESCE(a."dszt",\'0\') AS text) NOT IN (\'0\',\'10\') OR (CAST(COALESCE(a."dszt",\'0\') AS text)=\'0\' AND CASE WHEN COALESCE(a."yysj",\'\') ~ \'^\\d{4}-\\d{2}-\\d{2}[ T]\\d{2}:\\d{2}\' THEN to_timestamp(LEFT(a."yysj",16),\'YYYY-MM-DD HH24:MI\') ELSE NOW()+INTERVAL \'1 day\' END + make_interval(mins=>COALESCE(a."durationMinutes",CASE WHEN CAST(a."dslx" AS text)=\'1\' THEN 30 ELSE 60 END)) < NOW())) RETURNING r."objectId"',[companyId]);}catch(error){if(/does not exist|column/i.test(text(error&&error.message)))return[];throw error;}}
  3327. async function reservedCreditsFor(context,input,studentId,excludeAppointmentId=''){try{await releaseExpiredReservations(context,input);const rows=await Psql.query('SELECT "appointmentId","creditCosts" FROM "CourseCreditReservation" WHERE ($1::text IS NULL OR "company"=$1) AND "studentId"=$2 AND "state"=\'reserved\' AND ($3=\'\' OR COALESCE("appointmentId",\'\')<>$3)',[companyIdOf(context,input),number(studentId),text(excludeAppointmentId)]),totals={Purse:0,SilverCoin:0,UserExp:0,UserPoint:0};for(const row of rows)for(const cost of(Array.isArray(row.creditCosts)?row.creditCosts:[])){const account=text(cost.account);if(Object.prototype.hasOwnProperty.call(totals,account))totals[account]+=number(cost.amount);}return totals;}catch(error){if(/does not exist|column/i.test(text(error&&error.message)))return{Purse:0,SilverCoin:0,UserExp:0,UserPoint:0};throw error;}}
  3328. async function reserveAppointmentCredits(context,input,common,addon,rule){const company=common.get('company')||context.company,sourceKey='cloud:appointment-reservation:'+common.id,existing=await Psql.oneOrNone('SELECT "objectId" FROM "CourseCreditReservation" WHERE "company"=$1 AND "sourceKey"=$2 LIMIT 1',[pointerId(company),sourceKey]).catch(()=>null),target=existing?await new Parse.Query('CourseCreditReservation').get(existing.objectId,{useMasterKey:true}):new Parse.Object('CourseCreditReservation');target.set('company',company);target.set('sourceKey',sourceKey);target.set('appointmentId',common.id);target.set('appointmentGeneralId',number(common.get('generalId')));target.set('studentId',number(addon.get('szyh')));target.set('ruleId',rule.objectId);target.set('creditCosts',rule.creditCosts);target.set('state','reserved');target.set('reservedAt',new Date());target.unset('releasedAt');target.unset('releaseReason');target.unset('consumedAt');await target.save(null,{useMasterKey:true});return target;}
  3329. async function changeAppointmentReservation(context,input,common,state,reason=''){const companyId=pointerId(common.get('company')||context.company),row=await Psql.oneOrNone('SELECT "objectId" FROM "CourseCreditReservation" WHERE "company"=$1 AND "appointmentId"=$2 LIMIT 1',[companyId,common.id]).catch(()=>null);if(!row)return null;const target=await new Parse.Query('CourseCreditReservation').get(row.objectId,{useMasterKey:true});target.set('state',state);if(state==='released'){target.set('releasedAt',new Date());target.set('releaseReason',reason);}if(state==='consumed')target.set('consumedAt',new Date());await target.save(null,{useMasterKey:true});return target;}
  3330. async function preflightAppointments(context,input,payload,targetId='') { const studentId=number(payload.studentId),coachId=number(payload.coachId),courseId=number(payload.courseId),duration=appointmentDuration(payload),mode=deliveryMode(payload.deliveryMode||payload.deliveryMethod),candidates=recurrenceCandidates(payload); if(!studentId||!courseId||![30,60].includes(duration))fail(400,'会员、课程和课程时长必填'); const companyId=companyIdOf(context,input),student=await userByLegacyId(context,input,studentId,1); if(!student||student.isDisabled)fail(404,'会员不存在、已停用或身份异常'); if(coachId){const coach=await userByLegacyId(context,input,coachId,3);if(!coach)fail(404,'陪练老师不存在或身份异常');} const course=await Psql.oneOrNone('SELECT "nodeName" FROM "Node" WHERE ($1::text IS NULL OR "company"=$1) AND CAST("nodeId" AS text)=$2 LIMIT 1',[companyId,String(courseId)]); if(!course)fail(404,'课程不存在'); const teachingContexts=await Promise.all(candidates.map(item=>enrollmentTeachingContext(context,input,payload,item.start.toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'})))),teaching=teachingContexts[0],legacy=legacyOf(student),reserved=await reservedCreditsFor(context,input,studentId,targetId),costTotals=new Map();for(const entry of teachingContexts)for(const cost of entry.rule.creditCosts){const previous=costTotals.get(cost.account)||{...cost,required:0};previous.required+=number(cost.amount);costTotals.set(cost.account,previous);}const needed=Array.from(costTotals.values()).map(cost=>({...cost,reserved:number(reserved[cost.account]),balance:balanceForAccount(legacy,cost.account)})),shortages=needed.filter((cost)=>cost.balance-cost.reserved<cost.required);if(shortages.length)fail(409,'可用课时不足:'+shortages.map((cost)=>cost.label+'还需'+number(cost.required-(cost.balance-cost.reserved)).toFixed(2).replace(/\.00$/,'')).join(',')+'。请先充值或调账后再排课'); const existing=(await allAppointmentRows(context,input)).filter((row)=>row.targetId!==targetId&&!row.cancelled&&row.statusCode<30),rules=coachId?await availabilityRows(context,input,coachId):[],conflicts=[],warnings=[]; if(coachId&&!rules.length)warnings.push('该陪练尚未配置可用时间,本次允许排课'); for(const item of candidates){for(const row of existing){const rs=new Date(row.startsAt),re=new Date(row.endsAt);if(overlap(item.start,item.end,rs,re)&&(row.studentId===studentId||(coachId&&row.coachId===coachId)))conflicts.push({index:item.index,startsAt:dateIso(item.start),reason:row.studentId===studentId?'会员时间冲突':'陪练时间冲突',targetId:row.targetId});} if(coachId&&rules.length){const date=item.start.toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}),weekday=new Date(date+'T00:00:00+08:00').getDay(),clock=item.start.toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false,hour:'2-digit',minute:'2-digit'}),endClock=item.end.toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false,hour:'2-digit',minute:'2-digit'}),dateRules=rules.filter((rule)=>rule.ruleType==='date'&&rule.date===date),effective=dateRules.length?dateRules:rules.filter((rule)=>rule.ruleType==='weekly'&&rule.weekday===weekday);if(effective.some((rule)=>!rule.available&&overlap(clock,endClock,rule.startTime,rule.endTime))||effective.some((rule)=>rule.available)&&!effective.some((rule)=>rule.available&&clock>=rule.startTime&&endClock<=rule.endTime))conflicts.push({index:item.index,startsAt:dateIso(item.start),reason:'不在陪练可用时间内'});}} const unique=conflicts.filter((row,index,array)=>array.findIndex((item)=>item.index===row.index&&item.reason===row.reason)===index),conflictIndexes=[...new Set(unique.map((row)=>row.index))]; return {availableCount:candidates.length-conflictIndexes.length,conflictCount:conflictIndexes.length,issueCount:unique.length,conflictIndexes,conflicts:unique,warnings:warnings.join(';'),candidates:candidates.map((row,index)=>({index:row.index,startsAt:dateIso(row.start),endsAt:dateIso(row.end),teachingRule:teachingContexts[index].rule})),bindingId:teaching.bindingId,studentObjectId:text(student.objectId),courseName:text(course.nodeName),courseCategoryKey:teaching.mapping.categoryKey,courseCategoryName:teaching.mapping.categoryName,durationMinutes:duration,deliveryMode:mode,deliveryLabel:deliveryLabel(mode),teachingRule:teaching.rule,creditAvailability:needed}; }
  3331. async function userByLegacyId(context,input,userId,groupId=0) { const companyId=companyIdOf(context,input),identity=groupId===1?'member':groupId===2?'store':groupId===3?'coach':''; return Psql.oneOrNone('SELECT * FROM "_User" WHERE ($1::text IS NULL OR "company"=$1) AND COALESCE(CAST("legacyUserId" AS text),"legacyUserData"->>\'UserID\')=$2'+(identity?' AND ("identityType"=\''+identity+'\' OR ("identityType" IS NULL AND COALESCE(CAST("legacyGroupId" AS text),"legacyUserData"->>\'GroupID\')=\''+groupId+'\'))':'')+' AND COALESCE("identityType",\'\')<>\'conflict\' LIMIT 1',[companyId,String(userId)]); }
  3332. async function appointmentPair(context,input,targetId) { const common=await scopedObject('CommonModel',targetId,context,input);if(number(common.get('modelId'))!==54)fail(400,'目标不是预约记录');const addon=await findAddon('CourseAppointment',common.get('company')||context.company,common.get('itemId'));if(!addon)fail(404,'预约附表不存在');return{common,addon}; }
  3333. const CREDIT_LEDGER_META={Purse:{className:'UserExpDomP',scoreType:1,suffix:'purse'},SilverCoin:{className:'UserSIcon',scoreType:2,suffix:'silver'},UserExp:{className:'UserExpHis',scoreType:3,suffix:'trial'},UserPoint:{className:'UserUserPoint',scoreType:4,suffix:'point'}};
  3334. function appointmentCreditCosts(addon){const stored=addon.get('creditCostsSnapshot');if(Array.isArray(stored)&&stored.length)return stored.map((cost)=>({account:text(cost.account),label:TEACHING_CREDIT_LABELS[text(cost.account)]||text(cost.account),amount:number(cost.amount)})).filter((cost)=>CREDIT_LEDGER_META[cost.account]&&cost.amount>0);const type=number(addon.get('dslx')),category=text(addon.get('courseCategoryKey'))||(type===3?'trial':type===4?'review':'word'),duration=number(addon.get('durationMinutes'))||durationMinutes(type);return teachingCredits(category,duration);}
  3335. async function validateAppointmentCreditBalance(context,input,pair){await enrollmentSql('SELECT xs_validate_appointment($1,$2) AS result',[pointerId(pair.common.get('company')),pair.common.id]);const hold=text(pair.addon.get('creditHoldStatus'));if(hold&&hold!=='ready')fail(409,text(pair.addon.get('creditHoldReason'))||'该历史预约标记为待处理,暂不能开课或完课');const student=await userByLegacyId(context,input,number(pair.addon.get('szyh')),1);if(!student)fail(404,'预约学员不存在');const legacy=legacyOf(student),costs=appointmentCreditCosts(pair.addon);if(!costs.length)fail(409,'预约缺少有效课时规则,请先由管理员处理');const shortages=costs.filter((cost)=>number(legacy[cost.account])<cost.amount);if(shortages.length)fail(409,'当前课时不足:'+shortages.map((cost)=>cost.label+'需要 '+cost.amount).join(',')+'。请先充值或调账后再开课');return{costs,balances:Object.fromEntries(costs.map((cost)=>[cost.account,number(legacy[cost.account])]))};}
  3336. async function completeAppointment(context,input) {
  3337. requireRole(context,['ops-manager','ops-staff']);reasonOf(input);const pair=await appointmentPair(context,input,text(input.targetId));
  3338. return (await enrollmentSql('SELECT xs_complete_appointment($1,$2,$3) AS result',[pointerId(pair.common.get('company')),pair.common.id,context.current.id])).result;
  3339. }
  3340. function payrollStatusLabel(value) { return ({draft:'草稿',reviewed:'已审核',paid:'已发放'})[text(value)]||text(value); }
  3341. function payrollSummary(row) { const source=row&&typeof row.get==='function'?row.toJSON():row; return {objectId:row.id||text(source.objectId),month:text(source.month),status:text(source.status)||'draft',statusLabel:payrollStatusLabel(source.status||'draft'),coachCount:number(source.coachCount),lessonCount:number(source.lessonCount),totalHours:number(source.totalHours),baseAmount:number(source.baseAmount),reviewCount:number(source.reviewCount),reviewAmount:number(source.reviewAmount),adjustmentAmount:number(source.adjustmentAmount),payableAmount:number(source.payableAmount),exceptionCount:number(source.exceptionCount),reviewedAt:dateIso(source.reviewedAt),paidAt:dateIso(source.paidAt),paymentMethod:text(source.paymentMethod),paymentReference:text(source.paymentReference),updatedAt:dateIso(row.updatedAt||source.updatedAt)}; }
  3342. function payrollLine(row) { const source=row&&typeof row.get==='function'?row.toJSON():row; return {objectId:row.id||text(source.objectId),kind:text(source.kind)||'lesson',batchId:text(source.batchId),reviewRewardId:text(source.reviewRewardId),reviewTaskId:text(source.reviewTaskId),plannedDate:text(source.plannedDate),coachId:number(source.coachId),coachName:text(source.coachName)||'未识别陪练',coachObjectId:text(source.coachObjectId),storeId:number(source.storeId),storeName:text(source.storeName)||'未绑定门店',studentName:text(source.studentName)||'未记录学员',courseName:text(source.courseName)||'未记录课程',legacyGeneralId:number(source.legacyGeneralId),lessonId:text(source.lessonId),lessonAt:dateIso(source.lessonAt),originalMonth:text(source.originalMonth),settlementMonth:text(source.settlementMonth),classType:number(source.classType),classTypeLabel:text(source.courseCategoryName)||classType(source.classType),courseCategoryKey:text(source.courseCategoryKey),courseCategoryName:text(source.courseCategoryName),deliveryMode:source.kind==='review_reward'?'':deliveryMode(source.deliveryMode),deliveryLabel:source.kind==='review_reward'?'':deliveryLabel(source.deliveryMode),durationMinutes:number(source.durationMinutes),teachingRuleId:text(source.teachingRuleId),durationHours:number(source.durationHours),rateAmount:number(source.rateAmount),amount:number(source.amount),score:number(source.score),comment:text(source.comment),description:text(source.description),exception:text(source.exception),excluded:source.excluded===true}; }
  3343. async function existingPayrollLessonIds(context,input) { const rows=await newClassObjects('PayrollLine',context,input,10000); return new Set(rows.filter((row)=>text(row.get('kind'))==='lesson'&&row.get('excluded')!==true).map((row)=>number(row.get('legacyGeneralId'))?'legacy:'+number(row.get('legacyGeneralId')):text(row.get('lessonId')))); }
  3344. function payrollCalculatedLine(item,rates,settlementMonth,teachingRules,mappingByCourse) { const type=number(item.classType),lessonAt=item.lessonAt,date=(lessonAt||settlementMonth+'-01').slice(0,10),useConfigured=date>='2026-09-01',mapping=mappingByCourse.get(number(item.courseId)),categoryKey=text(item.courseCategoryKey)||text(mapping&&mapping.categoryKey),categoryName=text(item.courseCategoryName)||text(mapping&&mapping.categoryName),minutes=number(item.durationMinutes)||durationMinutes(type),mode=deliveryMode(item.deliveryMode),configured=useConfigured?teachingRuleFor(teachingRules,categoryKey,minutes,mode,date):null,legacyRate=useConfigured?null:type===4?null:rateFor(rates,type,date),exception=!item.coachId?'缺少陪练身份':!lessonAt?'缺少有效上课日期':useConfigured&&!categoryKey?'课程未确认标准分类':useConfigured&&!configured?'课程分类、时长和授课方式缺少有效计价规则':!useConfigured&&![1,2,3,4].includes(type)?'课型无法识别':!useConfigured&&type!==4&&!legacyRate?'课型未配置有效单价':'',rateAmount=useConfigured?number(configured&&configured.teacherPay):type===4?0:number(legacyRate&&legacyRate.amount),hours=useConfigured?minutes/60:type===4?0:number(legacyRate&&legacyRate.durationHours);return{objectId:item.objectId,kind:'lesson',coachId:item.coachId,coachName:item.coachName,coachObjectId:text(item.coachObjectId),coachMobile:text(item.coachMobile),storeId:item.storeId,storeName:text(item.storeName)||'未绑定门店',studentName:item.studentName,courseName:item.courseName,legacyGeneralId:item.legacyGeneralId,lessonId:item.objectId,lessonAt,originalMonth:lessonAt.slice(0,7),settlementMonth,classType:type,classTypeLabel:categoryName||classType(type),courseCategoryKey:categoryKey,courseCategoryName:categoryName,deliveryMode:mode,deliveryLabel:deliveryLabel(mode),durationMinutes:minutes,teachingRuleId:text(configured&&configured.objectId)||text(item.teachingRuleId),durationHours:hours,rateAmount,amount:exception?0:rateAmount,score:number(item.score),comment:text(item.comment),description:useConfigured?'配置规则快照 '+text(configured&&configured.objectId):type===4?'原系统未配置计薪规则,仅计课次':'旧上课记录 '+item.legacyGeneralId,exception}; }
  3345. function payrollVisibleLines(lines,input) { const storeId=number(input.storeId||(input.filters||{}).storeId),search=text(input.search).toLowerCase();return lines.filter((line)=>(!storeId||line.storeId===storeId)&&(!search||[line.coachName,line.coachId,line.storeName,line.coachMobile].some((value)=>String(value||'').toLowerCase().includes(search)))); }
  3346. function payrollCoachItems(lines,input) {
  3347. const map=new Map();for(const line of payrollVisibleLines(lines,input).filter((row)=>!row.excluded&&!row.exception)){
  3348. const row=map.get(line.coachId)||{coachId:line.coachId,coachName:line.coachName,coachObjectId:text(line.coachObjectId),coachMobile:text(line.coachMobile),storeId:line.storeId,storeName:line.storeName,type30Count:0,type60Count:0,trialCount:0,type4Count:0,lessonCount:0,totalHours:0,baseAmount:0,reviewCount:0,reviewAmount:0,adjustmentAmount:0,payableAmount:0,latestLessonAt:'',scoreTotal:0,scoreCount:0,averageScore:0};
  3349. if(line.kind==='review_reward'){row.reviewCount++;row.reviewAmount+=number(line.amount);}else if(line.kind==='adjustment'){row.adjustmentAmount+=number(line.amount);}else{row.lessonCount++;if(line.classType===1)row.type30Count++;else if(line.classType===2)row.type60Count++;else if(line.classType===3)row.trialCount++;else if(line.classType===4)row.type4Count++;row.totalHours+=number(line.durationHours);row.baseAmount+=number(line.amount);if(line.score>0){row.scoreTotal+=line.score;row.scoreCount++;row.averageScore=row.scoreTotal/row.scoreCount;}if(line.lessonAt>row.latestLessonAt)row.latestLessonAt=line.lessonAt;}
  3350. row.payableAmount+=number(line.amount);map.set(line.coachId,row);}return Array.from(map.values()).map(({scoreTotal,scoreCount,...row})=>row).sort((a,b)=>b.payableAmount-a.payableAmount||a.coachName.localeCompare(b.coachName,'zh-CN'));
  3351. }
  3352. function payrollResult(month,lines,sourceStatus,input) { const visible=payrollVisibleLines(lines,input).filter((row)=>!row.excluded),exceptions=visible.filter((row)=>row.exception),eligible=visible.filter((row)=>!row.exception),coachItems=payrollCoachItems(eligible,{}),lessons=eligible.filter((row)=>row.kind==='lesson'),reviews=eligible.filter((row)=>row.kind==='review_reward'),baseAmount=lessons.reduce((sum,row)=>sum+number(row.amount),0),reviewAmount=reviews.reduce((sum,row)=>sum+number(row.amount),0),adjustmentAmount=eligible.filter((row)=>row.kind==='adjustment').reduce((sum,row)=>sum+number(row.amount),0);return{objectId:'',month,status:'draft',statusLabel:'工资预览',coachCount:coachItems.length,lessonCount:lessons.length,totalHours:lessons.reduce((sum,row)=>sum+number(row.durationHours),0),baseAmount,reviewCount:reviews.length,reviewAmount,adjustmentAmount,payableAmount:baseAmount+reviewAmount+adjustmentAmount,exceptionCount:exceptions.length,type4Count:lessons.filter((row)=>row.classType===4).length,reviewedAt:'',paidAt:'',paymentMethod:'',paymentReference:'',updatedAt:'',lines:visible,exceptions,coachItems,sourceStatus,refreshedAt:sourceStatus.refreshedAt}; }
  3353. async function payrollPreviewData(context,input,month,includeUsed=false,requireLive=false) { if(!/^\d{4}-\d{2}$/.test(month))fail(400,'工资月份格式必须为 YYYY-MM');const [source,rates,teachingRules,mappings]=await Promise.all([payrollSourceRows(context,input,month,requireLive),payRateRows(context,input),teachingRuleRows(context,input),courseTeachingRows(context,input)]),mappingByCourse=new Map(mappings.filter((row)=>row.categoryConfirmed).map((row)=>[row.courseId,row])),used=includeUsed?new Set():await existingPayrollLessonIds(context,input),items=[...source.items];if(!includeUsed){const parts=month.split('-').map(Number),previousMonth=parts[1]===1?(parts[0]-1)+'-12':parts[0]+'-'+String(parts[1]-1).padStart(2,'0'),previousBatch=(await newClassObjects('PayrollBatch',context,input,1000)).find((row)=>text(row.get('month'))===previousMonth&&['reviewed','paid'].includes(text(row.get('status'))));if(previousBatch){const lateSource=await payrollSourceRows(context,input,previousMonth,requireLive);for(const item of lateSource.items)if(!used.has('legacy:'+item.legacyGeneralId)&&!used.has(item.objectId))items.push(item);}}const lines=items.filter((item)=>includeUsed||!used.has('legacy:'+item.legacyGeneralId)&&!used.has(item.objectId)).map((item)=>payrollCalculatedLine(item,rates,month,teachingRules,mappingByCourse));lines.push(...await reviewRewardPayrollLines(companyIdOf(context,input),month,includeUsed));return payrollResult(month,lines,source.sourceStatus,input); }
  3354. async function payrollOverviewData(context,input) { const month=text(input.month)||new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}).slice(0,7),preview=await payrollPreviewData(context,input,month,true,false),batches=(await payrollPage(context,{...input,page:1,pageSize:100})).items;return{...preview,batches,stores:Array.from(new Map(preview.coachItems.filter((row)=>row.storeId).concat(await Psql.query('SELECT DISTINCT "storeId","storeName" FROM "ReviewReward" WHERE "company"=$1 AND "storeId">0',[companyIdOf(context,input)])).map((row)=>[number(row.storeId),{storeId:number(row.storeId),storeName:row.storeName}])).values())}; }
  3355. async function payrollBatchObject(context,input,targetId) { return scopedObject('PayrollBatch',targetId,context,input); }
  3356. async function payrollLinesForBatch(context,input,batchId) { const rows=[];let cursor='';for(;;){const query=new Parse.Query('PayrollLine');const company=companyPointer(context,input);if(!company)fail(400,'工资明细必须指定帐套');query.equalTo('company',company);query.equalTo('batchId',batchId);query.ascending('objectId');query.limit(1000);if(cursor)query.greaterThan('objectId',cursor);const page=await query.find({useMasterKey:true});rows.push(...page);if(page.length<1000)break;cursor=page[page.length-1].id;}return rows; }
  3357. async function refreshPayrollTotals(batch,lines) { const result=payrollResult(text(batch.get('month')),lines.map(payrollLine),{refreshedAt:new Date().toISOString()},{});for(const key of ['coachCount','lessonCount','totalHours','baseAmount','reviewCount','reviewAmount','adjustmentAmount','payableAmount','exceptionCount'])batch.set(key,result[key]);await batch.save(null,{useMasterKey:true});return batch; }
  3358. async function payrollDetailData(context,input,targetId) { const batch=await payrollBatchObject(context,input,targetId),rows=await payrollLinesForBatch(context,input,batch.id),summary=payrollSummary(batch),lines=rows.map(payrollLine);return{...summary,lines,exceptions:lines.filter((row)=>row.exception&&!row.excluded)}; }
  3359. async function payrollPage(context,input) { const rows=await newClassObjects('PayrollBatch',context,input,1000),items=rows.map(payrollSummary).sort((a,b)=>b.month.localeCompare(a.month));return pageRows(items,input); }
  3360. async function createPayroll(context,input,payload) { requireRole(context,['ops-manager']);const reason=reasonOf(input),month=text(payload.month),existing=(await newClassObjects('PayrollBatch',context,input,1000)).find((row)=>text(row.get('month'))===month);if(existing)fail(409,'该月份已经存在全公司工资批次');const preview=await payrollPreviewData(context,{...input,storeId:0,search:''},month,false,true),company=companyPointer(context,input);if(!company)fail(400,'工资批次必须指定帐套');if(preview.exceptionCount)fail(409,'存在 '+preview.exceptionCount+' 条阻断异常,请先处理后再生成草稿');const batch=new Parse.Object('PayrollBatch');batch.set('company',company);batch.set('sourceKey','cloud:ops-payroll:'+companyIdOf(context,input)+':'+month);batch.set('month',month);batch.set('status','draft');batch.set('sourceRefreshedAt',new Date(preview.refreshedAt));batch.set('createdBy',context.current.id);await batch.save(null,{useMasterKey:true});const saved=[];try{for(const item of preview.lines){const line=new Parse.Object('PayrollLine');line.set('company',company);line.set('sourceKey','cloud:ops-payroll-line:'+companyIdOf(context,input)+':'+(item.reviewRewardId?'review:'+item.reviewRewardId:item.legacyGeneralId));line.set('batchId',batch.id);line.set('kind',item.kind||'lesson');for(const field of ['reviewRewardId','reviewTaskId','plannedDate','coachId','coachName','coachObjectId','storeId','storeName','studentName','courseName','legacyGeneralId','lessonId','originalMonth','settlementMonth','classType','courseCategoryKey','courseCategoryName','deliveryMode','durationMinutes','teachingRuleId','durationHours','rateAmount','amount','score','comment','description','exception'])line.set(field,item[field]===undefined?(['reviewRewardId','reviewTaskId','plannedDate','coachObjectId','teachingRuleId'].includes(field)?'':item[field]):item[field]);line.set('settlementMonth',month);line.set('lessonAt',item.lessonAt?new Date(item.lessonAt):null);await line.save(null,{useMasterKey:true});saved.push(line);}await refreshPayrollTotals(batch,saved);}catch(error){for(const line of saved)await line.destroy({useMasterKey:true}).catch(()=>undefined);await batch.destroy({useMasterKey:true}).catch(()=>undefined);throw error;}await audit(context,'create-payroll','PayrollBatch',batch.id,reason,null,payrollSummary(batch));return payrollSummary(batch); }
  3361. async function savePayRate(context,input,payload) { requireRole(context,['ops-manager']);const reason=reasonOf(input),type=number(payload.classType),amount=number(payload.amount,-1),hours=number(payload.durationHours,-1),effective=text(payload.effectiveFrom);if(![1,2,3,4].includes(type)||amount<0||hours<=0||!/^\d{4}-\d{2}-\d{2}$/.test(effective))fail(400,'课型、单价、工时或生效日期无效');const company=companyPointer(context,input);if(!company)fail(400,'计价规则必须指定帐套');const rate=new Parse.Object('CoachPayRate');rate.set('company',company);rate.set('sourceKey','cloud:ops-pay-rate:'+companyIdOf(context,input)+':'+type+':'+effective);rate.set('classType',type);rate.set('amount',amount);rate.set('durationHours',hours);rate.set('effectiveFrom',effective);rate.set('active',payload.active!==false);await rate.save(null,{useMasterKey:true});await audit(context,'save-pay-rate','CoachPayRate',rate.id,reason,null,safe(rate));return{objectId:rate.id,classType:type,classTypeLabel:classType(type),amount,durationHours:hours,effectiveFrom:effective,active:true,updatedAt:dateIso(rate.updatedAt)}; }
  3362. async function payrollAdjustment(context,input,payload) { requireRole(context,['ops-manager']);const reason=reasonOf(input),batch=await payrollBatchObject(context,input,text(input.targetId));assertVersion(batch,input.expectedUpdatedAt);if(text(batch.get('status'))!=='draft')fail(409,'只有草稿工资单可以添加调整项');const coachId=number(payload.coachId),amount=number(payload.amount);if(!coachId||!amount)fail(400,'陪练和调整金额必填');const coach=await userByLegacyId(context,input,coachId,3);if(!coach)fail(404,'陪练不存在');const line=new Parse.Object('PayrollLine');line.set('company',batch.get('company'));line.set('sourceKey','cloud:ops-payroll-adjustment:'+batch.id+':'+randomObjectId());line.set('batchId',batch.id);line.set('kind','adjustment');line.set('coachId',coachId);line.set('coachName',displayName(coach));line.set('durationHours',0);line.set('rateAmount',0);line.set('amount',amount);line.set('description',text(payload.description)||reason);line.set('exception','');await line.save(null,{useMasterKey:true});await refreshPayrollTotals(batch,await payrollLinesForBatch(context,input,batch.id));await audit(context,'add-payroll-adjustment','PayrollBatch',batch.id,reason,null,{coachId,amount});return payrollSummary(batch); }
  3363. async function payrollWorkflow(context,input,operation,payload) { requireRole(context,['ops-manager']);const reason=reasonOf(input),batch=await payrollBatchObject(context,input,text(input.targetId));assertVersion(batch,input.expectedUpdatedAt);const status=text(batch.get('status'));if(operation==='review'){if(status!=='draft')fail(409,'只有草稿可以审核');await refreshPayrollDraft(context,input);await refreshPayrollTotals(batch,await payrollLinesForBatch(context,input,batch.id));if(number(batch.get('exceptionCount'))>0)fail(409,'仍有异常明细,不能审核');batch.set('status','reviewed');batch.set('reviewedAt',new Date());batch.set('reviewedBy',context.current.id);}else if(operation==='reopen'){fail(409,'工资单审核后永久锁定,差异请进入下期调整单');}else{if(status!=='reviewed')fail(409,'只有已审核工资单可以标记发放');const method=text(payload.paymentMethod),reference=text(payload.paymentReference);if(!method||!reference)fail(400,'发放方式和凭证编号必填');batch.set('status','paid');batch.set('paidAt',new Date());batch.set('paidBy',context.current.id);batch.set('paymentMethod',method);batch.set('paymentReference',reference);}await batch.save(null,{useMasterKey:true});await audit(context,'payroll-'+operation,'PayrollBatch',batch.id,reason,{status},{status:batch.get('status')});return payrollSummary(batch); }
  3364. async function coachDetailData(context,input,objectId) { const page=await coachRows(context,{...input,page:1,pageSize:100,search:'',filters:{status:'all'}}),coach=page.items.find((row)=>row.objectId===objectId);if(!coach)fail(404,'陪练不存在');const schedule=await allAppointmentRows(context,input),availability=await availabilityRows(context,input,coach.userId),preview=await payrollPreviewData(context,input,new Date().toISOString().slice(0,7),true),batches=(await payrollPage(context,{...input,page:1,pageSize:100})).items,review=await Psql.one('SELECT COUNT(*)::int AS total FROM "MemoryPracticeRecord" WHERE ($1::text IS NULL OR "company"=$1) AND CAST("coachId" AS text)=$2',[companyIdOf(context,input),String(coach.userId)]).catch(()=>({total:0}));return{coach,availability,appointments:schedule.filter((row)=>row.coachId===coach.userId).slice(0,200),lessons:preview.lines.filter((row)=>row.coachId===coach.userId),payrolls:batches,reviewTaskCount:number(review.total)}; }
  3365. async function savePayRateUpsert(context,input,payload) { requireRole(context,['ops-manager']);const reason=reasonOf(input),type=number(payload.classType),amount=number(payload.amount,-1),hours=number(payload.durationHours,-1),effective=text(payload.effectiveFrom);if(![1,2,3,4].includes(type)||amount<0||hours<=0||!/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(effective))fail(400,'课型、单价、工时或生效日期无效');const company=companyPointer(context,input);if(!company)fail(400,'计价规则必须指定帐套');const existing=(await newClassObjects('CoachPayRate',context,input,1000)).find((row)=>number(row.get('classType'))===type&&text(row.get('effectiveFrom'))===effective),rate=existing||new Parse.Object('CoachPayRate'),before=existing?safe(existing):null;rate.set('company',company);rate.set('sourceKey','cloud:ops-pay-rate:'+companyIdOf(context,input)+':'+type+':'+effective);rate.set('classType',type);rate.set('amount',amount);rate.set('durationHours',hours);rate.set('effectiveFrom',effective);rate.set('active',payload.active!==false);await rate.save(null,{useMasterKey:true});await audit(context,'save-pay-rate','CoachPayRate',rate.id,reason,before,safe(rate));return{objectId:rate.id,classType:type,classTypeLabel:classType(type),amount,durationHours:hours,effectiveFrom:effective,active:rate.get('active')!==false,updatedAt:dateIso(rate.updatedAt)}; }
  3366. async function refreshPayrollDraft(context,input) { requireRole(context,['ops-manager','ops-staff']);const reason=reasonOf(input),batch=await payrollBatchObject(context,input,text(input.targetId));assertVersion(batch,input.expectedUpdatedAt);if(text(batch.get('status'))!=='draft')fail(409,'只有草稿工资单可以刷新');const month=text(batch.get('month')),preview=await payrollPreviewData(context,{...input,storeId:0,search:''},month,false,true),lines=await payrollLinesForBatch(context,input,batch.id),known=new Map(lines.map((line)=>[text(line.get('reviewRewardId'))||String(number(line.get('legacyGeneralId'))),line]));for(const item of preview.lines){let line=known.get(item.reviewRewardId||String(item.legacyGeneralId));if(!line){line=new Parse.Object('PayrollLine');line.set('company',batch.get('company'));line.set('sourceKey','cloud:ops-payroll-line:'+companyIdOf(context,input)+':'+(item.reviewRewardId?'review:'+item.reviewRewardId:item.legacyGeneralId));line.set('batchId',batch.id);line.set('kind',item.kind||'lesson');lines.push(line);known.set(item.reviewRewardId||String(item.legacyGeneralId),line);}if(line.get('excluded')===true)continue;for(const field of ['reviewRewardId','reviewTaskId','plannedDate','coachId','coachName','coachObjectId','storeId','storeName','studentName','courseName','legacyGeneralId','lessonId','originalMonth','classType','courseCategoryKey','courseCategoryName','deliveryMode','durationMinutes','teachingRuleId','durationHours','rateAmount','amount','score','comment','description','exception'])line.set(field,item[field]===undefined?(['reviewRewardId','reviewTaskId','plannedDate','coachObjectId','teachingRuleId'].includes(field)?'':item[field]):item[field]);line.set('settlementMonth',month);line.set('lessonAt',item.lessonAt?new Date(item.lessonAt):null);await line.save(null,{useMasterKey:true});}batch.set('sourceRefreshedAt',new Date(preview.refreshedAt));await refreshPayrollTotals(batch,lines);await audit(context,'refresh-payroll','PayrollBatch',batch.id,reason,null,payrollSummary(batch));return payrollDetailData(context,input,batch.id); }
  3367. async function professionalScheduleData(context,input) {
  3368. const data=await scheduleData(context,input),members=(await queryUsers(context,input)).map((row)=>({objectId:row.objectId,userId:row.userId,displayName:row.displayName,mobileMasked:row.mobileMasked,periods30:row.periods30,periods60:row.periods60,trialPeriods:row.trialPeriods,userPoint:row.userPoint,learningStatusLabel:row.learningStatusLabel,liveOnly:row.liveOnly===true,writeReady:row.writeReady!==false,readOnly:row.writeReady===false,readOnlyReason:text(row.readOnlyReason)})),companyId=companyIdOf(context,input);
  3369. const courseRows=await Psql.query('WITH binding_courses AS (SELECT b."company",CAST(b."kcid" AS text) AS course_id,ARRAY_AGG(DISTINCT CAST(b."yhid" AS text)) FILTER (WHERE COALESCE(CAST(b."yhid" AS text),\'\')<>\'\') AS member_ids FROM "CourseBinding" b JOIN "CommonModel" c ON c."company"=b."company" AND CAST(c."modelId" AS text)=\'58\' AND CAST(c."itemId" AS text)=CAST(b."id" AS text) WHERE ($1::text IS NULL OR b."company"=$1) AND COALESCE(CAST(c."status" AS text),\'99\')<>\'-2\' GROUP BY b."company",CAST(b."kcid" AS text)), appointment_courses AS (SELECT DISTINCT "company",CAST("kcid" AS text) AS course_id FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1)), course_ids AS (SELECT "company",course_id FROM binding_courses UNION SELECT "company",course_id FROM appointment_courses) SELECT CAST(n."nodeId" AS text) AS id,n."nodeName" AS name,COALESCE(b.member_ids,ARRAY[]::text[]) AS member_ids FROM "Node" n LEFT JOIN binding_courses b ON b."company"=n."company" AND b.course_id=CAST(n."nodeId" AS text) WHERE ($1::text IS NULL OR n."company"=$1) AND (CAST(n."nodeId" AS text)=\'398\' OR EXISTS(SELECT 1 FROM course_ids i WHERE i."company"=n."company" AND i.course_id=CAST(n."nodeId" AS text))) ORDER BY n."nodeName" LIMIT 1000',[companyId]);
  3370. const [teachingMappings,teachingRules]=await Promise.all([courseTeachingRows(context,input),teachingRuleRows(context,input)]),mappingMap=new Map(teachingMappings.map((row)=>[row.courseId,row]));
  3371. const courses=courseRows.map((row)=>{const courseId=number(row.id),mapping=mappingMap.get(courseId),supportedDurations=mapping&&mapping.confirmed?[...new Set(teachingRules.filter((rule)=>rule.active&&rule.categoryKey===mapping.categoryKey&&rule.effectiveFrom<=new Date().toISOString().slice(0,10)).map((rule)=>rule.durationMinutes))].sort((a,b)=>a-b):[];return{courseId,courseName:text(row.name)||'课程 '+courseId,memberIds:(Array.isArray(row.member_ids)?row.member_ids:[]).map(number).filter(Boolean),availableToAll:courseId===398,stageKey:text(mapping&&mapping.stageKey),stageName:text(mapping&&mapping.stageName),categoryKey:text(mapping&&mapping.categoryKey),categoryName:text(mapping&&mapping.categoryName),mappingConfirmed:Boolean(mapping&&mapping.confirmed),suggestedCategoryName:text(mapping&&mapping.suggestedCategoryName),supportedDurations};}).filter((row)=>row.courseId);
  3372. return{...data,members,courses,liveSourceAvailable:true,liveSourceMessage:'新数据库排课投影',liveSourceCount:data.appointments.length,sync:await operationalSyncIndicator(context,input)};
  3373. }
  3374. async function coachStore(context,input,parentUserId) { if(!parentUserId)return null;const companyId=companyIdOf(context,input),row=await Psql.oneOrNone('SELECT "objectId","username","realName","nickname","legacyUserData" FROM "_User" WHERE ($1::text IS NULL OR "company"=$1) AND COALESCE(CAST("legacyUserId" AS text),"legacyUserData"->>\'UserID\')=$2 AND COALESCE(CAST("legacyGroupId" AS text),"legacyUserData"->>\'GroupID\')=\'2\' AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1',[companyId,String(parentUserId)]);if(!row)fail(404,'所属门店不存在');return row; }
  3375. function coachPayload(payload,creating=false) { const username=text(payload.username),password=text(payload.password),displayName=text(payload.displayName),mobile=text(payload.mobile),parentUserId=number(payload.parentUserId);if(username.length<3||username.length>64)fail(400,'登录账号长度应为 3 至 64 位');if(!displayName||displayName.length>100)fail(400,'老师姓名为必填项且不能超过 100 位');if(mobile&&!/^1\d{10}$/.test(mobile))fail(400,'手机号格式不正确');if((creating||password)&&(password.length<8||password.length>72))fail(400,'密码长度应为 8 至 72 位');if(parentUserId<0||!Number.isInteger(parentUserId))fail(400,'所属门店无效');return{username,password,displayName,mobile,parentUserId,disabled:payload.disabled===true}; }
  3376. async function createCoach(context,input,payload) {
  3377. requireRole(context,['ops-manager']);const reason=reasonOf(input),data=coachPayload(payload,true),companyId=companyIdOf(context,input);if(!companyId)fail(400,'新增陪练必须指定帐套');const company=await new Parse.Query('Company').get(companyId,{useMasterKey:true});await coachStore(context,input,data.parentUserId);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,data.username]);if(duplicate)fail(409,'此登录账号已被使用');
  3378. const fields=await fieldsOf('_User'),created=new Parse.User();created.setUsername(data.username);created.setPassword(data.password);created.set('company',company);if(fields.type)created.set('type','user');if(fields.isDisabled)created.set('isDisabled',data.disabled);if(fields.isDeleted)created.set('isDeleted',false);if(fields.legacyGroupId)created.set('legacyGroupId',3);if(fields.nickname)created.set('nickname',data.displayName);if(fields.realName)created.set('realName',data.displayName);if(fields.mobile)created.set('mobile',data.mobile);if(fields.isAdmin)created.set('isAdmin',false);if(fields.roles)created.set('roles',[]);try{await created.signUp(null,{useMasterKey:true});}catch(error){if(number(error&&error.code)===202)fail(409,'此登录账号已被使用');fail(422,'陪练账号创建失败:'+text(error&&error.message));}
  3379. try{const legacy={UserID:0,UserName:data.username,HoneyName:data.displayName,GroupID:3,ParentUserID:data.parentUserId,VIP:0,RegTime:new Date().toISOString(),Purse:0,SilverCoin:0,UserExp:0,UserPoint:0,boffExp:0,State:data.disabled?0:1,OpsCreatedAt:new Date().toISOString()},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"=3,"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(legacy)]),userId=number(rows[0]&&rows[0].id);if(!userId)throw new Error('无法分配陪练业务 ID');await audit(context,'create-coach','_User',created.id,reason,null,{userId,username:data.username,displayName:data.displayName,parentUserId:data.parentUserId,disabled:data.disabled});return{objectId:created.id,userId};}catch(error){await created.destroy({useMasterKey:true}).catch(()=>undefined);if(error&&error.status)throw error;fail(422,'陪练业务资料初始化失败:'+text(error&&error.message));}
  3380. }
  3381. async function scopedCoachObject(context,input) { const objectId=text(input.targetId);if(!objectId)fail(400,'缺少陪练记录');const target=await new Parse.Query('_User').get(objectId,{useMasterKey:true}),companyId=companyIdOf(context,input),targetCompanyId=pointerId(target.get('company'));if(companyId&&targetCompanyId!==companyId)fail(404,'陪练不存在或不属于当前帐套');return target; }
  3382. async function updateCoach(context,input,payload) {
  3383. requireRole(context,['ops-manager']);const reason=reasonOf(input),data=coachPayload(payload,false),target=await scopedCoachObject(context,input);assertVersion(target,input.expectedUpdatedAt);const legacy={...(target.get('legacyUserData')||{})};if(number(target.get('legacyGroupId')||legacy.GroupID)!==3)fail(400,'目标账号不是陪练老师');await coachStore(context,input,data.parentUserId);const companyId=pointerId(target.get('company')||context.company),duplicate=await Psql.oneOrNone('SELECT "objectId" FROM "_User" WHERE "company"=$1 AND "username"=$2 AND "objectId"<>$3 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1',[companyId,data.username,target.id]);if(duplicate)fail(409,'此登录账号已被使用');const before={username:text(target.get('username')),displayName:displayName(target.toJSON()),mobile:mobileOf(target.toJSON()),parentUserId:number(legacy.ParentUserID),disabled:target.get('isDisabled')===true};target.set('username',data.username);target.set('nickname',data.displayName);target.set('realName',data.displayName);target.set('mobile',data.mobile);target.set('isDisabled',data.disabled);if(data.password)target.set('password',data.password);legacy.UserName=data.username;legacy.HoneyName=data.displayName;legacy.GroupID=3;legacy.ParentUserID=data.parentUserId;legacy.State=data.disabled?0:1;target.set('legacyGroupId',3);target.set('legacyUserData',legacy);await target.save(null,{useMasterKey:true});if(data.password||data.disabled){const query=new Parse.Query('_Session');query.equalTo('user',target);query.limit(1000);const sessions=await query.find({useMasterKey:true});if(sessions.length)await Parse.Object.destroyAll(sessions,{useMasterKey:true});}await audit(context,'update-coach','_User',target.id,reason,before,{username:data.username,displayName:data.displayName,mobile:data.mobile,parentUserId:data.parentUserId,disabled:data.disabled});return{objectId:target.id};
  3384. }
  3385. async function deleteCoach(context,input) {
  3386. requireRole(context,['ops-manager']);const reason=reasonOf(input),target=await scopedCoachObject(context,input);assertVersion(target,input.expectedUpdatedAt);const legacy={...(target.get('legacyUserData')||{})},coachId=number(target.get('legacyUserId')||legacy.UserID);if(number(target.get('legacyGroupId')||legacy.GroupID)!==3)fail(400,'目标账号不是陪练老师');const today=businessDate(),stored=await allAppointmentRows(context,input),live=await liveAppointmentRows({dateFrom:today}),active=stored.concat(live.allItems).filter((row)=>row.coachId===coachId&&!row.cancelled&&row.statusCode<30&&businessDate(row.startsAt)>=today);if(active.length)fail(409,'该老师还有 '+active.length+' 节未完成排课,请先改派或取消后再删除');const before={displayName:displayName(target.toJSON()),coachId,isDeleted:target.get('isDeleted')===true,isDisabled:target.get('isDisabled')===true};target.set('isDisabled',true);target.set('isDeleted',true);legacy.State=0;legacy.OpsDeletedAt=new Date().toISOString();target.set('legacyUserData',legacy);await target.save(null,{useMasterKey:true});const query=new Parse.Query('_Session');query.equalTo('user',target);query.limit(1000);const sessions=await query.find({useMasterKey:true});if(sessions.length)await Parse.Object.destroyAll(sessions,{useMasterKey:true});await audit(context,'delete-coach','_User',target.id,reason,before,{isDeleted:true,isDisabled:true});return{objectId:target.id,deleted:true};
  3387. }
  3388. function businessDate(value=new Date()) { return new Date(value).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}); }
  3389. async function appointmentCountsForBusinessDate(context,input,date=businessDate()) { const appointments=await allAppointmentRows(context,input),active=appointments.filter((row)=>!row.cancelled&&businessDate(row.startsAt)===date),byCoach=new Map();for(const row of active)if(row.coachId)byCoach.set(row.coachId,(byCoach.get(row.coachId)||0)+1);return{active,byCoach}; }
  3390. function appointmentHistory(rows) { const byCoach=new Map(),dates=[],now=new Date().toISOString();let totalAppointments=0;for(const row of rows){if(row.cancelled)continue;totalAppointments+=1;const date=row.startsAt?businessDate(row.startsAt):'';if(/^\d{4}-\d{2}-\d{2}$/.test(date))dates.push(date);if(!row.coachId)continue;const current=byCoach.get(row.coachId)||{totalAppointments:0,lastAppointmentAt:'',nextAppointmentAt:''};current.totalAppointments+=1;if(row.startsAt>=now&&number(row.statusCode)<30){if(!current.nextAppointmentAt||row.startsAt<current.nextAppointmentAt)current.nextAppointmentAt=row.startsAt;}else if(row.startsAt<now&&row.startsAt>current.lastAppointmentAt)current.lastAppointmentAt=row.startsAt;byCoach.set(row.coachId,current);}dates.sort();return{byCoach,totalAppointments,dataDateFrom:dates[0]||'',dataDateTo:dates[dates.length-1]||''};}
  3391. function coachActivityCompare(a,b) { const aNext=text(a.nextAppointmentAt),bNext=text(b.nextAppointmentAt);if(Boolean(aNext)!==Boolean(bNext))return aNext?-1:1;if(aNext&&bNext){const futureOrder=aNext.localeCompare(bNext);if(futureOrder)return futureOrder;}const aLast=text(a.lastAppointmentAt),bLast=text(b.lastAppointmentAt);if(Boolean(aLast)!==Boolean(bLast))return aLast?-1:1;if(aLast&&bLast){const historyOrder=bLast.localeCompare(aLast);if(historyOrder)return historyOrder;}return text(a.displayName).localeCompare(text(b.displayName),'zh-CN')||number(a.userId)-number(b.userId);}
  3392. function mergeAppointments(stored,liveRows) { const merged=new Map();for(const row of stored)merged.set(row.generalId?'general:'+row.generalId:'target:'+row.targetId,row);for(const row of liveRows)merged.set(row.generalId?'general:'+row.generalId:'target:'+row.targetId,row);return Array.from(merged.values()); }
  3393. async function appointmentSnapshot(context,input,dateFrom=businessDate()) { const stored=await allAppointmentRows(context,input),legacyRefresh=input.legacyRefresh===true,live=legacyRefresh?await cachedLiveAppointmentRows(context,input,dateFrom,true):{available:true,mode:'parse',message:'新数据库排课投影',items:[],allItems:[],total:stored.length,latestAt:stored.reduce((latest,row)=>row.startsAt>latest?row.startsAt:latest,''),refreshedAt:new Date().toISOString(),cacheHit:false,stale:false},appointments=legacyRefresh?mergeAppointments(stored,live.allItems):stored;return{stored,live,appointments}; }
  3394. function professionalCoachPageFromBase(context,input,month,base){const paging=pageInput(input),status=text(paging.filters.status||'all'),filtered=searchRows((base.rows||[]).filter((row)=>status==='all'||row.status===status),input).sort(coachActivityCompare),page=pageRows(filtered,input);return{...page,summary:base.summary||{total:(base.rows||[]).length,active:(base.rows||[]).filter((row)=>row.status==='active').length,disabled:(base.rows||[]).filter((row)=>row.status==='disabled').length,monthActive:(base.rows||[]).filter((row)=>row.monthLessons>0).length,liveOnly:(base.rows||[]).filter((row)=>row.readOnly===true).length},canManage:roleOf(context)==='ops-manager',stores:base.stores||[],liveSourceAvailable:base.liveSourceAvailable===true,liveSourceMessage:text(base.liveSourceMessage),liveSourceCount:number(base.liveSourceCount),dataDateFrom:text(base.dataDateFrom),dataDateTo:text(base.dataDateTo),payrollMonth:month,payrollSourceStatus:base.payrollSourceStatus,payrollRefreshedAt:text(base.payrollRefreshedAt),sync:base.sync};}
  3395. async function professionalCoachRows(context,input,providedSnapshot=null,providedPayroll=null) {
  3396. const month=/^\d{4}-\d{2}$/.test(text(input.month))?text(input.month):businessDate().slice(0,7),baseKey='coach-page-base-v6:'+month+':'+roleOf(context);if(!providedSnapshot&&!providedPayroll&&input.refresh!==true){const cached=await realtimeCacheRead(context,input,baseKey);if(cached&&cached.age<REALTIME_CACHE_TTL_MS)return professionalCoachPageFromBase(context,input,month,cached.payload);}
  3397. const [local,snapshot,payroll,legacyAccounts]=await Promise.all([coachRows(context,{...input,page:1,pageSize:100,search:'',filters:{...(input.filters||{}),status:'all'}},false),providedSnapshot||appointmentSnapshot(context,input,month+'-01'),providedPayroll||payrollOverviewData(context,{...input,month,storeId:0,search:'',refresh:input.refresh===true}),optionalLegacyMemberSnapshot(context,input)]),history=appointmentHistory(snapshot.appointments),today=businessDate(),todayCounts=new Map(),pendingCounts=new Map(),payrollMap=new Map(payroll.coachItems.map((row)=>[number(row.coachId),row])),accountMap=new Map((legacyAccounts.items||[]).map((row)=>[number(row.userId),row]));
  3398. for(const row of snapshot.appointments){if(!row.coachId||row.cancelled)continue;if(businessDate(row.startsAt)===today)todayCounts.set(row.coachId,(todayCounts.get(row.coachId)||0)+1);if(row.statusCode<30&&businessDate(row.startsAt)>=today)pendingCounts.set(row.coachId,(pendingCounts.get(row.coachId)||0)+1);}
  3399. const rows=local.map((row)=>{const pay=payrollMap.get(row.userId),account=accountMap.get(row.userId),accountName=account&&text(account.realName||account.nickname||account.username),mobile=text(account&&account.mobile)||row.mobile||row.mobileMasked,parentUserId=number(account&&account.parentUserId,row.parentUserId),status=account?(number(account.state,1)===0?'disabled':'active'):row.status;return{...row,username:text(account&&account.username)||row.username,displayName:accountName||row.displayName,mobile:roleOf(context)==='ops-manager'?mobile:'',mobileMasked:maskMobile(mobile),parentUserId,storeName:text(account&&account.parentDisplayName)||row.storeName,status,statusLabel:status==='disabled'?'已停用':'正常',todayAppointments:todayCounts.get(row.userId)||0,pendingAppointments:pendingCounts.get(row.userId)||0,...(history.byCoach.get(row.userId)||{totalAppointments:0,lastAppointmentAt:'',nextAppointmentAt:''}),monthLessons:number(pay&&pay.lessonCount),monthHours:number(pay&&pay.totalHours),estimatedPay:number(pay&&pay.payableAmount),averageScore:number(pay&&pay.averageScore,row.averageScore),lastLessonAt:text(pay&&pay.latestLessonAt)||row.lastLessonAt,registeredAt:text(account&&account.registeredAt),hasCurrentBusiness:Boolean(number(pay&&pay.lessonCount)||todayCounts.get(row.userId)||pendingCounts.get(row.userId)),source:'hybrid',sourceLabel:account?(payroll.sourceStatus.mode==='live'?'旧账号已关联 · 实时工资':'旧账号已关联 · 缓存工资'):(payroll.sourceStatus.mode==='live'?'新系统账号 · 实时工资':'新系统账号 · 缓存工资'),readOnly:false};}),knownCoachIds=new Set(rows.map((row)=>row.userId));
  3400. for(const coachIdRaw of legacyAccounts.coachIds||[]){const coachId=number(coachIdRaw),account=accountMap.get(coachId);if(!coachId||knownCoachIds.has(coachId)||!account)continue;knownCoachIds.add(coachId);const pay=payrollMap.get(coachId),coachHistory=history.byCoach.get(coachId)||{totalAppointments:0,lastAppointmentAt:'',nextAppointmentAt:''},mobile=text(account.mobile),status=number(account.state,1)===0?'disabled':'active';rows.push({objectId:'legacy-live-coach:'+coachId,userId:coachId,username:text(account.username),displayName:text(account.realName||account.nickname||account.username)||'陪练 '+coachId,mobile:roleOf(context)==='ops-manager'?mobile:'',mobileMasked:maskMobile(mobile),parentUserId:number(account.parentUserId),storeName:text(account.parentDisplayName)||'未绑定门店',status,statusLabel:status==='disabled'?'已停用':'正常',todayAppointments:todayCounts.get(coachId)||0,pendingAppointments:pendingCounts.get(coachId)||0,...coachHistory,monthLessons:number(pay&&pay.lessonCount),monthHours:number(pay&&pay.totalHours),estimatedPay:number(pay&&pay.payableAmount),averageScore:number(pay&&pay.averageScore),lastLessonAt:text(pay&&pay.latestLessonAt),registeredAt:text(account.registeredAt),hasCurrentBusiness:Boolean(number(pay&&pay.lessonCount)||todayCounts.get(coachId)||pendingCounts.get(coachId)),availabilityConfigured:false,updatedAt:text(account.updatedAt),source:'legacy-live',sourceLabel:pay?'旧系统账号 · 实时工资':'旧系统账号 · 历史授课证据',readOnly:true});}
  3401. for(const pay of payroll.coachItems){if(!pay.coachId||knownCoachIds.has(pay.coachId))continue;knownCoachIds.add(pay.coachId);const objectId=text(pay.coachObjectId),coachHistory=history.byCoach.get(pay.coachId)||{totalAppointments:0,lastAppointmentAt:''};rows.push({objectId:objectId||'legacy-payroll-coach:'+pay.coachId,userId:pay.coachId,username:'',displayName:pay.coachName,mobile:'',mobileMasked:'',parentUserId:pay.storeId,storeName:pay.storeName,status:'active',statusLabel:objectId?'正常':'实时账号',todayAppointments:todayCounts.get(pay.coachId)||0,pendingAppointments:pendingCounts.get(pay.coachId)||0,...coachHistory,monthLessons:pay.lessonCount,monthHours:pay.totalHours,estimatedPay:pay.payableAmount,averageScore:number(pay.averageScore),lastLessonAt:pay.latestLessonAt,availabilityConfigured:false,updatedAt:'',source:objectId?'hybrid':'legacy-live',sourceLabel:payroll.sourceStatus.mode==='live'?'实时工资记录':'缓存工资记录',readOnly:!objectId});}
  3402. for(const appointment of snapshot.live.allItems){if(!appointment.coachId||knownCoachIds.has(appointment.coachId))continue;knownCoachIds.add(appointment.coachId);const coachHistory=history.byCoach.get(appointment.coachId)||{totalAppointments:0,lastAppointmentAt:''};rows.push({objectId:'legacy-live-coach:'+appointment.coachId,userId:appointment.coachId,username:'',displayName:appointment.coachName,mobile:'',mobileMasked:'',parentUserId:0,storeName:'实时业务系统',status:'active',statusLabel:'实时账号',todayAppointments:todayCounts.get(appointment.coachId)||0,pendingAppointments:pendingCounts.get(appointment.coachId)||0,...coachHistory,monthLessons:0,monthHours:0,estimatedPay:0,averageScore:0,lastLessonAt:'',availabilityConfigured:false,updatedAt:appointment.updatedAt||'',source:'legacy-live',sourceLabel:'实时业务系统',readOnly:true});}
  3403. const companyId=companyIdOf(context,input),storeRows=await Psql.query('SELECT "objectId","username","realName","nickname","legacyUserId","legacyUserData" FROM "_User" WHERE ($1::text IS NULL OR "company"=$1) AND COALESCE(CAST("legacyGroupId" AS text),"legacyUserData"->>\'GroupID\')=\'2\' AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) ORDER BY COALESCE(NULLIF("realName",\'\'),NULLIF("nickname",\'\'),"username")',[companyId]),summary={total:rows.length,active:rows.filter((row)=>row.status==='active').length,disabled:rows.filter((row)=>row.status==='disabled').length,monthActive:rows.filter((row)=>row.monthLessons>0).length,liveOnly:rows.filter((row)=>row.readOnly===true).length},base={rows,summary,stores:storeRows.map((row)=>({objectId:text(row.objectId),userId:number(row.legacyUserId||legacyOf(row).UserID),displayName:displayName(row)})).filter((row)=>row.userId),liveSourceAvailable:true,liveSourceMessage:'新数据库陪练投影',liveSourceCount:snapshot.appointments.length,legacyRosterCount:rows.length,dataDateFrom:history.dataDateFrom,dataDateTo:history.dataDateTo,payrollSourceStatus:payroll.sourceStatus,payrollRefreshedAt:payroll.refreshedAt,sync:await operationalSyncIndicator(context,input)};await realtimeCacheWrite(context,input,baseKey,base,payroll.refreshedAt||legacyAccounts.refreshedAt||new Date().toISOString());return professionalCoachPageFromBase(context,input,month,base);
  3404. }
  3405. async function professionalCoachDetail(context,input,objectId) {
  3406. const month=/^\d{4}-\d{2}$/.test(text(input.month))?text(input.month):businessDate().slice(0,7),detailKey='coach-detail:'+objectId+':'+month+':'+roleOf(context);if(input.refresh!==true){const cached=await realtimeCacheRead(context,input,detailKey);if(cached&&cached.age<REALTIME_CACHE_TTL_MS)return cached.payload;}
  3407. const [payroll,snapshot,availability,legacyAccounts]=await Promise.all([payrollOverviewData(context,{...input,month,storeId:0,search:'',refresh:input.refresh===true}),appointmentSnapshot(context,input,month+'-01'),availabilityRows(context,input),optionalLegacyMemberSnapshot(context,input)]),syntheticCoachId=number((objectId.match(/:(\d+)$/)||[])[1]),selectedBase=await realtimeCacheRead(context,input,'coach-page-base-v6:'+month+':'+roleOf(context)),currentBase=selectedBase||await realtimeCacheRead(context,input,'coach-page-base-v6:'+businessDate().slice(0,7)+':'+roleOf(context));let coach=currentBase&&currentBase.payload&&Array.isArray(currentBase.payload.rows)?currentBase.payload.rows.find((row)=>row.objectId===objectId):null;
  3408. if(!coach&&!syntheticCoachId){const local=await coachRows(context,{...input,page:1,pageSize:100,search:'',filters:{status:'all'}},false);coach=local.find((row)=>row.objectId===objectId);}
  3409. if(!coach&&syntheticCoachId){const pay=payroll.coachItems.find((row)=>row.coachId===syntheticCoachId),appointment=snapshot.appointments.find((row)=>row.coachId===syntheticCoachId),account=(legacyAccounts.items||[]).find((row)=>number(row.userId)===syntheticCoachId),mobile=text(account&&account.mobile),status=account&&number(account.state,1)===0?'disabled':'active';coach={objectId,userId:syntheticCoachId,username:text(account&&account.username),displayName:text(account&&(account.realName||account.nickname||account.username)||pay&&pay.coachName||appointment&&appointment.coachName)||'陪练 '+syntheticCoachId,mobile:roleOf(context)==='ops-manager'?mobile:'',mobileMasked:maskMobile(mobile),parentUserId:number(account&&account.parentUserId||pay&&pay.storeId),storeName:text(account&&account.parentDisplayName||pay&&pay.storeName)||'未绑定门店',status,statusLabel:status==='disabled'?'已停用':'正常',todayAppointments:0,pendingAppointments:0,totalAppointments:0,lastAppointmentAt:'',monthLessons:number(pay&&pay.lessonCount),monthHours:number(pay&&pay.totalHours),estimatedPay:number(pay&&pay.payableAmount),averageScore:number(pay&&pay.averageScore),lastLessonAt:text(pay&&pay.latestLessonAt),availabilityConfigured:false,updatedAt:text(account&&account.updatedAt),source:'legacy-live',sourceLabel:pay?'旧系统账号 · 实时工资':'旧系统账号 · 历史授课证据',readOnly:true};}
  3410. if(!coach)fail(404,'陪练不存在或尚未完成身份同步');
  3411. const coachId=coach.userId,current=payroll.coachItems.find((row)=>row.coachId===coachId)||{coachId,coachName:coach.displayName,storeId:coach.parentUserId,storeName:coach.storeName,type30Count:0,type60Count:0,trialCount:0,type4Count:0,lessonCount:0,totalHours:0,baseAmount:0,adjustmentAmount:0,payableAmount:0,averageScore:0,latestLessonAt:''},review=await Psql.one('SELECT COUNT(*)::int AS total FROM "MemoryPracticeRecord" WHERE ($1::text IS NULL OR "company"=$1) AND CAST("plid" AS text)=$2',[companyIdOf(context,input),String(coachId)]).catch(()=>({total:0})),appointments=snapshot.appointments.filter((row)=>row.coachId===coachId&&row.startsAt.slice(0,7)===month).sort((a,b)=>b.startsAt.localeCompare(a.startsAt)).slice(0,500),lessons=payroll.lines.filter((row)=>row.coachId===coachId).sort((a,b)=>b.lessonAt.localeCompare(a.lessonAt)).slice(0,300),storedPayrollLines=(await newClassObjects('PayrollLine',context,input,10000)).map(payrollLine).filter((row)=>row.coachId===coachId&&text(row.settlementMonth)===month),history=payroll.batches.filter((batch)=>batch.month===month).map((batch)=>{const rows=storedPayrollLines.filter((line)=>line.batchId===batch.objectId&&!line.excluded);if(!rows.length)return null;const totals=payrollResult(month,rows,payroll.sourceStatus,{});return{...batch,coachCount:1,lessonCount:totals.lessonCount,totalHours:totals.totalHours,baseAmount:totals.baseAmount,reviewCount:totals.reviewCount,reviewAmount:totals.reviewAmount,adjustmentAmount:totals.adjustmentAmount,payableAmount:totals.payableAmount,exceptionCount:totals.exceptionCount};}).filter(Boolean),previewSummary={objectId:'preview:'+month,month,status:'draft',statusLabel:'实时预估',coachCount:1,lessonCount:current.lessonCount,totalHours:current.totalHours,baseAmount:current.baseAmount,reviewCount:current.reviewCount||0,reviewAmount:current.reviewAmount||0,adjustmentAmount:0,payableAmount:current.payableAmount,exceptionCount:lessons.filter((row)=>row.exception).length,type4Count:current.type4Count,reviewedAt:'',paidAt:'',paymentMethod:'',paymentReference:'',updatedAt:payroll.refreshedAt},monthHistory=appointmentHistory(appointments);
  3412. const result={coach,profile:{username:coach.username,mobile:coach.mobile||coach.mobileMasked,storeName:coach.storeName,statusLabel:coach.statusLabel,sourceLabel:coach.sourceLabel},currentPayroll:current,availability:availability.filter((row)=>row.coachId===coachId),appointments,lessons,reviews:lessons.filter((row)=>row.score>0||row.comment),payrolls:[previewSummary,...history],reviewTaskCount:number(review.total),payrollMonth:month,payrollSourceStatus:payroll.sourceStatus,refreshedAt:payroll.refreshedAt,dataDateFrom:monthHistory.dataDateFrom,dataDateTo:monthHistory.dataDateTo};await realtimeCacheWrite(context,input,detailKey,result,payroll.refreshedAt||new Date().toISOString());return result;
  3413. }
  3414. function storeNameOf(row){const legacy=legacyOf(row);return text(row.realName||legacy.CompanyName||row.username||legacy.HoneyName)||'未命名门店';}
  3415. function storeMobileOf(row){const legacy=legacyOf(row),direct=mobileOf(row),remark=text(legacy.Remark);return direct||(/^1\d{10}$/.test(remark)?remark:'');}
  3416. async function storeRows(context,input){
  3417. const companyId=companyIdOf(context,input),[baseRows,localChildRows,localCoaches,snapshot,legacyAccounts]=await Promise.all([
  3418. Psql.query('SELECT u.*,p."username" AS "parentUsername",p."realName" AS "parentRealName",p."nickname" AS "parentNickname",p."legacyUserData" AS "parentLegacy" FROM "_User" u LEFT JOIN "_User" p ON p."company"=u."company" AND COALESCE(CAST(p."legacyUserId" AS text),p."legacyUserData"->>\'UserID\')=COALESCE(u."legacyUserData"->>\'ParentUserID\',\'0\') WHERE ($1::text IS NULL OR u."company"=$1) AND COALESCE(CAST(u."legacyGroupId" AS text),u."legacyUserData"->>\'GroupID\')=\'2\' AND (u."isDeleted" IS NULL OR u."isDeleted"=FALSE) ORDER BY u."createdAt" DESC',[companyId]),
  3419. Psql.query('SELECT "objectId",COALESCE(CAST("legacyUserId" AS text),"legacyUserData"->>\'UserID\') AS uid,COALESCE("legacyUserData"->>\'ParentUserID\',\'0\') AS sid,COALESCE(CAST("legacyGroupId" AS text),"legacyUserData"->>\'GroupID\',\'0\') AS gid,COALESCE("identityType",\'\') AS identity_type FROM "_User" WHERE ($1::text IS NULL OR "company"=$1) AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) AND COALESCE("isAdmin",FALSE)=FALSE',[companyId]),
  3420. coachRows(context,{...input,page:1,pageSize:100,search:'',filters:{status:'all'}},false),
  3421. appointmentSnapshot(context,input),
  3422. optionalLegacyMemberSnapshot(context,input),
  3423. ]),liveItems=legacyAccounts.available?legacyAccounts.items||[]:[],liveById=new Map(liveItems.map((row)=>[number(row.userId),row])),liveIds=new Set(liveById.keys()),coachIds=new Set((legacyAccounts.coachIds||[]).map(number).filter(Boolean)),children=new Map(),addChild=(storeId,type)=>{if(!storeId||!type)return;const current=children.get(storeId)||{members:0,coaches:0};if(type==='coach')current.coaches++;else current.members++;children.set(storeId,current);},projectedMembers=await queryUsers(context,input);
  3424. for(const member of projectedMembers)addChild(number(member.parentUserId),'member');
  3425. const activeStudyingCounts=new Map();
  3426. for(const member of filterMembers(projectedMembers,{filters:{learningStatus:'studying',status:'active'}})){const storeId=number(member.parentUserId);if(storeId)activeStudyingCounts.set(storeId,(activeStudyingCounts.get(storeId)||0)+1);}
  3427. if(legacyAccounts.available)for(const coachId of coachIds){const account=liveById.get(coachId);if(account)addChild(number(account.parentUserId),'coach');}
  3428. if(!legacyAccounts.available)for(const row of localChildRows){const userId=number(row.uid),storeId=number(row.sid);if(!storeId||liveIds.has(userId)||number(row.gid)===2)continue;const identity=text(row.identity_type);if(identity==='coach')addChild(storeId,'coach');else if(identity==='member'&&!projectedMembers.some((member)=>number(member.userId)===userId))addChild(storeId,'member');}
  3429. const coachStore=new Map(localCoaches.map((row)=>[number(row.userId),number(row.parentUserId)]));for(const coachId of coachIds){const account=liveById.get(coachId);if(account&&number(account.parentUserId))coachStore.set(coachId,number(account.parentUserId));}const appointmentMap=new Map(),today=businessDate();
  3430. for(const appointment of snapshot.appointments){const storeId=coachStore.get(number(appointment.coachId));if(!storeId||appointment.cancelled)continue;const entry=appointmentMap.get(storeId)||{todayAppointments:0,totalAppointments:0,lastAppointmentAt:''};entry.totalAppointments++;if(businessDate(appointment.startsAt)===today)entry.todayAppointments++;if(appointment.startsAt>entry.lastAppointmentAt)entry.lastAppointmentAt=appointment.startsAt;appointmentMap.set(storeId,entry);}
  3431. const localRows=baseRows.map((row)=>{const legacy=legacyOf(row),userId=number(row.legacyUserId||legacy.UserID),account=liveById.get(userId),mobile=text(account&&account.mobile)||storeMobileOf(row),child=children.get(userId)||{},appointments=appointmentMap.get(userId)||{},parentLegacy=legacyOf({legacyUserData:row.parentLegacy}),parentStoreId=number(account&&account.parentUserId||legacy.ParentUserID),storeName=text(account&&(account.companyName||account.username||account.nickname||account.realName))||storeNameOf(row),contactName=text(account&&account.realName||account&&account.nickname||row.nickname||legacy.HoneyName),disabled=account?number(account.state,1)===0:row.isDisabled===true;return{objectId:text(row.objectId),userId,username:text(account&&account.username||row.username),storeName,contactName:contactName===storeName?'':contactName,mobile:roleOf(context)==='ops-manager'?mobile:'',mobileMasked:maskMobile(mobile),parentStoreId,parentStoreName:text(account&&account.parentDisplayName||row.parentRealName||row.parentUsername||parentLegacy.CompanyName||parentLegacy.HoneyName),memberCount:number(child.members),activeStudyingMemberCount:activeStudyingCounts.get(userId)||0,coachCount:number(child.coaches),todayAppointments:number(appointments.todayAppointments),totalAppointments:number(appointments.totalAppointments),lastAppointmentAt:text(appointments.lastAppointmentAt),registeredAt:text(account&&account.registeredAt)||registeredAtOf(row),lastLoginAt:text(account&&account.lastLoginAt)||firstDateIso(legacy.LastLoginTime,row.lastLoginAt),loginCount:number(account&&account.loginCount,legacy.LoginTimes||legacy.LoginCount),status:disabled?'disabled':'active',statusLabel:disabled?'已停用':'正常',updatedAt:dateIso(row.updatedAt),source:account?'hybrid':'parse',sourceLabel:account?'旧系统账号已关联':'新系统门店账号',readOnly:false};}),knownIds=new Set(localRows.map((row)=>row.userId));let rows=localRows.slice();
  3432. if(legacyAccounts.available)for(const account of liveItems){const userId=number(account.userId);if(number(account.groupId)!==2||!userId||knownIds.has(userId))continue;knownIds.add(userId);const mobile=text(account.mobile),child=children.get(userId)||{},appointments=appointmentMap.get(userId)||{},storeName=text(account.companyName||account.nickname||account.realName||account.username)||'门店 '+userId,contactName=text(account.realName),disabled=number(account.state,1)===0;rows.push({objectId:'legacy-live-store:'+userId,userId,username:text(account.username),storeName,contactName:contactName===storeName?'':contactName,mobile:roleOf(context)==='ops-manager'?mobile:'',mobileMasked:maskMobile(mobile),parentStoreId:number(account.parentUserId),parentStoreName:text(account.parentDisplayName),memberCount:number(child.members),activeStudyingMemberCount:activeStudyingCounts.get(userId)||0,coachCount:number(child.coaches),todayAppointments:number(appointments.todayAppointments),totalAppointments:number(appointments.totalAppointments),lastAppointmentAt:text(appointments.lastAppointmentAt),registeredAt:text(account.registeredAt),lastLoginAt:text(account.lastLoginAt),loginCount:number(account.loginCount),status:disabled?'disabled':'active',statusLabel:disabled?'已停用':'正常',updatedAt:text(account.updatedAt),source:'legacy-live',sourceLabel:'旧系统实时门店 · 待同步',readOnly:true});}
  3433. const allRows=rows.slice(),paging=pageInput(input),status=text(paging.filters.status||'all');if(status!=='all')rows=rows.filter((row)=>row.status===status);rows=searchRows(rows,input).sort((a,b)=>b.registeredAt.localeCompare(a.registeredAt)||b.userId-a.userId);const page=pageRows(rows,input),summary={total:allRows.length,active:allRows.filter((row)=>row.status==='active').length,disabled:allRows.filter((row)=>row.status==='disabled').length,members:allRows.reduce((sum,row)=>sum+row.memberCount,0),unboundMembers:projectedMembers.filter((row)=>!number(row.parentUserId)||!knownIds.has(number(row.parentUserId))).length,coaches:allRows.reduce((sum,row)=>sum+row.coachCount,0),todayAppointments:allRows.reduce((sum,row)=>sum+row.todayAppointments,0),liveOnly:allRows.filter((row)=>row.readOnly).length},parents=localRows.map((row)=>({objectId:row.objectId,userId:row.userId,displayName:row.storeName})).filter((row)=>row.userId);
  3434. const sourceMessages=[text(snapshot.live.message),text(legacyAccounts.message)].filter(Boolean);return{...page,summary,parents,canManage:roleOf(context)==='ops-manager',liveSourceAvailable:true,liveSourceMessage:sourceMessages.join(';')||'新数据库门店投影',legacyStoreCount:allRows.length,sync:await operationalSyncIndicator(context,input)};
  3435. }
  3436. async function storeDetailData(context,input){
  3437. const objectId=text(input.objectId||input.targetId),month=/^\d{4}-\d{2}$/.test(text(input.month))?text(input.month):businessDate().slice(0,7),cacheKey='store-detail:'+objectId+':'+month+':'+roleOf(context);if(!objectId)fail(400,'缺少门店记录');if(input.refresh!==true){const cached=await realtimeCacheRead(context,input,cacheKey);if(cached&&cached.age<REALTIME_CACHE_TTL_MS)return cached.payload;}
  3438. const target=await scopedStoreObject(context,{...input,targetId:objectId}),legacy=legacyOf(target.toJSON()),storeId=number(target.get('legacyUserId')||legacy.UserID),parentStoreId=number(legacy.ParentUserID),companyId=pointerId(target.get('company')||context.company);if(!storeId)fail(409,'门店缺少旧系统业务ID,暂时无法归集经营数据');
  3439. const [memberRows,localCoachRows,snapshot,payroll,parentStore,sync]=await Promise.all([
  3440. queryUsers(context,{...input,refresh:false}),
  3441. coachRows(context,{...input,page:1,pageSize:100,search:'',filters:{status:'all'}},false),
  3442. appointmentSnapshot(context,input,month+'-01'),
  3443. payrollOverviewData(context,{...input,month,storeId,search:'',refresh:input.refresh===true}),
  3444. parentStoreId?userByLegacyId(context,input,parentStoreId,2).catch(()=>null):Promise.resolve(null),
  3445. operationalSyncIndicator(context,input),
  3446. ]),members=memberRows.filter((row)=>number(row.parentUserId)===storeId).sort((a,b)=>b.registeredAt.localeCompare(a.registeredAt)||b.userId-a.userId),memberStoreById=new Map(memberRows.map((row)=>[number(row.userId),number(row.parentUserId)])),payrollMap=new Map(payroll.coachItems.map((row)=>[number(row.coachId),row])),coachMap=new Map(),coachStoreById=new Map();
  3447. for(const row of localCoachRows){coachStoreById.set(number(row.userId),number(row.parentUserId));if(number(row.parentUserId)!==storeId)continue;const pay=payrollMap.get(number(row.userId));coachMap.set(number(row.userId),{...row,monthLessons:number(pay&&pay.lessonCount),monthHours:number(pay&&pay.totalHours),estimatedPay:number(pay&&pay.payableAmount),averageScore:number(pay&&pay.averageScore,row.averageScore),lastLessonAt:text(pay&&pay.latestLessonAt)||row.lastLessonAt,source:'hybrid',sourceLabel:payroll.sourceStatus.mode==='live'?'门店账号 · 实时工资':'门店账号 · 缓存工资',readOnly:false});}
  3448. for(const pay of payroll.coachItems){coachStoreById.set(number(pay.coachId),storeId);if(!pay.coachId||coachMap.has(number(pay.coachId)))continue;coachMap.set(number(pay.coachId),{objectId:text(pay.coachObjectId)||'legacy-payroll-coach:'+number(pay.coachId),userId:number(pay.coachId),username:'',displayName:text(pay.coachName)||'陪练 '+number(pay.coachId),mobile:'',mobileMasked:'',parentUserId:storeId,storeName:storeNameOf(target.toJSON()),status:'active',statusLabel:text(pay.coachObjectId)?'正常':'实时账号',todayAppointments:0,pendingAppointments:0,totalAppointments:0,lastAppointmentAt:'',monthLessons:number(pay.lessonCount),monthHours:number(pay.totalHours),estimatedPay:number(pay.payableAmount),averageScore:number(pay.averageScore),lastLessonAt:text(pay.latestLessonAt),availabilityConfigured:false,updatedAt:'',source:'legacy-live',sourceLabel:payroll.sourceStatus.mode==='live'?'实时工资记录':'缓存工资记录',readOnly:!text(pay.coachObjectId)});}
  3449. const assignedToStore=(row)=>{const memberStore=memberStoreById.get(number(row.studentId));return memberStore===storeId||(memberStore===undefined&&coachStoreById.get(number(row.coachId))===storeId);},allAssigned=snapshot.appointments.filter(assignedToStore),appointments=allAssigned.filter((row)=>row.startsAt&&row.startsAt.slice(0,7)===month).sort((a,b)=>b.startsAt.localeCompare(a.startsAt)),activeAppointments=appointments.filter((row)=>!row.cancelled),today=businessDate(),todayCounts=new Map(),pendingCounts=new Map(),historyByCoach=new Map();
  3450. for(const row of allAssigned){if(!row.coachId||row.cancelled)continue;const history=historyByCoach.get(row.coachId)||{totalAppointments:0,lastAppointmentAt:''};history.totalAppointments++;if(row.startsAt>history.lastAppointmentAt)history.lastAppointmentAt=row.startsAt;historyByCoach.set(row.coachId,history);if(businessDate(row.startsAt)===today)todayCounts.set(row.coachId,(todayCounts.get(row.coachId)||0)+1);if(row.statusCode<30)pendingCounts.set(row.coachId,(pendingCounts.get(row.coachId)||0)+1);}
  3451. const coaches=Array.from(coachMap.values()).map((row)=>({...row,todayAppointments:todayCounts.get(row.userId)||0,pendingAppointments:pendingCounts.get(row.userId)||0,...(historyByCoach.get(row.userId)||{totalAppointments:0,lastAppointmentAt:''})})).sort((a,b)=>a.displayName.localeCompare(b.displayName,'zh-CN')),courseMap=new Map();
  3452. for(const row of activeAppointments){const key=number(row.courseId)||text(row.courseName)||'unknown',course=courseMap.get(key)||{courseId:number(row.courseId),courseName:text(row.courseName)||'未命名课程',appointmentCount:0,completedCount:0,students:new Set(),lastAppointmentAt:''};course.appointmentCount++;if(row.statusCode>=30)course.completedCount++;if(row.studentId)course.students.add(row.studentId);if(row.startsAt>course.lastAppointmentAt)course.lastAppointmentAt=row.startsAt;courseMap.set(key,course);}
  3453. const courses=Array.from(courseMap.values()).map((row)=>({courseId:row.courseId,courseName:row.courseName,appointmentCount:row.appointmentCount,completedCount:row.completedCount,studentCount:row.students.size,completionRate:row.appointmentCount?row.completedCount/row.appointmentCount*100:0,lastAppointmentAt:row.lastAppointmentAt})).sort((a,b)=>b.appointmentCount-a.appointmentCount||a.courseName.localeCompare(b.courseName,'zh-CN')),mobile=storeMobileOf(target.toJSON()),parentName=parentStore?storeNameOf(parentStore.toJSON()):'',store={objectId:target.id,userId:storeId,username:text(target.get('username')),storeName:storeNameOf(target.toJSON()),contactName:text(target.get('nickname')||legacy.HoneyName),mobile:roleOf(context)==='ops-manager'?mobile:'',mobileMasked:maskMobile(mobile),parentStoreId,parentStoreName:parentName,memberCount:members.length,coachCount:coaches.length,todayAppointments:activeAppointments.filter((row)=>businessDate(row.startsAt)===today).length,totalAppointments:allAssigned.filter((row)=>!row.cancelled).length,lastAppointmentAt:allAssigned.reduce((latest,row)=>row.startsAt>latest?row.startsAt:latest,''),registeredAt:registeredAtOf(target.toJSON()),lastLoginAt:firstDateIso(legacy.LastLoginTime,target.get('lastLoginAt')),loginCount:number(legacy.LoginTimes||legacy.LoginCount),status:target.get('isDisabled')===true?'disabled':'active',statusLabel:target.get('isDisabled')===true?'已停用':'正常',updatedAt:dateIso(target.updatedAt)},activeStudents=new Set(activeAppointments.map((row)=>number(row.studentId)).filter(Boolean)),summary={memberCount:members.length,coachCount:coaches.length,monthAppointments:activeAppointments.length,completedAppointments:activeAppointments.filter((row)=>row.statusCode>=30).length,activeStudents:activeStudents.size,lessonCount:number(payroll.lessonCount),totalHours:number(payroll.totalHours),estimatedCoachPay:number(payroll.payableAmount)},result={store,month,summary,members,coaches,appointments,courses,commissionStatus:{available:false,message:'旧系统源码只确认了陪练课次工资规则,没有可验证的门店佣金比例或结算口径;为避免误导运营人员,暂不生成门店佣金估算。'},payrollSourceStatus:payroll.sourceStatus,refreshedAt:new Date().toISOString(),sync};await realtimeCacheWrite(context,input,cacheKey,result,payroll.refreshedAt||new Date().toISOString());return result;
  3454. }
  3455. function storePayload(payload,creating=false){const username=text(payload.username),password=text(payload.password),storeName=text(payload.storeName),contactName=text(payload.contactName),mobile=text(payload.mobile),parentStoreId=number(payload.parentStoreId);if(username.length<2||username.length>64)fail(400,'门店登录账号长度应为 2 至 64 位');if(!storeName||storeName.length>100)fail(400,'门店名称为必填项且不能超过 100 位');if(contactName.length>100)fail(400,'联系人姓名不能超过 100 位');if(mobile&&!/^1\d{10}$/.test(mobile))fail(400,'请输入有效的 11 位手机号');if((creating||password)&&(password.length<8||password.length>72))fail(400,'密码长度应为 8 至 72 位');if(parentStoreId<0||!Number.isInteger(parentStoreId))fail(400,'上级门店无效');return{username,password,storeName,contactName,mobile,parentStoreId,disabled:payload.disabled===true};}
  3456. async function validateStoreParent(context,input,parentStoreId,ownUserId=0){if(!parentStoreId)return null;if(parentStoreId===ownUserId)fail(400,'门店不能归属于自己');let cursor=parentStoreId;for(let depth=0;depth<20&&cursor;depth++){if(cursor===ownUserId)fail(400,'门店层级不能形成循环');const parent=await userByLegacyId(context,input,cursor,2);if(!parent)fail(404,'上级门店不存在');cursor=number(legacyOf(parent).ParentUserID);}if(cursor)fail(409,'门店层级超过 20 级,请检查历史关系');return userByLegacyId(context,input,parentStoreId,2);}
  3457. async function scopedStoreObject(context,input){const objectId=text(input.targetId);if(!objectId)fail(400,'缺少门店记录');const target=await new Parse.Query('_User').get(objectId,{useMasterKey:true}),companyId=companyIdOf(context,input),targetCompanyId=pointerId(target.get('company'));if(companyId&&targetCompanyId!==companyId)fail(404,'门店不存在或不属于当前帐套');const legacy=target.get('legacyUserData')||{};if(number(target.get('legacyGroupId')||legacy.GroupID)!==2)fail(404,'门店不存在或身份已发生变化');return target;}
  3458. async function createStore(context,input,payload){requireRole(context,['ops-manager']);const reason=reasonOf(input),data=storePayload(payload,true),company=companyPointer(context,input),companyId=companyIdOf(context,input);if(!company||!companyId)fail(400,'新增门店必须指定帐套');await validateStoreParent(context,input,data.parentStoreId);const duplicate=await Psql.oneOrNone('SELECT "objectId" FROM "_User" WHERE "company"=$1 AND (LOWER("username")=LOWER($2) OR ($3<>\'\' AND COALESCE("mobile","phone",\'\')=$3)) AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1',[companyId,data.username,data.mobile]);if(duplicate)fail(409,'登录账号或手机号已被使用');const registeredAt=new Date(),target=new Parse.User();target.setUsername(data.username);target.setPassword(data.password);target.set('company',company);target.set('type','user');target.set('identityType','store');target.set('legacyGroupId',2);target.set('realName',data.storeName);target.set('nickname',data.contactName||data.storeName);target.set('mobile',data.mobile);target.set('registeredAt',registeredAt);target.set('passwordResetRequired',true);target.set('isDisabled',data.disabled);await target.signUp(null,{useMasterKey:true});try{const legacy={UserID:0,UserName:data.username,HoneyName:data.contactName||data.storeName,CompanyName:data.storeName,Mobile:data.mobile,Remark:data.mobile,GroupID:2,ParentUserID:data.parentStoreId,RegTime:registeredAt.toISOString(),LoginTimes:0,State:data.disabled?0:2,OpsCreatedAt:registeredAt.toISOString()},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"=2,"legacyUserData"=jsonb_set($3::jsonb,\'{UserID}\',to_jsonb(next_id.id),true),"registeredAt"=$4,"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id',[companyId,target.id,JSON.stringify(legacy),registeredAt]),userId=number(rows[0]&&rows[0].id);if(!userId)throw new Error('无法分配门店业务 ID');await audit(context,'create-store','_User',target.id,reason,null,{userId,storeName:data.storeName,mobileMasked:maskMobile(data.mobile),parentStoreId:data.parentStoreId});return{objectId:target.id,userId,updatedAt:dateIso(target.updatedAt)};}catch(error){await target.destroy({useMasterKey:true}).catch(()=>undefined);throw error;}}
  3459. async function updateStore(context,input,payload){requireRole(context,['ops-manager']);const reason=reasonOf(input),target=await scopedStoreObject(context,input);assertVersion(target,input.expectedUpdatedAt);const legacy={...(target.get('legacyUserData')||{})},userId=number(target.get('legacyUserId')||legacy.UserID),data=storePayload(payload,false),companyId=pointerId(target.get('company')||context.company);await validateStoreParent(context,input,data.parentStoreId,userId);const duplicate=await Psql.oneOrNone('SELECT "objectId" FROM "_User" WHERE "company"=$1 AND "objectId"<>$2 AND (LOWER("username")=LOWER($3) OR ($4<>\'\' AND COALESCE("mobile","phone",\'\')=$4)) AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1',[companyId,target.id,data.username,data.mobile]);if(duplicate)fail(409,'登录账号或手机号已被使用');const before={username:text(target.get('username')),storeName:storeNameOf(target.toJSON()),mobileMasked:maskMobile(storeMobileOf(target.toJSON())),parentStoreId:number(legacy.ParentUserID),disabled:target.get('isDisabled')===true};target.set('username',data.username);target.set('realName',data.storeName);target.set('nickname',data.contactName||data.storeName);target.set('mobile',data.mobile);target.set('isDisabled',data.disabled);target.set('identityType','store');if(data.password)target.setPassword(data.password);legacy.UserName=data.username;legacy.HoneyName=data.contactName||data.storeName;legacy.CompanyName=data.storeName;legacy.Mobile=data.mobile;legacy.Remark=data.mobile;legacy.GroupID=2;legacy.ParentUserID=data.parentStoreId;legacy.State=data.disabled?0:2;target.set('legacyUserData',legacy);await target.save(null,{useMasterKey:true});if(data.password||data.disabled){const sessions=new Parse.Query('_Session');sessions.equalTo('user',target);sessions.limit(1000);const rows=await sessions.find({useMasterKey:true});if(rows.length)await Parse.Object.destroyAll(rows,{useMasterKey:true});}await audit(context,'update-store','_User',target.id,reason,before,{username:data.username,storeName:data.storeName,mobileMasked:maskMobile(data.mobile),parentStoreId:data.parentStoreId,disabled:data.disabled});return{objectId:target.id,updatedAt:dateIso(target.updatedAt)};}
  3460. async function deleteStore(context,input){requireRole(context,['ops-manager']);const reason=reasonOf(input),target=await scopedStoreObject(context,input);assertVersion(target,input.expectedUpdatedAt);const legacy={...(target.get('legacyUserData')||{})},userId=number(target.get('legacyUserId')||legacy.UserID),companyId=pointerId(target.get('company')||context.company),children=await Psql.one('SELECT COUNT(*)::int AS total FROM "_User" WHERE ($1::text IS NULL OR "company"=$1) AND COALESCE("legacyUserData"->>\'ParentUserID\',\'0\')=$2 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE)',[companyId,String(userId)]);if(number(children.total)>0)fail(409,'该门店仍有 '+number(children.total)+' 个会员、陪练或下级门店,不能删除;可以先停用账号');target.set('isDisabled',true);target.set('isDeleted',true);legacy.State=0;legacy.OpsDeletedAt=new Date().toISOString();target.set('legacyUserData',legacy);await target.save(null,{useMasterKey:true});const sessions=new Parse.Query('_Session');sessions.equalTo('user',target);sessions.limit(1000);const rows=await sessions.find({useMasterKey:true});if(rows.length)await Parse.Object.destroyAll(rows,{useMasterKey:true});await audit(context,'delete-store','_User',target.id,reason,{storeName:storeNameOf(target.toJSON()),isDeleted:false},{isDeleted:true,isDisabled:true});return{objectId:target.id,deleted:true};}
  3461. async function professionalScheduleDataLocal(context,input) {
  3462. const data=await professionalScheduleData(context,input),stored=data.appointments,live=await cachedLiveAppointmentRows(context,input,data.dateFrom,input.refresh===true),coachId=number((input.filters||{}).coachId),memberMap=new Map(data.members.map((row)=>[row.userId,row])),baseCoachMap=new Map(data.coaches.map((row)=>[row.userId,row]));
  3463. const hydratedLive=live.allItems.map((row)=>{const member=memberMap.get(row.studentId),coach=baseCoachMap.get(row.coachId);return{...row,studentObjectId:member?member.objectId:'',studentName:member?member.displayName:row.studentName,coachObjectId:coach?coach.objectId:'',coachName:coach?coach.displayName:row.coachName};});
  3464. const all=mergeAppointments(stored,hydratedLive),history=appointmentHistory(all),appointments=all.filter((row)=>{const date=row.startsAt?businessDate(row.startsAt):'';return date>=data.dateFrom&&date<=data.dateTo&&(!coachId||row.coachId===coachId);}).sort((a,b)=>a.startsAt.localeCompare(b.startsAt)),rangeCounts=new Map();for(const row of appointments)if(row.coachId)rangeCounts.set(row.coachId,(rangeCounts.get(row.coachId)||0)+1);
  3465. const coaches=data.coaches.map((row)=>({...row,todayAppointments:rangeCounts.get(row.userId)||0})),knownCoachIds=new Set(coaches.map((row)=>row.userId));
  3466. for(const row of hydratedLive){if(!row.coachId||knownCoachIds.has(row.coachId))continue;knownCoachIds.add(row.coachId);const coachHistory=history.byCoach.get(row.coachId)||{totalAppointments:0,lastAppointmentAt:''};coaches.push({objectId:'legacy-live-coach:'+row.coachId,userId:row.coachId,displayName:row.coachName,mobile:'',mobileMasked:'',storeName:'实时业务系统',status:'active',statusLabel:'实时账号',todayAppointments:rangeCounts.get(row.coachId)||0,pendingAppointments:0,...coachHistory,monthLessons:0,monthHours:0,estimatedPay:0,averageScore:0,lastLessonAt:'',availabilityConfigured:false,updatedAt:''});
  3467. }
  3468. const historyFrom=[text(data.dataDateFrom),text(history.dataDateFrom)].filter(Boolean).sort()[0]||'',historyTo=[text(data.dataDateTo),text(history.dataDateTo),text(live.latestAt).slice(0,10)].filter(Boolean).sort().at(-1)||'',unassigned=appointments.filter((row)=>!row.coachId);return{...data,refreshedAt:new Date().toISOString(),totalAppointments:Math.max(number(data.totalAppointments),number(live.total)),dataDateFrom:historyFrom,dataDateTo:historyTo,appointments,unassigned,coaches:coaches.sort((a,b)=>a.displayName.localeCompare(b.displayName,'zh-CN')),liveSourceAvailable:live.available,liveSourceMessage:live.message,liveSourceCount:hydratedLive.length,sync:await operationalSyncIndicator(context,input)};
  3469. }
  3470. function addBusinessDays(dateKey,days) { return businessDate(new Date(new Date(dateKey+'T12:00:00+08:00').getTime()+days*86400000)); }
  3471. function scheduleTrendRange(period,today=businessDate()) { if(!['week','month','year'].includes(period))fail(400,'排课趋势时间维度无效');if(period==='week')return{dateFrom:addBusinessDays(today,-6),dateTo:today,periodLabel:'近 7 天'};if(period==='month')return{dateFrom:today.slice(0,8)+'01',dateTo:today,periodLabel:'本月'};return{dateFrom:today.slice(0,4)+'-01-01',dateTo:today,periodLabel:'本年'}; }
  3472. function scheduleTrendFromSnapshot(snapshot,period,today=businessDate()) {
  3473. const range=scheduleTrendRange(period,today),buckets=[],weekdays=['周日','周一','周二','周三','周四','周五','周六'];
  3474. for(let key=range.dateFrom;key<=range.dateTo;key=addBusinessDays(key,1)){const month=number(key.slice(5,7)),day=number(key.slice(8,10)),weekday=new Date(key+'T12:00:00+08:00').getDay();buckets.push({key,label:month+'月'+day+'日',shortLabel:period==='week'?weekdays[weekday]:period==='year'?month+'/'+day:day+'日'});}
  3475. const byKey=new Map(buckets.map((bucket)=>[bucket.key,{courseCount:0,completedCount:0,students:new Set()}])),allStudents=new Set(),todayStudents=new Set();let todayCourseCount=0;
  3476. for(const row of snapshot.appointments){if(row.cancelled||!row.startsAt)continue;const date=businessDate(row.startsAt);if(date<range.dateFrom||date>range.dateTo)continue;const bucket=byKey.get(date);if(!bucket)continue;bucket.courseCount++;if(row.statusCode>=30)bucket.completedCount++;if(row.studentId){bucket.students.add(row.studentId);allStudents.add(row.studentId);}if(date===today){todayCourseCount++;if(row.studentId)todayStudents.add(row.studentId);}}
  3477. const points=buckets.map((bucket)=>{const value=byKey.get(bucket.key);return{...bucket,courseCount:value.courseCount,studentCount:value.students.size,completedCount:value.completedCount,isToday:bucket.key===today};}),totalCourseCount=points.reduce((sum,point)=>sum+point.courseCount,0),maxValue=Math.max(1,...points.flatMap((point)=>[point.courseCount,point.studentCount]));
  3478. return{period,periodLabel:range.periodLabel,dateFrom:range.dateFrom,dateTo:range.dateTo,points,todayCourseCount,todayStudentCount:todayStudents.size,totalCourseCount,totalStudentCount:allStudents.size,maxValue,refreshedAt:new Date().toISOString(),liveSourceAvailable:snapshot.live.available,liveSourceMessage:snapshot.live.message};
  3479. }
  3480. async function scheduleTrendData(context,input) { const period=text(input.period||'week'),today=businessDate(),range=scheduleTrendRange(period,today),snapshot=await appointmentSnapshot(context,input,range.dateFrom);return scheduleTrendFromSnapshot(snapshot,period,today); }
  3481. async function professionalDashboard(context,input) { const date=businessDate(),payroll=await payrollOverviewData(context,{...input,month:date.slice(0,7),storeId:0,search:'',refresh:true}),data=await dashboard(context,input,payroll),range=scheduleTrendRange('week',date),snapshot=await appointmentSnapshot(context,input,range.dateFrom),todayRows=snapshot.appointments.filter((row)=>!row.cancelled&&row.startsAt&&businessDate(row.startsAt)===date),currentLiveRows=snapshot.live.allItems.filter((row)=>row.startsAt&&businessDate(row.startsAt)>=date),coachSnapshot={...snapshot,live:{...snapshot.live,allItems:currentLiveRows},appointments:mergeAppointments(snapshot.stored,currentLiveRows)},coachPage=await professionalCoachRows(context,{...input,page:1,pageSize:100,search:'',filters:{status:'active'}},coachSnapshot,payroll),metrics=data.metrics.map((row)=>row.key==='today'?{...row,value:todayRows.length}:row.key==='coaches'?{...row,value:coachPage.total,hint:snapshot.live.available?'新系统与实时业务账号去重统计':row.hint}:row),alerts=data.alerts.map((row)=>row.key==='unassigned'?{...row,value:todayRows.filter((item)=>!item.coachId).length}:row);return{...data,date,metrics,alerts,scheduleTrend:scheduleTrendFromSnapshot(snapshot,'week',date),refreshedAt:new Date().toISOString(),liveSourceAvailable:snapshot.live.available,liveSourceMessage:snapshot.live.message,sync:await operationalSyncIndicator(context,input)}; }
  3482. async function scheduleTrendFast(context,input,requestedPeriod=''){
  3483. const period=text(requestedPeriod||input.period||'week'),today=businessDate(),range=scheduleTrendRange(period,today),companyId=companyIdOf(context,input),rows=await Psql.query('SELECT LEFT(REPLACE(COALESCE(NULLIF(CAST(a."yysj" AS text),\'\'),NULLIF(CAST(a."bxrq" AS text),\'\')),\'/\',\'-\'),10) AS day,COUNT(*)::int AS course_count,COUNT(*) FILTER (WHERE CAST(COALESCE(a."dszt",\'0\') AS text) IN (\'30\',\'40\'))::int AS completed_count,ARRAY_AGG(DISTINCT CAST(a."szyh" AS text)) FILTER (WHERE COALESCE(CAST(a."szyh" AS text),\'\')<>\'\') AS students FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND CAST(a."id" AS text)=CAST(c."itemId" AS text) WHERE ($1::text IS NULL OR c."company"=$1) AND CAST(c."modelId" AS text)=\'54\' AND COALESCE(CAST(c."status" AS text),\'99\')<>\'-2\' AND LEFT(REPLACE(COALESCE(NULLIF(CAST(a."yysj" AS text),\'\'),NULLIF(CAST(a."bxrq" AS text),\'\')),\'/\',\'-\'),10) BETWEEN $2 AND $3 GROUP BY day',[companyId,range.dateFrom,range.dateTo]),byDay=new Map(rows.map((row)=>[text(row.day),row])),points=[],weekdays=['周日','周一','周二','周三','周四','周五','周六'],allStudents=new Set();
  3484. for(let key=range.dateFrom;key<=range.dateTo;key=addBusinessDays(key,1)){const row=byDay.get(key)||{},students=Array.isArray(row.students)?row.students.map(text).filter(Boolean):[];for(const id of students)allStudents.add(id);const month=number(key.slice(5,7)),day=number(key.slice(8,10)),weekday=new Date(key+'T12:00:00+08:00').getDay();points.push({key,label:month+'月'+day+'日',shortLabel:period==='week'?weekdays[weekday]:period==='year'?month+'/'+day:day+'日',courseCount:number(row.course_count),studentCount:new Set(students).size,completedCount:number(row.completed_count),isToday:key===today});}
  3485. const todayPoint=points.find((point)=>point.isToday)||{courseCount:0,studentCount:0},totalCourseCount=points.reduce((sum,point)=>sum+point.courseCount,0);return{period,periodLabel:range.periodLabel,dateFrom:range.dateFrom,dateTo:range.dateTo,points,todayCourseCount:todayPoint.courseCount,todayStudentCount:todayPoint.studentCount,totalCourseCount,totalStudentCount:allStudents.size,maxValue:Math.max(1,...points.flatMap((point)=>[point.courseCount,point.studentCount])),refreshedAt:new Date().toISOString(),liveSourceAvailable:true,liveSourceMessage:'新数据库排课投影'};
  3486. }
  3487. async function professionalDashboardFast(context,input){
  3488. const date=businessDate(),month=date.slice(0,7),companyId=companyIdOf(context,input),[userRows,appointmentFacts,coachRow,payroll,payrolls,trend,sync]=await Promise.all([
  3489. Psql.query('SELECT "identityType","isDisabled","legacyUserData" FROM "_User" WHERE ($1::text IS NULL OR "company"=$1) AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) AND COALESCE("isAdmin",FALSE)=FALSE',[companyId]),
  3490. Psql.one('SELECT COUNT(*) FILTER (WHERE day=$2)::int AS today_count,COUNT(*) FILTER (WHERE day=$2 AND COALESCE(NULLIF(CAST(a."pl" AS text),\'\'),NULLIF(CAST(a."fxpl" AS text),\'\'),\'\')=\'\')::int AS unassigned,COUNT(DISTINCT CAST(a."szyh" AS text)) FILTER (WHERE CAST(COALESCE(a."dszt",\'0\') AS text) NOT IN (\'0\',\'10\',\'11\',\'20\',\'30\',\'40\'))::int AS abnormal_members,ARRAY_AGG(DISTINCT CAST(a."szyh" AS text)) FILTER (WHERE CAST(COALESCE(a."dszt",\'0\') AS text) IN (\'0\',\'10\',\'11\',\'20\')) AS pending_members FROM (SELECT a.*,LEFT(REPLACE(COALESCE(NULLIF(CAST(a."yysj" AS text),\'\'),NULLIF(CAST(a."bxrq" AS text),\'\')),\'/\',\'-\'),10) AS day FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND CAST(a."id" AS text)=CAST(c."itemId" AS text) WHERE ($1::text IS NULL OR c."company"=$1) AND CAST(c."modelId" AS text)=\'54\' AND COALESCE(CAST(c."status" AS text),\'99\')<>\'-2\') a',[companyId,date]),
  3491. Psql.one('SELECT COUNT(*)::int AS total FROM "_User" WHERE ($1::text IS NULL OR "company"=$1) AND "identityType"=\'coach\' AND COALESCE("isDisabled",FALSE)=FALSE AND ("isDeleted" IS NULL OR "isDeleted"=FALSE)',[companyId]),
  3492. payrollPreviewData(context,{...input,month,storeId:0,search:'',refresh:false},month,true,false),
  3493. payrollPage(context,{...input,page:1,pageSize:100}),
  3494. scheduleTrendFast(context,input,'week'),
  3495. operationalSyncIndicator(context,input),
  3496. ]),pendingMembers=new Set((appointmentFacts.pending_members||[]).map(text)),members=userRows.filter((row)=>text(row.identityType)==='member'&&row.isDisabled!==true),studying=members.filter((row)=>{const legacy=legacyOf(row),uid=text(legacy.UserID);return number(legacy.Purse)>0||number(legacy.SilverCoin)>0||number(legacy.UserPoint)>0||pendingMembers.has(uid);}),low=studying.filter((row)=>{const legacy=legacyOf(row);return number(legacy.Purse)+number(legacy.SilverCoin)<=2;}).length,pendingPayroll=payrolls.items.filter((row)=>row.status==='draft'||row.status==='reviewed').length,estimatedPay=number(payroll.payableAmount),identity={objectId:context.current.id,username:text(context.current.get('username')),displayName:text(context.current.get('realName')||context.current.get('realname')||context.current.get('nickname')||context.current.get('username')),company:safe(context.company),roles:context.roles,isSuperAdmin:context.isSuperAdmin,operationsRole:roleOf(context)};
  3497. return{identity,date,metrics:[{key:'members',label:'在学会员',value:studying.length,hint:'当前有正式学习任务',route:'/admin/members',tone:'blue'},{key:'today',label:'今日课程',value:number(appointmentFacts.today_count),hint:'今天计划进行的课程',route:'/admin/schedule',tone:'amber'},{key:'coaches',label:'在岗陪练',value:number(coachRow.total),hint:'新数据库陪练身份投影',route:'/admin/coaches',tone:'green'},{key:'pay',label:'本月预估工资',value:estimatedPay,hint:'与工资结算同口径 · 新数据库上课记录',route:'/admin/payroll',tone:'rose'}],alerts:[{key:'unassigned',label:'今日待分配预约',value:number(appointmentFacts.unassigned),route:'/admin/schedule'},{key:'low',label:'低课时会员',value:low,route:'/admin/members'},{key:'abnormal',label:'预约异常会员',value:number(appointmentFacts.abnormal_members),route:'/admin/members'},{key:'payroll',label:'待处理工资批次',value:pendingPayroll,route:'/admin/payroll'}],payroll:{month,coachCount:number(payroll.coachCount),lessonCount:number(payroll.lessonCount),payableAmount:estimatedPay,sourceStatus:payroll.sourceStatus,refreshedAt:text(payroll.refreshedAt||payroll.sourceStatus&&payroll.sourceStatus.refreshedAt)},scheduleTrend:trend,refreshedAt:new Date().toISOString(),liveSourceAvailable:true,liveSourceMessage:'新数据库运营投影',sync};
  3498. }
  3499. async function revokeMemberSessions(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;}
  3500. async function memberTarget(context,input,objectId){const target=await scopedObject('_User',objectId,context,input),legacy=target.get('legacyUserData')||{},groupId=number(target.get('legacyGroupId')||legacy.GroupID),userId=number(target.get('legacyUserId')||legacy.UserID);if([2,3].includes(groupId)||target.get('isAdmin')===true||/陪练/.test(displayName(target.toJSON())))fail(404,'会员不存在或身份已发生变化');if(userId){const companyId=pointerId(target.get('company')||context.company),coach=await Psql.oneOrNone('SELECT 1 AS found WHERE EXISTS(SELECT 1 FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) AND (CAST("pl" AS text)=$2 OR CAST("fxpl" AS text)=$2)) OR EXISTS(SELECT 1 FROM "LessonRecord" WHERE ($1::text IS NULL OR "company"=$1) AND CAST("jsmz" AS text)=$2)',[companyId,String(userId)]).catch(()=>null);if(coach)fail(404,'会员不存在或身份已发生变化');}return target;}
  3501. function memberProfilePayload(payload,creating=false){const display=text(payload.displayName||payload.realName),nickname=text(payload.nickname||display),mobile=text(payload.mobile),username=text(payload.username||mobile),password=text(payload.password),email=text(payload.email),gender=text(payload.gender),grade=text(payload.grade),parentUserId=number(payload.parentUserId);if(!display||display.length>100)fail(400,'会员姓名为必填项且不能超过 100 位');if(!/^1\d{10}$/.test(mobile))fail(400,'请输入有效的 11 位手机号');if(username.length<3||username.length>64)fail(400,'登录账号长度应为 3 至 64 位');if(email&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))fail(400,'邮箱格式不正确');if((creating||password)&&(password.length<8||password.length>72))fail(400,'临时密码长度应为 8 至 72 位');if(parentUserId<0||!Number.isInteger(parentUserId))fail(400,'所属代理无效');return{displayName:display,nickname,mobile,username,password,email,gender,grade,parentUserId};}
  3502. async function createMember(context,input,payload){requireRole(context,['ops-manager','ops-staff']);const reason=reasonOf(input),data=memberProfilePayload(payload,true),company=companyPointer(context,input),companyId=companyIdOf(context,input);if(!company||!companyId)fail(400,'新增会员必须指定帐套');if(data.parentUserId&&!await userByLegacyId(context,input,data.parentUserId,2))fail(404,'所属门店或代理不存在');const exists=await Psql.oneOrNone('SELECT "objectId" FROM "_User" WHERE "company"=$1 AND (LOWER("username")=LOWER($2) OR COALESCE("mobile","phone",\'\')=$3) LIMIT 1',[companyId,data.username,data.mobile]);if(exists)fail(409,'登录账号或手机号已存在');const registeredAt=new Date(),target=new Parse.User();target.setUsername(data.username);target.setPassword(data.password);target.set('company',company);target.set('type','user');target.set('identityType','member');target.set('legacyGroupId',1);target.set('realName',data.displayName);target.set('nickname',data.nickname);target.set('mobile',data.mobile);target.set('email',data.email);target.set('gender',data.gender);target.set('grade',data.grade);target.set('registeredAt',registeredAt);target.set('passwordResetRequired',true);target.set('isDisabled',false);await target.save(null,{useMasterKey:true});try{const importBatchId=/^[A-Za-z0-9_-]{8,100}$/.test(text(payload.importBatchId))?text(payload.importBatchId):'',legacy={UserID:0,UserName:data.username,HoneyName:data.nickname,TrueName:data.displayName,Mobile:data.mobile,Email:data.email,GroupID:1,ParentUserID:data.parentUserId,Sex:data.gender,Position:data.grade,RegTime:registeredAt.toISOString(),Purse:0,SilverCoin:0,UserExp:0,UserPoint:0,LoginTimes:0,State:1,...(importBatchId?{OpsImportBatchId:importBatchId,OpsImportRowKey:data.mobile}:{})},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),"registeredAt"=$4,"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id',[companyId,target.id,JSON.stringify(legacy),registeredAt]);const userId=number(rows[0]&&rows[0].id);if(!userId)throw new Error('无法分配会员 ID');await target.fetch({useMasterKey:true});await audit(context,'create-member','_User',target.id,reason,null,{userId,displayName:data.displayName,mobileMasked:maskMobile(data.mobile),parentUserId:data.parentUserId,importBatchId});return{objectId:target.id,userId,displayName:data.displayName,registeredAt:registeredAt.toISOString(),updatedAt:dateIso(target.updatedAt)};}catch(error){await target.destroy({useMasterKey:true}).catch(()=>undefined);throw error;}}
  3503. function memberImportCell(row,names){for(const name of names)if(row&&row[name]!==undefined&&row[name]!==null&&text(row[name])!=='')return text(row[name]);return'';}
  3504. async function validateMemberImport(context,input,payload,batchId=''){
  3505. requireRole(context,['ops-manager']);reasonOf(input);const sourceRows=Array.isArray(payload.rows)?payload.rows:[];if(!sourceRows.length||sourceRows.length>200)fail(400,'每个会员导入文件必须包含 1 至 200 行');const companyId=companyIdOf(context,input);if(!companyId)fail(400,'会员导入必须指定帐套');const agents=await memberAgents(context,input),agentIds=new Set(agents.map((row)=>row.userId)),agentByName=new Map();for(const agent of agents){const key=text(agent.displayName).toLowerCase();if(!agentByName.has(key))agentByName.set(key,[]);agentByName.get(key).push(agent.userId);}const existingRows=await Psql.query('SELECT "objectId","username","mobile","phone","legacyUserId","legacyUserData" FROM "_User" WHERE "company"=$1',[companyId]),existingByMobile=new Map();for(const row of existingRows){const mobile=mobileOf(row);if(mobile)existingByMobile.set(mobile,row);}const mobileCounts=new Map();for(const raw of sourceRows){const mobile=memberImportCell(raw,['手机号','手机号码','mobile','Mobile','登录账号']);if(mobile)mobileCounts.set(mobile,(mobileCounts.get(mobile)||0)+1);}const normalized=[];for(let index=0;index<sourceRows.length;index++){const raw=sourceRows[index]||{},displayName=memberImportCell(raw,['姓名','会员姓名','真实姓名','displayName','realName']),nickname=memberImportCell(raw,['昵称','nickname'])||displayName,mobile=memberImportCell(raw,['手机号','手机号码','mobile','Mobile','登录账号']).replace(/\.0$/,''),password=memberImportCell(raw,['临时密码','密码','password']),gender=memberImportCell(raw,['性别','gender']),grade=memberImportCell(raw,['年级','grade']),email=memberImportCell(raw,['邮箱','email']),agentText=memberImportCell(raw,['门店ID','代理ID','所属门店ID','parentUserId','所属门店']),issues=[];let parentUserId=0;if(agentText){if(/^\d+$/.test(agentText))parentUserId=number(agentText);else{const matches=agentByName.get(agentText.toLowerCase())||[];if(matches.length===1)parentUserId=matches[0];else issues.push(matches.length?'门店名称不唯一,请改填门店ID':'所属门店不存在');}}if(!displayName||displayName.length>100)issues.push('姓名不能为空且不能超过100字');if(!/^1\d{10}$/.test(mobile))issues.push('手机号格式错误');if(password.length<8||password.length>72)issues.push('临时密码必须为8至72位');if(gender&&!['男','女','其他'].includes(gender))issues.push('性别只能是男、女或其他');if(grade.length>50)issues.push('年级不能超过50字');if(email&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))issues.push('邮箱格式错误');if(parentUserId&&!agentIds.has(parentUserId))issues.push('所属门店ID不存在');if((mobileCounts.get(mobile)||0)>1)issues.push('文件内手机号重复');const existing=existingByMobile.get(mobile),existingLegacy=existing?legacyOf(existing):{},resumed=Boolean(existing&&batchId&&text(existingLegacy.OpsImportBatchId)===batchId&&text(existingLegacy.OpsImportRowKey)===mobile);if(existing&&!resumed)issues.push('手机号或登录账号已存在');normalized.push({rowNumber:index+2,displayName,nickname,mobile,username:mobile,password,gender,grade,email,parentUserId,resumed,existingObjectId:resumed?text(existing.objectId):'',existingUserId:resumed?number(existing.legacyUserId||existingLegacy.UserID):0,issues:[...new Set(issues)]});}const digest=await sha256(JSON.stringify(normalized.map(({rowNumber,existingObjectId,existingUserId,resumed,issues,...row})=>row))),errors=normalized.filter((row)=>row.issues.length).length,resumed=normalized.filter((row)=>row.resumed&&!row.issues.length).length;return{digest,normalized,summary:{total:normalized.length,create:normalized.length-errors-resumed,resumed,error:errors},rows:normalized.map((row)=>({rowNumber:row.rowNumber,displayName:row.displayName,nickname:row.nickname,mobileMasked:maskMobile(row.mobile),gender:row.gender,grade:row.grade,parentUserId:row.parentUserId,action:row.issues.length?'error':row.resumed?'resumed':'create',issues:row.issues}))};
  3506. }
  3507. async function previewMemberImport(context,input,payload){const preview=await validateMemberImport(context,input,payload),batchId=randomObjectId(),company=companyPointer(context,input),batch=new Parse.Object('OpsMemberImportBatch'),expiresAt=new Date(Date.now()+2*3600000);batch.set('company',company);batch.set('sourceKey','cloud:member-import-preview:'+companyIdOf(context,input)+':'+batchId);batch.set('batchId',batchId);batch.set('payloadDigest',preview.digest);batch.set('fileName',text(payload.fileName).slice(0,200));batch.set('status','preview');batch.set('total',preview.summary.total);batch.set('createdBy',context.current.id);batch.set('expiresAt',expiresAt);await batch.save(null,{useMasterKey:true});return{batchId,fileName:text(payload.fileName),expiresAt:expiresAt.toISOString(),summary:preview.summary,rows:preview.rows};}
  3508. async function commitMemberImport(context,input,payload){requireRole(context,['ops-manager']);const reason=reasonOf(input);const batchId=text(input.targetId||payload.batchId);if(!batchId)fail(400,'请先完成会员导入预检');const query=new Parse.Query('OpsMemberImportBatch'),company=companyPointer(context,input);if(company)query.equalTo('company',company);query.equalTo('batchId',batchId);query.equalTo('createdBy',context.current.id);const batch=await query.first({useMasterKey:true});if(!batch)fail(404,'导入预检批次不存在或不属于当前操作人');if(text(batch.get('status'))==='completed')return batch.get('result')||{};if(batch.get('expiresAt')&&new Date(batch.get('expiresAt')).getTime()<Date.now())fail(410,'导入预检已过期,请重新选择文件');const preview=await validateMemberImport(context,input,payload,batchId);if(preview.digest!==text(batch.get('payloadDigest')))fail(409,'导入文件在预检后发生变化,请重新预检');if(preview.summary.error)fail(422,'导入文件仍有 '+preview.summary.error+' 行错误,请修正后重新预检');batch.set('status','processing');batch.set('startedAt',new Date());await batch.save(null,{useMasterKey:true});const items=[];try{for(const row of preview.normalized){if(row.resumed){items.push({objectId:row.existingObjectId,userId:row.existingUserId,displayName:row.displayName,mobileMasked:maskMobile(row.mobile),resumed:true});continue;}const created=await createMember(context,{...input,reason},{...row,importBatchId:batchId});items.push({...created,mobileMasked:maskMobile(row.mobile),resumed:false});if(items.length%10===0){batch.set('processed',items.length);batch.set('partialResult',{items:items.map((item)=>({objectId:item.objectId,userId:item.userId,displayName:item.displayName,mobileMasked:item.mobileMasked,resumed:item.resumed}))});await batch.save(null,{useMasterKey:true});}}}catch(error){batch.set('status','failed');batch.set('processed',items.length);batch.set('errorMessage',text(error.message).slice(0,500));batch.set('partialResult',{items:items.map((item)=>({objectId:item.objectId,userId:item.userId,displayName:item.displayName,mobileMasked:item.mobileMasked,resumed:item.resumed}))});await batch.save(null,{useMasterKey:true}).catch(()=>undefined);throw error;}const result={batchId,total:items.length,created:items.filter((item)=>!item.resumed).length,resumed:items.filter((item)=>item.resumed).length,items:items.map((item)=>({objectId:item.objectId,userId:item.userId,displayName:item.displayName,mobileMasked:item.mobileMasked,resumed:item.resumed}))};batch.set('status','completed');batch.set('processed',items.length);batch.set('completedAt',new Date());batch.set('result',result);batch.unset('partialResult');batch.unset('errorMessage');await batch.save(null,{useMasterKey:true});await audit(context,'import-members','OpsMemberImportBatch',batch.id,reason,null,{batchId,total:result.total,created:result.created,resumed:result.resumed,fileName:text(batch.get('fileName'))});return result;}
  3509. async function updateMemberProfile(context,input,payload){requireRole(context,['ops-manager','ops-staff']);const reason=reasonOf(input),target=await memberTarget(context,input,text(input.targetId));assertVersion(target,input.expectedUpdatedAt);const current=target.toJSON(),data=memberProfilePayload({...payload,mobile:text(payload.mobile)||mobileOf(current),username:current.username,password:''},false),before={displayName:displayName(current),nickname:text(current.nickname),mobileMasked:maskMobile(mobileOf(current)),email:text(current.email),gender:text(current.gender),grade:text(current.grade)};if(data.parentUserId&&!await userByLegacyId(context,input,data.parentUserId,2))fail(404,'所属门店或代理不存在');const duplicate=await Psql.oneOrNone('SELECT "objectId" FROM "_User" WHERE "company"=$1 AND "objectId"<>$2 AND COALESCE("mobile","phone",\'\')=$3 LIMIT 1',[pointerId(target.get('company')||context.company),target.id,data.mobile]);if(duplicate)fail(409,'手机号已被其他账号使用');const legacy={...(target.get('legacyUserData')||{})};legacy.HoneyName=data.nickname;legacy.TrueName=data.displayName;legacy.Mobile=data.mobile;legacy.Email=data.email;legacy.Sex=data.gender;legacy.Position=data.grade;legacy.ParentUserID=data.parentUserId;target.set('realName',data.displayName);target.set('nickname',data.nickname);target.set('mobile',data.mobile);target.set('email',data.email);target.set('gender',data.gender);target.set('grade',data.grade);target.set('legacyUserData',legacy);if(!target.get('registeredAt'))target.set('registeredAt',new Date(registeredAtOf(current)));await target.save(null,{useMasterKey:true});await audit(context,'update-member-profile','_User',target.id,reason,before,{displayName:data.displayName,nickname:data.nickname,mobileMasked:maskMobile(data.mobile),email:data.email,gender:data.gender,grade:data.grade,parentUserId:data.parentUserId});return{objectId:target.id,updatedAt:dateIso(target.updatedAt)};}
  3510. async function resetMemberPassword(context,input,payload){requireRole(context,['ops-manager']);const reason=reasonOf(input),password=text(payload.password);if(password.length<8||password.length>72)fail(400,'临时密码长度应为 8 至 72 位');const target=await memberTarget(context,input,text(input.targetId));assertVersion(target,input.expectedUpdatedAt);target.setPassword(password);target.set('passwordResetRequired',true);await target.save(null,{useMasterKey:true});const revokedSessions=await revokeMemberSessions(target);await audit(context,'reset-member-password','_User',target.id,reason,{passwordResetRequired:false},{passwordResetRequired:true,revokedSessions});return{objectId:target.id,revokedSessions,updatedAt:dateIso(target.updatedAt)};}
  3511. async function saveMemberNote(context,input,payload){requireRole(context,['ops-manager','ops-staff']);const reason=reasonOf(input),content=text(payload.content);if(content.length<2||content.length>2000)fail(400,'备注内容必须为 2 至 2000 个字符');const target=await memberTarget(context,input,text(input.targetId)),note=new Parse.Object('MemberNote'),company=target.get('company')||context.company;note.set('company',company);note.set('sourceKey','cloud:member-note:'+target.id+':'+randomObjectId());note.set('memberObjectId',target.id);note.set('memberUserId',number(target.get('legacyUserId')||(target.get('legacyUserData')||{}).UserID));note.set('content',content);note.set('operatorId',context.current.id);note.set('operatorName',text(context.current.get('realName')||context.current.get('nickname')||context.current.get('username')));await note.save(null,{useMasterKey:true});await audit(context,'save-member-note','MemberNote',note.id,reason,null,{memberObjectId:target.id,content});return{objectId:note.id,createdAt:dateIso(note.createdAt)};}
  3512. async function memberNotes(context,input){const objectId=text(input.objectId||input.targetId),rows=await newClassObjects('MemberNote',context,input,1000);return rows.filter((row)=>text(row.get('memberObjectId'))===objectId).map((row)=>({objectId:row.id,content:text(row.get('content')),operatorName:text(row.get('operatorName')),createdAt:dateIso(row.createdAt)})).sort((a,b)=>b.createdAt.localeCompare(a.createdAt));}
  3513. async function memberAuditRows(context,input){const objectId=text(input.objectId||input.targetId),rows=await newClassObjects('SysLog',context,input,1000);return rows.filter((row)=>text(row.get('detail')||row.get('content')||row.get('remind')).includes(objectId)).map((row)=>({objectId:row.id,operatorName:text(row.get('userName')),summary:text(row.get('remind')),detail:text(row.get('detail')||row.get('content')),createdAt:dateIso(row.createdAt)})).sort((a,b)=>b.createdAt.localeCompare(a.createdAt));}
  3514. async function exportMembers(context,input){const rows=memberSort(filterMembers(await queryUsers(context,input),input),input);return{fileName:'小树陪练会员_'+businessDate()+'.csv',items:rows.map((row)=>({userId:row.userId,displayName:row.displayName,nickname:row.nickname,mobile:roleOf(context)==='ops-manager'?(row.mobile||row.mobileMasked):row.mobileMasked,learningStatus:row.learningStatusLabel,registeredAt:row.registeredAt,parentDisplayName:row.parentDisplayName,periods30:row.periods30,periods60:row.periods60,trialPeriods:row.trialPeriods,lastLessonAt:row.lastLessonAt,nextAppointmentAt:row.nextAppointmentAt,riskFlags:row.riskFlags.join('、'),status:row.statusLabel}))};}
  3515. function contentStatus(value){const status=number(value,99);return{status,statusLabel:status===99?'已发布':status===0?'草稿':status===-2?'回收站':'状态 '+status};}
  3516. function cleanHtml(value){let html=String(value==null?'':value);if(html.length>200000)fail(400,'正文不能超过 20 万个字符');if(/<\s*(script|iframe|object|embed|form|meta|link)\b|\s+on[a-z]+\s*=|(?:javascript|vbscript|data\s*:\s*text\/html)\s*:/i.test(html))fail(400,'正文包含脚本、事件属性或危险链接,请清理后重试');html=html.replace(/<!--[\s\S]*?-->/g,'').replace(/<\s*style[^>]*>[\s\S]*?<\s*\/\s*style\s*>/gi,'');return html.trim();}
  3517. let vocabularyPhysicalSchemaPromise=null;
  3518. async function ensureVocabularyPhysicalSchema(){if(!vocabularyPhysicalSchemaPromise)vocabularyPhysicalSchemaPromise=Promise.all(['CommonModel','VocabularyWord','Node'].map((table)=>Psql.query('ALTER TABLE "'+table+'" ADD COLUMN IF NOT EXISTS "_rperm" text[], ADD COLUMN IF NOT EXISTS "_wperm" text[]'))).catch((error)=>{vocabularyPhysicalSchemaPromise=null;throw error;});await vocabularyPhysicalSchemaPromise;}
  3519. async function backfillVocabularyBatch(context,input,payload){
  3520. requireSuperAdmin(context);await ensureVocabularyPhysicalSchema();
  3521. const reason=reasonOf(input),idempotencyKey=text(input.idempotencyKey),companyId=companyIdOf(context,input),rawItems=Array.isArray(payload.items)?payload.items:[];
  3522. if(!companyId)fail(400,'词库补齐必须指定帐套');
  3523. if(idempotencyKey.length<12||idempotencyKey.length>200)fail(400,'词库补齐需要有效的幂等键');
  3524. if(!rawItems.length||rawItems.length>200)fail(400,'每批词库补齐必须包含 1 至 200 条记录');
  3525. const generalIds=new Set(),itemIds=new Set(),items=rawItems.map((raw)=>{const generalId=number(raw.generalId),itemId=number(raw.itemId),nodeId=number(raw.nodeId),word=text(raw.word),status=number(raw.status,99)===0?0:99;if(!generalId||!itemId||!nodeId||!word)fail(400,'词库补齐记录缺少旧内容ID、附表ID、单元或单词');if(generalIds.has(generalId)||itemIds.has(itemId))fail(400,'同一批次存在重复的旧内容ID或附表ID');generalIds.add(generalId);itemIds.add(itemId);return{addonObjectId:randomObjectId(),commonObjectId:randomObjectId(),generalId,itemId,nodeId,word:word.slice(0,200),meaning:text(raw.meaning).slice(0,4000),phonetic:text(raw.phonetic).slice(0,500),example:text(raw.example).slice(0,8000),audio:text(raw.audio).slice(0,2000),subtitle:text(raw.subtitle).slice(0,500),inputer:text(raw.inputer||'legacy-sync').slice(0,100),status,orderId:number(raw.orderId)};});
  3526. const sql='WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-vocabulary-backfill:\'||$2))), source AS MATERIALIZED (SELECT x.* FROM jsonb_to_recordset($1::jsonb) AS x("addonObjectId" text,"commonObjectId" text,"generalId" double precision,"itemId" double precision,"nodeId" double precision,"word" text,"meaning" text,"phonetic" text,"example" text,"audio" text,"subtitle" text,"inputer" text,"status" double precision,"orderId" double precision),lock_row), addon AS (INSERT INTO "VocabularyWord" ("objectId","createdAt","updatedAt","company","sourceKey","id","sy","yb","lj","yp") SELECT s."addonObjectId",NOW(),NOW(),$2,\'legacy-sync:vocabulary:addon:\'||s."itemId"::text,s."itemId",s."meaning",s."phonetic",s."example",s."audio" FROM source s WHERE NOT EXISTS (SELECT 1 FROM "VocabularyWord" v WHERE v."company"=$2 AND CAST(v."id" AS text)=CAST(s."itemId" AS text)) ON CONFLICT ("objectId") DO NOTHING RETURNING "objectId"), common AS (INSERT INTO "CommonModel" ("objectId","createdAt","updatedAt","company","sourceKey","generalId","itemId","modelId","nodeId","tableName","title","subtitle","inputer","status","orderId") SELECT s."commonObjectId",NOW(),NOW(),$2,\'legacy-sync:vocabulary:common:\'||s."generalId"::text,s."generalId",s."itemId",52,s."nodeId",\'ZL_C_ck\',s."word",s."subtitle",s."inputer",s."status",s."orderId" FROM source s WHERE NOT EXISTS (SELECT 1 FROM "CommonModel" c WHERE c."company"=$2 AND CAST(c."modelId" AS text)=\'52\' AND CAST(c."generalId" AS text)=CAST(s."generalId" AS text)) ON CONFLICT ("objectId") DO NOTHING RETURNING "objectId") SELECT (SELECT COUNT(*)::int FROM source) AS requested,(SELECT COUNT(*)::int FROM addon) AS "createdAddons",(SELECT COUNT(*)::int FROM common) AS "createdCommon"';
  3527. const result=await Psql.one(sql,[JSON.stringify(items),companyId]),summary={requested:number(result.requested),createdAddons:number(result.createdAddons),createdCommon:number(result.createdCommon),idempotencyKey,refreshedAt:new Date().toISOString()};await audit(context,'backfill-vocabulary-batch','VocabularyMigration',idempotencyKey,reason,null,summary);return summary;
  3528. }
  3529. function legacyVocabularyMedia(value){const raw=text(value).replace(/\\/g,'/').replace(/^http:\/\/a018\.2018\.z01\.com/i,'https://a018.2018.z01.com');if(!raw)return'';if(/^https?:\/\//i.test(raw)||raw.startsWith('//')||raw.startsWith('data:audio/'))return raw;const path=raw.replace(/^\/+/, '').replace(/^UploadFiles\//i,'');return'https://a018.2018.z01.com/UploadFiles/'+path;}
  3530. function wordProjection(row){return{targetId:text(row.targetId||row.objectId),generalId:number(row.generalId),itemId:number(row.itemId),word:text(row.title),meaning:text(row.sy),phonetic:text(row.yb),example:text(row.lj),audioUrl:legacyVocabularyMedia(row.yp),nodeId:number(row.nodeId),unitName:text(row.unitName),bookName:text(row.bookName),stageName:text(row.stageName),orderId:number(row.orderId),...contentStatus(row.status),createdAt:dateIso(row.createdAt),updatedAt:dateIso(row.updatedAt)};}
  3531. async function vocabularyTree(context,input){await ensureVocabularyPhysicalSchema();const companyId=companyIdOf(context,input);const nodes=await Psql.query('WITH RECURSIVE all_nodes AS (SELECT n.* FROM "Node" n WHERE n."company"=$1 AND COALESCE(n."zstatus",99)<>-2),direct_counts AS (SELECT CAST("nodeId" AS text) AS "nodeId",COUNT(*) FILTER (WHERE COALESCE("status",99)<>-2)::int AS count FROM "CommonModel" WHERE "company"=$1 AND CAST("modelId" AS text)=\'52\' GROUP BY CAST("nodeId" AS text)),vocabulary_nodes AS (SELECT n.* FROM all_nodes n WHERE CAST(COALESCE(n."contentModel",\'0\') AS text)=\'52\' OR CAST(n."nodeId" AS text) IN (SELECT "nodeId" FROM direct_counts) UNION SELECT p.* FROM all_nodes p JOIN vocabulary_nodes c ON CAST(c."parentId" AS text)=CAST(p."nodeId" AS text)),descendants AS (SELECT "nodeId" AS "ancestorId","nodeId" AS "descendantId" FROM vocabulary_nodes UNION SELECT d."ancestorId",n."nodeId" FROM descendants d JOIN vocabulary_nodes n ON CAST(n."parentId" AS text)=CAST(d."descendantId" AS text)),aggregate_counts AS (SELECT CAST(d."ancestorId" AS text) AS "nodeId",COALESCE(SUM(dc.count),0)::int AS count FROM descendants d JOIN direct_counts dc ON dc."nodeId"=CAST(d."descendantId" AS text) GROUP BY CAST(d."ancestorId" AS text)),child_counts AS (SELECT CAST("parentId" AS text) AS "parentId",COUNT(*)::int AS count FROM vocabulary_nodes GROUP BY CAST("parentId" AS text)) SELECT n."objectId",n."nodeId",n."nodeName",n."parentId",n."depth",n."orderId",n."contentModel",n."zstatus",COALESCE(dc.count,0)::int AS "directWordCount",COALESCE(ac.count,0)::int AS "wordCount",COALESCE(cc.count,0)::int AS "childCount" FROM vocabulary_nodes n LEFT JOIN direct_counts dc ON dc."nodeId"=CAST(n."nodeId" AS text) LEFT JOIN aggregate_counts ac ON ac."nodeId"=CAST(n."nodeId" AS text) LEFT JOIN child_counts cc ON cc."parentId"=CAST(n."nodeId" AS text) ORDER BY COALESCE(n."depth",0),COALESCE(n."orderId",0),n."nodeId"',[companyId]);const totalRow=await Psql.one('SELECT COUNT(*) FILTER (WHERE COALESCE("status",99)<>-2)::int AS total FROM "CommonModel" WHERE "company"=$1 AND CAST("modelId" AS text)=\'52\'',[companyId]);return{items:nodes.map((row)=>({objectId:text(row.objectId),nodeId:number(row.nodeId),name:text(row.nodeName),parentId:number(row.parentId),depth:number(row.depth),orderId:number(row.orderId),directWordCount:number(row.directWordCount),wordCount:number(row.wordCount),childCount:number(row.childCount),status:number(row.zstatus,99),canContainWords:text(row.contentModel)==='52'})),total:nodes.length,totalWords:number(totalRow.total),refreshedAt:new Date().toISOString()};}
  3532. async function vocabularyRows(context,input){const companyId=companyIdOf(context,input),page=Math.max(1,number(input.page,1)),pageSize=Math.min(100,Math.max(1,number(input.pageSize,20))),filters=input.filters&&typeof input.filters==='object'?input.filters:{},values=[companyId],clauses=['c."company"=$1','CAST(c."modelId" AS text)=\'52\''];const search=text(input.search);if(search){values.push('%'+search.replace(/[%_]/g,'')+'%');clauses.push('(c."title" ILIKE $'+values.length+' OR v."sy" ILIKE $'+values.length+' OR v."yb" ILIKE $'+values.length+')');}if(number(filters.nodeId)){values.push(String(number(filters.nodeId)));const nodeParam=values.length;clauses.push('EXISTS (WITH RECURSIVE subtree AS (SELECT n."nodeId" FROM "Node" n WHERE n."company"=$1 AND CAST(n."nodeId" AS text)=$'+nodeParam+' UNION SELECT child."nodeId" FROM "Node" child JOIN subtree parent ON child."company"=$1 AND CAST(child."parentId" AS text)=CAST(parent."nodeId" AS text)) SELECT 1 FROM subtree WHERE CAST(subtree."nodeId" AS text)=CAST(c."nodeId" AS text))');}if(filters.status!==undefined&&filters.status!==''){values.push(String(number(filters.status)));clauses.push('CAST(COALESCE(c."status",99) AS text)=$'+values.length);}else clauses.push('COALESCE(c."status",99)<>-2');if(filters.missingAudio===true)clauses.push('COALESCE(TRIM(v."yp"),\'\')=\'\'');const where=clauses.join(' AND '),count=await Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c LEFT JOIN "VocabularyWord" v ON v."company"=c."company" AND CAST(v."id" AS text)=CAST(c."itemId" AS text) WHERE '+where,values),rows=await Psql.query('SELECT c."objectId" AS "targetId",c.*,v."sy",v."yb",v."lj",v."yp",u."nodeName" AS "unitName",b."nodeName" AS "bookName",s."nodeName" AS "stageName" FROM "CommonModel" c LEFT JOIN "VocabularyWord" v ON v."company"=c."company" AND CAST(v."id" AS text)=CAST(c."itemId" AS text) LEFT JOIN "Node" u ON u."company"=c."company" AND CAST(u."nodeId" AS text)=CAST(c."nodeId" AS text) LEFT JOIN "Node" b ON b."company"=u."company" AND CAST(b."nodeId" AS text)=CAST(u."parentId" AS text) LEFT JOIN "Node" s ON s."company"=b."company" AND CAST(s."nodeId" AS text)=CAST(b."parentId" AS text) WHERE '+where+' ORDER BY COALESCE(c."orderId",0),c."generalId" LIMIT $'+(values.length+1)+' OFFSET $'+(values.length+2),values.concat([pageSize,(page-1)*pageSize]));return{page,pageSize,total:number(count.total),items:rows.map(wordProjection),refreshedAt:new Date().toISOString()};}
  3533. async function vocabularyTarget(context,input,targetId){const companyId=companyIdOf(context,input),row=await Psql.oneOrNone('SELECT c."objectId" AS "targetId",c.*,v."sy",v."yb",v."lj",v."yp",u."nodeName" AS "unitName",b."nodeName" AS "bookName",s."nodeName" AS "stageName" FROM "CommonModel" c JOIN "VocabularyWord" v ON v."company"=c."company" AND CAST(v."id" AS text)=CAST(c."itemId" AS text) LEFT JOIN "Node" u ON u."company"=c."company" AND CAST(u."nodeId" AS text)=CAST(c."nodeId" AS text) LEFT JOIN "Node" b ON b."company"=u."company" AND CAST(b."nodeId" AS text)=CAST(u."parentId" AS text) LEFT JOIN "Node" s ON s."company"=b."company" AND CAST(s."nodeId" AS text)=CAST(b."parentId" AS text) WHERE c."company"=$1 AND CAST(c."modelId" AS text)=\'52\' AND (c."objectId"=$2 OR CAST(c."generalId" AS text)=$2) LIMIT 1',[companyId,text(targetId)]);if(!row)fail(404,'词条不存在');return row;}
  3534. async function saveVocabulary(context,input,payload,options={}){requireRole(context,options.importing?['ops-manager']:['ops-manager','ops-staff']);const reason=options.reason||reasonOf(input),word=text(payload.word),meaning=text(payload.meaning),phonetic=text(payload.phonetic),example=text(payload.example),audioUrl=text(payload.audioUrl),nodeId=number(payload.nodeId),company=companyPointer(context,input),companyId=companyIdOf(context,input);if(!word||word.length>200||!nodeId)fail(400,'单词和所属单元为必填项');if(meaning.length>4000||phonetic.length>500||example.length>8000||audioUrl.length>2000)fail(400,'词条字段长度超出限制');const node=await Psql.oneOrNone('SELECT "nodeName","contentModel" FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 AND COALESCE("zstatus",99)<>-2 LIMIT 1',[companyId,nodeId]);if(!node||text(node.contentModel)!=='52')fail(404,'所属词库单元不存在');let common=null,addon=null,created=false,targetId=text(input.targetId||payload.targetId);if(targetId){const found=await vocabularyTarget(context,input,targetId);common=await scopedObject('CommonModel',text(found.targetId),context,input);assertVersion(common,input.expectedUpdatedAt);addon=await new Parse.Query('VocabularyWord').get(text(found.objectId||found.vocabularyObjectId||''),{useMasterKey:true}).catch(()=>null);if(!addon){const rows=await Psql.query('SELECT "objectId" FROM "VocabularyWord" WHERE "company"=$1 AND CAST("id" AS text)=$2 LIMIT 1',[companyId,String(found.itemId)]);if(rows[0])addon=await new Parse.Query('VocabularyWord').get(rows[0].objectId,{useMasterKey:true});}if(!addon)fail(409,'词条附表缺失');}else{const duplicate=await Psql.oneOrNone('SELECT c."objectId" FROM "CommonModel" c WHERE c."company"=$1 AND CAST(c."modelId" AS text)=\'52\' AND CAST(c."nodeId" AS text)=$2 AND LOWER(TRIM(c."title"))=LOWER($3) AND COALESCE(c."status",99)<>-2 LIMIT 1',[companyId,String(nodeId),word]);if(duplicate){if(options.importing){input.targetId=duplicate.objectId;return saveVocabulary(context,input,payload,{...options,reason});}fail(409,'该单元已存在同名单词');}created=true;addon=new Parse.Object('VocabularyWord');addon.set('company',company);addon.set('sourceKey','cloud:ops-word:'+companyId+':'+randomObjectId());await addon.save(null,{useMasterKey:true});const ids=await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-vocabulary-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "VocabularyWord",lock_row WHERE "company"=$1) UPDATE "VocabularyWord" SET "id"=next_id.id,"sourceKey"=\'cloud:ops-word:\'||$1||\':\'||next_id.id::text,"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id',[companyId,addon.id]);const itemId=number(ids[0]&&ids[0].id);await addon.fetch({useMasterKey:true});common=new Parse.Object('CommonModel');common.set('company',company);common.set('sourceKey','cloud:ops-word-common:'+companyId+':'+itemId);common.set('generalId',0);common.set('itemId',itemId);common.set('modelId',52);common.set('nodeId',nodeId);common.set('tableName','ZL_C_ck');common.set('title',word);common.set('inputer',text(context.current.get('username')));common.set('status',number(payload.status,0));common.set('orderId',number(payload.orderId));try{await common.save(null,{useMasterKey:true});const assigned=await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-content-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("generalId"),0)+1 AS id FROM "CommonModel",lock_row WHERE "company"=$1), next_order AS MATERIALIZED (SELECT COALESCE(MAX("orderId"),0)+1 AS id FROM "CommonModel",lock_row WHERE "company"=$1 AND CAST("nodeId" AS text)=$3) UPDATE "CommonModel" SET "generalId"=next_id.id,"orderId"=CASE WHEN COALESCE("orderId",0)>0 THEN "orderId" ELSE next_order.id END,"updatedAt"=NOW() FROM next_id,next_order WHERE "objectId"=$2 RETURNING next_id.id',[companyId,common.id,String(nodeId)]);if(!number(assigned[0]&&assigned[0].id))throw new Error('无法分配词条内容 ID');await common.fetch({useMasterKey:true});}catch(error){await addon.destroy({useMasterKey:true}).catch(()=>undefined);if(common&&common.id)await common.destroy({useMasterKey:true}).catch(()=>undefined);throw error;}}
  3535. const duplicate=await Psql.oneOrNone('SELECT "objectId" FROM "CommonModel" WHERE "company"=$1 AND CAST("modelId" AS text)=\'52\' AND CAST("nodeId" AS text)=$2 AND LOWER(TRIM("title"))=LOWER($3) AND "objectId"<>$4 AND COALESCE("status",99)<>-2 LIMIT 1',[companyId,String(nodeId),word,common.id]);if(duplicate)fail(409,'该单元已存在同名单词');const before=created?null:{word:text(common.get('title')),nodeId:number(common.get('nodeId')),meaning:text(addon.get('sy')),phonetic:text(addon.get('yb')),example:text(addon.get('lj')),audioUrl:text(addon.get('yp'))};common.set('title',word);common.set('nodeId',nodeId);if(payload.status!==undefined)common.set('status',number(payload.status));if(number(payload.orderId))common.set('orderId',number(payload.orderId));addon.set('sy',meaning);addon.set('yb',phonetic);addon.set('lj',example);addon.set('yp',audioUrl);try{await addon.save(null,{useMasterKey:true});await common.save(null,{useMasterKey:true});}catch(error){if(created){await addon.destroy({useMasterKey:true}).catch(()=>undefined);await common.destroy({useMasterKey:true}).catch(()=>undefined);}throw error;}const after=wordProjection({...common.toJSON(),targetId:common.id,sy:meaning,yb:phonetic,lj:example,yp:audioUrl,unitName:node.nodeName});await audit(context,created?'create-vocabulary':'update-vocabulary','CommonModel',common.id,reason,before,after);return after;}
  3536. async function vocabularyLifecycle(context,input,status){requireRole(context,['ops-manager']);const reason=reasonOf(input),row=await vocabularyTarget(context,input,text(input.targetId)),target=await scopedObject('CommonModel',text(row.targetId),context,input);assertVersion(target,input.expectedUpdatedAt);const before=contentStatus(target.get('status'));target.set('status',status);await target.save(null,{useMasterKey:true});await audit(context,status===-2?'recycle-vocabulary':'restore-vocabulary','CommonModel',target.id,reason,before,contentStatus(status));return{targetId:target.id,...contentStatus(status),updatedAt:dateIso(target.updatedAt)};}
  3537. async function moveVocabulary(context,input,payload){requireRole(context,['ops-manager']);const reason=reasonOf(input),row=await vocabularyTarget(context,input,text(input.targetId)),target=await scopedObject('CommonModel',text(row.targetId),context,input),nodeId=number(payload.nodeId),companyId=companyIdOf(context,input);assertVersion(target,input.expectedUpdatedAt);const node=await Psql.oneOrNone('SELECT "nodeName" FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 AND CAST(COALESCE("contentModel",\'0\') AS text)=\'52\' AND COALESCE("zstatus",99)<>-2',[companyId,nodeId]);if(!node)fail(404,'目标单元不存在');const duplicate=await Psql.oneOrNone('SELECT "objectId" FROM "CommonModel" WHERE "company"=$1 AND CAST("modelId" AS text)=\'52\' AND CAST("nodeId" AS text)=$2 AND LOWER(TRIM("title"))=LOWER($3) AND "objectId"<>$4 AND COALESCE("status",99)<>-2 LIMIT 1',[companyId,String(nodeId),text(target.get('title')),target.id]);if(duplicate)fail(409,'目标单元已有同名单词');const before={nodeId:number(target.get('nodeId'))};target.set('nodeId',nodeId);await target.save(null,{useMasterKey:true});await audit(context,'move-vocabulary','CommonModel',target.id,reason,before,{nodeId});return{targetId:target.id,nodeId,unitName:text(node.nodeName),updatedAt:dateIso(target.updatedAt)};}
  3538. function importCell(row,...names){for(const name of names)if(row&&row[name]!==undefined&&row[name]!==null)return row[name];return'';}
  3539. async function previewVocabularyImport(context,input,payload){requireRole(context,['ops-manager']);const reason=reasonOf(input),rows=Array.isArray(payload.rows)?payload.rows:[];if(!rows.length||rows.length>2000)fail(400,'每次导入必须包含 1 至 2000 条词汇');const companyId=companyIdOf(context,input),nodes=await Psql.query('SELECT u."nodeId",u."nodeName",b."nodeName" AS "bookName",s."nodeName" AS "stageName" FROM "Node" u LEFT JOIN "Node" b ON b."company"=u."company" AND b."nodeId"=u."parentId" LEFT JOIN "Node" s ON s."company"=b."company" AND s."nodeId"=b."parentId" WHERE u."company"=$1 AND CAST(COALESCE(u."contentModel",\'0\') AS text)=\'52\' AND COALESCE(u."zstatus",99)<>-2',[companyId]),nodeMap=new Map(nodes.map((row)=>[[text(row.stageName),text(row.bookName),text(row.nodeName)].join('|').toLowerCase(),number(row.nodeId)])),seen=new Set(),items=[];for(let index=0;index<rows.length;index++){const source=rows[index]||{},generalId=number(importCell(source,'GeneralID','generalId')),stage=text(importCell(source,'学段','stageName')),book=text(importCell(source,'教材','bookName')),unit=text(importCell(source,'单元','unitName')),word=text(importCell(source,'单词','word')),meaning=text(importCell(source,'释义','meaning')),phonetic=text(importCell(source,'音标','phonetic')),example=text(importCell(source,'例句','example')),audioUrl=text(importCell(source,'音频文件名或URL','音频','audioUrl')),nodeId=number(importCell(source,'单元ID','nodeId'))||nodeMap.get([stage,book,unit].join('|').toLowerCase())||0,key=nodeId+'|'+word.toLowerCase(),errors=[];if(!word)errors.push('缺少单词');if(!nodeId)errors.push('找不到对应单元');if(seen.has(key))errors.push('文件内重复');seen.add(key);let existing=null;if(!errors.length){const base='SELECT c."objectId",c."title",c."nodeId",v."sy",v."yb",v."lj",v."yp" FROM "CommonModel" c LEFT JOIN "VocabularyWord" v ON v."company"=c."company" AND CAST(v."id" AS text)=CAST(c."itemId" AS text) WHERE c."company"=$1 AND CAST(c."modelId" AS text)=\'52\' AND ';existing=generalId?await Psql.oneOrNone(base+'CAST(c."generalId" AS text)=$2 LIMIT 1',[companyId,String(generalId)]):await Psql.oneOrNone(base+'CAST(c."nodeId" AS text)=$2 AND LOWER(TRIM(c."title"))=LOWER($3) AND COALESCE(c."status",99)<>-2 LIMIT 1',[companyId,String(nodeId),word]);}const unchanged=existing&&text(existing.title)===word&&number(existing.nodeId)===nodeId&&text(existing.sy)===meaning&&text(existing.yb)===phonetic&&text(existing.lj)===example&&text(existing.yp)===audioUrl,action=errors.length?'error':unchanged?'unchanged':existing?'update':'create';items.push({row:index+2,targetId:text(existing&&existing.objectId),action,errors,nodeId,word,meaning,phonetic,example,audioUrl,mediaMissing:!audioUrl});}const summary={total:items.length,create:items.filter((row)=>row.action==='create').length,update:items.filter((row)=>row.action==='update').length,unchanged:items.filter((row)=>row.action==='unchanged').length,duplicate:items.filter((row)=>row.errors.includes('文件内重复')).length,error:items.filter((row)=>row.action==='error').length,missingMedia:items.filter((row)=>row.mediaMissing).length};const batch=new Parse.Object('ContentImportBatch');batch.set('company',companyPointer(context,input));batch.set('sourceKey','cloud:vocabulary-import:'+companyId+':'+text(input.idempotencyKey||randomObjectId()));batch.set('kind','vocabulary');batch.set('status','preview');batch.set('fileName',text(payload.fileName));batch.set('items',items);batch.set('summary',summary);batch.set('operatorId',context.current.id);batch.set('reason',reason);await batch.save(null,{useMasterKey:true});await audit(context,'preview-vocabulary-import','ContentImportBatch',batch.id,reason,null,summary);return{batchId:batch.id,status:'preview',summary,items,createdAt:dateIso(batch.createdAt)};}
  3540. async function commitVocabularyImport(context,input){requireRole(context,['ops-manager']);const reason=reasonOf(input),batch=await scopedObject('ContentImportBatch',text(input.targetId),context,input);if(text(batch.get('kind'))!=='vocabulary')fail(400,'导入批次类型错误');if(text(batch.get('status'))==='completed')return{batchId:batch.id,status:'completed',summary:batch.get('resultSummary')||batch.get('summary'),idempotent:true};const items=Array.isArray(batch.get('items'))?batch.get('items'):[];if(items.some((row)=>row.action==='error'))fail(409,'导入预检仍有错误,不能发布');batch.set('status','processing');await batch.save(null,{useMasterKey:true});const result={created:0,updated:0,skipped:0,failed:0,items:[]};for(const item of items){if(item.action==='unchanged'){result.skipped++;result.items.push({row:item.row,status:'skipped',targetId:text(item.targetId)});continue;}try{const saved=await saveVocabulary(context,{...input,targetId:text(item.targetId),expectedUpdatedAt:''},{...item,status:99},{importing:true,reason});if(item.action==='create')result.created++;else result.updated++;result.items.push({row:item.row,status:'success',targetId:saved.targetId});}catch(error){result.failed++;result.items.push({row:item.row,status:'failed',message:text(error.message)});}}batch.set('status',result.failed?'failed':'completed');batch.set('resultSummary',result);batch.set('completedAt',new Date());await batch.save(null,{useMasterKey:true});await audit(context,'commit-vocabulary-import','ContentImportBatch',batch.id,reason,null,result);if(result.failed)fail(409,'导入有 '+result.failed+' 条失败,可查看批次结果后重试');return{batchId:batch.id,status:'completed',summary:result,items:result.items,idempotent:false};}
  3541. async function vocabularyImportStatus(context,input){const batch=await scopedObject('ContentImportBatch',text(input.targetId),context,input);return{batchId:batch.id,kind:text(batch.get('kind')),status:text(batch.get('status')),fileName:text(batch.get('fileName')),summary:batch.get('resultSummary')||batch.get('summary')||{},items:batch.get('items')||[],createdAt:dateIso(batch.createdAt),updatedAt:dateIso(batch.updatedAt)};}
  3542. async function saveVocabularyNode(context,input,payload){requireRole(context,['ops-manager']);const reason=reasonOf(input),companyId=companyIdOf(context,input),company=companyPointer(context,input),name=text(payload.name),parentId=number(payload.parentId),targetId=text(input.targetId);if(!name||name.length>200)fail(400,'分类名称不能为空且不能超过 200 字');let target=null,created=false;if(targetId){target=await scopedObject('Node',targetId,context,input);assertVersion(target,input.expectedUpdatedAt);}else{created=true;target=new Parse.Object('Node');target.set('company',company);target.set('sourceKey','cloud:ops-vocabulary-node:'+companyId+':'+randomObjectId());target.set('nodeId',0);target.set('parentId',parentId);target.set('nodeName',name);target.set('contentModel',payload.canContainWords===true?'52':'');target.set('zstatus',99);target.set('orderId',number(payload.orderId));await target.save(null,{useMasterKey:true});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) UPDATE "Node" SET "nodeId"=next_id.id,"sourceKey"=\'cloud:ops-vocabulary-node:\'||$1||\':\'||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)){await target.destroy({useMasterKey:true});fail(500,'无法分配分类 ID');}await target.fetch({useMasterKey:true});}const before=created?null:{name:text(target.get('nodeName')),parentId:number(target.get('parentId')),orderId:number(target.get('orderId'))};target.set('nodeName',name);target.set('parentId',parentId);target.set('contentModel',payload.canContainWords===true?'52':text(target.get('contentModel')));if(payload.orderId!==undefined)target.set('orderId',number(payload.orderId));await target.save(null,{useMasterKey:true});const after={objectId:target.id,nodeId:number(target.get('nodeId')),name,parentId,orderId:number(target.get('orderId')),canContainWords:text(target.get('contentModel'))==='52',updatedAt:dateIso(target.updatedAt)};await audit(context,created?'create-vocabulary-node':'update-vocabulary-node','Node',target.id,reason,before,after);return after;}
  3543. function specialProjection(row){const contentHtml=text(row.content);return{targetId:text(row.targetId||row.objectId),generalId:number(row.generalId),itemId:number(row.itemId),title:text(row.title),synopsis:text(row.synopsis),coverUrl:legacySpecialMedia(row.topImg)||specialFirstImage(contentHtml),tags:text(row.tagKey).split(',').map((value)=>value.trim()).filter(Boolean),contentHtml,videoUrl:text(row.mp4),author:text(row.author),source:text(row.source),orderId:number(row.orderId),hits:number(row.hits),...contentStatus(row.status),createdAt:dateIso(row.createTime||row.createdAt),updatedAt:dateIso(row.updatedAt)};}
  3544. function legacySpecialMedia(value){const url=text(value).replace(/^http:\/\/a018\.2018\.z01\.com/i,'https://a018.2018.z01.com');if(!url||/\/nopic\.svg(?:\?|$)/i.test(url))return'';return url.startsWith('/UploadFiles/')?'https://a018.2018.z01.com'+url:url;}
  3545. function specialFirstImage(value){const match=String(value==null?'':value).match(/<img\b[^>]*\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i),url=legacySpecialMedia(match&&(match[1]||match[2]||match[3]));return url&&url.length<=2000&&(/^(?:https?:)?\/\//i.test(url)||url.startsWith('/'))?url:'';}
  3546. function legacySpecialHtml(value){let html=String(value==null?'':value).slice(0,200000);html=html.replace(/<\s*(script|iframe|object|embed|form|style)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi,'').replace(/<\s*(meta|link)\b[^>]*\/?\s*>/gi,'').replace(/\s+on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi,'').replace(/(?:javascript|vbscript|data\s*:\s*text\/html)\s*:/gi,'').replace(/http:\/\/a018\.2018\.z01\.com/gi,'https://a018.2018.z01.com').replace(/\b(src|href)\s*=\s*(["'])\/UploadFiles\//gi,'$1=$2https://a018.2018.z01.com/UploadFiles/');return html.trim();}
  3547. async function legacySpecialSource(){const config=await legacyScheduleConfig();if(!config.url||!config.apiId||!config.apiKey)return{available:false,message:'旧系统专项训练接口尚未配置',items:[],total:0,refreshedAt:''};try{const first=await legacyScheduleRequest(config,{action:'content_list',nid:3,psize:100,cpage:1}),raw=[...first.items],pageCount=Math.max(1,number(first.page.pageCount,1));for(let page=2;page<=pageCount;page++)raw.push(...(await legacyScheduleRequest(config,{action:'content_list',nid:3,psize:100,cpage:page})).items);const details=new Map();for(let index=0;index<raw.length;index+=5){const chunk=raw.slice(index,index+5),found=await Promise.all(chunk.map((row)=>legacyScheduleRequest(config,{action:'content_get',id:number(row.GeneralID||row.generalId)}).catch(()=>({items:[]}))));chunk.forEach((row,offset)=>details.set(number(row.GeneralID||row.generalId),found[offset].items[0]||{}));}const items=raw.map((row,index)=>{const generalId=number(row.GeneralID||row.generalId),detail=details.get(generalId)||{},title=text(row.Title||row.title||detail.Title||detail.title),sourceDate=legacyDateIso(row.UpDateTime||row.updatedAt||row.CreateTime||detail.CreateTime),contentHtml=legacySpecialHtml(detail.content||detail.Content),coverUrl=legacySpecialMedia(row.TopImg||row.topImg||detail.TopImg||detail.topImg)||specialFirstImage(contentHtml);return{generalId,title,status:number(row.Status??row.status,99),orderId:index+1,coverUrl,tags:text(row.TagKey||row.tagKey||detail.TagKey||detail.tagKey),synopsis:text(detail.synopsis||detail.Synopsis||detail.subtitle||detail.Subtitle),contentHtml,videoUrl:legacySpecialMedia(detail.mp4||detail.MP4||detail.video),author:text(detail.author||detail.Author),source:text(detail.source||detail.Source),sourceDate};}).filter((row)=>row.generalId&&row.title);return{available:true,message:'已与旧后台专项训练栏目对齐',items,total:number(first.page.itemCount,items.length),refreshedAt:new Date().toISOString()};}catch(error){return{available:false,message:'旧系统专项训练暂时读取失败:'+text(error&&error.message),items:[],total:0,refreshedAt:''};}}
  3548. async function allocateSpecialArticle(context,input,generalId){const companyId=companyIdOf(context,input),company=await new Parse.Query('Company').get(companyId,{useMasterKey:true}),article=new Parse.Object('ContentArticle');article.set('company',company);article.set('sourceKey','legacy-special-article:'+companyId+':'+generalId);await article.save(null,{useMasterKey:true});const rows=await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-article-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "ContentArticle",lock_row WHERE "company"=$1) UPDATE "ContentArticle" SET "id"=next_id.id,"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id',[companyId,article.id]),itemId=number(rows[0]&&rows[0].id);if(!itemId){await article.destroy({useMasterKey:true}).catch(()=>undefined);fail(500,'无法为旧专项内容分配附表 ID');}await article.fetch({useMasterKey:true});return{article,itemId};}
  3549. async function specialSyncStep(label,task){try{return await task();}catch(error){throw new Error(label+':'+text(error&&error.message));}}
  3550. async function syncLegacySpecial(context,input){const source=await legacySpecialSource();if(!source.available)return{...source,created:0,updated:0};const companyId=companyIdOf(context,input),existing=await Psql.query('SELECT "objectId","generalId","itemId" FROM "CommonModel" WHERE "company"=$1 AND CAST("modelId" AS text)=\'2\' AND CAST("nodeId" AS text)=\'3\'',[companyId]),byGeneralId=new Map(existing.map((row)=>[number(row.generalId),row]));let created=0,updated=0;for(const item of source.items){const found=byGeneralId.get(item.generalId);let common=found?await specialSyncStep('读取专项主表 '+item.generalId,()=>new Parse.Query('CommonModel').get(text(found.objectId),{useMasterKey:true})):null,article=null,itemId=number(found&&found.itemId);if(itemId){const row=await Psql.oneOrNone('SELECT "objectId" FROM "ContentArticle" WHERE "company"=$1 AND CAST("id" AS text)=$2 LIMIT 1',[companyId,String(itemId)]);if(row)article=await specialSyncStep('读取专项正文 '+item.generalId,()=>new Parse.Query('ContentArticle').get(text(row.objectId),{useMasterKey:true}));}if(!article){const allocated=await specialSyncStep('创建专项正文 '+item.generalId,()=>allocateSpecialArticle(context,input,item.generalId));article=allocated.article;itemId=allocated.itemId;}if(!common){common=new Parse.Object('CommonModel');common.set('company',article.get('company')||context.company);common.set('sourceKey','legacy-special-common:'+companyId+':'+item.generalId);common.set('generalId',item.generalId);common.set('itemId',itemId);common.set('modelId',2);common.set('nodeId',3);common.set('tableName','ZL_C_Article');common.set('inputer','legacy-sync');created++;}else updated++;common.set('title',item.title);common.set('subtitle',item.synopsis.slice(0,500));common.set('topImg',item.coverUrl);common.set('tagKey',item.tags);common.set('status',item.status);common.set('orderId',item.orderId);common.set('upDateTime',item.sourceDate?new Date(item.sourceDate):new Date());if(item.sourceDate&&!common.get('createTime'))common.set('createTime',new Date(item.sourceDate));article.set('content',item.contentHtml);article.set('synopsis',item.synopsis);article.set('mp4',item.videoUrl);article.set('author',item.author);article.set('source',item.source);await specialSyncStep('保存专项正文 '+item.generalId,()=>article.save(null,{useMasterKey:true}));await specialSyncStep('保存专项主表 '+item.generalId,()=>common.save(null,{useMasterKey:true}));byGeneralId.set(item.generalId,{objectId:common.id,generalId:item.generalId,itemId});}return{...source,created,updated};}
  3551. async function specialRows(context,input){let sync=null;if(input.refresh===true){try{sync=await syncLegacySpecial(context,input);}catch(error){sync={available:false,message:'专项训练同步失败:'+text(error&&error.message).slice(0,180),total:0,created:0,updated:0,refreshedAt:new Date().toISOString()};}}const companyId=companyIdOf(context,input),page=Math.max(1,number(input.page,1)),pageSize=Math.min(100,Math.max(1,number(input.pageSize,20))),filters=input.filters&&typeof input.filters==='object'?input.filters:{},values=[companyId],clauses=['c."company"=$1','CAST(c."modelId" AS text)=\'2\'','CAST(c."nodeId" AS text)=\'3\''];if(text(input.search)){values.push('%'+text(input.search).replace(/[%_]/g,'')+'%');clauses.push('(c."title" ILIKE $'+values.length+' OR a."synopsis" ILIKE $'+values.length+')');}if(filters.status!==undefined&&filters.status!==''){values.push(String(number(filters.status)));clauses.push('CAST(COALESCE(c."status",99) AS text)=$'+values.length);}const where=clauses.join(' AND '),total=await Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c LEFT JOIN "ContentArticle" a ON a."company"=c."company" AND CAST(a."id" AS text)=CAST(c."itemId" AS text) WHERE '+where,values),rows=await Psql.query('SELECT c."objectId" AS "targetId",c.*,a."content",a."synopsis",a."mp4",a."author",a."source" FROM "CommonModel" c LEFT JOIN "ContentArticle" a ON a."company"=c."company" AND CAST(a."id" AS text)=CAST(c."itemId" AS text) WHERE '+where+' ORDER BY COALESCE(c."orderId",0),c."generalId" LIMIT $'+(values.length+1)+' OFFSET $'+(values.length+2),values.concat([pageSize,(page-1)*pageSize]));return{page,pageSize,total:number(total.total),items:rows.map(specialProjection),refreshedAt:new Date().toISOString(),sourceStatus:sync?{available:sync.available,message:sync.message,legacyTotal:sync.total,created:sync.created,updated:sync.updated,refreshedAt:sync.refreshedAt}:null};}
  3552. async function specialTarget(context,input,targetId){const companyId=companyIdOf(context,input),row=await Psql.oneOrNone('SELECT c."objectId" AS "targetId",c.*,a."objectId" AS "articleObjectId",a."content",a."synopsis",a."mp4",a."author",a."source" FROM "CommonModel" c LEFT JOIN "ContentArticle" a ON a."company"=c."company" AND CAST(a."id" AS text)=CAST(c."itemId" AS text) WHERE c."company"=$1 AND CAST(c."modelId" AS text)=\'2\' AND CAST(c."nodeId" AS text)=\'3\' AND (c."objectId"=$2 OR CAST(c."generalId" AS text)=$2) LIMIT 1',[companyId,text(targetId)]);if(!row)fail(404,'专项训练内容不存在');return row;}
  3553. async function saveSpecial(context,input,payload){requireRole(context,['ops-manager','ops-staff']);const reason=reasonOf(input),title=text(payload.title),synopsis=text(payload.synopsis),coverUrl=text(payload.coverUrl),tags=Array.isArray(payload.tags)?payload.tags.map(text).filter(Boolean):text(payload.tags).split(/[,,]/).map(text).filter(Boolean),contentHtml=cleanHtml(payload.contentHtml),videoUrl=text(payload.videoUrl),author=text(payload.author),source=text(payload.source),companyId=companyIdOf(context,input),company=companyPointer(context,input);if(!title||title.length>200)fail(400,'标题不能为空且不能超过 200 字');if(synopsis.length>4000||coverUrl.length>2000||videoUrl.length>2000||tags.length>30)fail(400,'简介、媒体地址或标签超出限制');let common=null,article=null,created=false;const targetId=text(input.targetId||payload.targetId);if(targetId){const found=await specialTarget(context,input,targetId);common=await scopedObject('CommonModel',text(found.targetId),context,input);assertVersion(common,input.expectedUpdatedAt);if(roleOf(context)==='ops-staff'&&number(common.get('status'),99)===99)fail(403,'运营专员只能编辑草稿,已发布内容需由运营经理处理');if(text(found.articleObjectId))article=await new Parse.Query('ContentArticle').get(text(found.articleObjectId),{useMasterKey:true});}else{created=true;article=new Parse.Object('ContentArticle');article.set('company',company);article.set('sourceKey','cloud:ops-special:'+companyId+':'+randomObjectId());await article.save(null,{useMasterKey:true});const ids=await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-article-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "ContentArticle",lock_row WHERE "company"=$1) UPDATE "ContentArticle" SET "id"=next_id.id,"sourceKey"=\'cloud:ops-special:\'||$1||\':\'||next_id.id::text,"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id',[companyId,article.id]),itemId=number(ids[0]&&ids[0].id);await article.fetch({useMasterKey:true});common=new Parse.Object('CommonModel');common.set('company',company);common.set('sourceKey','cloud:ops-special-common:'+companyId+':'+itemId);common.set('generalId',0);common.set('itemId',itemId);common.set('modelId',2);common.set('nodeId',3);common.set('tableName','ZL_C_Article');common.set('inputer',text(context.current.get('username')));common.set('status',0);common.set('orderId',number(payload.orderId));try{await common.save(null,{useMasterKey:true});const assigned=await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-content-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("generalId"),0)+1 AS id FROM "CommonModel",lock_row WHERE "company"=$1), next_order AS MATERIALIZED (SELECT COALESCE(MAX("orderId"),0)+1 AS id FROM "CommonModel",lock_row WHERE "company"=$1 AND CAST("nodeId" AS text)=\'3\') UPDATE "CommonModel" SET "generalId"=next_id.id,"orderId"=CASE WHEN COALESCE("orderId",0)>0 THEN "orderId" ELSE next_order.id END,"updatedAt"=NOW() FROM next_id,next_order WHERE "objectId"=$2 RETURNING next_id.id',[companyId,common.id]);if(!number(assigned[0]&&assigned[0].id))throw new Error('无法分配专项内容 ID');await common.fetch({useMasterKey:true});}catch(error){await article.destroy({useMasterKey:true}).catch(()=>undefined);if(common&&common.id)await common.destroy({useMasterKey:true}).catch(()=>undefined);throw error;}}if(!article)fail(409,'专项训练附表缺失');const duplicate=await Psql.oneOrNone('SELECT "objectId" FROM "CommonModel" WHERE "company"=$1 AND CAST("modelId" AS text)=\'2\' AND CAST("nodeId" AS text)=\'3\' AND LOWER(TRIM("title"))=LOWER($2) AND "objectId"<>$3 AND COALESCE("status",99)<>-2 LIMIT 1',[companyId,title,common.id]);if(duplicate)fail(409,'专项训练已存在同名内容');const before=created?null:specialProjection({...common.toJSON(),content:article.get('content'),synopsis:article.get('synopsis'),mp4:article.get('mp4'),author:article.get('author'),source:article.get('source')});common.set('title',title);common.set('subtitle',synopsis.slice(0,500));common.set('topImg',coverUrl);common.set('tagKey',tags.join(','));if(payload.orderId!==undefined)common.set('orderId',number(payload.orderId));article.set('content',contentHtml);article.set('synopsis',synopsis);article.set('mp4',videoUrl);article.set('author',author);article.set('source',source);try{await article.save(null,{useMasterKey:true});await common.save(null,{useMasterKey:true});}catch(error){if(created){await article.destroy({useMasterKey:true}).catch(()=>undefined);await common.destroy({useMasterKey:true}).catch(()=>undefined);}throw error;}const after=specialProjection({...common.toJSON(),targetId:common.id,content:contentHtml,synopsis,mp4:videoUrl,author,source});await audit(context,created?'create-special-training':'update-special-training','CommonModel',common.id,reason,before,after);return after;}
  3554. async function specialLifecycle(context,input,status){requireRole(context,['ops-manager']);const reason=reasonOf(input),row=await specialTarget(context,input,text(input.targetId)),target=await scopedObject('CommonModel',text(row.targetId),context,input);assertVersion(target,input.expectedUpdatedAt);if(status===99&&(!text(row.title)||!cleanHtml(row.content)))fail(409,'发布前必须填写标题和正文');const before=contentStatus(target.get('status'));target.set('status',status);target.set('upDateTime',new Date());await target.save(null,{useMasterKey:true});await audit(context,status===99?'publish-special-training':status===0?'unpublish-special-training':status===-2?'recycle-special-training':'restore-special-training','CommonModel',target.id,reason,before,contentStatus(status));return{targetId:target.id,...contentStatus(status),updatedAt:dateIso(target.updatedAt)};}
  3555. function mediaConfig(){const env=typeof process!=='undefined'&&process.env?process.env:{};return{endpoint:text(env.XIAOSHU_S3_ENDPOINT).replace(/\/$/,''),bucket:text(env.XIAOSHU_S3_BUCKET),region:text(env.XIAOSHU_S3_REGION||'auto'),accessKey:text(env.XIAOSHU_S3_ACCESS_KEY_ID),secretKey:text(env.XIAOSHU_S3_SECRET_ACCESS_KEY),publicBase:text(env.XIAOSHU_S3_PUBLIC_BASE_URL).replace(/\/$/,'')};}
  3556. function hex(bytes){return Array.from(new Uint8Array(bytes)).map((value)=>value.toString(16).padStart(2,'0')).join('');}
  3557. async function sha256(value){return hex(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(value)));}
  3558. async function hmac(key,value,raw=false){const imported=await crypto.subtle.importKey('raw',typeof key==='string'?new TextEncoder().encode(key):key,{name:'HMAC',hash:'SHA-256'},false,['sign']),signed=await crypto.subtle.sign('HMAC',imported,new TextEncoder().encode(value));return raw?signed:hex(signed);}
  3559. async function uploadInlineMedia(context,input,payload){requireRole(context,['ops-manager']);const reason=reasonOf(input),kind=text(payload.kind),contentType=text(payload.contentType).toLowerCase(),fileName=text(payload.fileName).replace(/[^a-zA-Z0-9._-]/g,'_'),base64=text(payload.base64).replace(/^data:[^;]+;base64,/i,''),allowed=['image/jpeg','image/png','image/webp','image/gif'];if(kind!=='image'||!allowed.includes(contentType)||!fileName||!base64||base64.length>2800000||!/^[a-zA-Z0-9+/=]+$/.test(base64))fail(400,'封面文件类型或大小不符合要求');const signatures={'image/jpeg':'/9j/','image/png':'iVBOR','image/webp':'UklGR','image/gif':'R0lG'};if(!base64.startsWith(signatures[contentType]))fail(400,'封面文件内容与类型不一致');const config=await legacyScheduleConfig();if(!config.url||!config.apiId||!config.apiKey)fail(503,'封面上传服务尚未配置');let bytes;try{const binary=atob(base64);bytes=new Uint8Array(binary.length);for(let index=0;index<binary.length;index++)bytes[index]=binary.charCodeAt(index);}catch(_){fail(400,'封面文件编码无效');}const query=Object.entries({action:'upload',apiId:config.apiId,apiKey:config.apiKey}).map(([key,value])=>encodeURIComponent(key)+'='+encodeURIComponent(String(value))).join('&'),url=config.url+(config.url.includes('?')?'&':'?')+query,form=new FormData();form.append('file',new Blob([bytes],{type:contentType}),fileName);const response=await fetch(url,{method:'POST',body:form});const raw=await response.text();let result;try{result=JSON.parse(raw);}catch(_){fail(502,'封面上传服务返回了无效数据');}if(!response.ok||number(result&&result.retcode)===-1)fail(502,text(result&&result.retmsg)||'封面上传服务暂时不可用');const publicUrl=legacySpecialMedia(result&&result.result);if(!publicUrl)fail(502,'封面上传服务未返回文件地址');await audit(context,'upload-special-cover','MediaUpload',randomObjectId(),reason,null,{publicUrl,contentType,size:bytes.length});return{publicUrl,contentType,size:bytes.length,width:number(payload.width),height:number(payload.height),aspectRatio:'4:3'};}
  3560. async function presignMedia(context,input,payload){requireRole(context,['ops-manager']);const config=mediaConfig();if(!config.endpoint||!config.bucket||!config.accessKey||!config.secretKey||!config.publicBase)fail(501,'对象存储尚未配置,请设置 XIAOSHU_S3_* 服务端环境变量');const fileName=text(payload.fileName).replace(/[^a-zA-Z0-9._-]/g,'_'),contentType=text(payload.contentType).toLowerCase(),size=number(payload.size),kind=text(payload.kind),rules={image:{types:['image/jpeg','image/png','image/webp','image/gif'],max:10*1024*1024},audio:{types:['audio/mpeg','audio/mp3','audio/wav','audio/x-wav','audio/mp4'],max:30*1024*1024},video:{types:['video/mp4','video/webm'],max:1024*1024*1024}},rule=rules[kind];if(!fileName||!rule||!rule.types.includes(contentType)||size<1||size>rule.max)fail(400,'文件类型或大小不符合上传规则');const companyId=companyIdOf(context,input),now=new Date(),stamp=now.toISOString().replace(/[:-]|\.\d{3}/g,''),date=stamp.slice(0,8),objectKey=companyId+'/'+kind+'/'+date+'/'+randomObjectId()+'-'+fileName,host=new URL(config.endpoint).host,canonicalUri='/'+config.bucket+'/'+objectKey.split('/').map(encodeURIComponent).join('/'),algorithm='AWS4-HMAC-SHA256',scope=date+'/'+config.region+'/s3/aws4_request',credential=encodeURIComponent(config.accessKey+'/'+scope),query='X-Amz-Algorithm='+algorithm+'&X-Amz-Credential='+credential+'&X-Amz-Date='+stamp+'&X-Amz-Expires=900&X-Amz-SignedHeaders=host',canonicalRequest='PUT\n'+canonicalUri+'\n'+query+'\nhost:'+host+'\n\nhost\nUNSIGNED-PAYLOAD',stringToSign=algorithm+'\n'+stamp+'\n'+scope+'\n'+await sha256(canonicalRequest),kDate=await hmac('AWS4'+config.secretKey,date,true),kRegion=await hmac(kDate,config.region,true),kService=await hmac(kRegion,'s3',true),kSigning=await hmac(kService,'aws4_request',true),signature=await hmac(kSigning,stringToSign),uploadUrl=config.endpoint+canonicalUri+'?'+query+'&X-Amz-Signature='+signature,publicUrl=config.publicBase+'/'+objectKey;const ticket=new Parse.Object('MediaUploadTicket');ticket.set('company',companyPointer(context,input));ticket.set('sourceKey','cloud:media-ticket:'+companyId+':'+randomObjectId());ticket.set('objectKey',objectKey);ticket.set('publicUrl',publicUrl);ticket.set('contentType',contentType);ticket.set('size',size);ticket.set('kind',kind);ticket.set('status','pending');ticket.set('expiresAt',new Date(Date.now()+900000));await ticket.save(null,{useMasterKey:true});return{ticketId:ticket.id,method:'PUT',uploadUrl,publicUrl,objectKey,headers:{'Content-Type':contentType},expiresAt:new Date(Date.now()+900000).toISOString()};}
  3561. async function completeMedia(context,input){requireRole(context,['ops-manager']);const reason=reasonOf(input),ticket=await scopedObject('MediaUploadTicket',text(input.targetId),context,input);if(text(ticket.get('status'))==='completed')return{ticketId:ticket.id,publicUrl:text(ticket.get('publicUrl')),objectKey:text(ticket.get('objectKey')),idempotent:true};if(new Date(ticket.get('expiresAt')).getTime()<Date.now())fail(409,'上传凭证已过期');const response=await fetch(text(ticket.get('publicUrl')),{method:'HEAD'}).catch(()=>null);if(!response||!response.ok)fail(409,'尚未检测到已上传文件,请完成上传后重试');ticket.set('status','completed');ticket.set('completedAt',new Date());await ticket.save(null,{useMasterKey:true});await audit(context,'complete-media-upload','MediaUploadTicket',ticket.id,reason,null,{objectKey:ticket.get('objectKey'),kind:ticket.get('kind'),size:ticket.get('size')});return{ticketId:ticket.id,publicUrl:text(ticket.get('publicUrl')),objectKey:text(ticket.get('objectKey')),idempotent:false};}
  3562. const SYNC_DATASETS={
  3563. users:{label:'全部业务账号',className:'_User',records:[['_User','legacyUserId']]},
  3564. nodes:{label:'课程与词库目录',className:'Node',records:[['Node','nodeId']]},
  3565. 'course-bindings':{label:'课程绑定',modelId:58,records:[['CommonModel','generalId'],['CourseBinding','id']]},
  3566. appointments:{label:'预约排课',modelId:54,records:[['CommonModel','generalId'],['CourseAppointment','id']]},
  3567. lessons:{label:'上课记录',modelId:59,records:[['CommonModel','generalId'],['LessonRecord','id']]},
  3568. 'learning-records':{label:'每日学习记录',modelId:56,records:[['CommonModel','generalId'],['DailyStudyRecord','id']]},
  3569. 'practice-records':{label:'练习记录',modelId:53,records:[['CommonModel','generalId'],['PracticeRecord','id']]},
  3570. 'memory-records':{label:'抗遗忘记录',modelId:60,records:[['CommonModel','generalId'],['MemoryPracticeRecord','id']]},
  3571. assessments:{label:'测评档案',modelId:61,records:[['CommonModel','generalId'],['AssessmentProfile','id']]},
  3572. vocabulary:{label:'词库',modelId:52,records:[['CommonModel','generalId'],['VocabularyWord','id']]},
  3573. 'special-training':{label:'专项训练',modelId:2,nodeId:3,records:[['CommonModel','generalId'],['ContentArticle','id']]}
  3574. };
  3575. const SYNC_CLASSES=new Set(['_User','Node','CommonModel','CourseBinding','CourseAppointment','LessonRecord','DailyStudyRecord','PracticeRecord','MemoryPracticeRecord','AssessmentProfile','VocabularyWord','ContentArticle']);
  3576. const SYNC_FORBIDDEN_FIELDS=new Set(['password','userPwd','payPassWord','question','answer','acl','session','sessionToken','authData','legacyPasswordHash']);
  3577. const LEGACY_MUTATIONS={
  3578. 'ops/users/create':{command:'member.create',dataset:'users'},'ops/users/update-profile':{command:'member.update-profile',dataset:'users'},'ops/users/reset-password':{command:'member.reset-password',dataset:'users',manager:true},'ops/users/change-status':{command:'member.change-status',dataset:'users',manager:true},'ops/users/change-group':{command:'member.change-group',dataset:'users',manager:true},'ops/users/adjust-balance':{command:'member.adjust-balance',dataset:'users',manager:true},'ops/relations/save':{command:'member.bind-agent',dataset:'users'},
  3579. 'ops/coaches/create':{command:'coach.create',dataset:'users',manager:true},'ops/coaches/update':{command:'coach.update',dataset:'users',manager:true},'ops/coaches/delete':{command:'coach.delete',dataset:'users',manager:true},
  3580. 'ops/appointments/create':{command:'appointment.create',dataset:'appointments'},'ops/appointments/update':{command:'appointment.update',dataset:'appointments'},'ops/appointments/cancel':{command:'appointment.cancel',dataset:'appointments'},'ops/appointments/restore':{command:'appointment.restore',dataset:'appointments'},'ops/appointments/complete':{command:'appointment.complete',dataset:'appointments'},'ops/appointments/transition':{command:'appointment.transition',dataset:'appointments'},
  3581. 'ops/course-bindings/save':{command:'course-binding.save',dataset:'course-bindings'},'ops/course-bindings/recycle':{command:'course-binding.recycle',dataset:'course-bindings',manager:true},'ops/course-bindings/restore':{command:'course-binding.restore',dataset:'course-bindings',manager:true},
  3582. 'ops/vocabulary/save':{command:'vocabulary.save',dataset:'vocabulary'},'ops/vocabulary/move':{command:'vocabulary.move',dataset:'vocabulary'},'ops/vocabulary/recycle':{command:'vocabulary.recycle',dataset:'vocabulary',manager:true},'ops/vocabulary/restore':{command:'vocabulary.restore',dataset:'vocabulary',manager:true},'ops/vocabulary/import-commit':{command:'vocabulary.import',dataset:'vocabulary',manager:true},
  3583. 'ops/special-training/save':{command:'special-training.save',dataset:'special-training'},'ops/special-training/publish':{command:'special-training.publish',dataset:'special-training',manager:true},'ops/special-training/unpublish':{command:'special-training.unpublish',dataset:'special-training',manager:true},'ops/special-training/recycle':{command:'special-training.recycle',dataset:'special-training',manager:true},'ops/special-training/restore':{command:'special-training.restore',dataset:'special-training',manager:true}
  3584. };
  3585. function requireSuperAdmin(context){if(!context.isSuperAdmin)fail(403,'只有超级管理员可以访问数据同步中心');}
  3586. function syncConfig(){const env=typeof process!=='undefined'&&process.env?process.env:{};return{baseUrl:text(env.XIAOSHU_LEGACY_SYNC_BASE_URL).replace(/\/$/,''),keyId:text(env.XIAOSHU_LEGACY_SYNC_KEY_ID),secret:text(env.XIAOSHU_LEGACY_SYNC_SECRET),writesEnabled:/^(1|true|yes)$/i.test(text(env.XIAOSHU_LEGACY_SYNC_WRITES_ENABLED))};}
  3587. function syncBridgeConfigured(){const config=syncConfig();return Boolean(config.baseUrl&&config.keyId&&config.secret);}
  3588. function syncBridgeWritesEnabled(){const config=syncConfig();return Boolean(config.baseUrl&&config.keyId&&config.secret&&config.writesEnabled);}
  3589. async function operationalSyncIndicator(context,input){const config=syncConfig();if(!config.baseUrl||!config.keyId||!config.secret)return{state:'not_configured',label:'尚未启用持续同步',lastSuccessAt:'',lagSeconds:null};const rows=await newClassObjects('LegacySyncCheckpoint',context,input,1000),critical=rows.filter((row)=>['users','appointments','lessons'].includes(text(row.get('dataset')))),lastSuccessAt=critical.map((row)=>dateIso(row.get('lastSuccessAt'))).filter(Boolean).sort()[0]||'',lagSeconds=lastSuccessAt?Math.max(0,Math.floor((Date.now()-new Date(lastSuccessAt).getTime())/1000)):null,healthy=critical.length===3&&critical.every((row)=>text(row.get('state'))==='healthy'||text(row.get('state'))==='catching_up')&&lagSeconds!==null&&lagSeconds<=120;return{state:healthy?'synced':'delayed',label:healthy?'核心数据刚刚同步':'数据同步延迟,正在追平',lastSuccessAt,lagSeconds};}
  3590. async function syncBridgeRequest(path,options={}){
  3591. const config=syncConfig();if(!config.baseUrl||!config.keyId||!config.secret)fail(503,'旧系统同步桥尚未配置');
  3592. let url;try{url=new URL(config.baseUrl+path);}catch(_){fail(503,'旧系统同步桥地址无效');}if(url.protocol!=='https:'&&!['localhost','127.0.0.1','::1'].includes(url.hostname))fail(503,'旧系统同步桥必须使用 HTTPS');
  3593. const method=text(options.method||'GET').toUpperCase(),body=options.body?JSON.stringify(options.body):'',timestamp=String(Math.floor(Date.now()/1000)),nonce=randomObjectId()+randomObjectId(),bodyHash=await sha256(body),pathAndQuery=url.pathname+url.search,canonical=method+'\n'+pathAndQuery+'\n'+timestamp+'\n'+nonce+'\n'+bodyHash,signature=await hmac(config.secret,canonical),headers={'X-Xiaoshu-Key-Id':config.keyId,'X-Xiaoshu-Timestamp':timestamp,'X-Xiaoshu-Nonce':nonce,'X-Xiaoshu-Signature':signature,accept:'application/json'};if(body)headers['content-type']='application/json';
  3594. const response=await fetch(url.toString(),{method,headers,body:body||undefined});let payload={};try{payload=await response.json();}catch(_){}if(!response.ok)fail(response.status>=500?502:response.status,text(payload.message||payload.error)||'旧系统同步桥请求失败');return payload;
  3595. }
  3596. async function bridgeFirstWrite(context,input,operation,payload){const spec=LEGACY_MUTATIONS[operation];requireRole(context,spec.manager?['ops-manager']:['ops-manager','ops-staff']);const reason=reasonOf(input),idempotencyKey=text(input.idempotencyKey)||randomObjectId(),actor=text(context.current.get('username'))||context.current.id,body={actor,reason,idempotencyKey,expectedUpdatedAt:text(input.expectedUpdatedAt),payload:{targetId:text(input.targetId),...payload}},result=await syncBridgeRequest('/v1/commands/'+encodeURIComponent(spec.command),{method:'POST',body}),checkpoint=await syncCheckpoint(context,input,spec.dataset);checkpoint.set('state','pending');checkpoint.set('lastAttemptAt',new Date());await checkpoint.save(null,{useMasterKey:true});await audit(context,'legacy-bridge-write',spec.dataset,text(input.targetId)||idempotencyKey,reason,null,{operation:spec.command,idempotencyKey,syncPending:true});return{...((result&&result.result&&typeof result.result==='object')?result.result:{}),legacyResult:result&&result.result,syncPending:true,message:'旧业务系统已写入成功,Parse 投影正在同步'};}
  3597. async function syncCheckpoint(context,input,dataset){const company=companyPointer(context,input),query=new Parse.Query('LegacySyncCheckpoint');if(company)query.equalTo('company',company);query.equalTo('dataset',dataset);let checkpoint=await query.first({useMasterKey:true}).catch(()=>null);if(!checkpoint){checkpoint=new Parse.Object('LegacySyncCheckpoint');if(company)checkpoint.set('company',company);checkpoint.set('sourceKey','cloud:legacy-sync-checkpoint:'+companyIdOf(context,input)+':'+dataset);checkpoint.set('dataset',dataset);checkpoint.set('cursor','');checkpoint.set('state','never_synced');checkpoint.set('processedCount',0);}return checkpoint;}
  3598. async function acquireSyncLease(checkpoint,dataset){
  3599. if(!checkpoint.id)await checkpoint.save(null,{useMasterKey:true});
  3600. const leaseToken=randomObjectId()+randomObjectId(),rows=await Psql.query('UPDATE "LegacySyncCheckpoint" SET "leaseToken"=$2,"leaseUntil"=NOW()+INTERVAL \'5 minutes\',"state"=\'running\',"lastAttemptAt"=NOW(),"updatedAt"=NOW() WHERE "objectId"=$1 AND ("leaseUntil" IS NULL OR "leaseUntil"<NOW()) RETURNING "objectId"',[checkpoint.id,leaseToken]);
  3601. if(!rows.length)fail(409,dataset+'正在同步,请勿重复启动');
  3602. await checkpoint.fetch({useMasterKey:true});return leaseToken;
  3603. }
  3604. async function releaseSyncLease(checkpoint,leaseToken,state){
  3605. if(!checkpoint.id||!leaseToken)return;
  3606. await Psql.query('UPDATE "LegacySyncCheckpoint" SET "leaseToken"=NULL,"leaseUntil"=NULL,"state"=COALESCE($3,"state"),"updatedAt"=NOW() WHERE "objectId"=$1 AND "leaseToken"=$2',[checkpoint.id,leaseToken,state||null]).catch(()=>undefined);
  3607. }
  3608. function validateSyncPage(page,dataset,limit){
  3609. const spec=SYNC_DATASETS[dataset],items=page&&Array.isArray(page.items)?page.items:null;
  3610. if(!page||text(page.dataset)!==dataset||!items||text(page.cursor)===''||number(page.count)!==items.length||items.length>limit)fail(502,'旧系统同步桥返回的分页协议无效');
  3611. const expected=new Set(spec.records.map(([className,keyField])=>className+':'+keyField));
  3612. for(const item of items){
  3613. const operation=text(item&&item.operation||'upsert'),records=item&&Array.isArray(item.records)?item.records:[];
  3614. if(!text(item&&item.key)||!['upsert','delete'].includes(operation)||records.length!==expected.size)fail(502,'旧系统同步桥返回了无效变更项');
  3615. const actual=new Set();for(const record of records){const signature=text(record&&record.className)+':'+text(record&&record.keyField);if(!expected.has(signature)||actual.has(signature)||!text(record&&record.legacyKey)||!record.fields||typeof record.fields!=='object'||Array.isArray(record.fields))fail(502,'旧系统同步桥返回了越权或不完整的数据投影');actual.add(signature);}
  3616. }
  3617. return items;
  3618. }
  3619. async function syncIssueCounts(context,input,className){const company=companyPointer(context,input),rows=await newClassObjects(className,context,input,10000);const byDataset=new Map();for(const row of rows){if(row.get('resolved')===true)continue;const key=text(row.get('dataset'));byDataset.set(key,(byDataset.get(key)||0)+1);}return byDataset;}
  3620. async function syncParseCount(context,input,dataset){const spec=SYNC_DATASETS[dataset],companyId=companyIdOf(context,input);if(spec.className){const active=spec.className==='_User'?'("isDeleted" IS NULL OR "isDeleted"=FALSE)':spec.className==='Node'?'COALESCE("zstatus",99)<>-2':'TRUE';return number((await Psql.one('SELECT COUNT(*)::int AS total FROM "'+spec.className+'" WHERE ($1::text IS NULL OR "company"=$1) AND '+active,[companyId])).total);}const values=[companyId,String(spec.modelId)],clauses=['($1::text IS NULL OR "company"=$1)','CAST("modelId" AS text)=$2','COALESCE("status",99)<>-2'];if(spec.nodeId){values.push(String(spec.nodeId));clauses.push('CAST("nodeId" AS text)=$3');}return number((await Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" WHERE '+clauses.join(' AND '),values)).total);}
  3621. async function syncStatus(context,input){
  3622. requireSuperAdmin(context);const config=syncConfig(),configured=Boolean(config.baseUrl&&config.keyId&&config.secret),checkpoints=await newClassObjects('LegacySyncCheckpoint',context,input,1000),checkpointMap=new Map(checkpoints.map((row)=>[text(row.get('dataset')),row])),failureMap=await syncIssueCounts(context,input,'LegacySyncDeadLetter'),conflictMap=await syncIssueCounts(context,input,'LegacySyncConflict');let manifest=null,bridgeReachable=false,bridgeMessage='';if(configured&&input.probe!==false){try{manifest=await syncBridgeRequest('/v1/manifest');bridgeReachable=manifest&&manifest.consistent===true;if(!bridgeReachable)bridgeMessage='旧库正在变化,本次水位快照不一致,请重试';}catch(error){bridgeMessage=text(error.message);}}
  3623. const manifestMap=new Map((manifest&&Array.isArray(manifest.datasets)?manifest.datasets:[]).map((row)=>[text(row.key),row])),datasets=[];for(const [key,spec] of Object.entries(SYNC_DATASETS)){const checkpoint=checkpointMap.get(key),remote=manifestMap.get(key),parseCount=await syncParseCount(context,input,key).catch(()=>0),legacyCount=remote?number(remote.count):null,lastSuccessAt=checkpoint?dateIso(checkpoint.get('lastSuccessAt')):'',lastAttemptAt=checkpoint?dateIso(checkpoint.get('lastAttemptAt')):'',lagSeconds=lastSuccessAt?Math.max(0,Math.floor((Date.now()-new Date(lastSuccessAt).getTime())/1000)):null,pendingFailures=failureMap.get(key)||0,conflicts=conflictMap.get(key)||0,difference=legacyCount===null?null:parseCount-legacyCount,sourceMissing=difference!==null&&difference<0;let health=!configured?'not_configured':!lastSuccessAt?'never_synced':pendingFailures||conflicts?'error':lagSeconds>120||sourceMissing?'delayed':'healthy';datasets.push({key,label:spec.label,legacyCount,parseCount,difference,sourceCovered:sourceMissing===false,cursor:checkpoint?text(checkpoint.get('cursor')):'',lastSuccessAt,lastAttemptAt,lagSeconds,health,healthLabel:({healthy:'已同步',delayed:'有延迟/差异',error:'需要处理',not_configured:'未配置',never_synced:'从未同步'})[health],pendingFailures,conflicts,checksum:remote?text(remote.checksum):''});}
  3624. const totalFailures=datasets.reduce((sum,row)=>sum+row.pendingFailures,0),totalConflicts=datasets.reduce((sum,row)=>sum+row.conflicts,0),overallHealth=!configured?'not_configured':!bridgeReachable?'error':datasets.some((row)=>row.health==='error')?'error':datasets.some((row)=>row.health!=='healthy')?'delayed':'healthy';return{configured,bridgeReachable,bridgeUrl:config.baseUrl?new URL(config.baseUrl).origin:'',manifestTime:manifest?dateIso(manifest.manifestTime):'',serverTime:manifest?dateIso(manifest.serverTime):'',refreshedAt:new Date().toISOString(),overallHealth,overallHealthLabel:({healthy:'同步正常',delayed:'存在延迟或差异',error:'同步异常',not_configured:'未配置'})[overallHealth],totalFailures,totalConflicts,datasets,message:!configured?'同步桥尚未配置,运营页面仍可能是迁移快照':!bridgeReachable?bridgeMessage||'无法连通旧系统同步桥':overallHealth==='healthy'?'核心数据集在两分钟水位内':'请处理差异、冲突或失败队列'};
  3625. }
  3626. function syncValue(field,value,schema){if(value==null)return value;const type=schema&&schema[field]&&schema[field].type;if(type==='Date'){const parsed=new Date(value);return Number.isNaN(parsed.getTime())?undefined:parsed;}if(type==='Number')return number(value);if(type==='Boolean')return value===true||value===1||value==='1'||value==='true';return value;}
  3627. async function findSyncTarget(className,keyField,legacyKey,context,input){const companyId=companyIdOf(context,input),row=await Psql.oneOrNone('SELECT "objectId" FROM "'+className+'" WHERE ($1::text IS NULL OR "company"=$1) AND CAST("'+keyField+'" AS text)=$2 LIMIT 1',[companyId,String(legacyKey)]).catch(()=>null);return row?new Parse.Query(className).get(text(row.objectId),{useMasterKey:true}):null;}
  3628. async function applyUserSync(record,context,input){fail(409,'账号与课时已由新数据库管理,禁止旧数据覆盖');
  3629. const fields=record.fields||{},legacyId=number(record.legacyKey||fields.legacyUserId);let target=await findSyncTarget('_User','legacyUserId',legacyId,context,input),created=false;if(!target){created=true;target=new Parse.User();target.setUsername(text(fields.username)||'legacy_'+legacyId);target.setPassword(randomObjectId()+randomObjectId());target.set('company',companyPointer(context,input));}
  3630. const legacy={...(target.get('legacyUserData')||{})},legacyMap={legacyUserId:'UserID',username:'UserName',nickname:'HoneyName',realName:'TrueName',email:'Email',mobile:'Mobile',legacyGroupId:'GroupID',parentUserId:'ParentUserID',registeredAt:'RegTime',lastLoginAt:'LastLoginTime',loginCount:'LoginTimes',legacyState:'State',purse:'Purse',silverCoin:'SilverCoin',userExp:'UserExp',userPoint:'UserPoint',dummyPurse:'DummyPurse',credit:'UserCreit'};for(const [field,key] of Object.entries(legacyMap))if(fields[field]!==undefined)legacy[key]=fields[field];target.set('legacyUserId',legacyId);target.set('legacyGroupId',number(fields.legacyGroupId));target.set('legacyUserData',legacy);target.set('nickname',text(fields.nickname));target.set('realName',text(fields.realName));target.set('mobile',text(fields.mobile));target.set('email',text(fields.email));target.set('registeredAt',new Date(dateIso(fields.registeredAt)||Date.now()));target.set('isDisabled',number(fields.legacyState,1)===0);target.set('isDeleted',false);target.set('sourceKey',text(fields.sourceKey)||'legacy-sync:user:'+legacyId);await target.save(null,{useMasterKey:true});return{created,target};
  3631. }
  3632. function assertLegacyProjectionWritable(target,dataset){if(!target)return;const sourceKey=text(target.get('sourceKey'));if(['course-bindings','appointments','lessons'].includes(dataset)&&sourceKey.startsWith('cloud:'))fail(409,'旧系统变更与新系统已创建记录冲突,已进入异常队列,未覆盖新数据');}
  3633. async function applyProjectionRecord(record,operation,context,input,dataset){if(dataset==='users')fail(409,'账号与课时已由新数据库管理,禁止旧数据覆盖');
  3634. const className=text(record.className),keyField=text(record.keyField),legacyKey=text(record.legacyKey),spec=SYNC_DATASETS[dataset],allowed=spec&&spec.records.some(([allowedClass,allowedKey])=>allowedClass===className&&allowedKey===keyField);if(!allowed||!SYNC_CLASSES.has(className)||!/^[A-Za-z][A-Za-z0-9_]*$/.test(keyField))fail(400,'同步桥返回了未授权的 Parse 投影');if(operation==='delete'){const target=await findSyncTarget(className,keyField,legacyKey,context,input);if(!target)return{created:false,target:null};assertLegacyProjectionWritable(target,dataset);if(className==='_User'){target.set('isDeleted',true);target.set('isDisabled',true);const sessions=new Parse.Query('_Session');sessions.equalTo('user',target);sessions.limit(1000);const rows=await sessions.find({useMasterKey:true});if(rows.length)await Parse.Object.destroyAll(rows,{useMasterKey:true});}else if(className==='CommonModel')target.set('status',-2);else{const schema=await fieldsOf(className);if(schema.isDeleted)target.set('isDeleted',true);else if(schema.zstatus)target.set('zstatus',-2);else if(schema.status)target.set('status',-2);else return{created:false,target};}await target.save(null,{useMasterKey:true});return{created:false,target};}if(className==='_User')return applyUserSync(record,context,input);let target=await findSyncTarget(className,keyField,legacyKey,context,input),created=false;assertLegacyProjectionWritable(target,dataset);if(!target){created=true;target=new Parse.Object(className);target.set('company',companyPointer(context,input));}const schema=await fieldsOf(className);for(const [field,raw] of Object.entries(record.fields||{})){if(SYNC_FORBIDDEN_FIELDS.has(field)||/(?:password|session|secret|token|hash|masterkey|privatekey)/i.test(field)||!schema[field])continue;const value=syncValue(field,raw,schema);if(value!==undefined)target.set(field,value);}target.set(keyField,syncValue(keyField,legacyKey,schema));if(schema.isDeleted)target.set('isDeleted',false);await target.save(null,{useMasterKey:true});return{created,target};
  3635. }
  3636. async function pendingSyncDeadLetters(context,input,dataset,legacyKey){const query=new Parse.Query('LegacySyncDeadLetter'),company=companyPointer(context,input);if(company)query.equalTo('company',company);query.equalTo('dataset',dataset);query.equalTo('legacyKey',text(legacyKey));query.notEqualTo('resolved',true);query.limit(100);return query.find({useMasterKey:true});}
  3637. async function resolveSyncDeadLetters(context,input,dataset,legacyKey){const rows=await pendingSyncDeadLetters(context,input,dataset,legacyKey);if(!rows.length)return;for(const row of rows){row.set('resolved',true);row.set('resolvedAt',new Date());}await Parse.Object.saveAll(rows,{useMasterKey:true});}
  3638. async function writeSyncDeadLetter(context,input,dataset,item,error){const existing=await pendingSyncDeadLetters(context,input,dataset,item.key),company=companyPointer(context,input),row=existing[0]||new Parse.Object('LegacySyncDeadLetter');if(!row.id){if(company)row.set('company',company);row.set('sourceKey','cloud:legacy-sync-dead:'+companyIdOf(context,input)+':'+dataset+':'+text(item.key));row.set('dataset',dataset);row.set('legacyKey',text(item.key));row.set('attempts',0);}row.set('recordClasses',(item.records||[]).map((record)=>text(record.className)));row.set('errorCode',number(error.status||error.code));row.set('errorMessage',text(error.message).slice(0,1000));row.increment('attempts',1);row.set('resolved',false);row.set('lastAttemptAt',new Date());await row.save(null,{useMasterKey:true});}
  3639. async function refreshIdentityProjection(context,input){
  3640. const companyId=companyIdOf(context,input),users=await Psql.query('SELECT "objectId",COALESCE(CAST("legacyUserId" AS text),"legacyUserData"->>\'UserID\') uid,COALESCE(CAST("legacyGroupId" AS text),"legacyUserData"->>\'GroupID\') gid,COALESCE("isAdmin",FALSE) admin FROM "_User" WHERE ($1::text IS NULL OR "company"=$1) AND ("isDeleted" IS NULL OR "isDeleted"=FALSE)',[companyId]),coachRows=await Psql.query('SELECT DISTINCT uid FROM (SELECT NULLIF(CAST("pl" AS text),\'\') uid FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("fxpl" AS text),\'\') FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("jsmz" AS text),\'\') FROM "LessonRecord" WHERE ($1::text IS NULL OR "company"=$1)) x WHERE uid IS NOT NULL',[companyId]),studentRows=await Psql.query('SELECT DISTINCT uid FROM (SELECT NULLIF(CAST("szyh" AS text),\'\') uid FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("userId" AS text),\'\') FROM "DailyStudyRecord" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("yhid" AS text),\'\') FROM "CourseBinding" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("yhid" AS text),\'\') FROM "PracticeRecord" WHERE ($1::text IS NULL OR "company"=$1)) x WHERE uid IS NOT NULL',[companyId]),coachIds=new Set(coachRows.map((row)=>text(row.uid))),studentIds=new Set(studentRows.map((row)=>text(row.uid)));let conflicts=0;
  3641. for(const row of users){if(row.admin===true)continue;const uid=text(row.uid),gid=number(row.gid),coach=coachIds.has(uid),student=studentIds.has(uid),identity=gid===2?'store':coach&&student?'conflict':coach?'coach':student||gid===1?'member':'unknown',target=await new Parse.Query('_User').get(text(row.objectId),{useMasterKey:true});if(text(target.get('identityType'))!==identity){target.set('identityType',identity);await target.save(null,{useMasterKey:true});}if(identity==='conflict'){conflicts++;const existing=await Psql.oneOrNone('SELECT "objectId" FROM "LegacySyncConflict" WHERE "company"=$1 AND "dataset"=\'users\' AND "legacyKey"=$2 AND COALESCE("resolved",FALSE)=FALSE LIMIT 1',[companyId,uid]).catch(()=>null);if(!existing){const issue=new Parse.Object('LegacySyncConflict');issue.set('company',companyPointer(context,input));issue.set('sourceKey','cloud:legacy-sync-conflict:'+companyId+':user:'+uid);issue.set('dataset','users');issue.set('legacyKey',uid);issue.set('conflictType','identity');issue.set('message','账号同时出现在学员和陪练业务字段中');issue.set('resolved',false);await issue.save(null,{useMasterKey:true});}}}
  3642. return conflicts;
  3643. }
  3644. async function runSyncPage(context,input,internalAuthorized=false){
  3645. if(!internalAuthorized)requireSuperAdmin(context);const dataset=text(input.dataset),spec=SYNC_DATASETS[dataset];if(!spec)fail(400,'不支持的同步数据集');const checkpoint=await syncCheckpoint(context,input,dataset),full=input.full===true,previousCursor=full?'':text(checkpoint.get('cursor')),startedAt=new Date(),pageSize=Math.min(1000,Math.max(1,number(input.pageSize,500))),leaseToken=await acquireSyncLease(checkpoint,dataset);let finalState='error';
  3646. try{
  3647. const page=await syncBridgeRequest('/v1/changes?dataset='+encodeURIComponent(dataset)+'&cursor='+encodeURIComponent(previousCursor)+'&limit='+pageSize),items=validateSyncPage(page,dataset,pageSize);let created=0,updated=0,deleted=0,failures=0;
  3648. for(const item of items){try{for(const record of item.records){const result=await applyProjectionRecord(record,text(item.operation||'upsert'),context,input,dataset);if(result.created)created++;else if(result.target)updated++;if(text(item.operation)==='delete'&&result.target)deleted++;}await resolveSyncDeadLetters(context,input,dataset,item.key);}catch(error){failures++;await writeSyncDeadLetter(context,input,dataset,item,error);}}
  3649. const finishedAt=new Date();if(!failures){checkpoint.set('cursor',text(page.cursor));checkpoint.set('lastSuccessAt',finishedAt);finalState=page.hasMore?'catching_up':'healthy';}else finalState='error';checkpoint.increment('processedCount',Math.max(0,number(page.count)-failures));checkpoint.set('lastBatchCount',number(page.count));checkpoint.set('lastFailureCount',failures);const serverTime=dateIso(page.serverTime);if(serverTime)checkpoint.set('lastServerTime',new Date(serverTime));if(Number.isFinite(Number(page.watermark)))checkpoint.set('lastWatermark',number(page.watermark));await checkpoint.save(null,{useMasterKey:true});let conflicts=0;if(dataset==='users'&&!page.hasMore&&!failures)conflicts=await refreshIdentityProjection(context,input);return{dataset,processed:number(page.count),created,updated,deleted,conflicts,failures,hasMore:failures?true:page.hasMore===true,cursor:failures?previousCursor:text(page.cursor),startedAt:startedAt.toISOString(),finishedAt:finishedAt.toISOString()};
  3650. }catch(error){finalState='error';throw error;}finally{await releaseSyncLease(checkpoint,leaseToken,finalState);}
  3651. }
  3652. async function catchUpSync(context,input){
  3653. requireSuperAdmin(context);const maxPages=Math.min(10,Math.max(1,number(input.maxPages,5))),startedAt=new Date();let aggregate={dataset:text(input.dataset),processed:0,created:0,updated:0,deleted:0,conflicts:0,failures:0,hasMore:false,cursor:'',startedAt:startedAt.toISOString(),finishedAt:startedAt.toISOString(),pages:0};
  3654. for(let index=0;index<maxPages;index++){const page=await runSyncPage(context,{...input,full:index===0&&input.full===true});aggregate={...aggregate,processed:aggregate.processed+page.processed,created:aggregate.created+page.created,updated:aggregate.updated+page.updated,deleted:aggregate.deleted+page.deleted,conflicts:aggregate.conflicts+page.conflicts,failures:aggregate.failures+page.failures,hasMore:page.hasMore,cursor:page.cursor,finishedAt:page.finishedAt,pages:index+1};if(page.failures||!page.hasMore)break;}
  3655. return aggregate;
  3656. }
  3657. async function retrySync(context,input){requireSuperAdmin(context);let dataset=text(input.dataset);if(!dataset){const rows=await newClassObjects('LegacySyncDeadLetter',context,input,1000),pending=rows.find((row)=>row.get('resolved')!==true);dataset=pending?text(pending.get('dataset')):'';}if(!dataset)return{dataset:'',processed:0,created:0,updated:0,deleted:0,conflicts:0,failures:0,hasMore:false,cursor:'',startedAt:new Date().toISOString(),finishedAt:new Date().toISOString(),pages:0};return catchUpSync(context,{...input,dataset,full:false,maxPages:Math.min(3,Math.max(1,number(input.maxPages,3)))});}
  3658. const OPS_LIVE_SCOPES=new Set(['learning','courses','appointments','reading','review','account']);
  3659. function operationsVersion(row){return(dateIso(row&&row.changed_at)||'')+':'+number(row&&row.total);}
  3660. async function operationsScopeVersion(scope,companyId){
  3661. if(scope==='learning')return operationsVersion(await Psql.one('SELECT MAX(GREATEST(COALESCE(d."updatedAt",d."createdAt"),COALESCE(c."updatedAt",c."createdAt"))) changed_at,COUNT(*)::int total FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=d."company" AND c."modelId"=56 AND c."itemId"=d."id" WHERE d."company"=$1',[companyId]));
  3662. if(scope==='courses')return operationsVersion(await Psql.one('SELECT MAX(COALESCE("updatedAt","createdAt")) changed_at,COUNT(*)::int total FROM "CourseBinding" WHERE "company"=$1',[companyId]));
  3663. if(scope==='appointments')return operationsVersion(await Psql.one('SELECT MAX(COALESCE("updatedAt","createdAt")) changed_at,COUNT(*)::int total FROM "CourseAppointment" WHERE "company"=$1',[companyId]));
  3664. if(scope==='review')return operationsVersion(await Psql.one('SELECT MAX(COALESCE("updatedAt","createdAt")) changed_at,COUNT(*)::int total FROM "MemoryPracticeRecord" WHERE "company"=$1',[companyId]));
  3665. if(scope==='account')return operationsVersion(await Psql.one('SELECT MAX(changed_at) changed_at,SUM(total)::int total FROM (SELECT MAX(COALESCE("updatedAt","createdAt")) changed_at,COUNT(*)::int total FROM "_User" WHERE "company"=$1 UNION ALL SELECT MAX(COALESCE("updatedAt","createdAt")),COUNT(*)::int FROM "LessonRecord" WHERE "company"=$1 UNION ALL SELECT MAX(COALESCE("updatedAt","createdAt")),COUNT(*)::int FROM "CourseCreditReservation" WHERE "company"=$1) x',[companyId]));
  3666. if(scope==='reading')return operationsVersion(await Psql.one('SELECT MAX(changed_at) changed_at,SUM(total)::int total FROM (SELECT MAX(COALESCE("updatedAt","createdAt")) changed_at,COUNT(*)::int total FROM "SurveyItem" WHERE "type"=\'reading\' AND COALESCE("isDeleted",FALSE)=FALSE UNION ALL SELECT MAX(COALESCE("updatedAt","createdAt")),COUNT(*)::int FROM "SurveyLog") x'));
  3667. return ':0';
  3668. }
  3669. async function operationsLiveVersions(context,input){
  3670. const scopes=[...new Set((Array.isArray(input.scopes)?input.scopes:[]).map(text).filter((scope)=>OPS_LIVE_SCOPES.has(scope)))];if(!scopes.length)scopes.push('learning');const companyId=companyIdOf(context,input),values=await Promise.all(scopes.map(async(scope)=>[scope,await operationsScopeVersion(scope,companyId)])),scopeVersions=Object.fromEntries(values),indicator=await operationalSyncIndicator(context,{...input,probe:false}),pendingRow=await Psql.one('SELECT COUNT(*)::int total FROM "LegacySyncDeadLetter" WHERE "company"=$1 AND COALESCE("resolved",FALSE)=FALSE',[companyId]).catch(()=>({total:0})),pending=number(pendingRow.total),lagSeconds=number(indicator.lagSeconds),state=indicator.state==='not_configured'?'unavailable':indicator.lastSuccessAt&&lagSeconds<=10&&!pending?'current':'delayed';return{version:values.map(([scope,value])=>scope+'='+value).join('|'),scopeVersions,refreshedAt:new Date().toISOString(),syncStatus:{state,lastSuccessAt:indicator.lastSuccessAt||'',lagSeconds,pending}};
  3671. }
  3672. async function repairMemberIdentity(context,input,payload){requireRole(context,['ops-manager']);const reason=reasonOf(input),target=await scopedObject('_User',text(input.targetId),context,input);assertVersion(target,input.expectedUpdatedAt);const legacy={...(target.get('legacyUserData')||{})},userId=number(target.get('legacyUserId')||legacy.UserID),companyId=pointerId(target.get('company')||context.company),evidence=await Psql.oneOrNone('SELECT 1 AS found WHERE EXISTS(SELECT 1 FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) AND (CAST("pl" AS text)=$2 OR CAST("fxpl" AS text)=$2)) OR EXISTS(SELECT 1 FROM "LessonRecord" WHERE ($1::text IS NULL OR "company"=$1) AND CAST("jsmz" AS text)=$2)',[companyId,String(userId)]).catch(()=>null);if(number(target.get('legacyGroupId')||legacy.GroupID)!==1||(!evidence&&!/陪练/.test(displayName(target.toJSON()))))fail(409,'该账号没有可确认的陪练身份冲突');const before={groupId:1,identityType:text(target.get('identityType'))};legacy.GroupID=3;target.set('legacyGroupId',3);target.set('identityType','coach');target.set('legacyUserData',legacy);await target.save(null,{useMasterKey:true});await audit(context,'repair-member-identity','_User',target.id,reason,before,{groupId:3,identityType:'coach'});return{objectId:target.id,userId,groupId:3,updatedAt:dateIso(target.updatedAt)};}
  3673. async function handler(request,response) {
  3674. let context=null;
  3675. try {
  3676. const input=inputOf(request); const operation=text(input.operation); const payload=input.payload&&typeof input.payload==='object'?input.payload:{};
  3677. context=await auth(request);requireRole(context,['ops-manager','ops-staff','ops-auditor']);
  3678. if(operation==='ops/live/versions') return response.json({success:true,data:await operationsLiveVersions(context,input)});
  3679. if(operation==='ops/sync/status') return response.json({success:true,data:await syncStatus(context,input)});
  3680. if(operation==='ops/sync/run') return response.json({success:true,data:await catchUpSync(context,input)});
  3681. if(operation==='ops/sync/retry') return response.json({success:true,data:await retrySync(context,input)});
  3682. // Member, enrollment and scheduling writes use the new database exclusively.
  3683. if(operation==='ops/stores/credit-accounts'||operation==='ops/users/credit-accounts') return response.json({success:true,data:await creditAccounts(context,input,operation.startsWith('ops/stores/'))});
  3684. if(operation==='ops/stores/credit-post'||operation==='ops/users/adjust-balance') return response.json({success:true,data:await postCredits(context,input,payload,operation.startsWith('ops/stores/'))});
  3685. if(operation==='ops/course-bindings/options') return response.json({success:true,data:await enrollmentOptions(context,input)});
  3686. if(operation==='ops/course-bindings/save') return response.json({success:true,data:await saveEnrollment(context,input,payload)});
  3687. if(operation==='ops/course-bindings/recycle'||operation==='ops/course-bindings/restore') return response.json({success:true,data:await toggleEnrollment(context,input,operation.endsWith('restore'))});
  3688. if(LEGACY_MUTATIONS[operation]&&!/ops\/(users|relations|course-bindings|appointments)/.test(operation)&&syncBridgeWritesEnabled()) return response.json({success:true,data:await bridgeFirstWrite(context,input,operation,payload)});
  3689. if(operation==='ops/dashboard/summary') return response.json({success:true,data:await professionalDashboardFast(context,input)});
  3690. if(operation==='ops/dashboard/schedule-trend') return response.json({success:true,data:await scheduleTrendFast(context,input)});
  3691. if(operation==='ops/stores/list') return response.json({success:true,data:await storeRows(context,input)});
  3692. if(operation==='ops/stores/detail') return response.json({success:true,data:await storeDetailData(context,input)});
  3693. if(operation==='ops/stores/create') return response.json({success:true,data:await createStore(context,input,payload)});
  3694. if(operation==='ops/stores/update') return response.json({success:true,data:await updateStore(context,input,payload)});
  3695. if(operation==='ops/stores/delete') return response.json({success:true,data:await deleteStore(context,input)});
  3696. if(operation==='ops/coaches/list') return response.json({success:true,data:await professionalCoachRows(context,input)});
  3697. if(operation==='ops/coaches/detail') return response.json({success:true,data:await professionalCoachDetail(context,input,text(input.objectId))});
  3698. if(operation==='ops/coaches/create') return response.json({success:true,data:await createCoach(context,input,payload)});
  3699. if(operation==='ops/coaches/update') return response.json({success:true,data:await updateCoach(context,input,payload)});
  3700. if(operation==='ops/coaches/delete') return response.json({success:true,data:await deleteCoach(context,input)});
  3701. if(operation==='ops/schedule/calendar') return response.json({success:true,data:await enrollmentSchedule(context,input,await professionalScheduleData(context,input))});
  3702. if(operation==='ops/coach-availability/list') return response.json({success:true,data:pageRows(await availabilityRows(context,input,number((input.filters||{}).coachId)),input)});
  3703. if(operation==='ops/coach-availability/save') { requireRole(context,['ops-manager','ops-staff']);const reason=reasonOf(input),coachId=number(payload.coachId),ruleType=text(payload.ruleType),start=text(payload.startTime),end=text(payload.endTime);if(!coachId||!['weekly','date'].includes(ruleType)||!/^\d{2}:\d{2}$/.test(start)||!/^\d{2}:\d{2}$/.test(end)||start>=end)fail(400,'陪练、规则类型和有效时段必填');if(!(await userByLegacyId(context,input,coachId,3)))fail(404,'陪练不存在');if(ruleType==='date'&&!/^\d{4}-\d{2}-\d{2}$/.test(text(payload.date)))fail(400,'指定日期无效');const company=companyPointer(context,input),rule=new Parse.Object('CoachAvailability');rule.set('company',company);rule.set('sourceKey','cloud:ops-availability:'+companyIdOf(context,input)+':'+coachId+':'+randomObjectId());rule.set('coachId',coachId);rule.set('ruleType',ruleType);rule.set('weekday',number(payload.weekday));rule.set('date',text(payload.date));rule.set('startTime',start);rule.set('endTime',end);rule.set('available',payload.available!==false);rule.set('reason',reason);await rule.save(null,{useMasterKey:true});await audit(context,'save-coach-availability','CoachAvailability',rule.id,reason,null,safe(rule));return response.json({success:true,data:{objectId:rule.id}}); }
  3704. if(operation==='ops/coach-availability/delete') { requireRole(context,['ops-manager','ops-staff']);const reason=reasonOf(input),rule=await scopedObject('CoachAvailability',text(input.targetId),context,input);assertVersion(rule,input.expectedUpdatedAt);const before=safe(rule);await rule.destroy({useMasterKey:true});await audit(context,'delete-coach-availability','CoachAvailability',text(input.targetId),reason,before,null);return response.json({success:true,data:{deleted:true}}); }
  3705. if(operation==='ops/appointments/preflight') return response.json({success:true,data:await preflightAppointments(context,input,payload,text(input.targetId))});
  3706. if(operation==='ops/appointments/create') return response.json({success:true,data:await saveEnrollmentSchedule(context,input,payload)});
  3707. if(operation==='ops/appointments/update') return response.json({success:true,data:await saveEnrollmentSchedule(context,input,payload,true)});
  3708. if(operation==='ops/appointments/cancel') return response.json({success:true,data:await cancelEnrollmentSchedule(context,input,false)});
  3709. if(operation==='ops/appointments/restore') return response.json({success:true,data:await cancelEnrollmentSchedule(context,input,true)});
  3710. if(operation==='ops/appointments/complete') return response.json({success:true,data:await completeAppointment(context,input)});
  3711. if(operation==='ops/teaching-rules/list') return response.json({success:true,data:await teachingStandardsData(context,input)});
  3712. if(operation==='ops/teaching-categories/save') return response.json({success:true,data:await saveTeachingCategory(context,input,payload)});
  3713. if(operation==='ops/teaching-rules/save') return response.json({success:true,data:await saveTeachingRule(context,input,payload)});
  3714. if(operation==='ops/teaching-rules/deactivate') return response.json({success:true,data:await deactivateTeachingRule(context,input)});
  3715. if(operation==='ops/course-mappings/list'||operation==='ops/course-mappings/preview') return response.json({success:true,data:{items:await courseTeachingRows(context,input),refreshedAt:new Date().toISOString()}});
  3716. if(operation==='ops/course-mappings/commit') return response.json({success:true,data:await commitCourseMappings(context,input,payload)});
  3717. if(operation==='ops/credit-reservations/preview') return response.json({success:true,data:await historicalReservationPreview(context,input)});
  3718. if(operation==='ops/credit-reservations/commit') return response.json({success:true,data:await commitHistoricalReservations(context,input,payload)});
  3719. if(operation==='ops/credit-reconciliation/preview') return response.json({success:true,data:await creditReconciliationPreview(context,input)});
  3720. if(operation==='ops/credit-reconciliation/commit') return response.json({success:true,data:await commitCreditReconciliation(context,input,payload)});
  3721. if(operation==='ops/lesson-feedback/detail') return response.json({success:true,data:await lessonFeedbackDetail(context,input)});
  3722. if(operation==='ops/pay-rates/list') return response.json({success:true,data:pageRows(await payRateRows(context,input),input)});
  3723. if(operation==='ops/pay-rates/save') return response.json({success:true,data:await savePayRateUpsert(context,input,payload)});
  3724. if(operation==='ops/payroll/review-rewards') return response.json({success:true,data:await reviewRewardPage(companyIdOf(context,input),input)});
  3725. if(operation==='ops/payroll/overview') return response.json({success:true,data:await payrollOverviewData(context,input)});
  3726. if(operation==='ops/payroll/preview') return response.json({success:true,data:await payrollPreviewData(context,input,text(input.month||payload.month))});
  3727. if(operation==='ops/payroll/list') return response.json({success:true,data:await payrollPage(context,input)});
  3728. if(operation==='ops/payroll/detail') return response.json({success:true,data:await payrollDetailData(context,input,text(input.targetId))});
  3729. if(operation==='ops/payroll/create') return response.json({success:true,data:await createPayroll(context,input,payload)});
  3730. if(operation==='ops/payroll/add-adjustment') return response.json({success:true,data:await payrollAdjustment(context,input,payload)});
  3731. if(operation==='ops/payroll/refresh') return response.json({success:true,data:await refreshPayrollDraft(context,input)});
  3732. if(operation==='ops/payroll/export') return response.json({success:true,data:await payrollExportData(context,input)});
  3733. if(operation==='ops/payroll/review'||operation==='ops/payroll/reopen'||operation==='ops/payroll/mark-paid') return response.json({success:true,data:await payrollWorkflow(context,input,operation.split('/').pop(),payload)});
  3734. if(operation==='ops/payroll/exclude-line') return response.json({success:true,data:await payrollExclude(context,input)});
  3735. if(operation==='ops/payroll/coaches') return response.json({success:true,data:await payrollCoaches(context,input)});
  3736. if(operation==='ops/course-mappings/students') return response.json({success:true,data:await writingMappingPage(context,input)});
  3737. if(operation==='ops/course-mappings/student-commit') return response.json({success:true,data:await writingMappingCommit(context,input,payload)});
  3738. if(operation.startsWith('ops/vocabulary/')) await ensureVocabularyPhysicalSchema();
  3739. if(operation==='ops/vocabulary/backfill-batch') return response.json({success:true,data:await backfillVocabularyBatch(context,input,payload)});
  3740. if(operation==='ops/vocabulary/tree') return response.json({success:true,data:await vocabularyTree(context,input)});
  3741. if(operation==='ops/vocabulary/list') return response.json({success:true,data:await vocabularyRows(context,input)});
  3742. if(operation==='ops/vocabulary/detail') return response.json({success:true,data:wordProjection(await vocabularyTarget(context,input,text(input.targetId||input.objectId)))});
  3743. if(operation==='ops/vocabulary/save') return response.json({success:true,data:await saveVocabulary(context,input,payload)});
  3744. if(operation==='ops/vocabulary/move') return response.json({success:true,data:await moveVocabulary(context,input,payload)});
  3745. if(operation==='ops/vocabulary/recycle') return response.json({success:true,data:await vocabularyLifecycle(context,input,-2)});
  3746. if(operation==='ops/vocabulary/restore') return response.json({success:true,data:await vocabularyLifecycle(context,input,99)});
  3747. if(operation==='ops/vocabulary/import-preview') return response.json({success:true,data:await previewVocabularyImport(context,input,payload)});
  3748. if(operation==='ops/vocabulary/import-commit') return response.json({success:true,data:await commitVocabularyImport(context,input)});
  3749. if(operation==='ops/vocabulary/import-status') return response.json({success:true,data:await vocabularyImportStatus(context,input)});
  3750. if(operation==='ops/vocabulary/export') { const page=await vocabularyRows(context,{...input,page:1,pageSize:10000});return response.json({success:true,data:{fileName:'小树陪练词库_'+businessDate()+'.csv',items:page.items}}); }
  3751. if(operation==='ops/vocabulary/template') return response.json({success:true,data:{fileName:'小树陪练词库导入模板.xlsx',columns:['GeneralID','学段','教材','单元','单词','释义','音标','例句','音频文件名或URL']}});
  3752. if(operation==='ops/vocabulary/node-save') return response.json({success:true,data:await saveVocabularyNode(context,input,payload)});
  3753. if(operation==='ops/special-training/list') return response.json({success:true,data:await specialRows(context,input)});
  3754. if(operation==='ops/special-training/detail') return response.json({success:true,data:specialProjection(await specialTarget(context,input,text(input.targetId||input.objectId)))});
  3755. if(operation==='ops/special-training/save') { const contentHtml=cleanHtml(payload.contentHtml),coverUrl=text(payload.coverUrl)||specialFirstImage(contentHtml);return response.json({success:true,data:await saveSpecial(context,input,{...payload,contentHtml,coverUrl})}); }
  3756. if(operation==='ops/special-training/preview') { const contentHtml=cleanHtml(payload.contentHtml),coverUrl=text(payload.coverUrl)||specialFirstImage(contentHtml);return response.json({success:true,data:specialProjection({...payload,targetId:text(input.targetId),content:contentHtml,synopsis:payload.synopsis,mp4:payload.videoUrl,topImg:coverUrl,tagKey:Array.isArray(payload.tags)?payload.tags.join(','):payload.tags,status:0})}); }
  3757. if(operation==='ops/special-training/publish') return response.json({success:true,data:await specialLifecycle(context,input,99)});
  3758. if(operation==='ops/special-training/unpublish') return response.json({success:true,data:await specialLifecycle(context,input,0)});
  3759. if(operation==='ops/special-training/recycle') return response.json({success:true,data:await specialLifecycle(context,input,-2)});
  3760. if(operation==='ops/special-training/restore') return response.json({success:true,data:await specialLifecycle(context,input,0)});
  3761. if(operation==='ops/media/upload-inline') return response.json({success:true,data:await uploadInlineMedia(context,input,payload)});
  3762. if(operation==='ops/media/presign') return response.json({success:true,data:await presignMedia(context,input,payload)});
  3763. if(operation==='ops/media/complete') return response.json({success:true,data:await completeMedia(context,input)});
  3764. if(operation==='ops/users/list') return response.json({success:true,data:await usersPage(context,input)});
  3765. if(operation==='ops/users/detail') { const rows=await queryUsers(context,input); const row=rows.find((item)=>item.objectId===text(input.objectId)); if(!row) fail(404,'用户不存在或不属于当前帐套'); const role=roleOf(context),writable=row.writeReady!==false;row.relation={parentUserId:row.parentUserId,parentDisplayName:row.parentDisplayName}; row.permissions={canManage:writable&&role==='ops-manager',canEdit:writable&&role!=='ops-auditor',canAdjustBalance:writable&&role==='ops-manager',canResetPassword:writable&&role==='ops-manager',canRevealMobile:writable&&role==='ops-manager'};row.agents=await memberAgents(context,input);return response.json({success:true,data:row}); }
  3766. if(operation==='ops/users/create') return response.json({success:true,data:await createMember(context,input,payload)});
  3767. if(operation==='ops/users/import-preview') return response.json({success:true,data:await previewMemberImport(context,input,payload)});
  3768. if(operation==='ops/users/import-commit') return response.json({success:true,data:await commitMemberImport(context,input,payload)});
  3769. if(operation==='ops/users/update-profile') return response.json({success:true,data:await updateMemberProfile(context,input,payload)});
  3770. if(operation==='ops/users/reset-password') return response.json({success:true,data:await resetMemberPassword(context,input,payload)});
  3771. if(operation==='ops/users/save-note') return response.json({success:true,data:await saveMemberNote(context,input,payload)});
  3772. if(operation==='ops/users/notes') return response.json({success:true,data:{items:await memberNotes(context,input),refreshedAt:new Date().toISOString()}});
  3773. if(operation==='ops/users/audit') return response.json({success:true,data:{items:await memberAuditRows(context,input),refreshedAt:new Date().toISOString()}});
  3774. if(operation==='ops/users/export') return response.json({success:true,data:await exportMembers(context,input)});
  3775. if(operation==='ops/users/identity-conflicts') return response.json({success:true,data:await identityConflictsPage(context,input)});
  3776. if(operation==='ops/users/repair-identity') return response.json({success:true,data:await repairMemberIdentity(context,input,payload)});
  3777. if(operation==='ops/relations/list') { const users=await queryUsers(context,input); const rows=users.map((row)=>({...row,userObjectId:row.objectId})); return response.json({success:true,data:pageRows(searchRows(rows,input),input)}); }
  3778. if(operation==='ops/finance/list') { const users=await queryUsers(context,input); const rows=users.map((row)=>({...row,userObjectId:row.objectId})); return response.json({success:true,data:pageRows(searchRows(rows,input),input)}); }
  3779. if(operation==='ops/course-bindings/list') return response.json({success:true,data:await businessRows('course-bindings',context,input)});
  3780. if(operation==='ops/appointments/list') return response.json({success:true,data:await businessRows('appointments',context,input)});
  3781. if(operation==='ops/lessons/list') return response.json({success:true,data:await businessRows('lessons',context,input)});
  3782. if(operation==='ops/learning-records/list') return response.json({success:true,data:await businessRows('learning-records',context,input)});
  3783. if(operation==='ops/learning-reports/list') return response.json({success:true,data:await learningReportPage(context,input)});
  3784. if(operation==='ops/learning-reports/detail') return response.json({success:true,data:await learningReportDetail(context,input,false)});
  3785. if(operation==='ops/learning-reports/export') return response.json({success:true,data:await learningReportDetail(context,input,true)});
  3786. if(operation==='ops/money-logs/list') return response.json({success:true,data:await moneyRows(context,input)});
  3787. if(operation==='ops/users/reveal-mobile') { requireRole(context,['ops-manager']); const reason=reasonOf(input); const target=await memberTarget(context,input,text(input.targetId)); const before={mobileMasked:maskMobile(mobileOf(target.toJSON()))}; const mobile=mobileOf(target.toJSON()); await audit(context,'reveal-mobile','_User',target.id,reason,before,{revealed:true}); return response.json({success:true,data:{mobile}}); }
  3788. if(operation==='ops/users/change-status') { requireRole(context,['ops-manager']); const reason=reasonOf(input); const target=await memberTarget(context,input,text(input.targetId)); assertVersion(target,input.expectedUpdatedAt); if(target.id===context.current.id&&payload.disabled===true) fail(400,'不能停用当前登录账号'); const before={isDisabled:target.get('isDisabled')===true}; target.set('isDisabled',payload.disabled===true); await target.save(null,{useMasterKey:true}); if(payload.disabled===true)await revokeMemberSessions(target); await audit(context,'change-status','_User',target.id,reason,before,{isDisabled:payload.disabled===true}); return response.json({success:true,data:{objectId:target.id,isDisabled:payload.disabled===true}}); }
  3789. if(operation==='ops/users/change-group') { requireRole(context,['ops-manager']); const reason=reasonOf(input); const groupId=number(payload.groupId); if(!Number.isInteger(groupId)||groupId<0) fail(400,'用户组 ID 无效'); const target=await scopedObject('_User',text(input.targetId),context,input); assertVersion(target,input.expectedUpdatedAt); const legacy={...(target.get('legacyUserData')||{})}; const before={groupId:number(target.get('legacyGroupId')||legacy.GroupID)}; target.set('legacyGroupId',groupId); legacy.GroupID=groupId; target.set('legacyUserData',legacy); await target.save(null,{useMasterKey:true}); await audit(context,'change-group','_User',target.id,reason,before,{groupId}); return response.json({success:true,data:{objectId:target.id,groupId}}); }
  3790. if(operation==='ops/relations/save') { requireRole(context,['ops-manager','ops-staff']); const reason=reasonOf(input); const parentUserId=number(payload.parentUserId); if(!Number.isInteger(parentUserId)||parentUserId<0) fail(400,'代理用户 ID 无效'); const target=await memberTarget(context,input,text(input.targetId)); assertVersion(target,input.expectedUpdatedAt); const ownId=number(target.get('legacyUserId')||(target.get('legacyUserData')||{}).UserID); if(parentUserId===ownId) fail(400,'用户不能归属于自己'); if(parentUserId&&!await userByLegacyId(context,input,parentUserId,2))fail(404,'上级代理不存在'); const legacy={...(target.get('legacyUserData')||{})}; const before={parentUserId:number(legacy.ParentUserID)}; legacy.ParentUserID=parentUserId; target.set('legacyUserData',legacy); await target.save(null,{useMasterKey:true}); await audit(context,'save-relation','_User',target.id,reason,before,{parentUserId}); return response.json({success:true,data:{objectId:target.id,parentUserId}}); }
  3791. if(operation==='ops/appointments/transition') { requireRole(context,['ops-manager']); const reason=reasonOf(input); const target=await scopedObject('CommonModel',text(input.targetId),context,input); assertVersion(target,input.expectedUpdatedAt); if(number(target.get('modelId'))!==54) fail(400,'目标不是预约记录'); const addon=await findAddon('CourseAppointment',target.get('company')||context.company,target.get('itemId')); if(!addon) fail(404,'预约附表不存在'); const previous=number(addon.get('dszt')); const status=number(payload.status); if(status===11)fail(409,'不能直接修改为已完课,请使用“完成课程”操作,以原子校验课时、扣减余额、生成上课记录并保存计薪快照'); const allowed=({10:[0,10],20:[11,20],30:[20,30]})[status]; if(!allowed||!allowed.includes(previous)) fail(409,'预约状态不能从 '+previous+' 更新为 '+status); if(status===10)await validateAppointmentCreditBalance(context,input,{common:target,addon}); addon.set('dszt',String(status)); if(status===10) addon.set('kssj',new Date().toISOString()); await addon.save(null,{useMasterKey:true}); await audit(context,'appointment-transition-correction','CourseAppointment',addon.id,reason,{status:previous},{status}); return response.json({success:true,data:{targetId:target.id,previousStatus:previous,status}}); }
  3792. fail(400,'不支持的运营后台操作');
  3793. } catch(error) { const status=Number(error.status||(error.code===101?404:500)),message=status>=500&&!(context&&context.isSuperAdmin)?'运营云函数执行失败':text(error.message)||'运营云函数执行失败'; return response.status(status).json({success:false,error:status>=500?'cloud_function_error':'request_rejected',message}); }
  3794. }
  3795. `;
  3796. const definitions = [
  3797. { name: 'xiaoshu.ops.gateway', desc: '小树陪练运营管理后台专用网关(业务投影、分级隐私、三级权限与审计)', path: 'xiaoshu/ops/gateway', code: operationsGatewayCode + adminRolePolicyCode + reviewRewardCode + enrollmentOpsCode + payrollIntegrityCode, params: [{ name: 'operation', type: 'String', required: true }] },
  3798. { name: 'xiaoshu.ops.gateway.v3', desc: '小树陪练运营管理后台专用网关 V3(含历史排课投影、上海业务日期与陪练排课汇总)', path: 'xiaoshu/ops/gateway-v3', code: operationsGatewayCode + adminRolePolicyCode + reviewRewardCode + enrollmentOpsCode + payrollIntegrityCode, params: [{ name: 'operation', type: 'String', required: true }] },
  3799. { name: 'xiaoshu.admin.gateway', desc: '小树陪练 Angular 管理后台统一数据网关(权限、帐套、CRUD、统计)', path: 'xiaoshu/admin/gateway', code: adminGatewayCode + adminRolePolicyCode + operatorAccountsCode, params: [{ name: 'operation', type: 'String', required: true }] },
  3800. { 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 }] },
  3801. { name: 'cms.users.extended', desc: '替代 ZL_EX_UserView,返回安全用户扩展资料', path: 'xiaoshu/cms/users/extended', code: cmsReadCode('user-extended'), params: [{ name: 'userId', type: 'String', required: true }] },
  3802. { name: 'cms.users.platform', desc: '替代 ZL_User_PlatView', path: 'xiaoshu/cms/users/platform', code: cmsReadCode('user-platform'), params: [{ name: 'userId', type: 'String', required: true }] },
  3803. { name: 'cms.users.wechat', desc: '替代 ZL_User_WXView,过滤令牌与密钥', path: 'xiaoshu/cms/users/wechat', code: cmsReadCode('user-wechat'), params: [{ name: 'userId', type: 'String', required: true }] },
  3804. { 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 }] },
  3805. { 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 }] },
  3806. { name: 'cms.search', desc: '替代 ZL_SearchView,搜索规范化 CommonModel', path: 'xiaoshu/cms/search', code: cmsReadCode('search'), params: [{ name: 'keyword', type: 'String', required: true }] },
  3807. { name: 'xiaoshu.app.gateway', desc: '旧 WXAPP action 到 Parse 规范化类的兼容云函数;无法证明等价的动作返回 migration_blocked', path: 'xiaoshu/app/gateway', code: appGatewayCode + reviewRewardCode + enrollmentAppCode + mobileTeachingCode, params: [{ name: 'action', type: 'String', required: true }] },
  3808. ];
  3809. function validateDefinition(definition) {
  3810. if (definition.path.startsWith('/')) throw new Error(`${definition.name}: 当前执行器的全局 path 不接受前导 /`);
  3811. const factory = new Function(`${definition.code}\nreturn typeof handler;`);
  3812. if (factory() !== 'function') throw new Error(`${definition.name}: 未定义 handler`);
  3813. }
  3814. async function parseRequest(path, init = {}) {
  3815. const response = await fetch(`${PARSE_URL}${path}`, {
  3816. ...init,
  3817. headers: {
  3818. 'X-Parse-Application-Id': APP_ID,
  3819. 'X-Parse-Master-Key': MASTER_KEY,
  3820. 'Content-Type': 'application/json',
  3821. ...(init.headers || {}),
  3822. },
  3823. });
  3824. const payload = await response.json();
  3825. if (!response.ok || payload.error) throw new Error(payload.error || `Parse 请求失败:${response.status}`);
  3826. return payload;
  3827. }
  3828. async function ensureOperationsUserSchema() {
  3829. const schema = await parseRequest('/schemas/_User');
  3830. const currentFields = schema.fields || {};
  3831. const desiredFields = {
  3832. identityType: { type: 'String', required: false },
  3833. registeredAt: { type: 'Date', required: false },
  3834. grade: { type: 'String', required: false },
  3835. };
  3836. const missingFields = Object.fromEntries(
  3837. Object.entries(desiredFields).filter(([field]) => !currentFields[field]),
  3838. );
  3839. if (!Object.keys(missingFields).length) return;
  3840. await parseRequest('/schemas/_User', {
  3841. method: 'PUT',
  3842. body: JSON.stringify({ fields: missingFields }),
  3843. });
  3844. console.log('updated _User schema (' + Object.keys(missingFields).join(', ') + ')');
  3845. }
  3846. async function ensureReadingLinkSchema() {
  3847. const desiredFields = {
  3848. operatorId: { type: 'String', required: false },
  3849. learnerLegacyId: { type: 'Number', required: false },
  3850. sourceStudyId: { type: 'String', required: false },
  3851. };
  3852. for (const className of ['SurveyItem', 'SurveyLog']) {
  3853. const schema = await parseRequest(`/schemas/${className}`);
  3854. const missingFields = Object.fromEntries(
  3855. Object.entries(desiredFields).filter(([field]) => !(schema.fields || {})[field]),
  3856. );
  3857. if (!Object.keys(missingFields).length) continue;
  3858. await parseRequest(`/schemas/${className}`, { method: 'PUT', body: JSON.stringify({ fields: missingFields }) });
  3859. console.log('updated ' + className + ' reading link schema (' + Object.keys(missingFields).join(', ') + ')');
  3860. }
  3861. }
  3862. async function ensurePayrollSchemas() {
  3863. const schemas = {
  3864. CourseAppointment: {
  3865. company: { type: 'Pointer', targetClass: 'Company', required: false }, sourceKey: { type: 'String', required: false }, id: { type: 'Number', required: false },
  3866. categoryEvidence: {type:'String'}, courseCategoryKey: { type: 'String', required: false }, courseCategoryName: { type: 'String', required: false }, durationMinutes: { type: 'Number', required: false }, deliveryMode: { type: 'String', required: false }, teachingRuleId: { type: 'String', required: false }, teacherPaySnapshot: { type: 'Number', required: false }, creditCostsSnapshot: { type: 'Array', required: false }, creditHoldStatus: { type: 'String', required: false }, creditHoldReason: { type: 'String', required: false },
  3867. },
  3868. LessonRecord: {
  3869. company: { type: 'Pointer', targetClass: 'Company', required: false }, sourceKey: { type: 'String', required: false }, id: { type: 'Number', required: false },
  3870. pf: { type: 'String', required: false }, jffs: { type: 'String', required: false }, jsmz: { type: 'String', required: false }, kcid: { type: 'String', required: false }, kclx: { type: 'String', required: false }, kcmc: { type: 'String', required: false }, kzsj: { type: 'String', required: false }, pjnr: { type: 'String', required: false }, pldp: { type: 'String', required: false }, xymz: { type: 'String', required: false }, yyds: { type: 'String', required: false }, plpjsj: { type: 'String', required: false }, szmdid: { type: 'String', required: false },
  3871. legacyGeneralId: { type: 'Number', required: false }, lessonAt: { type: 'Date', required: false }, sourceCreatedAt: { type: 'Date', required: false },
  3872. studentName: { type: 'String', required: false }, coachName: { type: 'String', required: false }, courseName: { type: 'String', required: false }, storeId: { type: 'Number', required: false },
  3873. },
  3874. LessonRecordFact: {
  3875. company: { type: 'Pointer', targetClass: 'Company', required: false }, sourceKey: { type: 'String', required: false },
  3876. pf: { type: 'String', required: false }, jffs: { type: 'String', required: false }, jsmz: { type: 'String', required: false }, kcid: { type: 'String', required: false }, kclx: { type: 'String', required: false }, kcmc: { type: 'String', required: false }, kzsj: { type: 'String', required: false }, pjnr: { type: 'String', required: false }, pldp: { type: 'String', required: false }, xymz: { type: 'String', required: false }, yyds: { type: 'String', required: false }, plpjsj: { type: 'String', required: false }, szmdid: { type: 'String', required: false },
  3877. legacyGeneralId: { type: 'Number', required: false }, lessonAt: { type: 'Date', required: false }, sourceCreatedAt: { type: 'Date', required: false }, studentName: { type: 'String', required: false }, coachName: { type: 'String', required: false }, courseName: { type: 'String', required: false }, storeId: { type: 'Number', required: false },
  3878. },
  3879. PayrollLine: {
  3880. standardLessonKey: {type:'String'}, coachObjectId: {type:'String'}, coachMobile: {type:'String'}, reviewRewardId: { type:'String', required:false }, reviewTaskId: { type:'String', required:false }, plannedDate: { type:'String', required:false },
  3881. company: { type: 'Pointer', targetClass: 'Company', required: false }, sourceKey: { type: 'String', required: false }, batchId: { type: 'String', required: false }, kind: { type: 'String', required: false },
  3882. coachId: { type: 'Number', required: false }, coachName: { type: 'String', required: false }, lessonId: { type: 'String', required: false }, lessonAt: { type: 'Date', required: false },
  3883. classType: { type: 'Number', required: false }, durationHours: { type: 'Number', required: false }, rateAmount: { type: 'Number', required: false }, amount: { type: 'Number', required: false },
  3884. description: { type: 'String', required: false }, exception: { type: 'String', required: false }, excluded: { type: 'Boolean', required: false }, exclusionReason: { type: 'String', required: false },
  3885. legacyGeneralId: { type: 'Number', required: false }, originalMonth: { type: 'String', required: false }, settlementMonth: { type: 'String', required: false },
  3886. studentName: { type: 'String', required: false }, courseName: { type: 'String', required: false }, storeId: { type: 'Number', required: false }, storeName: { type: 'String', required: false },
  3887. courseCategoryKey: { type: 'String', required: false }, courseCategoryName: { type: 'String', required: false }, deliveryMode: { type: 'String', required: false }, durationMinutes: { type: 'Number', required: false }, teachingRuleId: { type: 'String', required: false },
  3888. },
  3889. PayrollBatch: {
  3890. version: {type:'Number'}, reviewCount: { type:'Number', required:false }, reviewAmount: { type:'Number', required:false },
  3891. company: { type: 'Pointer', targetClass: 'Company', required: false }, sourceKey: { type: 'String', required: false }, month: { type: 'String', required: false }, status: { type: 'String', required: false },
  3892. coachCount: { type: 'Number', required: false }, lessonCount: { type: 'Number', required: false }, totalHours: { type: 'Number', required: false }, baseAmount: { type: 'Number', required: false }, adjustmentAmount: { type: 'Number', required: false }, payableAmount: { type: 'Number', required: false }, exceptionCount: { type: 'Number', required: false },
  3893. createdBy: { type: 'String', required: false }, reviewedBy: { type: 'String', required: false }, paidBy: { type: 'String', required: false }, reviewedAt: { type: 'Date', required: false }, paidAt: { type: 'Date', required: false }, paymentMethod: { type: 'String', required: false }, paymentReference: { type: 'String', required: false }, sourceRefreshedAt: { type: 'Date', required: false },
  3894. },
  3895. CoachPayRate: {
  3896. company: { type: 'Pointer', targetClass: 'Company', required: false }, sourceKey: { type: 'String', required: false }, classType: { type: 'Number', required: false }, amount: { type: 'Number', required: false }, durationHours: { type: 'Number', required: false }, effectiveFrom: { type: 'String', required: false }, active: { type: 'Boolean', required: false },
  3897. },
  3898. TeachingCourseCategory: {
  3899. company: { type: 'Pointer', targetClass: 'Company', required: false }, sourceKey: { type: 'String', required: false }, key: { type: 'String', required: false }, name: { type: 'String', required: false }, sortOrder: { type: 'Number', required: false }, active: { type: 'Boolean', required: false },
  3900. },
  3901. TeachingCompensationRule: {
  3902. revision: {type:'Number'}, supersedesId: {type:'String'},
  3903. company: { type: 'Pointer', targetClass: 'Company', required: false }, sourceKey: { type: 'String', required: false }, categoryKey: { type: 'String', required: false }, durationMinutes: { type: 'Number', required: false }, deliveryMode: { type: 'String', required: false }, teacherPay: { type: 'Number', required: false }, creditCosts: { type: 'Array', required: false }, effectiveFrom: { type: 'String', required: false }, active: { type: 'Boolean', required: false },
  3904. },
  3905. CourseTeachingProfile: {
  3906. company: { type: 'Pointer', targetClass: 'Company', required: false }, sourceKey: { type: 'String', required: false }, courseId: { type: 'Number', required: false }, stageKey: { type: 'String', required: false }, categoryKey: { type: 'String', required: false }, confirmed: { type: 'Boolean', required: false }, confirmedBy: { type: 'String', required: false }, confirmedAt: { type: 'Date', required: false },
  3907. },
  3908. CourseCreditReservation: {
  3909. company: { type: 'Pointer', targetClass: 'Company', required: false }, sourceKey: { type: 'String', required: false }, appointmentId: { type: 'String', required: false }, appointmentGeneralId: { type: 'Number', required: false }, studentId: { type: 'Number', required: false }, ruleId: { type: 'String', required: false }, creditCosts: { type: 'Array', required: false }, state: { type: 'String', required: false }, reservedAt: { type: 'Date', required: false }, consumedAt: { type: 'Date', required: false }, releasedAt: { type: 'Date', required: false }, releaseReason: { type: 'String', required: false },
  3910. },
  3911. LessonCompletionReport: {
  3912. company: { type: 'Pointer', targetClass: 'Company', required: false }, sourceKey: { type: 'String', required: false }, appointmentId: { type: 'String', required: false }, appointmentGeneralId: { type: 'Number', required: false }, lessonId: { type: 'String', required: false }, studentId: { type: 'Number', required: false }, coachId: { type: 'Number', required: false }, courseCategoryKey: { type: 'String', required: false }, courseCategoryName: { type: 'String', required: false }, deliveryMode: { type: 'String', required: false }, durationMinutes: { type: 'Number', required: false }, actualStartAt: { type: 'Date', required: false }, actualEndAt: { type: 'Date', required: false }, creditCosts: { type: 'Array', required: false }, contentSummary: { type: 'String', required: false }, vocabularyCount: { type: 'Number', required: false }, reviewStatus: { type: 'String', required: false }, reviewSummary: { type: 'String', required: false }, trainingStatus: { type: 'String', required: false }, trainingSummary: { type: 'String', required: false }, antiForgetSummary: { type: 'Object', required: false }, homework: { type: 'String', required: false }, noHomework: { type: 'Boolean', required: false }, notes: { type: 'String', required: false }, submittedBy: { type: 'String', required: false }, submittedAt: { type: 'Date', required: false },
  3913. },
  3914. OpsRealtimeCache: {
  3915. company: { type: 'Pointer', targetClass: 'Company', required: false }, cacheKey: { type: 'String', required: false }, payload: { type: 'Object', required: false }, sourceRefreshedAt: { type: 'Date', required: false }, expiresAt: { type: 'Date', required: false },
  3916. },
  3917. OpsMemberImportBatch: {
  3918. company: { type: 'Pointer', targetClass: 'Company', required: false }, sourceKey: { type: 'String', required: false }, batchId: { type: 'String', required: false }, payloadDigest: { type: 'String', required: false }, fileName: { type: 'String', required: false }, status: { type: 'String', required: false }, total: { type: 'Number', required: false }, processed: { type: 'Number', required: false }, createdBy: { type: 'String', required: false }, expiresAt: { type: 'Date', required: false }, startedAt: { type: 'Date', required: false }, completedAt: { type: 'Date', required: false }, errorMessage: { type: 'String', required: false }, partialResult: { type: 'Object', required: false }, result: { type: 'Object', required: false },
  3919. },
  3920. };
  3921. for (const [className, desiredFields] of Object.entries(schemas)) {
  3922. let schema;
  3923. try { schema = await parseRequest(`/schemas/${className}`); }
  3924. catch (error) {
  3925. if (!/404|does not exist|not found/i.test(String(error.message))) throw error;
  3926. await parseRequest('/schemas/' + className, { method: 'POST', body: JSON.stringify({ className, fields: desiredFields }) });
  3927. console.log('created ' + className + ' payroll schema');
  3928. continue;
  3929. }
  3930. const missingFields = Object.fromEntries(Object.entries(desiredFields).filter(([field]) => !(schema.fields || {})[field]));
  3931. if (!Object.keys(missingFields).length) continue;
  3932. await parseRequest(`/schemas/${className}`, { method: 'PUT', body: JSON.stringify({ fields: missingFields }) });
  3933. console.log('updated ' + className + ' schema (' + Object.keys(missingFields).join(', ') + ')');
  3934. }
  3935. }
  3936. async function upsert(definition) {
  3937. const where = encodeURIComponent(JSON.stringify({ name: definition.name }));
  3938. const existing = await parseRequest(`/classes/Function?where=${where}&limit=1&keys=objectId`);
  3939. const body = {
  3940. name: definition.name,
  3941. desc: definition.desc,
  3942. type: 'standalone',
  3943. path: definition.path,
  3944. code: definition.code.trim(),
  3945. params: definition.params,
  3946. paramList: definition.params,
  3947. respType: 'json',
  3948. respJson: { success: true, data: {} },
  3949. enabled: true,
  3950. version: '1.3.0',
  3951. };
  3952. const objectId = existing.results?.[0]?.objectId;
  3953. if (objectId) {
  3954. await parseRequest(`/classes/Function/${objectId}`, { method: 'PUT', body: JSON.stringify(body) });
  3955. return { action: 'updated', objectId };
  3956. }
  3957. const created = await parseRequest('/classes/Function', { method: 'POST', body: JSON.stringify(body) });
  3958. return { action: 'created', objectId: created.objectId };
  3959. }
  3960. const selectedDefinitions = onlyName ? definitions.filter((definition) => definition.name === onlyName || definition.path === onlyName) : definitions;
  3961. if (!selectedDefinitions.length) throw new Error(`没有找到云函数:${onlyName}`);
  3962. for (const definition of selectedDefinitions) validateDefinition(definition);
  3963. if (validateOnly) {
  3964. console.log(`Validated ${selectedDefinitions.length} cloud function definitions.`);
  3965. } else {
  3966. if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY;不会把 masterKey 写入项目文件。');
  3967. if (selectedDefinitions.some((definition) => definition.name.startsWith('xiaoshu.ops.gateway'))) {
  3968. await ensureOperationsUserSchema();
  3969. await ensurePayrollSchemas();
  3970. }
  3971. if (selectedDefinitions.some((definition) => definition.name === 'xiaoshu.app.gateway')) await ensureReadingLinkSchema();
  3972. for (const definition of selectedDefinitions) {
  3973. const result = await upsert(definition);
  3974. console.log(`${result.action.padEnd(7)} ${definition.path} (${result.objectId})`);
  3975. }
  3976. }