บอร์ดประกาศ - รีแอ็กชันและรายงาน
ภาพรวม
สอง endpoint ที่รองรับด้าน engagement และการกำกับดูแลเนื้อหาของบอร์ดประกาศ ได้แก่ การกดรีแอ็กชัน (แตะเพื่อ toggle) และการรายงานโพสต์หรือคอมเมนต์เข้าคิว moderation ของ CMS
สิ่งที่ทั้งสอง endpoint ใช้ร่วมกันคือด่าน verifyTarget ซึ่งป้องกันไม่ให้ endpoint เหล่านี้กลายเป็น
oracle สำหรับสอดส่องเนื้อหาที่ผู้เรียกไม่มีสิทธิ์มองเห็น
Business Flow
กดรีแอ็กชัน — PUT /api/bulletin/:hash/reactions (rate limit 30/60s)
Body: {targetType, targetId, emoji}
resolveAccess→CanReact()ซึ่งเทียบเท่ากับCanWrite()ผู้ที่ถูก mute เฉพาะสโคปคอมเมนต์ ยังกดรีแอ็กชันได้- emoji ต้องอยู่ในชุด
settings.reaction_emojisของบอร์ดนั้นเอง มิฉะนั้นตอบ 400BULLETIN_INVALID_EMOJIหากไม่ตรวจ setting นี้ ค่าดังกล่าวจะเป็นเพียงของประดับ เพราะสตริงความยาว 1–16 ตัวใดก็ตามจะถูกบันทึกและไป render บนแถบรีแอ็กชันของทุกคน ส่วนสตริงที่ยาวกว่านั้นจะชนVARCHAR(16)และกลายเป็น Postgres error 22001 ที่โผล่ออกมาเป็น 500 แทนที่จะเป็น 400 การตรวจสมาชิกในชุดจึงครอบทั้งกรณีสตริงว่างและยาวเกินไปในคราวเดียว - เรียก
verifyTarget(อธิบายด้านล่าง) - 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 เดียวกัน
- อ่านด้วย
- คืน emoji counts หลัง mutate (
ReactionCounts) เพื่อให้ client อัปเดตแถบรีแอ็กชันได้ทันที - ตารางรีแอ็กชันเป็นตารางเดียวใน package นี้ที่ใช้ hard delete ส่วนที่เหลือเป็น soft delete ทั้งหมด
รายงานเนื้อหา — POST /api/bulletin/:hash/reports (rate limit 5/60s)
Body: {targetType, targetId, reason, detail}
resolveAccess→CanReport()ซึ่งต้องเปิด settingallow_user_reportsก่อน แล้วจึงตรวจCanWrite()reasonต้องอยู่ใน allowlist แบบปิด ได้แก่spam,harassment,inappropriate,misinformation,otherมิฉะนั้นตอบ 400BULLETIN_INVALID_REASONเนื่องจากคอลัมน์เป็นVARCHAR(50) NOT NULLและคิว moderation ของ CMS switch ตามค่าเหล่านี้ เหตุผลที่ไม่รู้จักจะทำให้ UI render ข้อความของผู้โจมตีแบบดิบๆ ให้ moderator เห็น หรือหลุดออกจาก switchdetailที่ยาวเกิน 500 rune ตอบ 400BULLETIN_DETAIL_TOO_LONGเพราะคอลัมน์เป็น TEXT จึงไม่มี ขอบเขตจากฝั่ง DB ถ้าไม่ cap ที่ชั้นนี้ ผู้ใช้ที่ล็อกอินแล้วสามารถเขียนแถวขนาดไม่จำกัดเข้าหน้าจอแอดมินได้- เรียก
verifyTarget - insert ภายใน transaction พร้อม audit
report.createโดยอนุญาต 1 รายงานต่อคู่ (เป้าหมาย, ผู้รายงาน) ตลอดกาล บังคับด้วย unique index กรณีซ้ำจะตอบ 409"already reported"ซึ่งแปลงมาจากErrDuplicateReportที่ repository คืนมา ไม่ใช่การ check-then-insert ที่แข่งกันเอง - ตอบ 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"ตรวจด้วยvisiblePostForCallertargetType = "comment"ตรวจสองชั้น คือสถานะของคอมเมนต์เอง (commentVisibleToCaller) และ การมองเห็นของโพสต์แม่ (คอมเมนต์บนโพสต์ที่ถูกลบหรือซ่อนเข้าถึงไม่ได้ทุกทางอยู่แล้ว จึงต้องเข้าถึงไม่ได้ ที่นี่ด้วย) โดย 404 ของโพสต์แม่ถูกแปลงข้อความเป็น"comment not found"เพื่อให้ทั้งสองกรณี แยกออกจากกันไม่ได้targetTypeอื่นตอบ 400"invalid target type"- ทุกความล้มเหลวยุบเป็น 404 เดียวกัน
ไฟล์และฟังก์ชันหลัก
| Route | Rate limit | Handler |
|---|---|---|
PUT /api/bulletin/:hash/reactions | 30/60s | internal/bulletin/handler.go → (*Handler).SetReaction |
POST /api/bulletin/:hash/reports | 5/60s | (*Handler).CreateReport |
internal/bulletin/service.go—SetReaction,CreateReport,verifyTarget,commentVisibleToCaller,visiblePostForCallerinternal/bulletin/repository.go—FindReactionForUpdate,UpsertReaction,DeleteReaction,ReactionCounts,ReactionStates,InsertReport,ErrDuplicateReport,InsertAuditinternal/bulletin/entity.go—Reaction,Report,ReportReasons,validReason,MaxReportDetailLength = 500,Settings.AllowsEmoji
จุดเชื่อมต่อกับ Service อื่น
- ตารางฐานข้อมูล
bulletin.reaction(uniqueidx_bul_reaction_one),bulletin.report(uniqueidx_bul_report_onceและ triggerreport_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(แถบรีแอ็กชัน)