增加 周期扣费(月)
This commit is contained in:
parent
d1fad94644
commit
727c1bf07d
@ -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('获取模块暴露的信息(服务器等,检查模块状态)');
|
||||
|
@ -38,14 +38,24 @@ 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) {
|
||||
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('此用户余额不足,无法开计月费项目。');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有 name,则随机
|
||||
@ -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);
|
||||
|
||||
|
@ -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',
|
||||
]);
|
||||
|
||||
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', '更改已应用。');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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 {
|
||||
if (! $this->host->isHourly() && ! $this->host->isNextMonthCancel() && ! $this->host->isTrial()) {
|
||||
$this->host->cost($this->host->getPrice());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,8 +122,14 @@ public function isSuspended(): bool
|
||||
return $this->status === 'suspended';
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expired_at && $this->expired_at->isPast();
|
||||
}
|
||||
|
||||
public function safeDelete(): bool
|
||||
{
|
||||
if ($this->isHourly()) {
|
||||
// 如果创建时间大于 1 小时
|
||||
if ($this->created_at->diffInHours(now()) > 1) {
|
||||
// 如果当前时间比扣费时间小,则说明没有扣费。执行扣费。
|
||||
@ -120,15 +137,80 @@ public function safeDelete(): bool
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(new HostJob($this, 'delete'));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
45
app/Notifications/User/LowBalance.php
Normal file
45
app/Notifications/User/LowBalance.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\User;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Notifications\Channels\WebChannel;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class LowBalance extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*/
|
||||
public function via(): array
|
||||
{
|
||||
return [WebChannel::class, 'mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(User $user): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->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.'元。当您的账户欠费时,您的服务将会被暂停。',
|
||||
];
|
||||
}
|
||||
}
|
@ -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, '预');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -38,15 +38,29 @@
|
||||
{{ $host->price }} 元
|
||||
@endif
|
||||
<br/>
|
||||
@if ($host->isMonthly())
|
||||
<span class="text-muted">月付</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<x-host-status :status="$host->status"/>
|
||||
@if ($host->isNextMonthCancel())
|
||||
<br/>
|
||||
<small>
|
||||
<span class="text-danger">不自动续费</span>
|
||||
</small>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<span class="small">
|
||||
{{ $host->updated_at }}
|
||||
<br/>
|
||||
{{ $host->created_at }}
|
||||
|
||||
@if ($host->isTrial())
|
||||
<br/>
|
||||
<span class="text-danger">试用到 {{ $host->trial_ends_at }}</span>
|
||||
@endif
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@ -85,6 +99,22 @@
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@if (!$host->isHourly())
|
||||
<a class="dropdown-item" href="#"
|
||||
onclick="document.getElementById('update-{{$host->id}}').submit()">
|
||||
{{ $host->isNextMonthCancel() ? '启用自动续订' : '取消自动续订'}}
|
||||
</a>
|
||||
|
||||
<form action="{{ route('hosts.update', $host) }}"
|
||||
id="update-{{$host->id}}"
|
||||
method="post" class="d-none">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<input type="hidden" name="cancel_at_period_end"
|
||||
value="{{ !$host->isNextMonthCancel() ? '1' : '0'}}">
|
||||
</form>
|
||||
@endif
|
||||
|
||||
<a class="dropdown-item" href="#"
|
||||
onclick="return confirm('删除操作将不可恢复,确定吗?') ? document.getElementById('delete-{{$host->id}}').submit() : false;">
|
||||
删除
|
||||
|
Loading…
Reference in New Issue
Block a user