// Attachments are uploaded by the assigned teacher and linked to one appointment. const LESSON_ATTACHMENT_TYPES = { jpg:['image/jpeg','/9j/'],jpeg:['image/jpeg','/9j/'],png:['image/png','iVBOR'],webp:['image/webp','UklGR'],gif:['image/gif','R0lG'], pdf:['application/pdf','JVBERi0'],doc:['application/msword','0M8R4KGxGuE'], docx:['application/vnd.openxmlformats-officedocument.wordprocessingml.document','UEsDB'], xls:['application/vnd.ms-excel','0M8R4KGxGuE'],xlsx:['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet','UEsDB'], ppt:['application/vnd.ms-powerpoint','0M8R4KGxGuE'],pptx:['application/vnd.openxmlformats-officedocument.presentationml.presentation','UEsDB'],txt:['text/plain',''], }; async function lessonAttachmentOrder(input,current,requireEditable=true) { assertLessonTenant(current); const order=await orderDetailRow(String(input.orderId||''),current,false); if(!order)fail(404,'预约不存在'); if(number(current.get('legacyGroupId'))!==3||ownLegacyId(current)!==number(order.pl))fail(403,'仅本节课老师可以上传和保存课堂附件'); if(!['primary_writing','middle_writing','high_writing'].includes(String(order.courseCategoryKey)))fail(403,'课堂附件仅用于语法写作课'); if(requireEditable){ const capabilities=await lessonOrderCapabilities(order,current); if(!capabilities.allowedActions.some(action=>['complete','editFeedback'].includes(action)))fail(403,'当前课程不可编辑课堂反馈'); } return order; } async function uploadLessonAttachment(input,current) { const order=await lessonAttachmentOrder(input,current); const name=String(input.fileName||'').trim(),extension=name.split('.').pop().toLowerCase(),rule=LESSON_ATTACHMENT_TYPES[extension]; const contentType=String(input.contentType||'').toLowerCase(),base64=String(input.base64||'').replace(/^data:[^;]+;base64,/i,''); if(!name||name.length>180||/[\x00-\x1f/\\]/.test(name)||!rule||contentType!==rule[0]||base64.length>6990508||!base64||!/^[A-Za-z0-9+/]+={0,2}$/.test(base64))fail(400,'请选择5MB以内的照片或常用文档'); let binary;try{binary=atob(base64);}catch(_){fail(400,'附件编码无效');} if(!binary.length||binary.length>5*1024*1024||!base64.startsWith(rule[1])||contentType==='text/plain'&&binary.includes('\x00'))fail(400,'附件内容与文件类型不符或超过5MB'); const extraTypes=Object.fromEntries(Object.values(LESSON_ATTACHMENT_TYPES).map(type=>[type[0],type[1]])); const url=await uploadAppMedia({...input,base64},extraTypes); if(!/^https?:\/\//i.test(url))fail(502,'上传未返回有效附件地址'); const attachment={id:randomObjectId(),name,url,contentType,size:binary.length}; await Psql.none('INSERT INTO "LessonFeedbackAttachment" ("objectId",company,"appointmentGeneralId","coachId","fileName","publicUrl","contentType",size,"createdAt") VALUES($1,$2,$3,$4,$5,$6,$7,$8,now())', [attachment.id,DEFAULT_COMPANY_ID,String(order.GeneralID),ownLegacyId(current),name,url,contentType,attachment.size]); return attachment; } async function resolveLessonFeedbackAttachments(input,current,feedback) { // The SQL command checks edit locks after replaying successful requests. Resolve // ownership here without rejecting an already successful retry after review. const order=await lessonAttachmentOrder(input,current,false),ids=feedback.attachments.map(file=>file.id); const rows=await Psql.query('SELECT "objectId" AS id,"fileName" AS name,"publicUrl" AS url,"contentType",size::int AS size FROM "LessonFeedbackAttachment" WHERE company=$1 AND "appointmentGeneralId"=$2 AND "coachId"=$3 AND "objectId"=ANY($4::text[])', [DEFAULT_COMPANY_ID,String(order.GeneralID),ownLegacyId(current),ids]); const byId=new Map(rows.map(row=>[row.id,row])); if(byId.size!==ids.length)fail(403,'附件不属于本节课或上传尚未完成,请重新选择'); feedback.attachments=ids.map(id=>byId.get(id)); }