บทเรียน 06 — เว็บเช่าอุปกรณ์: availability และ capacity ของ demo ที่รันได้จริง
ผลลัพธ์การเรียนรู้
ผู้เรียนจะใช้ /examples/rental/, GET /api/availability และ POST /api/rentals ของ project นี้, ตรวจ D1 table rental_days, และอธิบายได้ว่าทำไม demo ใช้ one row ต่อวันเพื่อกัน capacity แบบ atomic. Lab นี้จำลองการ reserve (status: "reserved-demo") เท่านั้น ไม่ใช่ contract การเช่าหรือ payment จริง.
ต้องมีก่อนเริ่ม
- รัน
npm run db:localและให้npm run devทำงานที่http://localhost:3320ตามบท 05 - ใช้ข้อมูลติดต่อสมมติเท่านั้น;
DEMO_MODE=trueredactname/emailก่อน insert แต่ยังมี validation, daily limit และ capacity constraint - เลือกวันเริ่มในอนาคตไม่เกิน 365 วัน, วันสิ้นสุดหลังวันเริ่ม 1–30 วัน. Demo ใช้ช่วง
[start,end): รวมวันเริ่ม แต่ไม่รวมวันคืน
Prompt สำหรับสร้างเว็บให้เช่าอุปกรณ์
คัดลอก prompt นี้ไปใช้ในสำเนา starter ของผู้เรียนก่อนทำ lab ตัวอย่างใช้ชื่อ “หยิบยืม” และ contract เดียวกับหน้าเว็บที่เปิดดูได้ ภาพหน้าจอเป็นผลลัพธ์อ้างอิงสำหรับเทียบงาน ส่วนการกันจองเกินจำนวนต้องพิสูจน์จาก API และ D1
สร้างเว็บไซต์ให้เช่าอุปกรณ์ถ่ายภาพภาษาไทยชื่อ “หยิบยืม” ใน starter หลักสูตรนี้
ให้ผู้เรียนเลือกกล้อง ไฟ หรือชุดเสียง เลือกช่วงวันและจำนวน ตรวจของว่าง และจองสาธิตได้
อ่าน package.json, examples/rental/, src/worker.ts และ migrations/0001_initial.sql
กับ 0002_demo_retention.sql ก่อนลงมือ เพื่อใช้ contract และกติกาที่มีอยู่จริง
ใช้ HTML/CSS/vanilla JavaScript ไม่เพิ่ม dependency ไม่เพิ่มระบบชำระเงิน
สรุปแผนและ state ของฟอร์ม แล้วสร้าง examples/rental/{index.html,style.css,main.js}
รักษา API เดิมและเว็บอื่น ไม่แก้ migration ที่ apply แล้ว; ถ้าขาด API ให้รายงาน prerequisite
แนวภาพ: เหลืองสด ดำ ขาว จัดข้อมูลอุปกรณ์ให้ดูง่ายและใช้งานได้เร็ว
ใช้ฟอนต์ไทยใน public/fonts และภาพ /images/rental-kit.webp
มี hero, รายการอุปกรณ์, ตัวเลือกช่วงวัน/จำนวน, ผลตรวจของว่าง,
ฟอร์มข้อมูลสาธิต, ขั้นตอนการเช่า และข้อความว่าการจองไม่มีผลทางการค้า
โหลดอุปกรณ์ ราคา และ stock จาก GET /api/catalog ไม่ใช้ราคาที่ hard-code เป็น authority
ฟอร์มใช้ equipmentId, start, end, quantity โดยวันที่เป็นปฏิทิน UTC
ใช้ช่วง [start,end): รวมวันรับ ไม่รวมวันคืน ช่วงเช่า 1–30 วันตาม server validation
เรียก GET /api/availability?equipmentId=...&start=...&end=...&quantity=...
แสดง loading/available/unavailable/error และ totalPrice/currency/days ตาม API
เมื่อเปลี่ยนตัวเลือก ให้ปิดการส่งทันที ยกเลิกผลเก่า และป้องกัน response ที่มาช้าทับค่าปัจจุบัน
ส่ง JSON {equipmentId,start,end,quantity,name,email,consent,website} ไป POST /api/rentals
ส่ง Idempotency-Key และใช้ key เดิมเมื่อ retry ข้อมูลเดิม
ไม่ส่ง price,totalPrice,stock หรือ status ให้ server เชื่อจาก client
D1 และ capacity trigger เป็นผู้ตัดสินจำนวนว่างตอนเขียนข้อมูล; preview ไม่ใช่การยืนยันจอง
เมื่อ 201 แสดง id,totalPrice,status และ expiresAt; demo hold หมดอายุภายใน 30 นาที
เมื่อ 409 UNAVAILABLE ให้แจ้งว่าของเพิ่งถูกจองและตรวจ availability ใหม่
แสดง error.message ให้ผู้ใช้เข้าใจ เก็บข้อมูลฟอร์มไว้แก้ไขหรือ retry
ใช้ข้อมูลติดต่อสมมติ แจ้งการปกปิดชื่อ/อีเมลก่อนบันทึก D1 และไม่อ้างว่าเป็นสัญญาเช่าจริง
ทุก input มี label ใช้ keyboard ได้ มี focus/status ชัดเจน และมือถือ 390px ไม่ล้น
ตรวจ npm run build, npm run lint และใช้ชุดทดสอบ API เดิมสำหรับวันไม่ถูกต้อง,
ช่วงติดกันไม่ทับกัน, ของเต็ม, concurrent requests และ idempotency
ใช้เฉพาะพอร์ตที่จองไว้และ local D1 ของชุดทดสอบ ไม่ reset ฐานข้อมูลจริง
ส่งสรุป source, ภาพหน้าเว็บ, request/response ตัวอย่าง และผลทดสอบที่รันจริง
ผลลัพธ์ที่ควรได้: เว็บ หยิบยืม มีตัวเลือกอุปกรณ์ วัน และจำนวน พร้อมผล availability และเลขอ้างอิงเมื่อจองสาธิตสำเร็จ เทียบ ภาพผลลัพธ์ และ prompt ภาพอุปกรณ์ แล้วพิสูจน์เรื่อง stock ด้วย lab ด้านล่าง
Prompt ติดตามผลหลังสร้าง
ตรวจเว็บให้เช่าที่เพิ่งสร้าง แล้วแก้กรณีที่ไม่ผ่านโดยรักษา API contract เดิม
ลองเปลี่ยนวันอย่างรวดเร็วระหว่างโหลด, วันคืนเท่ากับวันรับรายการถัดไป,
ของไม่พอ, POST ตอบ 409, ส่งคำขอเดิมซ้ำ และเปิดบนมือถือ 390px
ตรวจว่าผลของ query เก่าไม่เปิดปุ่มจองให้ข้อมูลใหม่ และราคา/stock มาจาก D1
รายงานผลพร้อม request/response และหลักฐานจากชุดทดสอบ ไม่สรุปว่าจองได้จากข้อความบน UI อย่างเดียว
Contract และ schema ที่มีอยู่จริง
เปิดแผนภาพขนาดเต็ม พร้อม prompt และแหล่งข้อมูลที่ใช้สร้าง
ความสัมพันธ์เดิมยังครบ: UI ส่ง GET equipmentId,start,end,quantity ไป /api/availability และส่ง POST ฟิลด์เดียวกันพร้อมข้อมูลติดต่อสาธิตไป /api/rentals; availability อ่าน equipment + rental_days; rentals เขียนชุดคำสั่งด้วย D1 batch และ capacity trigger ตรวจข้อมูลใน write path
เปิดลำดับคำขอขนาดเต็ม พร้อม prompt และแหล่งข้อมูลที่ใช้สร้าง
ฝึกอ่านภาพ: อธิบายสถานการณ์ที่ GET ตอบว่าว่างแต่ POST ตอบ 409 UNAVAILABLE แล้วชี้ว่าจุดใดป้องกัน overbooking จริง
Worker รับ query equipmentId, start, end, quantity ที่ /api/availability, และ POST body เดียวกันเพิ่ม name, email, consent, website ที่ /api/rentals. เมื่อ DB binding พร้อม Worker อ่าน equipment, daily rate และ stock จาก D1; migration 0001_initial.sql seed ตาราง equipment และมี rentals, rental_days กับ trigger rental_days_capacity_before_insert.
rental_days แตกแต่ละ reservation เป็นหนึ่ง row ต่อวัน. Trigger รวม quantity ของ equipment/date เดียวกัน แล้ว abort ด้วย capacity_exceeded หากมากกว่า equipment.stock. Worker ส่ง insert reservation, insert days และ idempotency record ผ่าน db.batch(), ซึ่ง D1 ระบุว่าเป็น transaction: statement ทำตามลำดับและ rollback sequence เมื่อ statement ใดล้มเหลว (D1 batch()). นี่ทำให้ availability preview ไม่ใช่ authority; capacity ถูกตรวจอีกครั้งตรง write path.
Lab: ตรวจ availability และสร้าง reservation สาธิต
ถ้ายังไม่ได้เปิด local environment ให้ใช้ command จริงของ project:
npm run db:local npm run devผลที่คาดหวัง: local D1 มี tables จาก
migrations/0001_initial.sqlและหน้าhttp://localhost:3320/examples/rental/โหลด catalog จาก/api/catalog.กำหนดช่วงวันแบบ portable ด้วย Node (ไม่ hard-code วันที่ที่จะหมดอายุในหลักสูตร):
START=$(node -e "console.log(new Date(Date.now()+7*864e5).toISOString().slice(0,10)") END=$(node -e "console.log(new Date(Date.now()+8*864e5).toISOString().slice(0,10)") echo "$START to $END"เรียก availability endpoint ที่มีจริง:
curl -sS "http://localhost:3320/api/availability?equipmentId=camera&start=$START&end=$END&quantity=1"ผลที่คาดหวัง: JSON มี
available,remaining,totalPrice,currency,days; เช่น{"available":true,"remaining":2,"totalPrice":189000,"currency":"THB","days":1}เมื่อยังไม่มี reservation.totalPriceเป็น integer satang และcurrencyเป็นTHB.POST reservation demo.
Originต้องตรง local Worker,consentต้องtrue, honeypotwebsiteต้องว่าง และ key นี้ใช้ซ้ำได้เฉพาะ payload เดิม:curl -i -X POST http://localhost:3320/api/rentals \ -H 'Origin: http://localhost:3320' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: rental-lab-001' \ --data "{\"equipmentId\":\"camera\",\"start\":\"$START\",\"end\":\"$END\",\"quantity\":1,\"name\":\"Demo Learner\",\"email\":\"learner@example.test\",\"consent\":true,\"website\":\"\"}"ผลที่คาดหวัง: HTTP
201และ{id,totalPrice,currency:"THB",status:"reserved-demo",message}. ข้อความจริงคือจองอุปกรณ์ในระบบสาธิตแล้ว รายการนี้ไม่มีผลทางการค้า; จึงห้ามเปลี่ยน copy ของ lesson/page เป็น “ชำระเงินสำเร็จ”.Inspect rows ที่ Worker สร้างจริง:
npx wrangler d1 execute DB --local --command \ "SELECT rental_id, equipment_id, rental_date, quantity FROM rental_days ORDER BY rental_date" npx wrangler d1 execute DB --local --command \ "SELECT id, status, name, email, total_price FROM rentals ORDER BY created_at DESC LIMIT 3"ผลที่คาดหวัง: วันใน
[START, END)มี row ในrental_days;rentals.statusเป็นreserved-demo, และ name/email เป็น[demo-redacted].ทดสอบ validation และ conflict. ลอง
end=$STARTจะได้400 INVALID_DURATION; ลอง quantity มากกว่า stock ของ camera (3) จะได้400 INVALID_FIELD. สร้าง/ยิง requests ซ้อนกันจนเกิน stock ในวันเดียวกัน แล้ว Worker จะ map SQLite trigger error เป็น409envelope นี้:{"error":{"code":"UNAVAILABLE","message":"อุปกรณ์ไม่พอสำหรับช่วงวันที่เลือก"}}ทดสอบ boundary โดยสร้าง A
[START,END)และ B เริ่มEND: สองรายการไม่แชร์rental_dateจึงไม่ชนกัน. B ที่เริ่มก่อนENDหนึ่งวันใช้วันร่วมกันและกิน stock วันนั้น. นี่เป็นผลของ half-open interval ที่ schema/recursive SQL ทำจริง.ตรวจ browser path ต่อ: เปิด
/examples/rental/, เลือก camera และวันเดียวกับ command, ดู response จาก/api/availability, ส่ง form ด้วย demo identity. จากนั้นรัน:npm run typecheck npm run test npm run build
ขอบเขต demo กับ business implementation
เปิดแผนภาพสถานะขนาดเต็ม พร้อม prompt และแหล่งข้อมูลที่ใช้สร้าง
ภาพนี้แสดงเฉพาะสิ่งที่โค้ดทำแล้ว: รุ่นปัจจุบันคืน reserved-demo; “หมดผล” เป็นเงื่อนไขเวลาและ “deleted” หมายถึงไม่มีแถวแล้ว ไม่ใช่ค่าในคอลัมน์ status ส่วนสถานะ quote, hold, confirmed และ cancelled ต้องมี schema, authorization และ test เพิ่มก่อนอ้างว่าใช้งานจริง
| เรื่อง | DEMO_MODE=true ปัจจุบัน | งานผลิตจริงภายหลัง |
|---|---|---|
| state | ทุก POST สำเร็จเป็น reserved-demo และกิน local capacity |
นิยาม quote/hold/confirmed/cancelled, hold expiry, staff authorization และ audit |
| อุปกรณ์/ราคา | D1 seeded catalog เป็น authority ของ availability/price; D1 เก็บ reservation | product/catalog administration ที่มี authorization และ migration strategy |
| data | redact PII, ไม่มี notification/payment | data policy, staff access, communication และ lawful retention |
| capacity | trigger per equipment-day ใน D1 | load/concurrency, cancellation/release rule และ reconciliation ตามธุรกิจ |
ตารางขวาเป็น conceptual alternative ไม่ใช่สิ่งที่ code ใน demo อ้างว่าทำแล้ว. หากออกแบบ state จริง อาจใช้ quote_request ที่ไม่กิน capacity, hold ที่มี expiry และ confirmed_booking ที่กิน capacity; ต้องเพิ่ม schema/authorization/test ใหม่ทั้งหมด. Current demo ตั้งใจสอน transaction/capacity โดยไม่รับเงินจริง.
OWASP แนะนำ validate ทั้งรูปแบบและ business rule ตั้งแต่รับ input (Input Validation); Worker ตรวจ date จริง, duration, date in past/too far, equipment ID และ quantity ก่อนสร้าง statements. D1 statement ใช้ bind parameters ไม่ต่อ user input เป็น SQL (D1 Worker Binding API, OWASP SQL injection).
ข้อผิดพลาดที่พบบ่อย
| อาการ | สาเหตุ | วิธีแก้ |
|---|---|---|
404 หรือ response ไม่ใช่ API |
พิมพ์ endpoint ไม่ตรง Worker | ใช้ /api/availability และ /api/rentals เท่านั้น |
403 ORIGIN_REJECTED |
POST ไม่มี Origin ที่ตรง Worker | ใช้ header ตาม lab |
400 DATE_IN_PAST |
คัดลอกวันที่เก่าจากตัวอย่าง | สร้าง START/END จากขั้น 2 ใหม่ |
409 UNAVAILABLE |
reservation อื่นกิน quantity ในวันนั้น | เป็น expected business response; เลือกวัน/จำนวนใหม่แล้วเช็ก availability |
| ไม่เห็นข้อมูลติดต่อใน D1 | demo redaction ทำงาน | ตรวจ [demo-redacted]; อย่าใช้ PII เพื่อ “แก้” ผลนี้ |
แบบประเมิน
ผู้เรียนสาธิต GET availability, POST 201, D1 rental_days และ 409 capacity conflict. ต้องชี้ได้ว่าทำไม GET เป็น preview แต่ trigger/batch คือจุดตัดสินจริง และบอกได้ว่า reserved-demo ต่างจากสัญญาเช่าหรือ payment confirmation อย่างไร.
อ่านต่อ: มาตรฐานคุณภาพและ commerce และ บท deploy.