Skip to main content

บอร์ดประกาศ - คอมเมนต์และ quote

ภาพรวม

ฟีเจอร์นี้ดูแลเธรดคอมเมนต์ใต้โพสต์ในบอร์ดประกาศ ครอบคลุมการอ่านคอมเมนต์ทีละหน้า (เรียงจากเก่าไปใหม่), การเขียนคอมเมนต์พร้อมแนบรูปและอ้างอิง (quote) คอมเมนต์อื่น และการลบคอมเมนต์ของตนเอง

จุดออกแบบที่เป็นหัวใจของฟีเจอร์คือ การ resolve quote ที่ฝั่ง server แบบ batch ไม่ปล่อยให้ client จับคู่คอมเมนต์ต้นทางเอง ซึ่งแก้ปัญหาทั้งด้านความถูกต้องของข้อมูลและด้านสิทธิ์การมองเห็นไปพร้อมกัน

Business Flow

อ่านรายการคอมเมนต์ — GET /api/bulletin/:hash/posts/:id/comments

  1. ตรวจสิทธิ์ผ่าน resolveAccessCanView()visiblePostForCaller(postID) โพสต์ที่ผู้เรียก มองไม่เห็นจะตอบ 404 ไม่ใช่คืนรายการคอมเมนต์ว่าง
  2. รองรับ cursor + limit (ค่าเริ่มต้น 20) เช่นเดียวกับฟีดหลัก หาก cursor ผิดรูปแบบระบบจะเริ่มจากต้นเธรด
  3. ListComments คืนเฉพาะคอมเมนต์สถานะ published รวมกับคอมเมนต์สถานะ pending ของผู้เรียกเอง (ใช้ UNION ALL ใน SQL) ส่วนคอมเมนต์ hidden และ deleted ไม่มีใครเห็น
  4. ResolveQuotes ทำงาน 1 query ต่อหนึ่งหน้า
    • รวบรวม quoteCommentId ของทั้งหน้าแล้ว dedupe ก่อน (คอมเมนต์ยอดนิยมมักถูก quote หลายครั้ง ในหน้าเดียวกัน) จากนั้นดึงด้วย id = ANY(...) ครั้งเดียวเพื่อกันปัญหา N+1
    • เหตุผลที่ต้องทำฝั่ง server: client รุ่นเดิมจับคู่ quote กับคอมเมนต์ที่ตัวเองมีอยู่ในมือ ทำให้ quote ที่ชี้ไปยังคอมเมนต์ในหน้าก่อนหน้าไม่ถูก render และที่แย่กว่านั้นคือ render สำเนาเก่าของคอมเมนต์ ที่ถูกลบไปแล้ว
    • เป้าหมายที่ถูก soft delete, ถูก moderator ซ่อน, อยู่คนละ tenant หรือหายไปแล้ว จะถูกยุบเป็น สถานะเดียวกันทั้งหมด คือ {deleted:true, snippet:""} เพื่อให้ client แสดงข้อความ "คอมเมนต์นี้ถูกลบ" ได้โดยไม่ต้องเดา
    • ใช้ predicate commentVisibleToCaller ตัวเดียวกับการ listing ดังนั้น quote จะไม่มีทางเปิดเผย คอมเมนต์ที่ผู้เรียกอ่านในเธรดไม่ได้
    • QuoteView มีเพียง {id, authorType, isMine, snippet} ไม่มี field ใดที่พา author_line_user_id ออกไปได้ (รักษา pseudonymity) และ snippet ถูกตัดที่ 120 rune
  5. Response เป็น object {comments, nextCursor?} ไม่ใช่ array เปล่า เพราะ cursor เป็นค่า opaque ถ้าไม่ส่งกลับมา client จะไปหน้าถัดไปไม่ได้ คอมเมนต์เรียงแบบ ASC ดังนั้น cursor คือแถวสุดท้ายของหน้า

สร้างคอมเมนต์ — POST /api/bulletin/:hash/posts/:id/comments (rate limit 10/60s)

  1. ตรวจ CanComment() ซึ่งต่างจาก CanWrite() ตรงที่ผู้ที่ถูก block ใน scope ใดก็ตามจากสองสโคป จะคอมเมนต์ไม่ได้
  2. visiblePostForCaller ตอบ 404 ถ้าผู้เรียกมองไม่เห็นโพสต์ ข้อสำคัญคือต้องเป็น 404 ไม่ใช่เปิดเผย BULLETIN_COMMENTS_CLOSED
  3. โพสต์ที่ปิดคอมเมนต์ตอบ 403 BULLETIN_COMMENTS_CLOSED
  4. เนื้อหาที่ trim แล้วว่างตอบ 400 BULLETIN_EMPTY_BODY ส่วนเนื้อหาที่ยาวเกิน max_comment_length ตอบ BULLETIN_BODY_TOO_LONG
  5. จำนวนรูปเกิน max_images_per_comment (ค่าเริ่มต้น 2 ซึ่งเป็น setting แยกจากฝั่งโพสต์ที่เริ่มต้นที่ 4 เพื่อให้บอร์ดที่ขยายขีดจำกัดด้านหนึ่งไม่ขยายอีกด้านโดยไม่ตั้งใจ) ตอบ BULLETIN_IMAGE_LIMIT
  6. quoteCommentId ต้องมีอยู่จริง อยู่บนโพสต์เดียวกัน และมีสถานะ published มิฉะนั้นตอบ 400 "invalid quote comment" โดยการ lookup เป็นแบบ tenant-scoped
  7. สถานะเริ่มต้นขึ้นกับ setting require_comment_approval ถ้าเปิดจะเป็น pending ถ้าไม่เปิดจะเป็น published
  8. ทำงานใน transaction เดียว: insert คอมเมนต์ → commit รูปผ่าน comment committer (key รูปแบบ bulletin/comment/{id}/{index}.{ext} ต้องทำหลัง insert เพราะ key ต้องใช้ comment id) → UpdateCommentImages → เขียน audit comment.create
  9. คืน CommentView ที่ resolve quote มาให้เรียบร้อยแล้ว พร้อม status 201 client จึงได้โครงสร้าง ข้อมูลเหมือนกับที่ได้จาก listing โดยไม่ต้องมี special case

ลบคอมเมนต์ — DELETE /api/bulletin/:hash/comments/:id

  • ตรวจสิทธิ์ด้วย CanComment() ไม่ใช่ CanWrite() เพราะผู้ที่ถูก block ใน scope comment ควรถูก ล็อกออกจากทุก action ของคอมเมนต์ รวมถึงการลบคอมเมนต์ของตัวเองด้วย
  • ownedComment ตอบ 404 เหมือนกันทุกกรณี ไม่ว่าจะเป็นคอมเมนต์ที่ไม่มีอยู่ อยู่คนละ OA หรือไม่ใช่ ของผู้เรียก
  • ทำ soft delete พร้อมเขียน audit comment.delete แล้วตอบ 204

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

RouteRate limitHandler
GET /api/bulletin/:hash/posts/:id/comments(*Handler).ListComments
POST /api/bulletin/:hash/posts/:id/comments10/60s(*Handler).CreateComment
DELETE /api/bulletin/:hash/comments/:id(*Handler).DeleteComment
  • internal/bulletin/service.goListComments, CreateComment, DeleteComment, ResolveQuotes, ownedComment, commentVisibleToCaller, visiblePostForCaller, postVisibleToCaller, categoryVisible, GetPost
  • internal/bulletin/repository.goListComments (predicate แบบ UNION ALL), InsertComment, FindCommentByID, FindCommentsByIDs (แบบ batch), SoftDeleteComment, UpdateCommentImages
  • internal/bulletin/view.goCommentView, QuoteView, NewCommentView, NewCommentViews, quoteSnippet, maxQuoteSnippetRunes
  • internal/bulletin/committer.go — committer instance สำหรับ imageKindComment

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

  • ตารางฐานข้อมูล bulletin.comment, bulletin.post, bulletin.audit_log
  • Object storage ผ่าน comment committer รายละเอียดดูที่ bulletin-post-write
  • bulletin-access-control เป็นแหล่งของ CanComment และค่า cap ต่างๆ ใน settings
  • GET /api/bulletin/:hash/posts/:id (GetPost) เป็น endpoint คู่กันที่ใช้ predicate การมองเห็น ชุดเดียวกัน
  • ตรงกับ client-web feature: bulletin-post-detail