Skip to main content

บอร์ดประกาศ - รีแอ็กชันและรายงาน

ภาพรวม

สอง endpoint ที่รองรับด้าน engagement และการกำกับดูแลเนื้อหาของบอร์ดประกาศ ได้แก่ การกดรีแอ็กชัน (แตะเพื่อ toggle) และการรายงานโพสต์หรือคอมเมนต์เข้าคิว moderation ของ CMS

สิ่งที่ทั้งสอง endpoint ใช้ร่วมกันคือด่าน verifyTarget ซึ่งป้องกันไม่ให้ endpoint เหล่านี้กลายเป็น oracle สำหรับสอดส่องเนื้อหาที่ผู้เรียกไม่มีสิทธิ์มองเห็น

Business Flow

กดรีแอ็กชัน — PUT /api/bulletin/:hash/reactions (rate limit 30/60s)

Body: {targetType, targetId, emoji}

  1. resolveAccessCanReact() ซึ่งเทียบเท่ากับ CanWrite() ผู้ที่ถูก mute เฉพาะสโคปคอมเมนต์ ยังกดรีแอ็กชันได้
  2. emoji ต้องอยู่ในชุด settings.reaction_emojis ของบอร์ดนั้นเอง มิฉะนั้นตอบ 400 BULLETIN_INVALID_EMOJI หากไม่ตรวจ setting นี้ ค่าดังกล่าวจะเป็นเพียงของประดับ เพราะสตริงความยาว 1–16 ตัวใดก็ตามจะถูกบันทึกและไป render บนแถบรีแอ็กชันของทุกคน ส่วนสตริงที่ยาวกว่านั้นจะชน VARCHAR(16) และกลายเป็น Postgres error 22001 ที่โผล่ออกมาเป็น 500 แทนที่จะเป็น 400 การตรวจสมาชิกในชุดจึงครอบทั้งกรณีสตริงว่างและยาวเกินไปในคราวเดียว
  3. เรียก verifyTarget (อธิบายด้านล่าง)
  4. upsert แบบ tap-to-toggle ภายใน transaction เดียว โดยยึด unique index (target_type, target_id, line_user_id)
    • อ่านด้วย FindReactionForUpdate เพียงเพื่อตรวจกรณี emoji เดิมซ้ำ ซึ่งเป็นกรณีเดียวที่จำเป็น ต้องรู้ค่าเก่า
    • emoji เดิม → DELETE (toggle off) พร้อม action reaction.remove การ DELETE ที่ไม่พบแถว (เพราะ request อื่นลบไปก่อน) ถือเป็น no-op ไม่ใช่ error
    • กรณีอื่น → UpsertReaction ด้วย statement เดียวแบบ insert-or-replace เพราะ FOR UPDATE บนแถวที่ยังไม่มีอยู่ล็อกอะไรไม่ได้เลย รีแอ็กชันครั้งแรกที่มาพร้อมกันสอง request จึงต้องไม่กลายเป็น duplicate-key 500
    • เขียน audit (reaction.set หรือ reaction.remove) ภายใน transaction เดียวกัน
  5. คืน emoji counts หลัง mutate (ReactionCounts) เพื่อให้ client อัปเดตแถบรีแอ็กชันได้ทันที
  6. ตารางรีแอ็กชันเป็นตารางเดียวใน package นี้ที่ใช้ hard delete ส่วนที่เหลือเป็น soft delete ทั้งหมด

รายงานเนื้อหา — POST /api/bulletin/:hash/reports (rate limit 5/60s)

Body: {targetType, targetId, reason, detail}

  1. resolveAccessCanReport() ซึ่งต้องเปิด setting allow_user_reports ก่อน แล้วจึงตรวจ CanWrite()
  2. reason ต้องอยู่ใน allowlist แบบปิด ได้แก่ spam, harassment, inappropriate, misinformation, other มิฉะนั้นตอบ 400 BULLETIN_INVALID_REASON เนื่องจากคอลัมน์เป็น VARCHAR(50) NOT NULL และคิว moderation ของ CMS switch ตามค่าเหล่านี้ เหตุผลที่ไม่รู้จักจะทำให้ UI render ข้อความของผู้โจมตีแบบดิบๆ ให้ moderator เห็น หรือหลุดออกจาก switch
  3. detail ที่ยาวเกิน 500 rune ตอบ 400 BULLETIN_DETAIL_TOO_LONG เพราะคอลัมน์เป็น TEXT จึงไม่มี ขอบเขตจากฝั่ง DB ถ้าไม่ cap ที่ชั้นนี้ ผู้ใช้ที่ล็อกอินแล้วสามารถเขียนแถวขนาดไม่จำกัดเข้าหน้าจอแอดมินได้
  4. เรียก verifyTarget
  5. insert ภายใน transaction พร้อม audit report.create โดยอนุญาต 1 รายงานต่อคู่ (เป้าหมาย, ผู้รายงาน) ตลอดกาล บังคับด้วย unique index กรณีซ้ำจะตอบ 409 "already reported" ซึ่งแปลงมาจาก ErrDuplicateReport ที่ repository คืนมา ไม่ใช่การ check-then-insert ที่แข่งกันเอง
  6. ตอบ 201 โดยไม่มี body

verifyTarget — ทำไมต้องตรวจการมองเห็น ไม่ใช่แค่การมีอยู่

ทุกเส้นทางการอ่าน (GetPost, ListComments, CreateComment) วิ่งผ่าน visiblePostForCaller และ ตอบ 404 สำหรับโพสต์ที่ moderator ซ่อน, โพสต์ pending ของผู้อื่น และโพสต์ในหมวดที่ผู้เรียกไม่อยู่ใน audience หากสอง endpoint นี้ข้าม predicate ดังกล่าว มันจะกลายเป็น oracle ที่บอกทั้งการมีอยู่และปริมาณ engagement ของเนื้อหาที่กฎการมองเห็นระดับหมวดหมู่ตั้งใจซ่อนไว้ (ชื่อหมวดเองก็อาจเป็นความลับ) เพราะ การตอบ 200 พร้อม emoji breakdown เท่ากับยืนยันการมีอยู่ของโพสต์ที่ทุกที่ตอบ 404 ส่วนการรายงานยิ่งหนัก กว่านั้น เพราะ trigger report_count จะทำงาน ทำให้คนนอกดันตัวเลข moderation ของเนื้อหาที่ไม่ควรรู้ว่า มีอยู่ได้

  • targetType = "post" ตรวจด้วย visiblePostForCaller
  • targetType = "comment" ตรวจสองชั้น คือสถานะของคอมเมนต์เอง (commentVisibleToCaller) และ การมองเห็นของโพสต์แม่ (คอมเมนต์บนโพสต์ที่ถูกลบหรือซ่อนเข้าถึงไม่ได้ทุกทางอยู่แล้ว จึงต้องเข้าถึงไม่ได้ ที่นี่ด้วย) โดย 404 ของโพสต์แม่ถูกแปลงข้อความเป็น "comment not found" เพื่อให้ทั้งสองกรณี แยกออกจากกันไม่ได้
  • targetType อื่นตอบ 400 "invalid target type"
  • ทุกความล้มเหลวยุบเป็น 404 เดียวกัน

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

RouteRate limitHandler
PUT /api/bulletin/:hash/reactions30/60sinternal/bulletin/handler.go(*Handler).SetReaction
POST /api/bulletin/:hash/reports5/60s(*Handler).CreateReport
  • internal/bulletin/service.goSetReaction, CreateReport, verifyTarget, commentVisibleToCaller, visiblePostForCaller
  • internal/bulletin/repository.goFindReactionForUpdate, UpsertReaction, DeleteReaction, ReactionCounts, ReactionStates, InsertReport, ErrDuplicateReport, InsertAudit
  • internal/bulletin/entity.goReaction, Report, ReportReasons, validReason, MaxReportDetailLength = 500, Settings.AllowsEmoji

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

  • ตารางฐานข้อมูล bulletin.reaction (unique idx_bul_reaction_one), bulletin.report (unique idx_bul_report_once และ trigger report_count), bulletin.post, bulletin.comment, bulletin.audit_log
  • คิว moderation ของ CMS อ่านตาราง bulletin.report โดย switch ตามค่า reason ดังนั้น allowlist ทั้งสองฝั่งต้อง sync กันเสมอ
  • bulletin-access-control เป็นแหล่งของ CanReact และ CanReport
  • ReactionStates ถูกใช้โดย bulletin-board-feed เพื่อ decorate ทั้งหน้า
  • ตรงกับ client-web feature: bulletin-moderation (การรายงาน) และ bulletin-board / bulletin-post-detail (แถบรีแอ็กชัน)