Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions app/Console/Commands/Store/DeductDailyPointsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

namespace Pterodactyl\Console\Commands\Store;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\UserPoints;
use Pterodactyl\Models\PointTransaction;
use Pterodactyl\Services\Store\PointsCalculator;

/**
* Deducts daily points from each user based on the servers they own.
* Servers that are suspended or have no point cost assigned are skipped.
* If a user runs out of points their server is suspended automatically.
*
* Schedule: daily (registered in Console\Kernel)
*/
class DeductDailyPointsCommand extends Command
{
protected $signature = 'p:store:deduct-daily-points';

protected $description = 'Deduct daily points from users based on their running servers\' resource costs.';

public function __construct(private readonly PointsCalculator $calculator)
{
parent::__construct();
}

public function handle(): void
{
// Only process fully installed, non-suspended servers.
$servers = Server::query()
->where(function ($q) {
$q->whereNull('status')->orWhere('status', '');
})
->get()
->filter(fn (Server $s) => $s->isInstalled() && !$s->isSuspended());

$deductedCount = 0;
$suspendedCount = 0;

foreach ($servers as $server) {
$cost = $server->points_per_day ?? $this->calculator->forServer($server);

// Persist the calculated cost if not already stored.
if (is_null($server->points_per_day)) {
$server->forceFill(['points_per_day' => $cost])->save();
}

DB::transaction(function () use ($server, $cost, &$deductedCount, &$suspendedCount) {
$points = UserPoints::firstOrCreate(
['user_id' => $server->owner_id],
['balance' => 0]
);

if ($points->balance < $cost) {
// Not enough points — suspend the server.
$server->forceFill(['status' => Server::STATUS_SUSPENDED])->save();
$suspendedCount++;

PointTransaction::create([
'user_id' => $server->owner_id,
'amount' => 0,
'type' => 'spend',
'description' => "积分不足,服务器 [{$server->name}] 已被暂停。",
]);
} else {
$points->decrement('balance', $cost);

PointTransaction::create([
'user_id' => $server->owner_id,
'amount' => -$cost,
'type' => 'spend',
'description' => "服务器 [{$server->name}] 每日扣除 {$cost} 积分。",
]);

$deductedCount++;
}
});
}

$this->info("完成:已从 {$deductedCount} 台服务器扣除积分,已暂停 {$suspendedCount} 台积分不足的服务器。");
}
}
2 changes: 2 additions & 0 deletions app/Console/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Pterodactyl\Console\Commands\Schedule\ProcessRunnableCommand;
use Pterodactyl\Console\Commands\Maintenance\PruneOrphanedBackupsCommand;
use Pterodactyl\Console\Commands\Maintenance\CleanServiceBackupFilesCommand;
use Pterodactyl\Console\Commands\Store\DeductDailyPointsCommand;

class Kernel extends ConsoleKernel
{
Expand All @@ -34,6 +35,7 @@ protected function schedule(Schedule $schedule): void
// Execute scheduled commands for servers every minute, as if there was a normal cron running.
$schedule->command(ProcessRunnableCommand::class)->everyMinute()->withoutOverlapping();
$schedule->command(CleanServiceBackupFilesCommand::class)->daily();
$schedule->command(DeductDailyPointsCommand::class)->dailyAt('03:00');

if (config('backups.prune_age')) {
// Every 30 minutes, run the backup pruning command so that any abandoned backups can be deleted.
Expand Down
100 changes: 100 additions & 0 deletions app/Http/Controllers/Admin/Store/OrderAdminController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

namespace Pterodactyl\Http\Controllers\Admin\Store;

use Illuminate\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Prologue\Alerts\AlertsMessageBag;
use Pterodactyl\Models\Order;
use Pterodactyl\Http\Controllers\Api\Client\PaymentController;
use Pterodactyl\Http\Controllers\Controller;

class OrderAdminController extends Controller
{
public function __construct(
private readonly AlertsMessageBag $alert,
private readonly PaymentController $payment,
) {
}

public function index(Request $request): View
{
$query = Order::query()->with(['user', 'product'])->orderByDesc('created_at');

if ($search = $request->input('filter.order_no')) {
$query->where('order_no', 'like', "%{$search}%");
}
if ($status = $request->input('filter.status')) {
$query->where('status', $status);
}

return view('admin.store.orders.index', [
'orders' => $query->paginate(30),
]);
}

/**
* Manually mark an order as paid and trigger fulfillment.
*/
public function complete(int $id): RedirectResponse
{
$order = Order::findOrFail($id);

if ($order->isPaid()) {
$this->alert->warning('该订单已经是已支付状态。')->flash();
return redirect()->route('admin.store.orders');
}

DB::transaction(function () use ($order) {
$order->update([
'status' => Order::STATUS_PAID,
'paid_at' => now(),
]);
$this->payment->fulfillOrder($order);
});

$this->alert->success("订单 [{$order->order_no}] 已手动标记为已支付并完成发货。")->flash();

return redirect()->route('admin.store.orders');
}

/**
* Cancel a pending or paid order.
*/
public function cancel(int $id): RedirectResponse
{
$order = Order::findOrFail($id);
$order->update(['status' => Order::STATUS_CANCELLED]);

$this->alert->success("订单 [{$order->order_no}] 已取消。")->flash();

return redirect()->route('admin.store.orders');
}

/**
* Mark an order as refunded.
*/
public function refund(int $id): RedirectResponse
{
$order = Order::findOrFail($id);
$order->update(['status' => Order::STATUS_REFUNDED]);

$this->alert->success("订单 [{$order->order_no}] 已标记为已退款。")->flash();

return redirect()->route('admin.store.orders');
}

/**
* Permanently delete an order.
*/
public function destroy(int $id): RedirectResponse
{
Order::findOrFail($id)->delete();

$this->alert->success('订单已删除。')->flash();

return redirect()->route('admin.store.orders');
}
}
46 changes: 46 additions & 0 deletions app/Http/Controllers/Admin/Store/PaymentSettingsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

namespace Pterodactyl\Http\Controllers\Admin\Store;

use Illuminate\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Prologue\Alerts\AlertsMessageBag;
use Pterodactyl\Models\PaymentSetting;
use Pterodactyl\Http\Controllers\Controller;

class PaymentSettingsController extends Controller
{
public function __construct(private readonly AlertsMessageBag $alert)
{
}

public function index(): View
{
return view('admin.store.payment-settings.index', [
'settings' => PaymentSetting::allAsArray(),
]);
}

public function update(Request $request): RedirectResponse
{
$keys = [
'alipay_enabled', 'alipay_app_id', 'alipay_private_key', 'alipay_public_key',
'alipay_face_enabled', 'alipay_face_code',
'wechat_enabled', 'wechat_app_id', 'wechat_mch_id', 'wechat_api_key',
];

foreach ($keys as $key) {
// Checkboxes send no value when unchecked, so treat absence as '0'.
if (str_ends_with($key, '_enabled')) {
PaymentSetting::set($key, $request->has($key) ? '1' : '0');
} else {
PaymentSetting::set($key, $request->input($key, ''));
}
}

$this->alert->success('支付设置已保存。')->flash();

return redirect()->route('admin.store.payment-settings');
}
}
66 changes: 66 additions & 0 deletions app/Http/Controllers/Admin/Store/PointsAdminController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

namespace Pterodactyl\Http\Controllers\Admin\Store;

use Illuminate\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Prologue\Alerts\AlertsMessageBag;
use Pterodactyl\Models\User;
use Pterodactyl\Models\UserPoints;
use Pterodactyl\Models\PointTransaction;
use Pterodactyl\Http\Controllers\Controller;

class PointsAdminController extends Controller
{
public function __construct(private readonly AlertsMessageBag $alert)
{
}

public function index(Request $request): View
{
$query = User::query()
->select('users.*')
->selectRaw('COALESCE(up.balance, 0) as points_balance')
->leftJoin('user_points as up', 'up.user_id', '=', 'users.id');

if ($search = $request->input('filter.email')) {
$query->where('users.email', 'like', "%{$search}%");
}

return view('admin.store.points.index', [
'users' => $query->orderByDesc('points_balance')->paginate(50),
]);
}

public function adjust(Request $request, int $userId): RedirectResponse
{
$data = $request->validate([
'amount' => 'required|integer|not_in:0',
'description' => 'nullable|string|max:191',
]);

$user = User::findOrFail($userId);

DB::transaction(function () use ($user, $data) {
$points = UserPoints::firstOrCreate(
['user_id' => $user->id],
['balance' => 0]
);
$points->increment('balance', $data['amount']);

PointTransaction::create([
'user_id' => $user->id,
'amount' => $data['amount'],
'type' => 'admin_adjust',
'description' => $data['description'] ?? '管理员调整',
]);
});

$verb = $data['amount'] > 0 ? '增加' : '减少';
$this->alert->success("已为用户 {$user->email} {$verb} " . abs($data['amount']) . " 积分。")->flash();

return redirect()->route('admin.store.points');
}
}
Loading