增加 周期扣费(月)

This commit is contained in:
iVampireSP.com 2023-03-08 00:45:29 +08:00
parent d1fad94644
commit 727c1bf07d
No known key found for this signature in database
GPG Key ID: 2F7B001CA27A8132
9 changed files with 286 additions and 32 deletions

View File

@ -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('获取模块暴露的信息(服务器等,检查模块状态)');

View File

@ -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);

View File

@ -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', '更改已应用。');
}
/**

View File

@ -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());
}
}
}
}

View File

@ -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;
}
}

View File

@ -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);
});
}

View 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.'元。当您的账户欠费时,您的服务将会被暂停。',
];
}
}

View File

@ -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, '预');
}
}
/**

View File

@ -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;">
删除