Apps — Bulletin
ภาพรวม
Bulletin เป็นหนึ่งใน "Apps" — โมดูลเสริมที่ทำงานด้วยระบบสิทธิ์ คนละแบบกับเมนูอื่นทั้งหมดในระบบ เมนูปกติ (เช่น Campaign) ถูกคุมด้วย ModuleGate ที่ผูกกับการเปิด/ปิดโมดูลให้องค์กร แต่ Apps ถูกคุมด้วย AppEnabledGuard ซึ่งอ่านตาราง line_oa_app แถวเดียวว่า "องค์กรนี้ได้รับอนุญาตให้ใช้แอปชื่อ bulletin หรือไม่" — ถ้าไม่มีแถว หรือแถวนั้น enabled = false ทุกเส้นทางของแอปตอบ 403 ทันที และสวิตช์เปิด/ปิดตัวนี้ องค์กรกดเองไม่ได้ ต้องเป็นผู้ดูแลระดับแพลตฟอร์มเท่านั้น (ดู สิทธิ์การเข้าถึง)
หน้าที่ของแอปคือทำบอร์ดปิดประกาศแบบฟอรัมให้สมาชิก LINE OA เข้าอ่าน โพสต์ และแสดงความคิดเห็นผ่านเว็บฝั่งลูกค้า (client-web / LIFF) ส่วนฝั่ง CMS นี้เป็นเครื่องมือกำกับดูแลเนื้อหา (moderation) ครบชุด 7 แท็บ — ประกาศ, ความคิดเห็น, รายงาน, หมวดหมู่, สมาชิก, ตั้งค่า, กิจกรรม
บทบาทที่ใช้งาน — แอดมินหรือผู้กำกับดูแลเนื้อหาบอร์ด — เมนูนี้จะปรากฏก็ต่อเมื่อผู้ดูแลระดับแพลตฟอร์มเปิดแอปนี้ให้องค์กรแล้วเท่านั้น
เส้นทางผู้ใช้งาน
1. ตรวจประกาศที่รออนุมัติ (คิวตรวจ)
- เปิดเมนู Apps > Bulletin — แท็บ ประกาศ / ความคิดเห็น / รายงาน มีป้ายตัวเลขบอกจำนวนที่รอตรวจ (ดึงจาก
GET /apps/bulletin/pending-countที่คืนค่า 3 ตัวแยกกัน) - เข้าแท็บประกาศ เลือกสถานะ "รอตรวจ" — หน้าจอสลับจากตารางเป็น มุมมองคิวตรวจ และเปลี่ยนวิธีดึงข้อมูลเป็นหน้าเดียว
page=1, limit=100แทนการแบ่งหน้า - ฝั่งซ้ายเป็นรายการรอตรวจ ฝั่งขวาแสดงประกาศเต็มใบพร้อมธีมจริงของบอร์ด ใช้ปุ่มลัดคีย์บอร์ดได้ทันที — J / K เลื่อนลง/ขึ้น, A อนุมัติ, H ซ่อน, X ลบ (ลบเท่านั้นที่ถามยืนยัน อนุมัติกับซ่อนถือว่าย้อนกลับได้)
- จัดการรายการหนึ่งเสร็จ รายการนั้นหายจากคิว แต่ตำแหน่งที่เลือกไว้ไม่ขยับ — ระบบไม่ได้เลื่อน index ขึ้นหนึ่ง จึงกลายเป็นรายการถัดไปโดยอัตโนมัติ ทำงานต่อได้ต่อเนื่องไม่ต้องกระโดดกลับไปหัวคิว
2. จัดการความคิดเห็นและรายงานจากผู้ใช้
- สลับไปแท็บความคิดเห็น — เป็นคิวระดับบอร์ดทั้งใบ (ไม่ใช่แยกตามประกาศ) กรองด้วยสถานะและรหัสประกาศได้
- สลับไปแท็บรายงาน ดูรายงานที่ยังเปิดอยู่ คลิกขยายแถวเพื่อดูเนื้อหาจริงที่ถูกรายงาน แล้วตัดสินเป็น resolved หรือ dismissed
- การตัดสินรายงาน ไม่แตะเนื้อหาที่ถูกรายงานเลย — ถ้าต้องซ่อนหรือลบประกาศ/ความคิดเห็นนั้นต้องสั่งแยกอีกครั้งที่แท็บของมัน
3. ตั้งค่าบอร์ดและหมวดหมู่
- เข้าแท็บหมวดหมู่ สร้างหมวดพร้อมกำหนด สิทธิ์การดู (สืบทอดจากบอร์ด
inherit/ จำกัดกลุ่มrestrictedโดยเลือกกลุ่มเป้าหมายจากเมนู Segment audience) และ สิทธิ์การเขียน (member/admin) ซึ่งเป็นสองแกนที่แยกจากกัน — ตั้ง "เฉพาะแอดมิน" ไม่ได้ซ่อนหมวดหมู่ สมาชิกยังอ่านได้ - เข้าแท็บตั้งค่าบอร์ด ปรับสิทธิ์การเข้าถึง (
view_access/write_accessซึ่งเป็น เซ็ต ของmember/guestไม่ใช่ค่าเดี่ยว), กฎการกำกับดูแล (require_post_approval/require_comment_approval), ค่าจำกัด (รูปต่อประกาศ 1–9, ความยาวเนื้อหาและความคิดเห็น 1–10,000, รีแอกชันไม่เกิน 8 ตัว) และธีมสีของบอร์ด - ธีมมีด่านตรวจค่าความต่างของสีตามมาตรฐาน WCAG ก่อนบันทึกเสมอ — ไม่ผ่านคือ บันทึกไม่ได้ ไม่ใช่แค่เตือน
- คัดลอกลิงก์บอร์ด (เว็บหรือ LIFF) จากการ์ดแชร์ลิงก์ไปเผยแพร่ให้สมาชิก
4. บล็อกสมาชิกและตรวจสอบย้อนหลัง
- เข้าแท็บสมาชิกที่ถูกบล็อก บล็อกผู้ใช้รายคน — ระบุขอบเขต (
allทั้งหมด หรือcommentเฉพาะความคิดเห็น), เหตุผลภายใน (ไม่บังคับ) และวันหมดอายุ (ไม่กรอก = ถาวร) - ยกเลิกการบล็อกที่มีอยู่ได้ — สถานะในตาราง (ใช้งานอยู่ / หมดอายุ / ยกเลิกแล้ว) คำนวณจากข้อมูล ไม่ใช่คอลัมน์แยก
- เข้าแท็บบันทึกการตรวจสอบย้อนหลังเพื่อดูประวัติการกระทำทั้งหมดของทีมงาน กรองตามประเภทเป้าหมาย (post/comment/report/block/category/board), รหัสเป้าหมาย และประเภทการกระทำ (22 ค่า) ได้
รายละเอียดสำคัญที่ตรวจสอบจากซอร์สโค้ดจริง
ยืนยันจาก internal/modules/bulletin/{controller,dto,service_board,service_moderation,service_safety,audit}.go และ internal/modules/apps/{guard,service}.go ของ line-management-cms-api-go:
- บอร์ดถูกสร้างให้เองตอนเปิดใช้ครั้งแรก และการสร้างนั้นกันการชนกันไว้แล้ว — ทุก endpoint เรียก
ensureBoardซึ่งรันINSERT INTO bulletin.board (line_oa_id, organization_id) ... ON CONFLICT (line_oa_id) DO NOTHINGเป็น SQL ดิบ (ไม่ใช่ GORM Create) โดยเจตนา เพราะโครงสร้างแบบ struct จะเขียนค่าศูนย์ทับค่า DEFAULT ของคอลัมน์name/settings/statusและเป็นON CONFLICTเพราะ cms-api กับ client-api อาจยิงเข้า OA ใหม่พร้อมกัน จุดที่ละเอียดกว่านั้นคือการ select กลับมาถูก scope ด้วย organization_id ด้วย — ถ้า LINE OA นั้นมีบอร์ดอยู่แล้วใต้อีกองค์กร คำสั่ง INSERT จะไม่ทำอะไรและการ select จะพลาด ผู้เรียกได้ 404 ไม่ใช่บอร์ดขององค์กรอื่น - ระบบ "ตราประทับสถานะ" (stamp state) เป็นสัญญาข้ามรีโปที่ล็อกไว้ด้วยชุดทดสอบร่วมกัน — ไฟล์
src/app/apps/bulletin/lib/stamp.tsถูกคัดลอกแบบตัวต่อตัวไปอยู่ในline-management-client-webด้วย และทั้งสองรีโปมีเทสต์tests/bulletin-stamp-contract.test.tsที่รัน fixture ชุดเดียวกัน (tests/fixtures/bulletin-stamp-states.json) กับไฟล์นั้น — แก้กฎในรีโปหนึ่ง เทสต์ของอีกรีโปพัง ซึ่งเป็นเจตนาทั้งหมดของการทำแบบนี้ ตัวกฎเองมีลำดับความสำคัญที่ตั้งใจ: สถานะการกำกับดูแลสำคัญกว่าชนชั้นของผู้เขียน — ประกาศทางการที่ถูกซ่อนจะประทับว่า "ซ่อน" ไม่ใช่ "ทางการ" เพราะคำถามว่า "ยังเผยแพร่อยู่ไหม" เร่งด่วนกว่า และการสลับลำดับจะทำให้ประกาศที่ถูกระงับยังสวมอำนาจของ OA อยู่ ส่วนสถานะpending/hidden/deletedมองเห็นได้เฉพาะแอดมิน (และเจ้าของประกาศในกรณีpending) คนอื่นได้ค่าnoneคือไม่รู้ว่ามีอยู่ - ด่านตรวจสีของธีมตรวจ "โทเคนหลังคลี่" ไม่ใช่ค่าที่ผู้ใช้กรอก — ธีมมีชั้นทับซ้อน
preset < accent/official_accent < tokensและvalidateTokenContrastทำงานกับผลลัพธ์หลังคลี่ทุกชั้นแล้ว เพราะถ้าตรวจเฉพาะค่าที่กรอก ด่านจะทำงานก็ต่อเมื่อกรอกครบทั้งคู่สี — ส่ง{"tokens":{"ink":"#777777"}}มาตัวเดียวจะผ่านฉลุยแล้วไปแสดงผลที่ 4.48:1 บนกระดาษขาวของ preset คู่สีที่ตรวจมี 3 คู่เท่านั้น (paper/ink,paper/inkSoft,board/ink) และboard/inkSoftถูกตัดออก โดยเจตนา เพราะไม่มีเนื้อความไหนใช้สีหมึกรองบนพื้นบอร์ดโดยตรง และถ้ารวมเข้ามาจะปฏิเสธ preset ค่าเริ่มต้นstaff-roomของตัวสินค้าเองที่ 4.12:1 — ด่านที่ปฏิเสธค่าเริ่มต้นของตัวเองคือด่านที่แอดมินจะหาทางเลี่ยง เกณฑ์คือ 4.5:1 ตาม WCAG 2.1 AA และตรวจซ้ำทั้งฝั่งหน้าเว็บ (หยุดก่อนยิง PUT) และฝั่ง backend - เพดานปักหมุด 5 รายการบังคับที่ "การเขียน" ไม่ใช่ "การอ่าน" และล็อกแถวบอร์ดไว้ก่อนนับ —
SetPinnedเปิดทรานแซกชัน แล้วSELECT ... FOR UPDATEแถวบอร์ดก่อนนับจำนวนที่ปักหมุดอยู่ เพราะแอดมินสองคนกดปักหมุดพร้อมกันจะอ่านได้ 4 ทั้งคู่แล้วเขียนทั้งคู่ เหลือ 6 รายการบนแถบที่แสดงได้ 5 ถ้าจำกัดแค่ตอนดึงข้อมูล การปักหมุดใบที่ 6 จะสำเร็จ รายงานว่าสำเร็จ แล้วไม่ปรากฏ — ซึ่งเป็นความล้มเหลวแบบที่แอดมินไม่มีทางรู้ว่าเกิดขึ้น เมื่อชนเพดานจะได้ 400 รหัสBULLETIN_PIN_LIMIT - ประกาศที่แอดมินเขียนเองข้ามคิวอนุมัติทั้งขาสร้างและขาแก้ไข —
newPostRowตั้งstatus = 'published'แม้บอร์ดเปิดrequire_post_approvalไว้ เพราะแอดมิน คือ ผู้อนุมัติ และpostStatusAfterEditแยกสองสายชัดเจน: การแก้ไขโดยแอดมินไม่เคยลดสถานะประกาศลง มีแต่การแก้ไขโดยเจ้าของประกาศจาก LIFF เท่านั้นที่ดึงประกาศที่เผยแพร่แล้วกลับเข้าคิว — โค้ดสองสายนี้อยู่ในฟังก์ชันเดียวกันเพื่อให้พฤติกรรมของ CMS เป็น "การเลือกที่มองเห็นได้" ไม่ใช่ "การไม่มีโค้ด" - หมวดหมู่แบบ
restrictedที่ไม่ได้เลือกกลุ่มเลย = เปิดให้สมาชิกทุกคน ไม่ใช่ปิดทุกคน — เป็นกฎที่กลับทางง่ายที่สุด backend จึงคืนฟิลด์audienceScopeที่คลี่ให้แล้วมาด้วยเพื่อไม่ให้ CMS ต้องคำนวณซ้ำ และฝั่งหน้าเว็บปฏิเสธการบันทึก "จำกัดกลุ่ม + ไม่เลือกอะไร" ตั้งแต่ก่อนยิง API อีกชั้น อีกจุดคือการเทียบaudience_idsทำที่ ค่าที่ถอดออกมาแล้ว ไม่ใช่ไบต์ที่ marshal เพราะคอลัมน์เป็น jsonb ที่ไม่รักษาข้อความต้นฉบับ ('[3,7]'::jsonb::textกลายเป็น[3, 7]) ถ้าเทียบไบต์ ทุกหมวดที่มีกลุ่มเป้าหมายจะถูกรายงานว่า "เปลี่ยน" ทุกครั้ง และการกดบันทึกฟอร์มเดิมซ้ำจะสร้างแถวcategory.editผี ๆ ในบันทึกการตรวจสอบ นอกจากนี้การลบหมวดหมู่ ไม่ลามไปหาประกาศ — ประกาศในหมวดที่ถูกลบถือว่าไม่มีหมวดและยังแสดงอยู่ตามสิทธิ์ระดับบอร์ด - การบล็อกซ้ำและการยกระดับการบล็อกต้อง "เพิกถอนของเดิมก่อน" เพราะช่องของ unique index —
idx_bul_block_activeเป็น UNIQUE (board_id, line_user_id) WHERE revoked_date IS NULL ซึ่งหมายความว่า บล็อกที่หมดอายุแล้วก็ยังครองช่องนั้นอยู่ (เพราะrevoked_dateยังเป็น NULL) และบล็อกขอบเขตcommentครองช่องเดียวกับที่บล็อกallต้องใช้CreateBlockจึงเพิกถอนแถวเดิมก่อนแล้วค่อย insert ในทรานแซกชันเดียวกัน — insert ก่อนคือถูก index ปฏิเสธไปแล้ว, เพิกถอนแยกทรานแซกชันคือถ้าพังกลางทางผู้ใช้จะหลุดบล็อกโดยไม่มีร่องรอย และการเพิกถอนอัตโนมัตินี้ถูกบันทึกเป็นค่า "before" ของblock.createไม่ใช่block.revokeที่ผู้กำกับดูแลไม่ได้สั่ง - การปิดรายงานไม่แตะโพสต์เลย และนั่นเป็นข้อบังคับเรื่อง deadlock ไม่ใช่แค่การออกแบบ UX — ทรานแซกชันเปลี่ยนสถานะรายงานทำให้ trigger ที่คำนวณ
report_countไปจับ advisory lock ของเป้าหมาย (4201 = post, 4202 = comment) อยู่แล้ว ถ้าโค้ดในทรานแซกชันเดียวกันไปแก้แถวโพสต์อีก นั่นคือลำดับการล็อกตรงข้ามที่ทำให้ deadlock พอดี นอกจากนี้เงื่อนไขstatus = 'open'ถูกเขียนซ้ำใน WHERE ของคำสั่ง UPDATE ไม่ใช่แค่ตรวจก่อนหน้า เพื่อไม่ให้ผู้กำกับดูแลสองคนที่เคลียร์คิวพร้อมกันเขียนทับการตัดสินของกัน - โมดูลนี้มี 23 เส้นทาง และเอกสารสเปกเดิมที่เขียนว่า 24 ถูกตัดสินว่าเป็นการนับพลาด — ส่วนหัวของ
controller.goระบุไว้ตรง ๆ ว่าตารางในสเปกเองแสดง 23 แถว และยืนยันซ้ำได้จากจำนวนเมธอดของ service ทั้งสามตัว (BoardService 7 + ModerationService 10 + SafetyService 6 = 23) จึงเลือก ไม่ เติมเส้นทางที่ไม่มีเมธอดรองรับเข้ามาให้ครบ 24 - ค่าจำกัดรีแอกชันผูกกับความกว้างของคอลัมน์จริง — ไม่เกิน 8 ตัวต่อบอร์ด และแต่ละตัวยาวไม่เกิน 16 อักขระ เพราะคอลัมน์
bulletin.reaction.emojiเป็น VARCHAR(16) — อีโมจิที่ client-api เก็บกลับไม่ได้ไม่ใช่ตัวเลือกที่ใช้งานได้จริง จึงถูกปฏิเสธที่นี่ - คำค้นหาถูกหนีอักขระ wildcard ของตัวมันเอง —
likeEscapeแปลง\,%,_ก่อนประกอบ LIKE ถ้าไม่ทำ การค้นคำว่า "50%" จะแมตช์ทุกประกาศบนบอร์ด
เมนูที่เกี่ยวข้องกันในเส้นทางนี้
- Content Management — แอปนี้ ไม่มี endpoint อัปโหลดรูปของตัวเอง ตัวเขียนประกาศเรียกใช้บริการอัปโหลดของโมดูล Content Management โดยตรง (
CONTENT_PAGE_SERVICE.uploadImage→POST /content-pages/upload-imageส่งเป็น multipart ฟิลด์ชื่อimage) จำนวนรูปที่อัปโหลดได้จำกัดตามค่าmax_images_per_postของบอร์ดเอง - Segment audience — เป็นแหล่งของกลุ่มเป้าหมายที่ใช้จำกัดสิทธิ์การดูหมวดหมู่ (โหมด
restricted) - Apps (คอนโซลผู้ดูแลแพลตฟอร์ม) — เป็นที่เดียวที่เปิด/ปิดแอปนี้ให้องค์กรได้ องค์กรกดเองไม่ได้
จุดที่ควรระวัง
- ประกาศที่แอดมินเขียนเองจะเผยแพร่ทันทีเสมอ แม้บอร์ดจะเปิดโหมดต้องอนุมัติก่อนไว้ก็ตาม — และการที่แอดมินแก้ประกาศของสมาชิกก็ไม่ดึงประกาศนั้นกลับเข้าคิวด้วย
- ป้ายตัวเลขบนแท็บไม่รีเฟรชทันทีหลังจัดการรายการ — แต่ละหน้ามีสำเนา hook นับจำนวนของตัวเอง หน้าที่กดจัดการจะรีเฟรชสำเนาของตัวเองแต่ไม่ได้รีเฟรชของแถบแท็บ ตัวเลขจะตรงอีกครั้งเมื่อสลับหน้า
- ถ้าแอปถูกปิด หน้าจอจะไม่ขึ้นข้อความว่า "ถูกปิด" —
pending-countที่ตอบ 403 ถูกกลืนเป็นค่า 0 ทั้งสามตัวโดยเจตนา ป้ายจึงหายไปเงียบ ๆ ไม่ใช่ขึ้นแบนเนอร์ผิดพลาด - ข้อความเตือนเรื่องค่าความต่างของสีเป็นภาษาอังกฤษไม่แปล โดยเจตนา เพื่อให้ตรงกับข้อความที่ backend ตอบกลับแบบตัวต่อตัว (เช่น
...has a contrast ratio of 4.12:1, below the 4.5:1 minimum for body text) — ถ้าแปลจะไล่ไม่ได้ว่าเป็นข้อความเดียวกัน - ปักหมุดได้ไม่เกิน 5 รายการต่อบอร์ด ต้องถอนหมุดของเดิมก่อน
- การยกเลิกบล็อกเป็นการเพิกถอน ไม่ใช่การลบข้อมูลจริง ประวัติว่าใครถูกบล็อกโดยใคร นานเท่าไหร่ ยังตรวจสอบย้อนหลังได้เสมอ และการเพิกถอนซ้ำเป็น no-op ไม่เขียนทับเวลา/ผู้เพิกถอนของครั้งแรก
- การล้างค่า "ใครอ่านบอร์ดได้" จะซ่อนบอร์ดจากทุกคนรวมถึงสมาชิก — เป็นพฤติกรรมที่อนุญาตโดยเจตนา เพราะเป็นวิธีปิดบอร์ดชั่วคราวโดยไม่ต้องลบ
- บันทึกการตรวจสอบย้อนหลังไม่มีการกระทำ
comment.edit— จาก 22 ค่าที่มี การแก้ไขความคิดเห็นไม่ถูกบันทึกเป็นการกระทำแยก
สิทธิ์การเข้าถึง (ตรวจสอบจากซอร์สโค้ดจริง)
- ฝั่งเซิร์ฟเวอร์ —
AppEnabledGuardถูกต่อเข้าไปจริงในทุกเส้นทางของโมดูลนี้ ยืนยันจากbulletin/controller.goที่สร้างappEnabled := apps.AppEnabledGuard(d)แล้วใส่เป็น middleware ตัวแรกของทั้ง 23 เส้นทางแบบครบทุกบรรทัด ไม่มีเส้นทางใดหลุด ตัว guard เอง (apps/guard.go) ดึงชื่อแอปจาก path ด้วย regex/apps/(\w+)แล้วเรียกIsAppEnabled(orgID, "bulletin")ซึ่งหาแถวในตารางline_oa_appที่organization_id = ? AND app_id = ? AND enabled = true— ไม่พบแถว = ปิด (ไม่ใช่ค่าเริ่มต้นเป็นเปิด) และตอบ 403 ข้อความApp "bulletin" is not enabled for this organizationข้อสังเกตสำคัญ: guard นี้ scope ด้วย organization_id เท่านั้น ไม่ได้แยกตาม LINE OA — เปิดแอปให้องค์กรคือเปิดให้ทุก OA ในองค์กรนั้น - การเปิด/ปิดแอปเป็น endpoint สำหรับผู้ดูแลระดับแพลตฟอร์มเท่านั้น —
PUT /api/apps/:appIdถูกครอบด้วยmodulegate.PlatformOnly(d)ซึ่งอ่านuser.typeของผู้เรียกจากฐานข้อมูลและปฏิเสธผู้ใช้ประเภทcustomerทันที คอมเมนต์ในโค้ดระบุเหตุผลตรง ๆ ว่าถ้าองค์กรสลับแอปของตัวเองได้ ก็แค่เปิดสิ่งที่ผู้ดูแลแพลตฟอร์มยึดไว้กลับคืนได้เอง ซึ่งทำให้การกำกับดูแลจากคอนโซลกลายเป็นเพียงคำแนะนำ และมี ความไม่สมมาตรที่ตั้งใจ:ModuleGateของโมดูลปกติ fail open (หาข้อมูลไม่ได้ก็ปล่อยผ่าน เพราะการล็อกองค์กรออกจากโมดูลที่มีสิทธิ์ใช้เป็นความเสียหายที่ยอมรับไม่ได้) แต่PlatformOnlyfail closed (ระบุตัวผู้เรียกไม่ได้ = ถือเป็น customer = ปฏิเสธ) เพราะความเสี่ยงที่นี่คือการยกระดับสิทธิ์ ไม่ใช่การถูกล็อกออก - policy annotation ระดับการกระทำที่เห็นในโค้ดไม่ได้บังคับอะไรเลย — ทุกเส้นทางมี
auth.CheckPolicies(auth.Can(auth.PolicyRead/ReadAll/Create/Update/Delete, auth.PolicyModuleLineOa))ประกาศไว้ครบ แต่auth.CheckPoliciesในinternal/modules/auth/policy.goเป็น no-op จริง ๆ (คอมเมนต์ในไฟล์เขียนว่า "Enforcement disabled — allow all authenticated requests (TS parity)") — จึงต้องเข้าใจให้ตรงว่า การบังคับจริงของโมดูลนี้มีชั้นเดียว คือAppEnabledGuardระดับแอป ไม่มีModuleGateแบบที่โมดูล Campaign มี และไม่มีการแยกสิทธิ์ระหว่างคนอ่านกับคนอนุมัติ — ใครเข้าถึงแอปได้ ทำได้ทุกอย่างในแอป - การแยกข้อมูลระหว่างองค์กรถูกบังคับที่ชั้น query ไม่ใช่ชั้นสิทธิ์ — ทุก query ในโมดูลนี้ต้องผ่าน
repo.scoped/repo.txที่บีบทั้งorganization_idและline_oa_idและไฟล์scope_test.goเป็น AST guard ที่ทำให้ build พังถ้ามีไฟล์อื่นในแพ็กเกจอ้างถึง gorm handle ดิบ ผลคือ id ที่ผู้เรียกส่งมา (หมวดหมู่, รายงาน, บล็อก) ถูกตรวจว่า "เป็นของเรา" ไม่ใช่ "มีอยู่จริง" และถ้าไม่ใช่ของเราจะได้ 404 ไม่ใช่ 403 โดยเจตนา เพราะ 403 คือการยืนยันว่าแถวนั้นมีอยู่ในที่ที่ผู้เรียกมองไม่เห็น
ไฟล์อ้างอิงจากซอร์สโค้ด
Frontend (cms-web-2026)
src/app/apps/bulletin/layout.tsx— แถบ 7 แท็บและป้ายตัวเลขที่รอตรวจsrc/app/apps/bulletin/posts/—page.tsx(สลับคิวตรวจ/ตาราง),triage-pane.tsx(ปุ่มลัด J/K/A/H/X และการเลื่อนคิว),post-table.tsx,post-composer.modal.tsx(อัปโหลดรูปผ่าน Content Management)src/app/apps/bulletin/settings/—page.tsx(ด่านตรวจสีก่อนบันทึก),theme-editor.tsx,phone-preview.tsxsrc/app/apps/bulletin/{comments,reports,categories,members,activity}/page.tsx— คิวความคิดเห็น, รายงาน, หมวดหมู่, สมาชิกที่ถูกบล็อก, บันทึกการตรวจสอบย้อนหลังsrc/app/apps/bulletin/lib/stamp.ts— เครื่องสถานะ "ตราประทับ" ที่คัดลอกตัวต่อตัวไปยัง client-websrc/app/apps/bulletin/lib/theme.ts— สูตรคำนวณ WCAG และคู่สีที่ตรวจsrc/services/bulletin.service.ts— endpoint ทั้งหมดที่หน้าเว็บเรียกใช้
Backend (line-management-cms-api-go)
internal/modules/apps/guard.go—AppEnabledGuardและการอ่านตารางline_oa_appinternal/modules/apps/controller.go—PUT /apps/:appIdกับmodulegate.PlatformOnlyinternal/modules/modulegate/modulegate.go—PlatformOnlyที่ fail closed (ต่างจากModuleGateที่ fail open)internal/modules/bulletin/controller.go— 23 เส้นทาง และการต่อAppEnabledGuardเข้าทุกเส้นทางinternal/modules/bulletin/service_board.go— การสร้างบอร์ดแบบ ON CONFLICT, settings, หมวดหมู่, ตัวนับที่รอตรวจinternal/modules/bulletin/service_moderation.go— สถานะประกาศ/ความคิดเห็น, เพดานปักหมุด 5 กับการล็อกแถวบอร์ด,postStatusAfterEditinternal/modules/bulletin/service_safety.go— รายงาน, ช่องของ unique index ของการบล็อก, บันทึกการตรวจสอบย้อนหลังinternal/modules/bulletin/dto.go— ค่าจำกัดของ settings, ชั้นทับซ้อนของธีม,validateTokenContrastinternal/modules/bulletin/scope.go+scope_test.go— การบีบ scope สองชั้นและ AST guard ที่บังคับให้ใช้
ดูรายละเอียดเชิงเทคนิคเพิ่มเติมที่ แอปกระดานข่าว (Bulletin)