feat(schedule): add schedule:bootstrap and minute-precise automation runs
Install per-module schedule:run cron from artisan, store sms/in_app send minutes, and match scheduled triggers at exact HH:mm Tehran time. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class ScheduleBootstrapCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'schedule:bootstrap
|
||||||
|
{--install-only : فقط خط cron همین ماژول را نصب کن}
|
||||||
|
{--run-only : فقط یکبار schedule:run بزن، cron را دست نزن}
|
||||||
|
{--dry-run : خط cron را نشان بده بدون نصب}
|
||||||
|
{--php= : مسیر php (پیشفرض PHP_BINARY)}';
|
||||||
|
|
||||||
|
protected $description = 'نصب cron این ماژول (schedule:run) و/یا اجرای یکبار زمانبند';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$runOnly = (bool) $this->option('run-only');
|
||||||
|
$installOnly = (bool) $this->option('install-only');
|
||||||
|
|
||||||
|
if ($runOnly && $installOnly) {
|
||||||
|
$this->error('فقط یکی از --run-only یا --install-only را بزنید.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $runOnly && ! $this->installCronLine()) {
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($installOnly) {
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('==> php artisan schedule:run');
|
||||||
|
|
||||||
|
return $this->call('schedule:run');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function installCronLine(): bool
|
||||||
|
{
|
||||||
|
$php = trim((string) ($this->option('php') ?: PHP_BINARY));
|
||||||
|
$artisan = base_path('artisan');
|
||||||
|
$marker = $artisan.' schedule:run';
|
||||||
|
$line = "* * * * * {$php} {$artisan} schedule:run >> /dev/null 2>&1";
|
||||||
|
|
||||||
|
$existing = $this->currentCrontab();
|
||||||
|
if ($this->crontabHasLine($existing, $marker)) {
|
||||||
|
$this->info("cron از قبل نصب است: {$artisan}");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('dry-run')) {
|
||||||
|
$this->line($line);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = trim($existing);
|
||||||
|
if ($payload !== '') {
|
||||||
|
$payload .= "\n";
|
||||||
|
}
|
||||||
|
$payload .= $line."\n";
|
||||||
|
|
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'khaneghi-crontab-');
|
||||||
|
if ($tmp === false) {
|
||||||
|
$this->error('ساخت فایل موقت crontab ناموفق بود.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($tmp, $payload);
|
||||||
|
exec('crontab '.escapeshellarg($tmp).' 2>&1', $output, $code);
|
||||||
|
@unlink($tmp);
|
||||||
|
|
||||||
|
if ($code !== 0) {
|
||||||
|
$this->error('نصب crontab ناموفق بود.');
|
||||||
|
foreach ($output as $row) {
|
||||||
|
$this->line($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("cron نصب شد:\n{$line}");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentCrontab(): string
|
||||||
|
{
|
||||||
|
exec('crontab -l 2>/dev/null', $lines, $code);
|
||||||
|
|
||||||
|
if ($code !== 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function crontabHasLine(string $crontab, string $marker): bool
|
||||||
|
{
|
||||||
|
if ($crontab === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (preg_split('/\R/u', $crontab) ?: [] as $row) {
|
||||||
|
$row = trim((string) $row);
|
||||||
|
if ($row === '' || str_starts_with($row, '#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str_contains($row, $marker)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ class Kernel extends ConsoleKernel
|
|||||||
{
|
{
|
||||||
// تیک قیمت — ۶ بار در دقیقه با فاصله ۱۰ ثانیه (montzar + zaarbox + محصولات)
|
// تیک قیمت — ۶ بار در دقیقه با فاصله ۱۰ ثانیه (montzar + zaarbox + محصولات)
|
||||||
// نیاز: * * * * * php artisan schedule:run
|
// نیاز: * * * * * php artisan schedule:run
|
||||||
|
// یا یکبار: php artisan schedule:bootstrap
|
||||||
$schedule->command('prices:tick-loop --iterations=6 --interval=10')
|
$schedule->command('prices:tick-loop --iterations=6 --interval=10')
|
||||||
->everyMinute()
|
->everyMinute()
|
||||||
->name('prices_tick_10s')
|
->name('prices_tick_10s')
|
||||||
|
|||||||
@@ -167,7 +167,9 @@ class AutomationController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$smsHour = $request->input('sms_send_hour');
|
$smsHour = $request->input('sms_send_hour');
|
||||||
|
$smsMinute = $request->input('sms_send_minute');
|
||||||
$inAppHour = $request->input('in_app_send_hour');
|
$inAppHour = $request->input('in_app_send_hour');
|
||||||
|
$inAppMinute = $request->input('in_app_send_minute');
|
||||||
$triggerConfigRaw = (array) $request->input('trigger_config', []);
|
$triggerConfigRaw = (array) $request->input('trigger_config', []);
|
||||||
$priceWindow = (string) ($triggerConfigRaw['price_window'] ?? 'calendar_day');
|
$priceWindow = (string) ($triggerConfigRaw['price_window'] ?? 'calendar_day');
|
||||||
$needsChannelHour = ! (
|
$needsChannelHour = ! (
|
||||||
@@ -266,9 +268,15 @@ class AutomationController extends Controller
|
|||||||
'sms_send_hour' => in_array(AutomationCatalog::CHANNEL_SMS, $channels, true)
|
'sms_send_hour' => in_array(AutomationCatalog::CHANNEL_SMS, $channels, true)
|
||||||
? ($smsHour === null ? null : (int) $smsHour)
|
? ($smsHour === null ? null : (int) $smsHour)
|
||||||
: null,
|
: null,
|
||||||
|
'sms_send_minute' => in_array(AutomationCatalog::CHANNEL_SMS, $channels, true)
|
||||||
|
? ($smsHour === null ? null : max(0, min(59, (int) ($smsMinute ?? 0))))
|
||||||
|
: null,
|
||||||
'in_app_send_hour' => in_array(AutomationCatalog::CHANNEL_IN_APP, $channels, true)
|
'in_app_send_hour' => in_array(AutomationCatalog::CHANNEL_IN_APP, $channels, true)
|
||||||
? ($inAppHour === null ? null : (int) $inAppHour)
|
? ($inAppHour === null ? null : (int) $inAppHour)
|
||||||
: null,
|
: null,
|
||||||
|
'in_app_send_minute' => in_array(AutomationCatalog::CHANNEL_IN_APP, $channels, true)
|
||||||
|
? ($inAppHour === null ? null : max(0, min(59, (int) ($inAppMinute ?? 0))))
|
||||||
|
: null,
|
||||||
'trigger_type' => $triggerType,
|
'trigger_type' => $triggerType,
|
||||||
'trigger_config' => $triggerConfig,
|
'trigger_config' => $triggerConfig,
|
||||||
'conditions' => $conditions,
|
'conditions' => $conditions,
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ class Automation extends Model
|
|||||||
'starts_at',
|
'starts_at',
|
||||||
'ends_at',
|
'ends_at',
|
||||||
'sms_send_hour',
|
'sms_send_hour',
|
||||||
|
'sms_send_minute',
|
||||||
'in_app_send_hour',
|
'in_app_send_hour',
|
||||||
|
'in_app_send_minute',
|
||||||
'trigger_type',
|
'trigger_type',
|
||||||
'trigger_config',
|
'trigger_config',
|
||||||
'last_anchor_price',
|
'last_anchor_price',
|
||||||
@@ -44,7 +46,9 @@ class Automation extends Model
|
|||||||
'channels' => 'array',
|
'channels' => 'array',
|
||||||
'last_anchor_price' => 'float',
|
'last_anchor_price' => 'float',
|
||||||
'sms_send_hour' => 'integer',
|
'sms_send_hour' => 'integer',
|
||||||
|
'sms_send_minute' => 'integer',
|
||||||
'in_app_send_hour' => 'integer',
|
'in_app_send_hour' => 'integer',
|
||||||
|
'in_app_send_minute' => 'integer',
|
||||||
'cooldown_hours' => 'integer',
|
'cooldown_hours' => 'integer',
|
||||||
'max_sends' => 'integer',
|
'max_sends' => 'integer',
|
||||||
'sent_count' => 'integer',
|
'sent_count' => 'integer',
|
||||||
|
|||||||
@@ -384,14 +384,25 @@ class AutomationEngine
|
|||||||
|
|
||||||
private function hourMatches(Automation $auto, string $channel): bool
|
private function hourMatches(Automation $auto, string $channel): bool
|
||||||
{
|
{
|
||||||
$h = $channel === AutomationCatalog::CHANNEL_SMS
|
[$hour, $minute] = $this->scheduledTime($auto, $channel);
|
||||||
? $auto->sms_send_hour
|
if ($hour === null) {
|
||||||
: $auto->in_app_send_hour;
|
|
||||||
if ($h === null) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (int) now('Asia/Tehran')->hour === (int) $h;
|
$now = now('Asia/Tehran');
|
||||||
|
|
||||||
|
return (int) $now->hour === (int) $hour
|
||||||
|
&& (int) $now->minute === (int) ($minute ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{0: int|null, 1: int|null} */
|
||||||
|
private function scheduledTime(Automation $auto, string $channel): array
|
||||||
|
{
|
||||||
|
if ($channel === AutomationCatalog::CHANNEL_SMS) {
|
||||||
|
return [$auto->sms_send_hour, $auto->sms_send_minute];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$auto->in_app_send_hour, $auto->in_app_send_minute];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function alreadyRanThisHour(Automation $auto, string $channel): bool
|
private function alreadyRanThisHour(Automation $auto, string $channel): bool
|
||||||
@@ -419,17 +430,16 @@ class AutomationEngine
|
|||||||
|
|
||||||
private function snapToHour(Automation $auto, string $channel, Carbon $after): Carbon
|
private function snapToHour(Automation $auto, string $channel, Carbon $after): Carbon
|
||||||
{
|
{
|
||||||
$h = $channel === AutomationCatalog::CHANNEL_SMS
|
[$hour, $minute] = $this->scheduledTime($auto, $channel);
|
||||||
? $auto->sms_send_hour
|
if ($hour === null) {
|
||||||
: $auto->in_app_send_hour;
|
|
||||||
if ($h === null) {
|
|
||||||
return $after;
|
return $after;
|
||||||
}
|
}
|
||||||
$local = $after->copy()->timezone('Asia/Tehran');
|
$local = $after->copy()->timezone('Asia/Tehran');
|
||||||
if ((int) $local->hour === (int) $h) {
|
$targetMinute = (int) ($minute ?? 0);
|
||||||
|
if ((int) $local->hour === (int) $hour && (int) $local->minute === $targetMinute) {
|
||||||
return $after;
|
return $after;
|
||||||
}
|
}
|
||||||
$target = $local->copy()->setTime((int) $h, 0, 0);
|
$target = $local->copy()->setTime((int) $hour, $targetMinute, 0);
|
||||||
if ($target->lt($local)) {
|
if ($target->lt($local)) {
|
||||||
$target->addDay();
|
$target->addDay();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('automations')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('automations', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('automations', 'sms_send_minute')) {
|
||||||
|
$table->unsignedTinyInteger('sms_send_minute')->nullable()->after('sms_send_hour');
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn('automations', 'in_app_send_minute')) {
|
||||||
|
$table->unsignedTinyInteger('in_app_send_minute')->nullable()->after('in_app_send_hour');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('automations')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('automations', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('automations', 'sms_send_minute')) {
|
||||||
|
$table->dropColumn('sms_send_minute');
|
||||||
|
}
|
||||||
|
if (Schema::hasColumn('automations', 'in_app_send_minute')) {
|
||||||
|
$table->dropColumn('in_app_send_minute');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,14 +1,27 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# نصب کران قیمت — یکی از دو روش را انتخاب کنید.
|
# نصب کران — برای هر ماژول/tenant یکبار از داخل همان مسیر سرور:
|
||||||
# مسیر را با مسیر واقعی سرور عوض کنید.
|
# cd /opt/test/server && php artisan schedule:bootstrap
|
||||||
|
#
|
||||||
|
# این دستور:
|
||||||
|
# 1) اگر خط cron این ماژول نبود، اضافه میکند
|
||||||
|
# 2) یکبار schedule:run را اجرا میکند
|
||||||
|
#
|
||||||
|
# فقط نصب cron:
|
||||||
|
# php artisan schedule:bootstrap --install-only
|
||||||
|
#
|
||||||
|
# فقط یکبار اجرا:
|
||||||
|
# php artisan schedule:bootstrap --run-only
|
||||||
|
#
|
||||||
|
# دیدن خط بدون نصب:
|
||||||
|
# php artisan schedule:bootstrap --dry-run
|
||||||
APP_DIR="/opt/rahimigold/server"
|
APP_DIR="/opt/rahimigold/server"
|
||||||
|
|
||||||
# ─── روش ۱ (پیشنهادی): schedule:run هر دقیقه ───
|
# ─── روش ۱ (پیشنهادی): schedule:bootstrap ───
|
||||||
# prices:tick-loop داخل Kernel هر ۱۰ ثانیه ۶ بار اجرا میشود.
|
# cd $APP_DIR && php artisan schedule:bootstrap
|
||||||
|
|
||||||
|
# ─── روش ۲: دستی crontab ───
|
||||||
# crontab -e:
|
# crontab -e:
|
||||||
# * * * * * cd $APP_DIR && php artisan schedule:run >> /dev/null 2>&1
|
# * * * * * cd $APP_DIR && php artisan schedule:run >> /dev/null 2>&1
|
||||||
|
|
||||||
# ─── روش ۲: مستقیم هر ۱۰ ثانیه (بدون schedule:run) ───
|
|
||||||
# * * * * * cd $APP_DIR && php artisan prices:tick >> /dev/null 2>&1
|
# * * * * * cd $APP_DIR && php artisan prices:tick >> /dev/null 2>&1
|
||||||
# * * * * * sleep 10 && cd $APP_DIR && php artisan prices:tick >> /dev/null 2>&1
|
# * * * * * sleep 10 && cd $APP_DIR && php artisan prices:tick >> /dev/null 2>&1
|
||||||
# * * * * * sleep 20 && cd $APP_DIR && php artisan prices:tick >> /dev/null 2>&1
|
# * * * * * sleep 20 && cd $APP_DIR && php artisan prices:tick >> /dev/null 2>&1
|
||||||
|
|||||||
Reference in New Issue
Block a user