ข้ามไปยังเนื้อหาหลัก
ProgrammerThailand

AI & Vibe Coding

Workshop: จาก Vibe Code สู่ Yii2 CRUD ด้วย Gii + AI

Workshop: จาก Vibe Code สู่ Yii2 CRUD ด้วย Gii + AI

หลายคนลองให้ AI เขียนระบบ CRUD ทั้งก้อนตั้งแต่ศูนย์ แล้วพบว่าได้โค้ดที่หน้าตาคล้าย Yii2 แต่ไม่ตรงกับโครงโปรเจกต์ ชื่อ namespace เพี้ยน บางเมธอดก็ไม่มีอยู่จริง ที่สำคัญคือมักลืมเรื่อง validation และสิทธิ์การเข้าถึง ทั้งที่ Yii2 มีเครื่องมือสร้างโครงที่แม่นยำอยู่แล้วคือ Gii บทความ workshop นี้จะพาทำระบบ "แจ้งซ่อม" ตั้งแต่ตารางจนถึงหน้าจอ โดยแบ่งงานให้ชัด: ให้ Gii สร้างโครงที่เป็นมาตรฐาน แล้วใช้ AI ช่วยในส่วนที่ต้องคิด คือ rules, behaviors และ AccessControl พร้อมเล่าทีละ prompt ว่าถามอะไร ได้อะไร และต้องแก้อะไรก่อนรับโค้ดเข้าโปรเจกต์

โจทย์และการแบ่งงานระหว่าง Gii กับ AI

โจทย์คือระบบแจ้งซ่อมภายในองค์กร ผู้ใช้ที่ล็อกอินแล้วแจ้งปัญหาได้ ดูและแก้ไขได้เฉพาะรายการของตัวเอง ส่วนเจ้าหน้าที่ดูได้ทุกรายการ เปลี่ยนสถานะได้ และมีเพียงผู้ดูแลระบบที่ลบได้ ใช้ Yii2 advanced template กับ PostgreSQL

หลักการแบ่งงานที่ใช้ตลอด workshop:

  • Gii สร้างสิ่งที่มีรูปแบบตายตัว: Model จากตาราง, Search model, Controller และ view แบบ CRUD งานนี้ Gii แม่นกว่า AI เพราะอ่าน schema จริงจากฐานข้อมูล
  • AI ช่วยคิดและร่างส่วนที่ขึ้นกับ business rule: migration, validation rules, scenario, behaviors และกฎสิทธิ์
  • เรา อ่านทุกบรรทัด รันจริง และตัดสินใจขั้นสุดท้าย

ก่อนเริ่มให้เตรียม context สั้น ๆ ไว้แปะทุก prompt ได้แก่ เวอร์ชัน PHP, ใช้ Yii2 advanced template, ฐานข้อมูล PostgreSQL, ระบบ user มาจาก common\models\User และเปิดใช้ RBAC ผ่าน authManager แล้ว การให้ข้อมูลเหล่านี้ตั้งแต่ต้นช่วยลดโอกาสที่ AI จะเดาโครงสร้างเอง

Prompt 1: ออกแบบตารางและ migration

เริ่มจากให้ AI ช่วยร่าง migration โดยบอกข้อกำหนดเป็นข้อ ๆ

บริบท: Yii2 advanced template, PHP 8, PostgreSQL
งาน: เขียน migration สร้างตาราง ticket สำหรับระบบแจ้งซ่อม
ฟิลด์: id, title (สูงสุด 200 ตัวอักษร), description (text), category,
priority (1=ต่ำ 2=ปกติ 3=ด่วน), status (new, in_progress, done),
contact_phone (ไม่บังคับ), created_by, updated_by, created_at, updated_at (int)
ข้อกำหนด: มี foreign key created_by ไปที่ตาราง user, index ที่ status และ created_by
ห้ามเพิ่มฟิลด์ที่ไม่ได้ระบุ ใช้ safeUp/safeDown

ผลที่ได้ใช้ได้เกือบทั้งหมด จุดที่ต้องแก้คือ AI ใส่ ON DELETE CASCADE ให้ foreign key ซึ่งหมายความว่าถ้าลบ user รายการแจ้งซ่อมจะหายไปด้วย สำหรับข้อมูลที่ต้องเก็บประวัติแบบนี้ควรใช้ RESTRICT นี่คือตัวอย่างของการตัดสินใจเชิงธุรกิจที่ AI เลือกแทนเราโดยไม่ถาม โค้ดหลังแก้:

public function safeUp()
{
    $this->createTable('{{%ticket}}', [
        'id' => $this->primaryKey(),
        'title' => $this->string(200)->notNull(),
        'description' => $this->text()->notNull(),
        'category' => $this->string(30)->notNull(),
        'priority' => $this->smallInteger()->notNull()->defaultValue(2),
        'status' => $this->string(20)->notNull()->defaultValue('new'),
        'contact_phone' => $this->string(20),
        'created_by' => $this->integer()->notNull(),
        'updated_by' => $this->integer(),
        'created_at' => $this->integer()->notNull(),
        'updated_at' => $this->integer()->notNull(),
    ]);
    $this->createIndex('idx-ticket-status', '{{%ticket}}', 'status');
    $this->createIndex('idx-ticket-created_by', '{{%ticket}}', 'created_by');
    $this->addForeignKey(
        'fk-ticket-created_by', '{{%ticket}}', 'created_by',
        '{{%user}}', 'id', 'RESTRICT', 'CASCADE'
    );
}

รัน php yii migrate แล้วตรวจตารางจริงด้วย \d ticket ใน psql ก่อนไปขั้นต่อไป

ให้ Gii สร้างโครง Model และ CRUD

ขั้นนี้ไม่ใช้ AI เลย เปิด Gii ใน environment dev (ห้ามเปิด Gii บน production) แล้วสร้าง Model ชื่อ Ticket ใน common\models จากนั้นใช้ CRUD Generator สร้าง TicketController และ TicketSearch ใน backend หรือจะใช้ผ่าน console ก็ได้

php yii gii/model --tableName=ticket --modelClass=Ticket --ns="common\models"
php yii gii/crud --modelClass="common\models\Ticket" \
  --searchModelClass="backend\models\TicketSearch" \
  --controllerClass="backend\controllers\TicketController"

Gii จะสร้าง rules จาก schema ให้ เช่น required, integer, string max 200 ซึ่งถูกต้องในระดับชนิดข้อมูล แต่ยังไม่รู้ business rule และมีจุดอันตรายหนึ่งจุด คือฟิลด์ created_by กับ status ถูกใส่ใน rules ทำให้กลายเป็น safe attribute ผู้ใช้สามารถส่งค่าเหล่านี้มากับฟอร์มเพื่อปลอมเจ้าของหรือเปลี่ยนสถานะเองได้ (mass assignment) ขั้นต่อไปเราจะให้ AI ช่วยปรับตรงนี้

Prompt 2: validation rules และ scenario

แปะไฟล์ Ticket.php ที่ Gii สร้าง พร้อมข้อกำหนด

นี่คือ model ที่ Gii สร้าง (แปะโค้ด) ช่วยปรับ rules() ตามนี้
- title, description, category บังคับกรอก และ trim ช่องว่าง
- category ต้องอยู่ในรายการที่กำหนดเท่านั้น (it, building, other)
- priority เป็น 1-3 ค่าเริ่มต้น 2
- contact_phone ไม่บังคับ ถ้ากรอกต้องเป็นเบอร์ไทย 9-10 หลักขึ้นต้นด้วย 0
- ผู้ใช้ทั่วไปห้ามกำหนด status, created_by, updated_by เอง
- เจ้าหน้าที่เปลี่ยน status ได้ผ่าน scenario ชื่อ staff
อธิบายสั้น ๆ ว่าแต่ละ rule ป้องกันอะไร

AI เสนอให้ลบ created_by ออกจาก rules และผูก rule ของ status ไว้กับ scenario staff ซึ่งเป็นแนวทางที่ถูก เพราะใน Yii2 attribute ที่ไม่มี rule ใน scenario ปัจจุบันจะไม่ถูกรับค่าผ่าน load() ผลที่ได้หลังตรวจแล้ว:

public const SCENARIO_STAFF = 'staff';
public const STATUSES = ['new', 'in_progress', 'done'];

public static function categoryOptions(): array
{
    return ['it' => 'ไอที', 'building' => 'อาคารสถานที่', 'other' => 'อื่น ๆ'];
}

public function rules(): array
{
    return [
        [['title', 'description', 'contact_phone'], 'trim'],
        [['title', 'description', 'category'], 'required'],
        ['title', 'string', 'max' => 200],
        ['description', 'string', 'max' => 5000],
        ['category', 'in', 'range' => array_keys(self::categoryOptions())],
        ['priority', 'default', 'value' => 2],
        ['priority', 'integer'],
        ['priority', 'in', 'range' => [1, 2, 3]],
        ['contact_phone', 'match', 'pattern' => '/^0\d{8,9}$/',
            'message' => 'เบอร์โทรต้องเป็นตัวเลข 9-10 หลักขึ้นต้นด้วย 0'],
        ['status', 'in', 'range' => self::STATUSES, 'on' => self::SCENARIO_STAFF],
    ];
}

สิ่งที่เราตรวจเพิ่ม: ร่างแรกของ AI ไม่มี rule integer ให้ priority และไม่จำกัดความยาว description เราจึงเพิ่ม integer ไว้ก่อน rule in และจำกัด description ไว้ที่ 5000 ตัวอักษร เพื่อไม่ให้มีคนส่งข้อความขนาดใหญ่เกินจำเป็น อีกเรื่องที่ต้องดูคือคำอธิบายที่ AI ให้มาตรงกับโค้ดจริงหรือไม่ เพราะบางครั้งคำอธิบายพูดถึง rule ที่ไม่ได้อยู่ในโค้ด

Prompt 3: behaviors สำหรับเวลาและผู้บันทึก

แทนที่จะให้ผู้ใช้ส่ง created_by มาเอง เราให้ระบบเติมจากผู้ที่ล็อกอินอยู่ prompt สั้น ๆ คือ "เพิ่ม behaviors ให้ Ticket ใช้ TimestampBehavior และ BlameableBehavior ของ Yii2 ที่มีอยู่แล้ว ห้ามเขียน beforeSave เอง" การระบุชื่อคลาสที่มีจริงช่วยกัน AI แต่ง behavior ขึ้นมาใหม่

use yii\behaviors\BlameableBehavior;
use yii\behaviors\TimestampBehavior;

public function behaviors(): array
{
    return [
        TimestampBehavior::class,
        BlameableBehavior::class,
    ];
}

ค่าเริ่มต้นของสอง behavior นี้ใช้ชื่อคอลัมน์ created_at, updated_at, created_by, updated_by ตรงกับตารางที่เราออกแบบไว้ จึงไม่ต้องตั้งค่าเพิ่ม และเนื่องจาก created_by เป็น NOT NULL เราต้องลบ rule required ของ created_by, created_at และ updated_at ที่ Gii สร้างไว้ด้วย (รวมถึง rule exist ที่ชี้ไปตาราง user) ไม่อย่างนั้น validate จะไม่ผ่าน เพราะ behavior เติมค่าตอนก่อน insert ซึ่งเกิดหลังขั้น validate จุดนี้ AI มักไม่บอกถ้าเราไม่แปะ rules ปัจจุบันให้ดู

Prompt 4: AccessControl และสิทธิ์ระดับเจ้าของข้อมูล

นี่คือส่วนที่สำคัญที่สุดและเป็นจุดที่ AI มักทำไม่ครบ prompt ที่ใช้:

ปรับ TicketController (แปะโค้ด) ให้
- ทุก action ต้องล็อกอิน
- delete เฉพาะ role admin และต้องเป็น POST เท่านั้น
- view/update ได้เฉพาะเจ้าของรายการ หรือผู้มีสิทธิ์ manageTicket ใน RBAC
- index แสดงเฉพาะรายการของตัวเอง ยกเว้นผู้มีสิทธิ์ manageTicket
- ผู้มีสิทธิ์ manageTicket ใช้ scenario staff ตอน update
ระวัง IDOR: ห้ามเชื่อ id จาก URL โดยไม่ตรวจเจ้าของ

AccessControl ของ Yii2 ตรวจได้ระดับ action และ role แต่ไม่รู้ว่าแถวไหนเป็นของใคร ดังนั้นต้องตรวจเจ้าของใน findModel() ด้วย ร่างแรกของ AI ตรวจเจ้าของเฉพาะใน actionUpdate แต่ลืม actionView ซึ่งแปลว่าใครก็เปลี่ยนเลข id ใน URL เพื่ออ่านรายการของคนอื่นได้ เราจึงย้ายการตรวจไปไว้ที่จุดเดียว

use Yii;
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
use yii\web\ForbiddenHttpException;
use yii\web\NotFoundHttpException;

public function behaviors(): array
{
    return [
        'access' => [
            'class' => AccessControl::class,
            'rules' => [
                [
                    'allow' => true,
                    'actions' => ['index', 'view', 'create', 'update'],
                    'roles' => ['@'],
                ],
                [
                    'allow' => true,
                    'actions' => ['delete'],
                    'roles' => ['admin'],
                ],
            ],
        ],
        'verbs' => [
            'class' => VerbFilter::class,
            'actions' => ['delete' => ['POST']],
        ],
    ];
}

public function actionUpdate(int $id)
{
    $model = $this->findModel($id);
    if (Yii::$app->user->can('manageTicket')) {
        $model->scenario = Ticket::SCENARIO_STAFF;
    }
    if ($model->load(Yii::$app->request->post()) && $model->save()) {
        return $this->redirect(['view', 'id' => $model->id]);
    }
    return $this->render('update', ['model' => $model]);
}

protected function findModel(int $id): Ticket
{
    $model = Ticket::findOne(['id' => $id]);
    if ($model === null) {
        throw new NotFoundHttpException('ไม่พบรายการแจ้งซ่อม');
    }
    $user = Yii::$app->user;
    if ((int) $model->created_by !== (int) $user->id && !$user->can('manageTicket')) {
        throw new ForbiddenHttpException('คุณไม่มีสิทธิ์เข้าถึงรายการนี้');
    }
    return $model;
}

ส่วนหน้ารายการ ให้กรองใน TicketSearch::search() ด้วย $query->andWhere(['created_by' => Yii::$app->user->id]) เมื่อผู้ใช้ไม่มีสิทธิ์ manageTicket อย่าลืมว่าการซ่อนปุ่มใน view ไม่ใช่การป้องกัน การตรวจต้องอยู่ฝั่ง controller หรือ model เสมอ

Prompt 5: ให้ AI ช่วยคิดกรณีทดสอบ แล้วเราทดสอบเอง

ขั้นสุดท้ายให้ AI ทำหน้าที่ผู้ตรวจ: "จากโค้ด model และ controller นี้ ลิสต์กรณีทดสอบด้านสิทธิ์และ validation ที่ควรมี เน้นกรณีที่น่าจะพัง" รายการที่ได้นำไปเขียนเป็น test ด้วย Codeception หรือทดสอบมือก่อนก็ได้ อย่างน้อยควรครอบคลุม:

  • ผู้ใช้ A เปิด ticket/view?id= ของผู้ใช้ B ต้องได้ 403
  • ผู้ใช้ทั่วไปส่ง Ticket[status]=done และ Ticket[created_by]=1 มากับฟอร์ม ค่าต้องไม่เปลี่ยน
  • เรียก delete ด้วย GET ต้องได้ 405 และผู้ใช้ที่ไม่ใช่ admin เรียกด้วย POST ต้องได้ 403
  • เบอร์โทรผิดรูปแบบ, category นอกรายการ, priority เป็น 9 ต้อง validate ไม่ผ่าน
  • title มีแต่ช่องว่างต้องถือว่าไม่ได้กรอก

ถ้าทดสอบข้อไหนไม่ผ่าน ให้ส่งผลลัพธ์จริงกลับไปให้ AI ช่วยวิเคราะห์ แทนการบอกแค่ว่า "ยังไม่ได้"

สรุป

  • ให้ Gii สร้างโครงที่อิง schema จริง แล้วใช้ AI กับส่วนที่เป็น business rule
  • ตรวจ rules ที่ Gii สร้างเสมอ ฟิลด์อย่าง created_by และ status ไม่ควรเป็น safe attribute สำหรับผู้ใช้ทั่วไป
  • ใช้ TimestampBehavior และ BlameableBehavior ที่มีอยู่แล้ว แทนการให้ AI เขียนตรรกะซ้ำ
  • AccessControl ตรวจระดับ action ส่วนสิทธิ์เจ้าของข้อมูลต้องตรวจใน findModel() และ search model
  • ปิดท้ายด้วยรายการกรณีทดสอบด้านสิทธิ์ แล้วทดสอบจริงทุกข้อ

ลองทำ

เพิ่มฟีเจอร์ "ความคิดเห็นในรายการแจ้งซ่อม" (ตาราง ticket_comment) โดยใช้ขั้นตอนเดียวกัน: ให้ AI ร่าง migration, ใช้ Gii สร้าง model และ CRUD, แล้วเขียน prompt ให้ AI ปรับ rules และ AccessControl ให้คอมเมนต์ได้เฉพาะเจ้าของรายการหรือเจ้าหน้าที่ จากนั้นลองเปลี่ยน ticket_id ในฟอร์มเป็นของคนอื่น แล้วจดว่าระบบของคุณป้องกันได้หรือไม่

ติดปัญหาตอนทำตาม? ถามในชุมชน

ความคิดเห็น (0)

ยังไม่มีความคิดเห็น — เริ่มคุยเป็นคนแรก ถามสิ่งที่สงสัย หรือแชร์ประสบการณ์ของคุณ