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