JMคู่มืออธิบายโค้ด JobMatch
อ่านแล้ว 0/0 บท
JobMatch · ระบบจับคู่งานอัตโนมัติ

คู่มืออธิบายโค้ด JobMatch

คู่มือนี้พาอ่านโค้ดทุกส่วนของระบบ ตั้งแต่ภาพรวมจนถึงรายไฟล์ ทั้งแอป Flutter, API ภาษา PHP, เว็บผู้ดูแลระบบ และฐานข้อมูล โค้ดทุกกล่องดึงมาจากไฟล์จริงพร้อมเลขบรรทัด เปิดไฟล์ใน VS Code แล้วกด Ctrl+G พิมพ์เลขบรรทัดเพื่อตามไปดูได้ทันที

50 ไฟล์แอป Flutter · 14,657 บรรทัด
36 ไฟล์API (PHP) · 3,263 บรรทัด
15 ไฟล์เว็บแอดมิน (PHP) · 3,231 บรรทัด
12 ตารางฐานข้อมูล MySQL
job_matching_system

อ้างอิงโค้ด ณ วันที่ 4 ต.ค. 2569 — ถ้าแก้โค้ดหลังจากนี้ เลขบรรทัดอาจเลื่อนไปจากในคู่มือ

☰ สารบัญ (แตะเพื่อเปิด)

วิธีใช้คู่มือนี้

อ่านตามลำดับไหนดี และสัญลักษณ์ในคู่มือหมายถึงอะไร

🧭 ถ้าเพิ่งเริ่มอ่าน

อ่านบทที่ 1–3 ก่อน (ภาพรวม → โครงสร้างโฟลเดอร์ → ความรู้พื้นฐาน) แล้วข้ามไปบทที่ 11 เส้นทางการทำงานแบบครบวงจร ซึ่งไล่โค้ดตามเหตุการณ์จริง เช่น "ผู้หางานกดสมัครงาน" จะเห็นว่าไฟล์ต่าง ๆ ส่งต่องานกันอย่างไร จากนั้นค่อยกลับมาอ่านรายไฟล์ที่สนใจ

🔎 ถ้าจะหาไฟล์ใดไฟล์หนึ่ง

พิมพ์ชื่อไฟล์ในช่องค้นหาของสารบัญ (เช่น login หรือ matching) หรือกด Ctrl+F ค้นทั้งหน้า หัวกล่องโค้ดบอกชื่อไฟล์และช่วงบรรทัดเสมอ

✅ บันทึกความคืบหน้า

ติ๊ก “อ่านแล้ว” ที่หัวแต่ละบท แถบด้านบนจะนับให้ และสารบัญจะขึ้นเครื่องหมาย ✓ ข้อมูลนี้เก็บไว้ในเบราว์เซอร์เครื่องนี้เท่านั้น

🎨 สีของป้ายและกล่อง

หน้าจอ ข้อมูล API แอดมิน บอกว่าไฟล์อยู่ชั้นไหน ส่วนกล่องข้อความสีฟ้า = ข้อควรรู้, สีเขียว = เคล็ดลับ, สีส้ม = ข้อควรระวัง/ข้อจำกัด

อ่านโค้ดให้เข้าใจเร็ว อย่าพยายามอ่านทุกบรรทัดตั้งแต่ต้นไฟล์ ให้หา “จุดเริ่ม” ก่อน เช่น ในหน้าจอ Flutter ให้ดู initState() (โหลดอะไรตอนเปิดหน้า) และเมธอดที่ขึ้นต้นด้วย _handle… (ทำอะไรเมื่อกดปุ่ม) ส่วนไฟล์ PHP ให้อ่านจากบนลงล่าง เพราะทำงานเรียงลำดับตามบรรทัดจริง

1. ภาพรวมระบบ

ระบบมีกี่ส่วน แต่ละส่วนคุยกันอย่างไร และใช้เทคโนโลยีอะไร

JobMatch แบ่งเป็น 4 ส่วน แต่ละส่วนคุยกันผ่านเครือข่าย:

📱 แอป Flutter

jobmatch_employer/lib/

แอปเดียวใช้ได้ 2 บทบาท คือ ผู้หางาน (seeker) และผู้ว่าจ้าง (employer) ผู้ใช้เลือกบทบาทที่หน้าแรก แล้วแอปจะพาไปชุดหน้าจอของบทบาทนั้น รันได้ทั้ง Android, iOS และเว็บ (เวอร์ชันเว็บ build ไว้ที่ htdocs/jobmatch_app)

🔌 API

C:\xampp\htdocs\jobmatch_api\ (สำเนาอยู่ใน backend/)

เขียนด้วย PHP โดยหนึ่งไฟล์คือหนึ่งบริการ (endpoint) เช่น login.php หรือ apply_job.php แต่ละไฟล์รับคำขอจากแอป ทำงานกับฐานข้อมูล แล้วตอบกลับเป็น JSON

🛠️ เว็บแอดมิน

C:\xampp\htdocs\jobmatch_admin\

เว็บของผู้ดูแลระบบ PHP สร้างหน้า HTML ฝั่งเซิร์ฟเวอร์และต่อฐานข้อมูลโดยตรงโดยไม่ผ่าน API แต่นำฟังก์ชันจับคู่ของ API มาใช้ร่วมด้วย จึงคำนวณแบบเดียวกันทั้งระบบ

🗄️ ฐานข้อมูล

MySQL (XAMPP) · job_matching_system

มี 12 ตาราง เก็บสมาชิก ประกาศงาน ใบสมัคร นัดสัมภาษณ์ แชท แจ้งเตือน ผลการจับคู่ คลังคำสำคัญ ค่าตั้งของระบบ และบันทึกการทำงานของแอดมิน

สถาปัตยกรรมของระบบ JobMatch แผนที่ OpenStreetMap ภาพแผนที่ · เปิด Google Maps Nominatim (OSM) แปลงที่อยู่ ↔ พิกัด Google Gemini แปลงทักษะเป็นคำมาตรฐาน แอป Flutter ผู้หางาน · ผู้ว่าจ้าง Android · iOS · เว็บ API (PHP) jobmatch_api 1 ไฟล์ = 1 endpoint MySQL job_matching_system 12 ตาราง เว็บแอดมิน (PHP) jobmatch_admin · Chart.js HTTP + JSON X-User-Id PDO/SQL PDO/SQL ใช้ฟังก์ชันจับคู่ร่วมกัน
ภาพที่ 1 สถาปัตยกรรมของระบบ: เส้นทึบ = เรียกใช้งาน, เส้นประ = ใช้โค้ดร่วมกัน, กล่องเส้นประ = บริการภายนอก

ข้อมูลเดินทางอย่างไรใน 1 ครั้งที่กดปุ่ม

ทุกฟีเจอร์ของแอปเดินตามรูปแบบเดียวกันนี้ ถ้าเข้าใจรอบนี้รอบเดียวก็อ่านโค้ดส่วนอื่นได้เกือบทั้งหมด (ตัวอย่าง: ผู้หางานกดปุ่ม “สมัครงาน”)

  1. ผู้ใช้กดปุ่มบนหน้าจอ ปุ่มเรียกเมธอดในคลาส State ของหน้านั้นseeker_job_detail_page.dart → _handleApply()
  2. เมธอดเรียกบริการกลางของแอปที่รวมการเรียก API ไว้ที่เดียวapi_service.dart → ApiService().applyJob(jobId:, seekerId:)
  3. ไลบรารี Dio ส่งคำขอ HTTP POST พร้อม JSON และแนบ header X-User-Id (รหัสผู้ใช้ที่ล็อกอินอยู่) ให้อัตโนมัติapi_service.dart → interceptor onRequest
  4. PHP เปิดการเชื่อมต่อฐานข้อมูล ตรวจว่าใครเป็นผู้เรียก ตรวจข้อมูล แล้วรันคำสั่ง SQL (บันทึกใบสมัคร + สร้างแจ้งเตือนให้บริษัท)apply_job.php → db_connect.php, api_auth.php
  5. PHP ตอบกลับเป็น JSON พร้อมรหัสสถานะ HTTP เช่น 201 {"success": true, "message": "สมัครงานสำเร็จ"}
  6. ApiService แปลง JSON เป็น Map หรือ Model (เช่น JobPostingModel.fromJson) แล้วคืนค่าให้หน้าจอ
  7. หน้าจอเรียก setState() ให้ Flutter วาดหน้าจอใหม่ เช่น เปลี่ยนปุ่มเป็น “ยกเลิกการสมัคร” และแสดงแถบข้อความ (SnackBar)

เทคโนโลยีและไลบรารีที่ใช้

ส่วนเทคโนโลยี / แพ็กเกจใช้ทำอะไรในโปรเจกต์
แอปFlutter 3 / Dart 3ภาษาและเฟรมเวิร์กของแอปทั้งหมด
แอปgo_routerกำหนดเส้นทางหน้า (URL) และพาผู้ใช้ที่ยังไม่ล็อกอินไปหน้าแรกอัตโนมัติ
แอปdioเรียก HTTP API และแนบ header ทุกคำขอผ่าน interceptor
แอปshared_preferencesเก็บข้อมูลผู้ล็อกอินไว้ในเครื่อง ปิดแอปแล้วเปิดใหม่ยังล็อกอินอยู่
แอปflutter_bloc (Cubit)จัดการสถานะของหน้า “จัดการประกาศงาน”
แอปgeolocator, geocodingอ่านตำแหน่ง GPS และแปลงพิกัดเป็นชื่อสถานที่
แอปflutter_map, latlong2แสดงแผนที่ OpenStreetMap ในหน้าแผนที่งาน
แอปimage_picker, file_pickerเลือกรูปโปรไฟล์/โลโก้/ภาพปก และเลือกไฟล์ Resume (PDF)
แอปsyncfusion_flutter_pdfviewerเปิดดู Resume PDF ภายในแอป
แอปurl_launcherเปิดลิงก์ภายนอก: Google Maps, โทรออก, อีเมล, ลิงก์ในแชท
แอปcached_network_image, intl, thai_provinces_flutterแสดงรูปพร้อมแคช, จัดรูปแบบตัวเลข/วันที่, ตัวเลือกจังหวัด-อำเภอ-ตำบล
API / แอดมินPHP 8.2 + PDOเขียน endpoint และหน้าเว็บแอดมิน เชื่อม MySQL แบบ prepared statement
ฐานข้อมูลMySQL/MariaDB (XAMPP)เก็บข้อมูลทั้งหมดของระบบ
แอดมินChart.js, html2canvas + jsPDFวาดกราฟในแดชบอร์ด/รายงาน และส่งออกรายงานเป็น PDF
บริการภายนอกNominatim, Google Geminiหาพิกัดจากที่อยู่ และช่วยแปลงทักษะที่ผู้ใช้พิมพ์เองให้ตรงกับคลังคำ
แพ็กเกจที่ติดตั้งไว้แต่ยังไม่ได้ใช้ equatable, get_it และ thai_province_dropdown มีอยู่ใน pubspec.yaml แต่ไม่มีไฟล์ไหนใน lib/ import ไปใช้ ลบออกได้โดยไม่กระทบแอป

2. โครงสร้างโฟลเดอร์

แต่ละไฟล์อยู่ที่ไหน และวางไว้ตรงนั้นเพราะอะไร

แอป Flutter (lib/)

โค้ดแอปแบ่งเป็น 3 ชั้นตามหน้าที่ เพื่อให้แก้ส่วนหนึ่งได้โดยไม่กระทบส่วนอื่น: core (ของกลางที่ทุกหน้าใช้), data (ข้อมูลและการคุยกับ API) และ presentation (หน้าจอ)

  • lib/
    • main.dart — จุดเริ่มต้นของแอป
    • core/ — ของกลางที่ทุกหน้าใช้ร่วมกัน
      • constants/app_constants.dart — ที่อยู่ API, ชื่อคีย์ที่เก็บในเครื่อง, ข้อความสถานะใบสมัคร
      • router/app_router.dart — แผนที่เส้นทางของทุกหน้า และเงื่อนไขต้องล็อกอิน
      • theme/app_theme.dart — สี ฟอนต์ และหน้าตาปุ่ม/ช่องกรอกของทั้งแอป
      • utils/formatters.dart — แปลงวันที่แบบไทย เวลาที่ผ่านมา และค่าจ้าง
      • utils/location_support.dart — ตรวจว่าอุปกรณ์นี้ใช้ GPS ได้หรือไม่
    • data/ — ข้อมูลและการเชื่อมต่อเซิร์ฟเวอร์
      • models/ — คลาสข้อมูล: member, job_posting, application, message, notification
      • services/api_service.dart — รวมการเรียก API ทุกตัวไว้ที่นี่
      • services/session_service.dart — เก็บ/อ่านข้อมูลผู้ที่ล็อกอินอยู่
    • presentation/ — ทุกอย่างที่ผู้ใช้มองเห็น
      • blocs/manage_jobs_cubit.dart — ตัวจัดการสถานะของหน้าจัดการประกาศงาน
      • pages/
        • splash/, welcome/ — หน้าโหลดตอนเปิดแอป และหน้าเลือกบทบาท
        • auth/ — เข้าสู่ระบบผู้ว่าจ้าง, สมัครผู้ว่าจ้าง, ลืมรหัสผ่าน (welcome_page.dart ในโฟลเดอร์นี้เป็นไฟล์ว่าง ไม่ได้ใช้)
        • home/ — โครงหน้าหลักผู้ว่าจ้าง (MainShell) และแท็บทั้ง 5
        • jobs/ — หน้าเพิ่ม/แก้ไขประกาศงาน
        • applications/ — รายชื่อผู้สมัคร, โปรไฟล์ผู้สมัคร, นัดสัมภาษณ์
        • chat/ — ห้องแชท (ใช้ร่วมกันทั้งสองบทบาท)
        • profile/ — แก้ไขข้อมูลบริษัท
        • seeker/ — ทุกหน้าของผู้หางาน รวมถึงโฟลเดอร์ tabs/ อีก 6 แท็บ
        • pdf_viewer_page.dart, not_found_page.dart
      • widgets/ — ชิ้นส่วนหน้าจอที่ใช้ซ้ำหลายหน้า

API (jobmatch_api/)

  • C:\xampp\htdocs\jobmatch_api\
    • db_connect.php, api_auth.php — ไฟล์กลาง: ต่อฐานข้อมูล และตรวจสิทธิ์ผู้เรียก
    • config.php — ค่าลับของเซิร์ฟเวอร์ (เช่น API key) ห้ามเผยแพร่ ห้ามส่งขึ้น git
    • .htaccess — ห้ามเรียกไฟล์กลาง/ไฟล์ค่าลับตรง ๆ จากเว็บ
    • matching_service.php, skill_matcher.php — ระบบจับคู่งาน (บทที่ 8)
    • ai_service.php, pdf_text.php, geocode_service.php — บริการเสริม: AI, อ่าน PDF, หาพิกัด
    • login.php, register_*.php, get_*.php, update_*.php … — endpoint ที่แอปเรียก (27 ไฟล์)
    • uploads/ — รูปโปรไฟล์ โลโก้ ภาพปก และโฟลเดอร์ย่อย resumes/ เก็บไฟล์ PDF
    • tools/ — สคริปต์แก้ข้อมูลครั้งเดียว
โค้ด API มี 2 ที่ ตัวที่ทำงานจริงอยู่ใน C:\xampp\htdocs\jobmatch_api (Apache ของ XAMPP เปิดจากที่นี่) ส่วน jobmatch_employer/backend/ เป็นสำเนาเก็บไว้กับโปรเจกต์ ถ้าแก้ไฟล์ที่หนึ่งต้องคัดลอกไปอีกที่ด้วยเสมอ ไม่อย่างนั้นสองที่จะไม่ตรงกัน

เว็บแอดมิน (jobmatch_admin/)

  • C:\xampp\htdocs\jobmatch_admin\
    • index.php — หน้าเข้าสู่ระบบ
    • dashboard.php, users.php, job_postings.php, keywords.php, matching.php, reports.php — 6 หน้าหลักตามเมนู
    • logout.php — ออกจากระบบ
    • config/database.php — ฟังก์ชันเชื่อมต่อฐานข้อมูล
    • includes/session_config.php, auth.php, header.php, footer.php — session, ตรวจล็อกอิน, ส่วนหัว/ท้ายของทุกหน้า
    • api/manage_user.php, manage_keyword.php — endpoint JSON ที่ JavaScript ในหน้าแอดมินเรียก
    • assets/css/style.css — หน้าตาของเว็บแอดมินทั้งหมด
    • sql/admin_setup.sql — สคริปต์สร้างตารางฝั่งแอดมิน

โฟลเดอร์อื่นในโปรเจกต์

โฟลเดอร์มีอะไร
android/, ios/, web/, windows/ …โค้ดเฉพาะแพลตฟอร์มที่ Flutter สร้างให้ (ชื่อแอป ไอคอน สิทธิ์ใช้ GPS/รูปภาพ) ปกติแทบไม่ต้องแก้
assets/โลโก้ และฟอนต์ Kanit ที่ฝังในแอป (ประกาศไว้ใน pubspec.yaml)
backend/sql/schema.sql (โครงสร้างตั้งต้น) และ migration_phase*.sql (ไฟล์ปรับโครงสร้างฐานข้อมูลทีละขั้น)
backend/tools/สคริปต์ build เว็บ, แคปหน้าจอ, ทดสอบ, สร้างข้อมูลตัวอย่าง (บทที่ 12)
docs/เอกสารประกอบรายงาน เช่น ER Diagram, พจนานุกรมข้อมูล, ผังการจับคู่ และคู่มือนี้
pubspec.yamlรายชื่อแพ็กเกจที่แอปใช้ + ไฟล์ assets

3. ความรู้พื้นฐานก่อนอ่านโค้ด

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

ฝั่ง Flutter / Dart

Widget และ State

ทุกอย่างบนจอคือ Widget แบบ StatelessWidget แสดงผลอย่างเดียว ส่วน StatefulWidget มีข้อมูลที่เปลี่ยนได้เก็บในคลาส State เมื่อข้อมูลเปลี่ยนต้องเรียก setState() เพื่อบอกให้ Flutter วาดหน้าจอใหม่

setState(() => _isLoading = true); // วาดใหม่ โชว์วงหมุน

วงจรชีวิตของหน้า

initState() ทำงานครั้งเดียวตอนเปิดหน้า ใช้โหลดข้อมูลหรือเริ่ม Timer ต่อด้วย build() ที่วาดหน้าจอ (เรียกซ้ำทุกครั้งที่ setState) และ dispose() ตอนปิดหน้า ใช้ยกเลิก Timer และ Controller กันหน่วยความจำรั่ว

async / await / Future

การเรียก API ใช้เวลา จึงคืนค่าเป็น Future (ค่าที่จะได้ในอนาคต) ใช้ await รอผลโดยหน้าจอไม่ค้าง เมธอดที่มี await ต้องประกาศ async

final res = await ApiService().login(email, pass);

ทำไมต้อง if (!mounted) return;

ระหว่างรอ await ผู้ใช้อาจกดย้อนกลับออกจากหน้าไปแล้ว ถ้าเรียก setState หรือใช้ context ของหน้าที่ปิดไปจะเกิด error จึงต้องเช็ก mounted หลัง await ทุกครั้ง พบแทบทุกหน้าในโปรเจกต์

Controller และ Form

TextEditingController ใช้อ่าน/ตั้งค่าข้อความในช่องกรอก ส่วน Form + GlobalKey<FormState> ใช้ตรวจทุกช่องพร้อมกันผ่าน validator

if (!_formKey.currentState!.validate()) return;

การเปลี่ยนหน้าด้วย go_router

context.go('/home') = ไปหน้านั้นแทนที่ประวัติทั้งหมด (กดย้อนกลับไม่ได้) ใช้หลังล็อกอิน
context.push('/home/post-job') = ซ้อนหน้าใหม่ กดย้อนกลับได้
context.pop(ค่า) = ปิดหน้าปัจจุบันและส่งค่ากลับให้หน้าก่อน

JSON → Model

API ตอบเป็นข้อความ JSON แอปแปลงเป็น Map<String, dynamic> แล้วสร้างอ็อบเจกต์ผ่าน factory Model.fromJson(map) ทำให้ทั้งแอปใช้ job.jobTitle แทน map['job_title'] ได้ ลดการพิมพ์ชื่อคีย์ผิด

Singleton (มีตัวเดียวทั้งแอป)

ApiService และ SessionService ถูกออกแบบให้มีอ็อบเจกต์เดียว เรียก ApiService() กี่ครั้งก็ได้ตัวเดิม จึงตั้งค่า Dio และ interceptor แค่ครั้งเดียว

Polling ด้วย Timer

แอปไม่มีระบบ push จึงใช้ Timer.periodic ถามเซิร์ฟเวอร์ซ้ำ ๆ เช่น ห้องแชททุก 3 วินาที, จำนวนข้อความ/แจ้งเตือนที่ยังไม่อ่านทุก 5 วินาที

_timer = Timer.periodic(Duration(seconds: 3), (_) => _fetchMessages(silent: true));

แท็บไม่โหลดใหม่ทุกครั้ง (KeepAlive)

แท็บที่ผสม AutomaticKeepAliveClientMixin และตั้ง wantKeepAlive => true จะถูกเก็บไว้ในหน่วยความจำ สลับแท็บไปมาแล้วกลับมา ข้อมูลและตำแหน่งเลื่อนยังอยู่

ฝั่ง PHP / ฐานข้อมูล

1 ไฟล์ = 1 endpoint

เมื่อแอปเรียก http://…/jobmatch_api/login.php Apache จะรันไฟล์ login.php จากบรรทัดแรกถึงบรรทัดสุดท้าย สิ่งที่ echo ออกมาคือคำตอบที่แอปได้รับ

อ่านข้อมูลขาเข้า 3 แบบ

$_GET['x'] = ค่าหลัง ? ใน URL (ใช้กับ GET)
json_decode(file_get_contents('php://input'), true) = JSON ในเนื้อคำขอ (POST/PUT)
$_POST + $_FILES = ฟอร์มแบบ multipart ใช้ตอนอัปโหลดไฟล์

PDO + Prepared Statement

ไม่นำค่าที่ผู้ใช้ส่งมาต่อเป็นสตริง SQL ตรง ๆ ใช้ ? แทน แล้วส่งค่าผ่าน execute() ฐานข้อมูลจะถือค่าเป็นข้อมูลล้วน ป้องกัน SQL Injection

$stmt = $pdo->prepare('SELECT * FROM member WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();

รหัสผ่านแบบ bcrypt

เก็บรหัสผ่านเป็นค่า hash ด้วย password_hash() (ย้อนกลับเป็นรหัสจริงไม่ได้) และตรวจตอนล็อกอินด้วย password_verify() แม้ฐานข้อมูลหลุดก็ไม่เห็นรหัสผ่านจริง

รูปแบบคำตอบ JSON

ทุก endpoint ตอบรูปแบบเดียวกัน แอปจึงเช็กแค่ success:

{"success": true,  "data": [...]}
{"success": false, "message": "ข้อความภาษาไทย"}

Session ของเว็บแอดมิน

เว็บแอดมินไม่ใช้ header แบบแอป แต่ใช้ session ของ PHP เมื่อล็อกอินผ่าน เซิร์ฟเวอร์จำ $_SESSION['admin_id'] ไว้ เบราว์เซอร์ถือแค่ cookie รหัส session

รหัสสถานะ HTTP ที่ API ใช้

รหัสความหมายตัวอย่างในโปรเจกต์
200สำเร็จดึงรายการงาน, อัปเดตสถานะ
201สร้างข้อมูลใหม่สำเร็จสมัครสมาชิก, สมัครงาน, โพสต์งาน, ส่งข้อความ
400ข้อมูลที่ส่งมาไม่ครบหรือผิดรูปแบบไม่กรอกอีเมล, รหัสผ่านสั้นกว่า 6 ตัว, ไฟล์ไม่ใช่ PDF
401ยังไม่ยืนยันตัวตนรหัสผ่านผิด, ไม่มี X-User-Id (แอปจะพาออกจากระบบอัตโนมัติ)
403รู้ว่าเป็นใคร แต่ไม่มีสิทธิ์บัญชีถูกระงับ, แก้ใบสมัครที่ไม่ใช่ของตัวเอง
404ไม่พบข้อมูลไม่พบงาน / ใบสมัคร
405ใช้ method ผิดเรียก login.php ด้วย GET
409ขัดแย้งกับข้อมูลเดิมอีเมลซ้ำ, สมัครงานซ้ำ, งานปิดรับแล้ว
500เซิร์ฟเวอร์ผิดพลาดSQL error (รายละเอียดจริงเขียนลง error log ไม่ส่งให้ผู้ใช้)

4. แอป Flutter: แกนกลาง (core)

ไฟล์ที่ทำงานก่อนทุกหน้าจอ และค่ากลางที่ทุกหน้าใช้ร่วมกัน

main.dart — จุดเริ่มต้นของแอป

lib/main.dartแกนกลาง

เมื่อเปิดแอป ฟังก์ชัน main() ทำงานเป็นอย่างแรก ไล่ตามลำดับดังนี้

  1. WidgetsFlutterBinding.ensureInitialized() ต้องเรียกก่อนใช้ปลั๊กอินใด ๆ ก่อน runApp เพราะ SharedPreferences ต้องคุยกับระบบปฏิบัติการ
  2. ล็อกจอเป็นแนวตั้ง และทำให้แถบสถานะด้านบนโปร่งใส
  3. await SessionService.instance.init() โหลดข้อมูลล็อกอินจากเครื่องให้เสร็จก่อนแสดงหน้าแรก เพราะ router ต้องรู้ทันทีว่าผู้ใช้ล็อกอินอยู่หรือไม่
  4. ตั้งค่า ApiService.onUnauthorized: ถ้า API ตอบ 401 (เช่น บัญชีถูกลบ) ให้ล้างข้อมูลล็อกอินแล้วพากลับหน้าแรก
  5. runApp() สร้าง MaterialApp.router โดยผูกธีม (AppTheme.theme) และตัวจัดการเส้นทาง (AppRouter.router)
lib/main.dartบรรทัด 8–39
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
  SystemChrome.setSystemUIOverlayStyle(
    const SystemUiOverlayStyle(
      statusBarColor: Colors.transparent,
      statusBarIconBrightness: Brightness.light,
    ),
  );
  await SessionService.instance.init();
  // เซสชันหมดอายุ (API ตอบ 401) -> ล้างข้อมูลเข้าสู่ระบบแล้วกลับไปหน้าเริ่มต้นให้ล็อกอินใหม่
  ApiService.onUnauthorized = () async {
    if (!SessionService.instance.isLoggedIn) return;
    await SessionService.instance.clearSession();
    AppRouter.router.go('/welcome');
  };
  runApp(const JobMatchEmployerApp());
}

class JobMatchEmployerApp extends StatelessWidget {
  const JobMatchEmployerApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'JobMatch',
      theme: AppTheme.theme,
      routerConfig: AppRouter.router,
      debugShowCheckedModeBanner: false,
    );
  }
}
ชื่อคลาส JobMatchEmployerApp และชื่อแพ็กเกจ jobmatch_employer มีคำว่า employer แต่แอปนี้รองรับทั้งผู้ว่าจ้างและผู้หางาน

app_constants.dart — ที่อยู่ API และค่าคงที่

lib/core/constants/app_constants.dartแกนกลาง

baseUrl คือที่อยู่ของ API อ่านจาก String.fromEnvironment('API_URL') ซึ่งกำหนดได้ตอนสั่งรัน/สั่ง build โดยไม่ต้องแก้โค้ด เช่น flutter run --dart-define=API_URL=http://192.168.1.50/jobmatch_api ถ้าไม่ระบุจะใช้ค่าเริ่มต้นในโค้ด ส่วนเวอร์ชันเว็บ build ด้วย API_URL=/jobmatch_api (path สัมพัทธ์) จึงเรียก API จากเครื่องเดียวกับที่เปิดหน้าเว็บ ใช้ได้ทุก IP

resolveUrl() แปลง path ของรูป/ไฟล์ที่ได้จาก API ให้เป็น URL เต็มที่เปิดได้จริง:

ค่าที่ได้จาก APIผลลัพธ์
uploads/employer_5_1712.png (path สัมพัทธ์)ต่อหน้าด้วย baseUrl → {baseUrl}/uploads/employer_5_1712.png
http://192.168.1.10/jobmatch_api/uploads/a.jpg (URL เต็มที่มี IP เก่าติดมา)ตัดส่วนหน้าทิ้งแล้วใช้ baseUrl ปัจจุบัน
https://images.unsplash.com/… (เว็บภายนอก)คืนค่าเดิม
lib/core/constants/app_constants.dartบรรทัด 6–28
static const String baseUrl = String.fromEnvironment(
  'API_URL',
  defaultValue: 'http://192.168.100.99/jobmatch_api',
);

/// แปลง path ไฟล์จาก API ให้เป็น URL เต็มที่ชี้ไปยังเซิร์ฟเวอร์ปัจจุบัน
/// - path สัมพัทธ์ (uploads/x.jpg) -> ต่อ baseUrl ให้
/// - URL เต็มของ jobmatch_api ที่มี IP เก่าติดมา -> เปลี่ยนเป็น baseUrl ปัจจุบัน
/// - URL ภายนอกอื่นๆ -> คืนค่าเดิม
static String? resolveUrl(String? path) {
  if (path == null || path.isEmpty) return path;
  if (path.startsWith('http://') || path.startsWith('https://')) {
    const marker = '/jobmatch_api/';
    final i = path.indexOf(marker);
    if (i == -1) return path;
    return _absolute('$baseUrl/${path.substring(i + marker.length)}');
  }
  return _absolute('$baseUrl/${path.startsWith('/') ? path.substring(1) : path}');
}

/// เวอร์ชันเว็บที่ baseUrl เป็น path สัมพัทธ์: เติมโดเมนของหน้าเว็บให้เป็น URL เต็ม (ใช้กับรูป/PDF)
static String _absolute(String url) =>
    url.startsWith('/') ? Uri.base.resolve(url).toString() : url;

ส่วนที่เหลือของไฟล์ (บรรทัด 30–79) เป็นค่าคงที่ 3 กลุ่ม: ที่อยู่ endpoint ทั้ง 27 ตัว, ชื่อคีย์ที่ใช้เก็บข้อมูลในเครื่อง และข้อความสถานะใบสมัคร (รอดำเนินการ, นัดสัมภาษณ์, ผ่านการคัดเลือก, ไม่ผ่านการคัดเลือก) ซึ่งฐานข้อมูลเก็บเป็นภาษาไทยตรง ๆ

ทำไมฐานข้อมูลเก็บแค่ path สัมพัทธ์ ถ้าเก็บ URL เต็ม เช่น http://192.168.100.99/… เมื่อย้ายเครื่องหรือเปลี่ยน Wi-Fi แล้ว IP เปลี่ยน รูปทั้งหมดจะเสีย การเก็บ uploads/x.jpg แล้วให้แอปต่อ baseUrl เองจึงทนต่อการเปลี่ยน IP

app_router.dart — แผนที่เส้นทางของทุกหน้า

lib/core/router/app_router.dartแกนกลาง

ใช้แพ็กเกจ go_router กำหนดว่า URL ไหนเปิดหน้าไหน แอปเริ่มที่ /splash เสมอ และทุกครั้งที่จะเปลี่ยนหน้า ฟังก์ชัน redirect จะถูกเรียกก่อนเพื่อตรวจสิทธิ์:

สถานการณ์redirect ทำอะไร
กำลังอยู่ที่ /splashไม่ทำอะไร ปล่อยให้ SplashPage ตัดสินเอง
ยังไม่ล็อกอิน แต่จะเข้าหน้าที่ต้องล็อกอินส่งไป /welcome
ล็อกอินแล้ว แต่จะเข้าหน้าล็อกอิน/สมัคร/ลืมรหัสส่งไปหน้าหลักตามบทบาท: /seeker-home หรือ /home
กรณีอื่นคืน null = ไปหน้าที่ขอได้เลย
lib/core/router/app_router.dartบรรทัด 26–55
class AppRouter {
  static final router = GoRouter(
    initialLocation: '/splash',
    // ลิงก์ผิด/หน้าที่ไม่มีอยู่ (เจอได้บนเว็บ) -> หน้าภาษาไทยพร้อมปุ่มกลับหน้าแรก แทนข้อความ error ภาษาอังกฤษ
    errorBuilder: (context, state) => const NotFoundPage(),
    redirect: (context, state) {
      final isLoggedIn = SessionService.instance.isLoggedIn;
      final role = SessionService.instance.userRole;
      final loc = state.uri.path;

      final isUnauthRoute =
          loc == '/login' ||
          loc == '/register' ||
          loc == '/welcome' ||
          loc == '/seeker-login' ||
          loc == '/seeker-register' ||
          loc == '/forgot-password';

      if (loc == '/splash') return null;

      if (!isLoggedIn && !isUnauthRoute) {
        return '/welcome';
      }

      if (isLoggedIn && isUnauthRoute) {
        return role == 'seeker' ? '/seeker-home' : '/home';
      }

      return null;
    },

เส้นทางทั้งหมดของแอป (หน้าที่ซ้อนอยู่ใต้ /home หรือ /seeker-home เปิดทับหน้าหลัก กดย้อนกลับได้):

เส้นทางหน้าหมายเหตุ
/splashSplashPageหน้าโหลดตอนเปิดแอป
/welcomeWelcomePageเลือกเป็นผู้หางานหรือผู้ว่าจ้าง
/login, /registerLoginPage, RegisterEmployerPageของผู้ว่าจ้าง
/seeker-login, /seeker-registerSeekerLoginPage, SeekerRegisterPageของผู้หางาน
/forgot-passwordForgotPasswordPageใช้ร่วมกันทั้งสองบทบาท
/home?tab=nMainShellหน้าหลักผู้ว่าจ้าง 5 แท็บ (tab=3 เปิดแท็บแจ้งเตือน)
/home/post-jobPostJobPageส่ง extra: job มาด้วย = โหมดแก้ไข
/home/candidates/:jobIdCandidateListPageผู้สมัครของงานนั้น
/home/candidate/:applicationIdCandidateDetailPageโปรไฟล์ผู้สมัคร 1 คน
/home/interview/:applicationIdInterviewPageฟอร์มนัดสัมภาษณ์
/home/edit-profileEditProfilePageแก้ข้อมูลบริษัท
/seeker-home?tab=nSeekerMainShellหน้าหลักผู้หางาน 5 แท็บ
/seeker-home/job/:jobIdSeekerJobDetailPageรายละเอียดงาน + ปุ่มสมัคร
/seeker-home/company/:employerUserIdSeekerCompanyDetailPageหน้าบริษัท
/seeker-home/profile, /seeker-home/notificationsSeekerProfileTab, SeekerNotificationsTabเปิดเป็นหน้าเดี่ยวได้ด้วย
…/chat/:receiverId/:receiverNameChatRoomPageมีทั้งใต้ /home และ /seeker-home

การส่งข้อมูลไปหน้าใหม่มี 3 แบบ: path parameter (:jobId อ่านด้วย s.pathParameters['jobId']), query parameter (?tab=3 อ่านด้วย s.uri.queryParameters['tab']) และ extra (ส่งอ็อบเจกต์ทั้งก้อน เช่น งานที่จะแก้ไข)

lib/core/router/app_router.dartบรรทัด 109–122
GoRoute(
  path: '/home',
  builder: (_, s) => MainShell(initialTab: int.tryParse(s.uri.queryParameters['tab'] ?? '') ?? 0),
  routes: [
    GoRoute(
      path: 'post-job',
      // ถ้ามีการส่ง JobPostingModel มาผ่าน context.push('/home/post-job', extra: job)
      // จะได้ค่าตรงนี้ผ่าน state.extra แล้วส่งต่อเป็นโหมดแก้ไข ถ้าไม่ได้ส่งมา (กดปุ่ม
      // "สร้างงานใหม่" ปกติ) extra จะเป็น null และหน้าจะเป็นโหมดโพสต์ใหม่ตามเดิม
      builder: (_, state) {
        final job = state.extra as JobPostingModel?;
        return PostJobPage(editJob: job);
      },
    ),

ถ้าเปิด URL ที่ไม่มีอยู่ (เจอได้บนเว็บ) errorBuilder จะแสดง NotFoundPage ภาษาไทยพร้อมปุ่มกลับหน้าแรก แทนข้อความ error ภาษาอังกฤษ

app_theme.dart — สีและหน้าตาของทั้งแอป

lib/core/theme/app_theme.dartแกนกลาง

รวมสีทั้งหมดไว้เป็นค่าคงที่ (ธีม “ฟ้าครีม”: ฟ้าเข้ม #1287B5 เป็นสีหลัก, พื้นครีม #FEF6F1, ตัวอักษรสีหมึก #231F20) และสร้าง ThemeData แบบ Material 3 ที่ใช้ฟอนต์ Kanit ซึ่งฝังมาในแอป ทุกหน้าเรียกสีผ่าน AppTheme.primary, AppTheme.error ฯลฯ ถ้าอยากเปลี่ยนสีทั้งแอปจึงแก้ที่ไฟล์นี้ไฟล์เดียว

lib/core/theme/app_theme.dartบรรทัด 5–45
class AppTheme {
  // ---- Color Palette ----
  static const Color primary = Color(0xFF1287B5); // ฟ้าเข้ม: แถบหัว ปุ่ม ไอคอน หัวข้อ
  static const Color primaryLight = Color(0xFF45CBF3); // ฟ้าสด (สีอ้างอิง): ไฮไลต์ ไล่เฉดสี
  static const Color primaryDark = Color(0xFF0B6A90); // ฟ้าเข้มกว่า: ปลายไล่เฉดสีของแถบหัว
  static const Color primaryDarker = Color(0xFF0E5577);
  static const Color primarySoft = Color(0xFFDFF5FD); // ฟ้าอ่อน: พื้นหลังป้าย/ชิป/ไอคอน
  static const Color primaryTint = Color(0xFFEFFAFE); // ฟ้าอ่อนมาก: พื้นการ์ดไฮไลต์
  static const Color primaryBorder = Color(0xFFB9E9FA); // เส้นขอบโทนฟ้า
  static const Color accent = Color(0xFF45CBF3);
  static const Color accentLight = Color(0xFFA6E6FA);
  static const Color ink = Color(0xFF231F20); // หมึกเข้ม: ตัวอักษรหลัก ของเด่นโทนเข้ม
  static const Color inkLight = Color(0xFF4A4340); // ปลายไล่เฉดสีของการ์ดสีหมึก
  static const Color cream = Color(0xFFFEF6F1); // ครีม (สีอ้างอิง): พื้นหลังหน้าจอ
  static const Color creamDark = Color(0xFFF3E9E2); // ครีมเข้ม: โครงตอนโหลดข้อมูล พื้นไอคอน
  static const Color creamLight = Color(0xFFFBF4EF);
  static const Color success = Color(0xFF2E9E6A);
  static const Color warning = Color(0xFFF29A2E);
  static const Color error = Color(0xFFE5484D);
  static const Color background = cream;
  static const Color surface = Color(0xFFFFFFFF);
  static const Color textPrimary = ink;
  static const Color textSecondary = Color(0xFF6B625C); // เทาอมน้ำตาล เข้ากับพื้นครีม
  static const Color divider = Color(0xFFEFE2D8);

  static ThemeData get theme => ThemeData(
    useMaterial3: true,
    fontFamily: 'Kanit',
    colorScheme: ColorScheme.fromSeed(
      seedColor: primary,
      primary: primary,
      secondary: primaryLight,
      surface: surface,
      error: error,
      onSurface: textPrimary,
    ),
    scaffoldBackgroundColor: background,
    appBarTheme: const AppBarTheme(
      backgroundColor: primary,
      foregroundColor: Colors.white,
      elevation: 0,

ส่วนที่เหลือของไฟล์ตั้งหน้าตาเริ่มต้นของปุ่ม (elevatedButtonTheme), ช่องกรอก (inputDecorationTheme), การ์ด และแถบด้านล่าง หน้าจอจึงไม่ต้องกำหนดสไตล์ซ้ำทุกปุ่ม

formatters.dart — แปลงวันที่และค่าจ้างให้อ่านง่าย

lib/core/utils/formatters.dartแกนกลาง
ฟังก์ชันตัวอย่างค่าเข้าผลลัพธ์
formatThaiDate2026-10-1515 ต.ค. 2569 (ปี ค.ศ. + 543)
formatRelativeThaiเวลาที่สร้างแจ้งเตือนเมื่อสักครู่ / 5 นาทีที่แล้ว / 3 ชม.ที่แล้ว / เมื่อวาน / ถ้าเก่ากว่านั้นแสดงวันที่
formatSalary15000-24000
350-500 บาท/วัน
ตามตกลง
15,000 – 24,000 บาท
350 – 500 บาท/วัน
ตามตกลง (ไม่มีตัวเลขจึงคืนค่าเดิม)
lib/core/utils/formatters.dartบรรทัด 3–41
const _thaiMonths = ['ม.ค.', 'ก.พ.', 'มี.ค.', 'เม.ย.', 'พ.ค.', 'มิ.ย.', 'ก.ค.', 'ส.ค.', 'ก.ย.', 'ต.ค.', 'พ.ย.', 'ธ.ค.'];
final _number = NumberFormat('#,##0');

/// แปลง "2026-10-15" (หรือ "2026-10-15 08:30:00") เป็น "15 ต.ค. 2569"
String formatThaiDate(String? isoDate) {
  final d = DateTime.tryParse(isoDate ?? '');
  if (d == null) return isoDate ?? '';
  return '${d.day} ${_thaiMonths[d.month - 1]} ${d.year + 543}';
}

/// เวลาแบบสัมพัทธ์สำหรับแจ้งเตือน: "เมื่อสักครู่", "5 นาทีที่แล้ว", "3 ชม.ที่แล้ว", "เมื่อวาน", เกินนั้นแสดงวันที่
String formatRelativeThai(String? dateTime) {
  final d = DateTime.tryParse(dateTime ?? '');
  if (d == null) return dateTime ?? '';
  final diff = DateTime.now().difference(d);
  if (diff.inMinutes < 1) return 'เมื่อสักครู่';
  if (diff.inMinutes < 60) return '${diff.inMinutes} นาทีที่แล้ว';
  if (diff.inHours < 24) return '${diff.inHours} ชม.ที่แล้ว';
  if (diff.inDays == 1) return 'เมื่อวาน';
  return formatThaiDate(dateTime);
}

/// จัดรูปแบบค่าจ้าง: "15000-24000 บาท" -> "15,000 – 24,000 บาท", "350-500 บาท/วัน" -> "350 – 500 บาท/วัน"
/// ข้อความที่ไม่มีตัวเลข (เช่น "ตามตกลง") คืนค่าเดิม
String formatSalary(String? raw) {
  final text = (raw ?? '').trim();
  if (text.isEmpty) return '';
  final numbers = RegExp(r'\d[\d,]*(?:\.\d+)?')
      .allMatches(text)
      .map((m) => double.tryParse(m.group(0)!.replaceAll(',', '')))
      .whereType<double>()
      .toList();
  if (numbers.isEmpty) return text;
  final unit = RegExp(r'/\s*\S+').firstMatch(text)?.group(0)?.replaceAll(' ', '') ?? '';
  final amount = numbers.length >= 2
      ? '${_number.format(numbers[0])} – ${_number.format(numbers[1])}'
      : _number.format(numbers[0]);
  return '$amount บาท$unit';
}

formatSalary ใช้ Regular Expression สองตัว: \d[\d,]*(?:\.\d+)? หาตัวเลขทุกตัวในข้อความ (รองรับมีคอมมาและทศนิยม) และ /\s*\S+ หาหน่วยหลังเครื่องหมาย / เช่น “/วัน” แล้วนำมาประกอบใหม่

location_support.dart — ใช้ GPS ได้ไหม

lib/core/utils/location_support.dartแกนกลาง

บนมือถือคืน true เสมอ แต่บนเว็บ เบราว์เซอร์ (โดยเฉพาะ Safari บน iPhone) อนุญาตให้ใช้ตำแหน่งปัจจุบันเฉพาะหน้าเว็บ https หรือ localhost ถ้าเปิดผ่าน http://192.168… คำขอ GPS อาจค้างไม่ตอบ ทำให้หน้าค้นหางานโหลดไม่ขึ้น จึงตรวจก่อนแล้วข้ามไปใช้พิกัดจากที่อยู่ในโปรไฟล์แทน ใช้ในหน้าค้นหางาน (seeker_search_tab.dart) และหน้าโพสต์งาน (post_job_page.dart)

lib/core/utils/location_support.dartบรรทัด 1–10
import 'package:flutter/foundation.dart' show kIsWeb;

/// ใช้ GPS ได้หรือไม่
/// เบราว์เซอร์ (โดยเฉพาะ Safari บน iPhone) ไม่ให้ใช้ตำแหน่งปัจจุบันบนเว็บแบบ http
/// และบางครั้งไม่ตอบกลับเลย ทำให้หน้าที่รอ GPS ค้าง จึงข้ามไปใช้พิกัดจากที่อยู่ในโปรไฟล์แทน
bool get canUseGps {
  if (!kIsWeb) return true;
  final uri = Uri.base;
  return uri.scheme == 'https' || uri.host == 'localhost' || uri.host == '127.0.0.1';
}

5. แอป Flutter: ชั้นข้อมูล (data)

Model, การเก็บ session และตัวกลางเรียก API

models/ — คลาสข้อมูล 5 ไฟล์

lib/data/models/*.dartข้อมูล

Model คือคลาสที่แทนข้อมูล 1 ชิ้น ทุกไฟล์ใช้โครงเดียวกัน: ฟิลด์แบบ final (สร้างแล้วแก้ไม่ได้) + constructor แบบ const + factory fromJson() ที่แปลง JSON จาก API เป็นอ็อบเจกต์

ไฟล์ / คลาสเก็บอะไรมาจาก endpoint
member_model.dart
MemberModel
ผู้ใช้ 1 คน: ส่วนร่วม (userId, email, name, role) + ส่วนบริษัท (employerId, companyName, logo, cover, พิกัด) + ส่วนผู้หางาน (seekerId, skills, education, resumeUrl …) ฟิลด์ของอีกบทบาทจะเป็น nulllogin.php, get_member.php
job_posting_model.dart
JobPostingModel
ประกาศงาน + ผลการจับคู่กับผู้หางานคนนี้ (matchScore, matchLevel, matchedSkills, missingSkills, isRecommended) + ระยะทาง distanceKm + จำนวนที่รับ (vacancy, hiredCount)get_jobs.php, get_employer_jobs.php
application_model.dart
ApplicationModel
ใบสมัคร + ข้อมูลผู้สมัคร + ชื่องาน/บริษัท + นัดสัมภาษณ์ + วันเริ่มงาน + คะแนน อันดับ และป้ายผู้สมัครแนะนำ (isTopCandidate)get_applications.php
message_model.dart
MessageModel, ChatListItem
ข้อความแชท 1 ข้อความ / 1 แถวในรายการห้องแชท (คู่สนทนา รูป ข้อความล่าสุด จำนวนที่ยังไม่อ่าน)get_messages.php
notification_model.dart
NotificationModel
แจ้งเตือน: หัวข้อ เนื้อหา type (บอกว่าเกี่ยวกับเรื่องอะไร) และอ่านแล้วหรือยังget_notifications.php

เทคนิคการแปลง JSON แบบกันพัง

ข้อมูลจาก API อาจขาดบางคีย์หรือมาเป็นชนิดไม่ตรง (เช่น ตัวเลขจาก MySQL ชนิด DECIMAL มาเป็นข้อความ) fromJson จึงเขียนแบบเผื่อไว้ทุกจุด:

lib/data/models/job_posting_model.dartบรรทัด 86–93ทักษะอาจมาเป็น List หรือข้อความคั่นด้วยคอมมา รองรับทั้งสองแบบ
factory JobPostingModel.fromJson(Map<String, dynamic> json) {
  List<String> skills = [];
  final rawSkills = json['required_skills'] ?? json['tags'];
  if (rawSkills is List) {
    skills = rawSkills.map((e) => e.toString()).toList();
  } else if (rawSkills is String && rawSkills.isNotEmpty) {
    skills = rawSkills.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
  }
lib/data/models/job_posting_model.dartบรรทัด 105–110?? คือ 'ถ้าเป็น null ให้ใช้ค่าทางขวา' จึงลองคีย์ชื่อแรก ไม่มีก็ลองชื่อที่สอง สุดท้ายใช้ค่าเริ่มต้น
return JobPostingModel(
  jobId:          json['job_id'] as int? ?? json['id'] as int? ?? 0,
  employerId:     json['employer_id'] as int? ?? 0,
  employerUserId: json['employer_user_id'] as int? ?? 0,
  jobTitle:       json['job_title'] as String? ?? json['title'] as String? ?? '',
  jobDescription: json['job_description'] as String? ?? '',

Model ยังมี getter ที่คำนวณค่าใหม่จากฟิลด์ที่มีอยู่ หน้าจอเรียกใช้ได้เหมือนฟิลด์ปกติ:

lib/data/models/job_posting_model.dartบรรทัด 67–84
/// ค่าจ้างเป็นตัวเลข (ดึงเฉพาะตัวเลขจากข้อความ เช่น "15,000" -> 15000) ใช้กรอง/เรียงตามค่าจ้าง
double? get salaryValue {
  final digits = (salary ?? '').replaceAll(RegExp(r'[^0-9.]'), '');
  final value = double.tryParse(digits);
  return (value == null || value <= 0) ? null : value;
}

/// งานที่ผู้หางานมองเห็นได้ ('open' = ข้อมูลเดิมก่อนมีระบบอนุมัติ)
bool get isApproved => status == 'open' || status == 'approved';

/// จำนวนที่ยังรับได้อยู่ตอนนี้ (ไม่ต่ำกว่า 0 แม้ hiredCount จะเกิน vacancy มาจาก backend)
int get remainingVacancy {
  final remaining = vacancy - hiredCount;
  return remaining < 0 ? 0 : remaining;
}

/// รับครบตามจำนวนที่ต้องการแล้วหรือยัง
bool get isFull => remainingVacancy <= 0;

session_service.dart — จำว่าใครล็อกอินอยู่

lib/data/services/session_service.dartข้อมูล

ห่อ SharedPreferences (ที่เก็บข้อมูลเล็ก ๆ ในเครื่อง ปิดแอปแล้วยังอยู่) ให้ใช้ง่าย และเป็น Singleton เรียกผ่าน SessionService.instance ได้จากทุกหน้า

lib/data/services/session_service.dartบรรทัด 5–16
class SessionService {
  static SessionService? _instance;
  static SessionService get instance => _instance ??= SessionService._();
  SessionService._();

  SharedPreferences? _prefs;
  static const String prefProfileImage = 'profile_image';
  static const String prefCompanyName = 'company_name';

  Future<void> init() async {
    _prefs = await SharedPreferences.getInstance();
  }
เมธอดใช้ตอนไหน
saveSession(member, employerId:, seekerId:)หลังล็อกอิน/สมัครสำเร็จ บันทึก user_id, ชื่อ, บทบาท, อีเมล, รูป, ชื่อบริษัท, employer_id หรือ seeker_id และตั้ง is_logged_in = true
rememberUserId(id)ระหว่างล็อกอิน จำ user_id ไว้ก่อน คำขอถัดไป (ดึงโปรไฟล์) จะได้แนบ X-User-Id ได้
updateProfileImage(), updateUserName()หลังเปลี่ยนรูป/ชื่อ ให้หน้าอื่นเห็นค่าใหม่
clearSession()ออกจากระบบ หรือ API ตอบ 401
lib/data/services/session_service.dartบรรทัด 63–73getter คืนค่าเริ่มต้น (0 หรือข้อความว่าง) ถ้ายังไม่มีข้อมูล หน้าจอจึงไม่ต้องเช็ก null
int get userId => _prefs?.getInt(AppConstants.prefUserId) ?? 0;
int get employerId => _prefs?.getInt(AppConstants.prefEmployerId) ?? 0;
int get seekerId => _prefs?.getInt(AppConstants.prefSeekerId) ?? 0;
String get userName => _prefs?.getString(AppConstants.prefUserName) ?? '';
String get userRole => _prefs?.getString(AppConstants.prefUserRole) ?? '';
String get userEmail => _prefs?.getString(AppConstants.prefUserEmail) ?? '';
/// ชื่อบริษัท (ผู้ว่าจ้าง) ไม่มีให้ใช้ชื่อผู้ใช้แทน
String get companyName => _prefs?.getString(prefCompanyName) ?? userName;
String get profileImage =>
    AppConstants.resolveUrl(_prefs?.getString(prefProfileImage)) ?? '';
bool get isLoggedIn => _prefs?.getBool(AppConstants.prefIsLoggedIn) ?? false;
user_id, seeker_id, employer_id ไม่ใช่เลขเดียวกัน ผู้ใช้ 1 คนมี user_id (ตาราง member) และมีอีกเลขตามบทบาท: seeker_id (ตาราง job_seeker) หรือ employer_id (ตาราง employer) แชทและแจ้งเตือนใช้ user_id, ใบสมัครใช้ seeker_id, ประกาศงานใช้ employer_id ถ้าส่งเลขผิดประเภท ระบบจะหาข้อมูลไม่เจอ (อ่านต่อในบทที่ 10)

api_service.dart — ตัวกลางเรียก API ทั้งหมด

lib/data/services/api_service.dartข้อมูล

ทุกหน้าที่ต้องคุยกับเซิร์ฟเวอร์จะเรียกผ่านคลาสนี้ ไม่มีหน้าไหนเรียก HTTP เอง ข้อดีคือแก้วิธีเชื่อมต่อ (เช่น เพิ่ม header) ได้ในที่เดียว

1) Singleton และ interceptor

factory ApiService() => _instance; ทำให้ทุกครั้งที่เรียก ApiService() ได้อ็อบเจกต์เดิม ใน constructor มีการติดตั้ง interceptor (ตัวดักคำขอ) ที่ทำงานกับทุกคำขอโดยอัตโนมัติ: ขาออกแนบ header X-User-Id ให้ backend รู้ว่าใครเรียก และขาเข้าถ้าเจอ 401 ก็เรียก onUnauthorized ที่ตั้งไว้ใน main.dart

lib/data/services/api_service.dartบรรทัด 12–49
class ApiService {
  static final ApiService _instance = ApiService._internal();
  factory ApiService() => _instance;
  ApiService._internal() {
    _dio.interceptors.add(
      InterceptorsWrapper(
        onRequest: (options, handler) {
          // แนบ user_id ของผู้ที่ล็อกอินอยู่ทุกคำขอ (backend ใช้ตรวจสิทธิ์ความเป็นเจ้าของข้อมูล)
          final userId = SessionService.instance.userId;
          if (userId > 0) {
            options.headers['X-User-Id'] = '$userId';
          }
          handler.next(options);
        },
        onResponse: (response, handler) {
          // token หมดอายุ / ไม่มี token (เช่น แอปเวอร์ชันเก่าที่ยังไม่เคยได้ token) -> ออกจากระบบให้ล็อกอินใหม่
          if (response.statusCode == 401 && !response.requestOptions.path.endsWith('login.php')) {
            _notifyUnauthorized();
          }
          handler.next(response);
        },
      ),
    );
  }

  /// เรียกเมื่อ backend ตอบ 401 (ตั้งค่าใน main.dart ให้ล้าง session แล้วกลับไปหน้าเริ่มต้น)
  static Future<void> Function()? onUnauthorized;
  static bool _handlingUnauthorized = false;

  static Future<void> _notifyUnauthorized() async {
    if (_handlingUnauthorized || onUnauthorized == null) return;
    _handlingUnauthorized = true;
    try {
      await onUnauthorized!();
    } finally {
      _handlingUnauthorized = false;
    }
  }

2) ค่าตั้งของ Dio และตัวช่วย

lib/data/services/api_service.dartบรรทัด 50–88
final Dio _dio = Dio(
  BaseOptions(
    connectTimeout: const Duration(seconds: 10),
    receiveTimeout: const Duration(seconds: 10),
    headers: {'Content-Type': 'application/json'},
    validateStatus: (status) => true, // ยอมรับทุก status code ไม่ throw exception
  ),
);

Map<String, dynamic> _noCache(Map<String, dynamic> params) {
  params['_t'] = DateTime.now().millisecondsSinceEpoch;
  return params;
}

void _logError(String method, Object error) {
  if (kDebugMode) {
    debugPrint('[ApiService] $method failed: $error');
  }
}

// แปลง response data ให้เป็น Map อย่างปลอดภัย
// รองรับทั้งกรณีที่ Dio parse ให้แล้ว (Map) และกรณีที่ยังเป็น String อยู่
// (เช่น PHP backend ส่ง BOM, Content-Type ไม่ตรง หรือ Dio ไม่ auto-parse)
Map<String, dynamic> _parseMap(dynamic data) {
  if (data is Map<String, dynamic>) return data;
  if (data is String) {
    final raw = data.trim();
    final clean = raw.startsWith('\uFEFF') ? raw.substring(1) : raw;
    if (clean.isNotEmpty) {
      try {
        final decoded = jsonDecode(clean);
        if (decoded is Map<String, dynamic>) return decoded;
      } catch (e) {
        if (kDebugMode) debugPrint('[ApiService] jsonDecode error: $e\nData: $clean');
      }
    }
  }
  return {'success': false, 'message': 'ไม่สามารถอ่านข้อมูลจากเซิร์ฟเวอร์ได้'};
}
  • timeout 10 วินาที เซิร์ฟเวอร์ไม่ตอบภายใน 10 วินาทีจะยกเลิก ไม่ให้แอปรอนาน
  • validateStatus: (status) => true สั่ง Dio ไม่ throw error เมื่อได้ 4xx/5xx เพราะ API ส่งข้อความภาษาไทยมาใน body อยู่แล้ว แอปจึงอ่าน success และ message ได้ตามปกติ
  • _noCache() เติมพารามิเตอร์ _t=เวลาปัจจุบัน ให้คำขอ GET ทุกครั้ง URL จะไม่ซ้ำ เบราว์เซอร์ (โดยเฉพาะเวอร์ชันเว็บ) จึงไม่ใช้ผลลัพธ์เก่าจากแคช
  • _parseMap() รับมือกรณี API ส่ง JSON มาเป็นข้อความ หรือมีอักขระ BOM () นำหน้า ซึ่งทำให้ jsonDecode พังถ้าไม่ตัดออก

3) รูปแบบของเมธอด

เมธอดแบ่งได้ 3 แบบ: แบบสั่งงาน คืน Map ให้หน้าจอเช็ก success/message, แบบดึงรายการ คืน List ของ Model (ถ้าพลาดคืนลิสต์ว่าง หน้าจอจะไม่พัง) และแบบอัปโหลดไฟล์

lib/data/services/api_service.dartบรรทัด 450–464แบบสั่งงาน: ส่ง JSON แล้วคืน Map
Future<Map<String, dynamic>> applyJob({
  required int jobId,
  required int seekerId,
}) async {
  try {
    final res = await _dio.post(
      AppConstants.applyJob,
      data: {'job_id': jobId, 'seeker_id': seekerId},
    );
    return _parseMap(res.data);
  } on DioException catch (e) {
    _logError('applyJob', e);
    return _handleError(e);
  }
}
lib/data/services/api_service.dartบรรทัด 336–353แบบดึงรายการ: แปลงแต่ละแถวเป็น Model ด้วย .map(...).toList()
Future<List<JobPostingModel>> getEmployerJobs(int employerId) async {
  try {
    final res = await _dio.get(
      AppConstants.getEmployerJobs,
      queryParameters: _noCache({'employer_id': employerId}),
    );
    final body = _parseMap(res.data);
    if (body['success'] == true) {
      return (body['data'] as List)
          .map((j) => JobPostingModel.fromJson(j))
          .toList();
    }
    return [];
  } catch (e) {
    _logError('getEmployerJobs', e);
    return [];
  }
}
lib/data/services/api_service.dartบรรทัด 771–798แบบอัปโหลด: ส่งแบบ multipart/form-data ใช้ fromBytes จึงทำงานได้ทั้งมือถือและเว็บ (บนเว็บอ่านไฟล์จาก path ไม่ได้)
Future<Map<String, dynamic>> uploadProfileImage({
  required int userId,
  required String role,
  // ส่งเป็น bytes เพื่อให้ใช้ได้ทั้งแอปมือถือและเว็บ (บนเว็บอ่านไฟล์จาก path ไม่ได้)
  required List<int> bytes,
  required String fileName,
  // 'cover' = ภาพปกบริษัท, null = รูปโปรไฟล์/โลโก้
  String? kind,
}) async {
  try {
    final formData = FormData.fromMap({
      'user_id': userId,
      'role': role,
      if (kind != null) 'kind': kind,
      'file': MultipartFile.fromBytes(bytes, filename: fileName),
    });
    final res = await _dio.post(
      AppConstants.uploadProfileImage,
      data: formData,
    );
    return _parseMap(res.data);
  } on DioException catch (e) {
    _logError('uploadProfileImage', e);
    return _handleError(e);
  } catch (e) {
    return {'success': false, 'message': e.toString()};
  }
}

4) เมธอดทั้งหมดและหน้าที่เรียกใช้

เมธอดHTTP → endpointเรียกจากหน้า
loginPOST login.phpเข้าสู่ระบบทั้ง 2 บทบาท, หลังสมัครสมาชิก
registerEmployer / registerSeekerPOST register_employer.php / register_seeker.phpหน้าสมัครสมาชิก
getMemberGET get_member.phpหลังล็อกอิน, โปรไฟล์, แก้ไขบริษัท, หน้าบริษัท
updateEmployerProfilePUT update_employer.phpEditProfilePage
updateSeekerProfilePOST update_seeker.phpSeekerProfileTab
getEmployerJobsGET get_employer_jobs.phpแดชบอร์ด, จัดการงาน (Cubit), รายชื่อผู้สมัคร, หน้าบริษัท
postJob / updateJob / deleteJobPOST post_job.php / PUT update_job.php / POST delete_job.phpPostJobPage, ManageJobsTab
getJobsForSeekerGET get_jobs.phpหน้าแรก/ค้นหา/รายละเอียดงานของผู้หางาน
applyJob / cancelApplicationPOST apply_job.php / cancel_application.phpรายละเอียดงาน, งานของฉัน
getApplicationsByJob / …ByEmployer / …BySeekerGET get_applications.php (?job_id / ?employer_id / ?seeker_id)รายชื่อผู้สมัคร / แดชบอร์ด, โปรไฟล์ผู้สมัคร, นัดสัมภาษณ์ / งานของฉัน
updateApplicationStatusPUT update_application.phpผู้ว่าจ้างเปลี่ยนสถานะ, ผู้หางานตอบรับ/ปฏิเสธงาน
postInterviewPOST post_interview.phpInterviewPage
getChatList / getMessages / sendMessageGET get_messages.php / POST send_message.phpแท็บแชท, ห้องแชท, ตัวนับบนแถบล่าง
getNotifications / markNotificationAsRead / deleteNotificationGET get_notifications.php / POST mark_notification_read.php / POST delete_notification.phpแท็บแจ้งเตือน, ตัวนับ, ปัดลบ
getKeywords / addKeywordGET get_keywords.php / POST add_keyword.phpเลือกทักษะตอนสมัคร/แก้โปรไฟล์/โพสต์งาน
getEmployerUserIdGET get_employer_user_id.phpรายละเอียดงาน (หา user_id เพื่อเปิดแชท + เบอร์โทร)
uploadProfileImage / uploadResumePOST upload_profile_image.php / upload_resume.phpหน้าโปรไฟล์ทั้งสองบทบาท
resetPasswordPOST reset_password.phpForgotPasswordPage
login() ต่างจากเมธอดอื่นเล็กน้อย login (บรรทัด 91–121) ขอผลลัพธ์เป็นข้อความดิบ (ResponseType.plain) แล้วถอด JSON เอง เพื่อให้ได้ข้อความ error ที่ชัดเจนเวลาเซิร์ฟเวอร์ตอบผิดรูปแบบ และถ้าสำเร็จจะเรียก rememberUserId() ทันที

6. แอป Flutter: หน้าจอ (presentation)

ทุกหน้าที่ผู้ใช้เห็น แยกตามบทบาท พร้อมโค้ดส่วนที่ทำงานจริง

เกือบทุกหน้าในโปรเจกต์เขียนด้วยแพตเทิร์นเดียวกัน จำแพตเทิร์นนี้ไว้แล้วจะอ่านหน้าไหนก็เข้าใจเร็ว:

  1. ฟิลด์สถานะ ในคลาส State เช่น _isLoading, _jobs, _isSaving
  2. initState() เรียกเมธอดโหลดข้อมูล เช่น _fetchJobs() (บางหน้าเริ่ม Timer สำหรับ polling ด้วย)
  3. เมธอดโหลด/สั่งงาน ตั้ง _isLoading = true → await ApiService()… → เช็ก mounted → setState ใส่ข้อมูลใหม่
  4. build() แสดง 3 สถานะ: กำลังโหลด (วงหมุน/โครงสีเทา), ไม่มีข้อมูล (ข้อความ + ไอคอน) และมีข้อมูล (รายการ/การ์ด)
  5. ผลลัพธ์ สำเร็จหรือผิดพลาดแจ้งผู้ใช้ด้วย SnackBar สีเขียว/แดง ข้อความผิดพลาดใช้ res['message'] ที่ API ส่งมา

แผนผังหน้าจอของทั้งสองบทบาท

ผู้ว่าจ้าง: MainShell (5 แท็บ)

  1. หน้าแรก → DashboardTab
  2. งานของฉัน → ManageJobsTab
  3. แชท → ChatTab
  4. แจ้งเตือน → NotificationsTab
  5. บริษัท → ProfileTab

หน้าที่เปิดซ้อน: เพิ่ม/แก้ไขงาน, รายชื่อผู้สมัคร, โปรไฟล์ผู้สมัคร, นัดสัมภาษณ์, ห้องแชท, แก้ไขบริษัท

ผู้หางาน: SeekerMainShell (5 แท็บ)

  1. หน้าแรก → SeekerHomeTab
  2. งานของฉัน → SeekerApplicationsTab
  3. ค้นหา → SeekerSearchTab
  4. ข้อความ → SeekerChatTab
  5. โปรไฟล์ → SeekerProfileTab

หน้าที่เปิดซ้อน: รายละเอียดงาน, หน้าบริษัท, แผนที่งาน, แจ้งเตือน (ไอคอนกระดิ่งในหน้าแรก), ห้องแชท, ดู PDF

เปิดแอปและเข้าสู่ระบบ

SplashPage — หน้าโหลดตอนเปิดแอป

lib/presentation/pages/splash/splash_page.dartหน้าจอ

แสดงโลโก้ 2 วินาที แล้วตัดสินว่าจะไปหน้าไหนจากข้อมูลใน SessionService: ล็อกอินเป็นผู้หางาน → /seeker-home, เป็นผู้ว่าจ้าง → /home, ยังไม่ล็อกอิน → /welcome

lib/presentation/pages/splash/splash_page.dartบรรทัด 20–32
Future<void> _checkSession() async {
  await Future.delayed(const Duration(seconds: 2));
  if (!mounted) return;
  if (SessionService.instance.isLoggedIn) {
    if (SessionService.instance.userRole == 'seeker') {
      context.go('/seeker-home');
    } else {
      context.go('/home');
    }
  } else {
    context.go('/welcome');
  }
}

WelcomePage — เลือกบทบาท

lib/presentation/pages/welcome/welcome_page.dartหน้าจอ

หน้าแรกของผู้ที่ยังไม่ล็อกอิน มีปุ่มเข้าสู่ระบบแยกของผู้หางาน (/seeker-login) และผู้ว่าจ้าง (/login) และ _showLoginOptions() ที่เปิดแผ่นเลื่อนจากด้านล่าง (showModalBottomSheet) ให้เลือกบทบาท หน้าสมัครสมาชิกและลืมรหัสผ่านเข้าได้จากลิงก์ในหน้าเข้าสู่ระบบของแต่ละบทบาท

LoginPage / SeekerLoginPage — เข้าสู่ระบบ

lib/presentation/pages/auth/login_page.dart · seeker/seeker_login_page.dartหน้าจอ

สองหน้าทำงานเหมือนกัน ต่างกันแค่บทบาทที่ยอมรับ ลำดับการทำงานของ _handleLogin():

  1. ตรวจฟอร์ม (อีเมล/รหัสผ่านต้องไม่ว่าง) ถ้าไม่ผ่านหยุดทันที
  2. เรียก login() ถ้าสำเร็จเช็กว่า role ตรงกับหน้านี้ (ผู้หางานล็อกอินในหน้าผู้ว่าจ้างไม่ได้)
  3. เรียก getMember() ดึงโปรไฟล์เต็ม (รูป ชื่อบริษัท ฯลฯ) แล้วบันทึกลง SessionService
  4. context.go('/home') ไปหน้าหลัก ใช้ go ไม่ใช่ push เพื่อไม่ให้กดย้อนกลับมาหน้าล็อกอินได้
lib/presentation/pages/auth/login_page.dartบรรทัด 22–64
Future<void> _handleLogin() async {
  if (!_formKey.currentState!.validate()) return;
  setState(() => _isLoading = true);

  final res = await ApiService().login(
    _emailController.text.trim(),
    _passwordController.text,
  );

  setState(() => _isLoading = false);

  if (!mounted) return;

  if (res['success'] == true) {
    final userData = res['data'];
    final role = userData['role'] ?? 'employer';

    if (role != 'employer') {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('บัญชีนี้ไม่ใช่บัญชีสำหรับนายจ้าง'),
          backgroundColor: AppTheme.error,
        ),
      );
      return;
    }

    final member = MemberModel.fromJson(userData);

    final fullMember = await ApiService().getMember(member.userId);
    await SessionService.instance.saveSession(fullMember ?? member);

    if (!mounted) return;
    context.go('/home');
  } else {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(res['message'] ?? 'เข้าสู่ระบบไม่สำเร็จ'),
        backgroundColor: AppTheme.error,
      ),
    );
  }
}

RegisterEmployerPage — สมัครสมาชิกผู้ว่าจ้าง

lib/presentation/pages/auth/register_employer_page.dartหน้าจอ

มีตัวเลือกประเภท “บริษัท/นิติบุคคล” หรือ “บุคคลทั่วไป” (ถ้าเป็นบุคคลทั่วไป ระบบใช้ชื่อคนเป็นชื่อบริษัท) ที่อยู่เลือกจังหวัด/อำเภอ/ตำบลจากรายการ แล้วรวมเป็นข้อความรูปแบบ … ต.… อ.… จ.… รหัสไปรษณีย์ ซึ่ง backend ใช้หาพิกัดต่อ เมื่อสมัครสำเร็จจะล็อกอินให้อัตโนมัติ ไม่ต้องกรอกซ้ำ

lib/presentation/pages/auth/register_employer_page.dartบรรทัด 81–124
final isCompany = _employerType == EmployerType.company;
final subdistrict = _thaiAddressController.subdistrict?.nameTh ?? '';
final district = _thaiAddressController.district?.nameTh ?? '';
final province = _thaiAddressController.province?.nameTh ?? '';
final zipCodeText =
    _thaiAddressController.subdistrict?.postcode.toString() ?? '';
final fullAddress = _addressDetailController.text.trim();

final completeAddress =
    '$fullAddress ต.$subdistrict อ.$district จ.$province $zipCodeText';

final res = await ApiService().registerEmployer(
  name: _nameController.text.trim(),
  email: _emailController.text.trim(),
  password: _passwordController.text,
  contactPhone: _contactPhoneController.text.trim(),
  companyAddress: completeAddress.trim(),
  companyName: isCompany
      ? _companyNameController.text.trim()
      : _nameController.text.trim(),
  companyDetail: isCompany
      ? _companyDetailController.text.trim()
      : 'บุคคลทั่วไป',
  latitude: _latitude,
  longitude: _longitude,
);

if (!mounted) return;

if (res['success'] == true) {
  final loginRes = await ApiService().login(
    _emailController.text.trim(),
    _passwordController.text,
  );
  if (!mounted) return;

  if (loginRes['success'] == true) {
    final userData = loginRes['data'];
    final member = MemberModel.fromJson(userData);
    final fullMember = await ApiService().getMember(member.userId);

    await SessionService.instance.saveSession(fullMember ?? member);
    if (!mounted) return;
    context.go('/home');

SeekerRegisterPage — สมัครสมาชิกผู้หางาน (3 ขั้นตอน)

lib/presentation/pages/seeker/seeker_register_page.dartหน้าจอ

ฟอร์มแบ่งเป็น 3 ขั้น แต่ละขั้นมี GlobalKey<FormState> ของตัวเอง ปุ่ม “ถัดไป” ตรวจเฉพาะขั้นปัจจุบัน ผ่านแล้วค่อยเลื่อนขั้น ขั้นสุดท้ายจึงส่งข้อมูล ตอนเปิดหน้าจะโหลดคลังทักษะ (getKeywords) มาให้เลือกตามหมวด หลังสมัครสำเร็จจะล็อกอินอัตโนมัติและบันทึก seekerId

lib/presentation/pages/seeker/seeker_register_page.dartบรรทัด 153–170
void _nextStep() {
  bool isStepValid = false;
  if (_currentStep == 0) {
    isStepValid = _step1FormKey.currentState!.validate();
  } else if (_currentStep == 1) {
    isStepValid = _step2FormKey.currentState!.validate();
  } else if (_currentStep == 2) {
    isStepValid = _step3FormKey.currentState!.validate();
  }

  if (isStepValid) {
    if (_currentStep < 2) {
      setState(() => _currentStep += 1);
    } else {
      _handleRegister();
    }
  }
}
lib/presentation/pages/seeker/seeker_register_page.dartบรรทัด 211–236สมัครสำเร็จ → ล็อกอินให้ทันที → ดึงโปรไฟล์ → บันทึก session → ไปหน้าหลักผู้หางาน
if (res['success'] == true) {
  final loginRes = await ApiService().login(
    _emailController.text.trim(),
    _passwordController.text,
  );

  if (!mounted) return;

  if (loginRes['success'] == true) {
    final userData = loginRes['data'];
    final member = MemberModel.fromJson(userData);
    final fullMember = await ApiService().getMember(member.userId);

    await SessionService.instance.saveSession(
      fullMember ?? member,
      seekerId: userData['seeker_id'] as int?,
    );

    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(
        content: Text('สมัครสมาชิกและเข้าสู่ระบบสำเร็จ!'),
        backgroundColor: AppTheme.success,
      ),
    );
    context.go('/seeker-home');

ForgotPasswordPage — ลืมรหัสผ่าน

lib/presentation/pages/auth/forgot_password_page.dartหน้าจอ

กรอกอีเมล + เบอร์โทรที่ใช้สมัคร + รหัสผ่านใหม่ ถ้าข้อมูลตรงกับในระบบ backend จะตั้งรหัสใหม่ให้ แล้วหน้านี้ปิดตัวเองกลับไปหน้าล็อกอิน

lib/presentation/pages/auth/forgot_password_page.dartบรรทัด 33–54
Future<void> _submit() async {
  if (!_formKey.currentState!.validate()) return;
  setState(() => _isLoading = true);

  final res = await ApiService().resetPassword(
    email: _emailController.text.trim(),
    phone: _phoneController.text.trim(),
    newPassword: _passwordController.text,
  );

  if (!mounted) return;
  setState(() => _isLoading = false);

  final ok = res['success'] == true;
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Text(res['message'] ?? (ok ? 'ตั้งรหัสผ่านใหม่สำเร็จ' : 'ตั้งรหัสผ่านใหม่ไม่สำเร็จ')),
      backgroundColor: ok ? AppTheme.success : AppTheme.error,
    ),
  );
  if (ok) context.pop();
}

โครงหน้าหลัก (Shell) และแถบเมนูด้านล่าง

MainShell และ SeekerMainShell

lib/presentation/pages/home/main_shell.dart · seeker/seeker_main_shell.dartหน้าจอ

Shell คือ “กรอบ” ที่มีแถบเมนูด้านล่าง และสลับเนื้อหาตรงกลางตามแท็บที่เลือก ทั้งสองไฟล์ยังถามเซิร์ฟเวอร์ทุก 5 วินาทีว่ามีข้อความ/แจ้งเตือนที่ยังไม่อ่านกี่รายการ เพื่อแสดงตัวเลขสีแดง (Badge) บนไอคอน

lib/presentation/pages/home/main_shell.dartบรรทัด 21–47
class _MainShellState extends State<MainShell> {
  int _currentIndex = 0;
  int _unreadChatCount = 0;
  int _unreadNotiCount = 0;
  Timer? _unreadTimer;

  final List<Widget> _tabs = const [
    DashboardTab(),
    ManageJobsTab(),
    ChatTab(),
    NotificationsTab(),
    ProfileTab(),
  ];

  @override
  void initState() {
    super.initState();
    _currentIndex = widget.initialTab.clamp(0, _tabs.length - 1);
    _fetchUnreadCount();
    _unreadTimer = Timer.periodic(const Duration(seconds: 5), (_) => _fetchUnreadCount());
  }

  @override
  void dispose() {
    _unreadTimer?.cancel();
    super.dispose();
  }

ฝั่งผู้ว่าจ้างใช้ IndexedStack ซึ่งสร้างทุกแท็บไว้พร้อมกันแล้วแสดงทีละแท็บ ส่วนฝั่งผู้หางานใช้ PageView + PageController โดยปิดการปัดด้วย NeverScrollableScrollPhysics (สลับแท็บได้จากแถบล่างเท่านั้น) และเปิดเมธอด switchTab() ให้หน้าลูกเรียกสลับแท็บเองได้

lib/presentation/pages/seeker/seeker_main_shell.dartบรรทัด 66–91
void _onPageChanged(int index) {
  setState(() => _currentIndex = index);
}

void switchTab(int index) {
  _pageController.jumpToPage(index);
  if (index == 3) _fetchUnreadCount();
}

void _onBottomNavTapped(int index) {
  _pageController.jumpToPage(index);
  if (index == 3) _fetchUnreadCount();
}

@override
Widget build(BuildContext context) {
  return Scaffold(
    body: PageView(
      controller: _pageController,
      onPageChanged: _onPageChanged,
      physics: const NeverScrollableScrollPhysics(),
      children: _tabs,
    ),
    bottomNavigationBar: BottomNavigationBar(
      currentIndex: _currentIndex,
      onTap: _onBottomNavTapped,

ตัวอย่างที่หน้าลูกสั่งสลับแท็บ: กดกระดิ่งในหน้าแรก → เปิดหน้าแจ้งเตือน → ถ้าผู้ใช้แตะแจ้งเตือนเรื่องใบสมัคร หน้าแจ้งเตือนจะ pop พร้อมค่า 'applications' กลับมา หน้าแรกจึงหา Shell ที่ครอบอยู่ด้วย findAncestorStateOfType แล้วสั่งไปแท็บ “งานของฉัน”

lib/presentation/pages/seeker/tabs/seeker_home_tab.dartบรรทัด 160–167
onPressed: () {
  context.push<String>('/seeker-home/notifications').then((result) {
    _fetchUnreadNotifications();
    // แตะแจ้งเตือนเรื่องใบสมัคร -> สลับไปแท็บ "งานของฉัน"
    if (result == kOpenApplicationsTab && context.mounted) {
      context.findAncestorStateOfType<SeekerMainShellState>()?.switchTab(1);
    }
  });

หน้าฝั่งผู้ว่าจ้าง

DashboardTab — แดชบอร์ด

lib/presentation/pages/home/tabs/dashboard_tab.dartหน้าจอ

ส่วนหัวแสดงโลโก้และชื่อบริษัท ตามด้วยการ์ดสถิติ 4 ใบ (งานที่เปิดรับ, ผู้สมัครทั้งหมด, รอดำเนินการ, นัดสัมภาษณ์แล้ว) ผู้สมัครล่าสุด และงานที่เปิดรับอยู่ ตัวเลขทั้งหมดนับในแอปจากรายการงานและใบสมัครที่โหลดมา ระหว่างโหลดจะแสดงโครงสีเทากะพริบ (shimmer) แล้วค่อย ๆ ปรากฏด้วย FadeTransition ดึงหน้าจอลงเพื่อรีเฟรชได้ (RefreshIndicator) แตะผู้สมัครจะไปหน้าโปรไฟล์ผู้สมัคร แตะงานจะไปหน้ารายชื่อผู้สมัครของงานนั้น

lib/presentation/pages/home/tabs/dashboard_tab.dartบรรทัด 58–71
Future<void> _loadDashboardData() async {
  if (!mounted) return;
  setState(() => _isLoading = true);
  final employerId = SessionService.instance.employerId;
  final jobs = await ApiService().getEmployerJobs(employerId);
  final apps = await ApiService().getApplicationsByEmployer(employerId);
  if (!mounted) return;
  setState(() {
    _jobs = jobs;
    _recentApplications = apps;
    _isLoading = false;
  });
  _fadeController.forward(from: 0);
}

ManageJobsTab + ManageJobsCubit — จัดการประกาศงาน

lib/presentation/pages/home/tabs/manage_jobs_tab.dart · blocs/manage_jobs_cubit.dartหน้าจอ

หน้าเดียวในโปรเจกต์ที่จัดการสถานะด้วย Cubit (จากแพ็กเกจ flutter_bloc) แทน setState แนวคิดคือแยก “ตรรกะการโหลดข้อมูล” ออกจากหน้าจอ Cubit ปล่อย (emit) สถานะออกมาทีละแบบ: เริ่มต้น → กำลังโหลด → โหลดสำเร็จ (มีรายการงาน) หรือ ผิดพลาด (มีข้อความ)

lib/presentation/blocs/manage_jobs_cubit.dartบรรทัด 6–38
// กำหนด State
abstract class ManageJobsState {}

class ManageJobsInitial extends ManageJobsState {}

class ManageJobsLoading extends ManageJobsState {}

class ManageJobsLoaded extends ManageJobsState {
  final List<JobPostingModel> jobs;
  ManageJobsLoaded(this.jobs);
}

class ManageJobsError extends ManageJobsState {
  final String message;
  ManageJobsError(this.message);
}

// สร้าง Cubit
class ManageJobsCubit extends Cubit<ManageJobsState> {
  final ApiService _apiService;

  ManageJobsCubit(this._apiService) : super(ManageJobsInitial());

  Future<void> fetchJobs() async {
    emit(ManageJobsLoading());
    try {
      final employerId = SessionService.instance.employerId;
      final jobs = await _apiService.getEmployerJobs(employerId);
      emit(ManageJobsLoaded(jobs));
    } catch (e) {
      emit(ManageJobsError("ไม่สามารถดึงข้อมูลได้: ${e.toString()}"));
    }
  }

ฝั่งหน้าจอใช้ BlocBuilder ฟังสถานะ แล้ววาดหน้าตามชนิดของสถานะด้วย if (state is …) ปุ่มแก้ไขงานจะเปิด /home/post-job พร้อม extra: job และเมื่อกลับมาจะสั่ง fetchJobs() ใหม่

lib/presentation/pages/home/tabs/manage_jobs_tab.dartบรรทัด 160–192
return BlocProvider.value(
  value: _manageJobsCubit,
  child: Scaffold(
    appBar: AppBar(
      title: const Text('จัดการประกาศงาน'),

    ),
    body: BlocBuilder<ManageJobsCubit, ManageJobsState>(
      builder: (context, state) {
        if (state is ManageJobsLoading || state is ManageJobsInitial) {
          return const Center(child: CircularProgressIndicator());
        }

        if (state is ManageJobsError) {
          return Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Icon(Icons.error_outline, size: 48, color: AppTheme.error),
                const SizedBox(height: 12),
                Text(state.message, style: const TextStyle(color: AppTheme.error)),
                const SizedBox(height: 16),
                ElevatedButton.icon(
                  onPressed: () => _manageJobsCubit.fetchJobs(),
                  icon: const Icon(Icons.refresh),
                  label: const Text('ลองใหม่'),
                ),
              ],
            ),
          );
        }

        if (state is ManageJobsLoaded) {

PostJobPage — เพิ่ม/แก้ไขประกาศงาน

lib/presentation/pages/jobs/post_job_page.dartหน้าจอ

หน้าเดียวใช้ได้ 2 โหมด: ถ้าได้รับ editJob มา (_isEditMode) จะเติมค่าเดิมลงฟอร์มและบันทึกด้วย updateJob ไม่อย่างนั้นเป็นการโพสต์ใหม่ด้วย postJob

  • เลือกทักษะ: โหลดคลังคำสำคัญจาก getKeywords() แล้วจัดกลุ่มตามหมวด (Map<หมวด, List<ทักษะ>>) ผู้ใช้เลือกหมวดก่อนแล้วติ๊กทักษะ ถ้าไม่มีในคลังพิมพ์เพิ่มได้ (เรียก addKeyword)
  • สถานที่ทำงาน: พิมพ์เองได้ หรือกดปุ่มใช้ตำแหน่งปัจจุบัน ซึ่งขอสิทธิ์ GPS → อ่านพิกัด → แปลงเป็นชื่อสถานที่ด้วยแพ็กเกจ geocoding ถ้าแปลงไม่ได้จะเขียนเป็น “พิกัด xx, yy” (backend จะแปลงเป็น ต./อ./จ. ให้อีกที)
lib/presentation/pages/jobs/post_job_page.dartบรรทัด 203–242อ่าน GPS แล้วแปลงเป็นชื่อสถานที่ เก็บพิกัดไว้ส่งไปพร้อมประกาศงาน
try {
  Position position = await Geolocator.getCurrentPosition(
    locationSettings: const LocationSettings(
      accuracy: LocationAccuracy.high,
    ),
  );
  double realLat = position.latitude;
  double realLng = position.longitude;
  String locationName = '';

  try {
    List<Placemark> placemarks = await placemarkFromCoordinates(
      realLat,
      realLng,
    );
    if (placemarks.isNotEmpty) {
      Placemark place = placemarks[0];
      locationName =
          place.street ??
          place.subLocality ??
          place.locality ??
          place.subAdministrativeArea ??
          place.administrativeArea ??
          '';
    }
  } catch (e) {
    debugPrint('ไม่สามารถแปลงพิกัดเป็นชื่อเมืองได้: $e');
  }

  if (locationName.isEmpty) {
    locationName =
        'พิกัด ${realLat.toStringAsFixed(6)}, ${realLng.toStringAsFixed(6)}';
  }

  if (!mounted) return;
  setState(() {
    _latitude = realLat.toString();
    _longitude = realLng.toString();
    _jobLocationController.text = locationName;
  });
lib/presentation/pages/jobs/post_job_page.dartบรรทัด 261–297กดบันทึก: เลือกเรียก updateJob หรือ postJob ตามโหมด (ค่าจ้างตัดคอมมาออกก่อนส่ง)
Future<void> _handlePostJob() async {
  if (!_formKey.currentState!.validate()) return;
  setState(() => _isLoading = true);

  final employerId = SessionService.instance.employerId;
  final vacancy = int.tryParse(_vacancyController.text.trim()) ?? 1;

  final Map<String, dynamic> res;
  if (_isEditMode) {
    res = await ApiService().updateJob(
      jobId: widget.editJob!.jobId,
      jobTitle: _jobTitleController.text.trim(),
      jobDescription: _jobDescriptionController.text.trim(),
      qualifications: _qualificationsController.text.trim(),
      jobLocation: _jobLocationController.text.trim(),
      requiredSkills: _requiredSkillsController.text.trim(),
      jobType: _selectedJobType,
      salary: _salaryController.text.trim().replaceAll(',', ''),
      vacancy: vacancy,
      latitude: _latitude,
      longitude: _longitude,
    );
  } else {
    res = await ApiService().postJob(
      employerId: employerId,
      jobTitle: _jobTitleController.text.trim(),
      jobDescription: _jobDescriptionController.text.trim(),
      qualifications: _qualificationsController.text.trim(),
      jobLocation: _jobLocationController.text.trim(),
      requiredSkills: _requiredSkillsController.text.trim(),
      jobType: _selectedJobType,
      salary: _salaryController.text.trim().replaceAll(',', ''),
      vacancy: vacancy,
      latitude: _latitude,
      longitude: _longitude,
    );
  }

สำเร็จแล้วเรียก context.pop(true) ปิดหน้าและบอกหน้าก่อนหน้าว่ามีการเปลี่ยนแปลง

CandidateListPage — รายชื่อผู้สมัครของงาน

lib/presentation/pages/applications/candidate_list_page.dartหน้าจอ

โหลดข้อมูล 2 อย่างพร้อมกันด้วย Future.wait (ผู้สมัครของงานนี้ + ข้อมูลงานเพื่อรู้จำนวนที่รับ) เร็วกว่าเรียกทีละอัน ผู้สมัครเรียงตามคะแนนจากมากไปน้อย (backend เรียงให้) แถบด้านบนบอก “รับแล้ว X/Y คน” ถ้ารับครบแล้วจะกดรับเพิ่มไม่ได้

lib/presentation/pages/applications/candidate_list_page.dartบรรทัด 57–83
Future<void> _fetchAll() async {
  setState(() => _isLoading = true);

  final employerId = SessionService.instance.employerId;
  final results = await Future.wait([
    ApiService().getApplicationsByJob(widget.jobId),
    ApiService().getEmployerJobs(employerId),
  ]);

  if (!mounted) return;

  final candidates = results[0] as List<ApplicationModel>;
  final jobs = results[1] as List<JobPostingModel>;
  JobPostingModel? job;
  for (final j in jobs) {
    if (j.jobId == widget.jobId) {
      job = j;
      break;
    }
  }

  setState(() {
    _candidates = candidates;
    _job = job;
    _isLoading = false;
  });
}

ปุ่ม “รับเข้าทำงาน” จะเปิด showHireDialog ให้เลือกวันเริ่มงานก่อน ถ้ากดยกเลิกในหน้าต่างจะไม่เปลี่ยนสถานะ

lib/presentation/pages/applications/candidate_list_page.dartบรรทัด 85–106
Future<void> _updateStatus(ApplicationModel cand, String newStatus) async {
  if (newStatus == kAcceptedStatus && (_job?.isFull ?? false)) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('รับครบตามจำนวนที่กำหนดแล้ว'), backgroundColor: AppTheme.error),
    );
    return;
  }

  // รับเข้าทำงาน -> ให้เลือกวันเริ่มงานก่อน (กดยกเลิก = ไม่เปลี่ยนสถานะ)
  HireDetails? hire;
  if (newStatus == kAcceptedStatus) {
    hire = await showHireDialog(context, candidateName: cand.fullname);
    if (hire == null || !mounted) return;
  }

  setState(() => _isUpdatingStatus = true);
  final res = await ApiService().updateApplicationStatus(
    cand.applicationId,
    newStatus,
    startDate: hire?.startDate,
    message: hire?.message,
  );

CandidateDetailPage — โปรไฟล์ผู้สมัคร

lib/presentation/pages/applications/candidate_detail_page.dartหน้าจอ

แสดงข้อมูลผู้สมัคร ทักษะ คะแนนความเหมาะสม สถานะ และปุ่มดำเนินการ: นัดสัมภาษณ์ (ไป InterviewPage), รับเข้าทำงาน, ไม่ผ่าน, แชท และเปิด Resume ด้วย PdfViewerPage หน้านี้ดึงใบสมัครทั้งหมดของบริษัทแล้วเลือกใบที่ applicationId ตรงกัน

lib/presentation/pages/applications/candidate_detail_page.dartบรรทัด 53–77
Future<void> _updateStatus(String newStatus) async {
  // รับเข้าทำงาน -> ให้เลือกวันเริ่มงานก่อน (กดยกเลิก = ไม่เปลี่ยนสถานะ)
  HireDetails? hire;
  if (newStatus == AppConstants.statusPassed) {
    hire = await showHireDialog(context, candidateName: _application?.fullname);
    if (hire == null || !mounted) return;
  }
  final res = await ApiService().updateApplicationStatus(
    widget.applicationId,
    newStatus,
    startDate: hire?.startDate,
    message: hire?.message,
  );
  if (!mounted) return;
  if (res['success'] == true) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('อัปเดตสถานะเป็น "$newStatus" สำเร็จ'), backgroundColor: AppTheme.success),
    );
    _loadCandidateDetail();
  } else {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(res['message'] ?? 'อัปเดตสถานะไม่สำเร็จ'), backgroundColor: AppTheme.error),
    );
  }
}

InterviewPage — นัดสัมภาษณ์

lib/presentation/pages/applications/interview_page.dartหน้าจอ

เลือกวัน เวลา รูปแบบ (ออนไซต์/ออนไลน์) สถานที่หรือลิงก์ และหมายเหตุ จุดที่น่าสนใจคือแยกค่าที่แสดงกับค่าที่ส่ง: ช่องกรอกแสดง “15 ต.ค. 2569” และ “10:00 น.” ให้อ่านง่าย แต่เก็บค่ารูปแบบมาตรฐาน 2026-10-15 และ 10:00:00 ไว้ในตัวแปรแยกเพื่อส่งให้ API

lib/presentation/pages/applications/interview_page.dartบรรทัด 47–74
Future<void> _selectDate() async {
  final picked = await showDatePicker(
    context: context,
    initialDate: DateTime.now().add(const Duration(days: 1)),
    firstDate: DateTime.now(),
    lastDate: DateTime.now().add(const Duration(days: 90)),
  );
  if (picked != null) {
    setState(() {
      _dateValue = "${picked.year}-${picked.month.toString().padLeft(2, '0')}-${picked.day.toString().padLeft(2, '0')}";
      _dateController.text = formatThaiDate(_dateValue);
    });
  }
}

Future<void> _selectTime() async {
  final picked = await showTimePicker(
    context: context,
    initialTime: const TimeOfDay(hour: 10, minute: 0),
  );
  if (picked != null) {
    final hhmm = "${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}";
    setState(() {
      _timeValue = '$hhmm:00';
      _timeController.text = '$hhmm น.';
    });
  }
}

ChatTab, NotificationsTab, ProfileTab, EditProfilePage

lib/presentation/pages/home/tabs/… · profile/edit_profile_page.dartหน้าจอ
  • ChatTab แสดงรายการห้องแชท (getChatList) พร้อมรูป ข้อความล่าสุด และจำนวนที่ยังไม่อ่าน แตะแล้วเปิดห้องแชท กลับมาจะโหลดรายการใหม่
  • NotificationsTab แตะแจ้งเตือนจะทำเครื่องหมายว่าอ่านแล้ว และถ้า type เป็น application_<job_id> จะแยกเลขงานออกมาแล้วเปิดหน้ารายชื่อผู้สมัครของงานนั้น ปัดซ้าย/ขวาเพื่อลบได้
  • ProfileTab หน้าโปรไฟล์บริษัท เปลี่ยนโลโก้และภาพปกได้ (ภาพปกส่ง kind: 'cover') ปุ่มออกจากระบบจะล้าง session แล้วไป /login
  • EditProfilePage โหลดข้อมูลบริษัทด้วย getMember ใส่ฟอร์ม แล้วบันทึกด้วย updateEmployerProfile
lib/presentation/pages/home/tabs/notifications_tab.dartบรรทัด 87–101แจ้งเตือนเรื่องใบสมัครมี type = application_<job_id> จึงแยกเลขงานด้วย split('_')
onTap: () async {
  if (!noti.isRead) {
    await ApiService().markNotificationAsRead(noti.notiId);
    _loadNotifications();
  }

  if (noti.type.startsWith('application_')) {
    final parts = noti.type.split('_');
    if (parts.length > 1) {
      final jobId = parts[1];
      if (context.mounted) {
        context.push('/home/candidates/$jobId');
      }
      return;
    }
lib/presentation/pages/home/tabs/profile_tab.dartบรรทัด 61–92อัปโหลดโลโก้/ภาพปก: ย่อขนาดรูปก่อนส่ง (ภาพปกกว้างกว่าโลโก้) แล้วโหลดโปรไฟล์ใหม่
Future<void> _pickAndUploadImage({bool cover = false}) async {
  final picker = ImagePicker();
  final pickedFile = await picker.pickImage(
    source: ImageSource.gallery,
    // ภาพปกเป็นแนวนอนเต็มความกว้าง จึงให้ความละเอียดมากกว่าโลโก้
    maxWidth: cover ? 1600 : 800,
    maxHeight: cover ? 900 : 800,
    imageQuality: 85,
  );

  if (pickedFile == null) return;

  setState(() => _isUploading = true);
  final userId = SessionService.instance.userId;

  final res = await ApiService().uploadProfileImage(
    userId: userId,
    role: 'employer',
    bytes: await pickedFile.readAsBytes(),
    fileName: pickedFile.name,
    kind: cover ? 'cover' : null,
  );

  setState(() => _isUploading = false);

  if (!mounted) return;

  if (res['success'] == true && res['image_url'] != null) {
    final imageUrl = res['image_url'] as String;
    if (!cover) await SessionService.instance.updateProfileImage(imageUrl);
    await _loadProfile();

หน้าฝั่งผู้หางาน

SeekerHomeTab — งานแนะนำ

lib/presentation/pages/seeker/tabs/seeker_home_tab.dartหน้าจอ

ดึงงานทั้งหมดพร้อมคะแนนจาก get_jobs.php แล้วแบ่งเป็น 2 กลุ่ม: งานแนะนำ (isRecommended = คะแนนถึงระดับ “เหมาะสมดี” ตามเกณฑ์ที่แอดมินตั้ง) และงานอื่นที่น่าสนใจ (เรียงตามคะแนน คะแนนเท่ากันเอางานใหม่ขึ้นก่อน) ทุก 10 วินาทีจะรีเฟรชแบบเงียบ (silent: true = ไม่โชว์วงหมุน และถ้าโหลดพลาดก็คงรายการเดิมไว้) งานที่บริษัทเพิ่งโพสต์จึงขึ้นมาเอง

lib/presentation/pages/seeker/tabs/seeker_home_tab.dartบรรทัด 66–103
Future<void> _fetchJobs({bool silent = false}) async {
  // silent = รีเฟรชเบื้องหลัง ไม่แสดงวงหมุน และถ้าโหลดไม่สำเร็จก็คงรายการเดิมไว้
  if (!silent) setState(() => _isLoading = true);
  final userId = SessionService.instance.userId;
  if (userId <= 0) {
    if (mounted) setState(() => _isLoading = false);
    return;
  }

  final res = await ApiService().getJobsForSeeker(userId);
  if (!mounted) return;

  if (res['success'] == true && res['data'] is List) {
    final list = (res['data'] as List)
        .map((j) => JobPostingModel.fromJson(j))
        .toList();

    // งานแนะนำ: คะแนนถึงระดับ "เหมาะสมดี" ขึ้นไป ตามเกณฑ์ที่ Admin ตั้ง (ตารางที่ 2.1)
    final matched = list.where((j) => j.isRecommended).toList();

    // งานที่น่าสนใจ: เอางานที่เหลือที่ไม่ได้อยู่ใน matched มาแสดงเป็นงานมาใหม่
    final interesting = list.where((j) => !j.isRecommended).toList();
    // เรียงตามคะแนนความเหมาะสมก่อน (ขอบเขต 1.3.4.4) คะแนนเท่ากันเอางานใหม่ขึ้นก่อน
    interesting.sort((a, b) {
      final byScore = b.matchScore.compareTo(a.matchScore);
      return byScore != 0 ? byScore : b.jobId.compareTo(a.jobId);
    });

    setState(() {
      _allJobs = list;
      _filteredJobs = matched;
      _interestingJobs = interesting;
      _isLoading = false;
    });
  } else if (!silent) {
    setState(() => _isLoading = false);
  }
}
lib/presentation/pages/seeker/tabs/seeker_search_tab.dartหน้าจอ

ตอนเปิดหน้าจะลองอ่านตำแหน่ง GPS (รอได้ไม่เกิน 6 วินาที) แล้วส่ง lat/lng ไปให้ backend คำนวณระยะทาง ถ้าไม่ได้ตำแหน่ง backend จะใช้พิกัดจากที่อยู่ในโปรไฟล์แทน

lib/presentation/pages/seeker/tabs/seeker_search_tab.dartบรรทัด 73–118
/// ขอตำแหน่ง GPS ปัจจุบัน (ถ้าผู้ใช้ไม่อนุญาต จะใช้พิกัดในโปรไฟล์แทนที่ backend)
Future<Position?> _currentPosition() async {
  try {
    if (!canUseGps) return null; // เว็บแบบ http: ไม่รอ GPS ให้โหลดรายการงานทันที
    if (!await Geolocator.isLocationServiceEnabled()) return null;
    var permission = await Geolocator.checkPermission();
    if (permission == LocationPermission.denied) {
      permission = await Geolocator.requestPermission();
    }
    if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) {
      return null;
    }
    return await Geolocator.getCurrentPosition(
      locationSettings: const LocationSettings(accuracy: LocationAccuracy.medium),
    ).timeout(const Duration(seconds: 8));
  } catch (_) {
    return null;
  }
}

Future<void> _fetchJobs() async {
  setState(() => _isLoading = true);
  // จำกัดเวลารอ GPS/หน้าต่างขออนุญาต ไม่ให้รายการงานค้าง ถ้าเกินเวลาใช้พิกัดในโปรไฟล์แทน
  final position = await _currentPosition()
      .timeout(const Duration(seconds: 6), onTimeout: () => null);
  final res = await ApiService().getJobsForSeeker(
    SessionService.instance.userId,
    lat: position?.latitude,
    lng: position?.longitude,
  );
  if (!mounted) return;

  if (res['success'] == true && res['data'] is List) {
    final origin = res['origin'];
    setState(() {
      _allJobs = (res['data'] as List).map((j) => JobPostingModel.fromJson(j)).toList();
      _origin = origin is Map
          ? LatLng((origin['lat'] as num).toDouble(), (origin['lng'] as num).toDouble())
          : null;
      _isLoading = false;
    });
    _applyFilters();
  } else {
    setState(() => _isLoading = false);
  }
}

การค้นหาและกรองทำในเครื่องทั้งหมด (ไม่เรียก API ซ้ำ) จึงตอบสนองทันที: ค้นคำในชื่องาน/บริษัท/สถานที่/ประเภท/ทักษะ, กรองประเภทงาน, ระยะทางสูงสุด, ค่าจ้างขั้นต่ำ แล้วเรียงตาม คะแนน / ระยะใกล้ / ค่าจ้างสูง / ล่าสุด สังเกตการใช้ switch แบบ expression ของ Dart 3 ในการเรียง และงานที่ไม่มีข้อมูล (ไม่มีพิกัด/ค่าจ้าง) ถูกไว้ท้ายสุดเสมอ

lib/presentation/pages/seeker/tabs/seeker_search_tab.dartบรรทัด 120–154
void _applyFilters() {
  final q = _searchController.text.trim().toLowerCase();
  final result = _allJobs.where((j) {
    if (q.isNotEmpty) {
      final haystack = [
        j.jobTitle,
        j.companyName ?? '',
        j.jobLocation,
        j.jobType,
        j.requiredSkills.join(' '),
      ].join(' ').toLowerCase();
      if (!haystack.contains(q)) return false;
    }
    if (_jobTypes.isNotEmpty && !_jobTypes.contains(j.jobType)) return false;
    if (_maxDistanceKm != null && (j.distanceKm == null || j.distanceKm! > _maxDistanceKm!)) return false;
    if (_minSalary != null && (j.salaryValue == null || j.salaryValue! < _minSalary!)) return false;
    return true;
  }).toList();

  int compareNullable(double? a, double? b, {bool descending = false}) {
    if (a == null && b == null) return 0;
    if (a == null) return 1; // ไม่มีข้อมูลไว้ท้ายสุด
    if (b == null) return -1;
    return descending ? b.compareTo(a) : a.compareTo(b);
  }

  result.sort((a, b) => switch (_sortBy) {
        _SortBy.match => b.matchScore.compareTo(a.matchScore),
        _SortBy.distance => compareNullable(a.distanceKm, b.distanceKm),
        _SortBy.salary => compareNullable(a.salaryValue, b.salaryValue, descending: true),
        _SortBy.newest => b.jobId.compareTo(a.jobId),
      });

  setState(() => _filteredJobs = result);
}

SeekerJobMapPage — แผนที่งาน

lib/presentation/pages/seeker/seeker_job_map_page.dartหน้าจอ

รับรายการงาน (ที่ผ่านตัวกรองแล้ว) และตำแหน่งผู้ใช้จากหน้าค้นหา แสดงบนแผนที่ OpenStreetMap ด้วยแพ็กเกจ flutter_map ซึ่งซ้อนเป็นชั้น (layer): TileLayer = ภาพแผนที่, MarkerLayer = หมุดงานและจุดตำแหน่งผู้ใช้, RichAttributionWidget = เครดิต OSM ที่ต้องแสดงตามเงื่อนไขการใช้งาน

lib/presentation/pages/seeker/seeker_job_map_page.dartบรรทัด 32–43งานหลายตำแหน่งที่อยู่จุดเดียวกันจะซ้อนทับกัน จึงปัดพิกัดเหลือทศนิยม 4 ตำแหน่ง (~11 เมตร) แล้วรวมเป็นกลุ่ม
/// งานหลายตำแหน่งที่พิกัดเดียวกันจะซ้อนกัน จึงรวมเป็นกลุ่มต่อพิกัด
Map<LatLng, List<JobPostingModel>> get _groupedJobs {
  final groups = <LatLng, List<JobPostingModel>>{};
  for (final job in _jobsWithLocation) {
    final point = LatLng(
      double.parse(job.latitude!.toStringAsFixed(4)),
      double.parse(job.longitude!.toStringAsFixed(4)),
    );
    groups.putIfAbsent(point, () => []).add(job);
  }
  return groups;
}
lib/presentation/pages/seeker/seeker_job_map_page.dartบรรทัด 111–156
FlutterMap(
  mapController: _mapController,
  options: MapOptions(
    initialCenter: _initialCenter,
    initialZoom: widget.origin != null ? 11 : 6,
    onTap: (_, __) => setState(() => _selected = null),
  ),
  children: [
    TileLayer(
      urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
      userAgentPackageName: 'com.jobmatch.jobmatch_employer',
    ),
    MarkerLayer(
      markers: [
        if (widget.origin != null)
          Marker(
            point: widget.origin!,
            width: 26,
            height: 26,
            child: Container(
              decoration: BoxDecoration(
                color: AppTheme.primaryLight,
                shape: BoxShape.circle,
                border: Border.all(color: Colors.white, width: 3),
                boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 4)],
              ),
            ),
          ),
        for (final entry in groups.entries)
          Marker(
            point: entry.key,
            width: 44,
            height: 44,
            alignment: Alignment.topCenter,
            child: GestureDetector(
              onTap: () => _showJobsAt(entry.value),
              child: _JobPin(jobs: entry.value),
            ),
          ),
      ],
    ),
    const RichAttributionWidget(
      attributions: [TextSourceAttribution('OpenStreetMap contributors')],
    ),
  ],
),

แตะหมุดที่มีงานเดียวจะแสดงการ์ดงานด้านล่าง ถ้ามีหลายงานจะเปิดรายการให้เลือก (_showJobsAt)

SeekerJobDetailPage — รายละเอียดงานและปุ่มสมัคร

lib/presentation/pages/seeker/seeker_job_detail_page.dartหน้าจอ

แสดงสรุปงาน (ค่าจ้าง สถานที่ ประเภท จำนวนรับ) คะแนนการจับคู่ ทักษะที่ตรง/ที่ยังขาด รายละเอียด และคุณสมบัติ ปุ่มด้านล่างเปลี่ยนตามสถานะ: ยังไม่สมัคร = “สมัครงาน”, สมัครแล้ว = “ยกเลิกการสมัคร” นอกจากนี้แสดงเบอร์ติดต่อของบริษัท และกดเปิดแผนที่ Google Maps, แชท หรือเปิดหน้าบริษัทได้

lib/presentation/pages/seeker/seeker_job_detail_page.dartบรรทัด 136–193
Future<void> _checkApplicationStatus() async {
  final seekerId = SessionService.instance.seekerId;
  if (seekerId <= 0) return;

  final apps = await ApiService().getApplicationsBySeeker(seekerId);
  final found = apps
      .where(
        (a) => a.jobId == widget.jobId && a.status != _cancelledStatus,
      )
      .toList();

  if (!mounted) return;
  setState(() {
    _myApplication = found.isNotEmpty ? found.first : null;
  });
}

Future<void> _handleApply() async {
  final seekerId = SessionService.instance.seekerId;
  if (seekerId <= 0) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(
        content: Text('กรุณาล็อกอินด้วยบัญชีผู้หางานก่อนสมัครงาน'),
        backgroundColor: AppTheme.error,
      ),
    );
    return;
  }

  setState(() => _isApplying = true);
  final res = await ApiService().applyJob(
    jobId: widget.jobId,
    seekerId: seekerId,
  );

  if (!mounted) return;

  if (res['success'] == true) {
    // ดึงสถานะใบสมัครล่าสุดมาแสดง เพื่อสลับปุ่มเป็น "ยกเลิกการสมัคร"
    await _checkApplicationStatus();
    if (!mounted) return;
    setState(() => _isApplying = false);
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(
        content: Text('สมัครงานสำเร็จแล้ว! นายจ้างจะพิจารณาในลำดับถัดไป'),
        backgroundColor: AppTheme.success,
      ),
    );
  } else {
    setState(() => _isApplying = false);
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(res['message'] ?? 'เกิดข้อผิดพลาดในการสมัครงาน'),
        backgroundColor: AppTheme.error,
      ),
    );
  }
}
ปุ่มแชทต้องใช้ user_id ของบริษัท แต่ประกาศงานรู้แค่ employer_id หน้านี้จึงเรียก getEmployerUserId() แปลงก่อน (_fetchEmployerInfo บรรทัด 71–89)

SeekerApplicationsTab — งานของฉัน (สถานะใบสมัคร)

lib/presentation/pages/seeker/tabs/seeker_applications_tab.dartหน้าจอ

แสดงทุกใบสมัครพร้อมสถานะ นัดสัมภาษณ์ และวันเริ่มงาน ถามเซิร์ฟเวอร์ทุก 20 วินาที และหยุดถามเมื่อแอปถูกพับไปเบื้องหลัง (ฟังวงจรชีวิตแอปผ่าน didChangeAppLifecycleState) เพื่อประหยัดแบตเตอรี่และเน็ต ทุกครั้งที่โหลด จะเทียบสถานะใหม่กับสถานะที่จำไว้ ถ้าเปลี่ยนจะแจ้ง “สถานะอัปเดต: …” ทันที

lib/presentation/pages/seeker/tabs/seeker_applications_tab.dartบรรทัด 45–95
// หยุด/เริ่มโพลใหม่ตามสถานะแอป จะได้ไม่ยิง request ทิ้งขว้างตอนแอปอยู่เบื้องหลัง
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
  if (state == AppLifecycleState.resumed) {
    _startPolling();
    _fetchApplications(silent: true);
  } else {
    _pollTimer?.cancel();
  }
}

void _startPolling() {
  _pollTimer?.cancel();
  _pollTimer = Timer.periodic(_pollInterval, (_) => _fetchApplications(silent: true));
}

// silent = true: รีเฟรชเบื้องหลังแบบเงียบๆ (ใช้กับ polling) ไม่โชว์ spinner เต็มจอ
Future<void> _fetchApplications({bool silent = false}) async {
  if (!silent) setState(() => _isLoading = true);

  final seekerId = SessionService.instance.seekerId;
  final list = await ApiService().getApplicationsBySeeker(seekerId);
  if (!mounted) return;

  // เทียบสถานะเก่ากับใหม่ เพื่อแจ้งเตือนผู้ใช้เมื่อมีอะไรเปลี่ยน (สไตล์ real-time)
  final changedTitles = <String>[];
  for (final app in list) {
    final prevStatus = _lastKnownStatuses[app.applicationId];
    if (prevStatus != null && prevStatus != app.status) {
      changedTitles.add(app.jobTitle ?? 'ตำแหน่งงาน');
    }
  }
  _lastKnownStatuses = {for (final a in list) a.applicationId: a.status};

  setState(() {
    _applications = list;
    _isLoading = false;
  });

  if (changedTitles.isNotEmpty) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text('สถานะอัปเดต: ${changedTitles.join(", ")}'),
        backgroundColor: AppTheme.primary,
        behavior: SnackBarBehavior.floating,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
        margin: const EdgeInsets.all(16),
      ),
    );
  }
}

เมื่อได้รับการคัดเลือกแล้ว ผู้หางานกด “ตอบรับ” หรือ “ปฏิเสธ” งานได้ ซึ่งส่งสถานะ ตอบรับงานแล้ว / ปฏิเสธงาน ไปที่ update_application.php ตัวเดียวกับที่ผู้ว่าจ้างใช้ (backend แยกสิทธิ์ให้เอง)

lib/presentation/pages/seeker/tabs/seeker_applications_tab.dartบรรทัด 151–184
Future<void> _respondToOffer(ApplicationModel app, bool accept) async {
  final confirm = await showDialog<bool>(
    context: context,
    builder: (context) => AlertDialog(
      title: Text(accept ? 'ยืนยันตอบรับงาน' : 'ยืนยันปฏิเสธงาน'),
      content: Text(
        accept
            ? 'คุณต้องการตอบรับตำแหน่ง "${app.jobTitle ?? ''}" ใช่หรือไม่?'
            : 'คุณต้องการปฏิเสธตำแหน่ง "${app.jobTitle ?? ''}" ใช่หรือไม่? การตัดสินใจนี้ไม่สามารถย้อนกลับได้',
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context, false),
          child: const Text('ยกเลิก', style: TextStyle(color: Colors.grey)),
        ),
        ElevatedButton(
          onPressed: () => Navigator.pop(context, true),
          style: ElevatedButton.styleFrom(
            backgroundColor: accept ? AppTheme.success : Colors.red,
            foregroundColor: Colors.white,
          ),
          child: Text(accept ? 'ตอบรับ' : 'ปฏิเสธ'),
        ),
      ],
    ),
  );

  if (confirm != true) return;
  if (!mounted) return;

  setState(() => _isLoading = true);
  final res = await ApiService().updateApplicationStatus(
    app.applicationId,
    accept ? _acceptedStatus : _rejectedStatus,

SeekerNotificationsTab — แจ้งเตือนของผู้หางาน

lib/presentation/pages/seeker/tabs/seeker_notifications_tab.dartหน้าจอ

ใช้ type ของแจ้งเตือนตัดสินว่าแตะแล้วไปไหน: recommend_job_<id> → เปิดรายละเอียดงานนั้น, application_<id> → กลับไปแท็บ “งานของฉัน” การทำเครื่องหมายว่าอ่านแล้วเป็นแบบมองโลกในแง่ดี (optimistic update): เปลี่ยนบนจอทันทีโดยไม่รอเซิร์ฟเวอร์ ถ้าบันทึกพลาดค่อยคืนค่าเดิม ผู้ใช้จึงรู้สึกว่าแอปเร็ว

lib/presentation/pages/seeker/tabs/seeker_notifications_tab.dartบรรทัด 43–76
// แจ้งเตือนงานแนะนำ (recommend_job_<job_id>) -> เปิดหน้ารายละเอียดงานนั้น
int? _recommendedJobId(String type) =>
    type.startsWith('recommend_job_') ? int.tryParse(type.substring('recommend_job_'.length)) : null;

// แจ้งเตือนเรื่องใบสมัคร (application_<job_id>: นัดสัมภาษณ์ / ผลการพิจารณา) -> ไปแท็บ "งานของฉัน"
bool _isApplicationUpdate(String type) => type.startsWith('application_');

bool _hasDestination(String type) => _recommendedJobId(type) != null || _isApplicationUpdate(type);

IconData _getIconForType(NotificationModel n) => notificationVisual(n).icon;

Color _getColorForType(NotificationModel n) => notificationVisual(n).color;

// แตะแจ้งเตือน -> ทำเครื่องหมายว่าอ่านแล้ว (แจ้งเตือนยังอยู่ในรายการแต่จางลง) แล้วพาไปหน้าที่เกี่ยวข้อง
Future<void> _handleTap(int index) async {
  final item = _notifications[index];

  if (!item.isRead) {
    setState(() => _notifications[index] = _asRead(item));
    ApiService().markNotificationAsRead(item.notiId).then((res) {
      if (res['success'] != true && mounted) {
        // บันทึกไม่สำเร็จ -> คืนสถานะยังไม่อ่าน
        setState(() => _notifications[index] = item);
      }
    });
  }

  final jobId = _recommendedJobId(item.type);
  if (jobId != null) {
    context.push('/seeker-home/job/$jobId');
  } else if (_isApplicationUpdate(item.type)) {
    context.pop(kOpenApplicationsTab);
  }
}

SeekerProfileTab — โปรไฟล์ผู้หางาน

lib/presentation/pages/seeker/tabs/seeker_profile_tab.dartหน้าจอ

มี 2 โหมดสลับด้วยปุ่มดินสอ (_isEditing): โหมดดูแสดงข้อมูลและทักษะเป็นชิป โหมดแก้ไขเปิดช่องกรอกทั้งหมด เปลี่ยนรูป อัปโหลด Resume และเลือกทักษะได้

lib/presentation/pages/seeker/tabs/seeker_profile_tab.dartบรรทัด 289–321เลือก PDF: ตรวจนามสกุลและขนาด (ไม่เกิน 5 MB) ฝั่งแอปก่อน ลดการส่งไฟล์ที่ใช้ไม่ได้ไปเซิร์ฟเวอร์
Future<void> _pickAndUploadDocument(String type) async {
  if (!_isEditing) return;
  final label = type == 'portfolio' ? 'Portfolio' : 'Resume';
  try {
    final result = await FilePicker.platform.pickFiles(
      type: FileType.custom,
      allowedExtensions: ['pdf'],
      withData: true,
    );
    if (result == null || !mounted) return;
    final file = result.files.single;

    String? error;
    if (!file.name.toLowerCase().endsWith('.pdf')) {
      error = 'กรุณาเลือกไฟล์ PDF เท่านั้น';
    } else if (file.size > _maxDocumentBytes) {
      error = 'ไฟล์มีขนาดเกิน 5 MB';
    }
    if (error != null) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(error), backgroundColor: AppTheme.error),
      );
      return;
    }

    setState(() => _isSaving = true);
    final res = await ApiService().uploadResume(
      userId: SessionService.instance.userId,
      filePath: file.path,
      bytes: file.bytes,
      fileName: file.name,
      type: type,
    );

เมื่ออัปโหลด Resume สำเร็จ backend จะอ่านข้อความใน PDF หาทักษะที่ตรงกับคลังคำ แล้วส่งรายการคำที่เจอกลับมา แอปจึงแจ้ง “พบทักษะในเอกสาร: …” (ดูpdf_text.php)

lib/presentation/pages/seeker/tabs/seeker_profile_tab.dartบรรทัด 420–427บัญชีเก่าที่ไม่ได้บันทึกหมวดความสนใจ: เดาหมวดจากทักษะที่มี
String? get _effectiveCategory {
  if (_jobCategories.containsKey(_selectedCategory)) return _selectedCategory;
  final skills = _skillsController.text.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toSet();
  for (final entry in _jobCategories.entries) {
    if (entry.value.any(skills.contains)) return entry.key;
  }
  return null;
}
lib/presentation/pages/seeker/tabs/seeker_profile_tab.dartบรรทัด 547–571บันทึก: หมวดความสนใจถูกเก็บรวมไว้ต้นข้อความ profile_detail และใช้ที่อยู่ใหม่เฉพาะเมื่อกรอกครบ
String finalProfileDetail = _experienceController.text.trim();
if (_selectedCategory != null) {
  finalProfileDetail = "ความสนใจ: $_selectedCategory\n\n$finalProfileDetail";
}

// ยังไม่เคยมีที่อยู่ ช่องกรอกจะเปิดอยู่แล้วโดยไม่ต้องกด "เปลี่ยนที่อยู่"
final changingAddress = _isChangingAddress || _addressController.text.trim().isEmpty;
final address = changingAddress && _addressInput.isComplete
    ? _addressInput.fullAddress
    : _addressController.text.trim();

final res = await ApiService().updateSeekerProfile(
  userId: userId,
  name: _nameController.text.trim(),
  phone: _phoneController.text.trim(),
  skills: _skillsController.text.trim(),
  profileDetail: finalProfileDetail.trim(),
  address: address,
  age: int.tryParse(_ageController.text.trim()) ?? 0,
  idCard: _idCardController.text.trim(),
  dateOfBirth: _formatDateForApi(_dobController.text.trim()),
  education: _selectedEducation ?? '',
  gender: _selectedGender ?? 'ไม่ระบุ',
  experience: _experienceController.text.trim(),
);

SeekerChatTab และ SeekerCompanyDetailPage

lib/presentation/pages/seeker/…หน้าจอ
  • SeekerChatTab รายการห้องแชทแบบเดียวกับฝั่งผู้ว่าจ้าง แต่รีเฟรชเองทุก 3 วินาที
  • SeekerCompanyDetailPage หน้าบริษัท: ภาพปก โลโก้ รายละเอียด ที่อยู่ (กดเปิด Google Maps) โทร/อีเมล (เปิดแอปโทรศัพท์/อีเมลด้วย url_launcher) แชท และรายการงานที่เปิดรับของบริษัทนั้น (get_employer_jobs.php คืนเฉพาะงานที่เปิดรับเมื่อผู้เรียกไม่ใช่เจ้าของบริษัท)

หน้าที่ใช้ร่วมกันทั้งสองบทบาท

ChatRoomPage — ห้องแชท

lib/presentation/pages/chat/chat_room_page.dartหน้าจอ

รับ receiverId (user_id ของอีกฝ่าย) และชื่อผ่าน URL ดึงข้อความทุก 3 วินาที (การเปิดห้องแชทถือว่าอ่านข้อความแล้ว backend จะตั้ง is_read ให้) ส่งข้อความแล้วโหลดใหม่และเลื่อนลงล่างสุด ข้อความที่มีลิงก์ (เช่น ลิงก์ Zoom ที่ระบบส่งตอนนัดสัมภาษณ์) แสดงผ่าน LinkifyText ซึ่งแตะเปิดลิงก์ได้

lib/presentation/pages/chat/chat_room_page.dartบรรทัด 32–86
@override
void initState() {
  super.initState();
  _fetchMessages();
  // Polling ข้อความใหม่ทุก 3 วินาที
  _timer = Timer.periodic(const Duration(seconds: 3), (_) => _fetchMessages(silent: true));
}

@override
void dispose() {
  _timer?.cancel();
  _messageController.dispose();
  _scrollController.dispose();
  super.dispose();
}

Future<void> _fetchMessages({bool silent = false}) async {
  if (!silent) setState(() => _isLoading = true);
  final senderId = SessionService.instance.userId;

  if (senderId <= 0) {
    if (!silent && mounted) {
      setState(() => _isLoading = false);
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('ไม่พบข้อมูลผู้ใช้งาน กรุณาล็อกอินใหม่'),
          backgroundColor: AppTheme.error,
        ),
      );
    }
    return;
  }

  final msgs = await ApiService().getMessages(senderId, widget.receiverId);
  if (!mounted) return;
  setState(() {
    _messages = msgs;
    if (!silent) _isLoading = false;
  });
  if (_messages.isNotEmpty && !silent) {
    _scrollToBottom();
  }
}

void _scrollToBottom() {
  WidgetsBinding.instance.addPostFrameCallback((_) {
    if (_scrollController.hasClients) {
      _scrollController.animateTo(
        _scrollController.position.maxScrollExtent,
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeOut,
      );
    }
  });
}
_scrollToBottom() ใช้ addPostFrameCallback เพื่อรอให้ Flutter วาดข้อความใหม่เสร็จก่อน ค่อยเลื่อนไปท้ายสุด ถ้าเลื่อนทันทีจะยังไม่รู้ความสูงจริงของรายการ

PdfViewerPage และ NotFoundPage

lib/presentation/pages/pdf_viewer_page.dart · not_found_page.dartหน้าจอ

PdfViewerPage เปิด PDF จาก URL ด้วย SfPdfViewer.network() ใช้ทั้งตอนผู้หางานดู Resume ตัวเอง และตอนผู้ว่าจ้างดู Resume ผู้สมัคร (เปิดด้วย Navigator.push แบบเดิมของ Flutter ไม่ผ่าน go_router) ส่วน NotFoundPage คือหน้า “ไม่พบหน้านี้” ที่ router แสดงเมื่อ URL ผิด

Widget ที่ใช้ซ้ำ (widgets/)

match_badge.dart — ป้ายคะแนนการจับคู่

lib/presentation/widgets/match_badge.dartหน้าจอ

ฟังก์ชัน matchLevelColor() แปลงระดับ (very_high / good / medium / low) เป็นสี และ MatchBadge แสดงป้าย เช่น “85% · เหมาะสมสูงมาก” ใช้ทั้งในการ์ดงาน แผนที่ และหน้ารายละเอียด

lib/presentation/widgets/match_badge.dartบรรทัด 5–16
Color matchLevelColor(String level) {
  switch (level) {
    case 'very_high':
      return const Color(0xFF047857);
    case 'good':
      return AppTheme.primary;
    case 'medium':
      return const Color(0xFFB45309);
    default:
      return AppTheme.textSecondary;
  }
}

hire_dialog.dart — หน้าต่างรับเข้าทำงาน

lib/presentation/widgets/hire_dialog.dartหน้าจอ

ฟังก์ชัน showHireDialog() เปิดหน้าต่างให้เลือกวันเริ่มงาน (บังคับ) และข้อความถึงผู้สมัคร (ไม่บังคับ) แล้วคืนค่าเป็น HireDetails หรือ null ถ้ากดยกเลิก ใช้ StatefulBuilder เพื่อให้หน้าต่างอัปเดตตัวเอง (เช่น โชว์ error) ได้ทั้งที่ไม่ใช่ StatefulWidget

lib/presentation/widgets/hire_dialog.dartบรรทัด 79–88
onPressed: () {
  if (startDate == null) {
    setDialogState(() => error = 'กรุณาเลือกวันเริ่มงาน');
    return;
  }
  Navigator.pop(
    dialogContext,
    HireDetails(startDate!.toIso8601String().substring(0, 10), messageController.text.trim()),
  );
},

notification_swipe_delete.dart — ไอคอนแจ้งเตือนและการปัดลบ

lib/presentation/widgets/notification_swipe_delete.dartหน้าจอ

มี 3 ฟังก์ชันที่ทั้งสองบทบาทใช้ร่วมกัน: notificationVisual() เลือกไอคอน/สีจาก type และหัวข้อ (คืนค่าเป็น record ของ Dart 3 คือ ({IconData icon, Color color})), swipeToDeleteNotification() ห่อการ์ดด้วย Dismissible ให้ปัดลบได้ และ deleteNotificationWithUndo() ที่ลบจากจอทันทีแต่รอ 4 วินาทีก่อนลบจริงที่เซิร์ฟเวอร์ เผื่อผู้ใช้กด “เลิกทำ”

lib/presentation/widgets/notification_swipe_delete.dartบรรทัด 58–86
void deleteNotificationWithUndo(
  BuildContext context, {
  required NotificationModel item,
  required VoidCallback onRemove,
  required VoidCallback onRestore,
}) {
  onRemove();
  final messenger = ScaffoldMessenger.of(context);
  messenger.hideCurrentSnackBar();
  messenger
      .showSnackBar(
        SnackBar(
          content: const Text('ลบการแจ้งเตือนแล้ว'),
          duration: const Duration(seconds: 4),
          action: SnackBarAction(label: 'เลิกทำ', textColor: AppTheme.primaryLight, onPressed: onRestore),
        ),
      )
      .closed
      .then((reason) async {
    if (reason == SnackBarClosedReason.action) return;
    final res = await ApiService().deleteNotification(item.notiId);
    if (res['success'] != true) {
      onRestore();
      messenger.showSnackBar(
        const SnackBar(content: Text('ลบการแจ้งเตือนไม่สำเร็จ ลองใหม่อีกครั้ง'), backgroundColor: AppTheme.error),
      );
    }
  });
}

thai_address_input.dart — ช่องกรอกที่อยู่ไทย

lib/presentation/widgets/thai_address_input.dartหน้าจอ

ช่องบ้านเลขที่/ถนน + ตัวเลือกจังหวัด อำเภอ ตำบล (แพ็กเกจ thai_provinces_flutter) มีคลาส Controller แยกให้หน้าที่ใช้งานอ่านค่า isComplete และ fullAddress ในหน้าแก้ไขโปรไฟล์จะแสดงที่อยู่เดิมก่อน และเปิดช่องกรอกเมื่อกด “เปลี่ยนที่อยู่”

lib/presentation/widgets/thai_address_input.dartบรรทัด 6–23
class ThaiAddressInputController {
  final TextEditingController detail = TextEditingController();
  final ThaiAddressController thai = ThaiAddressController();

  bool get isComplete =>
      detail.text.trim().isNotEmpty &&
      thai.province != null &&
      thai.district != null &&
      thai.subdistrict != null;

  /// ที่อยู่รวมเป็นข้อความเดียว เช่น "99/1 ต.ในเมือง อ.เมืองขอนแก่น จ.ขอนแก่น 40000"
  /// (backend ใช้รูปแบบ ต./อ./จ. นี้หาพิกัดของตำบล)
  String get fullAddress {
    final sub = thai.subdistrict;
    return '${detail.text.trim()} ต.${sub?.nameTh ?? ''} อ.${thai.district?.nameTh ?? ''} '
            'จ.${thai.province?.nameTh ?? ''} ${sub?.postcode ?? ''}'
        .trim();
  }

7. API ฝั่งเซิร์ฟเวอร์ (PHP)

โครงของ endpoint, ไฟล์กลาง, รายการ endpoint ทั้งหมด และไฟล์สำคัญทีละไฟล์

API อยู่ที่ C:\xampp\htdocs\jobmatch_api\ เมื่อเปิด XAMPP (Apache + MySQL) แอปจะเรียกได้ที่ http://<IP เครื่อง>/jobmatch_api/ชื่อไฟล์.php ลองเปิด http://localhost/jobmatch_api/get_keywords.php ในเบราว์เซอร์ดูได้เลย จะเห็น JSON ที่แอปได้รับจริง

โครงสร้างของ endpoint 1 ไฟล์ (ตัวอย่าง apply_job.php)

เกือบทุก endpoint เรียงขั้นตอนแบบเดียวกัน อ่านไฟล์นี้ให้เข้าใจแล้วไฟล์อื่นจะง่ายขึ้นมาก

  1. require ไฟล์กลาง เชื่อมฐานข้อมูล ($pdo), ฟังก์ชันตรวจสิทธิ์ และฟังก์ชันอื่นที่ต้องใช้
  2. ตรวจ HTTP method ถ้าไม่ใช่ที่กำหนดตอบ 405
  3. อ่านข้อมูลขาเข้า ถอด JSON จาก body ถ้าว่างตอบ 400
  4. ระบุตัวและตรวจสิทธิ์ ใช้รหัสจากผู้ที่ล็อกอินอยู่ ไม่เชื่อรหัสที่ส่งมาในข้อมูล (กันสมัครงานแทนคนอื่น)
  5. ตรวจเงื่อนไขทางธุรกิจ งานมีอยู่จริงไหม เปิดรับอยู่ไหม สมัครซ้ำไหม
  6. ทำงานกับฐานข้อมูล INSERT/UPDATE และงานข้างเคียง เช่น สร้างแจ้งเตือน
  7. ตอบกลับ ด้วยรหัสสถานะ + JSON ถ้า SQL ผิดพลาด (PDOException) เขียนรายละเอียดลง error log แล้วตอบข้อความกลาง ๆ พร้อม 500
jobmatch_api/apply_job.phpบรรทัด 14–40ขั้น 1–4: require, ตรวจ method, อ่าน JSON, ใช้ seeker_id จากผู้ที่ล็อกอิน
require_once 'db_connect.php';
require_once 'api_auth.php';
require_once 'matching_service.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    echo json_encode(['success' => false, 'message' => 'Method Not Allowed']);
    exit();
}

$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
    http_response_code(400);
    echo json_encode(['success' => false, 'message' => 'ไม่ได้รับข้อมูล']);
    exit();
}

$jobId = (int) ($input['job_id'] ?? 0);
$seekerId = (int) ($input['seeker_id'] ?? 0);
// ใช้ seeker_id จาก token เสมอ ไม่เชื่อค่าที่ส่งมา (กันสมัครงานแทนคนอื่น)
$seekerId = requireSeeker($pdo)['seeker_id'];

if ($jobId === 0 || $seekerId === 0) {
    http_response_code(400);
    echo json_encode(['success' => false, 'message' => 'กรุณาระบุ job_id และ seeker_id']);
    exit();
}
jobmatch_api/apply_job.phpบรรทัด 42–107ขั้น 5–7: ตรวจงาน, กันสมัครซ้ำ, บันทึก, แจ้งเตือนบริษัท, ตอบกลับ
try {
    // ตรวจสอบว่างานนี้มีอยู่จริง
    $checkJob = $pdo->prepare('SELECT employer_id, job_title, status FROM job_posting WHERE job_id = ?');
    $checkJob->execute([$jobId]);
    $jobPosting = $checkJob->fetch();
    if (!$jobPosting) {
        http_response_code(404);
        echo json_encode(['success' => false, 'message' => 'ไม่พบตำแหน่งงานนี้']);
        exit();
    }
    if (!isJobVisible($jobPosting['status'])) {
        http_response_code(409);
        echo json_encode(['success' => false, 'message' => 'ตำแหน่งงานนี้ยังไม่เปิดรับสมัคร หรือปิดรับสมัครแล้ว']);
        exit();
    }
    $employerId = $jobPosting['employer_id'];
    $jobTitle = $jobPosting['job_title'];

    // ป้องกันสมัครงานเดิมซ้ำ
    $checkDup = $pdo->prepare('SELECT COUNT(*) FROM application WHERE job_id = ? AND seeker_id = ?');
    $checkDup->execute([$jobId, $seekerId]);
    if ($checkDup->fetchColumn() > 0) {
        http_response_code(409);
        echo json_encode(['success' => false, 'message' => 'คุณสมัครงานนี้ไปแล้ว']);
        exit();
    }

    $stmt = $pdo->prepare('
        INSERT INTO application (job_id, seeker_id, apply_date, status)
        VALUES (?, ?, CURDATE(), ?)
    ');
    $stmt->execute([$jobId, $seekerId, 'รอดำเนินการ']);
    $applicationId = (int) $pdo->lastInsertId();

    // ดึงข้อมูลชื่อผู้สมัคร
    $getSeeker = $pdo->prepare('SELECT fullname FROM job_seeker WHERE seeker_id = ?');
    $getSeeker->execute([$seekerId]);
    $seekerRow = $getSeeker->fetch();
    $seekerName = $seekerRow ? $seekerRow['fullname'] : 'มีผู้';

    // ดึง user_id ของ employer เพื่อส่งแจ้งเตือน
    $getEmp = $pdo->prepare('SELECT user_id FROM employer WHERE employer_id = ?');
    $getEmp->execute([$employerId]);
    $empUser = $getEmp->fetch();
    if ($empUser) {
        $empUserId = $empUser['user_id'];

        $notiTitle = "มีผู้สมัครงานใหม่";
        $notiContent = $seekerName . " ได้สมัครงานในตำแหน่ง " . $jobTitle;
        $notiType = 'application_' . $jobId;

        $insertNoti = $pdo->prepare('INSERT INTO notification (user_id, title, content, type) VALUES (?, ?, ?, ?)');
        $insertNoti->execute([$empUserId, $notiTitle, $notiContent, $notiType]);
    }

    http_response_code(201);
    echo json_encode([
        'success' => true,
        'message' => 'สมัครงานสำเร็จ',
        'application_id' => $applicationId,
    ]);
} catch (PDOException $e) {
    http_response_code(500);
    error_log('apply_job: ' . $e->getMessage());
    echo json_encode(['success' => false, 'message' => 'เกิดข้อผิดพลาดของระบบ กรุณาลองใหม่อีกครั้ง'], JSON_UNESCAPED_UNICODE);
}
ทำไมเช็กสมัครซ้ำแล้ว ฐานข้อมูลยังมี UNIQUE KEY อีกชั้น ตาราง application มี UNIQUE KEY (job_id, seeker_id) ถ้าผู้ใช้กดปุ่มสมัครรัว ๆ จนคำขอ 2 อันมาถึงพร้อมกัน การเช็กด้วย SELECT อาจผ่านทั้งคู่ แต่ฐานข้อมูลจะไม่ยอมให้บันทึกแถวซ้ำ จึงเป็นการป้องกันชั้นสุดท้าย

ไฟล์กลางที่ทุก endpoint ใช้

db_connect.php — เชื่อมฐานข้อมูลและตั้งค่า header

jobmatch_api/db_connect.phpAPI
  • ปิดการแสดง error บนหน้าจอ (display_errors = 0) เพราะ PHP จะพิมพ์คำเตือนเป็น HTML ปนเข้าไปใน JSON ทำให้แอปถอดข้อมูลไม่ได้ แต่ยังเขียนลง log ไว้ตรวจ
  • header บอกว่าคำตอบเป็น JSON (UTF-8) และเปิด CORS ให้เวอร์ชันเว็บเรียกได้ คำขอ OPTIONS คือคำขอ “ถามก่อน” (preflight) ที่เบราว์เซอร์ส่งมาก่อนคำขอจริง ตอบ 200 แล้วจบเลย
  • PDO options: ERRMODE_EXCEPTION = SQL ผิดให้โยน exception (ใช้ try/catch จับได้), FETCH_ASSOC = ผลลัพธ์เป็น array ตามชื่อคอลัมน์, EMULATE_PREPARES => false = ใช้ prepared statement จริงของ MySQL
jobmatch_api/db_connect.phpบรรทัด 7–38
// ปิดการแสดง error เป็น HTML (ป้องกัน <br /> ปนใน JSON response)
ini_set('display_errors', '0');
error_reporting(E_ALL);
ini_set('log_errors', '1');

header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, GET, PUT, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-User-Id');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(200);
    exit();
}

$host = 'localhost';
$db   = 'job_matching_system';
$user = 'root';
$pass = '';

try {
    $pdo = new PDO("mysql:host=$host;dbname=$db;charset=utf8mb4", $user, $pass, [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
    ]);
} catch (PDOException $e) {
    http_response_code(500);
    error_log('db_connect: ' . $e->getMessage());
    echo json_encode(['success' => false, 'message' => 'เกิดข้อผิดพลาดของระบบ กรุณาลองใหม่อีกครั้ง'], JSON_UNESCAPED_UNICODE);
    exit();
}

api_auth.php — รู้ว่าใครเรียก และตรวจความเป็นเจ้าของข้อมูล

jobmatch_api/api_auth.phpAPI

แอปแนบ header X-User-Id มากับทุกคำขอ ไฟล์นี้อ่านค่านั้น ($_SERVER['HTTP_X_USER_ID']) แล้วค้นในฐานข้อมูลว่าเป็นผู้ใช้คนไหน บทบาทอะไร มี seeker_id/employer_id อะไร และบัญชียัง active อยู่ไหม ผลลัพธ์ถูกเก็บในตัวแปร static จึงค้นฐานข้อมูลแค่ครั้งเดียวต่อคำขอ แม้จะเรียก currentUser() หลายครั้ง

jobmatch_api/api_auth.phpบรรทัด 8–59
/** user_id ของผู้ใช้ที่ล็อกอินอยู่ แอปส่งมาใน header "X-User-Id" ทุกคำขอ (0 = ไม่ได้ล็อกอิน) */
function requestUserId(): int {
    $value = $_SERVER['HTTP_X_USER_ID'] ?? '';
    return ctype_digit(trim((string) $value)) ? (int) $value : 0;
}

function authFail(int $code, string $message): void {
    http_response_code($code);
    echo json_encode(['success' => false, 'message' => $message, 'auth_error' => $code === 401], JSON_UNESCAPED_UNICODE);
    exit;
}

/**
 * คืนข้อมูลผู้ใช้ที่ล็อกอินอยู่: ['user_id', 'role', 'seeker_id' | null, 'employer_id' | null]
 * $required = false ใช้กับ endpoint ที่ดูได้โดยไม่ต้องล็อกอิน (คืน null ถ้าไม่ได้ส่ง user_id)
 */
function currentUser(PDO $pdo, bool $required = true): ?array {
    static $user = false;
    if ($user !== false) return $user;

    $userId = requestUserId();
    if ($userId === 0) {
        if ($required) authFail(401, 'กรุณาเข้าสู่ระบบใหม่อีกครั้ง');
        return $user = null;
    }

    $stmt = $pdo->prepare('
        SELECT m.user_id, m.role, m.status, js.seeker_id, e.employer_id
        FROM member m
        LEFT JOIN job_seeker js ON js.user_id = m.user_id
        LEFT JOIN employer e ON e.user_id = m.user_id
        WHERE m.user_id = ?
        LIMIT 1
    ');
    $stmt->execute([$userId]);
    $row = $stmt->fetch();

    if (!$row) {
        if ($required) authFail(401, 'ไม่พบบัญชีผู้ใช้ กรุณาเข้าสู่ระบบใหม่อีกครั้ง');
        return $user = null;
    }
    if (($row['status'] ?? 'active') !== 'active') {
        authFail(403, 'บัญชีของคุณถูกระงับการใช้งาน');
    }

    return $user = [
        'user_id'     => (int) $row['user_id'],
        'role'        => $row['role'],
        'seeker_id'   => $row['seeker_id'] !== null ? (int) $row['seeker_id'] : null,
        'employer_id' => $row['employer_id'] !== null ? (int) $row['employer_id'] : null,
    ];
}

ฟังก์ชันช่วยที่ endpoint ต่าง ๆ เรียกใช้:

jobmatch_api/api_auth.phpบรรทัด 61–94
function requireSeeker(PDO $pdo): array {
    $user = currentUser($pdo);
    if ($user['seeker_id'] === null) authFail(403, 'เฉพาะผู้หางานเท่านั้น');
    return $user;
}

function requireEmployer(PDO $pdo): array {
    $user = currentUser($pdo);
    if ($user['employer_id'] === null) authFail(403, 'เฉพาะผู้ว่าจ้างเท่านั้น');
    return $user;
}

/** ปฏิเสธคำขอถ้าเงื่อนไขความเป็นเจ้าของไม่ผ่าน */
function ensureOwner(bool $isOwner): void {
    if (!$isOwner) authFail(403, 'คุณไม่มีสิทธิ์เข้าถึงข้อมูลนี้');
}

function employerOwnsJob(PDO $pdo, int $employerId, int $jobId): bool {
    $stmt = $pdo->prepare('SELECT COUNT(*) FROM job_posting WHERE job_id = ? AND employer_id = ?');
    $stmt->execute([$jobId, $employerId]);
    return $stmt->fetchColumn() > 0;
}

/** ข้อมูลเจ้าของใบสมัคร: seeker_id ของผู้สมัคร และ employer_id ของงาน */
function applicationOwners(PDO $pdo, int $applicationId): ?array {
    $stmt = $pdo->prepare('
        SELECT a.seeker_id, j.employer_id
        FROM application a JOIN job_posting j ON a.job_id = j.job_id
        WHERE a.application_id = ?
    ');
    $stmt->execute([$applicationId]);
    $row = $stmt->fetch();
    return $row ? ['seeker_id' => (int) $row['seeker_id'], 'employer_id' => (int) $row['employer_id']] : null;
}
ฟังก์ชันทำอะไรตัวอย่างที่ใช้
currentUser($pdo, $required)คืนข้อมูลผู้เรียก ถ้าบังคับแต่ไม่มี → 401, ถูกระงับ → 403get_messages, send_message
requireSeeker / requireEmployerต้องเป็นบทบาทนั้นเท่านั้น ไม่ใช่ → 403apply_job / post_job
ensureOwner($เงื่อนไข)เงื่อนไขเป็นเท็จ → 403 “ไม่มีสิทธิ์เข้าถึงข้อมูลนี้”get_applications, update_application
employerOwnsJobงานนี้เป็นของบริษัทนี้ไหมupdate_job, delete_job
applicationOwnersใบสมัครนี้เป็นของผู้หางานคนไหน / งานของบริษัทไหนupdate_application, post_interview, cancel_application

config.php และ .htaccess — ค่าลับและการปิดไฟล์ภายใน

jobmatch_api/config.php · .htaccessAPI

config.php คืนค่าเป็น array ของค่าลับ (เช่น API key ของ Gemini และชื่อโมเดล) ไฟล์อื่นอ่านด้วย $config = require 'config.php'; ไฟล์นี้ห้ามส่งขึ้น git และห้ามนำไปแสดงในรายงาน (คู่มือนี้จึงไม่แสดงเนื้อหาไฟล์) ส่วน config.example.php ใน backend/ เป็นแม่แบบที่ไม่มีค่าจริง

.htaccess สั่ง Apache ไม่ให้ใครเรียกไฟล์ค่าลับและไฟล์ฟังก์ชันภายในผ่านเว็บโดยตรง (ไฟล์เหล่านี้มีไว้ให้ require เท่านั้น) และปิดการแสดงรายชื่อไฟล์ในโฟลเดอร์

backend/.htaccessบรรทัด 1–5
# ไฟล์ตั้งค่าและไฟล์ฟังก์ชันภายใน ห้ามเรียกตรงจากเว็บ
<FilesMatch "^(config|db_connect|api_auth|matching_service|skill_matcher|pdf_text|ai_service|geocode_service)\.php$">
    Require all denied
</FilesMatch>
Options -Indexes

รายการ endpoint ทั้งหมด

“สิทธิ์” คือใครเรียกได้ (ตรวจผ่าน api_auth.php) และ “ผลข้างเคียง” คืองานอื่นที่เกิดขึ้นนอกจากหน้าที่หลัก

ไฟล์Methodสิทธิ์หน้าที่หลักผลข้างเคียง
บัญชีผู้ใช้และโปรไฟล์
login.phpPOSTทุกคนตรวจอีเมล/รหัสผ่าน คืนข้อมูลผู้ใช้ + ข้อมูลบริษัทหรือผู้หางานแปลงรหัสแบบเก่าเป็น bcrypt, สร้างโปรไฟล์ผู้หางานเปล่าถ้ายังไม่มี
register_seeker.phpPOSTทุกคนINSERT member + job_seeker ใน transaction เดียวหาพิกัดจากที่อยู่, คำนวณการจับคู่ใหม่ทั้งหมด
register_employer.phpPOSTทุกคนINSERT member + employerหาพิกัดจากที่อยู่บริษัท
reset_password.phpPOSTทุกคนอีเมล + เบอร์โทรตรงกัน → ตั้งรหัสผ่านใหม่—
get_member.phpGETล็อกอินข้อมูลผู้ใช้ตาม user_idซ่อนวันเกิดและคีย์เวิร์ด Resume ถ้าไม่ใช่เจ้าของบัญชี
update_seeker.phpPOST/PUTผู้หางาน (ตัวเอง)UPDATE job_seeker (+ ชื่อใน member)AI แปลงทักษะ, หาพิกัดใหม่ถ้าที่อยู่เปลี่ยน, คำนวณจับคู่ใหม่เมื่อมีทักษะ
update_employer.phpPOST/PUTผู้ว่าจ้าง (ตัวเอง)UPDATE employerหาพิกัดใหม่ถ้าที่อยู่เปลี่ยน
upload_profile_image.phpPOST (ไฟล์)ล็อกอินเก็บรูปใน uploads/ แล้วบันทึก path เป็นรูปโปรไฟล์ / โลโก้ / ภาพปก—
upload_resume.phpPOST (ไฟล์)ผู้หางานเก็บ PDF ใน uploads/resumes/ บันทึก resume_urlอ่านคำสำคัญจาก PDF, ลบไฟล์เก่า, คำนวณจับคู่ใหม่
คลังคำสำคัญ
get_keywords.phpGETทุกคนรายการคำสำคัญเรียงตามหมวด (“หมวดใหม่” อยู่ท้ายสุด)—
add_keyword.phpPOSTล็อกอินเพิ่มคำสำคัญใหม่ถ้ายังไม่มี—
ประกาศงาน
get_jobs.phpGETทุกคนงานที่เปิดรับ + คะแนน ระดับ ระยะทาง ทักษะที่ตรง/ขาด (มีคะแนนเมื่อล็อกอินเป็นผู้หางาน)—
get_employer_jobs.phpGETล็อกอินงานของบริษัท + จำนวนผู้สมัครเจ้าของเห็นทุกสถานะ คนอื่นเห็นเฉพาะที่เปิดรับ
post_job.phpPOSTผู้ว่าจ้างINSERT job_posting (สถานะ approved แสดงทันที)AI แปลงทักษะ, แปลงพิกัด↔ชื่อสถานที่, คำนวณจับคู่, แจ้งเตือนงานแนะนำ
update_job.phpPUTเจ้าของงานUPDATE job_postingAI แปลงทักษะ, คำนวณจับคู่ของงานนี้ใหม่
delete_job.phpPOST/DELETEเจ้าของงานDELETE job_postingใบสมัคร นัดสัมภาษณ์ และผลจับคู่ของงานถูกลบตาม (ON DELETE CASCADE)
get_employer_user_id.phpGETล็อกอินหา user_id, ชื่อบริษัท, เบอร์ จาก employer_id—
ใบสมัครและนัดสัมภาษณ์
apply_job.phpPOSTผู้หางานINSERT application สถานะ “รอดำเนินการ”แจ้งเตือนบริษัท “มีผู้สมัครงานใหม่”
cancel_application.phpPOST/DELETEเจ้าของใบสมัครDELETE application—
get_applications.phpGETเจ้าของข้อมูล3 โหมด: ตามงาน / ตามบริษัท / ตามผู้หางานคำนวณคะแนนสดและจัดอันดับผู้สมัคร
update_application.phpPUT/POSTผู้ว่าจ้างเจ้าของงาน หรือผู้หางาน (ตอบรับ/ปฏิเสธ)เปลี่ยนสถานะใบสมัคร (+ วันเริ่มงาน/ข้อความ)นับ hired_count ใหม่, ปิด/เปิดรับงานอัตโนมัติ, แจ้งเตือน, ส่งข้อความแชท
post_interview.phpPOSTผู้ว่าจ้างเจ้าของงานสร้างหรือเลื่อนนัด + สถานะ “นัดสัมภาษณ์”แจ้งเตือน + ส่งรายละเอียดนัดเข้าแชท
แชทและแจ้งเตือน
get_messages.phpGETคู่สนทนา2 โหมด: ข้อความในห้อง / รายการห้องแชทเปิดห้อง = ตั้งข้อความที่ส่งถึงเราเป็นอ่านแล้ว
send_message.phpPOSTล็อกอินINSERT message (ผู้ส่ง = ผู้ล็อกอินเสมอ)—
get_notifications.phpGETเจ้าของแจ้งเตือนของผู้ใช้ เรียงใหม่สุดก่อน—
mark_notification_read.phpPOSTเจ้าของตั้ง Is_read = 1—
delete_notification.phpPOSTเจ้าของลบแจ้งเตือน—

รายละเอียด endpoint สำคัญ

login.php — เข้าสู่ระบบ

jobmatch_api/login.phpAPI

ค้นผู้ใช้จากอีเมล → บล็อกบัญชีที่ไม่ active → ตรวจรหัสผ่าน จุดที่น่าสนใจคือรองรับข้อมูลเก่าที่เก็บรหัสเป็นข้อความธรรมดา: ถ้ารหัสใน DB ยังไม่ใช่ bcrypt จะเทียบตรง ๆ ด้วย hash_equals (กันการวัดเวลาเดารหัส) และถ้าถูก ก็แปลงเป็น bcrypt ทันที

jobmatch_api/login.phpบรรทัด 45–63
$storedPassword = $user['password'];
$isBcryptHash = password_get_info($storedPassword)['algo'] !== null;

if ($isBcryptHash) {
    $isValid = password_verify($password, $storedPassword);
} else {
    $isValid = hash_equals($storedPassword, $password);
    if ($isValid) {
        $newHash = password_hash($password, PASSWORD_DEFAULT);
        $upd = $pdo->prepare('UPDATE member SET password = ? WHERE user_id = ?');
        $upd->execute([$newHash, $user['user_id']]);
    }
}

if (!$isValid) {
    http_response_code(401);
    echo json_encode(['success' => false, 'message' => 'อีเมลหรือรหัสผ่านไม่ถูกต้อง']);
    exit();
}

เมื่อผ่านแล้วจะลบรหัสผ่านออกจากข้อมูลก่อนส่งกลับ (unset($user['password'])) แล้วรวมข้อมูลบริษัทหรือผู้หางานเข้าไป ถ้าเป็นผู้หางานรุ่นเก่าที่ยังไม่มีแถวใน job_seeker จะสร้างให้เลย

jobmatch_api/login.phpบรรทัด 75–101
if ($user['role'] === 'employer') {
    $empStmt = $pdo->prepare('SELECT employer_id, company_name, company_detail, contact_phone, company_address FROM employer WHERE user_id = ? LIMIT 1');
    $empStmt->execute([$user['user_id']]);
    $emp = $empStmt->fetch(PDO::FETCH_ASSOC);
    if ($emp) {
        $emp['employer_id'] = (int) $emp['employer_id'];
        $data = array_merge($data, $emp);
    }
} elseif ($user['role'] === 'seeker') {
    $seekerStmt = $pdo->prepare('SELECT seeker_id, fullname, age, date_of_birth, education, phone, skills, profile_detail, address FROM job_seeker WHERE user_id = ? LIMIT 1');
    $seekerStmt->execute([$user['user_id']]);
    $seeker = $seekerStmt->fetch(PDO::FETCH_ASSOC);
    if (!$seeker) {
        // บัญชีเก่าที่ยังไม่มีโปรไฟล์ผู้หางาน -> สร้างโปรไฟล์เปล่าให้ จะได้สมัครงานและกรอกทักษะได้
        $pdo->prepare('INSERT INTO job_seeker (user_id, fullname, phone) VALUES (?, ?, ?)')
            ->execute([$user['user_id'], mb_substr($user['name'], 0, 50), mb_substr($user['phone'] ?? '', 0, 10)]);
        $seekerStmt->execute([$user['user_id']]);
        $seeker = $seekerStmt->fetch(PDO::FETCH_ASSOC);
    }
    if ($seeker) {
        $seeker['seeker_id'] = (int) $seeker['seeker_id'];
        if (isset($seeker['age'])) {
            $seeker['age'] = (int) $seeker['age'];
        }
        $data = array_merge($data, $seeker);
    }
}

register_seeker.php — สมัครสมาชิกผู้หางาน

jobmatch_api/register_seeker.phpAPI

ตรวจข้อมูล (อีเมลถูกรูปแบบ, รหัส ≥ 6 ตัว, เบอร์ ≤ 10 หลัก, อีเมลไม่ซ้ำ) แล้วบันทึก 2 ตาราง ใน transaction เดียว: ถ้าบันทึกตารางที่สองพลาด rollBack() จะยกเลิกตารางแรกด้วย ไม่เกิดบัญชีครึ่ง ๆ กลาง ๆ

jobmatch_api/register_seeker.phpบรรทัด 84–140
$passwordHash = password_hash($password, PASSWORD_DEFAULT);

try {
    $pdo->beginTransaction();

    // 1. บันทึกลงตาราง member
    $stmt = $pdo->prepare('INSERT INTO member (name, email, password, role) VALUES (?, ?, ?, "seeker")');
    $stmt->execute([$name, $email, $passwordHash]);
    $userId = (int) $pdo->lastInsertId();

    // 2. บันทึกลงตาราง job_seeker (ครบถ้วนทั้ง 12 ฟิลด์ตรงกับโครงสร้างตาราง)[cite: 1]
    $stmt2 = $pdo->prepare('
        INSERT INTO job_seeker
        (user_id, fullname, age, date_of_birth, phone, gender, skills, profile_detail, education, experience, address, latitude, longitude)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    ');

    $stmt2->execute([
        $userId,
        $name,
        $age,
        $dob,
        $phone,
        $gender,
        $skills,
        $profileDetail,
        $education,
        $experience,
        $address,
        $latitude,
        $longitude
    ]);

    $seekerId = (int) $pdo->lastInsertId();

    $pdo->commit();

    // ผู้หางานใหม่ -> คำนวณการจับคู่กับงานทั้งหมด (ขอบเขต 1.3.4.6)
    try {
        recalculateAllMatching($pdo);
    } catch (PDOException $e) {
        error_log('register_seeker matching: ' . $e->getMessage());
    }

    http_response_code(201);
    echo json_encode([
        'success'   => true,
        'message'   => 'สมัครสมาชิกผู้หางานสำเร็จ',
        'user_id'   => $userId,
        'seeker_id' => $seekerId,
    ]);
} catch (PDOException $e) {
    $pdo->rollBack();
    http_response_code(500);
    error_log('register_seeker: ' . $e->getMessage());
    echo json_encode(['success' => false, 'message' => 'เกิดข้อผิดพลาดของระบบ กรุณาลองใหม่อีกครั้ง'], JSON_UNESCAPED_UNICODE);
}

get_member.php — ข้อมูลผู้ใช้และความเป็นส่วนตัว

jobmatch_api/get_member.phpAPI

ใครล็อกอินอยู่ก็ดูโปรไฟล์คนอื่นได้ (เช่น ผู้ว่าจ้างดูผู้สมัคร) แต่ถ้าไม่ใช่เจ้าของบัญชี จะตัดข้อมูลส่วนตัวออกก่อนส่ง และ normalize ค่า role (ตัดช่องว่าง/ตัวพิมพ์ใหญ่) กันข้อมูลใน DB เพี้ยนแล้วไม่ดึงตารางลูก

jobmatch_api/get_member.phpบรรทัด 57–76
if ($normalizedRole === 'seeker') {
    $seekerStmt = $pdo->prepare(
        'SELECT seeker_id, fullname, age, date_of_birth, education, phone, gender, skills, experience, profile_detail, address,
                resume_url, resume_keywords, updated_at, profile_image
         FROM job_seeker WHERE user_id = ? LIMIT 1'
    );
    $seekerStmt->execute([$userId]);
    $seeker = $seekerStmt->fetch();
    if ($seeker) {
        $seeker['seeker_id'] = (int) $seeker['seeker_id'];
        // แปลง age ให้เป็น int ถ้ามีค่า
        if (isset($seeker['age'])) {
            $seeker['age'] = (int) $seeker['age'];
        }
        if (!$isSelf) {
            // ข้อมูลส่วนบุคคล: เลขบัตรประชาชนและวันเกิดให้เห็นเฉพาะเจ้าของบัญชี
            unset($seeker['date_of_birth'], $seeker['resume_keywords']);
        }
        $data = array_merge($data, $seeker);
    }

update_application.php — เปลี่ยนสถานะใบสมัคร

jobmatch_api/update_application.phpAPI

ไฟล์ที่ตรรกะซับซ้อนที่สุดใน API เพราะใช้ทั้งผู้ว่าจ้าง (ตัดสินผล) และผู้หางาน (ตอบรับ/ปฏิเสธข้อเสนอ) ขั้นแรกตรวจว่าใครมีสิทธิ์เปลี่ยนเป็นสถานะไหน และเปลี่ยนจากสถานะเดิมได้ไหม:

jobmatch_api/update_application.phpบรรทัด 58–83
// ผู้หางานตอบรับ/ปฏิเสธได้เฉพาะใบสมัครของตัวเอง ส่วนสถานะอื่นผู้ว่าจ้างเจ้าของงานเป็นคนเปลี่ยน
$isSeekerAction = in_array($status, $offerResponseStatuses, true);
ensureOwner($isSeekerAction
    ? $owners['seeker_id'] === $authUser['seeker_id']
    : $owners['employer_id'] === $authUser['employer_id']);

// ลำดับสถานะที่อนุญาต: รอดำเนินการ -> นัดสัมภาษณ์ -> ผ่าน/ไม่ผ่าน -> (ผู้หางาน) ตอบรับ/ปฏิเสธ
$currentStmt = $pdo->prepare('SELECT status FROM application WHERE application_id = ?');
$currentStmt->execute([$applicationId]);
$currentStatus = $currentStmt->fetchColumn();

if ($currentStatus === $status) {
    // กดซ้ำ -> ไม่ต้องบันทึกและไม่ส่งแจ้งเตือนซ้ำ
    echo json_encode(['success' => true, 'message' => 'สถานะนี้ถูกบันทึกไว้แล้ว', 'status' => $status]);
    exit();
}
if ($isSeekerAction && $currentStatus !== 'ผ่านการคัดเลือก') {
    http_response_code(409);
    echo json_encode(['success' => false, 'message' => 'ตอบรับหรือปฏิเสธได้เฉพาะเมื่อผ่านการคัดเลือกแล้ว']);
    exit();
}
if (!$isSeekerAction && in_array($currentStatus, $offerResponseStatuses, true)) {
    http_response_code(409);
    echo json_encode(['success' => false, 'message' => 'ผู้สมัครตอบกลับข้อเสนอแล้ว ไม่สามารถเปลี่ยนสถานะได้']);
    exit();
}
ลำดับสถานะของใบสมัคร รอดำเนินการ นัดสัมภาษณ์ ผ่านการคัดเลือก ไม่ผ่านการคัดเลือก ตอบรับงานแล้ว ปฏิเสธงาน ผู้หางานกด “ยกเลิกการสมัคร” = ลบใบสมัครทิ้ง (cancel_application.php)
ผู้ว่าจ้างเปลี่ยนผู้หางานตอบข้ามขั้นได้ (ตัดสินโดยไม่สัมภาษณ์)
ภาพที่ 2 ลำดับสถานะของใบสมัคร (ข้อความสถานะเก็บเป็นภาษาไทยในคอลัมน์ application.status)

หลังบันทึกสถานะ จะนับจำนวนคนที่รับแล้ว (hired_count) ใหม่จากใบสมัครจริงทุกครั้ง (ไม่ใช้วิธี +1/−1 ซึ่งเพี้ยนได้ถ้ากดซ้ำ) แล้วปิดรับงานอัตโนมัติเมื่อรับครบ หรือเปิดรับใหม่ถ้ามีคนปฏิเสธจนกลับมาไม่ครบ

jobmatch_api/update_application.phpบรรทัด 110–126
// อัปเดตจำนวน hired_count ของงานนั้นใหม่ (นับเฉพาะคนที่ ผ่านการคัดเลือก หรือ ตอบรับงานแล้ว)
if ($jobId) {
    $updateHiredCountStmt = $pdo->prepare("
        UPDATE job_posting
        SET hired_count = (
            SELECT COUNT(*)
            FROM application
            WHERE job_id = ? AND status IN ('ผ่านการคัดเลือก', 'ตอบรับงานแล้ว')
        )
        WHERE job_id = ?
    ");
    $updateHiredCountStmt->execute([$jobId, $jobId]);

    // รับครบตามจำนวนที่เปิดรับแล้ว -> ปิดรับสมัครอัตโนมัติ, ถ้ากลับมาไม่ครบ (เช่น ผู้สมัครปฏิเสธ) -> เปิดรับอีกครั้ง
    $pdo->prepare("UPDATE job_posting SET status = 'closed' WHERE job_id = ? AND hired_count >= vacancy AND status IN ('open', 'approved')")->execute([$jobId]);
    $pdo->prepare("UPDATE job_posting SET status = 'approved' WHERE job_id = ? AND hired_count < vacancy AND status = 'closed'")->execute([$jobId]);
}

สุดท้ายสร้างแจ้งเตือนให้อีกฝ่าย และถ้าเป็นการแจ้งผลจากบริษัทจะส่งข้อความเข้าแชทด้วย (ผู้ส่ง = user_id ของบริษัท) ผู้หางานจึงเห็นผลทั้งในแจ้งเตือนและในแชท

jobmatch_api/update_application.phpบรรทัด 158–172
// ส่งผลการพิจารณาเข้าแชทด้วย (จากบริษัทถึงผู้สมัคร) เหมือนตอนนัดสัมภาษณ์
if ($status === 'ผ่านการคัดเลือก') {
    $chatMessage = "ยินดีด้วยครับ/ค่ะ คุณผ่านการคัดเลือกในตำแหน่ง " . $jobTitle;
    if ($startDate !== '') {
        $chatMessage .= "\nวันเริ่มงาน: " . date('d/m/Y', strtotime($startDate));
    }
    $chatMessage .= "\nกรุณากดตอบรับหรือปฏิเสธงานในหน้า \"งานของฉัน\"";
} else {
    $chatMessage = "ขอบคุณที่สนใจสมัครงานตำแหน่ง " . $jobTitle . " ขออภัย คุณไม่ผ่านการคัดเลือกในครั้งนี้";
}
if ($resultMessage !== '') {
    $chatMessage .= "\nเพิ่มเติม: " . $resultMessage;
}
$pdo->prepare('INSERT INTO message (sender_id, receiver_id, message, sent_date, is_read) VALUES (?, ?, ?, NOW(), 0)')
    ->execute([$info['employer_user_id'], $seekerUserId, $chatMessage]);

post_interview.php — นัดสัมภาษณ์ / เลื่อนนัด

jobmatch_api/post_interview.phpAPI

ใบสมัคร 1 ใบมีนัดได้ 1 นัด ถ้ามีนัดอยู่แล้วถือเป็นการเลื่อนนัด (UPDATE แถวเดิม) และถ้าข้อมูลเหมือนเดิมทุกช่อง (เช่น กดบันทึกซ้ำ) จะตอบสำเร็จโดยไม่ส่งแจ้งเตือน/ข้อความซ้ำ จากนั้นตั้งสถานะใบสมัครเป็น “นัดสัมภาษณ์” แล้วส่งแจ้งเตือน + ข้อความแชทที่มีวัน เวลา รูปแบบ และสถานที่/ลิงก์

jobmatch_api/post_interview.phpบรรทัด 55–83
// 1 ใบสมัครมีนัดสัมภาษณ์ได้ 1 นัด: ถ้ามีอยู่แล้วถือเป็นการเลื่อนนัด (แก้ไขนัดเดิม ไม่สร้างซ้ำ)
$existing = $pdo->prepare('SELECT interview_id, interview_date, interview_time, interview_type, location_link, additional_notes FROM interview WHERE application_id = ? ORDER BY interview_id DESC LIMIT 1');
$existing->execute([$applicationId]);
$existingRow = $existing->fetch();
$isReschedule = (bool) $existingRow;

if ($existingRow
    && $existingRow['interview_date'] === $interviewDate
    && substr($existingRow['interview_time'], 0, 5) === substr($interviewTime, 0, 5)
    && $existingRow['interview_type'] === $interviewType
    && $existingRow['location_link'] === $locationLink
    && $existingRow['additional_notes'] === $additionalNotes) {
    // ข้อมูลเหมือนเดิมทุกอย่าง (เช่น กดปุ่มซ้ำ) -> ไม่ส่งแจ้งเตือน/แชทซ้ำ
    echo json_encode(['success' => true, 'message' => 'นัดสัมภาษณ์นี้ถูกบันทึกไว้แล้ว', 'interview_id' => (int) $existingRow['interview_id']]);
    exit();
}

if ($isReschedule) {
    $interviewId = (int) $existingRow['interview_id'];
    $pdo->prepare('UPDATE interview SET interview_date = ?, interview_time = ?, interview_type = ?, location_link = ?, additional_notes = ?, status = "scheduled" WHERE interview_id = ?')
        ->execute([$interviewDate, $interviewTime, $interviewType, $locationLink, $additionalNotes, $interviewId]);
} else {
    $stmt = $pdo->prepare('
        INSERT INTO interview (application_id, interview_date, interview_time, interview_type, location_link, additional_notes)
        VALUES (?, ?, ?, ?, ?, ?)
    ');
    $stmt->execute([$applicationId, $interviewDate, $interviewTime, $interviewType, $locationLink, $additionalNotes]);
    $interviewId = (int) $pdo->lastInsertId();
}

upload_resume.php และ upload_profile_image.php — อัปโหลดไฟล์

jobmatch_api/upload_resume.php · upload_profile_image.phpAPI

ไฟล์ที่อัปโหลดแบบ multipart มาอยู่ใน $_FILES['file'] PHP เก็บไว้ในโฟลเดอร์ชั่วคราวก่อน แล้ว move_uploaded_file() ย้ายไปที่จริง การตรวจ Resume เช็กเนื้อไฟล์จริง ไม่ใช่แค่นามสกุล: 4 ไบต์แรกต้องเป็น %PDF และชนิด MIME ต้องเป็น application/pdf (กันคนเปลี่ยนชื่อไฟล์อื่นเป็น .pdf)

jobmatch_api/upload_resume.phpบรรทัด 43–48
// ตรวจเนื้อไฟล์จริง ไม่ใช่แค่นามสกุล: ต้องเป็น PDF (ขึ้นต้นด้วย %PDF)
$header = (string) file_get_contents($file['tmp_name'], false, null, 0, 5);
$mime = function_exists('finfo_open') ? finfo_file(finfo_open(FILEINFO_MIME_TYPE), $file['tmp_name']) : 'application/pdf';
if (strncmp($header, '%PDF', 4) !== 0 || $mime !== 'application/pdf') {
    fail(400, 'อนุญาตให้อัปโหลดเฉพาะไฟล์ PDF เท่านั้น');
}
jobmatch_api/upload_resume.phpบรรทัด 66–85อ่านข้อความใน PDF → หาคำสำคัญ → บันทึก → คำนวณจับคู่ใหม่ → ลบไฟล์เก่า (1 คนมี Resume ได้ 1 ไฟล์)
try {
    $column = 'resume_url';
    $keywords = [];

    if ($type === 'resume') {
        // Keyword Matching จากเนื้อหา Resume (PDF ที่เป็นภาพสแกนจะดึงข้อความไม่ได้ -> ไม่มีคีย์เวิร์ด)
        $keywords = extractKeywordsFromText($pdo, extractPdfText(__DIR__ . '/' . $relativePath));
        $stmt = $pdo->prepare('UPDATE job_seeker SET resume_url = ?, resume_keywords = ? WHERE user_id = ?');
        $stmt->execute([$relativePath, implode(', ', $keywords), $userId]);
        recalculateAllMatching($pdo);
    } else {
        $stmt = $pdo->prepare("UPDATE job_seeker SET $column = ? WHERE user_id = ?");
        $stmt->execute([$relativePath, $userId]);
    }

    // ลบไฟล์เก่าของประเภทเดียวกัน (1 ไฟล์ต่อประเภท)
    $old = $seekerRow[$column] ?? '';
    if ($old && $old !== $relativePath && str_starts_with($old, 'uploads/resumes/') && is_file(__DIR__ . '/' . $old)) {
        @unlink(__DIR__ . '/' . $old);
    }

อัปโหลดรูปไม่เชื่อ role/user_id ที่ส่งมา แต่ใช้ของผู้ที่ล็อกอินอยู่ ส่ง kind=cover มาจะบันทึกเป็นภาพปกบริษัท ชื่อไฟล์ใหม่ประกอบจากบทบาท + user_id + เวลา (time()) จึงไม่ชนกัน และบันทึกลง DB เป็น path สัมพัทธ์

jobmatch_api/upload_profile_image.phpบรรทัด 21–27
$userId = isset($_POST['user_id']) ? (int) $_POST['user_id'] : 0;
$role   = isset($_POST['role']) ? trim($_POST['role']) : 'seeker';
// อัปโหลดได้เฉพาะรูปของตัวเอง
$authUser = currentUser($pdo);
$userId = $authUser['user_id'];
$role   = $authUser['employer_id'] !== null ? 'employer' : 'seeker';
$isCover = $role === 'employer' && ($_POST['kind'] ?? '') === 'cover';

get_messages.php และ send_message.php — แชท

jobmatch_api/get_messages.php · send_message.phpAPI

ข้อความทุกข้อความอยู่ในตาราง message แถวละ 1 ข้อความ (sender_id, receiver_id เป็น user_id) “ห้องแชท” ไม่มีตารางของตัวเอง แต่เกิดจากการจัดกลุ่มข้อความตามคู่สนทนา:

jobmatch_api/get_messages.phpบรรทัด 61–77CASE หา 'อีกฝ่าย' ของแต่ละข้อความ แล้ว GROUP BY ให้เหลือ 1 แถวต่อคู่สนทนา, SUM(CASE…) นับข้อความที่ยังไม่อ่าน
$stmt = $pdo->prepare('
    SELECT
        CASE WHEN m.sender_id = ? THEN m.receiver_id ELSE m.sender_id END AS other_user_id,
        mem.name AS other_name,
        COALESCE(NULLIF(MAX(e.company_logo), ""), MAX(js.profile_image)) AS other_image,
        m.message AS last_message,
        m.sent_date AS last_sent_date,
        SUM(CASE WHEN m.receiver_id = ? AND m.is_read = 0 THEN 1 ELSE 0 END) AS unread_count
    FROM message m
    JOIN member mem ON mem.user_id = CASE WHEN m.sender_id = ? THEN m.receiver_id ELSE m.sender_id END
    LEFT JOIN employer e ON e.user_id = mem.user_id
    LEFT JOIN job_seeker js ON js.user_id = mem.user_id
    WHERE m.sender_id = ? OR m.receiver_id = ?
    GROUP BY other_user_id
    ORDER BY last_sent_date DESC
');
$stmt->execute([$userId, $userId, $userId, $userId, $userId]);

ส่วน send_message.php ไม่ใช้ sender_id ที่แอปส่งมา แต่ใช้ผู้ที่ล็อกอินเป็นผู้ส่งเสมอ จึงปลอมเป็นคนอื่นส่งข้อความไม่ได้

reset_password.php — ลืมรหัสผ่าน

jobmatch_api/reset_password.phpAPI

เบอร์โทรเก็บคนละตารางตามบทบาท จึงใช้ COALESCE(js.phone, e.contact_phone) เลือกค่าที่มี ตัดอักขระที่ไม่ใช่ตัวเลขออกก่อนเทียบ (พิมพ์ 081-234-5678 ก็ได้) และเมื่อไม่ตรง ระบบไม่บอกว่าผิดที่อีเมลหรือที่เบอร์ เพื่อไม่ให้ใช้ทดลองเดาว่าอีเมลไหนมีในระบบ

jobmatch_api/reset_password.phpบรรทัด 36–57
// เบอร์โทรเก็บแยกตามบทบาท: ผู้หางาน = job_seeker.phone, ผู้ว่าจ้าง = employer.contact_phone
$stmt = $pdo->prepare('
    SELECT m.user_id, m.status, COALESCE(js.phone, e.contact_phone) AS phone
    FROM member m
    LEFT JOIN job_seeker js ON js.user_id = m.user_id
    LEFT JOIN employer e ON e.user_id = m.user_id
    WHERE m.email = ? AND m.role IN ("seeker", "employer")
    LIMIT 1
');
$stmt->execute([$email]);
$user = $stmt->fetch();

// ไม่บอกว่าผิดที่อีเมลหรือเบอร์ เพื่อไม่ให้ใช้เดาว่าอีเมลไหนมีในระบบ
if (!$user || preg_replace('/\D/', '', $user['phone'] ?? '') !== $phone) {
    fail(401, 'อีเมลหรือเบอร์โทรศัพท์ไม่ตรงกับข้อมูลที่สมัครไว้');
}
if ($user['status'] !== 'active') {
    fail(403, 'บัญชีของคุณถูกระงับการใช้งาน');
}

$pdo->prepare('UPDATE member SET password = ? WHERE user_id = ?')
    ->execute([password_hash($newPassword, PASSWORD_DEFAULT), $user['user_id']]);

post_job.php / update_job.php — บันทึกประกาศงาน

jobmatch_api/post_job.php · update_job.phpAPI

ก่อนบันทึกมีการเตรียมข้อมูล 3 อย่าง: (1) ให้ AI แปลงทักษะที่พิมพ์เองเป็นคำในคลัง (2) ถ้าสถานที่เป็นข้อความ “พิกัด xx, yy” แปลงเป็น ต./อ./จ. (3) ถ้าพิมพ์สถานที่เองโดยไม่มีพิกัด ค้นหาพิกัดจากข้อความให้

jobmatch_api/post_job.phpบรรทัด 21–44
$employerId     = (int) ($input['employer_id'] ?? 0);
// ใช้ employer_id จาก token เสมอ (กันโพสต์งานในนามบริษัทอื่น)
$employerId     = requireEmployer($pdo)['employer_id'];
$jobTitle       = trim($input['job_title'] ?? '');
$jobDescription = trim($input['job_description'] ?? '');
$qualifications = trim($input['qualifications'] ?? '');
$jobLocation    = trim($input['job_location'] ?? '');
$rawRequiredSkills = trim($input['required_skills'] ?? '');
// ให้ AI ช่วยแปลงคำศัพท์เป็นคำมาตรฐาน
$requiredSkills = normalizeSkillsWithAI($rawRequiredSkills);

$jobType        = trim($input['job_type'] ?? '');
$salary         = trim($input['salary'] ?? '');
// ระบบไม่มีขั้นตอนอนุมัติ: ประกาศงานแสดงให้ผู้หางานเห็นทันที
$status         = 'approved';
$vacancy        = (int) ($input['vacancy'] ?? 1);
$latitude       = isset($input['latitude']) && $input['latitude'] !== '' ? (float) $input['latitude'] : null;
$longitude      = isset($input['longitude']) && $input['longitude'] !== '' ? (float) $input['longitude'] : null;
// กดใช้ตำแหน่งปัจจุบันแล้วแอปหาชื่อสถานที่ไม่ได้ จะได้ "พิกัด xx, yy" -> แปลงเป็น ต./อ./จ.
$jobLocation    = readableLocation($jobLocation, $latitude, $longitude);
// พิมพ์ที่อยู่เอง (ไม่ได้ใช้ GPS) -> หาพิกัดจากที่อยู่ เพื่อคำนวณระยะทาง "ห่าง x กม." ได้
if (($latitude === null || $longitude === null) && $jobLocation !== '') {
    [$latitude, $longitude] = coordinatesForAddress($jobLocation, null, null);
}

บันทึกแล้วคำนวณการจับคู่ของงานใหม่ทันที และส่งแจ้งเตือน “งานแนะนำสำหรับคุณ” ให้ผู้หางานที่คะแนนถึงเกณฑ์ (update_job.php คำนวณใหม่แต่ไม่ส่งแจ้งเตือนซ้ำ)

jobmatch_api/post_job.phpบรรทัด 63–72
$stmt = $pdo->prepare('
    INSERT INTO job_posting (employer_id, job_title, job_description, qualifications, job_location, required_skills, job_type, salary, status, post_date, vacancy, latitude, longitude)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURDATE(), ?, ?, ?)
');
$stmt->execute([$employerId, $jobTitle, $jobDescription, $qualifications, $jobLocation, $requiredSkills, $jobType, $salary, $status, $vacancy, $latitude, $longitude]);
$jobId = (int) $pdo->lastInsertId();

// คำนวณการจับคู่ทันที แล้วแจ้งเตือนงานแนะนำให้ผู้หางานที่คะแนนถึงเกณฑ์
recalculateMatchingForJob($pdo, $jobId);
notifyRecommendedSeekers($pdo, $jobId);
คำสั่ง ALTER TABLE … ADD COLUMN IF NOT EXISTS ในไฟล์ (บรรทัด 53–61) เป็นตัวช่วยสำหรับฐานข้อมูลรุ่นเก่าที่ยังไม่มีคอลัมน์พิกัด/จำนวนรับ ถ้ามีคอลัมน์อยู่แล้วคำสั่งนี้ไม่ทำอะไร

บริการเสริม: หาพิกัด, AI, อ่าน PDF

geocode_service.php — ที่อยู่ ↔ พิกัด (OpenStreetMap Nominatim)

jobmatch_api/geocode_service.phpAPI

ผู้ใช้ไม่ต้องปักหมุดเอง ระบบหาพิกัดระดับตำบลจากที่อยู่ เพื่อคำนวณ “ห่าง x กม.” coordinatesForAddress() ตัดสินว่าจะใช้พิกัดไหน:

jobmatch_api/geocode_service.phpบรรทัด 15–26
function coordinatesForAddress(string $address, $latitude, $longitude, ?array $current = null): array {
    if (is_numeric($latitude) && is_numeric($longitude)) {
        return [(float) $latitude, (float) $longitude];
    }
    $address = trim($address);
    if ($current !== null && ($address === '' || $address === trim((string) ($current['address'] ?? '')))
        && $current['latitude'] !== null && $current['longitude'] !== null) {
        return [(float) $current['latitude'], (float) $current['longitude']];
    }
    $found = geocodeThaiAddress($address !== '' ? $address : (string) ($current['address'] ?? ''));
    return $found ? [$found['latitude'], $found['longitude']] : [null, null];
}

geocodeThaiAddress() ดึง ต./อ./จ. ออกจากที่อยู่ด้วย regex แล้วลองค้นหาไล่จากละเอียดไปหยาบ (ตำบล → แบบไม่มีคำนำหน้าสำหรับกรุงเทพฯ → ระดับอำเภอ) ระหว่างแต่ละครั้งหน่วง 1.1 วินาที เพราะ Nominatim อนุญาตแค่ 1 คำขอต่อวินาที และถ้าเชื่อมต่อไม่ได้จะคืน null ทันที การบันทึกที่อยู่จึงทำต่อได้แม้ไม่มีเน็ต

jobmatch_api/geocode_service.phpบรรทัด 87–125
function geocodeThaiAddress(string $address): ?array {
    $address = trim($address);
    if ($address === '') return null;

    $queries = [];
    if (preg_match('/ต\.\s*(\S+)\s+อ\.\s*(\S+)\s+จ\.\s*(\S+)/u', $address, $m)) {
        [, $sub, $dist, $prov] = $m;
        $queries[] = "ตำบล$sub อำเภอ$dist จังหวัด$prov";
        $queries[] = "$sub $dist $prov";   // กรุงเทพฯ ใช้แขวง/เขต จึงลองแบบไม่ใส่คำนำหน้าด้วย
        $queries[] = "$dist $prov";        // หาตำบลไม่เจอ ใช้พิกัดระดับอำเภอแทน
    } else {
        $queries[] = $address;
    }

    foreach ($queries as $i => $query) {
        if ($i > 0) usleep(1100000); // Nominatim จำกัด 1 คำขอต่อวินาที
        $url = 'https://nominatim.openstreetmap.org/search?format=json&limit=1&countrycodes=th&q=' . rawurlencode($query);
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 3,
            CURLOPT_TIMEOUT        => 5,
            CURLOPT_USERAGENT      => 'JobMatch-student-project/1.0',
        ]);
        $body = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($body === false || $code !== 200) {
            error_log("geocode_service: Nominatim unavailable (HTTP $code)");
            return null; // เชื่อมต่อไม่ได้ ไม่ต้องลองคำค้นถัดไปให้ผู้ใช้รอนาน
        }
        $rows = json_decode($body, true);
        if (is_array($rows) && isset($rows[0]['lat'], $rows[0]['lon'])) {
            return ['latitude' => round((float) $rows[0]['lat'], 7), 'longitude' => round((float) $rows[0]['lon'], 7)];
        }
    }
    return null;
}

ทางกลับกัน reverseGeocodeThai() แปลงพิกัดเป็น “ต.… อ.… จ.…” โดยใช้ zoom=10 (ระดับตำบล) และเลือกฟิลด์ตามรูปแบบข้อมูลไทยของ OSM (province = จังหวัด, county = อำเภอ, municipality/city_district = ตำบล ส่วนกรุงเทพฯ ใช้แขวง/เขต)

ai_service.php — ให้ AI ช่วยแปลงทักษะเป็นคำมาตรฐาน

jobmatch_api/ai_service.phpAPI

ปัญหา: ผู้ใช้พิมพ์ทักษะไม่ตรงกับคลังคำ เช่น “เขียนเว็บ” ขณะที่คลังมี “PHP” ทำให้จับคู่ไม่ได้ ฟังก์ชัน normalizeSkillsWithAI() แก้ปัญหานี้แบบประหยัดและปลอดภัย:

  1. ทักษะที่ตรงกับคลังอยู่แล้ว (ไม่สนตัวพิมพ์เล็กใหญ่) ใช้ได้เลย ไม่ส่งให้ AI
  2. ส่งเฉพาะคำที่ไม่รู้จักไปให้ Google Gemini พร้อมรายการคลังคำ สั่งให้ตอบเป็น JSON array เท่านั้น และตั้ง temperature: 0 (ตอบคงที่ ไม่สุ่ม)
  3. ไม่เชื่อ AI ทั้งหมด: ยอมรับคำตอบเฉพาะคำที่มีอยู่ในคลังจริง ถ้า AI ตอบคำอื่นใช้คำที่ผู้ใช้พิมพ์
  4. ถ้าไม่มี API key, AI ตอบช้าเกิน 8 วินาที หรือตอบผิดรูปแบบ ใช้ข้อความเดิมทั้งหมด ระบบไม่พัง
jobmatch_api/ai_service.phpบรรทัด 26–51
$keywords = $pdo->query('SELECT keyword FROM keywords')->fetchAll(PDO::FETCH_COLUMN);
$inputSkills = array_values(array_filter(array_map('trim', explode(',', $rawSkillsString)), fn($s) => $s !== ''));

// ทักษะที่ตรงกับฐานข้อมูลอยู่แล้ว ไม่ต้องส่งให้ AI
$lowerKeywords = array_combine(array_map('mb_strtolower', $keywords), $keywords);
$result = [];
$unknown = [];
foreach ($inputSkills as $skill) {
    if (isset($lowerKeywords[mb_strtolower($skill)])) {
        $result[] = $lowerKeywords[mb_strtolower($skill)];
    } else {
        $unknown[] = $skill;
    }
}
if (!$unknown) {
    return implode(', ', array_unique($result));
}

$model = $config['gemini_model'] ?? 'gemini-flash-latest';
$url = 'https://generativelanguage.googleapis.com/v1beta/models/' . rawurlencode($model) . ':generateContent';
$prompt = "You map job skills typed by users to a fixed keyword list.\n"
    . "Keyword list: " . implode(' | ', $keywords) . "\n"
    . "For each input skill, answer with the single closest keyword from the list, copied exactly, "
    . "or the input skill unchanged if nothing in the list has the same meaning.\n"
    . "Return ONLY a JSON array of strings in the same order as the input.\n"
    . "Input: " . json_encode($unknown, JSON_UNESCAPED_UNICODE);
jobmatch_api/ai_service.phpบรรทัด 75–85
if (!is_array($mapped) || count($mapped) !== count($unknown)) {
    error_log('ai_service: Gemini unavailable (HTTP ' . $httpCode . '), keeping raw skills');
    return implode(', ', array_unique(array_merge($result, $unknown)));
}

foreach ($mapped as $i => $value) {
    $value = trim((string) $value);
    // ยอมรับเฉพาะคำที่อยู่ในฐานข้อมูลจริง ถ้า AI ตอบคำอื่นมาให้ใช้คำที่ผู้ใช้พิมพ์
    $result[] = isset($lowerKeywords[mb_strtolower($value)]) ? $lowerKeywords[mb_strtolower($value)] : $unknown[$i];
}
return implode(', ', array_unique($result));
API key ส่งไปใน header x-goog-api-key ไม่ใส่ใน URL เพื่อไม่ให้ key ติดไปใน log ของเซิร์ฟเวอร์ และอ่านจาก environment variable ก่อน แล้วค่อยอ่านจาก config.php

pdf_text.php — ดึงข้อความจากไฟล์ PDF

jobmatch_api/pdf_text.phpAPI

อ่าน PDF โดยไม่ต้องติดตั้งโปรแกรมเสริม ทำงาน 3 ขั้น: (1) หา stream ทั้งหมดในไฟล์ ถ้าบีบอัดแบบ FlateDecode ให้คลายด้วย gzuncompress (2) รวมตาราง ToUnicode (CMap) ซึ่งบอกว่ารหัสตัวอักษรในไฟล์ตรงกับอักษรอะไร (ไฟล์จาก Word/Google Docs ใช้แบบนี้ จำเป็นมากสำหรับภาษาไทย) (3) อ่านข้อความจากคำสั่งแสดงข้อความของ PDF (Tj, TJ, ', ") ที่อยู่ระหว่าง BT … ET

jobmatch_api/pdf_text.phpบรรทัด 17–30
// 1) อ่าน stream ทั้งหมด (คลายการบีบอัดถ้าเป็น FlateDecode)
$streams = [];
if (preg_match_all('/<<(.*?)>>\s*stream\r?\n(.*?)\r?\nendstream/s', $data, $matches, PREG_SET_ORDER)) {
    foreach ($matches as $m) {
        $content = $m[2];
        if (str_contains($m[1], '/FlateDecode')) {
            $inflated = @gzuncompress($content);
            if ($inflated === false) $inflated = @gzinflate($content);
            if ($inflated === false) continue;
            $content = $inflated;
        }
        $streams[] = $content;
    }
}
jobmatch_api/pdf_text.phpบรรทัด 56–77
// 3) ดึงข้อความจากคำสั่งแสดงผลข้อความใน content stream
$text = '';
foreach ($streams as $s) {
    // เฉพาะ content stream ที่มีบล็อกข้อความ (BT ... ET) ข้ามฟอนต์/รูปภาพ
    if (!preg_match('/\bBT\b/', $s)) continue;
    if (!preg_match_all('/(\[(?:[^\]\\\\]|\\\\.)*\]|\((?:[^()\\\\]|\\\\.)*\)|<[0-9A-Fa-f\s]+>)\s*(Tj|TJ|\'|")/s', $s, $ops, PREG_SET_ORDER)) {
        continue;
    }
    foreach ($ops as $op) {
        $operand = $op[1];
        if ($operand[0] === '[') {
            preg_match_all('/\((?:[^()\\\\]|\\\\.)*\)|<[0-9A-Fa-f\s]+>/s', $operand, $parts);
            foreach ($parts[0] as $part) $text .= decodePdfString($part, $cmap);
        } else {
            $text .= decodePdfString($operand, $cmap);
        }
        $text .= ' ';
    }
    $text .= "\n";
}

return trim(preg_replace('/[ \t]+/u', ' ', $text) ?? '');
PDF ที่เป็นภาพสแกน หรือเข้ารหัสไว้ จะดึงข้อความไม่ได้ (ได้ข้อความว่าง) ผลคือไม่มีคำสำคัญจาก Resume แต่ทักษะในโปรไฟล์ยังใช้จับคู่ได้ตามปกติ

8. ระบบจับคู่งาน (หัวใจของโปรเจกต์)

คำนวณคะแนนอย่างไร ใช้คะแนนที่ไหน และคำนวณใหม่เมื่อไหร่

แนวคิดและสูตร

ระบบเทียบ “ชุดทักษะ” สองชุด:

  • A = ทักษะของผู้หางาน = ทักษะที่กรอกในโปรไฟล์ + คำสำคัญที่พบใน Resume (seekerSkillSet)
  • B = ทักษะที่งานต้องการ = ทักษะที่บริษัทระบุ ถ้าไม่ได้ระบุ ใช้คำสำคัญที่พบในรายละเอียดงานและคุณสมบัติแทน (jobSkillSet)
คะแนน = |A ∩ B| ÷ |B| × 100 จำนวนทักษะที่ตรงกัน หารด้วย จำนวนทักษะที่งานต้องการ · งานที่ไม่ระบุทักษะเลยได้ 50 · ผู้หางานที่ไม่มีทักษะเลยได้ 0
ทำไมไม่ใช้สูตร Jaccard สูตรเดิมในโค้ดคือ |A ∩ B| ÷ |A ∪ B| ซึ่งทำให้คนที่มีทักษะมากกว่าที่งานต้องการได้คะแนนลดลง (ตัวหารใหญ่ขึ้น) ทั้งที่ควรเหมาะกับงานมากกว่า สูตรปัจจุบันหารด้วยทักษะที่งานต้องการอย่างเดียว คนที่มีครบตามที่งานต้องการจึงได้ 100% เสมอ
ผังการทำงานของระบบจับคู่
ภาพที่ 3 ผังการจับคู่ (ไฟล์ docs/matching_flow.svg ที่ใช้ในรายงาน)

skill_matcher.php — ทำคำให้เป็นมาตรฐาน แล้วคิดคะแนน

ก่อนเทียบ ทุกทักษะผ่าน normalizeSkill(): ตัดช่องว่าง ทำเป็นตัวพิมพ์เล็ก แล้วแปลงคำพ้องความหมาย/คำย่อเป็นคำมาตรฐานเดียวกัน เช่น “JS” → “javascript”, “React.js” → “react”, “บาริสต้า” → “barista”

jobmatch_api/skill_matcher.phpบรรทัด 8–14พจนานุกรมคำพ้อง (ตัดมาบางส่วน) ซ้าย = คำที่ผู้ใช้มักพิมพ์ ขวา = คำมาตรฐาน
function normalizeSkill($skill) {
    // พจนานุกรมคำพ้องความหมาย (ซ้ายคือคำที่มักจะพิมพ์มา -> ขวาคือคำมาตรฐาน)
    $synonyms = [
        // JavaScript Ecosystem
        'js' => 'javascript',
        'react.js' => 'react',
        'reactjs' => 'react',
jobmatch_api/skill_matcher.phpบรรทัด 71–93
    $clean = mb_strtolower(trim($skill));

    // เช็คว่ามีในพจนานุกรมหรือไม่ ถ้ามีให้แปลงเป็นคำมาตรฐาน
    if (isset($synonyms[$clean])) {
        return $synonyms[$clean];
    }

    return $clean;
}

/**
 * แปลง array ทักษะทั้งหมดให้เป็นคำมาตรฐาน
 */
function normalizeSkillsArray($skillsArray) {
    $normalized = [];
    foreach ($skillsArray as $skill) {
        $clean = normalizeSkill($skill);
        if ($clean !== '') {
            $normalized[] = $clean;
        }
    }
    return array_values(array_unique($normalized)); // ตัดตัวซ้ำ
}
jobmatch_api/skill_matcher.phpบรรทัด 99–115หัวใจของระบบ: array_intersect หาทักษะที่ตรงกัน แล้วหารด้วยจำนวนทักษะที่งานต้องการ
function calculateMatchScore($seekerSkillsString, $jobSkillsString) {
    $jobSkills = normalizeSkillsArray(explode(',', (string) $jobSkillsString));
    if (empty($jobSkills)) {
        return 50.0;
    }

    $seekerSkills = normalizeSkillsArray(explode(',', (string) $seekerSkillsString));
    if (empty($seekerSkills)) {
        return 0.0;
    }

    // คะแนน = ทักษะที่ตรงกัน / ทักษะที่งานต้องการ (|A ∩ B| / |B|)
    // ผู้หางานที่มีทักษะครบตามที่งานต้องการได้ 100% ทักษะอื่นที่มีเกินมาไม่ทำให้คะแนนลดลง
    // (สูตร Jaccard เดิม: count($intersect) / count(array_unique(array_merge($seekerSkills, $jobSkills))))
    $intersect = array_intersect($seekerSkills, $jobSkills);
    return round(count($intersect) / count($jobSkills) * 100, 2);
}

ตัวอย่างการคำนวณ

ขั้นตัวอย่างที่ 1ตัวอย่างที่ 2 (คำพ้อง)
งานต้องการ (B)PHP, MySQL, HTML, CSSJavaScript, React
ผู้หางานมี (A)โปรไฟล์: php, Html, Flutter + จาก Resume: MySQLJS, React.js
หลัง normalizeB = {php, mysql, html, css}
A = {php, html, flutter, mysql}
B = {javascript, react}
A = {javascript, react}
A ∩ B{php, html, mysql} = 3 ทักษะ{javascript, react} = 2 ทักษะ
คะแนน3 ÷ 4 × 100 = 75%2 ÷ 2 × 100 = 100%
ระดับ (เกณฑ์เริ่มต้น)≥ 70 → เหมาะสมสูงมาก≥ 70 → เหมาะสมสูงมาก
ผลที่เกิดขึ้นอยู่ใน “งานแนะนำ” (≥ 50), ได้แจ้งเตือนตอนโพสต์งาน (≥ 70), ยังไม่ติดป้ายผู้สมัครแนะนำ (ต้อง ≥ 80)ครบทุกเกณฑ์ รวมถึงติดป้ายผู้สมัครแนะนำ

matching_service.php — เกณฑ์ ระดับ และชุดทักษะ

ไฟล์นี้เป็นฟังก์ชันกลางที่ทั้ง API และหน้าแอดมิน require ไปใช้ เกณฑ์ทั้ง 5 ค่าอ่านจากตาราง system_settings (แอดมินแก้ได้ในหน้า “เกณฑ์การจับคู่”) ถ้ายังไม่มีในตาราง ใช้ค่าเริ่มต้นในโค้ด

jobmatch_api/matching_service.phpบรรทัด 13–51
const VISIBLE_JOB_STATUSES = ['open', 'approved'];

// ค่าเริ่มต้น ใช้เมื่อยังไม่ได้รัน migration หรือไม่มีค่าในตาราง
const DEFAULT_MATCH_SETTINGS = [
    'level_very_high'         => 70,
    'level_good'              => 50,
    'level_medium'            => 40,
    'notify_threshold'        => 70,
    'top_candidate_threshold' => 80,
];

function getMatchSettings(PDO $pdo): array {
    static $cache = null;
    if ($cache !== null) return $cache;

    $settings = DEFAULT_MATCH_SETTINGS;
    try {
        $rows = $pdo->query('SELECT setting_key, setting_value FROM system_settings')->fetchAll(PDO::FETCH_KEY_PAIR);
        foreach ($rows as $key => $value) {
            if (array_key_exists($key, $settings)) {
                $settings[$key] = (float) $value;
            }
        }
    } catch (PDOException $e) {
        // ตารางยังไม่มี ใช้ค่าเริ่มต้น
    }
    return $cache = $settings;
}

/**
 * แปลคะแนนเป็นระดับความเหมาะสม (ตารางที่ 2.1)
 * คืนค่า ['key' => very_high|good|medium|low, 'label' => ข้อความไทย]
 */
function getMatchLevel(float $score, array $settings): array {
    if ($score >= $settings['level_very_high']) return ['key' => 'very_high', 'label' => 'เหมาะสมสูงมาก'];
    if ($score >= $settings['level_good'])      return ['key' => 'good',      'label' => 'เหมาะสมดี'];
    if ($score >= $settings['level_medium'])    return ['key' => 'medium',    'label' => 'เหมาะสมปานกลาง'];
    return ['key' => 'low', 'label' => 'เหมาะสมน้อย'];
}
ค่าตั้งค่าเริ่มต้นใช้ที่ไหน
level_very_high / level_good / level_medium70 / 50 / 40แบ่งระดับ: เหมาะสมสูงมาก / ดี / ปานกลาง / ต่ำกว่า 40 = น้อย (ตารางที่ 2.1 ในรายงาน)
level_good50get_jobs.php: คะแนน ≥ ค่านี้ = is_recommended แสดงใน “งานแนะนำ” หน้าแรกผู้หางาน
notify_threshold70post_job.php → notifyRecommendedSeekers: ส่งแจ้งเตือนงานใหม่ให้ผู้หางานที่คะแนนถึง
top_candidate_threshold80get_applications.php: ติดป้าย “ผู้สมัครแนะนำ” ให้ผู้ว่าจ้างเห็นก่อน

ชุดทักษะ A และ B ประกอบขึ้นจากฟังก์ชันด้านล่าง ชุดของงานใช้ทักษะที่บริษัทระบุก่อน และใช้คำจากรายละเอียดงานเฉพาะเมื่อไม่ได้ระบุ เพราะคำทั่วไปในรายละเอียด (เช่น “ความรับผิดชอบ”) จะไปเพิ่มตัวหาร ทำให้คะแนนต่ำโดยไม่จำเป็น

jobmatch_api/matching_service.phpบรรทัด 185–204
/**
 * ชุดทักษะของงาน (B) = ทักษะที่ผู้ว่าจ้างระบุ
 * ถ้าผู้ว่าจ้างไม่ได้ระบุทักษะ จึงใช้คำสำคัญที่พบในรายละเอียดงานและคุณสมบัติผู้สมัครแทน (Keyword Matching)
 * ไม่รวมสองส่วนเข้าด้วยกัน เพราะคำในรายละเอียดงาน (เช่น "ความรับผิดชอบ") ทำให้คะแนนต่ำลงทั้งที่ผู้ว่าจ้างไม่ได้ต้องการ
 */
function jobSkillSet(PDO $pdo, array $job): string {
    $skills = array_values(array_filter(array_map('trim', explode(',', $job['required_skills'] ?? ''))));
    if ($skills) {
        return implode(', ', array_unique($skills));
    }
    $fromText = extractKeywordsFromText($pdo, ($job['job_description'] ?? '') . "\n" . ($job['qualifications'] ?? ''));
    return implode(', ', $fromText);
}

/**
 * ชุดทักษะของผู้หางาน (A) = ทักษะในโปรไฟล์ + คำสำคัญที่พบใน Resume
 */
function seekerSkillSet(array $seeker): string {
    return trim(($seeker['skills'] ?? '') . ', ' . ($seeker['resume_keywords'] ?? ''), ', ');
}

การหาคำสำคัญในข้อความอิสระ (Keyword Matching)

extractKeywordsFromText() ไล่ทุกคำในคลังคำสำคัญแล้วดูว่าปรากฏในข้อความ (Resume หรือรายละเอียดงาน) ไหม แยกวิธีตามภาษา:

  • คำภาษาอังกฤษ ต้องเป็นคำทั้งคำ เช่น “Git” ต้องไม่นับเมื่อเจอในคำว่า “digit” จึงใช้ lookbehind/lookahead (?<![A-Za-z0-9]) … (?![A-Za-z0-9]) และยอมให้มีช่องว่างแทรกระหว่างตัวอักษร (\s*) เพราะ PDF บางไฟล์แยกตัวอักษรเป็นช่วง ๆ
  • คำภาษาไทย ไม่มีการเว้นวรรคระหว่างคำ จึงลบช่องว่างทั้งหมดก่อนแล้วค้นแบบ substring และรวมสระอำที่ PDF มักเก็บแยกเป็น นิคหิต + สระอา กลับเป็นสระอำ
jobmatch_api/matching_service.phpบรรทัด 165–183
function extractKeywordsFromText(PDO $pdo, string $text): array {
    if (trim($text) === '') return [];
    // PDF มักเก็บสระอำเป็น นิคหิต + สระอา (บางครั้งมีช่องว่างคั่น) -> ตัดช่องว่างก่อนแล้วรวมกลับเป็นสระอำ
    $compactThai = str_replace("\u{0E4D}\u{0E32}", "\u{0E33}", preg_replace('/\s+/u', '', $text));

    $found = [];
    foreach (getKeywordList($pdo) as $keyword) {
        $kw = trim($keyword);
        if ($kw === '') continue;
        if (preg_match('/^[\x20-\x7E]+$/', $kw)) {
            $letters = array_map(fn($ch) => preg_quote($ch, '/'), mb_str_split(str_replace(' ', '', $kw)));
            $pattern = '/(?<![A-Za-z0-9])' . implode('\s*', $letters) . '(?![A-Za-z0-9])/iu';
            if (preg_match($pattern, $text)) $found[] = $kw;
        } elseif (mb_strpos($compactThai, preg_replace('/\s+/u', '', $kw)) !== false) {
            $found[] = $kw;
        }
    }
    return $found;
}

คะแนนฝั่งผู้หางาน: get_jobs.php

ทุกครั้งที่ผู้หางานเปิดหน้าแรก/ค้นหา endpoint นี้คำนวณคะแนนสดให้ทุกงาน พร้อมแยกทักษะที่ตรงและที่ขาด (แสดงในหน้ารายละเอียดงาน) และระยะทาง แล้วเรียงงานจากคะแนนมากไปน้อย

jobmatch_api/get_jobs.phpบรรทัด 55–83
foreach ($stmt->fetchAll() as $job) {
    $jobSkillsOriginal = array_values(array_filter(array_map('trim', explode(',', $job['required_skills'] ?? '')), fn($s) => $s !== ''));
    // ชุดทักษะของงาน = ทักษะที่ระบุ + คีย์เวิร์ดที่พบในรายละเอียดงาน/คุณสมบัติ (Keyword Matching)
    $jobSkillSetString = jobSkillSet($pdo, $job);
    $jobSkillsAll = [];
    foreach (array_filter(array_map('trim', explode(',', $jobSkillSetString))) as $skill) {
        $jobSkillsAll[normalizeSkill($skill)] ??= $skill; // ตัดคำซ้ำ
    }

    // ผู้ใช้ที่ไม่ได้ล็อกอินเป็นผู้หางาน (userId = 0) ไม่มีคะแนน
    $matchScore = $userId > 0 ? calculateMatchScore($seekerSkillsString, $jobSkillSetString) : 0.0;
    $level = getMatchLevel($matchScore, $settings);

    // พิกัดงาน: ใช้ของประกาศงานก่อน ถ้าไม่มีใช้พิกัดบริษัท
    $jobLat = $job['latitude'] ?? $job['company_lat'];
    $jobLng = $job['longitude'] ?? $job['company_lng'];
    $distance = ($originLat !== null && $jobLat !== null && $jobLng !== null)
        ? round(distanceKm($originLat, $originLng, (float) $jobLat, (float) $jobLng), 1)
        : null;

    $matchedSkills = [];
    $missingSkills = [];
    foreach ($jobSkillsAll as $canonical => $skill) {
        if (in_array($canonical, $userSkillsCanonical, true)) {
            $matchedSkills[] = $skill;
        } else {
            $missingSkills[] = $skill;
        }
    }
jobmatch_api/get_jobs.phpบรรทัด 122–123เรียงด้วย spaceship operator <=> บน array [คะแนน, job_id]: คะแนนมากก่อน ถ้าเท่ากันงานใหม่ (id มากกว่า) ก่อน
// จัดอันดับตามคะแนนความเหมาะสมจากมากไปน้อย (ขอบเขต 1.3.4.4) งานใหม่กว่าขึ้นก่อนเมื่อคะแนนเท่ากัน
usort($result, fn($a, $b) => [$b['match_score'], $b['job_id']] <=> [$a['match_score'], $a['job_id']]);

คะแนนฝั่งผู้ว่าจ้าง: จัดอันดับผู้สมัคร

get_applications.php (โหมดตามงาน/ตามบริษัท) คำนวณคะแนนของผู้สมัครแต่ละคนสดจากทักษะปัจจุบัน ใส่ระดับ ป้ายผู้สมัครแนะนำ แล้วจัดอันดับภายในแต่ละงาน (1 = คะแนนสูงสุด)

jobmatch_api/get_applications.phpบรรทัด 135–173
// คำนวณคะแนนสดจากทักษะปัจจุบัน (ไม่ใช้ตาราง matching ซึ่งคำนวณไว้แค่ตอนโพสต์งาน
// ทำให้ผู้หางานที่สมัครสมาชิก/แก้ทักษะทีหลัง หรืองานที่ถูกแก้ไข ได้คะแนนผิด)
// แล้วจัดอันดับผู้สมัครภายในแต่ละงาน (1 = คะแนนสูงสุด)
if ($seekerId === 0) {
    $settings = getMatchSettings($pdo);
    foreach ($rows as &$row) {
        // A = ทักษะในโปรไฟล์ + คีย์เวิร์ดจาก Resume, B = ทักษะที่ระบุ + คีย์เวิร์ดจากรายละเอียดงาน
        $row['match_score'] = calculateMatchScore(
            seekerSkillSet($row),
            jobSkillSet($pdo, [
                'required_skills' => $row['required_skills'] ?? '',
                'job_description' => $row['job_description_for_match'] ?? '',
                'qualifications'  => $row['qualifications_for_match'] ?? '',
            ])
        );
        $level = getMatchLevel($row['match_score'], $settings);
        $row['match_level'] = $level['key'];
        $row['match_level_label'] = $level['label'];
        // ผู้สมัครที่คะแนนถึงเกณฑ์ให้ผู้ว่าจ้างเห็นเป็นลำดับแรก (เอกสารบทที่ 2: 80% ขึ้นไป)
        $row['is_top_candidate'] = $row['match_score'] >= $settings['top_candidate_threshold'];
    }
    unset($row);

    $byJob = [];
    foreach ($rows as $i => $row) {
        $byJob[$row['job_id']][] = $i;
    }
    foreach ($byJob as $indexes) {
        usort($indexes, fn($a, $b) => $rows[$b]['match_score'] <=> $rows[$a]['match_score']);
        foreach ($indexes as $rank => $i) {
            $rows[$i]['ranking'] = $rank + 1;
        }
    }

    if ($jobId > 0) {
        // เรียงตามคะแนน แล้วตามวันสมัครล่าสุด (usort ของ PHP 8 เป็น stable sort)
        usort($rows, fn($a, $b) => $b['match_score'] <=> $a['match_score']);
    }
}

ตาราง matching และการคำนวณใหม่

นอกจากคำนวณสด ระบบยังเก็บผลไว้ในตาราง matching (ผู้หางาน × งาน, คะแนน, อันดับ) ใช้แจ้งเตือนงานแนะนำ และสรุปจำนวนตามระดับในหน้าแอดมิน ฟังก์ชันคำนวณใหม่จะลบผลเก่าของงานแล้วบันทึกใหม่ทั้งหมด เก็บเฉพาะคู่ที่คะแนนมากกว่า 0

jobmatch_api/matching_service.phpบรรทัด 61–94
function recalculateMatchingForJob(PDO $pdo, int $jobId): array {
    $job = $pdo->prepare('SELECT required_skills, job_description, qualifications FROM job_posting WHERE job_id = ?');
    $job->execute([$jobId]);
    $jobRow = $job->fetch();
    if (!$jobRow) return [];
    $jobSkills = jobSkillSet($pdo, $jobRow);

    $scores = [];
    foreach ($pdo->query('SELECT seeker_id, user_id, skills, resume_keywords FROM job_seeker')->fetchAll() as $s) {
        $score = calculateMatchScore(seekerSkillSet($s), $jobSkills);
        if ($score > 0) {
            $scores[] = ['seeker_id' => (int) $s['seeker_id'], 'user_id' => (int) $s['user_id'], 'score' => $score];
        }
    }
    usort($scores, fn($a, $b) => $b['score'] <=> $a['score']);

    $pdo->prepare('DELETE FROM matching WHERE job_id = ?')->execute([$jobId]);
    $insert = $pdo->prepare('INSERT INTO matching (seeker_id, job_id, ranking, totalScore) VALUES (?, ?, ?, ?)');
    foreach ($scores as $rank => $row) {
        $insert->execute([$row['seeker_id'], $jobId, $rank + 1, $row['score']]);
    }
    return $scores;
}

/**
 * คำนวณตาราง matching ใหม่ทั้งหมด (เมื่อผู้หางานแก้ทักษะ / สมัครสมาชิก / Admin แก้ Keyword)
 * ต้องคำนวณทุกงาน เพราะอันดับของงานเปลี่ยนตามคะแนนของผู้หางานทุกคน
 */
function recalculateAllMatching(PDO $pdo): void {
    $jobIds = $pdo->query('SELECT job_id FROM job_posting')->fetchAll(PDO::FETCH_COLUMN);
    foreach ($jobIds as $jobId) {
        recalculateMatchingForJob($pdo, (int) $jobId);
    }
}
เหตุการณ์ไฟล์คำนวณใหม่
บริษัทโพสต์งานใหม่post_job.phpเฉพาะงานนั้น + ส่งแจ้งเตือนงานแนะนำ
บริษัทแก้ไขงานupdate_job.phpเฉพาะงานนั้น
ผู้หางานสมัครสมาชิกregister_seeker.phpทุกงาน
ผู้หางานแก้ทักษะupdate_seeker.phpทุกงาน
ผู้หางานอัปโหลด Resumeupload_resume.phpทุกงาน
แอดมินเพิ่ม/แก้/ลบคำสำคัญkeywords.php (แอดมิน)ทุกงาน
แอดมินกดปุ่มคำนวณใหม่matching.php (แอดมิน)ทุกงาน
ข้อสังเกตเรื่องประสิทธิภาพ “คำนวณใหม่ทุกงาน” = (จำนวนงาน × จำนวนผู้หางาน) รอบ ข้อมูลระดับโปรเจกต์นักศึกษาทำงานได้ทันที แต่ถ้าข้อมูลหลักหมื่นขึ้นไปควรคำนวณเฉพาะส่วนที่เปลี่ยน หรือย้ายไปทำเบื้องหลัง (background job)

ส่งแจ้งเตือนงานแนะนำ

เมื่อโพสต์งาน notifyRecommendedSeekers() ไล่ผู้หางานจากคะแนนมากไปน้อย หยุดทันทีเมื่อคะแนนต่ำกว่าเกณฑ์ (break ได้เพราะเรียงแล้ว) และกันส่งซ้ำด้วย type = recommend_job_<job_id> ค่า type นี้เองที่แอปใช้รู้ว่าแตะแจ้งเตือนแล้วต้องเปิดงานไหน

jobmatch_api/matching_service.phpบรรทัด 101–134
function notifyRecommendedSeekers(PDO $pdo, int $jobId): int {
    $settings = getMatchSettings($pdo);
    $job = $pdo->prepare('
        SELECT j.job_title, j.status, e.company_name
        FROM job_posting j LEFT JOIN employer e ON j.employer_id = e.employer_id
        WHERE j.job_id = ?
    ');
    $job->execute([$jobId]);
    $jobRow = $job->fetch();
    if (!$jobRow || !isJobVisible($jobRow['status'])) return 0;

    $type = 'recommend_job_' . $jobId;
    $already = $pdo->prepare('SELECT COUNT(*) FROM notification WHERE user_id = ? AND type = ?');
    $insert = $pdo->prepare('INSERT INTO notification (user_id, title, content, type) VALUES (?, ?, ?, ?)');

    $sent = 0;
    foreach (recalculateMatchingForJob($pdo, $jobId) as $row) {
        if ($row['score'] < $settings['notify_threshold']) break; // เรียงจากมากไปน้อยแล้ว
        $already->execute([$row['user_id'], $type]);
        if ($already->fetchColumn() > 0) continue;

        $level = getMatchLevel($row['score'], $settings);
        $content = sprintf(
            'ตำแหน่ง %s%s ตรงกับทักษะของคุณ %s%% (%s)',
            $jobRow['job_title'],
            $jobRow['company_name'] ? ' ที่ ' . $jobRow['company_name'] : '',
            rtrim(rtrim(number_format($row['score'], 2), '0'), '.'),
            $level['label']
        );
        $insert->execute([$row['user_id'], 'งานแนะนำสำหรับคุณ', $content, $type]);
        $sent++;
    }
    return $sent;
}

ระยะทาง: สูตร Haversine

ระยะ “ห่าง x กม.” คำนวณจากพิกัดผู้หางาน (GPS ปัจจุบัน หรือพิกัดจากที่อยู่ในโปรไฟล์) กับพิกัดงาน (ถ้างานไม่มี ใช้พิกัดบริษัท) ด้วยสูตร Haversine ที่คิดความโค้งของโลก (รัศมี 6,371 กม.) ผลคือระยะเส้นตรง ไม่ใช่ระยะทางตามถนน

jobmatch_api/matching_service.phpบรรทัด 136–145
/**
 * ระยะทางเป็นกิโลเมตรระหว่างสองพิกัด (สูตร Haversine) ใช้แสดง "ห่าง x กม." และกรองงานใกล้ฉัน
 */
function distanceKm(float $lat1, float $lng1, float $lat2, float $lng2): float {
    $earthRadius = 6371.0;
    $dLat = deg2rad($lat2 - $lat1);
    $dLng = deg2rad($lng2 - $lng1);
    $a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2;
    return $earthRadius * 2 * atan2(sqrt($a), sqrt(1 - $a));
}

9. เว็บผู้ดูแลระบบ (Admin)

หน้าเว็บ PHP แบบสร้างหน้าที่เซิร์ฟเวอร์ ใช้ session และฟอร์ม POST

เว็บแอดมินต่างจาก API ตรงที่ PHP สร้างหน้า HTML ทั้งหน้าส่งให้เบราว์เซอร์ (ไม่ได้ตอบ JSON) เปิดที่ http://localhost/jobmatch_admin/ ทุกหน้า (ยกเว้นหน้าล็อกอิน) เขียนเรียง 4 ส่วนเหมือนกัน:

  1. require ไฟล์กลาง + requireLogin() ยังไม่ล็อกอินจะถูกส่งกลับไป index.php
  2. จัดการคำสั่ง (ถ้าเป็น POST) ปุ่มต่าง ๆ ในหน้าเป็นฟอร์มที่ส่ง action กลับมาที่หน้าเดิม ใช้ switch แยกคำสั่ง ทำงานกับฐานข้อมูล แล้วบันทึกประวัติด้วย logAction()
  3. อ่านตัวกรอง/คำค้น/หน้า จาก $_GET แล้ว query ข้อมูลที่จะแสดง
  4. แสดงผล include header.php (เมนูด้านข้าง) → HTML ตาราง/กราฟ → footer.php (JavaScript ช่วยเหลือ)
jobmatch_admin/users.phpบรรทัด 7–54ตัวอย่างขั้น 1–2 จาก users.php: ระงับ / ยกเลิก / เปิดใช้บัญชี แล้วบันทึกประวัติ
require_once __DIR__ . '/config/database.php';
require_once __DIR__ . '/includes/auth.php';
requireLogin();

$pdo = getDBConnection();

// Handle actions
$message = '';
$messageType = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $action = $_POST['action'] ?? '';
    // รองรับทั้ง member_id และ user_id
    $memberId = intval($_POST['member_id'] ?? $_POST['user_id'] ?? 0);

    if ($memberId > 0) {
        try {
            switch ($action) {
                case 'suspend':
                    $stmt = $pdo->prepare("UPDATE member SET status = 'suspended' WHERE user_id = ?");
                    $stmt->execute([$memberId]);
                    logAction($pdo, 'suspend_user', 'member', $memberId, 'ระงับบัญชีผู้ใช้');
                    $message = 'ระงับบัญชีผู้ใช้สำเร็จ';
                    $messageType = 'warning';
                    break;

                case 'ban':
                    $stmt = $pdo->prepare("UPDATE member SET status = 'banned' WHERE user_id = ?");
                    $stmt->execute([$memberId]);
                    logAction($pdo, 'ban_user', 'member', $memberId, 'ยกเลิกบัญชีผู้ใช้');
                    $message = 'ยกเลิกบัญชีผู้ใช้สำเร็จ';
                    $messageType = 'danger';
                    break;

                case 'activate':
                    $stmt = $pdo->prepare("UPDATE member SET status = 'active' WHERE user_id = ?");
                    $stmt->execute([$memberId]);
                    logAction($pdo, 'activate_user', 'member', $memberId, 'เปิดใช้งานบัญชีผู้ใช้');
                    $message = 'เปิดใช้งานบัญชีผู้ใช้สำเร็จ';
                    $messageType = 'success';
                    break;
            }
        } catch (Exception $e) {
            $message = 'เกิดข้อผิดพลาด: ' . $e->getMessage();
            $messageType = 'danger';
        }
    }
}

ล็อกอินและ session

index.php — หน้าเข้าสู่ระบบ

jobmatch_admin/index.phpแอดมิน

บัญชีผู้ดูแลอยู่ในตาราง member เดียวกับผู้ใช้แอป แต่ role = 'admin' พิมพ์ได้ทั้งอีเมลเต็มหรือแค่ส่วนหน้า @ ตรวจรหัสแบบเดียวกับ login.php ของแอป และเมื่อผ่านจะเรียก session_regenerate_id(true) สร้างรหัส session ใหม่ ป้องกันการโจมตีแบบ session fixation (ผู้ไม่หวังดีฝังรหัส session ไว้ล่วงหน้า)

jobmatch_admin/index.phpบรรทัด 29–58
// บัญชีผู้ดูแลระบบอยู่ในตาราง member (role = 'admin') ล็อกอินด้วยอีเมลเต็ม หรือส่วนหน้า @ ก็ได้ (เช่น admin)
$stmt = $pdo->prepare("SELECT * FROM member WHERE role = 'admin' AND status = 'active' AND (email = ? OR SUBSTRING_INDEX(email, '@', 1) = ?)");
$stmt->execute([$username, $username]);
$admin = $stmt->fetch();

// ตรวจรหัสผ่านกับค่า bcrypt ที่เก็บไว้ ถ้ายังเป็นรหัสแบบข้อความธรรมดา (ข้อมูลเก่า)
// ให้เทียบตรงๆ แล้วแปลงเป็น bcrypt ทันที เหมือน login.php ของแอป
$isValid = false;
if ($admin) {
    $stored = (string) $admin['password'];
    if (password_get_info($stored)['algo'] !== null) {
        $isValid = password_verify($password, $stored);
    } elseif (hash_equals($stored, $password)) {
        $isValid = true;
        $upd = $pdo->prepare('UPDATE member SET password = ? WHERE user_id = ?');
        $upd->execute([password_hash($password, PASSWORD_DEFAULT), $admin['user_id']]);
    }
}

if ($isValid) {
    // ป้องกัน session fixation: สร้าง session id ใหม่หลัง login สำเร็จ
    session_regenerate_id(true);

    $_SESSION['admin_id'] = $admin['user_id'];
    $_SESSION['admin_name'] = $admin['name'];
    $_SESSION['admin_username'] = $admin['email'];

    session_write_close(); // Ensure session is saved before redirect
    header('Location: dashboard.php');
    exit;

includes/session_config.php และ includes/auth.php

jobmatch_admin/includes/แอดมิน

session_config.php ใช้แทนการเรียก session_start() ตรง ๆ เพื่อกำหนดโฟลเดอร์เก็บ session ให้แน่นอน และตั้ง cookie แบบ httponly (JavaScript อ่าน cookie ไม่ได้ ลดความเสี่ยง XSS) กับ samesite=Lax

jobmatch_admin/includes/session_config.phpบรรทัด 20–30
// ตั้งค่า cookie ให้ path ครอบคลุมทั้ง root และ /api/ (กัน session หายเวลาข้ามโฟลเดอร์)
session_set_cookie_params([
    'lifetime' => 0,
    'path'     => '/',
    'domain'   => '',
    'secure'   => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
    'httponly' => true,
    'samesite' => 'Lax',
]);

session_start();

auth.php มีฟังก์ชันตรวจล็อกอิน และ logAction() ที่บันทึกทุกการกระทำของแอดมินลงตาราง system_logs (ใคร ทำอะไร กับข้อมูลไหน จาก IP ไหน) ซึ่งหน้า “รายงาน” นำมาแสดงเป็นภาษาไทย

jobmatch_admin/includes/auth.phpบรรทัด 11–45
function requireLogin() {
    if (!isset($_SESSION['admin_id'])) {
        header('Location: index.php');
        exit;
    }
}

function isLoggedIn() {
    return isset($_SESSION['admin_id']);
}

function getAdminName() {
    return $_SESSION['admin_name'] ?? 'Administrator';
}

function getAdminId() {
    return $_SESSION['admin_id'] ?? null;
}

// Log admin actions
function logAction($pdo, $action, $targetType = null, $targetId = null, $details = null) {
    try {
        $stmt = $pdo->prepare("INSERT INTO system_logs (admin_id, action, target_type, target_id, details, ip_address) VALUES (?, ?, ?, ?, ?, ?)");
        $stmt->execute([
            getAdminId(),
            $action,
            $targetType,
            $targetId,
            $details,
            $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'
        ]);
    } catch (Exception $e) {
        // Silently fail - logging should not break the application
    }
}

logout.php ล้างข้อมูลใน session ลบ cookie ของ session ฝั่งเบราว์เซอร์ ทำลาย session แล้วกลับไปหน้าเข้าสู่ระบบ

โครงหน้า: header, footer, css

includes/header.php และ includes/footer.php

jobmatch_admin/includes/header.php · footer.php · assets/css/style.cssแอดมิน

header.php เรียก requireLogin() ให้อีกชั้น หาว่ากำลังอยู่หน้าไหน ($currentPage) เพื่อไฮไลต์เมนู และเตรียม “กิจกรรมล่าสุด” ของปุ่มกระดิ่ง (สมาชิกใหม่ 6 คน + ประกาศงานใหม่ 6 งาน เรียงตามเวลา เหลือ 8 รายการ) โหลด Chart.js จาก CDN ไว้ให้ทุกหน้าใช้วาดกราฟ

jobmatch_admin/includes/header.phpบรรทัด 25–56
// กิจกรรมล่าสุดสำหรับปุ่มแจ้งเตือน: สมาชิกสมัครใหม่ + ประกาศงานใหม่
// "ใหม่" = id มากกว่าที่ผู้ดูแลเคยเปิดดูแล้ว (จำไว้ใน localStorage ฝั่งเบราว์เซอร์)
$adminActivity = [];
try {
    $roleLabels = ['seeker' => 'ผู้หางาน', 'employer' => 'ผู้ว่าจ้าง'];
    $rows = $pdo->query("SELECT user_id, name, email, role, created_at FROM member
                         WHERE role IN ('seeker','employer') ORDER BY user_id DESC LIMIT 6")->fetchAll();
    foreach ($rows as $r) {
        $adminActivity[] = [
            'type'  => 'member', 'id' => (int) $r['user_id'], 'icon' => 'fa-user-plus',
            'title' => 'สมาชิกใหม่: ' . $r['name'],
            'sub'   => ($roleLabels[$r['role']] ?? $r['role']) . ' · ' . date('d/m/Y H:i', strtotime($r['created_at'])),
            'url'   => 'users.php?search=' . urlencode($r['email']),
            'sort'  => strtotime($r['created_at']),
        ];
    }
    $rows = $pdo->query("SELECT j.job_id, j.job_title, j.post_date, e.company_name FROM job_posting j
                         LEFT JOIN employer e ON e.employer_id = j.employer_id
                         ORDER BY j.job_id DESC LIMIT 6")->fetchAll();
    foreach ($rows as $r) {
        $adminActivity[] = [
            'type'  => 'job', 'id' => (int) $r['job_id'], 'icon' => 'fa-briefcase',
            'title' => 'ประกาศงานใหม่: ' . $r['job_title'],
            'sub'   => ($r['company_name'] ?: 'ไม่ระบุบริษัท') . ' · ' . date('d/m/Y', strtotime($r['post_date'])),
            'url'   => 'job_postings.php?search=' . urlencode($r['job_title']),
            // post_date ไม่มีเวลา: ใช้สิ้นวันเพื่อให้งานของวันเดียวกันอยู่ใกล้สมาชิกใหม่ของวันนั้น
            'sort'  => strtotime($r['post_date'] . ' 23:59:59'),
        ];
    }
    usort($adminActivity, fn($a, $b) => $b['sort'] <=> $a['sort']);
    $adminActivity = array_slice($adminActivity, 0, 8);
} catch (Exception $e) { $adminActivity = []; }

footer.php มี JavaScript ที่ทุกหน้าใช้: เปิด/ปิดเมนู, toast แจ้งผล, modal, ส่งออก CSV, ฟังก์ชัน apiCall() และตรรกะจุดแดงของกระดิ่ง ซึ่งจำ id ล่าสุดที่แอดมินเคยเห็นไว้ใน localStorage ของเบราว์เซอร์ (ไม่ได้เก็บในฐานข้อมูล)

jobmatch_admin/includes/footer.phpบรรทัด 19–59
// แจ้งเตือนกิจกรรมล่าสุด: จำ id ล่าสุดที่เคยเห็นแยกตามประเภท เพื่อแสดงจุดแดงเมื่อมีรายการใหม่
const NOTI_KEY = 'jobmatchAdminNotiSeen';
function readNotiSeen() {
    try { return JSON.parse(localStorage.getItem(NOTI_KEY)) || {}; } catch (e) { return {}; }
}
function refreshNotiState() {
    const seen = readNotiSeen();
    let hasNew = false;
    document.querySelectorAll('.noti-item').forEach(item => {
        const isNew = Number(item.dataset.id) > (seen[item.dataset.type] || 0);
        item.classList.toggle('is-new', isNew);
        if (isNew) hasNew = true;
    });
    const dot = document.getElementById('notiDot');
    if (dot) dot.hidden = !hasNew;
}
function toggleNotiPanel(e) {
    e.stopPropagation();
    const panel = document.getElementById('notiPanel');
    const opening = !panel.classList.contains('open');
    panel.classList.toggle('open', opening);
    if (opening) {
        // เปิดดูแล้ว = ถือว่าเห็นทุกรายการ แต่ยังคงไฮไลต์ "ใหม่" ไว้จนกว่าจะปิดแผง
        const seen = readNotiSeen();
        document.querySelectorAll('.noti-item').forEach(item => {
            seen[item.dataset.type] = Math.max(seen[item.dataset.type] || 0, Number(item.dataset.id));
        });
        try { localStorage.setItem(NOTI_KEY, JSON.stringify(seen)); } catch (e) {}
        document.getElementById('notiDot').hidden = true;
    } else {
        refreshNotiState();
    }
}
document.addEventListener('click', function(e) {
    const panel = document.getElementById('notiPanel');
    if (panel && panel.classList.contains('open') && !panel.contains(e.target)) {
        panel.classList.remove('open');
        refreshNotiState();
    }
});
refreshNotiState();

style.css (ราว 1,400 บรรทัด) กำหนดหน้าตาทั้งเว็บ: เมนูด้านข้าง การ์ดสถิติ ตาราง ป้ายสถานะ ปุ่ม modal และแผงแจ้งเตือน (.noti-*)

หน้าหลัก 6 หน้า

หน้าแสดงอะไรทำอะไรได้
dashboard.phpจำนวนสมาชิก (ไม่นับแอดมิน) ผู้หางาน ผู้ว่าจ้าง ประกาศงาน ใบสมัคร, สัดส่วนสถานะงาน, สมาชิก/งาน/ใบสมัครล่าสุด 5 รายการ, กราฟการสมัครสมาชิกรายวัน/เดือน/ปีดูภาพรวม
users.phpรายชื่อสมาชิก กรองตามบทบาท/ถูกระงับ ค้นหา แบ่งหน้าละ 15 คนดูรายละเอียด, ระงับ, เปิดใช้, ยกเลิกบัญชี
job_postings.phpประกาศงานทั้งหมดพร้อมชื่อบริษัทและป้ายสถานะค้นหา/กรอง, ดูรายละเอียด, ลบประกาศ
keywords.phpคลังคำสำคัญแยกหมวด พร้อมจำนวนคำต่อหมวดเพิ่มทีละคำ, เพิ่มหลายคำพร้อมกัน, แก้ไข, ลบ — ทุกครั้งที่สำเร็จจะคำนวณการจับคู่ใหม่ทั้งหมด
matching.phpเกณฑ์คะแนน 5 ค่า, จำนวนคู่ในแต่ละระดับ, บริษัทที่ตอบกลับผู้สมัครมากที่สุดแก้เกณฑ์ (มีผลทันที), กดคำนวณผลการจับคู่ใหม่
reports.phpสถิติรวม, กราฟสมาชิก/ประกาศงานตามช่วงเวลา, สถานะงาน, สถิติคำสำคัญ, ประวัติการทำงานของแอดมิน (แปลชื่อการกระทำเป็นภาษาไทย)ส่งออกรายงานเป็น PDF (html2canvas จับภาพหน้า + jsPDF สร้างไฟล์)

matching.php — แก้เกณฑ์การจับคู่

jobmatch_admin/matching.phpแอดมิน

require matching_service.php ของ API มาใช้ตรง ๆ (บรรทัด 10) ตรวจว่าทุกค่าเป็นตัวเลข 0–100 และเรียงลำดับถูกต้อง (ปานกลาง ≤ ดี ≤ สูงมาก) แล้วบันทึกด้วย INSERT … ON DUPLICATE KEY UPDATE คือ “ถ้ายังไม่มีคีย์นี้ให้เพิ่ม ถ้ามีแล้วให้แก้ค่า” ในคำสั่งเดียว

jobmatch_admin/matching.phpบรรทัด 25–59
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save') {
    $values = [];
    $errors = [];
    foreach ($fields as $key => $meta) {
        $v = $_POST[$key] ?? '';
        if (!is_numeric($v) || $v < 0 || $v > 100) {
            $errors[] = $meta['label'] . ' ต้องเป็นตัวเลข 0-100';
        }
        $values[$key] = (float) $v;
    }
    if (!$errors && !($values['level_medium'] <= $values['level_good'] && $values['level_good'] <= $values['level_very_high'])) {
        $errors[] = 'ระดับคะแนนต้องเรียงกัน: ปานกลาง ≤ ดี ≤ สูงมาก';
    }

    if ($errors) {
        $message = implode(' / ', $errors);
        $messageType = 'danger';
    } else {
        $stmt = $pdo->prepare('INSERT INTO system_settings (setting_key, setting_value) VALUES (?, ?)
                               ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)');
        foreach ($values as $key => $value) {
            $stmt->execute([$key, rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.')]);
        }
        logAction($pdo, 'update_matching_settings', 'system_settings', null, json_encode($values, JSON_UNESCAPED_UNICODE));
        $message = 'บันทึกเกณฑ์การจับคู่สำเร็จ ระบบจะใช้เกณฑ์ใหม่ทันที';
        $messageType = 'success';
    }
}

if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'recalculate') {
    recalculateAllMatching($pdo);
    logAction($pdo, 'recalculate_matching', 'matching', null, 'คำนวณผลการจับคู่ใหม่ทั้งหมด');
    $message = 'คำนวณผลการจับคู่ใหม่ทั้งหมดเรียบร้อย';
    $messageType = 'success';
}

เพราะ API อ่านเกณฑ์จากตารางเดียวกันทุกครั้งที่มีคำขอ ค่าใหม่จึงมีผลกับแอปทันที (ระดับความเหมาะสม งานแนะนำ ป้ายผู้สมัครแนะนำ)

api/manage_user.php และ api/manage_keyword.php

jobmatch_admin/api/แอดมิน

endpoint ที่ตอบ JSON สำหรับเรียกแบบ AJAX ผ่าน apiCall() ใน footer.php ตรวจล็อกอินด้วย session เหมือนหน้าอื่น ปัจจุบันหน้า users.php และ keywords.php ใช้ฟอร์ม POST แทน สองไฟล์นี้จึงเป็นทางเลือกที่ยังไม่ได้เรียกใช้

jobmatch_admin/api/manage_user.phpบรรทัด 27–44
$validStatuses = [
    'approve' => 'active',
    'suspend' => 'suspended',
    'ban' => 'banned',
    'activate' => 'active',
];

if (!isset($validStatuses[$action])) {
    echo json_encode(['success' => false, 'message' => 'Invalid action']);
    exit;
}

try {
    $newStatus = $validStatuses[$action];
    $stmt = $pdo->prepare("UPDATE member SET status = ? WHERE user_id = ?");
    $stmt->execute([$newStatus, $memberId]);

    logAction($pdo, $action . '_user', 'member', $memberId, "API: $action user");
config/database.php มีฟังก์ชัน getDBConnection() ที่สร้าง PDO ครั้งเดียวแล้วเก็บไว้ในตัวแปร static (เรียกกี่ครั้งก็ได้การเชื่อมต่อเดิม) ส่วน sql/admin_setup.sql เป็นสคริปต์สร้างตารางฝั่งแอดมินตอนติดตั้งครั้งแรก

10. ฐานข้อมูล

12 ตาราง ความสัมพันธ์ และเลข id ที่มักสับสน

ER Diagram ของฐานข้อมูล job_matching_system
ภาพที่ 4 ER Diagram (ไฟล์ docs/er_diagram.svg สร้างจากโครงสร้างฐานข้อมูลจริงด้วย backend/tools/export_er_diagram.php)

ตารางทั้งหมด

ตารางเก็บอะไรคอลัมน์สำคัญ
memberบัญชีผู้ใช้ทุกคน (ผู้หางาน ผู้ว่าจ้าง แอดมิน)user_id (PK), email (ไม่ซ้ำ), password (bcrypt), name, role, status (active / suspended / banned), created_at
job_seekerข้อมูลเพิ่มเติมของผู้หางานseeker_id (PK), user_id (FK), fullname, phone, skills (ข้อความคั่นคอมมา), education, experience, address, resume_url, resume_keywords, profile_image, latitude, longitude
employerข้อมูลบริษัทemployer_id (PK), user_id (FK), company_name, company_detail, contact_phone, company_address, company_logo, company_cover, latitude, longitude
job_postingประกาศงานjob_id (PK), employer_id (FK), job_title, job_description, qualifications, job_location, required_skills, job_type, salary, status, post_date, latitude, longitude, vacancy, hired_count
applicationใบสมัครงานapplication_id (PK), job_id (FK), seeker_id (FK), apply_date, status (ข้อความไทย), start_date, result_message — คู่ (job_id, seeker_id) ห้ามซ้ำ
interviewนัดสัมภาษณ์ (1 ใบสมัคร : 1 นัด)interview_id (PK), application_id (FK), interview_date, interview_time, interview_type (onsite / online), location_link, additional_notes, status
matchingผลการจับคู่ที่คำนวณเก็บไว้matching_id (PK), seeker_id (FK), job_id (FK), ranking, totalScore
keywordsคลังคำสำคัญ/ทักษะkeyword_id (PK), keyword, category — คู่ (keyword, category) ห้ามซ้ำ
messageข้อความแชทmessage_id (PK), sender_id (FK→member), receiver_id (FK→member), message, sent_date, is_read
notificationการแจ้งเตือนNoti_id (PK), user_id (FK), title, content, type, Is_read, Created_at
system_settingsค่าตั้งของระบบ (เกณฑ์การจับคู่)setting_key (PK), setting_value, description, updated_at
system_logsประวัติการทำงานของแอดมินlog_id (PK), admin_id (FK→member), action, target_type, target_id, details, ip_address, created_at
คอลัมน์ของตาราง notification ขึ้นต้นตัวใหญ่ (Noti_id, Is_read, Created_at) ต่างจากตารางอื่น get_notifications.php จึงแปลงเป็นตัวเล็ก (noti_id, is_read, created_at) ก่อนส่งให้แอป

ความสัมพันธ์ระหว่างตาราง

  • member 1 — 1 job_seeker และ member 1 — 1 employer: ผู้ใช้หนึ่งคนมีโปรไฟล์ตามบทบาทของตัวเอง
  • employer 1 — N job_posting: บริษัทหนึ่งมีได้หลายประกาศ
  • job_posting 1 — N application N — 1 job_seeker: ใบสมัครเป็นตัวเชื่อมงานกับผู้หางาน (ความสัมพันธ์แบบ many-to-many)
  • application 1 — 1 interview: ใบสมัครหนึ่งมีนัดได้หนึ่งนัด (เลื่อนนัด = แก้แถวเดิม)
  • job_posting / job_seeker 1 — N matching: ผลจับคู่ของแต่ละคู่ผู้หางาน–งาน
  • member 1 — N message (ทั้งผู้ส่งและผู้รับ), member 1 — N notification, member (แอดมิน) 1 — N system_logs

Foreign key เกือบทั้งหมดตั้ง ON DELETE CASCADE คือ ลบแถวแม่แล้วแถวลูกถูกลบตามอัตโนมัติ เช่น ลบประกาศงาน → ใบสมัคร → นัดสัมภาษณ์ และผลจับคู่ของงานนั้นหายไปด้วย ไม่เหลือข้อมูลกำพร้า (ยกเว้น system_logs ที่ตั้ง ON DELETE SET NULL เพื่อเก็บประวัติไว้แม้บัญชีแอดมินถูกลบ)

user_id, seeker_id, employer_id — ใช้ตัวไหนเมื่อไหร่

จุดที่คนอ่านโค้ดสับสนบ่อยที่สุด สมมุติผู้หางาน “สมชาย” สมัครสมาชิก ระบบสร้าง 2 แถว:

ตารางแถวที่ถูกสร้างใช้อ้างอิงในเรื่อง
memberuser_id = 25ล็อกอิน, header X-User-Id, แชท (sender_id/receiver_id), แจ้งเตือน, รูป/อัปโหลด, URL ห้องแชทในแอป
job_seekerseeker_id = 12, user_id = 25ใบสมัคร (application.seeker_id), ผลจับคู่ (matching.seeker_id)
employer (ของบริษัท)employer_id = 7, user_id = 40ประกาศงาน (job_posting.employer_id) — ถ้าจะแชทกับบริษัทต้องแปลงเป็น user_id 40 ก่อน
เวลาเห็นตัวแปร id ในโค้ด ให้ถามตัวเองว่า “id นี้ของตารางไหน” เสมอ ตัวอย่างการแปลงในโปรเจกต์: get_employer_user_id.php (employer_id → user_id) และ api_auth.php ที่ JOIN หา seeker_id/employer_id จาก user_id ทุกคำขอ

ค่าที่เป็นไปได้ของคอลัมน์สถานะ

คอลัมน์ค่าความหมาย
member.roleseeker / employer / adminบทบาทผู้ใช้
member.statusactive / suspended / bannedใช้งานได้ / ถูกระงับชั่วคราว / ถูกยกเลิก (ล็อกอินไม่ได้ทั้งสองกรณีหลัง)
job_posting.statusapproved / open / closedapproved = ประกาศปกติ (open = ข้อมูลรุ่นเก่า) ทั้งสองค่าผู้หางานมองเห็น, closed = รับครบแล้ว ไม่แสดง
application.statusรอดำเนินการ / นัดสัมภาษณ์ / ผ่านการคัดเลือก / ไม่ผ่านการคัดเลือก / ตอบรับงานแล้ว / ปฏิเสธงานดูภาพลำดับสถานะในบทที่ 7
notification.typerecommend_job_<job_id> / application_<job_id>งานแนะนำ / เรื่องใบสมัครของงานนั้น (แอปใช้ตัดสินว่าแตะแล้วไปหน้าไหน)

ไฟล์ SQL และ migration

backend/sql/schema.sql คือโครงสร้างตั้งต้น ส่วน migration คือไฟล์ที่ปรับโครงสร้างทีละขั้นเมื่อระบบเพิ่มฟีเจอร์ ต้องรันเรียงลำดับ:

ไฟล์เปลี่ยนอะไร
migration_fix_latlng.sqlเพิ่มคอลัมน์พิกัดให้ตาราง employer
migration_phase2_admin_matching.sqlตาราง system_settings (เกณฑ์การจับคู่), คอลัมน์คุณสมบัติผู้สมัครในประกาศงาน, กันสมัครงานซ้ำด้วย UNIQUE (job_id, seeker_id)
migration_phase3_coordinates.sqlย้ายพิกัดจากข้อความ “พิกัด lat, lng” ไปเก็บในคอลัมน์ latitude/longitude
migration_phase4_portfolio_resume.sqlคอลัมน์ Portfolio และคีย์เวิร์ดที่ดึงจาก Resume (resume_keywords)
migration_phase5_auth_tokens.sqlตาราง auth_tokens และปิดรับงานที่รับครบแล้ว
migration_phase6_keyword_categories.sqlขยายคลังคำสำคัญเป็น 20 หมวดหลัก
migration_phase7_admin_in_member.sqlย้ายบัญชีแอดมินจากตาราง admin มาอยู่ใน member (role = admin)
migration_phase8_drop_idcard_portfolio.sqlเลิกเก็บเลขบัตรประชาชนและไฟล์ Portfolio
migration_phase9_interview_type.sqlรูปแบบสัมภาษณ์ ออนไลน์/ออนไซต์
migration_phase10_start_date.sqlวันเริ่มงานในใบสมัคร
backend/sql/migration_phase10_start_date.sqlบรรทัด 1–2ตัวอย่าง migration ขนาดเล็ก: เพิ่ม 1 คอลัมน์
-- Phase 10: วันเริ่มงาน (บริษัทกำหนดตอนรับเข้าทำงาน) และ result_message เก็บข้อความถึงผู้สมัคร
ALTER TABLE application ADD COLUMN start_date DATE NULL AFTER status;

11. เส้นทางการทำงานแบบครบวงจร

ไล่โค้ดตามเหตุการณ์จริง ตั้งแต่กดปุ่มจนข้อมูลถึงอีกฝั่ง

แต่ละขั้นบอกไฟล์และฟังก์ชันไว้ด้วย ลองเปิดไฟล์ตามไปทีละขั้นพร้อมกับทดลองกดในแอปจริง จะเข้าใจเร็วที่สุด

ก. เปิดแอปครั้งแรก → ผู้หางานเข้าสู่ระบบ

  1. แอปเริ่มทำงาน โหลด session จากเครื่อง แล้วสร้าง MaterialAppmain.dart → main()
  2. router เปิด /splash ก่อนเสมอ Splash รอ 2 วินาที เห็นว่ายังไม่ล็อกอินจึงไป /welcomesplash_page.dart → _checkSession()
  3. ผู้ใช้กด “เข้าสู่ระบบผู้หางาน” → /seeker-loginwelcome_page.dart
  4. กรอกอีเมล/รหัสผ่าน กดเข้าสู่ระบบ → แอปส่ง POST ไป login.phpseeker_login_page.dart → _handleLogin() → ApiService.login()
  5. เซิร์ฟเวอร์ค้นอีเมล ตรวจสถานะบัญชี ตรวจรหัสด้วย password_verify แล้วรวมข้อมูลจาก job_seeker ส่งกลับlogin.php
  6. แอปจำ user_id ไว้ทันที คำขอถัดไปจึงมี X-User-Idapi_service.dart → login() → rememberUserId()
  7. ดึงโปรไฟล์เต็ม แล้วบันทึก sessionApiService.getMember() → get_member.php · SessionService.saveSession()
  8. context.go('/seeker-home') → router ตรวจแล้วอนุญาต → SeekerMainShell สร้างแท็บ แต่ละแท็บโหลดข้อมูลของตัวเองใน initStateapp_router.dart → redirect · seeker_main_shell.dart

ข. บริษัทโพสต์งาน → ผู้หางานที่เหมาะได้แจ้งเตือน

  1. ผู้ว่าจ้างกด “สร้างงานใหม่” → /home/post-job กรอกฟอร์ม เลือกทักษะจากคลังคำpost_job_page.dart → _fetchDropdownData() (get_keywords.php)
  2. กดโพสต์ → POST post_job.phppost_job_page.dart → _handlePostJob() → ApiService.postJob()
  3. เซิร์ฟเวอร์ใช้ employer_id ของผู้ล็อกอิน ให้ AI แปลงทักษะเป็นคำในคลัง แปลงสถานที่/พิกัด แล้ว INSERT งานสถานะ approvedpost_job.php · ai_service.php · geocode_service.php
  4. คำนวณคะแนนผู้หางานทุกคนกับงานนี้ บันทึกในตาราง matchingmatching_service.php → recalculateMatchingForJob()
  5. ผู้หางานที่คะแนน ≥ 70 ได้แจ้งเตือน “งานแนะนำสำหรับคุณ” (type = recommend_job_<id>)matching_service.php → notifyRecommendedSeekers()
  6. ฝั่งผู้หางาน หน้าแรกถามเซิร์ฟเวอร์ทุก 10 วินาที: ตัวเลขบนกระดิ่งเพิ่มขึ้น และงานใหม่ขึ้นในรายการ “งานแนะนำ” ถ้าคะแนน ≥ 50seeker_home_tab.dart → Timer → _fetchUnreadNotifications(), _fetchJobs(silent: true)
  7. แตะแจ้งเตือน → เปิดหน้ารายละเอียดงานนั้นโดยตรงseeker_notifications_tab.dart → _handleTap() → /seeker-home/job/<id>

ค. สมัครงาน → นัดสัมภาษณ์ → รับเข้าทำงาน → ตอบรับ

  1. ผู้หางานกด “สมัครงาน” → INSERT ใบสมัคร “รอดำเนินการ” + แจ้งเตือนบริษัท (type = application_<job_id>)seeker_job_detail_page.dart → _handleApply() · apply_job.php
  2. ฝั่งบริษัท MainShell ถามทุก 5 วินาที ตัวเลขบนแท็บแจ้งเตือนขึ้น แตะแจ้งเตือน → เปิดรายชื่อผู้สมัครของงานนั้นmain_shell.dart → _fetchUnreadCount() · notifications_tab.dart
  3. รายชื่อผู้สมัครเรียงตามคะแนนที่คำนวณสด ผู้ที่ ≥ 80 ติดป้ายผู้สมัครแนะนำcandidate_list_page.dart → _fetchAll() · get_applications.php
  4. บริษัทเปิดโปรไฟล์ผู้สมัคร กด “นัดสัมภาษณ์” กรอกวัน เวลา รูปแบบ → บันทึกนัด เปลี่ยนสถานะ ส่งแจ้งเตือน และส่งรายละเอียดเข้าแชทinterview_page.dart → _handleSaveInterview() · post_interview.php
  5. ฝั่งผู้หางาน แท็บ “งานของฉัน” ถามทุก 20 วินาที เห็นสถานะเปลี่ยนจึงขึ้นแถบ “สถานะอัปเดต” และเห็นข้อความนัดในแชทseeker_applications_tab.dart → _fetchApplications(silent: true)
  6. บริษัทกด “รับเข้าทำงาน” เลือกวันเริ่มงาน → สถานะ “ผ่านการคัดเลือก” → นับ hired_count ใหม่ (ครบแล้วปิดรับงานอัตโนมัติ) + แจ้งเตือน + ข้อความแชทhire_dialog.dart → showHireDialog() · update_application.php
  7. ผู้หางานกด “ตอบรับ” → สถานะ “ตอบรับงานแล้ว” (เซิร์ฟเวอร์ตรวจว่าเป็นเจ้าของใบสมัคร และสถานะเดิมต้องเป็น “ผ่านการคัดเลือก”) → แจ้งเตือนบริษัทseeker_applications_tab.dart → _respondToOffer() · update_application.php

ง. อัปโหลด Resume → คะแนนการจับคู่เปลี่ยน

  1. ในโหมดแก้ไขโปรไฟล์ กดอัปโหลด Resume เลือก PDF (ตรวจนามสกุลและขนาด ≤ 5 MB ฝั่งแอป)seeker_profile_tab.dart → _pickAndUploadDocument()
  2. ส่งแบบ multipart → เซิร์ฟเวอร์ตรวจว่าเป็น PDF จริง แล้วย้ายไฟล์ไป uploads/resumes/ApiService.uploadResume() · upload_resume.php
  3. อ่านข้อความใน PDF หาคำที่ตรงกับคลังคำสำคัญ เก็บใน resume_keywordspdf_text.php → extractPdfText() · matching_service.php → extractKeywordsFromText()
  4. คำนวณการจับคู่ใหม่ทั้งหมด ลบไฟล์ Resume เก่า แล้วตอบรายการคำที่พบ → แอปแจ้ง “พบทักษะในเอกสาร: …”recalculateAllMatching()
  5. ครั้งหน้าที่เปิดหน้าแรก ชุดทักษะ A = ทักษะในโปรไฟล์ + resume_keywords งานที่ตรงกับคำใน Resume จึงได้คะแนนสูงขึ้นget_jobs.php → seekerSkillSet()

จ. แชท

  1. เปิดห้องแชท → ดึงข้อความทั้งสองทิศทางเรียงตามเวลา และตั้งข้อความที่ส่งถึงเราเป็นอ่านแล้วchat_room_page.dart → _fetchMessages() · get_messages.php?sender_id&receiver_id
  2. Timer ดึงใหม่ทุก 3 วินาที (ข้อความจากอีกฝ่ายจึงขึ้นภายในไม่กี่วินาที)chat_room_page.dart → initState()
  3. ส่งข้อความ → INSERT โดยผู้ส่ง = ผู้ล็อกอิน → ล้างช่องพิมพ์ ดึงใหม่ เลื่อนลงล่างสุด_sendMessage() · send_message.php
  4. อีกฝ่ายเห็นจำนวนข้อความใหม่บนแถบเมนูจากการถามทุก 5 วินาที (GROUP BY ตามคู่สนทนา + นับที่ยังไม่อ่าน)main_shell.dart / seeker_main_shell.dart → getChatList() · get_messages.php?user_id

ฉ. แอดมินปรับเกณฑ์การจับคู่

  1. แอดมินแก้ค่าในหน้า “เกณฑ์การจับคู่” กดบันทึก → ฟอร์ม POST action=save กลับมาหน้าเดิมjobmatch_admin/matching.php
  2. ตรวจค่า → บันทึกลง system_settings → บันทึกประวัติลง system_logsINSERT … ON DUPLICATE KEY UPDATE · logAction()
  3. คำขอถัดไปจากแอปอ่านเกณฑ์ใหม่ทันที ระดับ งานแนะนำ และป้ายผู้สมัครแนะนำเปลี่ยนตามmatching_service.php → getMatchSettings()

12. เครื่องมือ สคริปต์ และการทดสอบ

ไฟล์ใน backend/tools/ และ test/ ที่ช่วยตอนพัฒนา ไม่ได้เป็นส่วนของแอปที่ผู้ใช้ใช้งาน

ไฟล์ใช้ทำอะไร
build_web.ps1build แอปเป็นเว็บ (base-href /jobmatch_app/, API_URL /jobmatch_api) แล้ววางที่ htdocs/jobmatch_app และพิมพ์ที่อยู่ให้เปิดบนมือถือที่ต่อ Wi-Fi เดียวกัน
capture_screens.ps1, capture_admin.ps1แคปหน้าจอแอป (ขนาด iPhone 15) และหน้าแอดมิน สำหรับภาพประกอบในรายงาน ด้วย Microsoft Edge แบบไม่มีหน้าต่าง
e2e_full.ps1, e2e_address.ps1ทดสอบแบบครบวงจร: เรียก API จริงพร้อม header X-User-Id ตามลำดับการใช้งาน แล้วสรุปจำนวนกรณีที่ผ่าน/ไม่ผ่าน
cleanup_e2e_notifications.phpลบแจ้งเตือนที่การทดสอบ e2e สร้างทิ้งไว้ (รันได้จาก command line เท่านั้น)
test_matching.phpทดสอบสูตรการจับคู่ทีละกรณี รันด้วย C:\xampp\php\php.exe backend\tools\test_matching.php
test/resolve_url_test.dart, test/widget_test.dartunit test ของ AppConstants.resolveUrl 3 กรณี และ smoke test ของแอป รันด้วย flutter test
seed_demo_applications.php, seed_demo_seeker_skills.phpสร้างข้อมูลตัวอย่างสำหรับนำเสนอ (ใบสมัครหลายสถานะ, ทักษะของผู้หางานตัวอย่าง)
export_data_dictionary.php, export_er_diagram.phpสร้าง docs/data_dictionary.doc และ docs/er_diagram.svg จากโครงสร้างฐานข้อมูลจริง
fill_employer_coordinates.php, fix_coordinate_addresses.phpเติมพิกัดให้บริษัทที่ยังไม่มี / แปลงสถานที่ที่เป็นข้อความ “พิกัด xx, yy” เป็น ต./อ./จ.
dedupe_keywords.phpลบคำสำคัญที่ซ้ำ เก็บแถวที่ id น้อยที่สุด
sync.phpนับ hired_count ของทุกงานใหม่จากใบสมัครจริง
recalc.phpสคริปต์รุ่นเก่า อ้างไฟล์ matching_logic.php ที่ไม่มีแล้วจึงใช้ไม่ได้ ให้ใช้ปุ่ม “คำนวณใหม่” ในหน้าแอดมินแทน
check_db.php, check_table.php, get_cols.php, get_kw.php, test_job*.php, test_msg.php, alter_job_posting.phpสคริปต์ตรวจ/ดีบักระหว่างพัฒนา เช่น ดูคอลัมน์ของตาราง ดูข้อความล่าสุด เพิ่มคอลัมน์ที่ขาด
demo_accounts.local.txtบัญชีทดสอบในเครื่อง ห้ามแชร์หรือใส่ในรายงาน

13. ข้อจำกัดและแนวทางพัฒนาต่อ

รู้ไว้ช่วยตอบคำถามกรรมการได้ และเป็นไอเดียพัฒนาเวอร์ชันถัดไป

🔐 การยืนยันตัวตนด้วย X-User-Id

API เชื่อเลข user_id ที่แอปส่งมาใน header ผู้ที่รู้ user_id ของคนอื่นและใช้เครื่องมืออย่าง Postman ปลอม header ได้

แนวทาง: ให้ login.php ออก token สุ่มแล้วเก็บค่า hash ในตาราง auth_tokens (migration ระยะ 5 สร้างตารางไว้แล้ว แต่โค้ดปัจจุบันยังไม่ได้ใช้) หรือใช้ JWT และเปิด HTTPS

🔄 Polling แทน push

แอปถามเซิร์ฟเวอร์ซ้ำทุก 3–20 วินาที เปลืองแบตเตอรี่และเน็ต และข้อมูลใหม่มาช้าได้ตามรอบ

แนวทาง: WebSocket สำหรับแชท และ Firebase Cloud Messaging สำหรับแจ้งเตือนแบบ push

⏱️ คำนวณการจับคู่ทั้งหมดใหม่

หลายเหตุการณ์สั่งคำนวณทุกงาน × ทุกผู้หางาน ข้อมูลน้อยไม่มีปัญหา แต่ข้อมูลมากจะช้า

แนวทาง: คำนวณเฉพาะผู้หางานคนที่เปลี่ยน หรือทำเป็นงานเบื้องหลัง

🧪 ค่าตั้งสำหรับช่วงพัฒนา

CORS เปิดให้ทุกโดเมน (*), MySQL ใช้ root ไม่มีรหัสผ่าน และเชื่อมต่อแบบ http ไม่เข้ารหัส

แนวทาง: จำกัดโดเมน, สร้างผู้ใช้ฐานข้อมูลเฉพาะแอปพร้อมรหัสผ่าน, ใช้ HTTPS ก่อนใช้งานจริง

🔑 ลืมรหัสผ่านไม่มี OTP

ผู้ที่รู้ทั้งอีเมลและเบอร์โทรของเจ้าของบัญชีตั้งรหัสใหม่ได้ (การส่ง OTP อยู่นอกขอบเขตโครงงาน)

แนวทาง: ส่งรหัส OTP ทางอีเมลหรือ SMS ก่อนอนุญาตให้ตั้งรหัสใหม่

🏷️ ข้อความไทยถูกใช้เป็นค่าในโค้ด

สถานะใบสมัครเก็บเป็นข้อความไทยและเขียนซ้ำหลายที่ (PHP, Dart, หน้าแอดมิน) ส่วนไอคอนแจ้งเตือนเลือกจากคำในหัวข้อ เช่น “สัมภาษณ์” ถ้าแก้ข้อความใน PHP ต้องแก้ notificationVisual() ตามด้วย

แนวทาง: เก็บเป็นรหัสภาษาอังกฤษ (เช่น interview) แล้วแปลเป็นไทยตอนแสดงผล

🗑️ ยกเลิกใบสมัคร = ลบทิ้ง

cancel_application.php ลบแถวใบสมัครจริงและไม่ได้จำกัดสถานะฝั่งเซิร์ฟเวอร์ ประวัติการสมัครจึงหายไป

แนวทาง: เปลี่ยนเป็นสถานะ “ยกเลิกแล้ว” และอนุญาตเฉพาะสถานะที่ยังไม่ตัดสินผล

📁 โค้ด API สองชุด

htdocs/jobmatch_api กับ backend/ ต้องคัดลอกให้ตรงกันเอง ลืมเมื่อไหร่สองชุดจะต่างกัน

แนวทาง: ให้ Apache ชี้ไปที่ backend/ โดยตรง (ตั้ง Alias) หรือเขียนสคริปต์ซิงก์อัตโนมัติ

คอมเมนต์บางจุดเก่ากว่าโค้ด อย่าเชื่อคอมเมนต์อย่างเดียว
  • api_service.dart บรรทัด 27 พูดถึง “token” แต่ระบบปัจจุบันใช้ header X-User-Id
  • api_service.dart บรรทัด 525–528 ยกตัวอย่างสถานะ 'accepted'/'rejected' และบอกว่าต้องแก้ backend ให้นับคนที่รับ แต่ระบบจริงใช้สถานะภาษาไทย และ update_application.php นับ hired_count ให้แล้ว
  • seeker_applications_tab.dart บรรทัด 142–147 และ manage_jobs_tab.dart บรรทัด 36–38 เป็นโน้ตให้ตรวจ/แก้ส่วนอื่น ซึ่งทำเสร็จแล้ว (update_application.php รับสถานะไทย, router รับ extra แล้ว)
  • apply_job.php และ update_application.php มีคำว่า “[ไฟล์ใหม่]” / “NOTE: สมมติว่า…” ซึ่งเป็นบันทึกตอนเพิ่มไฟล์ครั้งแรก
ยึดตัวโค้ดเป็นหลักเสมอ และถ้าแก้โค้ดก็ควรแก้คอมเมนต์ให้ตรงด้วย

14. แบบฝึกหัดลองแก้โค้ด

เรียนรู้เร็วที่สุดคือการลองแก้แล้วดูผล ทำบนสำเนาโปรเจกต์ หรือจำไว้ว่าแก้อะไรเพื่อเปลี่ยนกลับ

ก่อนลองแก้ ให้สำรองโปรเจกต์และฐานข้อมูลไว้ก่อน (เช่น คัดลอกโฟลเดอร์ และ export ฐานข้อมูลจาก phpMyAdmin) โดยเฉพาะช่วงใกล้นำเสนอ

1. เปลี่ยนสีหลักของแอป ง่าย

ลองเปลี่ยนสีฟ้าหลักเป็นสีอื่น แล้วดูว่าปุ่ม แถบหัว และไอคอนเปลี่ยนตามทั้งแอปไหม

คำใบ้

lib/core/theme/app_theme.dart ค่า primary (บรรทัด 7) แล้วกด Hot Restart (Shift+R ใน terminal ที่รัน flutter run)

2. ปรับรอบรีเฟรชห้องแชท ง่าย

เปลี่ยนจากทุก 3 วินาทีเป็น 5 วินาที แล้วสังเกตว่าข้อความจากอีกฝ่ายขึ้นช้าลงแค่ไหน

คำใบ้

chat_room_page.dart บรรทัด 37 Duration(seconds: 3)

3. เพิ่มตัวเลือกระยะทาง 200 กม. ง่าย

ในหน้าค้นหา ตัวกรอง “ระยะทาง” มี 5–100 กม. ลองเพิ่ม 200

คำใบ้

seeker_search_tab.dart บรรทัด 35 ลิสต์ _distanceOptions

4. เรียก API ด้วยเบราว์เซอร์ ง่าย

เปิด http://localhost/jobmatch_api/get_jobs.php แล้วดูว่า match_score ของทุกงานเป็น 0 เพราะอะไร

คำใบ้

เบราว์เซอร์ไม่ได้ส่ง header X-User-Id ทำให้ currentUser($pdo, false) คืน null → userId = 0 → ไม่คำนวณคะแนน (get_jobs.php บรรทัด 14–15 และ 65)

5. เพิ่มคำพ้องความหมาย กลาง

ให้ผู้ใช้ที่พิมพ์ “py” นับว่าเป็น “python” แล้วรันชุดทดสอบการจับคู่

คำใบ้

เพิ่ม 'py' => 'python', ใน $synonyms ของ skill_matcher.php แล้วรัน test_matching.php อย่าลืมคัดลอกไฟล์ไปอีกที่ (htdocs ↔ backend/)

6. ทดลองเปลี่ยนเกณฑ์ “เหมาะสมดี” กลาง

ในหน้าแอดมินเปลี่ยนจาก 50 เป็น 60 แล้วดูหน้าแรกผู้หางาน งานแนะนำลดลงไหม อธิบายว่าค่าเดินทางผ่านไฟล์ไหนบ้าง

คำใบ้

matching.php (บันทึก system_settings) → getMatchSettings() → get_jobs.php (is_recommended) → JobPostingModel.isRecommended → SeekerHomeTab._fetchJobs()

7. แสดงจำนวนผู้สมัครบนการ์ดงานฝั่งผู้หางาน กลาง

ฝึกแก้ครบ 3 ชั้น: API → Model → หน้าจอ

คำใบ้

ใน get_jobs.php เพิ่ม subquery (SELECT COUNT(*) FROM application a WHERE a.job_id = j.job_id) AS applicant_count ใน SELECT และใส่ 'applicant_count' ในผลลัพธ์ ส่วน Model ไม่ต้องแก้ เพราะ JobPostingModel.fromJson อ่าน applicant_count อยู่แล้ว (บรรทัด 116) แล้วแสดง job.applicantCount ใน _buildJobCard ของ seeker_home_tab.dart

8. แจ้งเตือนผู้สมัครเมื่อบริษัทแก้ไขประกาศ กลาง

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

คำใบ้

ใน update_job.php หลัง UPDATE: SELECT user_id ของผู้สมัคร (JOIN application กับ job_seeker) แล้ว INSERT notification โดยตั้ง type เป็น application_<job_id> แอปจะพาไปแท็บ “งานของฉัน” เองเมื่อแตะ เพราะ type ขึ้นต้นด้วย application_

9. เขียน endpoint ใหม่ ยาก

สร้าง get_job_stats.php คืนจำนวนงานที่เปิดรับและจำนวนใบสมัครทั้งหมด แล้วเรียกจากแอป

คำใบ้

ทำตามโครงในบทที่ 7 → เพิ่มค่าคงที่ใน AppConstants → เพิ่มเมธอดใน ApiService (แบบคืน Map) → เรียกใน initState ของหน้าที่ต้องการ

10. ออกแบบระบบ token ยาก

วางแผน (หรือลองทำบนสำเนา) ให้ API ใช้ token แทน X-User-Id

คำใบ้

login.php สร้าง bin2hex(random_bytes(32)) เก็บ hash('sha256', token) ในตาราง auth_tokens พร้อมวันหมดอายุ ส่ง token ให้แอป → แอปเก็บใน SessionService และส่งใน header Authorization: Bearer … ผ่าน interceptor → api_auth.php ค้น hash แทนการอ่าน X-User-Id

15. อภิธานศัพท์

คำศัพท์ที่เจอในโค้ดและในคู่มือนี้

คำความหมาย
API / Endpointช่องทางให้โปรแกรมอื่นเรียกใช้บริการ ในโปรเจกต์นี้ 1 endpoint = 1 ไฟล์ PHP
JSONรูปแบบข้อความสำหรับส่งข้อมูล เช่น {"success": true}
HTTP methodGET = ขอดูข้อมูล, POST = ส่ง/สร้าง, PUT = แก้ไข, DELETE = ลบ
Status codeตัวเลขบอกผลของคำขอ เช่น 200 สำเร็จ, 401 ยังไม่ยืนยันตัวตน, 500 เซิร์ฟเวอร์ผิดพลาด
Widgetชิ้นส่วนหน้าจอของ Flutter ทุกอย่างบนจอเป็น Widget
State / setStateข้อมูลที่เปลี่ยนได้ของหน้าจอ / คำสั่งให้วาดหน้าจอใหม่หลังข้อมูลเปลี่ยน
Future / async / awaitงานที่ได้ผลในอนาคต / ประกาศว่าฟังก์ชันมีการรอ / รอผลโดยไม่ทำให้หน้าจอค้าง
Modelคลาสที่แทนข้อมูล 1 ชิ้น เช่น ประกาศงาน 1 งาน
Singletonคลาสที่มีอ็อบเจกต์เดียวทั้งโปรแกรม
Interceptorตัวดักคำขอ/คำตอบ HTTP ทุกอันเพื่อทำงานร่วม เช่น แนบ header
Sessionข้อมูลว่าใครล็อกอินอยู่ (แอปเก็บในเครื่อง, เว็บแอดมินเก็บที่เซิร์ฟเวอร์)
Route / Routerเส้นทาง (URL) ของหน้า / ตัวจัดการว่าเส้นทางไหนเปิดหน้าไหน
Pollingการถามเซิร์ฟเวอร์ซ้ำเป็นรอบ ๆ ว่ามีข้อมูลใหม่ไหม
Cubit / Blocรูปแบบแยกตรรกะจัดการสถานะออกจากหน้าจอ (แพ็กเกจ flutter_bloc)
Optimistic updateเปลี่ยนหน้าจอทันทีโดยสมมุติว่าสำเร็จ ถ้าเซิร์ฟเวอร์ตอบว่าพลาดค่อยย้อนกลับ
Multipart / form-dataรูปแบบคำขอสำหรับส่งไฟล์ไปพร้อมข้อมูลอื่น
PDOไลบรารีมาตรฐานของ PHP สำหรับเชื่อมฐานข้อมูล
Prepared statementคำสั่ง SQL ที่ใช้ ? แทนค่า แล้วส่งค่าแยก ป้องกัน SQL Injection
SQL Injectionการโจมตีโดยแทรกคำสั่ง SQL มากับข้อมูลที่กรอก
Transactionกลุ่มคำสั่ง SQL ที่ต้องสำเร็จทั้งหมด หรือยกเลิกทั้งหมด (commit / rollBack)
Primary key / Foreign keyคอลัมน์ระบุแถวที่ไม่ซ้ำ / คอลัมน์ที่อ้างถึง primary key ของอีกตาราง
ON DELETE CASCADEลบแถวแม่แล้วแถวลูกที่อ้างถึงถูกลบตามอัตโนมัติ
Migrationไฟล์ SQL ที่ปรับโครงสร้างฐานข้อมูลทีละขั้น
Hash / bcryptการแปลงข้อมูลทางเดียว (ย้อนกลับไม่ได้) / อัลกอริทึมที่ใช้เก็บรหัสผ่าน
CORSกฎของเบราว์เซอร์ว่าเว็บจากโดเมนหนึ่งเรียก API ของอีกโดเมนได้หรือไม่
Geocoding / Reverse geocodingแปลงที่อยู่เป็นพิกัด / แปลงพิกัดเป็นที่อยู่
Haversineสูตรคำนวณระยะทางระหว่างสองพิกัดบนผิวโลก
Normalizeทำข้อมูลให้อยู่รูปแบบมาตรฐานเดียวกันก่อนเปรียบเทียบ เช่น ตัวพิมพ์เล็ก แปลงคำย่อ
Keyword Matchingการหาคำสำคัญจากคลังคำที่ปรากฏในข้อความอิสระ เช่น Resume
BOMอักขระพิเศษที่บางโปรแกรมใส่ไว้ต้นไฟล์ UTF-8 ทำให้ถอด JSON ไม่ได้ถ้าไม่ตัดออก
CDNเซิร์ฟเวอร์กลางที่ให้โหลดไลบรารี เช่น Chart.js
XAMPPชุดโปรแกรม Apache + MySQL + PHP สำหรับรันเซิร์ฟเวอร์ในเครื่อง