สร้างแอป DVD Rental สำหรับผู้ใช้ปลายทาง — โครงสร้าง Vue 3 + Spring Boot คู่กับแอปผู้ดูแลระบบ พร้อมแผนที่บทความ
ที่มา
ก่อนหน้านี้ฉันสร้างแอปผู้ดูแลระบบโดยใช้ฐานข้อมูลตัวอย่าง PostgreSQL dvdrental
→ สร้างแอปผู้ดูแลระบบ DVD Rental ด้วย Spring Boot + Thymeleaf บน dvdrental Sample DB
แอปผู้ดูแลระบบนั้นช่วยให้พนักงานจัดการข้อมูลลูกค้าได้ แต่ไม่มีที่สำหรับลูกค้าใช้งานเอง แอปผู้ใช้ปลายทางนี้เริ่มต้นเพื่อตอบคำถามนั้น
Tech Stack
| Layer | เทคโนโลยี |
|---|---|
| Backend | Spring Boot 3 + MyBatis + Spring Security |
| Frontend | Vue 3 + TypeScript + Vite |
| DB | PostgreSQL (dvdrental) |
| Payment | PayPay Dynamic QR Code, PAY.JP credit card |
| Deployment | Single JAR (frontend bundled into static/) |
Frontend จะถูก bundle เข้า Spring Boot JAR ตอน build ดังนั้นแอปจึง deploy เป็นหน่วยเดียว
→ Bundle Frontend Vue3/React เข้า Spring Boot เพื่อ Serve อัตโนมัติ
พื้นที่สาธารณะและพื้นที่สมาชิก
แอปแบ่งออกเป็นสองส่วน:
| พื้นที่ | การเข้าถึง | เนื้อหา |
|---|---|---|
| สาธารณะ | ไม่ต้อง login | รายการภาพยนตร์, รายละเอียดภาพยนตร์ |
| สมาชิก | ต้อง login | หน้าของฉัน, ประวัติการเช่า, ชำระเงิน |
โครงสร้างหน้าจอ — การจัดการ currentPage ใน App.vue
แอปนี้ไม่ใช้ Vue Router การเปลี่ยนหน้าจัดการด้วย currentPage ref ใน App.vue
// App.vue (ตัดตอน)
const currentPage = ref<
'films' | 'detail' | 'auth' | 'mypage' | 'checkout' | 'checkout-complete' | ...
>('films')
→ การตัดสินใจออกแบบ: จัดการ Page State ด้วย currentPage แบบง่ายๆ ใน App.vue
ภาพรวมหน้าจอ
① รายการภาพยนตร์ (FilmListView.vue)

รายการภาพยนตร์รองรับการกรองตามหมวดหมู่ ระดับการเช่า และระยะเวลา บวกการค้นหาแบบข้อความอิสระ — ทั้งหมดใช้ Vue 3 computed บน frontend
→ ใช้แค่ Vue 3 computed เพื่อสร้าง Frontend Filter Search
สถานะแผง filter sidebar ซ้ายถูก persist ใน localStorage เพื่อให้ยังคงปิดอยู่ระหว่าง session
→ Persist สถานะเปิด/ปิด Sidebar ด้วย localStorage โดยป้องกัน Flash
② รายละเอียดภาพยนตร์ (FilmDetailView.vue)

หน้ารายละเอียดภาพยนตร์แสดงภาพปกและวิดีโอตัวอย่างที่สร้างโดย Gemini Ultra
→ สร้างโปสเตอร์ภาพยนตร์และวิดีโอตัวอย่างด้วย Gemini Ultra สำหรับแอป DVD Rental
หน้ารายละเอียดมีสองปุ่ม: “เช่าเดี๋ยวนี้” และ “ใส่ตะกร้า” การแยกสองปุ่มนี้เป็นการตัดสินใจด้าน UX
<!-- FilmDetailView.vue (ปุ่ม CTA ตัดตอน) -->
<template v-if="isRentalSection">
<button class="btn-primary" @click="emit('direct-checkout', film)">
เช่าเดี๋ยวนี้ (ชำระเงิน)
</button>
<button v-if="isAuthenticated" class="btn-secondary"
@click="emit('add-to-cart', film)">
ใส่ตะกร้า
</button>
</template>
→ เหตุใด EC Sites ควรแยก “ใส่ตะกร้า” และ “ซื้อเดี๋ยวนี้” — การออกแบบและการ implement
เมื่อ navigate ไปหน้ารายละเอียด resetDetailViewport บังคับ scroll ไปด้านบนและล้าง focus เพื่อไม่ให้ตำแหน่ง scroll ของหน้าก่อนค้างอยู่
// FilmDetailView.vue (ตัดตอน scroll/focus reset)
const resetDetailViewport = () => {
window.scrollTo({ top: 0, left: 0, behavior: 'auto' })
const activeElement = document.activeElement
if (activeElement instanceof HTMLElement) {
activeElement.blur()
}
}
watch(() => film.filmId, () => {
resetDetailViewport()
void resolveInitialMedia()
})
onMounted(() => {
resetDetailViewport()
void resolveInitialMedia()
})
→ เหตุใดฉันเพิ่ม scrollTo(0, 0) และ blur() เมื่อ Navigate ไปหน้ารายละเอียด
④ Login / ลงทะเบียน (AuthView.vue)

Login เป็น REST call ปกติ frontend → backend ไม่ใช้ filter chain ของ Spring Security แต่ verify password ด้วย BCryptPasswordEncoder แบบ manual
// AuthController.java (ตัดตอน login)
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
AuthMapper.AuthRecord record = authMapper.findByEmail(request.email());
if (record == null) {
// ส่ง message เดียวกันไม่ว่า email จะมีอยู่หรือไม่ (ป้องกัน enumeration)
return ResponseEntity.status(401)
.body(new LoginResponse(false, "อีเมลหรือรหัสผ่านไม่ถูกต้อง", ""));
}
if (!passwordEncoder.matches(request.password(), record.passwordHash())) {
return ResponseEntity.status(401)
.body(new LoginResponse(false, "อีเมลหรือรหัสผ่านไม่ถูกต้อง", ""));
}
authMapper.updateLastLoginAt(record.customerId());
return ResponseEntity.ok(new LoginResponse(true, "เข้าสู่ระบบสำเร็จ", "/films"));
}
⑤ ลงทะเบียนสมาชิก (MemberRegistrationView.vue)

ขั้นตอน 3 steps: กรอกข้อมูล → ยืนยัน → เสร็จสิ้น หน้ายืนยันแสดงค่าที่กรอกแบบ read-only และสามารถย้อนกลับแก้ไขได้
การจัดการ Steps
การเปลี่ยนหน้าจัดการด้วย currentPage ref ใน App.vue แทน Vue Router ค่าในฟอร์มถูกบันทึกชั่วคราวใน registerDraft และส่งไปหน้ายืนยันเป็น props
// App.vue (ตัดตอน registration flow)
const registerDraft = ref<RegisterRequest | null>(null)
const openMemberRegisterConfirm = (payload: RegisterRequest) => {
registerDraft.value = payload // บันทึก input ชั่วคราว
currentPage.value = 'member-register-confirm'
}
<!-- App.vue (ตัดตอน template) -->
<MemberRegistrationView
v-else-if="currentPage === 'member-register'"
@confirm="openMemberRegisterConfirm"
@back-auth="currentPage = 'auth'"
/>
<MemberRegistrationConfirmView
v-else-if="currentPage === 'member-register-confirm' && registerDraft"
:payload="registerDraft"
@back-edit="currentPage = 'member-register'"
@registered="currentPage = 'auth'"
/>
MemberRegistrationView แค่ emit ค่า input ไปหา parent ผ่าน emit('confirm', { ...form }) โดยไม่รู้ว่าจะ navigate ไปไหน การควบคุมการ navigate รวมศูนย์ที่ parent component
Validation ข้อมูลที่กรอก
การ validate password ทำใน @submit.prevent="goConfirm" แทนการพึ่งพา HTML required โดย TypeScript ตรวจสอบความตรงกันของ password ความยาว และการผสมตัวอักษรและตัวเลขอย่างชัดเจน
// MemberRegistrationView.vue (ตัดตอน validation)
const goConfirm = () => {
passwordError.value = ''
if (form.password !== form.passwordConfirmation) {
passwordError.value = 'รหัสผ่านไม่ตรงกัน'
return
}
if (form.password.length < 8) {
passwordError.value = 'รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร'
return
}
if (!/[a-zA-Z]/.test(form.password) || !/[0-9]/.test(form.password)) {
passwordError.value = 'รหัสผ่านต้องประกอบด้วยตัวอักษรและตัวเลข'
return
}
emit('confirm', { ...form })
}
เรียก API จากหน้ายืนยัน
หน้ายืนยัน (MemberRegistrationConfirmView.vue) มีปุ่ม “ลงทะเบียน” และเรียก API จากที่นั่น flag loading ป้องกันการส่งซ้ำ ข้อผิดพลาดแสดงใน screen
// MemberRegistrationConfirmView.vue (ตัดตอน registration)
const submit = async () => {
loading.value = true
try {
const result = await registerMember(props.payload)
message.value = result.message
emit('registered') // parent สลับไปหน้า 'auth'
} catch (error) {
isError.value = true
message.value = error instanceof Error ? error.message : 'การลงทะเบียนล้มเหลว'
} finally {
loading.value = false
}
}
⑥ Cart Drawer (CartDrawer.vue)
UI drawer ที่ slide มาจากขวา เปิดเมื่อคลิกไอคอน cart ในหน้ารายการหรือรายละเอียดภาพยนตร์

สถานะ cart ถูก persist ใน localStorage ดังนั้นเนื้อหาจะยังคงอยู่แม้ปิดหน้าเว็บ
// useCart.ts (ตัดตอน add to cart)
function addToCart(film: PublicFilmSummary): boolean {
ensureInitialized()
const exists = itemsRef.value.some((item) => item.filmId === film.filmId)
if (exists) return false // ป้องกันการเพิ่มซ้ำ
const next: CartItem = { ...film, addedAt: new Date().toISOString() }
itemsRef.value = [next, ...itemsRef.value]
saveToStorage(itemsRef.value)
return true
}
⑦ หน้าของฉัน (MyPageView.vue)

ข้อมูลหน้าของฉันดึงมาโดย JOIN ตาราง dvdrental ที่มีอยู่ สถานะการเช่าคำนวณแบบ dynamic ด้วย SQL CASE expression
// MyPageMapper.java (ตัดตอน rental history)
@Select("""
SELECT
r.rental_id,
f.title AS film_title,
CASE
WHEN r.return_date IS NOT NULL THEN 'RETURNED'
WHEN r.rental_date < CURRENT_TIMESTAMP - INTERVAL '7 days' THEN 'OVERDUE'
ELSE 'OPEN'
END AS rental_status,
COALESCE(SUM(p.amount), 0) AS billed_amount
FROM rental r
JOIN inventory i ON i.inventory_id = r.inventory_id
JOIN film f ON f.film_id = i.film_id
LEFT JOIN payment p ON p.rental_id = r.rental_id
WHERE r.customer_id = #{customerId}
GROUP BY r.rental_id, r.rental_date, r.return_date, f.title
ORDER BY r.rental_date DESC
LIMIT #{size} OFFSET #{offset}
""")
List<RentalHistoryItemResponse> selectRentalHistory(
@Param("customerId") int customerId,
@Param("size") int size,
@Param("offset") int offset);
⑧ หน้าชำระเงิน (CheckoutView.vue)
หน้าชำระเงินใช้ layout “Isolated Checkout” ที่ซ่อน header และ nav ปกติ

มีสองวิธีชำระเงิน: PAY.JP และ PayPay
PayPay สร้าง Dynamic QR Code และ redirect ไปแอป PayPay การชำระเงินสำเร็จตรวจจับด้วย polling
// PayPayService.java (ตัดตอน QR code generation)
public QRCodeDetails createQRCode(int amount, String merchantPaymentId, String redirectUrl)
throws ApiException {
QRCode qrCode = new QRCode();
qrCode.setMerchantPaymentId(merchantPaymentId);
qrCode.setAmount(new MoneyAmount().amount(amount).currency(MoneyAmount.CurrencyEnum.JPY));
qrCode.setCodeType("ORDER_QR");
qrCode.setOrderDescription("ชำระเงินเช่า DVD");
qrCode.setRequestedAt(Instant.now().getEpochSecond());
return paymentApi.createQRCode(qrCode);
}
PAY.JP mount Payjp.js บน frontend ดังนั้นข้อมูลบัตรจะไม่ผ่าน server เลย (PCI DSS compliant)
การปรับแต่ง DB สำหรับ dvdrental
เนื่องจาก dvdrental เป็น sample DB จึงต้องเพิ่มตารางที่จำเป็นเพื่อรันแอปผู้ใช้ปลายทาง การเปลี่ยนแปลง schema จัดการด้วย Flyway
→ การเพิ่ม Schema Migration อย่างค่อยเป็นค่อยไปด้วย Flyway
ตารางที่เพิ่ม ① customer_authentication
ตาราง customer ของ dvdrental เก็บแค่ข้อมูลธุรกิจ (ชื่อ ที่อยู่ ฯลฯ) และไม่มี password hash สำหรับ authentication จึงสร้างตาราง authentication แยกต่างหาก
CREATE TABLE public.customer_authentication (
customer_id INTEGER PRIMARY KEY
REFERENCES public.customer(customer_id),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
auth_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
MyBatis Mapper อ้างอิงตารางนี้โดยตรง
// AuthMapper.java (ตัดตอน)
@Select("""
SELECT customer_id, email, password_hash, auth_status
FROM public.customer_authentication
WHERE LOWER(email) = LOWER(#{email})
LIMIT 1
""")
AuthRecord findByEmail(@Param("email") String email);
ตารางที่เพิ่ม ② rental_tier
rental_rate ในตาราง film ของ dvdrental เป็นตัวเลขแบบ flat จึงเพิ่ม tier master เพื่อให้กรองตามหมวดหมู่เช่น “ใหม่” “กึ่งใหม่” และ “มาตรฐาน” ได้
CREATE TABLE public.rental_tier (
rental_tier_id SERIAL PRIMARY KEY,
code VARCHAR(20) NOT NULL UNIQUE, -- 'NEW', 'SEMI_NEW', 'STANDARD'
label VARCHAR(50) NOT NULL,
default_rate NUMERIC(5,2) NOT NULL
);
-- เพิ่ม FK column ในตาราง film
ALTER TABLE public.film
ADD COLUMN rental_tier_id INTEGER REFERENCES public.rental_tier(rental_tier_id);
Film list API JOIN rental_tier และคืน tier_code สำหรับ frontend filtering
// PublicFilmMapper.java (ตัดตอน)
@Select("""
SELECT
f.film_id, f.title, f.release_year,
c.name AS category_name,
COALESCE(f.rental_rate, rt.default_rate) AS rental_rate,
rt.code AS tier_code
FROM film f
JOIN film_category fc ON fc.film_id = f.film_id
JOIN category c ON c.category_id = fc.category_id
JOIN rental_tier rt ON rt.rental_tier_id = f.rental_tier_id
ORDER BY f.film_id
LIMIT #{size} OFFSET #{offset}
""")
List<PublicFilmSummaryResponse> selectPublicFilms(@Param("size") int size, @Param("offset") int offset);
Column ที่เพิ่ม ③ film.taste_tags
Tag บรรยากาศถูกสร้างอัตโนมัติโดย LLM (Ollama / OpenAI) และเก็บใน column taste_tags ของตาราง film
ALTER TABLE film ADD COLUMN IF NOT EXISTS taste_tags TEXT[];
Spring Boot batch ส่งชื่อภาพยนตร์และคำอธิบายให้ LLM และเก็บ tag array ที่ได้รับกลับเป็น TEXT[]
→ ผสาน LLM (Ollama/OpenAI) Batch สำหรับสร้าง Film Tag อัตโนมัติเข้า Spring Boot
→ การ Map PostgreSQL Array Type (text[]) ด้วย Spring Boot + JPA
โครงสร้าง Backend API
REST API แบ่งระหว่าง public endpoint และ authenticated endpoint
Public API (ไม่ต้อง auth)
GET /api/public/health health check
GET /api/public/films รายการภาพยนตร์ (paginated)
Authenticated API (ต้อง login)
POST /api/auth/login login
GET /api/me/profile ข้อมูลสมาชิกตัวเอง
GET /api/me/rentals ประวัติการเช่าของตัวเอง
GET /api/me/payments ประวัติการชำระเงินของตัวเอง
POST /api/payments/paypay สร้าง PayPay QR code
GET /api/payments/paypay/{id} ตรวจสอบสถานะชำระเงิน (polling)
POST /api/payments/payjp ลงทะเบียนบัตร PAY.JP & เรียกเก็บเงิน
ในการพัฒนา setup คือ localhost:5173 (Vue) → localhost:8082 (Spring Boot) ต่างกัน origin ใน production mvn package จะ bundle frontend build artifacts เข้า static/ และ Spring Boot จะ serve
→ บันทึกการฝ่า CORS และ Proxy ด้วย Vue 3 + Vite + Spring Boot
→ Bundle Frontend Vue3/React เข้า Spring Boot เพื่อ Serve อัตโนมัติ
ฟีเจอร์ที่ implement แล้ว
พื้นที่สาธารณะ (ไม่ต้อง login)
- รายการภาพยนตร์ (filter หมวดหมู่ ระดับการเช่า ระยะเวลา ค้นหาข้อความอิสระ)
- รายละเอียดภาพยนตร์ (ภาพปก วิดีโอตัวอย่าง สถานะ stock นักแสดง)
- คำแนะนำสำหรับผู้ที่ยังไม่ login
- กรองด้วย mood tag ที่สร้างอัตโนมัติด้วย LLM (อยู่ระหว่างพัฒนา)
พื้นที่สมาชิก (หลัง login)
- ลงทะเบียนสมาชิก (3 steps: กรอกข้อมูล → ยืนยัน → เสร็จสิ้น)
- Login / logout
- หน้าของฉัน (สรุปการใช้งาน ประวัติการเช่า ประวัติการชำระเงิน)
- แก้ไขข้อมูลสมาชิก เปลี่ยนที่อยู่อีเมล
- ขั้นตอนลบบัญชี (พร้อมหน้ายืนยัน)
- คำแนะนำส่วนตัวและรายการ “ดูทีหลัง”
ชำระเงิน & เช่า
- ขั้นตอน “ใส่ตะกร้า” (cart drawer)
- ขั้นตอน “เช่าเดี๋ยวนี้” สำหรับ checkout ทันที
- ชำระเงินด้วยบัตรเครดิต PAY.JP (ลงทะเบียนบัตรและนำกลับมาใช้)
- ชำระเงินด้วย PayPay Dynamic QR Code (ตรวจจับการสำเร็จด้วย polling)
ปัญหาระหว่างการพัฒนา
อุบัติเหตุ Encoding จากการแก้ไขข้อความแบบ bulk
ระหว่างการพัฒนา การ replace ข้อความแบบ bulk ทำให้เกิดความเสียหายจาก encoding ใน Vue files หลายไฟล์
- “สร้างเวอร์ชันที่ถูกต้องทั้งหมดใหม่” เร็วกว่าการตาม fix ทีละส่วน
- หลังจากกู้คืน ตรวจสอบ list, feature, ranking, และ detail ตามลำดับก่อน merge ไป develop
ในงานที่เน้น text-replacement ควรวางแผนขั้นตอนกู้คืนล่วงหน้า โดยสมมติว่าอุบัติเหตุ encoding จะเกิดขึ้น
→ อุบัติเหตุ Encoding จากการแก้ไขข้อความแบบ Bulk และการกู้คืนด้วยการสร้างใหม่ทั้งหมด
ฟีเจอร์ที่กำลังพัฒนา / ยังไม่เริ่ม
ส่วนนี้จะอัปเดตตามความคืบหน้าการพัฒนา
- การผสาน registration flow กับตาราง
customer_authenticationอย่างสมบูรณ์ - Logic ล็อค inventory สำหรับ production (ปัจจุบันอ้างอิง sample data เท่านั้น)
- การจัดการช่วงเวลาเช่า (ต่อเวลาและคืน)
- การแจ้งยืนยันทางอีเมลสำหรับ order
- Deploy ขึ้น AWS (setup ECS/Fargate เหมือน admin app)
แผนที่บทความ (บทความที่เขียน/กำลังเขียนสำหรับซีรีส์นี้)
แต่ละบทความมุ่งเน้นหัวข้อเฉพาะและสามารถอ้างอิงแบบ standalone สำหรับหัวข้อ Spring Boot / Java / Vue 3 / TypeScript
■ การตัดสินใจออกแบบโปรเจกต์โดยรวม
| หัวข้อ | บทความ |
|---|---|
| เหตุใดแยก repo สำหรับ admin และ customer app | เหตุใดเราแยก Admin และ Customer App ออกเป็น Repository ต่างกัน |
| วิสัยทัศน์ platform | วิสัยทัศน์การพัฒนา DVD Rental System ให้เป็น Platform แบบ DMM |
| เหตุใดไม่เริ่มจากการออกแบบ DB ใหม่ | การเลือกที่จะไม่ออกแบบตารางจากศูนย์ — สร้างบน dvdrental |
■ โครงสร้าง Backend
| หัวข้อ | บทความ |
|---|---|
| เหตุใดเลือก Vue 3 + REST API แยกกัน | เหตุใดเราเลือก Architecture แยก Spring Boot Backend + Vue 3 Frontend |
| เหตุใดเลือก MyBatis แทน JPA | เหตุใดเราเลือก MyBatis แทน JPA สำหรับ Spring Boot API Server |
| เพิ่ม DB schema อย่างค่อยเป็นค่อยไปด้วย Flyway | การเพิ่ม Schema Migration อย่างค่อยเป็นค่อยไปด้วย Flyway |
| LLM batch สำหรับสร้าง film tag อัตโนมัติ | ผสาน LLM (Ollama/OpenAI) Batch สำหรับสร้าง Film Tag อัตโนมัติเข้า Spring Boot |
| PostgreSQL array type กับ Spring Boot | การ Map PostgreSQL Array Type (text[]) ด้วย Spring Boot + JPA |
■ โครงสร้าง Frontend
■ การออกแบบ UX & Payment Flow
| หัวข้อ | บทความ |
|---|---|
| เหตุใดควรแยก “ใส่ตะกร้า” และ “ซื้อเดี๋ยวนี้” | เหตุใด EC Sites ควรแยก “ใส่ตะกร้า” และ “ซื้อเดี๋ยวนี้” — การออกแบบและ implement |
| เหตุใดหน้าชำระเงินควรเป็น “Isolated Checkout” | เหตุใดหน้าชำระเงินควรเป็น “Isolated Checkout” และวิธี Implement |
■ สภาพแวดล้อมการพัฒนา
| หัวข้อ | บทความ |
|---|---|
| รัน Maven/Node บน Windows โดยไม่พึ่ง PATH | รัน Maven / Node / Java บน Windows โดยไม่พึ่ง PATH — Setup ที่สมบูรณ์ในโปรเจกต์ |
■ บทความจาก Admin App คู่
บทสรุป
ตอนสร้าง admin app รู้สึกว่า “มีที่สำหรับพนักงานจัดการข้อมูลลูกค้าแล้ว แต่ยังไม่มีที่สำหรับลูกค้าใช้งานเอง” ความรู้สึกนั้นไม่เคยหายไป
แอปผู้ใช้ปลายทางนี้เริ่มต้นเพื่อตอบคำถามนั้น
อย่างไรก็ตาม มันยังไม่สมบูรณ์
ส่วนที่จำเป็นสำหรับ “บริการที่ใช้งานได้จริง” — เช่น payment flow และ inventory locking logic — ยังขาดอยู่บางส่วน ฉันบันทึกการตัดสินใจออกแบบและการ implement ในบทความแต่ละบทความขณะค่อยๆ นำไปสู่ความสมบูรณ์
ซีรีส์บทความทั้งหมดนี้เขียนขึ้นเพื่อทำหน้าที่เป็นหนังสืออ้างอิงและตำราเรียนสำหรับ pattern การ implement Spring Boot / Java / Vue 3 / TypeScript ผ่านการพัฒนาแอปจริง เมื่อต้องการค้นคว้าเทคโนโลยีหรือหัวข้อเฉพาะ ให้อ้างอิงหมวดหมู่ที่เกี่ยวข้องใน article map โดยตรง
Links ของแต่ละบทความจะถูกเพิ่มตามลำดับเมื่อมีการเผยแพร่