0

Campaign Management: Làm chủ vòng đời chiến dịch với Task Scheduling và State Validation

để làm một chức năng Campaign Management (Quản lý chiến dịch), nếu chỉ làm CRUD (Thêm/Sửa/Xóa) cơ bản thì quá nhàm chán và giống hệt quản lý Sản phẩm hay Bài viết.

Đặc thù "chí mạng" của một Chiến dịch (Marketing, Sale, Ads) là nó bị ràng buộc bởi Thời gian (Start Date, End Date) và Ngân sách (Budget). Trong thực tế (như hệ thống của Shopee hay Facebook Ads), bạn không thể kích hoạt một chiến dịch nếu ngân sách bằng 0, và hệ thống phải tự động đóng (Complete) chiến dịch khi thời gian kết thúc, chứ không thể bắt nhân viên canh đúng 12h đêm vào bấm nút tắt được!

Hôm nay, mình sẽ hướng dẫn bạn thiết kế một module Quản lý chiến dịch hoàn toàn mới, áp dụng State Management (Quản lý trạng thái) kết hợp với Laravel Task Scheduling (Cronjob tự động) để giải quyết bài toán thời gian thực này.

Lời mở đầu: Chiến dịch không phải là dữ liệu tĩnh!

Một chiến dịch luôn trải qua vòng đời: Nháp (Draft) -> Đang chạy (Active) -> Kết thúc (Completed). Lỗi sai phổ biến của anh em Backend là để Frontend truyền trạng thái status lên qua API Update. VD: Frontend gửi {"status": "active"}, Backend lưu luôn xuống DB. Điều này dẫn đến thảm họa: Một chiến dịch đã hết hạn từ tháng trước, nhưng ai đó gọi API đổi status thành "active", hệ thống vẫn cho phép!

Hôm nay, ta sẽ tước quyền quyết định trạng thái của Frontend. Trạng thái chiến dịch sẽ được Backend tự động nội suy dựa trên Logic nghiệp vụ (Ngân sách, Thời gian hiện tại).

Bước 1: Khởi tạo dự án & Xây dựng "Móng" Database

Tạo một dự án hoàn toàn mới:

laravel new enterprise-campaigns
cd enterprise-campaigns

1. Tạo Model và Migration:

php artisan make:model Campaign -m

2. Thiết kế bảng campaigns:

Mở file migration vừa tạo lên. Ta cần lưu thông tin cơ bản, ngân sách và mốc thời gian.

// database/migrations/xxxx_create_campaigns_table.php
public function up(): void
{
    Schema::create('campaigns', function (Blueprint $table) {
        $table->id();
        $table->string('name'); // Tên chiến dịch
        $table->text('description')->nullable();
        $table->decimal('budget', 15, 2)->default(0); // Ngân sách chạy
        $table->dateTime('start_date'); // Thời gian bắt đầu
        $table->dateTime('end_date'); // Thời gian kết thúc
        $table->string('status')->default('draft'); // Trạng thái: draft, active, completed
        $table->timestamps();
    });
}

Chạy lệnh php artisan migrate để tạo bảng.

Bước 2: Chuẩn hóa Trạng thái bằng Enum (PHP 8.1+)

Tuyệt đối không gõ hardcode chuỗi 'draft', 'active' lung tung trong code. Ta dùng Enum để quản lý.

Tạo thư mục app/Enums và thêm file CampaignStatus.php:

// app/Enums/CampaignStatus.php
namespace App\Enums;

enum CampaignStatus: string
{
    case DRAFT = 'draft';
    case ACTIVE = 'active';
    case COMPLETED = 'completed';
}

Trong Model Campaign.php, ta ép kiểu (cast) thuộc tính status về dạng Enum này:

// app/Models/Campaign.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use App\Enums\CampaignStatus;

class Campaign extends Model
{
    protected $fillable = [
        'name', 'description', 'budget', 'start_date', 'end_date', 'status'
    ];

    protected $casts = [
        'start_date' => 'datetime',
        'end_date' => 'datetime',
        'status' => CampaignStatus::class, // Ép kiểu Enum
    ];
}

Bước 3: Action Pattern - Xử lý logic Kích hoạt chiến dịch

Ta tạo một Action chuyên xử lý việc "Kích hoạt" (Activate) chiến dịch. Đây là nơi kiểm tra mọi ràng buộc ngặt nghèo nhất.

mkdir app/Actions

Tạo file app/Actions/ActivateCampaignAction.php:

// app/Actions/ActivateCampaignAction.php
namespace App\Actions;

use App\Models\Campaign;
use App\Enums\CampaignStatus;
use Illuminate\Validation\ValidationException;

class ActivateCampaignAction
{
    public function execute(Campaign $campaign): Campaign
    {
        // 1. Kiểm tra trạng thái hiện tại (Chỉ Draft mới được kích hoạt)
        if ($campaign->status !== CampaignStatus::DRAFT) {
            throw ValidationException::withMessages([
                'status' => 'Chỉ có thể kích hoạt chiến dịch đang ở trạng thái Nháp.'
            ]);
        }

        // 2. Kiểm tra ngân sách
        if ($campaign->budget <= 0) {
            throw ValidationException::withMessages([
                'budget' => 'Chiến dịch phải có ngân sách lớn hơn 0 để hoạt động.'
            ]);
        }

        // 3. Kiểm tra thời gian logic (Ngày kết thúc phải ở tương lai)
        if ($campaign->end_date->isPast()) {
            throw ValidationException::withMessages([
                'end_date' => 'Không thể kích hoạt chiến dịch đã qua ngày kết thúc.'
            ]);
        }

        // Đạt mọi điều kiện -> Chuyển trạng thái
        $campaign->update([
            'status' => CampaignStatus::ACTIVE
        ]);

        return $campaign;
    }
}

Bước 4: Tự động hóa với Task Scheduling (Sức mạnh thực sự)

Giả sử một chiến dịch đang chạy (Active) và kết thúc vào lúc 23:59 ngày hôm nay. Ta không thể bắt user canh giờ để chuyển nó sang Completed. Ta sẽ viết một con Bot (Console Command) chạy ngầm mỗi phút để tự động rà soát.

1. Tạo Command:

php artisan make:command AutoCompleteCampaigns

2. Viết logic tự động đóng chiến dịch:

// app/Console/Commands/AutoCompleteCampaigns.php
namespace App\Console\Commands;

use Illuminate\Console\Command;
use App\Models\Campaign;
use App\Enums\CampaignStatus;
use Illuminate\Support\Facades\Log;

class AutoCompleteCampaigns extends Command
{
    protected $signature = 'campaigns:auto-complete';
    protected $description = 'Tự động quét và đóng các chiến dịch đã quá hạn';

    public function handle()
    {
        // Tìm các chiến dịch ĐANG CHẠY nhưng thời gian kết thúc nhỏ hơn Hiện tại
        $expiredCampaigns = Campaign::where('status', CampaignStatus::ACTIVE->value)
                                    ->where('end_date', '<', now())
                                    ->get();

        $count = 0;
        foreach ($expiredCampaigns as $campaign) {
            $campaign->update(['status' => CampaignStatus::COMPLETED]);
            $count++;
            Log::info("Đã tự động đóng chiến dịch ID: {$campaign->id}");
        }

        $this->info("Đã đóng {$count} chiến dịch hết hạn.");
    }
}

(Trong thực tế, bạn sẽ khai báo command này chạy everyMinute() trong routes/console.php để hệ thống tự động hóa hoàn toàn).

Bước 5: Controller & Routing điều hướng

Tạo Controller để hứng API.

php artisan make:controller Api/CampaignController
// app/Http/Controllers/Api/CampaignController.php
namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Campaign;
use App\Actions\ActivateCampaignAction;

class CampaignController extends Controller
{
    /**
     * Tạo mới một chiến dịch (Mặc định là Draft)
     */
    public function store(Request $request)
    {
        $request->validate([
            'name' => 'required|string|max:255',
            'budget' => 'required|numeric|min:0',
            'start_date' => 'required|date|after_or_equal:today',
            'end_date' => 'required|date|after:start_date',
        ]);

        $campaign = Campaign::create([
            'name' => $request->name,
            'description' => $request->description,
            'budget' => $request->budget,
            'start_date' => $request->start_date,
            'end_date' => $request->end_date,
            // Status tự động lấy default ở DB là 'draft'
        ]);

        return response()->json([
            'success' => true,
            'message' => 'Đã tạo chiến dịch nháp thành công.',
            'data' => $campaign
        ], 201);
    }

    /**
     * Kích hoạt chiến dịch
     */
    public function activate($id, ActivateCampaignAction $action)
    {
        $campaign = Campaign::findOrFail($id);

        $action->execute($campaign);

        return response()->json([
            'success' => true,
            'message' => 'Chiến dịch đã được kích hoạt thành công!',
            'data' => $campaign
        ]);
    }
}

Mở routes/api.php đăng ký:

use App\Http\Controllers\Api\CampaignController;

Route::post('/campaigns', [CampaignController::class, 'store']);
Route::post('/campaigns/{id}/activate', [CampaignController::class, 'activate']);

Bước 6: Thử lửa với Postman từ A-Z

Khởi động server: php artisan serve

Kịch bản 1: Tạo một chiến dịch thiếu ngân sách (Lỗi cố ý)

  • Method: POST
  • URL: [http://127.0.0.1:8000/api/campaigns](http://127.0.0.1:8000/api/campaigns)
  • Headers: Accept: application/json
  • Body (JSON):
{
    "name": "Siêu Sale Tháng 6",
    "budget": 0,
    "start_date": "2026-06-01 00:00:00",
    "end_date": "2026-06-10 23:59:00"
}
  • Kết quả: API tạo thành công, trả về JSON với status tự động là draft (Bản nháp thì chưa cần budget lớn hơn 0).

Kịch bản 2: Cố gắng kích hoạt Chiến dịch lỗi (Test Guard)

Lấy ID của chiến dịch vừa tạo (ID = 1), ta gọi API kích hoạt.

  • Method: POST
  • URL: [http://127.0.0.1:8000/api/campaigns/1/activate](http://127.0.0.1:8000/api/campaigns/1/activate)
  • Headers: Accept: application/json
  • Kết quả: Action Class tung khiên chặn đứng ngay lập tức!
{
    "message": "Chiến dịch phải có ngân sách lớn hơn 0 để hoạt động.",
    "errors": {
        "budget": ["Chiến dịch phải có ngân sách lớn hơn 0 để hoạt động."]
    }
}

Kịch bản 3: Kịch bản hoàn hảo (Happy Path)

Bây giờ, tạo một chiến dịch chuẩn chỉ với ngân sách 5 triệu.

  • Method: POST
  • URL: [http://127.0.0.1:8000/api/campaigns](http://127.0.0.1:8000/api/campaigns)
  • Body:
{
    "name": "Tri ân Khách hàng VIP",
    "budget": 5000000,
    "start_date": "2026-05-10 00:00:00",
    "end_date": "2026-12-31 23:59:00"
}

(Giả sử ID trả về là 2).

Gọi API kích hoạt:

  • Method: POST
  • URL: [http://127.0.0.1:8000/api/campaigns/2/activate](http://127.0.0.1:8000/api/campaigns/2/activate)
  • Kết quả: Kích hoạt thành công mượt mà!
{
    "success": true,
    "message": "Chiến dịch đã được kích hoạt thành công!",
    "data": {
        "id": 2,
        "name": "Tri ân Khách hàng VIP",
        "budget": "5000000.00",
        "status": "active"
    }
}

Kịch bản 4: Test sự vi diệu của Command tự động đóng

Mở thêm 1 terminal thứ 2. Giả lập việc quá thời gian, chúng ta chạy lệnh command ta vừa code:

php artisan campaigns:auto-complete
  • Màn hình Terminal hiện: Đã đóng 0 chiến dịch hết hạn. (Bởi vì End Date của ta đang là tháng 12/2026, chưa hết hạn).
  • Thử nghiệm: Nếu bạn vào Database sửa end_date của ID 2 thành ngày hôm qua, rồi chạy lại lệnh trên, Terminal sẽ báo Đã tự động đóng chiến dịch ID: 2 và status trong Database sẽ âm thầm chuyển thành completed!

Tổng kết

Qua module Campaign Management này, anh em rút ra được 3 nguyên lý thiết kế backend hạng nặng:

  1. State Protection: Frontend không được truyền status. Backend tự chuyển đổi status dựa trên nghiệp vụ (Action Class).
  2. Enums Everywhere: Ràng buộc dữ liệu từ trong lõi, tránh các bug do gõ sai chính tả chuỗi string.
  3. Cronjob Automation: Những nghiệp vụ gắn với thời gian thực (Time-bound events) phải dùng Command / Scheduled Task để quét nền, không để user tương tác thủ công.

Chúc anh em áp dụng thành công và nâng cấp tư duy thiết kế hệ thống của mình nhé!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí