diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 2ae1997..8cbdbf7 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -2,6 +2,7 @@ namespace App\Console; +use App\Jobs\Host\CancelExpiredHostJob; use App\Jobs\Host\DeleteHostJob; use App\Jobs\Host\DispatchHostCostQueueJob; use App\Jobs\Host\ScanErrorHostsJob; @@ -30,7 +31,9 @@ protected function schedule(Schedule $schedule): void $schedule->command('sanctum:prune-expired --hours=24')->daily()->runInBackground()->onOneServer()->name('清理过期的 Token。'); // 扣费 - $schedule->job(new DispatchHostCostQueueJob(now()->minute))->everyMinute()->withoutOverlapping()->onOneServer()->name('部署扣费任务'); + $schedule->job(new DispatchHostCostQueueJob(now(), null, 'hourly'))->everyMinute()->withoutOverlapping()->onOneServer()->name('部署扣费任务 (小时)'); + $schedule->job(new DispatchHostCostQueueJob(now(), null, 'monthly'))->hourly()->withoutOverlapping()->onOneServer()->name('部署扣费任务 (月度)'); + $schedule->job(new CancelExpiredHostJob())->hourly()->withoutOverlapping()->onOneServer()->name('部署清理到期主机任务'); // 获取模块暴露的信息(服务器等,检查模块状态) $schedule->job(new DispatchFetchModuleJob())->withoutOverlapping()->everyMinute()->name('获取模块暴露的信息(服务器等,检查模块状态)'); diff --git a/app/Http/Controllers/Module/HostController.php b/app/Http/Controllers/Module/HostController.php index 585278c..6d7ae46 100644 --- a/app/Http/Controllers/Module/HostController.php +++ b/app/Http/Controllers/Module/HostController.php @@ -38,13 +38,23 @@ public function store(Request $request): Response|JsonResponse 'status' => 'required|in:draft,running,stopped,error,suspended,pending', 'price' => 'required|numeric', 'user_id' => 'required|integer|exists:users,id', + 'managed_price' => 'nullable|numeric', + 'billing_cycle' => 'nullable|in:hourly,monthly', + 'trial_ends_at' => 'nullable|date|after:now', + 'configuration' => 'nullable|array', ]); $user = (new User)->findOrFail($request->input('user_id')); if ($request->input('price') > 0) { - if ($user->balance < 1) { - return $this->error('此用户余额不足,无法开设计费项目。'); + if ($request->billing_cycle === 'hourly') { + if (! $user->hasBalance(1)) { + return $this->error('此用户余额不足,无法开设计费项目。'); + } + } else { + if (! $user->hasBalance($request->input('managed_price', $request->input('price')))) { + return $this->error('此用户余额不足,无法开计月费项目。'); + } } } @@ -58,6 +68,8 @@ public function store(Request $request): Response|JsonResponse 'price' => $request->input('price'), 'managed_price' => $request->input('managed_price'), 'status' => $request->input('status'), + 'billing_cycle' => $request->input('billing_cycle', 'hourly'), + 'trial_ends_at' => $request->input('trial_ends_at'), ]; $host = (new Host)->create($data); @@ -73,9 +85,6 @@ public function store(Request $request): Response|JsonResponse public function show(Host $host): JsonResponse { return $this->success($host); - // - - // dd($host->cost()); } /** @@ -88,10 +97,21 @@ public function update(Request $request, Host $host): JsonResponse { $this->validate($request, [ 'status' => 'sometimes|in:running,stopped,error,suspended,pending', + 'price' => 'sometimes|nullable|numeric', 'managed_price' => 'sometimes|numeric|nullable', + 'configuration' => 'nullable|array', + 'trial_ends_at' => 'nullable|date|after:now', ]); - $update = $request->except(['module_id', 'user_id']); + $update = $request->only([ + 'name', + 'status', + 'price', + 'managed_price', + 'billing_cycle', + 'trial_ends_at', + 'configuration', + ]); $host->update($update); diff --git a/app/Http/Controllers/Web/HostController.php b/app/Http/Controllers/Web/HostController.php index a1bab13..b082e44 100644 --- a/app/Http/Controllers/Web/HostController.php +++ b/app/Http/Controllers/Web/HostController.php @@ -23,12 +23,29 @@ public function index(): View public function update(Request $request, Host $host): RedirectResponse { $request->validate([ - 'status' => 'required|in:running,stopped,suspended', + 'status' => 'nullable|in:running,stopped,suspended', + 'cancel_at_period_end' => 'nullable|boolean', ]); - $status = $host->changeStatus($request->input('status')); + if ($request->filled('status')) { + $status = $host->changeStatus($request->input('status')); - return $status ? back()->with('success', '修改成功。') : back()->with('error', '修改失败。'); + if (! $status) { + return back()->with('error', '在修改主机状态时发生错误。'); + } + } + + if ($request->filled('cancel_at_period_end')) { + if ($host->isHourly()) { + return back()->with('error', '按小时计费的主机无法进行此操作。'); + } + + $host->update([ + 'cancel_at_period_end' => $request->boolean('cancel_at_period_end'), + ]); + } + + return back()->with('info', '更改已应用。'); } /** diff --git a/app/Jobs/Host/DispatchHostCostQueueJob.php b/app/Jobs/Host/DispatchHostCostQueueJob.php index e09f5ca..92836cf 100644 --- a/app/Jobs/Host/DispatchHostCostQueueJob.php +++ b/app/Jobs/Host/DispatchHostCostQueueJob.php @@ -3,6 +3,7 @@ namespace App\Jobs\Host; use App\Models\Host; +use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; @@ -12,19 +13,22 @@ class DispatchHostCostQueueJob implements ShouldQueue { use InteractsWithQueue, Queueable, SerializesModels; - protected int $minute; + protected Carbon $now; protected ?Host $host; + protected string $type; + /** * Create a new job instance. * * @return void */ - public function __construct($minute, Host $host = null) + public function __construct(Carbon $now, Host $host = null, $type = 'hourly') { - $this->minute = $minute; + $this->now = $now; $this->host = $host; + $this->type = $type; $this->onQueue('host-cost'); } @@ -37,18 +41,28 @@ public function handle(): void if (! $this->host) { $host = new Host(); - if (app()->environment() != 'local') { + if ($this->type == 'monthly') { + // 月度计费,需要精确到天和小时 + $host = $host->where('day_at', $this->now->day); + $host = $host->where('hour_at', $this->now->hour); + $host = $host->where('cancel_at_period_end', false); + } elseif (app()->environment() != 'local') { $host = $host->where('minute_at', $this->minute); } - $host->whereIn('status', ['running', 'stopped'])->with(['user', 'module'])->chunk(500, function ($hosts) { + + $host->where('billing_cycle', $this->type)->whereIn('status', ['running', 'stopped'])->with(['user', 'module'])->chunk(500, function ($hosts) { $hosts->each(function ($host) { + /* @var Host $host */ + if ($host->module->isUp()) { - dispatch(new self($this->minute, $host)); + dispatch(new self($this->now, $host, $this->type)); } }); }); } else { - $this->host->cost($this->host->getPrice()); + if (! $this->host->isHourly() && ! $this->host->isNextMonthCancel() && ! $this->host->isTrial()) { + $this->host->cost($this->host->getPrice()); + } } } } diff --git a/app/Models/Host.php b/app/Models/Host.php index 5f2735e..346ab85 100644 --- a/app/Models/Host.php +++ b/app/Models/Host.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Exceptions\User\BalanceNotEnoughException; use App\Jobs\Host\HostJob; use App\Jobs\Host\UpdateOrDeleteHostJob; use GeneaLabs\LaravelModelCaching\Traits\Cachable; @@ -25,13 +26,23 @@ class Host extends Model 'configuration', 'status', 'suspended_at', + 'trial_ends_at', + 'billing_cycle', + 'cancel_at_period_end', + 'last_paid', + 'last_paid_at', + 'expired_at', ]; protected $casts = [ 'price' => 'decimal:2', + 'last_paid' => 'decimal:2', 'managed_price' => 'decimal:2', 'configuration' => 'array', 'suspended_at' => 'datetime', + 'last_paid_at' => 'datetime', + 'trial_ends_at' => 'datetime', + 'expired_at' => 'datetime', ]; /** @noinspection PhpUndefinedMethodInspection */ @@ -111,13 +122,41 @@ public function isSuspended(): bool return $this->status === 'suspended'; } + public function isExpired(): bool + { + return $this->expired_at && $this->expired_at->isPast(); + } + public function safeDelete(): bool { - // 如果创建时间大于 1 小时 - if ($this->created_at->diffInHours(now()) > 1) { - // 如果当前时间比扣费时间小,则说明没有扣费。执行扣费。 - if (now()->minute < $this->minute_at) { - $this->cost(); + if ($this->isHourly()) { + // 如果创建时间大于 1 小时 + if ($this->created_at->diffInHours(now()) > 1) { + // 如果当前时间比扣费时间小,则说明没有扣费。执行扣费。 + if (now()->minute < $this->minute_at) { + $this->cost(); + } + } + } elseif ($this->isMonthly() && $this->last_paid && ! $this->isExpired()) { + // 根据扣费时间,计算出退款金额 + $refund = $this->getRefundAmount(); + + if ($refund) { + // 如果有退款金额,则退款 + $this->module?->reduce($refund, 'module_balance', '主机 '.$this->name.' 退款。', [ + 'host_id' => $this->id, + 'module_id' => $this->module_id, + ]); + $this->user->charge($refund, 'balance', '主机 '.$this->name.' 退款。', [ + 'host_id' => $this->id, + 'module_id' => $this->module_id, + ]); + + // 退款后,更新扣费时间 + $this->update([ + 'last_paid_at' => null, + 'last_paid' => 0, + ]); } } @@ -126,9 +165,52 @@ public function safeDelete(): bool return true; } + public function getRefundAmount(): string|null + { + if (! $this->last_paid_at) { + return null; + } + + // 如果是月付,则按比例 + $days = $this->last_paid_at->daysInMonth; + + // 本月已经过的天数 + $passed_days = $this->last_paid_at->day; + + // 本月还剩下的天数 + $left_days = $days - $passed_days; + + // 计算 + return bcmul($this->last_paid, bcdiv($left_days, $days, 2), 2); + } + + public function isTrial(): bool + { + return $this->trial_ends_at !== null; + } + + public function isMonthly(): bool + { + return $this->billing_cycle === 'monthly'; + } + + public function isHourly(): bool + { + return $this->billing_cycle === 'hourly'; + } + + public function isNextMonthCancel(): bool + { + return $this->cancel_at_period_end; + } + public function cost( string $amount = null, $auto = true, $description = null ): bool { + if ($this->isTrial() && ! $this->trial_ends_at->isPast()) { + return true; + } + $this->load('user'); $user = $this->user; $user->load('user_group'); @@ -157,7 +239,7 @@ public function cost( } } - if ($auto) { + if ($auto && $this->isHourly()) { // 获取本月天数 $days = now()->daysInMonth; // 本月每天的每小时的价格 @@ -195,12 +277,19 @@ public function cost( Cache::put($month_cache_key, $hosts_balances, 604800); - if (! $description) { - $description = '模块发起的扣费。'; + $description = '主机: '.$this->name.', '.$description; + + if ($auto && $this->isHourly()) { + $description .= '小时计费。'; + } elseif ($auto && $this->isMonthly()) { + $description .= '月度计费。'; + } else { + $description .= '扣费。'; } - if ($auto) { - $description = '自动扣费。'; + if ($this->isTrial() && $this->trial_ends_at->isPast()) { + $description .= '试用已过期。'; + $this->trial_ends_at = null; } if ($append_description) { @@ -212,17 +301,29 @@ public function cost( 'module_id' => $this->module_id, ]; - $left = $user->reduce($real_price, $description, false, $data)->user_remain; + $this->last_paid = $real_price; + $this->last_paid_at = now(); + + if ($this->isMonthly()) { + $this->expired_at = now()->addMonth(); + } + + try { + $left = $user->reduce($real_price, $description, ! $this->isHourly(), $data)->user_remain; + } catch (BalanceNotEnoughException) { + $this->changeStatus('suspended'); + + return false; + } $this->addLog($real_price); if ($left < 0) { $this->changeStatus('suspended'); - - $this->last_paid = $real_price; - $this->save(); } + $this->save(); + return true; } @@ -291,7 +392,11 @@ public function changeStatus( return false; } - if (! $user->hasBalance('0.5')) { + if ($this->isMonthly()) { + if (! $user->hasBalance($this->price)) { + return false; + } + } elseif (! $user->hasBalance('0.5')) { return false; } } diff --git a/app/Models/User.php b/app/Models/User.php index 3579207..9d9eeaf 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,6 +6,8 @@ use App\Exceptions\User\BalanceNotEnoughException; use App\Models\Affiliate\Affiliates; use App\Models\Affiliate\AffiliateUser; +use App\Notifications\User\BalanceNotEnough; +use App\Notifications\User\LowBalance; use GeneaLabs\LaravelModelCaching\CachedBuilder; use GeneaLabs\LaravelModelCaching\Traits\Cachable; use Illuminate\Contracts\Auth\MustVerifyEmail; @@ -208,6 +210,9 @@ public function reduce(string|null $amount = '0', string $description = '消费' if ($this->balance < $amount) { if ($fail) { + // 发送邮件通知 + $this->notify(new BalanceNotEnough()); + throw new BalanceNotEnoughException(); } } @@ -229,6 +234,12 @@ public function reduce(string|null $amount = '0', string $description = '消费' broadcast(new Users($this, 'balances.amount.reduced', $this)); + // 如果用户的余额小于 5 元,则发送邮件提醒(一天只发送一次,使用缓存) + if (! $this->hasBalance(5) && ! Cache::has('user_balance_less_than_5_'.$this->id)) { + $this->notify(new LowBalance()); + Cache::put('user_balance_less_than_5_'.$this->id, true, now()->addDay()); + } + return (new Transaction)->create($data); }); } diff --git a/app/Notifications/User/LowBalance.php b/app/Notifications/User/LowBalance.php new file mode 100644 index 0000000..c2ee775 --- /dev/null +++ b/app/Notifications/User/LowBalance.php @@ -0,0 +1,45 @@ +subject('账户余额过低') + ->line('您的账户余额过低。还剩下 '.$user->balance.' 元。') + ->action('充值', route('balances.index')) + ->line('当您的账户欠费时,您的服务将会被暂停。'); + } + + /** + * Get the array representation of the notification. + */ + public function toArray(User $user): array + { + return [ + 'title' => '账户余额过低', + 'message' => '您的账户余额过低。还剩下'.$user->balance.'元。当您的账户欠费时,您的服务将会被暂停。', + ]; + } +} diff --git a/app/Observers/HostObserver.php b/app/Observers/HostObserver.php index fdc93e1..fbf3c39 100644 --- a/app/Observers/HostObserver.php +++ b/app/Observers/HostObserver.php @@ -14,6 +14,7 @@ public function creating(Host $host): void { $host->hour_at = now()->hour; $host->minute_at = now()->minute; + $host->day_at = now()->day; if ($host->price !== null) { $host->price = bcdiv($host->price, 1, 2); @@ -22,6 +23,10 @@ public function creating(Host $host): void if ($host->managed_price !== null) { $host->managed_price = bcdiv($host->managed_price, 1, 2); } + + if (! $host->billing_cycle) { + $host->billing_cycle = 'hourly'; + } } /** @@ -37,6 +42,10 @@ public function created(Host $host): void $host->user->notify(new WebNotification($host, 'hosts.created')); $host->save(); + + if ($host->isMonthly()) { + $host->cost(null, true, '预'); + } } /** diff --git a/resources/views/hosts/index.blade.php b/resources/views/hosts/index.blade.php index e9ddd89..8ce4a96 100644 --- a/resources/views/hosts/index.blade.php +++ b/resources/views/hosts/index.blade.php @@ -38,15 +38,29 @@ {{ $host->price }} 元 @endif
+ @if ($host->isMonthly()) + 月付 + @endif + @if ($host->isNextMonthCancel()) +
+ + 不自动续费 + + @endif {{ $host->updated_at }}
{{ $host->created_at }} + + @if ($host->isTrial()) +
+ 试用到 {{ $host->trial_ends_at }} + @endif
@@ -85,6 +99,22 @@ @endif + @if (!$host->isHourly()) + + {{ $host->isNextMonthCancel() ? '启用自动续订' : '取消自动续订'}} + + +
+ @csrf + @method('PATCH') + +
+ @endif + 删除