Skip to main content

Apps — Bulletin

ภาพรวม​

Bulletin เป็นหนึ่งใน "Apps" — โมดูลเสริมที่ทำงานด้วยระบบสิทธิ์ คนละแบบกับเมนูอื่นทั้งหมดในระบบ เมนูปกติ (เช่น Campaign) ถูกคุมด้วย ModuleGate ที่ผูกกับการเปิด/ปิดโมดูลให้องค์กร แต่ Apps ถูกคุมด้วย AppEnabledGuard ซึ่งอ่านตาราง line_oa_app แถวเดียวว่า "องค์กรนี้ได้รับอนุญาตให้ใช้แอปชื่อ bulletin หรือไม่" — ถ้าไม่มีแถว หรือแถวนั้น enabled = false ทุกเส้นทางของแอปตอบ 403 ทันที และสวิตช์เปิด/ปิดตัวนี้ องค์กรกดเองไม่ได้ ต้องเป็นผู้ดูแลระดับแพลตฟอร์มเท่านั้น (ดู สิทธิ์การเข้าถึง)

หน้าที่ของแอปคือทำบอร์ดปิดประกาศแบบฟอรัมให้สมาชิก LINE OA เข้าอ่าน โพสต์ และแสดงความคิดเห็นผ่านเว็บฝั่งลูกค้า (client-web / LIFF) ส่วนฝั่ง CMS นี้เป็นเครื่องมือกำกับดูแลเนื้อหา (moderation) ครบชุด 7 แท็บ — ประกาศ, ความคิดเห็น, รายงาน, หมวดหมู่, สมาชิก, ตั้งค่า, กิจกรรม

บทบาทที่ใช้งาน — แอดมินหรือผู้กำกับดูแลเนื้อหาบอร์ด — เมนูนี้จะปรากฏก็ต่อเมื่อผู้ดูแลระดับแพลตฟอร์มเปิดแอปนี้ให้องค์กรแล้วเท่านั้น

เส้นทางผู้ใช้งาน​

1. ตรวจประกาศที่รออนุมัติ (คิวตรวจ)​

  1. เปิดเมนู Apps > Bulletin — แท็บ ประกาศ / ความคิดเห็น / รายงาน มีป้ายตัวเลขบอกจำนวนที่รอตรวจ (ดึงจาก GET /apps/bulletin/pending-count ที่คืนค่า 3 ตัวแยกกัน)
  2. เข้าแท็บประกาศ เลือกสถานะ "รอตรวจ" — หน้าจอสลับจากตารางเป็น มุมมองคิวตรวจ และเปลี่ยนวิธีดึงข้อมูลเป็นหน้าเดียว page=1, limit=100 แทนการแบ่งหน้า
  3. ฝั่งซ้ายเป็นรายการรอตรวจ ฝั่งขวาแสดงประกาศเต็มใบพร้อมธีมจริงของบอร์ด ใช้ปุ่มลัดคีย์บอร์ดได้ทันที — J / K เลื่อนลง/ขึ้น, A อนุมัติ, H ซ่อน, X ลบ (ลบเท่านั้นที่ถามยืนยัน อนุมัติกับซ่อนถือว่าย้อนกลับได้)
  4. จัดการรายการหนึ่งเสร็จ รายการนั้นหายจากคิว แต่ตำแหน่งที่เลือกไว้ไม่ขยับ — ระบบไม่ได้เลื่อน index ขึ้นหนึ่ง จึงกลายเป็นรายการถัดไปโดยอัตโนมัติ ทำงานต่อได้ต่อเนื่องไม่ต้องกระโดดกลับไปหัวคิว

2. จัดการความคิดเห็นและรายงานจากผู้ใช้​

  1. สลับไปแท็บความคิดเห็น — เป็นคิวระดับบอร์ดทั้งใบ (ไม่ใช่แยกตามประกาศ) กรองด้วยสถานะและรหัสประกาศได้
  2. สลับไปแท็บรายงาน ดูรายงานที่ยังเปิดอยู่ คลิกขยายแถวเพื่อดูเนื้อหาจริงที่ถูกรายงาน แล้วตัดสินเป็น resolved หรือ dismissed
  3. การตัดสินรายงาน ไม่แตะเนื้อหาที่ถูกรายงานเลย — ถ้าต้องซ่อนหรือลบประกาศ/ความคิดเห็นนั้นต้องสั่งแยกอีกครั้งที่แท็บของมัน

3. ตั้งค่าบอร์ดและหมวดหมู่​

  1. เข้าแท็บหมวดหมู่ สร้างหมวดพร้อมกำหนด สิทธิ์การดู (สืบทอดจากบอร์ด inherit / จำกัดกลุ่ม restricted โดยเลือกกลุ่มเป้าหมายจากเมนู Segment audience) และ สิทธิ์การเขียน (member / admin) ซึ่งเป็นสองแกนที่แยกจากกัน — ตั้ง "เฉพาะแอดมิน" ไม่ได้ซ่อนหมวดหมู่ สมาชิกยังอ่านได้
  2. เข้าแท็บตั้งค่าบอร์ด ปรับสิทธิ์การเข้าถึง (view_access / write_access ซึ่งเป็น เซ็ต ของ member / guest ไม่ใช่ค่าเดี่ยว), กฎการกำกับดูแล (require_post_approval / require_comment_approval), ค่าจำกัด (รูปต่อประกาศ 1–9, ความยาวเนื้อหาและความคิดเห็น 1–10,000, รีแอกชันไม่เกิน 8 ตัว) และธีมสีของบอร์ด
  3. ธีมมีด่านตรวจค่าความต่างของสีตามมาตรฐาน WCAG ก่อนบันทึกเสมอ — ไม่ผ่านคือ บันทึกไม่ได้ ไม่ใช่แค่เตือน
  4. คัดลอกลิงก์บอร์ด (เว็บหรือ LIFF) จากการ์ดแชร์ลิงก์ไปเผยแพร่ให้สมาชิก

4. บล็อกสมาชิกและตรวจสอบย้อนหลัง​

  1. เข้าแท็บสมาชิกที่ถูกบล็อก บล็อกผู้ใช้รายคน — ระบุขอบเขต (all ทั้งหมด หรือ comment เฉพาะความคิดเห็น), เหตุผลภายใน (ไม่บังคับ) และวันหมดอายุ (ไม่กรอก = ถาวร)
  2. ยกเลิกการบล็อกที่มีอยู่ได้ — สถานะในตาราง (ใช้งานอยู่ / หมดอายุ / ยกเลิกแล้ว) คำนวณจากข้อมูล ไม่ใช่คอลัมน์แยก
  3. เข้าแท็บบันทึกการตรวจสอบย้อนหลังเพื่อดูประวัติการกระทำทั้งหมดของทีมงาน กรองตามประเภทเป้าหมาย (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 (หาข้อมูลไม่ได้ก็ปล่อยผ่าน เพราะการล็อกองค์กรออกจากโมดูลที่มีสิทธิ์ใช้เป็นความเสียหายที่ยอมรับไม่ได้) แต่ PlatformOnly fail 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.tsx
  • src/app/apps/bulletin/{comments,reports,categories,members,activity}/page.tsx — คิวความคิดเห็น, รายงาน, หมวดหมู่, สมาชิกที่ถูกบล็อก, บันทึกการตรวจสอบย้อนหลัง
  • src/app/apps/bulletin/lib/stamp.ts — เครื่องสถานะ "ตราประทับ" ที่คัดลอกตัวต่อตัวไปยัง client-web
  • src/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_app
  • internal/modules/apps/controller.go — PUT /apps/:appId กับ modulegate.PlatformOnly
  • internal/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 กับการล็อกแถวบอร์ด, postStatusAfterEdit
  • internal/modules/bulletin/service_safety.go — รายงาน, ช่องของ unique index ของการบล็อก, บันทึกการตรวจสอบย้อนหลัง
  • internal/modules/bulletin/dto.go — ค่าจำกัดของ settings, ชั้นทับซ้อนของธีม, validateTokenContrast
  • internal/modules/bulletin/scope.go + scope_test.go — การบีบ scope สองชั้นและ AST guard ที่บังคับให้ใช้

ดูรายละเอียดเชิงเทคนิคเพิ่มเติมที่ แอปกระดานข่าว (Bulletin)