Skip to main content

บอร์ดประกาศ — สร้าง แก้ไข ลบโพสต์ และการยืนยันรูปภาพ

ภาพรวม

เส้นทางเขียนของโพสต์ทั้งหมด ได้แก่ การสร้าง แก้ไข ลบแบบ soft delete และการเปิดหรือปิดคอมเมนต์ของโพสต์ตัวเอง ทุกการ mutate ทำงานภายใน transaction เดียวกันพร้อมกับแถว audit log เพื่อไม่ให้เกิด audit trail ที่มีช่องโหว่

รวมถึง image committer ซึ่งเป็นกลไกย้ายรูปจาก prefix temp/ ที่ endpoint upload สร้างไว้ ไปยัง path ถาวร กลไกนี้เป็นทั้งการจัดระเบียบไฟล์และ ด่านความปลอดภัย เพราะเป็นการพิสูจน์ว่าผู้เรียกเป็นผู้อัปโหลด object นั้นจริง

Business Flow

สร้างโพสต์ — POST /api/bulletin/:hash/posts (rate limit 5/60s)

  1. เรียก resolveAccess แล้วตรวจ CanWrite()

  2. validateTitle — ลำดับของกฎเป็นส่วนหนึ่งของสัญญา โดย trim ก่อน แล้วตรวจว่าว่างหรือไม่ (BULLETIN_EMPTY_TITLE) จากนั้นจึงตรวจความยาว (BULLETIN_TITLE_TOO_LONG เพดาน 200 นับเป็น rune เพราะ Postgres VARCHAR(200) นับเป็นตัวอักษร หัวข้อภาษาไทย 200 ตัวจึงต้องผ่าน) ค่าที่บันทึกจริงคือค่าที่ trim แล้ว ดังนั้นหัวข้อที่เป็นช่องว่าง 300 ตัวจะถือว่า "ว่าง" ไม่ใช่ "ยาวเกิน" และ CMS ใช้ error code สองตัวนี้ร่วมกัน

  3. Body ที่ยาวเกิน max_body_length ตอบ BULLETIN_BODY_TOO_LONG ส่วนรูปที่เกิน max_images_per_post ตอบ BULLETIN_IMAGE_LIMIT

  4. หากระบุ categoryId มา ต้องตรวจ 2 ข้อตามลำดับนี้

    • ความเป็นเจ้าของ — FK บน post.category_id พิสูจน์เพียงว่าหมวดนั้นมีอยู่ที่ใดที่หนึ่ง ไม่ได้พิสูจน์ว่าอยู่บนบอร์ดของผู้เรียก ดังนั้นการค้นไม่พบถือเป็น input ที่ผิด ตอบ 400 invalid category ไม่ใช่ 404
    • สิทธิ์การโพสต์ — จากนั้นจึงดู post_access ของหมวดนั้น หากไม่ใช่ member ตอบ 403 BULLETIN_CATEGORY_ADMIN_ONLY

    ลำดับนี้ทำให้หมวดของบอร์ดอื่นได้คำตอบว่า invalid category ไม่ใช่ admin only ซึ่งจะเป็นการยืนยันว่ามีหมวด id นั้นอยู่จริง

  5. กำหนดสถานะ — หาก require_post_approval เปิดอยู่จะได้ pending มิฉะนั้นได้ published ส่วน allowComments ใช้ค่าที่ส่งมาหากมี มิฉะนั้นใช้ allow_comments_default โดยเก็บเป็น pointer เพื่อแยกกรณี "ไม่ส่งค่ามา" ออกจาก "ส่งค่า false มา"

  6. ทำงานใน transaction เดียว ตามลำดับ insert โพสต์ → commit รูป (ต้องทำหลัง insert เพราะ key ถาวรต้องใช้ post id) → เรียก UpdatePostImages (แยกจาก UpdatePost เพราะตัวหลัง stamp edited_date และโพสต์ที่มีรูปไม่ควรถูกทำเครื่องหมายว่า "ถูกแก้ไข" ตั้งแต่แรกเกิด) → insert audit post.create หากขั้นตอนใดล้มเหลวจะ rollback ทั้งหมด จึงไม่มีโพสต์กำพร้าที่ชี้ไปยัง object ที่ไม่เคยถูก copy

  7. ตอบกลับ PostView ด้วยสถานะ 201

แก้ไขโพสต์ — PUT /api/bulletin/:hash/posts/:id

  1. ตรวจ CanWrite() แล้วเรียก ownedPost — กรณีไม่มีอยู่ อยู่คนละ OA หรือไม่ใช่ของตัวเอง ตอบ 404 เหมือนกันทั้งหมด
  2. การแก้ไขไม่ได้รับข้อยกเว้นจากกฎหัวข้อ เพราะการแก้จนหัวข้อว่างจะเหลือ thread ที่ไม่มีชื่อในลิสต์ที่นำด้วยหัวข้อ
  3. โพสต์ที่อยู่ในสถานะ published และบอร์ดตั้ง require_post_approval ไว้ จะ กลับไปเป็น pending ส่วนสถานะอื่นคงเดิม
  4. UpdatePostInput ไม่มี field categoryId โดยเจตนา เพราะ repository ไม่มีคอลัมน์ให้เขียน และการรับ field แล้วทิ้งเงียบ ๆ แย่กว่าการไม่มี field นั้นเลย เหตุนี้เองที่ UpdatePost จึงไม่ต้องตรวจ post_access ซ้ำ เพราะการแก้ไขไม่สามารถย้ายโพสต์เข้าหมวด admin-only ได้ และมี test คอยเป็นสายสะดุดไว้
  5. Commit รูปเฉพาะเมื่อมีการส่ง images มา แล้วทำ update และ insert audit post.edit ภายใน transaction เดียวกัน พร้อมบันทึกค่า before และ after

ลบโพสต์ — DELETE /api/bulletin/:hash/posts/:id

ตรวจ CanWrite() แล้ว ownedPost จากนั้น soft delete โดยตั้งสถานะเป็น deleted พร้อมกำหนด deleted_dateไม่เคยใช้ SQL DELETE และ insert audit post.delete ก่อนตอบกลับ 204

เปิด/ปิดคอมเมนต์ — PUT /api/bulletin/:hash/posts/:id/comments-setting

ตรวจ CanWrite() แล้ว ownedPost จากนั้นเรียก SetAllowComments และ insert audit post.comments_toggle ก่อนตอบกลับ 204

Image committer (storageCommitter.Commit)

  • Layout ถาวรอยู่ในรูป bulletin/{post|comment}/{ownerID}/{index}.{ext} โดยดัชนีคือตำแหน่งใน array ที่ส่งมา การ commit ซ้ำจึง เขียนทับ object ของตัวเอง ไม่สะสมขยะ และนามสกุลไฟล์เดิมถูกรักษาไว้
  • ค่า kind ซึ่งระบุว่าเป็น post หรือ comment ถูก fix ตั้งแต่ตอน construct จึงมี 2 instance แยกกันแทนที่จะเป็นตัวเดียวที่ branch ภายใน ผลคือ layout ของทั้งสองฝั่งเบี่ยงออกจากกันไม่ได้
  • ทำงาน 2 phase โดยเจตนา — phase 1 normalize แล้วเรียก HeadObject เพื่อพิสูจน์ทุก key ก่อน จากนั้น phase 2 จึง copy ผลคือ request ที่มี reference เสียแม้เพียงหนึ่งตัวจะไม่เขียนอะไรเลย
  • normalizeTempImageKey รับได้ทั้ง key ดิบในรูป temp/temp-{ms}.{ext} และ public URL ที่ endpoint upload คืนมา แล้ว normalize ให้เป็น key ส่วนค่าอื่น ๆ ไม่ว่าจะเป็น prefix อื่น path traversal หรือข้อความมั่ว จะถูกปฏิเสธ
  • HeadObject คือขั้นความปลอดภัยตัวจริง เพราะเป็นสิ่งเดียวที่กันการอ้างถึง object ของ tenant อื่นหรือ key ที่ไม่เคยถูกอัปโหลด หากล้มเหลวตอบ 400 BULLETIN_INVALID_IMAGE
  • เมื่อไม่มี config ของ storage ระบบจะ fail closed คือปฏิเสธทุกรูป ไม่ใช่ปล่อยให้ temp key ผ่านไปเก็บ ซึ่งเป็น defect เดิมที่ทำให้รูปทั้งหมดกลายเป็น 404 เมื่อ temp prefix ถูกกวาด
  • Interface objectStore ไม่มีเมธอด delete โดยเจตนา — object ใน temp/ ไม่เคยถูกลบที่นี่ เพราะหากลบแล้ว transaction rollback จะเหลือสภาพที่ทั้งต้นทางหายไปและไม่มีอะไรชี้ไปยังสำเนา การกวาด temp/ จึงเป็นงาน operations แยกต่างหาก

ไฟล์และฟังก์ชันหลัก

RouteRate limitHandler
POST /api/bulletin/:hash/posts5/60s(*Handler).CreatePost
PUT /api/bulletin/:hash/posts/:id(*Handler).UpdatePost
DELETE /api/bulletin/:hash/posts/:id(*Handler).DeletePost
PUT /api/bulletin/:hash/posts/:id/comments-setting(*Handler).SetCommentsSetting
  • internal/bulletin/service.goCreatePost, UpdatePost, DeletePost, SetAllowComments, ownedPost, validateTitle, interface postRepo, AuditEntry, ImageCommitter
  • internal/bulletin/committer.goNewStorageCommitter, (storageCommitter).Commit, permanentKey, extensionOf, objectStore
  • internal/bulletin/register.gonormalizeTempImageKey, tempImageKeyRE
  • internal/bulletin/repository.goWithTx, InsertPost, UpdatePost, UpdatePostImages, SoftDeletePost, SetAllowComments, InsertAudit, FindPostByID, FindCategoryByID

หมายเหตุเรื่องการตั้งชื่อ route: ทุก wildcard ใต้ /posts/:id ต้องใช้ชื่อ :id เหมือนกันทั้งหมด เพราะ gin จะ panic หากชื่อ param ต่างกันภายใน subtree เดียวกัน ส่วน /comments/:id ถือเป็น subtree แยกเนื่องจาก static segment ต่างกัน

จุดเชื่อมต่อกับ Service อื่น

  • ฐานข้อมูล — ตาราง bulletin.post, bulletin.category และ bulletin.audit_log
  • Object storage — เรียกผ่าน internal/storagex ด้วย HeadObject และ CopyObject
  • File upload — แหล่งที่มาของ temp key คือ endpoint POST /upload-file/temp
  • Bulletin access control — เรียก CanWrite และอ่านค่าเพดานต่าง ๆ จาก settings
  • client-web — ตรงกับฟีเจอร์ bulletin-board (ส่วนเขียนโพสต์), bulletin-moderation (เมนูแก้ไขและลบของเจ้าของโพสต์) และ file-upload