บอร์ดประกาศ — สร้าง แก้ไข ลบโพสต์ และการยืนยันรูปภาพ
ภาพรวม
เส้นทางเขียนของโพสต์ทั้งหมด ได้แก่ การสร้าง แก้ไข ลบแบบ soft delete และการเปิดหรือปิดคอมเมนต์ของโพสต์ตัวเอง ทุกการ mutate ทำงานภายใน transaction เดียวกันพร้อมกับแถว audit log เพื่อไม่ให้เกิด audit trail ที่มีช่องโหว่
รวมถึง image committer ซึ่งเป็นกลไกย้ายรูปจาก prefix temp/ ที่ endpoint upload สร้างไว้ ไปยัง path ถาวร กลไกนี้เป็นทั้งการจัดระเบียบไฟล์และ ด่านความปลอดภัย เพราะเป็นการพิสูจน์ว่าผู้เรียกเป็นผู้อัปโหลด object นั้นจริง
Business Flow
สร้างโพสต์ — POST /api/bulletin/:hash/posts (rate limit 5/60s)
-
เรียก
resolveAccessแล้วตรวจCanWrite() -
validateTitle— ลำดับของกฎเป็นส่วนหนึ่งของสัญญา โดย trim ก่อน แล้วตรวจว่าว่างหรือไม่ (BULLETIN_EMPTY_TITLE) จากนั้นจึงตรวจความยาว (BULLETIN_TITLE_TOO_LONGเพดาน 200 นับเป็น rune เพราะ PostgresVARCHAR(200)นับเป็นตัวอักษร หัวข้อภาษาไทย 200 ตัวจึงต้องผ่าน) ค่าที่บันทึกจริงคือค่าที่ trim แล้ว ดังนั้นหัวข้อที่เป็นช่องว่าง 300 ตัวจะถือว่า "ว่าง" ไม่ใช่ "ยาวเกิน" และ CMS ใช้ error code สองตัวนี้ร่วมกัน -
Body ที่ยาวเกิน
max_body_lengthตอบBULLETIN_BODY_TOO_LONGส่วนรูปที่เกินmax_images_per_postตอบBULLETIN_IMAGE_LIMIT -
หากระบุ
categoryIdมา ต้องตรวจ 2 ข้อตามลำดับนี้- ความเป็นเจ้าของ — FK บน
post.category_idพิสูจน์เพียงว่าหมวดนั้นมีอยู่ที่ใดที่หนึ่ง ไม่ได้พิสูจน์ว่าอยู่บนบอร์ดของผู้เรียก ดังนั้นการค้นไม่พบถือเป็น input ที่ผิด ตอบ 400invalid categoryไม่ใช่ 404 - สิทธิ์การโพสต์ — จากนั้นจึงดู
post_accessของหมวดนั้น หากไม่ใช่memberตอบ 403BULLETIN_CATEGORY_ADMIN_ONLY
ลำดับนี้ทำให้หมวดของบอร์ดอื่นได้คำตอบว่า invalid category ไม่ใช่ admin only ซึ่งจะเป็นการยืนยันว่ามีหมวด id นั้นอยู่จริง
- ความเป็นเจ้าของ — FK บน
-
กำหนดสถานะ — หาก
require_post_approvalเปิดอยู่จะได้pendingมิฉะนั้นได้publishedส่วนallowCommentsใช้ค่าที่ส่งมาหากมี มิฉะนั้นใช้allow_comments_defaultโดยเก็บเป็น pointer เพื่อแยกกรณี "ไม่ส่งค่ามา" ออกจาก "ส่งค่า false มา" -
ทำงานใน transaction เดียว ตามลำดับ insert โพสต์ → commit รูป (ต้องทำหลัง insert เพราะ key ถาวรต้องใช้ post id) → เรียก
UpdatePostImages(แยกจากUpdatePostเพราะตัวหลัง stampedited_dateและโพสต์ที่มีรูปไม่ควรถูกทำเครื่องหมายว่า "ถูกแก้ไข" ตั้งแต่แรกเกิด) → insert auditpost.createหากขั้นตอนใดล้มเหลวจะ rollback ทั้งหมด จึงไม่มีโพสต์กำพร้าที่ชี้ไปยัง object ที่ไม่เคยถูก copy -
ตอบกลับ
PostViewด้วยสถานะ 201
แก้ไขโพสต์ — PUT /api/bulletin/:hash/posts/:id
- ตรวจ
CanWrite()แล้วเรียกownedPost— กรณีไม่มีอยู่ อยู่คนละ OA หรือไม่ใช่ของตัวเอง ตอบ 404 เหมือนกันทั้งหมด - การแก้ไขไม่ได้รับข้อยกเว้นจากกฎหัวข้อ เพราะการแก้จนหัวข้อว่างจะเหลือ thread ที่ไม่มีชื่อในลิสต์ที่นำด้วยหัวข้อ
- โพสต์ที่อยู่ในสถานะ
publishedและบอร์ดตั้งrequire_post_approvalไว้ จะ กลับไปเป็นpendingส่วนสถานะอื่นคงเดิม UpdatePostInputไม่มี fieldcategoryIdโดยเจตนา เพราะ repository ไม่มีคอลัมน์ให้เขียน และการรับ field แล้วทิ้งเงียบ ๆ แย่กว่าการไม่มี field นั้นเลย เหตุนี้เองที่UpdatePostจึงไม่ต้องตรวจpost_accessซ้ำ เพราะการแก้ไขไม่สามารถย้ายโพสต์เข้าหมวด admin-only ได้ และมี test คอยเป็นสายสะดุดไว้- Commit รูปเฉพาะเมื่อมีการส่ง
imagesมา แล้วทำ update และ insert auditpost.editภายใน transaction เดียวกัน พร้อมบันทึกค่า before และ after
ลบโพสต์ — DELETE /api/bulletin/:hash/posts/:id
ตรวจ CanWrite() แล้ว ownedPost จากนั้น soft delete โดยตั้งสถานะเป็น deleted พร้อมกำหนด deleted_date — ไม่เคยใช้ SQL DELETE และ insert audit post.delete ก่อนตอบกลับ 204
เปิด/ปิดคอมเมนต์ — PUT /api/bulletin/:hash/posts/:id/comments-setting
ตรวจ CanWrite() แล้ว ownedPost จากนั้นเรียก SetAllowComments และ insert audit post.comments_toggle ก่อนตอบกลับ 204
Image committer (storageCommitter.Commit)
- Layout ถาวรอยู่ในรูป
bulletin/{post|comment}/{ownerID}/{index}.{ext}โดยดัชนีคือตำแหน่งใน array ที่ส่งมา การ commit ซ้ำจึง เขียนทับ object ของตัวเอง ไม่สะสมขยะ และนามสกุลไฟล์เดิมถูกรักษาไว้ - ค่า
kindซึ่งระบุว่าเป็น post หรือ comment ถูก fix ตั้งแต่ตอน construct จึงมี 2 instance แยกกันแทนที่จะเป็นตัวเดียวที่ branch ภายใน ผลคือ layout ของทั้งสองฝั่งเบี่ยงออกจากกันไม่ได้ - ทำงาน 2 phase โดยเจตนา — phase 1 normalize แล้วเรียก
HeadObjectเพื่อพิสูจน์ทุก key ก่อน จากนั้น phase 2 จึง copy ผลคือ request ที่มี reference เสียแม้เพียงหนึ่งตัวจะไม่เขียนอะไรเลย normalizeTempImageKeyรับได้ทั้ง key ดิบในรูปtemp/temp-{ms}.{ext}และ public URL ที่ endpoint upload คืนมา แล้ว normalize ให้เป็น key ส่วนค่าอื่น ๆ ไม่ว่าจะเป็น prefix อื่น path traversal หรือข้อความมั่ว จะถูกปฏิเสธHeadObjectคือขั้นความปลอดภัยตัวจริง เพราะเป็นสิ่งเดียวที่กันการอ้างถึง object ของ tenant อื่นหรือ key ที่ไม่เคยถูกอัปโหลด หากล้มเหลวตอบ 400BULLETIN_INVALID_IMAGE- เมื่อไม่มี config ของ storage ระบบจะ fail closed คือปฏิเสธทุกรูป ไม่ใช่ปล่อยให้ temp key ผ่านไปเก็บ ซึ่งเป็น defect เดิมที่ทำให้รูปทั้งหมดกลายเป็น 404 เมื่อ temp prefix ถูกกวาด
- Interface
objectStoreไม่มีเมธอด delete โดยเจตนา — object ในtemp/ไม่เคยถูกลบที่นี่ เพราะหากลบแล้ว transaction rollback จะเหลือสภาพที่ทั้งต้นทางหายไปและไม่มีอะไรชี้ไปยังสำเนา การกวาดtemp/จึงเป็นงาน operations แยกต่างหาก
ไฟล์และฟังก์ชันหลัก
| Route | Rate limit | Handler |
|---|---|---|
POST /api/bulletin/:hash/posts | 5/60s | (*Handler).CreatePost |
PUT /api/bulletin/:hash/posts/:id | — | (*Handler).UpdatePost |
DELETE /api/bulletin/:hash/posts/:id | — | (*Handler).DeletePost |
PUT /api/bulletin/:hash/posts/:id/comments-setting | — | (*Handler).SetCommentsSetting |
internal/bulletin/service.go—CreatePost,UpdatePost,DeletePost,SetAllowComments,ownedPost,validateTitle, interfacepostRepo,AuditEntry,ImageCommitterinternal/bulletin/committer.go—NewStorageCommitter,(storageCommitter).Commit,permanentKey,extensionOf,objectStoreinternal/bulletin/register.go—normalizeTempImageKey,tempImageKeyREinternal/bulletin/repository.go—WithTx,InsertPost,UpdatePost,UpdatePostImages,SoftDeletePost,SetAllowComments,InsertAudit,FindPostByID,FindCategoryByID
หมายเหตุเรื่องการตั้งชื่อ route: ทุก wildcard ใต้
/posts/:idต้องใช้ชื่อ:idเหมือนกันทั้งหมด เพราะ gin จะ panic หากชื่อ param ต่างกันภายใน subtree เดียวกัน ส่วน/comments/:idถือเป็น subtree แยกเนื่องจาก static segment ต่างกัน
จุดเชื่อมต่อกับ Service อื่น
- ฐานข้อมูล — ตาราง
bulletin.post,bulletin.categoryและbulletin.audit_log - Object storage — เรียกผ่าน
internal/storagexด้วยHeadObjectและCopyObject - File upload — แหล่งที่มาของ temp key คือ endpoint
POST /upload-file/temp - Bulletin access control — เรียก
CanWriteและอ่านค่าเพดานต่าง ๆ จาก settings - client-web — ตรงกับฟีเจอร์
bulletin-board(ส่วนเขียนโพสต์),bulletin-moderation(เมนูแก้ไขและลบของเจ้าของโพสต์) และfile-upload