สร้างแอป DVD Rental สำหรับผู้ใช้ปลายทาง — โครงสร้าง Vue 3 + Spring Boot คู่กับแอปผู้ดูแลระบบ พร้อมแผนที่บทความ
บทนำ
ก่อนหน้านี้ฉันสร้างแอปผู้ดูแลระบบโดยอิงจาก PostgreSQL sample DB dvdrental
การสร้างแอพจัดการ DVD Rental ด้วย Spring Boot + Thymeleaf บน PostgreSQL dvdrental Sample DB
แอปผู้ดูแลระบบคือ “ระบบที่พนักงานใช้ในงานประจำวัน” มีทั้งจัดการลูกค้า จัดการสต็อก จัดการการเช่า จัดการการชำระเงิน และรายงานยอดขายครบถ้วน
แต่แค่นี้เป็นเพียง “เครื่องมือฝั่งผู้จัดการ” เท่านั้น
ยังไม่มีแอปสำหรับฝั่งที่เช่า DVD จริงๆ
บทความนี้คือภาคต่อจากเรื่องนั้น เป็นเรื่องราวของการสร้างแอปฝั่งผู้ใช้ปลายทางให้คู่กับแอปผู้ดูแลระบบ — แอปที่ลูกค้าค้นหาผลงานเองและสมัครเช่าออนไลน์ได้
แอปนี้ยังอยู่ระหว่างการพัฒนา บทความนี้ไม่ใช่รายงานความสำเร็จ แต่เป็นบันทึกการตัดสินใจออกแบบและการเลือกเทคโนโลยีที่กำลังดำเนินอยู่ จะอัปเดตตามการเพิ่ม/เปลี่ยนแปลงฟีเจอร์ต่อไป
ตำแหน่งของแอปนี้
แอปผู้ดูแลระบบและแอปผู้ใช้ปลายทางใช้ฐานข้อมูล dvdrental เดียวกันร่วมกัน แต่สร้างเป็นแอปพลิเคชันที่แยกกันโดยสิ้นเชิง
┌─────────────────────┐
│ PostgreSQL │
│ (dvdrental) │
└──────────┬──────────┘
│ DB ร่วม
┌──────────────────┴───────────────────┐
│ │
┌──────────▼──────────┐ ┌────────────▼────────────┐
│ แอปผู้ดูแลระบบ │ │ แอปผู้ใช้ปลายทาง │
│ Spring Boot │ │ Spring Boot (REST API) │
│ + Thymeleaf │ │ + Vue 3 + TypeScript │
│ │ │ │
│ เฉพาะพนักงาน │ │ สำหรับลูกค้า │
│ Network ภายใน │ │ เปิดเผยบนอินเทอร์เน็ต │
└─────────────────────┘ └─────────────────────────┘
| แอป | Tech Stack | กลุ่มเป้าหมาย | ขอบเขตการเปิดเผย |
|---|---|---|---|
| แอปผู้ดูแลระบบ | Spring Boot + Thymeleaf | พนักงาน | Network ภายใน |
| แอปผู้ใช้ปลายทาง | Spring Boot (REST API) + Vue 3 + TypeScript | ลูกค้า | เปิดเผยบนอินเทอร์เน็ต |
ทั้งสองแอปใช้ฐานข้อมูล PostgreSQL dvdrental เดียวกัน และทำงานเป็นแอปพลิเคชันอิสระจากกัน
แอปผู้ดูแลระบบคือ “ฝั่งขับเคลื่อนงาน” ส่วนแอปผู้ใช้ปลายทางคือ “ฝั่งที่ใช้บริการ” เนื่องจากบทบาทต่างกัน tech stack และ authentication จึงแยกกันด้วย
เทคโนโลยีที่สองแอปนี้ใช้ร่วมกัน และวิธีอ่าน series บทความนี้
| แอปผู้ดูแลระบบ | แอปผู้ใช้ปลายทาง | |
|---|---|---|
| ภาษา | Java | Java |
| Backend Framework | Spring Boot | Spring Boot |
| Frontend | Thymeleaf (server-side rendering) + TypeScript | Vue 3 + TypeScript |
| รูปแบบ API | MVC (Controller → View) | REST API (Controller → JSON) |
Backend ของทั้งสองแอปคือ Java + Spring Boot เหมือนกัน ส่วนที่แยกกันคือสถาปัตยกรรม frontend แอปผู้ดูแลระบบใช้ server-side rendering ด้วย Thymeleaf ส่วนแอปผู้ใช้ปลายทางเป็น SPA ด้วย Vue 3 ทั้งสองใช้ TypeScript จึงมีแนวทางเดียวกันคือ type-safe UI implementation
การสร้างโครงสร้างสองเสาหลักนี้ทำให้บันทึก implementation pattern ของ Spring Boot / Java / Vue 3 / TypeScript ได้จากทั้งสองมุมมอง series บทความนี้เขียนขึ้นเพื่อเป็นทั้ง “บันทึกสิ่งที่สร้าง” และหนังสืออ้างอิง/ตำราสำหรับกลับมาอ่านทีหลังเกี่ยวกับเทคโนโลยีทั้ง 4 นี้ แต่ละบทความเจาะจงหัวข้อเดียว จึงอ้างอิงเดี่ยวๆ ได้เมื่อต้องการค้นหาเทคโนโลยีหรือ implementation pattern เฉพาะ
ความแตกต่างสำคัญจากแอปผู้ดูแลระบบ: มี Public Area ที่ใช้ได้โดยไม่ต้อง Login
แอปผู้ดูแลระบบทุกฟีเจอร์ต้อง login ออกแบบมาโดยสมมติว่ามีแต่พนักงานเข้าถึงได้ ไม่มีหน้าจอใดที่เข้าถึงได้ในสถานะไม่ได้ login
ในทางกลับกัน แอปผู้ใช้ปลายทางมี Public Area ที่ใช้ได้โดยไม่ต้อง login
| แอปผู้ดูแลระบบ | แอปผู้ใช้ปลายทาง | |
|---|---|---|
| ฟีเจอร์ที่ใช้ได้โดยไม่ login | ไม่มี (ทุกหน้าต้อง login) | รายการผลงาน・ละเอียด・ตรวจสอบสต็อก・แนะนำ |
| ฟีเจอร์ที่ใช้ได้หลัง login | จัดการลูกค้า・จัดการสต็อก・รายงานยอดขาย ฯลฯ | My page・สมัครเช่า・ชำระเงิน・ประวัติ |
| กลุ่มเป้าหมาย | พนักงานภายใน | ผู้ใช้ทั่วไป (รวมก่อนสมัครสมาชิก) |
เพื่อให้ flow “ดูผลงานก่อน แล้วสมัครสมาชิกเมื่ออยากเช่า” เป็นไปได้ Public Area นี้จึงเป็นส่วนออกแบบที่จำเป็น การค้นหา・ดู・ตรวจสอบสต็อกทำได้ครบโดยไม่ต้อง authentication
เหตุผลที่แยก repository แทนที่จะรวมกัน รายละเอียดการตัดสินใจนั้นเขียนไว้ในบทความแยกต่างหาก
→ เหตุใดจึงแยก Admin Panel และ Customer App เป็น Repository ต่างกัน — สิ่งที่ได้และสิ่งที่เสีย
การเลือก Tech Stack
| Layer | เทคโนโลยี |
|---|---|
| Backend | Spring Boot 3 + MyBatis + Spring Security |
| Frontend | Vue 3 + TypeScript + Vite |
| Database | PostgreSQL (อิงจาก dvdrental + ขยายด้วย Flyway) |
| การชำระเงิน | PayPay Dynamic QR Code API / PAY.JP |
| Deployment | รวม frontend เข้า jar ด้วย mvn package เดียว |
แอปผู้ดูแลระบบเป็น Spring Boot + Thymeleaf แต่แอปนี้เลือกใช้ โครงสร้างแยกที่ให้ Spring Boot ทำหน้าที่เป็น REST API อย่างเดียว และสร้าง UI ด้วย Vue 3 เหตุผลที่เลือกแบบนี้เขียนรายละเอียดไว้ในบทความถัดไป
→ เหตุใดจึงเลือกโครงสร้าง Spring Boot Backend + Vue 3 Frontend แยกกัน และวิธีเชื่อมต่อจริงๆ
โครงสร้างหน้าจอ
การเปลี่ยนหน้าทั้งหมดถูกจัดการโดย App.vue ตัวเดียวผ่าน state currentPage
ไม่ใช้ Vue Router แต่สลับหน้าด้วยการแสดง/ซ่อน component
เรื่องการคำนึงถึงลิขสิทธิ์
โครงสร้างและ layout ของ UI เป็น implementation ต้นฉบับที่ไม่พึ่งพา design ของ service ที่มีอยู่จริง โดยอ้างอิงจาก service รายใหญ่จริง เช่น เว็บ EC และบริการสตรีมมิ่งวิดีโอ ภาพ title และวิดีโอตัวอย่างก็ใช้แนวทางเดียวกัน คือใช้เนื้อหาที่สร้างด้วย AI แทนผลงานหรือตัวละครที่มีอยู่แล้ว
① Header / Navigation ที่ใช้ร่วมกัน

Header มีโครงสร้าง 3 ชั้น (top bar, masthead, global tabs) เมื่อเข้าสู่หน้าชำระเงิน header ปกติจะถูกซ่อนและเปลี่ยนเป็น header “Isolated Checkout” ที่เรียบง่ายกว่า
<!-- App.vue (ส่วนที่ตัดมา) -->
<header class="masthead" v-if="!isCheckoutFlow">
<!-- Header ปกติ -->
</header>
<header class="checkout-masthead" v-if="isCheckoutFlow">
<div class="brand">CINEMA DAYS</div>
<div class="secure-note">🔒 การชำระเงินดำเนินการผ่านการเชื่อมต่อที่ปลอดภัย</div>
</header>
→ เหตุใดหน้าชำระเงินควรเป็น “Isolated Checkout” และวิธี Implement
type ของ currentPage กำหนดเป็น union literal และไม่ใช้ Vue Router การไม่ต้องออกแบบ routing สำหรับการเปลี่ยนแท็บ ทำให้ state transition เรียบง่ายในแอปขนาดเล็ก
// App.vue (ส่วนที่ตัดมาของ type currentPage)
type PageType =
| 'films' | 'cd-rentals' | 'streaming'
| 'ranking' | 'feature' | 'detail'
| 'auth' | 'member-register' | 'member-register-confirm'
| 'mypage' | 'checkout'
const currentPage = ref<PageType>('films')
→ การตัดสินใจออกแบบโดยใช้ตัวแปร currentPage ง่ายๆ เพื่อจัดการสถานะหน้าใน App.vue
② หน้ารายการผลงาน (FilmsView.vue)

Filter ผลงาน implement ด้วย computed ของ Vue 3 เท่านั้น ไม่มีการเรียก API เพิ่ม
สถานะเปิด/ปิดของ sidebar เก็บไว้ใน localStorage
<!-- FilmsView.vue (ส่วน filter ที่ตัดมา) -->
<ul class="filter-list">
<li
v-for="cat in categoryOptions"
:key="cat"
:class="{ 'filter-active': selectedCategory === cat }"
@click="applyCategoryFilter(cat)"
>{{ cat }}</li>
</ul>
→ การ Implement Front-End Complete Filter Search ด้วย Vue 3 computed
→ การรักษาสถานะเปิด/ปิด Sidebar ด้วย localStorage พร้อมป้องกัน Flicker ในการ Render ครั้งแรก
③ หน้าละเอียดผลงาน (FilmDetailView.vue)

ภาพ Title และวิดีโอตัวอย่างสร้างด้วย Gemini Ultra
ภาพ title (poster visual) และวิดีโอตัวอย่างที่แสดงในหน้าละเอียดผลงาน สร้างด้วย ฟีเจอร์สร้างภาพและวิดีโอของ Google Gemini Ultra
- คำนึงถึงลิขสิทธิ์ สร้างภายใต้เงื่อนไขที่ไม่พึ่งพาผลงานหรือตัวละครที่มีอยู่แล้ว
- ไม่กำหนด prompt แบบละเอียดตายตัว แต่รันแบบใกล้เคียง “ปล่อยให้ AI ตัดสินใจ”
- ผลลัพธ์คือได้เนื้อหาที่ไม่เนี้ยบเกินไปและมีเอกลักษณ์บางอย่าง ทำให้มี presence ใน UI มากขึ้น
การใช้เนื้อหาที่สร้างด้วย AI เป็นวิธีที่ใช้งานได้จริงมาก ทั้งในแง่ทำให้หน้าจอดูดีระหว่างพัฒนา และทำให้บทความอ่านสนุกขึ้น
อย่างไรก็ตาม ก็มีการเตรียม fallback ไว้สำหรับกรณีที่ภาพหรือวิดีโอขาดหาย
// FilmDetailView.vue (ส่วน fallback ภาพ title ที่ตัดมา)
const buildTitleImageCandidates = (filmId: number): string[] => [
`/media/film-${filmId}-title.jpg`,
`/media/film-${filmId}-title.png`,
]
ลองตัวเลือกทีละตัวตามลำดับ ถ้าล้มเหลวทั้งหมดจะตกไปที่การแสดง placeholder
→ วิธีที่ฉันใช้ Gemini Ultra สร้างโปสเตอร์ภาพยนตร์และ Thumbnail วิดีโอตัวอย่างสำหรับแอพเช่า DVD
หน้าละเอียดผลงานมีสองปุ่มคือ “เช่าเดี๋ยวนี้” และ “เพิ่มลงตะกร้า” เหตุผลที่แยกสองปุ่มนี้คือการตัดสินใจเชิง UX design
<!-- 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 ควรแยก ‘เพิ่มลงตะกร้า’ กับ ‘ซื้อเลย’ และวิธี Implement
เมื่อเปลี่ยนไปหน้าละเอียด resetDetailViewport จะบังคับสกรอลล์ไปด้านบนสุดและล้าง focus เพื่อไม่ให้ตำแหน่งสกรอลล์ของหน้าก่อนหน้าค้างอยู่ การเรียกทั้งจาก watch ของ film.filmId และ onMounted ครอบคลุมทั้งกรณี mount ครั้งแรกและกรณีที่ component เดียวกันถูกใช้ซ้ำ
// FilmDetailView.vue (ส่วนรีเซ็ตสกรอลล์/focus ที่ตัดมา)
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() เมื่อนำทางไปหน้าละเอียด
④ หน้า Login / สมัครสมาชิก (AuthView.vue)

การ login เป็นการเรียก REST ปกติจาก frontend ไปยัง backend API
ไม่ใช้ filter chain ของ Spring Security แต่ตรวจสอบด้วยมือผ่าน BCryptPasswordEncoder
// AuthController.java (ส่วน login ที่ตัดมา)
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
AuthMapper.AuthRecord record = authMapper.findByEmail(request.email());
if (record == null) {
// ส่งข้อความเดียวกันแม้ email จะไม่มีอยู่จริง (ป้องกันการไล่เดา)
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, "Login สำเร็จ", "/films"));
}
⑤ ฟอร์มสมัครสมาชิก (MemberRegistrationView.vue)

เป็น flow 3 ขั้นตอน: กรอกข้อมูล → ยืนยัน → สมัครเสร็จสมบูรณ์ หน้ายืนยันแสดงค่าที่กรอกแบบอ่านอย่างเดียว และย้อนกลับไปแก้ไขได้
กลไกการจัดการขั้นตอน
การเปลี่ยนหน้าไม่ใช้ Vue Router แต่จัดการด้วย currentPage ref ใน App.vue
ค่าที่กรอกในฟอร์มบันทึกชั่วคราวไว้ใน registerDraft แล้วส่งต่อไปยังหน้ายืนยันผ่าน props โดยตรง
// App.vue (ส่วน flow สมัครสมาชิกที่ตัดมา)
const registerDraft = ref<RegisterRequest | null>(null)
const openMemberRegisterConfirm = (payload: RegisterRequest) => {
registerDraft.value = payload // บันทึกค่าที่กรอกชั่วคราว
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 แค่ส่งค่าที่กรอกให้ parent ผ่าน emit('confirm', { ...form }) เท่านั้น โดยตัวมันเองไม่รู้ว่า “จะไปหน้าไหนต่อ” เป็น pattern ที่รวมการควบคุมการเปลี่ยนหน้าไว้ที่ parent component
การ Validate ข้อมูลที่กรอก
การตรวจสอบรหัสผ่านทำภายใน @submit.prevent="goConfirm" แทนที่จะพึ่ง required ของ HTML จะตรวจสอบด้วย TypeScript อย่างชัดเจนว่ารหัสผ่านตรงกัน ความยาว และมีตัวอักษร/ตัวเลขผสมกัน
// 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 ป้องกันการส่งซ้ำ และแสดง error ในหน้าจอ
// MemberRegistrationConfirmView.vue (ส่วนการสมัครที่ตัดมา)
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)
เป็น drawer UI ที่ slide เข้ามาจากขอบขวา เปิดเมื่อคลิกไอคอนตะกร้าที่หน้ารายการหรือหน้าละเอียดผลงาน

สถานะตะกร้าถูกบันทึกถาวรใน localStorage จึงยังคงเนื้อหาอยู่แม้ปิดหน้าไปแล้ว
// useCart.ts (ส่วนเพิ่มลงตะกร้าที่ตัดมา)
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
}
⑦ My Page (MyPageView.vue)

ข้อมูล My page ได้มาจากการ JOIN table เดิมของ dvdrental สถานะการเช่าคำนวณแบบ dynamic ด้วย CASE expression ของ SQL
// MyPageMapper.java (ส่วนประวัติการเช่าที่ตัดมา)
@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)
หน้าชำระเงินใช้โครงสร้าง “Isolated Checkout” ที่ซ่อน header/nav ปกติ

Implement วิธีชำระเงินสองแบบคือ PAY.JP และ PayPay
สำหรับ PayPay จะสร้าง Dynamic QR Code และพาไปยังแอป PayPay
ตรวจจับการชำระเงินสำเร็จด้วยการ polling
// PayPayService.java (ส่วนสร้าง QR code ที่ตัดมา)
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 Rental Payment");
qrCode.setRequestedAt(Instant.now().getEpochSecond());
return paymentApi.createQRCode(qrCode);
}
สำหรับ PAY.JP จะ mount Payjp.js ที่ฝั่ง frontend ข้อมูลบัตรจึงไม่ผ่าน server ของเราเลย (สอดคล้องกับ PCI DSS)
การปรับแต่ง DB ของ dvdrental
เนื่องจาก dvdrental เป็น sample DB จึงเพิ่ม table ที่จำเป็นสำหรับรันแอปผู้ใช้ปลายทาง
จัดการการเปลี่ยนแปลง schema ด้วย Flyway
→ การเพิ่ม Schema Migration อย่างค่อยเป็นค่อยไปด้วย Flyway
Table ที่เพิ่ม ① customer_authentication
Table customer ของ dvdrental มีแค่ข้อมูลธุรกิจ (ชื่อ ที่อยู่ ฯลฯ) ไม่มี password hash ที่จำเป็นสำหรับ authentication จึงสร้าง table สำหรับ 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 อ้างอิง table นี้โดยตรง
// 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);
Table ที่เพิ่ม ② rental_tier
rental_rate ใน table film ของ dvdrental เป็นตัวเลขแบบราบเรียบ
จึงเพิ่ม master table สำหรับแบ่งประเภทเช่น “ผลงานใหม่” “ผลงานกึ่งใหม่” “มาตรฐาน” เพื่อให้กรองได้
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
);
-- เพิ่ม column FK ให้ table film
ALTER TABLE public.film
ADD COLUMN rental_tier_id INTEGER REFERENCES public.rental_tier(rental_tier_id);
API รายการผลงาน JOIN กับ rental_tier นี้และ return tier_code เพื่อใช้กรองที่ฝั่ง frontend
// 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
LLM (Ollama / OpenAI) สร้าง tag บรรยากาศของภาพยนตร์อัตโนมัติ บันทึกไว้ใน column taste_tags ของ table film
ALTER TABLE film ADD COLUMN IF NOT EXISTS taste_tags TEXT[];
Spring Boot batch ส่งชื่อภาพยนตร์และคำบรรยายให้ LLM แล้วบันทึก tag array ที่ได้กลับมาเป็น TEXT[]
การ mapping array type ของ PostgreSQL กับ Spring Boot และ implementation ของ batch process เขียนไว้ในบทความแยกต่างหาก
→ การรวม LLM Batch (Ollama/OpenAI) สำหรับ Auto-Generate Tags ภาพยนตร์เข้ากับ Spring Boot
→ วิธีการ Map PostgreSQL Array Types (text[]) กับ Spring Boot + JPA
โครงสร้าง API ของ Backend
REST API แยก public endpoint และ endpoint ที่ต้อง authentication
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 สร้าง QR code PayPay
GET /api/payments/paypay/{id} ตรวจสอบสถานะการชำระเงิน (polling)
POST /api/payments/payjp ลงทะเบียนบัตร/เรียกเก็บเงิน PAY.JP
ตอนพัฒนาใช้โครงสร้างคนละ origin คือ localhost:5173 (Vue) → localhost:8082 (Spring Boot)
ใน production ใช้ mvn package เดียวรวม build output ของ frontend เข้า static/ แล้วให้ Spring Boot serve
→ บันทึกการฝ่า CORS และ Proxy ใน Vue 3 + Vite + Spring Boot
→ การ Bundle Frontend Build ของ Vue3/React กับ Spring Boot เพื่อ Serve อัตโนมัติ
ฟีเจอร์ที่ Implement แล้ว
Public Area (ไม่ต้อง Login)
- รายการผลงาน (กรองตามหมวดหมู่・ประเภทเช่า・ความยาว, ค้นหาด้วยคำอิสระ)
- ละเอียดผลงาน (ภาพ title・วิดีโอตัวอย่าง・สถานะสต็อก・นักแสดง)
- แนะนำสำหรับผู้ที่ไม่ได้ login
- กรองด้วย tag บรรยากาศที่ LLM สร้างอัตโนมัติ (กำลัง implement)
Member Area (หลัง Login)
- สมัครสมาชิก (3 ขั้นตอน: กรอก → ยืนยัน → เสร็จสมบูรณ์)
- Login・Logout
- My page (สรุปการใช้งาน, ประวัติการเช่า, ประวัติการชำระเงิน)
- แก้ไขข้อมูลสมาชิก・เปลี่ยนอีเมล
- flow ยกเลิกสมาชิก (มีหน้ายืนยัน)
- แนะนำสำหรับสมาชิกที่ login แล้ว・รายการเช่าทีหลัง
การชำระเงิน・การเช่า
- flow “เพิ่มลงตะกร้า” สำหรับเพิ่มผลงานลงตะกร้า (cart drawer)
- flow “เช่าเดี๋ยวนี้” ที่ไปสู่การเช่าทันที
- ชำระด้วยบัตรเครดิต PAY.JP (ลงทะเบียนบัตรและใช้ซ้ำได้)
- ชำระด้วย PayPay Dynamic QR Code (ตรวจจับความสำเร็จด้วย polling)
ปัญหาที่เกิดระหว่างพัฒนา
อุบัติเหตุ Encoding จากการแก้ไขข้อความเป็นชุด
ระหว่างทำงาน เครื่องมือแทนที่ข้อความเป็นชุดเขียนออกมาแล้วเกิด garbling ที่มีสาเหตุจาก encoding ทำให้ syntax ของไฟล์ Vue หลายไฟล์พัง
- “สร้างเวอร์ชันที่ถูกต้องใหม่ทั้งหมด” เร็วกว่า “ไล่แก้ทีละส่วน”
- หลังกู้คืน ตรวจสอบหน้ารายการ・feature・ranking・ละเอียด ตามลำดับก่อน merge เข้า develop
ได้เรียนรู้ว่างานที่เน้นการแทนที่ข้อความ ควรกำหนดขั้นตอนกู้คืนไว้ล่วงหน้าโดยสมมติว่าจะเกิดอุบัติเหตุ encoding
→ อุบัติเหตุ Encoding ที่เกิดขึ้นระหว่างการแก้ไขข้อความเป็นชุด และการกู้คืนด้วยการสร้างใหม่ทั้งหมด
ฟีเจอร์ที่กำลัง Implement / ยังไม่เริ่ม
หัวข้อนี้จะอัปเดตตามความคืบหน้าของการพัฒนา
- เชื่อมต่อ flow สมัครสมาชิกกับ table
customer_authenticationให้สมบูรณ์ - logic การจองสต็อกสำหรับ production (ปัจจุบันอ้างอิง sample data เท่านั้น)
- จัดการระยะเวลาเช่า (flow ต่ออายุ・คืน)
- แจ้งเตือนยืนยันการสมัครทางอีเมล
- Deploy ไปยัง AWS (โครงสร้าง ECS/Fargate เดียวกับแอปผู้ดูแลระบบ)
แผนที่บทความ (บทความที่เขียนแล้ว/กำลังเขียนใน series นี้)
รวบรวมบทความที่เขียนระหว่างการสร้างแอปนี้ แต่ละบทความเจาะจงหัวข้อเดียว จึงอ้างอิงเดี่ยวๆ ได้เมื่อต้องการค้นหาหัวข้อเฉพาะของ Spring Boot / Java / Vue 3 / TypeScript
บทความที่มี ※กำลังสร้าง ต่อท้ายชื่อ คือสถานะ draft หรือก่อนโพสต์
■ การตัดสินใจออกแบบภาพรวมโปรเจกต์
| หัวข้อ | บทความ |
|---|---|
| เหตุผลที่แยก repository จากแอปผู้ดูแลระบบ | เหตุใดจึงแยก Admin Panel และ Customer App เป็น Repository ต่างกัน — สิ่งที่ได้และสิ่งที่เสีย |
| ภาพอนาคตในฐานะ platform | วิสัยทัศน์การพัฒนาระบบ DVD Rental ให้เป็น Platform แบบ DMM — จะวางก้าวแรกไว้ที่ไหน |
| เหตุผลที่ไม่ออกแบบ DB จากศูนย์ | การเลือกที่จะไม่ออกแบบ Table ตั้งแต่ต้น — การพัฒนาบน PostgreSQL Sample DB dvdrental |
■ โครงสร้าง Backend
| หัวข้อ | บทความ |
|---|---|
| เหตุผลที่เลือกโครงสร้างแยก Vue 3 + REST API | เหตุใดจึงเลือกโครงสร้าง Spring Boot Backend + Vue 3 Frontend แยกกัน และวิธีเชื่อมต่อจริงๆ |
| เหตุผลที่เลือก MyBatis แทน JPA | เหตุใดจึงเลือก MyBatis แทน JPA สำหรับ Spring Boot API Server |
| การเพิ่ม DB schema แบบค่อยเป็นค่อยไปด้วย Flyway | การเพิ่ม Schema Migration อย่างค่อยเป็นค่อยไปด้วย Flyway |
| Batch สร้าง tag ภาพยนตร์อัตโนมัติด้วย LLM | การรวม LLM Batch (Ollama/OpenAI) สำหรับ Auto-Generate Tags ภาพยนตร์เข้ากับ Spring Boot |
| การ Mapping array type ของ PostgreSQL กับ Spring Boot | วิธีการ Map PostgreSQL Array Types (text[]) กับ Spring Boot + JPA |
■ โครงสร้าง Frontend
■ UX Design・Payment Flow
| หัวข้อ | บทความ |
|---|---|
| เหตุผลที่ควรแยก “เพิ่มลงตะกร้า” กับ “ซื้อเลย” | ทำไมเว็บ EC ควรแยก ‘เพิ่มลงตะกร้า’ กับ ‘ซื้อเลย’ และวิธี Implement |
| เหตุผลที่หน้าชำระเงินควรเป็น “Isolated Checkout” | เหตุใดหน้าชำระเงินควรเป็น “Isolated Checkout” และวิธี Implement |
■ Development Environment
| หัวข้อ | บทความ |
|---|---|
| รัน Maven / Node บน Windows โดยไม่พึ่ง PATH | Configuration โปรเจกต์แบบ Self-Contained: รัน Maven / Node / Java บน Windows โดยไม่พึ่ง PATH |
■ บทความคู่กันจากฝั่งแอปผู้ดูแลระบบ
รวมบทความที่เขียนฝั่งแอปผู้ดูแลระบบซึ่งเกี่ยวข้องโดยตรงกับแอปผู้ใช้ปลายทางนี้
บทส่งท้าย
ตอนที่สร้างแอปผู้ดูแลระบบ สิ่งที่ค้างคาใจตลอดคือ “มีที่ให้พนักงานจัดการข้อมูลลูกค้าแล้ว แต่ไม่มีที่ให้ลูกค้าใช้เอง”
แอปผู้ใช้ปลายทางนี้คือโปรเจกต์ที่เริ่มขึ้นเพื่อตอบคำถามนั้น
แต่ยังสร้างไม่เสร็จ
ส่วนที่จำเป็นเพื่อให้เป็น “service ที่ใช้งานได้จริง” เช่น payment flow และ logic การจองสต็อก ยังไม่ครบทั้งหมด ฉันกำลังค่อยๆ ทำให้เสร็จสมบูรณ์ พร้อมบันทึกการตัดสินใจออกแบบและ implementation ไว้ในแต่ละบทความ
series บทความทั้งหมดนี้เขียนขึ้นเพื่อเป็นหนังสืออ้างอิง/ตำราที่รวบรวม implementation pattern ของ Spring Boot / Java / Vue 3 / TypeScript ผ่านการพัฒนาแอปจริง เมื่อต้องการค้นหาเทคโนโลยีหรือหัวข้อเฉพาะ ให้อ้างอิงหมวดหมู่ที่เกี่ยวข้องจากแผนที่บทความโดยตรง
ลิงก์ของแต่ละบทความจะเติมเข้าไปตามลำดับจากบทความที่โพสต์แล้ว