สร้างแอป DVD Rental สำหรับผู้ใช้ปลายทาง — โครงสร้าง Vue 3 + Spring Boot คู่กับแอปผู้ดูแลระบบ พร้อมแผนที่บทความ

Vue3 Spring Boot TypeScript PostgreSQL

บทนำ

ก่อนหน้านี้ฉันสร้างแอปผู้ดูแลระบบโดยอิงจาก 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 บทความนี้

แอปผู้ดูแลระบบแอปผู้ใช้ปลายทาง
ภาษาJavaJava
Backend FrameworkSpring BootSpring Boot
FrontendThymeleaf (server-side rendering) + TypeScriptVue 3 + TypeScript
รูปแบบ APIMVC (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เทคโนโลยี
BackendSpring Boot 3 + MyBatis + Spring Security
FrontendVue 3 + TypeScript + Vite
DatabasePostgreSQL (อิงจาก 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 / สมัครสมาชิก

การ 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 เข้ามาจากขอบขวา เปิดเมื่อคลิกไอคอนตะกร้าที่หน้ารายการหรือหน้าละเอียดผลงาน

Cart drawer

สถานะตะกร้าถูกบันทึกถาวรใน 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

ข้อมูล 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

หัวข้อบทความ
วิธีแก้ปัญหา CORS / Proxy ตอนพัฒนาบันทึกการฝ่า CORS และ Proxy ใน Vue 3 + Vite + Spring Boot
โครงสร้างรวม frontend เข้า jar สำหรับ productionการ Bundle Frontend Build ของ Vue3/React กับ Spring Boot เพื่อ Serve อัตโนมัติ
Implement filter search ด้วย computed อย่างเดียวการ Implement Front-End Complete Filter Search ด้วย Vue 3 computed
รักษาสถานะ sidebar ด้วย localStorage พร้อมป้องกัน flickerการรักษาสถานะเปิด/ปิด Sidebar ด้วย localStorage พร้อมป้องกัน Flicker ในการ Render ครั้งแรก
เลือกไม่รวม desktop กับ mobile ด้วย responsive CSSการเลือกไม่รวม Desktop และ Mobile ด้วย Responsive CSS และวิธีการตรวจสอบ
สร้างโปสเตอร์・วิดีโอภาพยนตร์ด้วย Gemini Ultraวิธีที่ฉันใช้ Gemini Ultra สร้างโปสเตอร์ภาพยนตร์และ Thumbnail วิดีโอตัวอย่างสำหรับแอพเช่า DVD
จัดการสถานะหน้าจอด้วย currentPage ของ App.vueการตัดสินใจออกแบบโดยใช้ตัวแปร currentPage ง่ายๆ เพื่อจัดการสถานะหน้าใน App.vue
เหตุผลที่เพิ่ม scrollTo + blur ตอนเปลี่ยนไปหน้าละเอียดเหตุใดฉันจึงเพิ่ม scrollTo(0, 0) และ blur() เมื่อนำทางไปหน้าละเอียด
อุบัติเหตุ encoding จากการแก้ไขข้อความเป็นชุดและการกู้คืนอุบัติเหตุ Encoding ที่เกิดขึ้นระหว่างการแก้ไขข้อความเป็นชุด และการกู้คืนด้วยการสร้างใหม่ทั้งหมด

■ UX Design・Payment Flow

หัวข้อบทความ
เหตุผลที่ควรแยก “เพิ่มลงตะกร้า” กับ “ซื้อเลย”ทำไมเว็บ EC ควรแยก ‘เพิ่มลงตะกร้า’ กับ ‘ซื้อเลย’ และวิธี Implement
เหตุผลที่หน้าชำระเงินควรเป็น “Isolated Checkout”เหตุใดหน้าชำระเงินควรเป็น “Isolated Checkout” และวิธี Implement

■ Development Environment

หัวข้อบทความ
รัน Maven / Node บน Windows โดยไม่พึ่ง PATHConfiguration โปรเจกต์แบบ Self-Contained: รัน Maven / Node / Java บน Windows โดยไม่พึ่ง PATH

■ บทความคู่กันจากฝั่งแอปผู้ดูแลระบบ

รวมบทความที่เขียนฝั่งแอปผู้ดูแลระบบซึ่งเกี่ยวข้องโดยตรงกับแอปผู้ใช้ปลายทางนี้

หัวข้อบทความ
โครงสร้างรวมของแอปผู้ดูแลระบบ (dvdrental + Spring Boot + Thymeleaf)การสร้างแอพจัดการ DVD Rental ด้วย Spring Boot + Thymeleaf บน PostgreSQL dvdrental Sample DB
Deploy แอปผู้ดูแลระบบไปยัง AWS ECS/Fargate + RDSConfiguration, Operations, และ Security สำหรับ Deploy Spring Boot + Thymeleaf + PostgreSQL Admin App ไปยัง AWS ECS/Fargate + RDS
วิธี localize dvdrental เป็นภาษาญี่ปุ่นวิธีที่ฉัน Localize ฐานข้อมูลตัวอย่าง dvdrental เป็นภาษาญี่ปุ่น: ใช้ SQL และ CSV ร่วมกันเพื่อสร้างข้อมูลสำหรับหน้า Admin
รักษาเงื่อนไขการค้นหาข้ามหน้า (@SessionAttributes)การเก็บรักษาเงื่อนไขการค้นหาข้ามหน้าด้วย @SessionAttributes ของ Spring MVC
ค้นพบต้นทุนคงที่ของ ECS Fargate (ALB + NAT Gateway)ค้นพบต้นทุนคงที่ของ ECS Fargate (ALB + NAT Gateway) และการทบทวนสถาปัตยกรรม
ตรวจสอบ log ของ Spring Boot ที่รันบน ECS ด้วย CloudWatch Logsวิธีตรวจสอบ Log Spring Boot App ที่รันบน AWS ECS ด้วย CloudWatch Logs
Deploy ไปผิด region ด้วย CDK และการแก้ไขการ Deploy ไปผิด Region ด้วย AWS CDK + PowerShell + SSO และการแก้ไขปัญหา
สลับ active profile ของ Spring Boot ระหว่าง Docker/ECSการออกแบบการสลับ Active Profile ของ Spring Boot ระหว่าง docker-compose และ ECS
ความสัมพันธ์ระหว่าง encoding ของ PostgreSQL กับ Windowsการต่อสู้กับปัญหา Encoding ของ PostgreSQL บน Windows + Docker

บทส่งท้าย

ตอนที่สร้างแอปผู้ดูแลระบบ สิ่งที่ค้างคาใจตลอดคือ “มีที่ให้พนักงานจัดการข้อมูลลูกค้าแล้ว แต่ไม่มีที่ให้ลูกค้าใช้เอง”

แอปผู้ใช้ปลายทางนี้คือโปรเจกต์ที่เริ่มขึ้นเพื่อตอบคำถามนั้น

แต่ยังสร้างไม่เสร็จ

ส่วนที่จำเป็นเพื่อให้เป็น “service ที่ใช้งานได้จริง” เช่น payment flow และ logic การจองสต็อก ยังไม่ครบทั้งหมด ฉันกำลังค่อยๆ ทำให้เสร็จสมบูรณ์ พร้อมบันทึกการตัดสินใจออกแบบและ implementation ไว้ในแต่ละบทความ

series บทความทั้งหมดนี้เขียนขึ้นเพื่อเป็นหนังสืออ้างอิง/ตำราที่รวบรวม implementation pattern ของ Spring Boot / Java / Vue 3 / TypeScript ผ่านการพัฒนาแอปจริง เมื่อต้องการค้นหาเทคโนโลยีหรือหัวข้อเฉพาะ ให้อ้างอิงหมวดหมู่ที่เกี่ยวข้องจากแผนที่บทความโดยตรง

ลิงก์ของแต่ละบทความจะเติมเข้าไปตามลำดับจากบทความที่โพสต์แล้ว

ส่งข้อความได้ตามสบาย

ไม่ว่าจะเป็นการว่าจ้างงาน แนะนำโปรเจกต์ ความคิดเห็น หรือคำถาม ยินดีรับทั้งหมด ฉันหวังเป็นอย่างยิ่งว่าจะได้เชื่อมต่อกับผู้ที่มีอุดมการณ์อันสูงส่งเช่นเดียวกัน ฉันจะมุ่งมั่นท้าทายในสิ่งที่ทุ่มเททั้งชีวิตต่อไป ขอขอบคุณและฝากเนื้อฝากตัวด้วยครับ/ค่ะ