โปรเจกต์ Backend ของ Event Around พัฒนาด้วย FastAPI + PostgreSQL + Docker
- โครงสร้างโปรเจกต์ FastAPI
- PostgreSQL รันผ่าน Docker
- SQLAlchemy สำหรับเชื่อมฐานข้อมูล
- Alembic สำหรับ migration
- API ตรวจสถานะระบบ (
health) - API สมัครสมาชิกนักศึกษา
- API ยืนยันตัวตน (Auth) ครบชุดเบื้องต้น
- API หมวดหมู่กิจกรรม (Categories) ครบชุดพื้นฐาน
- API บันทึกกิจกรรมโปรด (Saved Events)
- API นำเข้ากิจกรรมจาก CSV และ JSON
- โครงคลาส UML (Skeleton Phase) ครบชุดใน
app/domain - สคริปต์ seed ข้อมูลตัวอย่าง
- ชุดทดสอบเบื้องต้น
- FastAPI
- SQLAlchemy
- PostgreSQL
- Alembic
- Docker
- ตอนนี้รัน local ได้ด้วย FastAPI + Docker PostgreSQL + ไฟล์
.envเดิม - โค้ดส่วน config และ database ยังถูกรวมอยู่ใน
app/coreเป็นหลัก - ยังไม่มีชั้นสำหรับ Render โดยตรง เช่น CORS, port จาก environment, และ blueprint สำหรับ deploy
- ยังไม่มี CI/CD workflow ที่แยกหน้าที่ชัดเจนระหว่างตรวจโค้ดกับ deploy
app/core/settings.pyเก็บ config จาก environment และคำนวณDATABASE_URLให้ถูกทั้ง local และ productionapp/db/เก็บBase,engine,SessionLocal, และget_dbapp/main.pyเป็นจุดเริ่ม app, ใส่ CORS, และ health endpoint สำหรับตรวจสถานะalembic/env.pyใช้DATABASE_URLจาก environment โดยตรง.github/workflows/ci.ymlใช้ตรวจ lint, format, test, และ migration validationrender.yamlใช้เป็น Render Blueprint สำหรับ web service + PostgreSQL
- CI ใช้ GitHub Actions เท่านั้น
- รันตอน
pull_requestและpushเข้าmain - ตรวจ dependencies, lint, format check, test, และ migration validation
- รันตอน
- CD ใช้ Render auto deploy หลัง merge เข้า
main- Render จะ build จาก
Dockerfile - ใช้ Render PostgreSQL ใน production
- รัน
alembic upgrade headก่อนเริ่ม service เพื่อให้ schema ตรงกับโค้ด
- Render จะ build จาก
- Secrets
- ฝั่ง GitHub เก็บเฉพาะ secrets ที่ workflow ต้องใช้จริง
- ฝั่ง Render เก็บ production secrets เช่น
JWT_SECRET_KEYและCORS_ORIGINS
app/core/settings.pyสำหรับอ่านค่าจาก environment และสร้างDATABASE_URLapp/db/base.pyและapp/db/session.pyสำหรับ SQLAlchemy engine/sessionapp/main.pyสำหรับ CORS และ health endpointrender.yamlสำหรับ Render Blueprint.github/workflows/ci.ymlสำหรับ CI บน GitHub Actionspyproject.tomlและpytest.iniสำหรับกติกา lint/testrequirements-dev.txtสำหรับเครื่องมือ dev เช่นruffและblack.env.exampleสำหรับตัวอย่าง config ที่ไม่ใช่ secret
- Push code ขึ้น GitHub
- สร้าง Render PostgreSQL
- สร้าง Web Service จาก repo นี้ หรือใช้
render.yaml - ตั้งค่า environment variables บน Render
- เปิด Auto Deploy ให้ merge เข้า
mainแล้ว deploy อัตโนมัติ - ตรวจ health endpoint ที่
/health - นำ base URL ของ Render ไปให้ frontend เรียกใช้งาน
- โปรเจกต์มีโครง UML แยกใน
app/domainเพื่อเตรียม OOP เต็มรูปแบบ - ในเฟสนี้ คลาสใน
app/domainเป็น skeleton (method signatures ครบ แต่ยังไม่ลง business logic ลึก) - API ปัจจุบันยังทำงานผ่าน service/repository เดิม และจะค่อย map เข้ากับ domain classes ในรอบถัดไป
User(abstract)StudentOrganizerEventCategoryEventEventManagerLocationServiceAuthManagerEventAroundSystem
- Python 3.11 ขึ้นไป
- Docker Desktop (รองรับ Docker Compose)
Windows (PowerShell):
python -m venv .venv
.\.venv\Scripts\Activate.ps1macOS/Linux:
python -m venv .venv
source .venv/bin/activatepip install -r requirements.txtสร้างไฟล์ .env ที่ root ของโปรเจกต์ แล้วใส่ค่าตัวอย่างนี้:
APP_NAME=Event Around API
APP_ENV=development
APP_DEBUG=true
API_V1_PREFIX=/api/v1
APP_TIMEZONE=Asia/Bangkok
POSTGRES_USER=event_user
POSTGRES_PASSWORD=event_pass
POSTGRES_DB=event_around_db
POSTGRES_HOST=localhost
POSTGRES_PORT=5435
DATABASE_URL=postgresql+psycopg://event_user:event_pass@localhost:5435/event_around_db
JWT_SECRET_KEY=change-me-in-production
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=60
REFRESH_TOKEN_EXPIRE_DAYS=7APP_TIMEZONEใช้กำหนด timezone สำหรับเวลาที่ส่งออกทาง API โดยค่า default คือAsia/Bangkok- ระบบเก็บค่า
datetimeในฐานข้อมูลเป็น UTC เพื่อให้ deploy บน server timezone อะไรก็ให้ผลลัพธ์เหมือนกัน - เวลาที่ตอบกลับไปยัง frontend จะถูกแปลงเป็นเวลาไทยเสมอและส่งในรูปแบบ ISO 8601 ที่มี offset เช่น
2026-04-30T15:20:00+07:00 - input ที่ส่งมาเป็น
datetimeแบบไม่มี timezone จะถูกตีความเป็นเวลาAsia/Bangkokก่อนแปลงไปเก็บเป็น UTC - ข้อมูลเก่าที่เป็น naive datetime ในคอลัมน์ legacy จะถูกอ่านเป็น UTC และ migration
0008_convert_legacy_datetimes_to_timestamptz.pyใช้แปลง schema ฝั่ง PostgreSQL ให้เป็นTIMESTAMP WITH TIME ZONE - access token และ refresh token ยังคำนวณอายุจาก UTC เพื่อให้การหมดอายุคงที่ในทุก environment แต่เมื่อ response มี field เวลา จะส่งออกเป็น Bangkok time
ตัวอย่าง response:
{
"success": true,
"message": "ดึงรายละเอียดกิจกรรมสำเร็จ",
"data": {
"eventId": 101,
"startTime": "2026-04-30T15:20:00+07:00",
"endTime": "2026-04-30T18:20:00+07:00",
"createdAt": "2026-04-25T09:00:00+07:00"
}
}docker compose up -d db
docker compose psalembic upgrade headถ้าเจอ error ลักษณะ relation "users" already exists แปลว่าเคยสร้างตารางไว้ก่อนหน้า ให้รีเซ็ตฐานข้อมูลแล้วรันใหม่:
docker compose down -v
docker compose up -d db
alembic upgrade headuvicorn app.main:app --reloadAPI จะรันที่ http://127.0.0.1:8000
curl http://127.0.0.1:8000/api/v1/healthผลลัพธ์ที่คาดหวัง:
{"success":true,"message":"Event Around API is running","data":null}รันแอป:
make runรันเทสต์ทั้งหมด:
make testตรวจ lint:
make lintจัด format โค้ด:
make formatหรือรันตรง:
python -m pytest -qseed ข้อมูลตัวอย่าง:
make seedโปรเจกต์นี้ใช้ pytest เป็นหลักในการรันทดสอบอัตโนมัติ และใช้ FastAPI TestClient สำหรับยิง API แบบไม่ต้องเปิด browser หรือเรียกผ่าน Postman ทุกครั้ง
เครื่องมือที่ใช้:
pytestสำหรับรันทดสอบและสรุปผลว่าpassed / failed / warningsเท่าไหร่FastAPI TestClientสำหรับทดสอบ API แบบเร็วและแยกจากการรันเซิร์ฟเวอร์จริง- SQLite in-memory +
StaticPoolในชุดทดสอบ เพื่อให้เทสเร็วและไม่กระทบฐานข้อมูลจริง
ทำไมวิธีนี้ดี:
- รันเร็ว และเห็นผลทันที
- เทสแยกจากข้อมูลจริง ลดความเสี่ยงทำข้อมูลใน DB พัง
- เขียน assert ได้ชัดเจน ทำให้จับ bug ได้ง่าย
คำสั่งรันเทสต์ทั้งโปรเจกต์:
python -m pytest -ra -qถ้าต้องการให้แสดง warning ด้วย:
python -m pytest -ra -W defaultบัญชีตัวอย่างที่ได้จากการ seed:
- ADMIN
- email:
admin@example.com - password:
Password123!
- email:
- ORGANIZER
- email:
organizer@example.com - password:
Password123!
- email:
หมายเหตุ:
- สคริปต์ seed จะตรวจ email ซ้ำก่อน insert (รันซ้ำแล้วไม่เพิ่มข้อมูลซ้ำ)
- ควรเปลี่ยนรหัสผ่านเริ่มต้นทันทีหลังล็อกอินครั้งแรก
ดูวิธีรันและทดสอบ API แบบละเอียดที่ไฟล์ TEST_API.md
ดูวิธี deploy ตั้งแต่ local จนขึ้น Render จริงที่ไฟล์ DEPLOY_RENDER.md
- POST /api/v1/auth/register/student
- POST /api/v1/auth/register/organizer
- POST /api/v1/auth/login
- POST /api/v1/auth/refresh
- POST /api/v1/auth/logout
- GET /api/v1/auth/me
- PATCH /api/v1/auth/me
- POST /api/v1/auth/change-password
- GET /api/v1/categories
- Public
- query:
includeInactive=true|false(default เป็นfalse)
- POST /api/v1/categories
- ต้องเป็น
ADMINหรือORGANIZER
- ต้องเป็น
- GET /api/v1/categories/{categoryId}
- Public
- PATCH /api/v1/categories/{categoryId}
- ต้องเป็น
ADMINหรือORGANIZER
- ต้องเป็น
- DELETE /api/v1/categories/{categoryId}
- ต้องเป็น
ADMINหรือORGANIZER - เป็น soft delete (
isActive = false)
- ต้องเป็น
- GET /api/v1/events
- Public
- query:
page,pageSize,search,categoryId,status,startFrom,endTo,sortBy,sortOrder - Default แสดงเฉพาะ
PUBLISHED
- POST /api/v1/events
- ต้องเป็น
ADMINหรือORGANIZER - Organizer สร้างได้เป็น
DRAFT - Admin สามารถสร้างได้ทั้ง
DRAFTหรือPUBLISHED
- ต้องเป็น
- PATCH /api/v1/events/{eventId}
- ต้องเป็นเจ้าของกิจกรรม (
ORGANIZERที่เป็น organizer ของ event)
- ต้องเป็นเจ้าของกิจกรรม (
- DELETE /api/v1/events/{eventId}
- ต้องเป็นเจ้าของกิจกรรม (
ORGANIZERที่เป็น organizer ของ event)
- ต้องเป็นเจ้าของกิจกรรม (
- POST /api/v1/events/{eventId}/publish
- ต้องเป็น
ADMIN - ตรวจสอบข้อมูลครบก่อนเปลี่ยนเป็น
PUBLISHED
- ต้องเป็น
- POST /api/v1/events/{eventId}/cancel
- ต้องเป็น
ADMINหรือเจ้าของกิจกรรม - บันทึกเหตุผลการยกเลิก
- ต้องเป็น
- GET /api/v1/events/{eventId}
- Public
- ถ้า
eventอยู่ในสถานะอื่นที่ไม่ใช่PUBLISHEDจะต้องเป็นADMINหรือORGANIZERเท่านั้น isSavedจะคืนค่าเฉพาะสำหรับผู้ใช้ที่ล็อกอินเป็นSTUDENT
- GET /api/v1/events/my-events
- ต้องเป็น
ORGANIZER - query:
page,pageSize,status,search,sortBy,sortOrder - แสดงเฉพาะกิจกรรมที่ organizer ปัจจุบันเป็นเจ้าของ
- ต้องเป็น
- GET /api/v1/events/nearby
- Public
- query:
latitude,longitude,radiusKm,search,categoryId,page,pageSize,sortBy,sortOrder - ดึงกิจกรรมที่อยู่ในรัศมีเทียบกับตำแหน่งผู้ใช้
- GET /api/v1/events/map
- Public
- query:
latitude,longitude,radiusKm,search,categoryId - คืนข้อมูลเบาๆ สำหรับ marker บนแผนที่
- GET /api/v1/events/upcoming
- Public
- query:
page,pageSize,categoryId,sortBy,sortOrder - แสดงกิจกรรมที่กำลังจะมาถึง (start_time > now และ status = PUBLISHED)
- GET /api/v1/events/active
- Public
- query:
page,pageSize,categoryId,sortBy,sortOrder - แสดงกิจกรรมที่ยัง active (status = PUBLISHED และ end_time > now)
- GET /api/v1/organizer/dashboard
- ต้องเป็น
ORGANIZER - แสดงสรุปข้อมูล dashboard ของ organizer
- คืนจำนวน event ทั้งหมด, DRAFT, PUBLISHED, CANCELLED และยอด saved รวม
- กรณีผิดเงื่อนไข:
- ถ้าไม่ส่ง token จะตอบ
401 - ถ้า role ไม่ใช่
ORGANIZERจะตอบ403
- ถ้าไม่ส่ง token จะตอบ
- ต้องเป็น
- GET /api/v1/organizer/events/{eventId}/stats
- ต้องเป็น
ORGANIZERและเป็นเจ้าของกิจกรรม - ดูสถิติราย event เช่น savedCount, status, start/end time
- กรณีผิดเงื่อนไข:
- ถ้าไม่ส่ง token จะตอบ
401 - ถ้า role ไม่ใช่
ORGANIZERหรือไม่ใช่เจ้าของกิจกรรม จะตอบ403 - ถ้าไม่พบกิจกรรม จะตอบ
404
- ถ้าไม่ส่ง token จะตอบ
- ต้องเป็น
- POST /api/v1/import/events/csv
- ต้องเป็น
ADMINหรือORGANIZER - รับไฟล์
CSVผ่านform-data - รองรับ
defaultStatusเป็นDRAFTหรือPUBLISHED - ถ้า
categoryIdไม่ตรงกับข้อมูลจริง ระบบจะนับแถวนั้นเป็น failed - ถ้า
ORGANIZERใส่status=PUBLISHEDใน row นั้น จะถูกปฏิเสธ - ถ้าโครงสร้าง CSV ผิด จะตอบ
400 bad csv format
- ต้องเป็น
- POST /api/v1/import/events/json
- ต้องเป็น
ADMINหรือORGANIZER - รับ
application/json - ใช้โครงสร้าง
eventsเป็น array ของข้อมูลกิจกรรม - ถ้า
categoryIdไม่ตรงกับข้อมูลจริง ระบบจะนับแถวนั้นเป็น failed - ถ้าค่าใน row ไม่ถูกต้อง ระบบจะคืนรายละเอียด error ตาม field
- ต้องเป็น
- POST /api/v1/saved-events
- ต้องเป็น
STUDENT - Body:
eventId - บันทึกกิจกรรมลงรายการโปรดของผู้ใช้
- response จะคืน
eventIdและsaved = true - กรณีผิดเงื่อนไข:
- ถ้าไม่ส่ง token จะตอบ
401 - ถ้า role ไม่ใช่
STUDENTจะตอบ403 - ถ้าไม่พบกิจกรรม จะตอบ
404 - ถ้าบันทึกกิจกรรมซ้ำ จะตอบ
409 - ถ้า
eventIdไม่ถูกต้อง จะตอบ422
- ถ้าไม่ส่ง token จะตอบ
- ต้องเป็น
- GET /api/v1/saved-events
- ต้องเป็น
STUDENT - query:
page,pageSize,status,sortBy,sortOrder - ดึงรายการกิจกรรมที่ student บันทึกไว้ทั้งหมด พร้อม pagination
sortByรองรับsavedAt,startTime,endTime- response แต่ละ item มี
savedAtเพื่อแสดงเวลาที่บันทึก - กรณีผิดเงื่อนไข:
- ถ้าไม่ส่ง token จะตอบ
401 - ถ้า role ไม่ใช่
STUDENTจะตอบ403 - ถ้า
statusไม่ถูกต้อง จะตอบ400
- ถ้าไม่ส่ง token จะตอบ
- ต้องเป็น
- DELETE /api/v1/saved-events/{eventId}
- ต้องเป็น
STUDENT - Path param:
eventId - ยกเลิกบันทึกกิจกรรมออกจากรายการโปรดของผู้ใช้
- response จะคืน
eventIdและsaved = false - กรณีผิดเงื่อนไข:
- ถ้าไม่ส่ง token จะตอบ
401 - ถ้า role ไม่ใช่
STUDENTจะตอบ403 - ถ้าไม่พบกิจกรรมในรายการบันทึก จะตอบ
404
- ถ้าไม่ส่ง token จะตอบ
- ต้องเป็น
- GET /api/v1/saved-events/check/{eventId}
- ต้องเป็น
STUDENT - Path param:
eventId - ใช้เช็กว่า event นี้ถูกบันทึกโดยนักศึกษาปัจจุบันแล้วหรือยัง เพื่อเอาไปแสดงปุ่ม save/unsave บน frontend
- response จะคืน
eventIdและisSaved = true|false - กรณีผิดเงื่อนไข:
- ถ้าไม่ส่ง token จะตอบ
401 - ถ้า role ไม่ใช่
STUDENTจะตอบ403 - ถ้าไม่พบกิจกรรม จะตอบ
404
- ถ้าไม่ส่ง token จะตอบ
- ต้องเป็น
- ผู้ใช้
ORGANIZERสร้างกิจกรรมใหม่ในสถานะDRAFTได้ ADMINสามารถสร้างกิจกรรมเป็นPUBLISHEDได้โดยตรง หรืออนุมัติกิจกรรมDRAFTให้เป็นPUBLISHED- เจ้าของกิจกรรม (Organizer) สามารถแก้ไขหรือลบกิจกรรมของตัวเองได้
ADMINและเจ้าของกิจกรรมสามารถยกเลิกกิจกรรม พร้อมบันทึกเหตุผลการยกเลิก- กิจกรรมที่ยังเป็น
DRAFTจะไม่แสดงในรายการสาธารณะจนกว่าจะถูกเผยแพร่
หมายเหตุ:
-
ชื่อหมวดหมู่ห้ามซ้ำ (ตรวจแบบไม่สนตัวพิมพ์เล็ก/ใหญ่)
-
ถ้า query
includeInactiveไม่ถูกต้อง จะตอบ400 -
ถ้าไม่มีสิทธิ์ จะตอบ
403 -
ถ้าไม่พบหมวดหมู่ จะตอบ
404 -
AuthServiceเริ่ม delegate บาง business flow ไปที่app/domain/AuthManagerแล้ว -
register_studentและregister_organizerใช้AuthManager.register_student()/AuthManager.register_organizer()เพื่อกำหนด role จาก domain -
is_email_uniqueใช้AuthManager.is_email_unique()ใน flow ตรวจสอบอีเมลซ้ำ -
loginใช้AuthManager.login()ในชั้น domain ก่อนออก token -
Userdomain มีverify_password()และ getter/setter หลัก เพื่อ map กับพฤติกรรมตาม UML -
API contract ไม่เปลี่ยน จากสเปกเดิม
- ถ้า
pytestimportappไม่ได้ ให้ใช้:
python -m pytest -q- ปิดเฉพาะ container:
docker compose down- ปิดและลบ volume (รีเซ็ตฐานข้อมูลทั้งหมด):
docker compose down -v