|
|
@@ -8,6 +8,7 @@ const legacyAppointmentCode = readFileSync(new URL('./cloud/legacy-appointment.j
|
|
|
const storeScopeCode = readFileSync(new URL('./cloud/store-scope.js', import.meta.url), 'utf8');
|
|
|
const creditLedgerCode = readFileSync(new URL('./cloud/member-credit-ledger.js', import.meta.url), 'utf8') + readFileSync(new URL('./cloud/legacy-finance.js', import.meta.url), 'utf8');
|
|
|
const enrollmentOpsCode = readFileSync(new URL('./cloud/member-enrollment.js', import.meta.url), 'utf8') + '\n' + readFileSync(new URL('./cloud/store-credit.js', import.meta.url), 'utf8');
|
|
|
+const lessonExportCode = readFileSync(new URL('./cloud/lesson-export.js', import.meta.url), 'utf8');
|
|
|
const lessonRecordsCode = readFileSync(new URL('./cloud/lesson-records.js', import.meta.url), 'utf8');
|
|
|
const supportRequestsAppCode = readFileSync(new URL('./cloud/support-requests-app.js', import.meta.url), 'utf8');
|
|
|
const supportRequestsCode = readFileSync(new URL('./cloud/support-requests.js', import.meta.url), 'utf8');
|
|
|
@@ -228,8 +229,10 @@ async function audit(context, action, className, objectId) {
|
|
|
}
|
|
|
|
|
|
async function handler(request, response) {
|
|
|
- const diagnosticId=String(request.body?.params?.requestId||'');
|
|
|
- if(/^[a-zA-Z0-9-]{1,64}$/.test(diagnosticId)&&typeof response.on==='function')response.on('finish',()=>{if(response.statusCode>=400)console.warn(JSON.stringify({event:'gateway_error',requestId:diagnosticId,status:response.statusCode,version:'2026-10-13-lesson-batches-v8'}));});
|
|
|
+ const startedAt=Date.now(),suppliedId=String(request.body?.params?.requestId||'');
|
|
|
+ const diagnosticId=/^[a-zA-Z0-9-]{1,64}$/.test(suppliedId)?suppliedId:Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,10);
|
|
|
+ if(typeof response.setHeader==='function')response.setHeader('X-Request-Id',diagnosticId);
|
|
|
+ if(typeof response.on==='function')response.on('finish',()=>{console.info(JSON.stringify({event:'gateway_request',requestId:diagnosticId,operation:String(request.body?.params?.operation||'').slice(0,100),status:response.statusCode,durationMs:Date.now()-startedAt}));});
|
|
|
try {
|
|
|
const input = inputOf(request);
|
|
|
const operation = String(input.operation || 'meta');
|
|
|
@@ -2362,8 +2365,10 @@ function tenant(query, fields, context) { if (fields.company && context.company)
|
|
|
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(/ | /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>').replace(/"/gi, '"').replace(/'|'/gi, "'").replace(/\s+/g, ' ').trim(); }
|
|
|
function escapeRegex(value) { return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); }
|
|
|
async function handler(request, response) {
|
|
|
- const diagnosticId=String(request.body?.params?.requestId||'');
|
|
|
- if(/^[a-zA-Z0-9-]{1,64}$/.test(diagnosticId)&&typeof response.on==='function')response.on('finish',()=>{if(response.statusCode>=400)console.warn(JSON.stringify({event:'gateway_error',requestId:diagnosticId,status:response.statusCode,version:'2026-10-13-lesson-batches-v8'}));});
|
|
|
+ const startedAt=Date.now(),suppliedId=String(request.body?.params?.requestId||'');
|
|
|
+ const diagnosticId=/^[a-zA-Z0-9-]{1,64}$/.test(suppliedId)?suppliedId:Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,10);
|
|
|
+ if(typeof response.setHeader==='function')response.setHeader('X-Request-Id',diagnosticId);
|
|
|
+ if(typeof response.on==='function')response.on('finish',()=>{console.info(JSON.stringify({event:'gateway_request',requestId:diagnosticId,operation:String(request.body?.params?.operation||'').slice(0,100),status:response.statusCode,durationMs:Date.now()-startedAt}));});
|
|
|
try {
|
|
|
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));
|
|
|
if (MODE === 'content-normalizer') return response.json({ success: true, data: { content: stripHtml(input.content), mode: String(input.mode || 'strip') } });
|
|
|
@@ -4148,6 +4153,9 @@ async function retrySync(context,input){requireSuperAdmin(context);let dataset=t
|
|
|
const OPS_LIVE_SCOPES=new Set(['learning','courses','appointments','reading','review','account']);
|
|
|
function operationsVersion(row){return(dateIso(row&&row.changed_at)||'')+':'+number(row&&row.total);}
|
|
|
async function operationsScopeVersion(scope,companyId){
|
|
|
+ const installed=await Psql.one("SELECT to_regprocedure('xs_read_version(text,text)') IS NOT NULL installed");
|
|
|
+ if(installed.installed)return(await Psql.one('SELECT xs_read_version($1,$2) version',[companyId,scope])).version;
|
|
|
+
|
|
|
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]));
|
|
|
if(scope==='courses')return operationsVersion(await Psql.one('SELECT MAX(COALESCE("updatedAt","createdAt")) changed_at,COUNT(*)::int total FROM "CourseBinding" WHERE "company"=$1',[companyId]));
|
|
|
if(scope==='appointments')return operationsVersion(await Psql.one('SELECT MAX(COALESCE("updatedAt","createdAt")) changed_at,COUNT(*)::int total FROM "CourseAppointment" WHERE "company"=$1',[companyId]));
|
|
|
@@ -4161,8 +4169,10 @@ async function operationsLiveVersions(context,input){
|
|
|
}
|
|
|
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)};}
|
|
|
async function handler(request,response) {
|
|
|
- const diagnosticId=String(request.body?.params?.requestId||'');
|
|
|
- if(/^[a-zA-Z0-9-]{1,64}$/.test(diagnosticId)&&typeof response.on==='function')response.on('finish',()=>{if(response.statusCode>=400)console.warn(JSON.stringify({event:'gateway_error',requestId:diagnosticId,status:response.statusCode,version:'2026-10-13-lesson-batches-v8'}));});
|
|
|
+ const startedAt=Date.now(),suppliedId=String(request.body?.params?.requestId||'');
|
|
|
+ const diagnosticId=/^[a-zA-Z0-9-]{1,64}$/.test(suppliedId)?suppliedId:Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,10);
|
|
|
+ if(typeof response.setHeader==='function')response.setHeader('X-Request-Id',diagnosticId);
|
|
|
+ if(typeof response.on==='function')response.on('finish',()=>{console.info(JSON.stringify({event:'gateway_request',requestId:diagnosticId,operation:String(request.body?.params?.operation||'').slice(0,100),status:response.statusCode,durationMs:Date.now()-startedAt}));});
|
|
|
let context=null;
|
|
|
try {
|
|
|
const input=inputOf(request); const operation=text(input.operation); const payload=input.payload&&typeof input.payload==='object'?input.payload:{};
|
|
|
@@ -4293,6 +4303,16 @@ async function handler(request,response) {
|
|
|
if(operation==='ops/lessons/detail') return response.json({success:true,data:await lessonHistoryDetail(context,input)});
|
|
|
if(operation==='ops/lessons/transfer-preview') return response.json({success:true,data:await lessonTransferPreview(context,input)});
|
|
|
if(operation==='ops/lessons/transfer') return response.json({success:true,data:await lessonTransferCommit(context,input)});
|
|
|
+ if(operation==='ops/lessons/export/create') return response.json({success:true,data:await lessonExportCreate(context,input)});
|
|
|
+ if(operation==='ops/lessons/export/status') return response.json({success:true,data:await lessonExportStatus(context,input)});
|
|
|
+ if(operation==='ops/lessons/export/cancel') return response.json({success:true,data:await lessonExportCancel(context,input)});
|
|
|
+ if(operation==='ops/lessons/export/download') return response.json({success:true,data:await lessonExportDownload(context,input)});
|
|
|
+ if(operation==='ops/lessons/export/maintenance') return response.json({success:true,data:await lessonExportMaintenance(context,input)});
|
|
|
+ if(operation==='ops/lessons/export/claim') return response.json({success:true,data:await lessonExportClaim(context,input)});
|
|
|
+ if(operation==='ops/lessons/export/snapshot') return response.json({success:true,data:await lessonExportSnapshot(context,input)});
|
|
|
+ if(operation==='ops/lessons/export/chunk') return response.json({success:true,data:await lessonExportChunk(context,input)});
|
|
|
+ if(operation==='ops/lessons/export/finish') return response.json({success:true,data:await lessonExportFinish(context,input)});
|
|
|
+ if(operation==='ops/lessons/export/fail') return response.json({success:true,data:await lessonExportFail(context,input)});
|
|
|
if(operation==='ops/lessons/list') return response.json({success:true,data:await lessonHistoryPage(context,input)});
|
|
|
if(operation==='ops/learning-records/list') return response.json({success:true,data:await businessRows('learning-records',context,input)});
|
|
|
if(operation==='ops/learning-reports/list') return response.json({success:true,data:await learningReportPage(context,input)});
|
|
|
@@ -4305,13 +4325,13 @@ async function handler(request,response) {
|
|
|
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}}); }
|
|
|
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 enrollmentSql('SELECT xs_start_appointment($1,$2) AS result',[pointerId(target.get('company')),target.id]);}else{addon.set('dszt',String(status));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}}); }
|
|
|
fail(400,'不支持的运营后台操作');
|
|
|
- } 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}); }
|
|
|
+ } 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===429?'read_capacity_busy':status>=500?'cloud_function_error':'request_rejected',message,...(status===429?{retryAfterMs:2000}:{})}); }
|
|
|
}
|
|
|
`;
|
|
|
|
|
|
const definitions = [
|
|
|
- { name: 'xiaoshu.ops.gateway', desc: '小树陪练运营管理后台专用网关(业务投影、分级隐私、三级权限与审计)', path: 'xiaoshu/ops/gateway', code: operationsGatewayCode + storeScopeCode + accountDeletionOpsCode + adminRolePolicyCode + reviewRewardCode + enrollmentOpsCode + creditLedgerCode + payrollIntegrityCode + lessonWorkflowOpsCode + lessonRecordsCode + supportRequestsCode, params: [{ name: 'operation', type: 'String', required: true }] },
|
|
|
- { name: 'xiaoshu.ops.gateway.v3', desc: '小树陪练运营管理后台专用网关 V3(含历史排课投影、上海业务日期与陪练排课汇总)', path: 'xiaoshu/ops/gateway-v3', code: operationsGatewayCode + storeScopeCode + accountDeletionOpsCode + adminRolePolicyCode + reviewRewardCode + enrollmentOpsCode + creditLedgerCode + payrollIntegrityCode + lessonWorkflowOpsCode + lessonRecordsCode + supportRequestsCode, params: [{ name: 'operation', type: 'String', required: true }] },
|
|
|
+ { name: 'xiaoshu.ops.gateway', desc: '小树陪练运营管理后台专用网关(业务投影、分级隐私、三级权限与审计)', path: 'xiaoshu/ops/gateway', code: operationsGatewayCode + storeScopeCode + accountDeletionOpsCode + adminRolePolicyCode + reviewRewardCode + enrollmentOpsCode + creditLedgerCode + payrollIntegrityCode + lessonWorkflowOpsCode + lessonRecordsCode + lessonExportCode + supportRequestsCode, params: [{ name: 'operation', type: 'String', required: true }] },
|
|
|
+ { name: 'xiaoshu.ops.gateway.v3', desc: '小树陪练运营管理后台专用网关 V3(含历史排课投影、上海业务日期与陪练排课汇总)', path: 'xiaoshu/ops/gateway-v3', code: operationsGatewayCode + storeScopeCode + accountDeletionOpsCode + adminRolePolicyCode + reviewRewardCode + enrollmentOpsCode + creditLedgerCode + payrollIntegrityCode + lessonWorkflowOpsCode + lessonRecordsCode + lessonExportCode + supportRequestsCode, params: [{ name: 'operation', type: 'String', required: true }] },
|
|
|
{ name: 'xiaoshu.admin.gateway', desc: '小树陪练 Angular 管理后台统一数据网关(权限、帐套、CRUD、统计)', path: 'xiaoshu/admin/gateway', code: adminGatewayCode + adminRolePolicyCode + operatorAccountsCode, params: [{ name: 'operation', type: 'String', required: true }] },
|
|
|
{ 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 }] },
|
|
|
{ name: 'cms.users.extended', desc: '替代 ZL_EX_UserView,返回安全用户扩展资料', path: 'xiaoshu/cms/users/extended', code: cmsReadCode('user-extended'), params: [{ name: 'userId', type: 'String', required: true }] },
|