บอร์ดประกาศ - รายละเอียดและคอมเมนต์
ภาพรวม
หน้ารายละเอียดแสดงประกาศหนึ่งรายการแบบเต็ม พร้อมส่วนสนทนาด้านล่างที่ผู้ใช้ เข้ามาแสดงความคิดเห็นได้ ความคิดเห็นรองรับการอ้างอิงความคิดเห็นอื่น (quote) การแนบรูป การกดรีแอ็กชัน และการลบความคิดเห็นของตัวเอง
ข้อแตกต่างจากฟีดที่ควรทราบคือ ความคิดเห็นเรียงจาก เก่าไปใหม่ ตามธรรมชาติของ การสนทนา ในขณะที่ฟีดประกาศเรียงจากใหม่ไปเก่า แต่ทั้งสองใช้รูปแบบ cursor เหมือนกัน ดังนั้นการกด "โหลดเพิ่ม" จึงต่อท้ายรายการเดิมเหมือนกันทั้งคู่
Business Flow
- ผู้ใช้เปิด
/{hash}/bulletin/{postId}— ระบบตรวจก่อนว่ารหัสประกาศเป็นจำนวนเต็มบวก ถ้าไม่ใช่จะตอบเป็นหน้าไม่พบข้อมูลทันที - ยืนยันตัวตนด้วยกลไกเดียวกับหน้าฟีด แล้วโหลดข้อมูลสามชุดพร้อมกัน: การตั้งค่าบอร์ดและหมวดหมู่ ตัวประกาศ และรายการความคิดเห็น (cursor pagination ครั้งละ 20 รายการ)
- เรนเดอร์ตัวประกาศแบบเต็ม: หัวข้อเป็นหัวเรื่อง ต่อด้วยบรรทัดผู้เขียน เนื้อหาเต็ม รูปภาพที่แนบมา และแถบรีแอ็กชัน
- รายการความคิดเห็นแต่ละรายการแสดง:
- ป้ายผู้เขียนแบบไม่ระบุตัวตน
- หากเป็นการอ้างอิงความคิดเห็นอื่น จะแสดงข้อความย่อ (ไม่เกิน 120 ตัวอักษร) ของความคิดเห็นต้นทาง หากต้นทางถูกลบหรือถูกซ่อนไปแล้ว จะแสดงสถานะว่าถูกลบ เหมือนกันทุกกรณี เพื่อไม่ให้เนื้อหาที่ถูกลบรั่วผ่านการ quote
- แตะที่ข้อความอ้างอิงเพื่อเลื่อนไปยังความคิดเห็นต้นทาง หากยังอยู่ในหน้า
- ปุ่มอ้างอิง และปุ่มลบซึ่งจะแสดงเฉพาะความคิดเห็นของตัวเอง
- ช่องเขียนความคิดเห็นจะแสดงเมื่อประกาศนั้นเปิดรับความคิดเห็น และผู้ใช้ไม่ได้ถูกบล็อก
- จำกัดความยาวตามค่าที่ตั้งไว้ในบอร์ด
- จำนวนรูปที่แนบได้ในความคิดเห็นเป็นค่าคนละตัวกับของประกาศ (ค่าเริ่มต้นคือ 2 รูป)
- เมื่อส่งสำเร็จ ระบบจะโหลดทั้งรายการความคิดเห็นและตัวประกาศใหม่ เพื่อให้ตัวเลขจำนวนความคิดเห็นตรง
- หากประกาศปิดรับความคิดเห็น จะแสดงข้อความแจ้งแทนช่องเขียน
- การลบความคิดเห็นเรียก endpoint ที่อ้างอิงด้วยรหัสความคิดเห็นโดยตรง ไม่ได้อยู่ใต้เส้นทางของประกาศ
- การจัดการ error: สถานะ 404 ครอบคลุมทุกกรณีที่ผู้ใช้ "มองไม่เห็น" ประกาศนั้น ไม่ว่าจะถูกลบ ถูกซ่อน ไม่ใช่ของตน หรืออยู่คนละหมวดที่ไม่มีสิทธิ์ ส่วนสถานะ 401 หมายถึงเซสชันหมดอายุ
- มีปุ่มย้อนกลับไปที่หน้าบอร์ดอยู่ในหน้า เพื่อไม่ต้องพึ่งปุ่ม back ของเบราว์เซอร์
หน้าจอและองค์ประกอบหลัก
- หน้ารายละเอียด (
src/app/[hash]/bulletin/[postId]/page.tsx) — จุดเข้าและ การตรวจความถูกต้องของรหัสประกาศ - ตัวควบคุมหน้า (
post-detail.container.tsx) — ประสานการโหลดข้อมูลสามชุด จัดการสถานะการอ้างอิง และการลบความคิดเห็น - การ์ดประกาศ (
PostSheet) — ใช้ตัวเดียวกับฟีด แต่สลับเป็นโหมดรายละเอียด ซึ่งแสดงเนื้อหาเต็มและใช้หัวข้อเป็นหัวเรื่องของหน้า - รายการความคิดเห็น (
CommentList) — รวมการแสดงข้อความอ้างอิงและปุ่มการกระทำ - ช่องเขียนความคิดเห็น (
CommentComposer) — พร้อมตัวนับความยาวและการแนบรูป - องค์ประกอบร่วม — ตะแกรงรูป ป้ายผู้เขียน แถบรีแอ็กชัน และหน้าจอสถานะ ใช้ร่วมกับหน้าฟีดทั้งหมด
ฝั่งข้อมูลใช้ hook useBulletinPost และ useBulletinComments โดยเรียกผ่าน
service ตัวเดียวกับหน้าฟีด
Endpoint ที่ใช้ในหน้านี้
| Method | Path |
|---|---|
| GET | /bulletin/{hash}/posts/{id} |
| GET | /bulletin/{hash}/posts/{id}/comments?cursor=&limit= |
| POST | /bulletin/{hash}/posts/{id}/comments |
| DELETE | /bulletin/{hash}/comments/{commentId} |
เส้นทางลบความคิดเห็นอยู่ใต้ /comments โดยตรง ไม่ได้อยู่ใต้ /posts
เพราะรหัสที่ส่งไปคือรหัสของความคิดเห็น ไม่ใช่รหัสประกาศ
จุดเชื่อมต่อกับฟีเจอร์อื่น
- ใช้โครงการยืนยันตัวตน ธีม และการแปลง error ชุดเดียวกับ bulletin-board
- ปุ่มรายงานเนื้อหาและเมนูของเจ้าของโพสต์อยู่ในชั้นสิทธิ์และการจัดการเนื้อหา (ดู bulletin-moderation)
- การอัปโหลดไฟล์ — ใช้กับการแนบรูปในความคิดเห็น (ดู file-upload)
- มีชุดทดสอบครอบคลุมรายการความคิดเห็น ช่องเขียน และการอ้างอิงความคิดเห็น
รายละเอียดฝั่ง Backend (Client API)
การอ่านเธรดคอมเมนต์
GET /api/bulletin/:hash/posts/:id/comments
- ผ่าน
resolveAccess→CanView()→ แล้ว ตรวจการมองเห็นตัวโพสต์ก่อนเสมอ — โพสต์ที่ผู้เรียกมองไม่เห็นตอบ 404 ไม่ใช่รายการคอมเมนต์ว่าง (รายการว่างจะบอกใบ้ว่า โพสต์นั้นมีอยู่จริง) - cursor และ limit ทำงานเหมือนฟีด (default 20) และ cursor ที่พังรูปถูกยอมรับเป็น "เริ่มจากต้น" ไม่ใช่ 400
- SQL คืน คอมเมนต์ที่ published รวมกับคอมเมนต์
pendingของผู้เรียกเอง (เป็น UNION ALL) ส่วน hidden/deleted ไม่มีใครเห็น — ผู้ใช้ที่โพสต์ในบอร์ดที่ต้องอนุมัติจึงยังเห็นคอมเมนต์ ของตัวเองที่รออนุมัติอยู่ - คอมเมนต์เรียง ASC ดังนั้น cursor คือแถวสุดท้ายของหน้า และ response เป็น object
ที่มี
nextCursorไม่ใช่ array เปล่า เพราะ cursor เป็น opaque ถ้าไม่ส่งมา client ไปหน้า 2 ไม่ได้
การ resolve quote — ทำฝั่ง server แบบ batch
นี่คือจุดออกแบบเด่นของ endpoint นี้: ข้อความย่อของคอมเมนต์ที่ถูกอ้างอิงถูก resolve ที่ server ด้วย query เดียวต่อหนึ่งหน้า ไม่ให้ client จับคู่เอง
- รวบรวม id ของ quote ทั้งหน้าแล้ว dedupe (คอมเมนต์ยอดนิยมมักถูก quote หลายครั้งในหน้าเดียว) แล้วดึงทีเดียว — กัน N+1 ที่จะเป็น 20 round trip ในเธรดที่คุยกันสนุก
- เหตุที่ต้องทำฝั่ง server: client เดิมจับคู่กับคอมเมนต์ที่ตัวเองมีอยู่ในหน้า ทำให้ quote ที่เป้าอยู่หน้าก่อนไม่ render และแย่กว่านั้นคือ render สำเนาเก่าของคอมเมนต์ที่ถูกลบไปแล้ว
- เป้าที่ถูก soft-delete / ถูกซ่อนโดย moderator / อยู่ tenant อื่น / หายไปเลย ถูกยุบเป็น สถานะเดียวกัน คือ "ถูกลบ" พร้อมข้อความย่อว่าง เพื่อให้ client พูดว่า "คอมเมนต์นี้ถูกลบ" ได้โดยไม่ต้องเดา และไม่รั่วข้อมูลว่ากรณีไหนเป็นกรณีไหน
- quote ใช้ predicate การมองเห็น ตัวเดียวกับ listing จึงไม่เคยเปิดเผยคอมเมนต์ที่ผู้เรียก อ่านในเธรดไม่ได้
- โครงข้อมูล quote ที่ส่งออกมีเพียง id, ชนิดผู้เขียน, ธง "เป็นของฉัน" และข้อความย่อ
ไม่มี field ใดที่พา
author_line_user_idออกไปได้ และตัดข้อความที่ 120 rune
การเขียนคอมเมนต์
POST /api/bulletin/:hash/posts/:id/comments — rate limit 10 ครั้ง/60 วินาที
- ใช้
CanComment()ซึ่ง ต่างจากCanWrite(): block ทั้งสอง scope ห้ามคอมเมนต์ (คนที่ถูก mute เฉพาะคอมเมนต์ยังโพสต์และกดรีแอ็กชันได้ แต่คอมเมนต์ไม่ได้) - ตรวจการมองเห็นโพสต์ → 404 ถ้ามองไม่เห็น ต้องเป็น 404 ไม่ใช่การเปิดเผยว่าโพสต์ปิดคอมเมนต์
- โพสต์ที่ปิดคอมเมนต์ → 403
BULLETIN_COMMENTS_CLOSED - เนื้อหาที่ trim แล้วว่าง → 400
BULLETIN_EMPTY_BODY(ขอบล่างที่ระบบเดิมไม่มี — คอมเมนต์ว่างคือแถวเปล่าในเธรดของทุกคน); ยาวเกินค่าmax_comment_length→BULLETIN_BODY_TOO_LONG - รูปเกิน
max_images_per_comment(default 2 เป็น setting แยกจากฝั่งโพสต์ที่ default 4 โดยเจตนา — บอร์ดที่ขยายด้านหนึ่งต้องไม่ขยายอีกด้านโดยไม่รู้ตัว) →BULLETIN_IMAGE_LIMIT - คอมเมนต์ที่ถูก quote ต้อง มีอยู่, อยู่บนโพสต์นี้, และ published ไม่งั้น 400
"invalid quote comment"และการค้นเป็น tenant-scoped - สถานะเริ่มต้นขึ้นกับ
require_comment_approval: เปิดไว้ →pendingไม่งั้นpublished - ทำงานใน transaction เดียว: insert คอมเมนต์ → commit รูปจาก
temp/ไป path ถาวร (ต้องหลัง insert เพราะ key ถาวรมี comment id อยู่ในนั้น) → อัปเดตรายการรูป → เขียน audit log - คืนคอมเมนต์ที่ resolve quote มาให้แล้ว สถานะ 201 — client จึงได้รูปข้อมูลเดียวกับที่ได้ จาก listing ไม่ต้องมี special case
การลบคอมเมนต์
DELETE /api/bulletin/:hash/comments/:id
- gate ด้วย
CanComment()ไม่ใช่CanWrite()— คนที่ถูก block scope คอมเมนต์ควรถูกล็อก ออกจาก action ของคอมเมนต์ทั้งหมด รวมถึงการลบของตัวเอง - ความล้มเหลวทุกแบบ (ไม่มีคอมเมนต์นั้น / อยู่คนละ OA / ไม่ใช่ของตัวเอง) ตอบ 404 เหมือนกันหมด
- เป็น soft delete พร้อม audit log ไม่ใช่ SQL DELETE แล้วตอบ 204