lesson-attachments.js 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
  1. // Attachments are uploaded by the assigned teacher and linked to one appointment.
  2. const LESSON_ATTACHMENT_TYPES = {
  3. jpg:['image/jpeg','/9j/'],jpeg:['image/jpeg','/9j/'],png:['image/png','iVBOR'],webp:['image/webp','UklGR'],gif:['image/gif','R0lG'],
  4. pdf:['application/pdf','JVBERi0'],doc:['application/msword','0M8R4KGxGuE'],
  5. docx:['application/vnd.openxmlformats-officedocument.wordprocessingml.document','UEsDB'],
  6. xls:['application/vnd.ms-excel','0M8R4KGxGuE'],xlsx:['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet','UEsDB'],
  7. ppt:['application/vnd.ms-powerpoint','0M8R4KGxGuE'],pptx:['application/vnd.openxmlformats-officedocument.presentationml.presentation','UEsDB'],txt:['text/plain',''],
  8. };
  9. async function lessonAttachmentOrder(input,current,requireEditable=true) {
  10. assertLessonTenant(current);
  11. const order=await orderDetailRow(String(input.orderId||''),current,false);
  12. if(!order)fail(404,'预约不存在');
  13. if(number(current.get('legacyGroupId'))!==3||ownLegacyId(current)!==number(order.pl))fail(403,'仅本节课老师可以上传和保存课堂附件');
  14. if(!['primary_writing','middle_writing','high_writing'].includes(String(order.courseCategoryKey)))fail(403,'课堂附件仅用于语法写作课');
  15. if(requireEditable){
  16. const capabilities=await lessonOrderCapabilities(order,current);
  17. if(!capabilities.allowedActions.some(action=>['complete','editFeedback'].includes(action)))fail(403,'当前课程不可编辑课堂反馈');
  18. }
  19. return order;
  20. }
  21. async function uploadLessonAttachment(input,current) {
  22. const order=await lessonAttachmentOrder(input,current);
  23. const name=String(input.fileName||'').trim(),extension=name.split('.').pop().toLowerCase(),rule=LESSON_ATTACHMENT_TYPES[extension];
  24. const contentType=String(input.contentType||'').toLowerCase(),base64=String(input.base64||'').replace(/^data:[^;]+;base64,/i,'');
  25. 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以内的照片或常用文档');
  26. let binary;try{binary=atob(base64);}catch(_){fail(400,'附件编码无效');}
  27. if(!binary.length||binary.length>5*1024*1024||!base64.startsWith(rule[1])||contentType==='text/plain'&&binary.includes('\x00'))fail(400,'附件内容与文件类型不符或超过5MB');
  28. const extraTypes=Object.fromEntries(Object.values(LESSON_ATTACHMENT_TYPES).map(type=>[type[0],type[1]]));
  29. const url=await uploadAppMedia({...input,base64},extraTypes);
  30. if(!/^https?:\/\//i.test(url))fail(502,'上传未返回有效附件地址');
  31. const attachment={id:randomObjectId(),name,url,contentType,size:binary.length};
  32. 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())',
  33. [attachment.id,DEFAULT_COMPANY_ID,String(order.GeneralID),ownLegacyId(current),name,url,contentType,attachment.size]);
  34. return attachment;
  35. }
  36. async function resolveLessonFeedbackAttachments(input,current,feedback) {
  37. // The SQL command checks edit locks after replaying successful requests. Resolve
  38. // ownership here without rejecting an already successful retry after review.
  39. const order=await lessonAttachmentOrder(input,current,false),ids=feedback.attachments.map(file=>file.id);
  40. 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[])',
  41. [DEFAULT_COMPANY_ID,String(order.GeneralID),ownLegacyId(current),ids]);
  42. const byId=new Map(rows.map(row=>[row.id,row]));
  43. if(byId.size!==ids.length)fail(403,'附件不属于本节课或上传尚未完成,请重新选择');
  44. feedback.attachments=ids.map(id=>byId.get(id));
  45. }