Tech Blog

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

by y104
Vue3 Spring Boot TypeScript PostgreSQL

ที่มา

ก่อนหน้านี้ฉันสร้างแอปผู้ดูแลระบบโดยใช้ฐานข้อมูลตัวอย่าง PostgreSQL dvdrental

สร้างแอปผู้ดูแลระบบ DVD Rental ด้วย Spring Boot + Thymeleaf บน dvdrental Sample DB

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


Tech Stack

Layerเทคโนโลยี
BackendSpring Boot 3 + MyBatis + Spring Security
FrontendVue 3 + TypeScript + Vite
DBPostgreSQL (dvdrental)
PaymentPayPay Dynamic QR Code, PAY.JP credit card
DeploymentSingle 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 / ลงทะเบียน

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 drawer

สถานะ 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

หัวข้อบทความ
แก้ปัญหา CORS/Proxy ระหว่างการพัฒนาบันทึกการฝ่า CORS และ Proxy ด้วย Vue 3 + Vite + Spring Boot
Bundle frontend เข้า jar ใน productionBundle Frontend Vue3/React เข้า Spring Boot เพื่อ Serve อัตโนมัติ
Filter search ด้วย computed เท่านั้นใช้แค่ Vue 3 computed เพื่อสร้าง Frontend Filter Search
Persist สถานะ sidebar ด้วย localStorage โดยป้องกัน flashPersist สถานะเปิด/ปิด Sidebar ด้วย localStorage โดยป้องกัน Flash
ไม่รวม desktop และ mobile ด้วย responsive CSSการตัดสินใจไม่รวม Desktop และ Mobile ด้วย Responsive CSS เดียว
สร้างโปสเตอร์และวิดีโอด้วย Gemini Ultraสร้างโปสเตอร์ภาพยนตร์และวิดีโอตัวอย่างด้วย Gemini Ultra สำหรับแอป DVD Rental
การจัดการ screen state ด้วย App.vue currentPageการตัดสินใจออกแบบ: จัดการ Page State ด้วย currentPage แบบง่ายๆ ใน App.vue
เหตุใดเพิ่ม scrollTo + blur เมื่อ navigateเหตุใดฉันเพิ่ม scrollTo(0, 0) และ blur() เมื่อ Navigate ไปหน้ารายละเอียด
อุบัติเหตุ encoding จากการแก้ไขแบบ bulk และการกู้คืนอุบัติเหตุ Encoding จากการแก้ไขข้อความแบบ Bulk และการกู้คืนด้วยการสร้างใหม่ทั้งหมด

■ การออกแบบ 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 (dvdrental + Spring Boot + Thymeleaf)สร้างแอปผู้ดูแลระบบ DVD Rental ด้วย Spring Boot + Thymeleaf บน dvdrental Sample DB
Deploy admin app ขึ้น AWS ECS/Fargate + RDSDeploy Spring Boot + Thymeleaf + PostgreSQL Admin App ขึ้น AWS ECS/Fargate + RDS
วิธี localize dvdrental เป็นภาษาญี่ปุ่นวิธี Localize dvdrental PostgreSQL Sample DB เป็นภาษาญี่ปุ่นด้วย SQL และ CSV
Persist search conditions ข้ามหน้า (@SessionAttributes)Persist Search Conditions ข้ามหน้าด้วย Spring MVC @SessionAttributes
ค้นพบ fixed costs ของ ECS Fargate (ALB + NAT Gateway)ค้นพบ ECS Fargate Fixed Costs (ALB + NAT Gateway) และทบทวน Architecture
ตรวจสอบ log ของ Spring Boot ใน ECS ด้วย CloudWatch Logsวิธีตรวจสอบ Log Spring Boot App ที่รันบน AWS ECS ด้วย CloudWatch Logs
Deploy ผิด region ด้วย CDK และการ cleanupDeploy ผิด Region ด้วย AWS CDK + PowerShell + SSO และการ Cleanup
สลับ Spring Boot active profile ใน Docker/ECSการออกแบบสลับ Spring Boot Active Profile ใน docker-compose และ ECS
PostgreSQL encoding และ WindowsPostgreSQL Encoding และ Windows

บทสรุป

ตอนสร้าง admin app รู้สึกว่า “มีที่สำหรับพนักงานจัดการข้อมูลลูกค้าแล้ว แต่ยังไม่มีที่สำหรับลูกค้าใช้งานเอง” ความรู้สึกนั้นไม่เคยหายไป

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

อย่างไรก็ตาม มันยังไม่สมบูรณ์

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

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

Links ของแต่ละบทความจะถูกเพิ่มตามลำดับเมื่อมีการเผยแพร่

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

กรุณาส่งข้อความ หากมีคำปรึกษาด้านเทคนิค ความคิดเห็น หรือคำถาม