diff --git a/app/Console/Commands/Store/DeductDailyPointsCommand.php b/app/Console/Commands/Store/DeductDailyPointsCommand.php new file mode 100644 index 0000000000..d0eba4037a --- /dev/null +++ b/app/Console/Commands/Store/DeductDailyPointsCommand.php @@ -0,0 +1,85 @@ +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} 台积分不足的服务器。"); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 56343297c4..365a1b09ed 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -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 { @@ -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. diff --git a/app/Http/Controllers/Admin/Store/OrderAdminController.php b/app/Http/Controllers/Admin/Store/OrderAdminController.php new file mode 100644 index 0000000000..2630484489 --- /dev/null +++ b/app/Http/Controllers/Admin/Store/OrderAdminController.php @@ -0,0 +1,100 @@ +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'); + } +} diff --git a/app/Http/Controllers/Admin/Store/PaymentSettingsController.php b/app/Http/Controllers/Admin/Store/PaymentSettingsController.php new file mode 100644 index 0000000000..d0ab325976 --- /dev/null +++ b/app/Http/Controllers/Admin/Store/PaymentSettingsController.php @@ -0,0 +1,46 @@ + 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'); + } +} diff --git a/app/Http/Controllers/Admin/Store/PointsAdminController.php b/app/Http/Controllers/Admin/Store/PointsAdminController.php new file mode 100644 index 0000000000..fba9fbc513 --- /dev/null +++ b/app/Http/Controllers/Admin/Store/PointsAdminController.php @@ -0,0 +1,66 @@ +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'); + } +} diff --git a/app/Http/Controllers/Admin/Store/ProductController.php b/app/Http/Controllers/Admin/Store/ProductController.php new file mode 100644 index 0000000000..b68e96d055 --- /dev/null +++ b/app/Http/Controllers/Admin/Store/ProductController.php @@ -0,0 +1,125 @@ + Product::query()->orderBy('sort_order')->orderBy('price')->paginate(50), + 'cpu_rate' => $this->calculator->getCpuRatePerCore(), + 'memory_rate' => $this->calculator->getMemoryRatePerGb(), + 'disk_rate' => $this->calculator->getDiskRatePerGb(), + ]); + } + + public function create(): View + { + return view('admin.store.products.new', [ + 'product' => null, + 'locations' => Location::all(), + 'nodes' => Node::all(), + 'eggs' => Egg::with('nest')->orderBy('name')->get(), + 'cpu_rate' => $this->calculator->getCpuRatePerCore(), + 'memory_rate' => $this->calculator->getMemoryRatePerGb(), + 'disk_rate' => $this->calculator->getDiskRatePerGb(), + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $this->validateProduct($request); + Product::create($data); + $this->alert->success('商品已创建。')->flash(); + return redirect()->route('admin.store.products'); + } + + public function edit(int $id): View + { + return view('admin.store.products.new', [ + 'product' => Product::findOrFail($id), + 'locations' => Location::all(), + 'nodes' => Node::all(), + 'eggs' => Egg::with('nest')->orderBy('name')->get(), + 'cpu_rate' => $this->calculator->getCpuRatePerCore(), + 'memory_rate' => $this->calculator->getMemoryRatePerGb(), + 'disk_rate' => $this->calculator->getDiskRatePerGb(), + ]); + } + + public function update(Request $request, int $id): RedirectResponse + { + $data = $this->validateProduct($request); + Product::findOrFail($id)->update($data); + $this->alert->success('商品已更新。')->flash(); + return redirect()->route('admin.store.products'); + } + + public function destroy(int $id): RedirectResponse + { + Product::findOrFail($id)->delete(); + $this->alert->success('商品已删除。')->flash(); + return redirect()->route('admin.store.products'); + } + + private function validateProduct(Request $request): array + { + $isServer = $request->input('type') === 'server'; + + $data = $request->validate([ + 'name' => 'required|string|max:191', + 'description' => 'nullable|string|max:1000', + 'type' => 'required|string|in:points,server_days,server,custom', + 'value' => 'required|integer|min:0', + 'price' => 'required|numeric|min:0', + 'currency' => 'required|string|max:8', + 'is_active' => 'sometimes|boolean', + 'sort_order' => 'nullable|integer|min:0', + // Server package fields + 'location_id' => 'nullable|integer|exists:locations,id', + 'node_id' => 'nullable|integer|exists:nodes,id', + 'egg_id' => 'nullable|integer|exists:eggs,id', + 'cpu' => 'nullable|integer|min:0', + 'memory' => 'nullable|integer|min:0', + 'disk' => 'nullable|integer|min:0', + 'databases' => 'nullable|integer|min:0', + 'backups' => 'nullable|integer|min:0', + 'allocations' => 'nullable|integer|min:0', + // Per-product daily-points rates + 'points_cpu_rate' => 'nullable|integer|min:0', + 'points_memory_rate' => 'nullable|integer|min:0', + 'points_disk_rate' => 'nullable|integer|min:0', + ]); + + $data['is_active'] = $request->boolean('is_active'); + $data['sort_order'] = $data['sort_order'] ?? 0; + + // Clear server-only fields when type is not 'server' + if (!$isServer) { + foreach (['location_id','node_id','egg_id','cpu','memory','disk','databases','backups','allocations', + 'points_cpu_rate','points_memory_rate','points_disk_rate'] as $f) { + $data[$f] = null; + } + } + + return $data; + } +} diff --git a/app/Http/Controllers/Admin/Store/RedemptionCodeAdminController.php b/app/Http/Controllers/Admin/Store/RedemptionCodeAdminController.php new file mode 100644 index 0000000000..269cbdbf6a --- /dev/null +++ b/app/Http/Controllers/Admin/Store/RedemptionCodeAdminController.php @@ -0,0 +1,58 @@ + RedemptionCode::query() + ->withCount('uses') + ->orderByDesc('created_at') + ->paginate(50), + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'code' => 'nullable|string|max:64|unique:redemption_codes,code', + 'type' => 'required|string|in:points,days', + 'value' => 'required|integer|min:1', + 'uses_total' => 'required|integer|min:1', + 'expires_at' => 'nullable|date', + ]); + + // Auto-generate a code if not provided. + $data['code'] = $data['code'] ?: strtoupper(Str::random(12)); + $data['uses_remaining'] = $data['uses_total']; + + RedemptionCode::create($data); + + $this->alert->success("兑换码 [{$data['code']}] 已创建。")->flash(); + + return redirect()->route('admin.store.redemption-codes'); + } + + public function destroy(int $id): RedirectResponse + { + RedemptionCode::findOrFail($id)->delete(); + + $this->alert->success('兑换码已删除。')->flash(); + + return redirect()->route('admin.store.redemption-codes'); + } +} diff --git a/app/Http/Controllers/Admin/Store/TicketAdminController.php b/app/Http/Controllers/Admin/Store/TicketAdminController.php new file mode 100644 index 0000000000..7f0a845331 --- /dev/null +++ b/app/Http/Controllers/Admin/Store/TicketAdminController.php @@ -0,0 +1,94 @@ +with('user') + ->withCount('replies') + ->orderByDesc('created_at'); + + if ($status = $request->input('filter.status')) { + $query->where('status', $status); + } + if ($priority = $request->input('filter.priority')) { + $query->where('priority', $priority); + } + + return view('admin.store.tickets.index', [ + 'tickets' => $query->paginate(30), + ]); + } + + public function view(int $id): View + { + $ticket = Ticket::with([ + 'user', + 'replies' => fn ($q) => $q->with('user')->orderBy('created_at'), + ])->findOrFail($id); + + return view('admin.store.tickets.view', ['ticket' => $ticket]); + } + + public function reply(Request $request, int $id): RedirectResponse + { + $ticket = Ticket::findOrFail($id); + + $data = $request->validate(['content' => 'required|string|max:65535']); + + TicketReply::create([ + 'ticket_id' => $ticket->id, + 'user_id' => $request->user()->id, + 'content' => $data['content'], + 'is_staff' => true, + ]); + + $ticket->update([ + 'last_reply_at' => now(), + 'status' => Ticket::STATUS_IN_PROGRESS, + ]); + + $this->alert->success('回复已发送。')->flash(); + + return redirect()->route('admin.store.tickets.view', $ticket->id); + } + + public function updateStatus(Request $request, int $id): RedirectResponse + { + $ticket = Ticket::findOrFail($id); + + $data = $request->validate([ + 'status' => 'required|string|in:open,in_progress,closed', + ]); + + $ticket->update(['status' => $data['status']]); + + $this->alert->success('工单状态已更新。')->flash(); + + return redirect()->route('admin.store.tickets.view', $ticket->id); + } + + public function destroy(int $id): RedirectResponse + { + Ticket::findOrFail($id)->delete(); + + $this->alert->success('工单已删除。')->flash(); + + return redirect()->route('admin.store.tickets'); + } +} diff --git a/app/Http/Controllers/Api/Client/OrderController.php b/app/Http/Controllers/Api/Client/OrderController.php new file mode 100644 index 0000000000..4770b8866e --- /dev/null +++ b/app/Http/Controllers/Api/Client/OrderController.php @@ -0,0 +1,63 @@ +user()->id) + ->with('product') + ->orderByDesc('created_at') + ->paginate(20); + + return new JsonResponse([ + 'data' => $orders->map(fn (Order $o) => $this->transform($o)), + 'meta' => [ + 'total' => $orders->total(), + 'current_page' => $orders->currentPage(), + 'last_page' => $orders->lastPage(), + ], + ]); + } + + /** + * Cancel a pending order belonging to the authenticated user. + */ + public function cancel(Request $request, string $orderNo): JsonResponse + { + $order = Order::where('order_no', $orderNo) + ->where('user_id', $request->user()->id) + ->firstOrFail(); + + if (!$order->isPending()) { + return new JsonResponse(['message' => '只有待支付订单可以取消。'], 422); + } + + $order->update(['status' => Order::STATUS_CANCELLED]); + + return new JsonResponse(['success' => true]); + } + + private function transform(Order $o): array + { + return [ + 'order_no' => $o->order_no, + 'subject' => $o->subject, + 'product_name' => $o->product?->name, + 'amount' => $o->amount, + 'currency' => $o->currency, + 'status' => $o->status, + 'payment_method' => $o->payment_method, + 'paid_at' => $o->paid_at?->toIso8601String(), + 'created_at' => $o->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Api/Client/PaymentController.php b/app/Http/Controllers/Api/Client/PaymentController.php new file mode 100644 index 0000000000..1496c2eae2 --- /dev/null +++ b/app/Http/Controllers/Api/Client/PaymentController.php @@ -0,0 +1,176 @@ +validate([ + 'product_id' => 'required|integer|exists:products,id', + 'payment_method' => 'required|string|in:alipay,alipay_face,wechat', + ]); + + $method = $request->input('payment_method'); + + // Check that the chosen payment method is actually enabled. + if (!PaymentSetting::isEnabled($method)) { + throw new DisplayException('所选支付方式当前不可用。'); + } + + $product = Product::where('id', $request->input('product_id')) + ->where('is_active', true) + ->firstOrFail(); + + $order = Order::create([ + 'order_no' => date('YmdHis') . strtoupper(Str::random(8)), + 'user_id' => $request->user()->id, + 'product_id' => $product->id, + 'subject' => $product->name, + 'amount' => $product->price, + 'currency' => $product->currency, + 'status' => Order::STATUS_PENDING, + 'payment_method' => $method, + ]); + + $paymentInfo = $this->buildPaymentInfo($order, $method); + + return new JsonResponse([ + 'order_no' => $order->order_no, + 'amount' => $order->amount, + 'currency' => $order->currency, + 'payment_method' => $order->payment_method, + 'payment_info' => $paymentInfo, + ]); + } + + /** + * Query the status of an order. + * For pending orders the payment info is also returned so the user can re-display the QR code. + */ + public function queryOrder(Request $request, string $orderNo): JsonResponse + { + $order = Order::where('order_no', $orderNo) + ->where('user_id', $request->user()->id) + ->firstOrFail(); + + $response = [ + 'order_no' => $order->order_no, + 'status' => $order->status, + 'amount' => $order->amount, + 'paid_at' => $order->paid_at?->toIso8601String(), + ]; + + // Include payment_info so pending orders can show the payment screen again. + if ($order->isPending() && $order->payment_method) { + $response['payment_info'] = $this->buildPaymentInfo($order, $order->payment_method); + } + + return new JsonResponse($response); + } + + /** + * Handle payment callback/notify from payment gateway. + * + * IMPORTANT: In production you MUST verify the cryptographic signature sent + * by the payment provider (Alipay RSA2 / WeChat Pay HMAC-SHA256) before + * processing any order fulfillment. Skipping this check allows anyone to + * forge a successful-payment notification. + */ + public function notify(Request $request, string $method): JsonResponse + { + $orderNo = $request->input('out_trade_no'); + $tradeNo = $request->input('trade_no') ?? $request->input('transaction_id'); + + $order = Order::where('order_no', $orderNo) + ->where('status', Order::STATUS_PENDING) + ->first(); + + if (!$order) { + return new JsonResponse(['success' => false]); + } + + DB::transaction(function () use ($order, $tradeNo) { + $order->update([ + 'status' => Order::STATUS_PAID, + 'payment_trade_no' => $tradeNo, + 'paid_at' => now(), + ]); + + $this->fulfillOrder($order); + }); + + return new JsonResponse(['success' => true]); + } + + /** + * Build payment gateway-specific information. + * In production this would call the actual Alipay/WeChat SDK. + */ + private function buildPaymentInfo(Order $order, string $method): array + { + return match ($method) { + 'alipay' => [ + 'type' => 'alipay_online', + 'instructions' => '请使用支付宝扫描下方二维码完成支付', + 'qr_placeholder' => 'https://qr.alipay.com/placeholder/' . $order->order_no, + ], + 'alipay_face' => [ + 'type' => 'alipay_face_to_face', + 'instructions' => '请向收款方出示下方付款码,或使用支付宝扫码', + 'qr_placeholder' => PaymentSetting::get('alipay_face_code', '(收款码未配置)'), + ], + 'wechat' => [ + 'type' => 'wechat_pay', + 'instructions' => '请使用微信扫描下方二维码完成支付', + 'qr_placeholder' => 'weixin://wxpay/bizpayurl?placeholder=' . $order->order_no, + ], + default => [], + }; + } + + /** + * Fulfill a paid order by granting the product's reward. + */ + public function fulfillOrder(Order $order): void + { + if (!$order->product) { + return; + } + + $product = $order->product; + + if ($product->type === 'points') { + $points = UserPoints::firstOrCreate( + ['user_id' => $order->user_id], + ['balance' => 0] + ); + $points->increment('balance', $product->value); + + PointTransaction::create([ + 'user_id' => $order->user_id, + 'amount' => $product->value, + 'type' => 'earn', + 'description' => "购买商品 [{$product->name}] 获得积分,订单号:{$order->order_no}", + ]); + } + // server and server_days types can be fulfilled here in the future + // once the server provisioning integration is wired up. + } +} diff --git a/app/Http/Controllers/Api/Client/PointsController.php b/app/Http/Controllers/Api/Client/PointsController.php new file mode 100644 index 0000000000..74cc50aed3 --- /dev/null +++ b/app/Http/Controllers/Api/Client/PointsController.php @@ -0,0 +1,40 @@ +user(); + + $points = UserPoints::firstOrCreate( + ['user_id' => $user->id], + ['balance' => 0] + ); + + $transactions = PointTransaction::where('user_id', $user->id) + ->orderByDesc('created_at') + ->limit(20) + ->get(); + + return new JsonResponse([ + 'balance' => $points->balance, + 'transactions' => $transactions->map(fn ($t) => [ + 'id' => $t->id, + 'amount' => $t->amount, + 'type' => $t->type, + 'description' => $t->description, + 'created_at' => $t->created_at?->toIso8601String(), + ]), + ]); + } +} diff --git a/app/Http/Controllers/Api/Client/RedemptionCodeController.php b/app/Http/Controllers/Api/Client/RedemptionCodeController.php new file mode 100644 index 0000000000..aebe4fefd5 --- /dev/null +++ b/app/Http/Controllers/Api/Client/RedemptionCodeController.php @@ -0,0 +1,79 @@ +validate(['code' => 'required|string|max:64']); + + $user = $request->user(); + $code = $request->input('code'); + + $redemptionCode = RedemptionCode::where('code', $code)->first(); + + if (!$redemptionCode || !$redemptionCode->isUsable()) { + throw new DisplayException('兑换码无效或已过期。'); + } + + $alreadyUsed = RedemptionCodeUse::where('redemption_code_id', $redemptionCode->id) + ->where('user_id', $user->id) + ->exists(); + + if ($alreadyUsed) { + throw new DisplayException('您已使用过此兑换码。'); + } + + DB::transaction(function () use ($redemptionCode, $user) { + // Record usage + RedemptionCodeUse::create([ + 'redemption_code_id' => $redemptionCode->id, + 'user_id' => $user->id, + ]); + + // Decrement uses remaining + $redemptionCode->decrement('uses_remaining'); + + // Apply reward based on type + if ($redemptionCode->type === 'points') { + $points = UserPoints::firstOrCreate( + ['user_id' => $user->id], + ['balance' => 0] + ); + $points->increment('balance', $redemptionCode->value); + + PointTransaction::create([ + 'user_id' => $user->id, + 'amount' => $redemptionCode->value, + 'type' => 'redeem_code', + 'description' => "兑换码 [{$redemptionCode->code}] 获得积分", + ]); + } + }); + + return new JsonResponse([ + 'success' => true, + 'type' => $redemptionCode->type, + 'value' => $redemptionCode->value, + 'message' => $redemptionCode->type === 'points' + ? "兑换成功!获得 {$redemptionCode->value} 积分。" + : "兑换成功!", + ]); + } +} diff --git a/app/Http/Controllers/Api/Client/StoreController.php b/app/Http/Controllers/Api/Client/StoreController.php new file mode 100644 index 0000000000..ff7fb611d8 --- /dev/null +++ b/app/Http/Controllers/Api/Client/StoreController.php @@ -0,0 +1,61 @@ +orderBy('sort_order') + ->orderBy('price') + ->get(); + + return new JsonResponse([ + 'products' => $products->map(fn ($p) => $this->transformProduct($p)), + 'payment_methods' => PaymentSetting::enabledMethods(), + ]); + } + + private function transformProduct(Product $p): array + { + $data = [ + 'id' => $p->id, + 'name' => $p->name, + 'description' => $p->description, + 'type' => $p->type, + 'value' => $p->value, + 'price' => $p->price, + 'currency' => $p->currency, + ]; + + // For server packages, include resource configuration. + if ($p->type === 'server') { + $location = $p->location_id ? Location::find($p->location_id) : null; + $node = $p->node_id ? Node::find($p->node_id) : null; + + $data['server_config'] = [ + 'location' => $location ? ['id' => $location->id, 'short' => $location->short, 'long' => $location->long] : null, + 'node' => $node ? ['id' => $node->id, 'name' => $node->name] : null, + 'cpu' => $p->cpu, + 'memory' => $p->memory, + 'disk' => $p->disk, + 'databases' => $p->databases, + 'backups' => $p->backups, + 'allocations' => $p->allocations, + ]; + } + + return $data; + } +} diff --git a/app/Http/Controllers/Api/Client/TicketController.php b/app/Http/Controllers/Api/Client/TicketController.php new file mode 100644 index 0000000000..d8029df878 --- /dev/null +++ b/app/Http/Controllers/Api/Client/TicketController.php @@ -0,0 +1,142 @@ +user()->id) + ->withCount('replies') + ->orderByDesc('created_at') + ->paginate(20); + + return new JsonResponse([ + 'data' => $tickets->map(fn (Ticket $t) => $this->transformSummary($t)), + 'meta' => [ + 'total' => $tickets->total(), + 'current_page' => $tickets->currentPage(), + 'last_page' => $tickets->lastPage(), + ], + ]); + } + + /** + * Create a new support ticket. + */ + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'title' => 'required|string|max:191', + 'content' => 'required|string|max:65535', + 'priority' => 'sometimes|string|in:low,normal,high,urgent', + ]); + + $ticket = Ticket::create([ + 'user_id' => $request->user()->id, + 'title' => $data['title'], + 'content' => $data['content'], + 'priority' => $data['priority'] ?? Ticket::PRIORITY_NORMAL, + 'status' => Ticket::STATUS_OPEN, + ]); + + return new JsonResponse(['ticket' => $this->transformSummary($ticket)], 201); + } + + /** + * View a single ticket with all its replies. + * + * @throws \Pterodactyl\Exceptions\DisplayException + */ + public function show(Request $request, int $id): JsonResponse + { + $ticket = Ticket::where('user_id', $request->user()->id) + ->where('id', $id) + ->with(['replies' => fn ($q) => $q->with('user')->orderBy('created_at')]) + ->firstOrFail(); + + return new JsonResponse(['ticket' => $this->transformDetail($ticket)]); + } + + /** + * Add a reply to an existing ticket. + * + * @throws \Pterodactyl\Exceptions\DisplayException + */ + public function reply(Request $request, int $id): JsonResponse + { + $ticket = Ticket::where('user_id', $request->user()->id) + ->where('id', $id) + ->firstOrFail(); + + if ($ticket->status === Ticket::STATUS_CLOSED) { + throw new DisplayException('工单已关闭,无法回复。'); + } + + $data = $request->validate(['content' => 'required|string|max:65535']); + + TicketReply::create([ + 'ticket_id' => $ticket->id, + 'user_id' => $request->user()->id, + 'content' => $data['content'], + 'is_staff' => false, + ]); + + $ticket->update([ + 'last_reply_at' => now(), + 'status' => Ticket::STATUS_OPEN, + ]); + + return new JsonResponse(['success' => true]); + } + + /** + * Close a ticket. + */ + public function close(Request $request, int $id): JsonResponse + { + $ticket = Ticket::where('user_id', $request->user()->id) + ->where('id', $id) + ->firstOrFail(); + + $ticket->update(['status' => Ticket::STATUS_CLOSED]); + + return new JsonResponse(['success' => true]); + } + + private function transformSummary(Ticket $t): array + { + return [ + 'id' => $t->id, + 'title' => $t->title, + 'status' => $t->status, + 'priority' => $t->priority, + 'replies_count' => $t->replies_count ?? 0, + 'last_reply_at' => $t->last_reply_at?->toIso8601String(), + 'created_at' => $t->created_at?->toIso8601String(), + ]; + } + + private function transformDetail(Ticket $t): array + { + return array_merge($this->transformSummary($t), [ + 'content' => $t->content, + 'replies' => $t->replies->map(fn (TicketReply $r) => [ + 'id' => $r->id, + 'content' => $r->content, + 'is_staff' => $r->is_staff, + 'user_name' => $r->user?->username ?? '—', + 'created_at' => $r->created_at?->toIso8601String(), + ])->all(), + ]); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 0000000000..7318a841f3 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,75 @@ + 'integer', + 'product_id' => 'integer', + 'amount' => 'float', + 'metadata' => 'array', + 'paid_at' => 'datetime', + ]; + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\Pterodactyl\Models\User, $this> + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\Pterodactyl\Models\Product, $this> + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function isPaid(): bool + { + return $this->status === self::STATUS_PAID; + } + + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } +} diff --git a/app/Models/PaymentSetting.php b/app/Models/PaymentSetting.php new file mode 100644 index 0000000000..01977bd5d0 --- /dev/null +++ b/app/Models/PaymentSetting.php @@ -0,0 +1,74 @@ +where('key', $key)->first(); + + return $row?->value ?? $default; + } + + /** + * Set a payment setting value, upserting if needed. + */ + public static function set(string $key, mixed $value): void + { + static::query()->updateOrInsert( + ['key' => $key], + ['value' => $value, 'updated_at' => now()] + ); + } + + /** + * Return all settings as an associative array. + */ + public static function allAsArray(): array + { + return static::query()->pluck('value', 'key')->all(); + } + + /** + * Determine whether a given payment method is enabled. + */ + public static function isEnabled(string $method): bool + { + return (bool) static::get($method . '_enabled', false); + } + + /** + * Return an array of currently enabled payment method keys. + */ + public static function enabledMethods(): array + { + $methods = []; + foreach (['alipay', 'alipay_face', 'wechat'] as $method) { + if (static::isEnabled($method)) { + $methods[] = $method; + } + } + return $methods; + } +} diff --git a/app/Models/PointTransaction.php b/app/Models/PointTransaction.php new file mode 100644 index 0000000000..2534b943a1 --- /dev/null +++ b/app/Models/PointTransaction.php @@ -0,0 +1,38 @@ + 'integer', + 'amount' => 'integer', + ]; + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\Pterodactyl\Models\User, $this> + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 0000000000..ba712b5d56 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,77 @@ + 'integer', + 'price' => 'float', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + 'location_id' => 'integer', + 'node_id' => 'integer', + 'egg_id' => 'integer', + 'cpu' => 'integer', + 'memory' => 'integer', + 'disk' => 'integer', + 'databases' => 'integer', + 'backups' => 'integer', + 'allocations' => 'integer', + 'points_cpu_rate' => 'integer', + 'points_memory_rate' => 'integer', + 'points_disk_rate' => 'integer', + ]; + + public static array $validationRules = [ + 'name' => 'required|string|max:191', + 'type' => 'required|string|in:points,server_days,server,custom', + 'value' => 'required|integer|min:0', + 'price' => 'required|numeric|min:0', + 'currency' => 'required|string|max:8', + 'is_active' => 'sometimes|boolean', + ]; + + /** + * @return \Illuminate\Database\Eloquent\Relations\HasMany<\Pterodactyl\Models\Order, $this> + */ + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } +} diff --git a/app/Models/RedemptionCode.php b/app/Models/RedemptionCode.php new file mode 100644 index 0000000000..1355f30766 --- /dev/null +++ b/app/Models/RedemptionCode.php @@ -0,0 +1,71 @@ + 'integer', + 'uses_total' => 'integer', + 'uses_remaining' => 'integer', + 'is_active' => 'boolean', + 'expires_at' => 'datetime', + ]; + + public static array $validationRules = [ + 'code' => 'required|string|max:64', + 'type' => 'required|string|in:points,days', + 'value' => 'required|integer|min:1', + 'uses_total' => 'required|integer|min:1', + 'expires_at' => 'nullable|date', + ]; + + /** + * Check whether this code is usable (active, not expired, has uses remaining). + */ + public function isUsable(): bool + { + if (!$this->is_active) { + return false; + } + if ($this->uses_remaining <= 0) { + return false; + } + if ($this->expires_at && $this->expires_at->isPast()) { + return false; + } + + return true; + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\HasMany<\Pterodactyl\Models\RedemptionCodeUse, $this> + */ + public function uses(): HasMany + { + return $this->hasMany(RedemptionCodeUse::class, 'redemption_code_id'); + } +} diff --git a/app/Models/RedemptionCodeUse.php b/app/Models/RedemptionCodeUse.php new file mode 100644 index 0000000000..0c48e2f5f7 --- /dev/null +++ b/app/Models/RedemptionCodeUse.php @@ -0,0 +1,44 @@ + 'integer', + 'user_id' => 'integer', + ]; + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\Pterodactyl\Models\RedemptionCode, $this> + */ + public function code(): BelongsTo + { + return $this->belongsTo(RedemptionCode::class, 'redemption_code_id'); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\Pterodactyl\Models\User, $this> + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Server.php b/app/Models/Server.php index bb7dcc0099..312d746cee 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -46,6 +46,8 @@ * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $installed_at + * @property \Illuminate\Support\Carbon|null $expires_at + * @property int|null $points_per_day * @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\ActivityLog[] $activity * @property int|null $activity_count * @property Allocation|null $allocation @@ -198,6 +200,8 @@ class Server extends Model implements Identifiable self::UPDATED_AT => 'datetime', 'deleted_at' => 'datetime', 'installed_at' => 'datetime', + 'expires_at' => 'datetime', + 'points_per_day' => 'integer', ]; /** diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php new file mode 100644 index 0000000000..0ad313e4a3 --- /dev/null +++ b/app/Models/Ticket.php @@ -0,0 +1,64 @@ + 'integer', + 'last_reply_at' => 'datetime', + ]; + + public static array $validationRules = [ + 'title' => 'required|string|max:191', + 'content' => 'required|string|max:65535', + 'priority' => 'sometimes|string|in:low,normal,high,urgent', + ]; + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\Pterodactyl\Models\User, $this> + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\HasMany<\Pterodactyl\Models\TicketReply, $this> + */ + public function replies(): HasMany + { + return $this->hasMany(TicketReply::class); + } +} diff --git a/app/Models/TicketReply.php b/app/Models/TicketReply.php new file mode 100644 index 0000000000..13e0ada268 --- /dev/null +++ b/app/Models/TicketReply.php @@ -0,0 +1,51 @@ + 'integer', + 'user_id' => 'integer', + 'is_staff' => 'boolean', + ]; + + public static array $validationRules = [ + 'content' => 'required|string|max:65535', + ]; + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\Pterodactyl\Models\Ticket, $this> + */ + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\Pterodactyl\Models\User, $this> + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/UserPoints.php b/app/Models/UserPoints.php new file mode 100644 index 0000000000..a6fdef7b2c --- /dev/null +++ b/app/Models/UserPoints.php @@ -0,0 +1,50 @@ + 'integer', + 'balance' => 'integer', + ]; + + public static array $validationRules = [ + 'user_id' => 'required|integer|exists:users,id', + 'balance' => 'required|integer', + ]; + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\Pterodactyl\Models\User, $this> + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\HasMany<\Pterodactyl\Models\PointTransaction, $this> + */ + public function transactions(): HasMany + { + return $this->hasMany(PointTransaction::class, 'user_id', 'user_id'); + } +} diff --git a/app/Services/Store/PointsCalculator.php b/app/Services/Store/PointsCalculator.php new file mode 100644 index 0000000000..2c9823341c --- /dev/null +++ b/app/Services/Store/PointsCalculator.php @@ -0,0 +1,98 @@ +cpuRatePerCore) + + $memoryGb * ($memoryRate ?? $this->memoryRatePerGb) + + $diskGb * ($diskRate ?? $this->diskRatePerGb) + ); + + // Minimum of 1 point per day for any active server. + return max(1, $cost); + } + + /** + * Calculate and return the daily points cost for a Server model. + */ + public function forServer(Server $server): int + { + return $this->calculate($server->cpu, $server->memory, $server->disk); + } + + /** + * Calculate the daily points cost using a Product's per-product rate overrides. + * Falls back to global rates for fields left null. + */ + public function forProduct(Product $product): int + { + return $this->calculate( + $product->cpu ?? 0, + $product->memory ?? 0, + $product->disk ?? 0, + $product->points_cpu_rate, + $product->points_memory_rate, + $product->points_disk_rate, + ); + } + + public function getCpuRatePerCore(): int + { + return $this->cpuRatePerCore; + } + + public function getMemoryRatePerGb(): int + { + return $this->memoryRatePerGb; + } + + public function getDiskRatePerGb(): int + { + return $this->diskRatePerGb; + } +} diff --git a/app/Transformers/Api/Client/ServerTransformer.php b/app/Transformers/Api/Client/ServerTransformer.php index 747d2f2d82..1cc3f6c263 100644 --- a/app/Transformers/Api/Client/ServerTransformer.php +++ b/app/Transformers/Api/Client/ServerTransformer.php @@ -79,6 +79,8 @@ public function transform(Server $server): array // This field is deprecated, please use "status". 'is_installing' => !$server->isInstalled(), 'is_transferring' => !is_null($server->transfer), + 'expires_at' => $server->expires_at?->toIso8601String(), + 'points_per_day' => $server->points_per_day, ]; } diff --git a/database/migrations/2026_03_08_000001_add_expires_at_to_servers_table.php b/database/migrations/2026_03_08_000001_add_expires_at_to_servers_table.php new file mode 100644 index 0000000000..82aa372956 --- /dev/null +++ b/database/migrations/2026_03_08_000001_add_expires_at_to_servers_table.php @@ -0,0 +1,22 @@ +timestamp('expires_at')->nullable()->after('installed_at'); + }); + } + + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('expires_at'); + }); + } +}; diff --git a/database/migrations/2026_03_08_000002_create_user_points_table.php b/database/migrations/2026_03_08_000002_create_user_points_table.php new file mode 100644 index 0000000000..fe24efe4e3 --- /dev/null +++ b/database/migrations/2026_03_08_000002_create_user_points_table.php @@ -0,0 +1,38 @@ +id(); + $table->unsignedInteger('user_id'); + $table->integer('balance')->default(0); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->unique('user_id'); + }); + + Schema::create('point_transactions', function (Blueprint $table) { + $table->id(); + $table->unsignedInteger('user_id'); + $table->integer('amount'); + $table->string('type', 32); // earn, spend, refund, admin_adjust, redeem_code + $table->string('description')->nullable(); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('point_transactions'); + Schema::dropIfExists('user_points'); + } +}; diff --git a/database/migrations/2026_03_08_000003_create_redemption_codes_table.php b/database/migrations/2026_03_08_000003_create_redemption_codes_table.php new file mode 100644 index 0000000000..63a65e22b1 --- /dev/null +++ b/database/migrations/2026_03_08_000003_create_redemption_codes_table.php @@ -0,0 +1,40 @@ +id(); + $table->string('code', 64)->unique(); + $table->string('type', 32)->default('points'); // points, days, product + $table->integer('value')->default(0); // points amount, or days + $table->unsignedInteger('uses_total')->default(1); + $table->unsignedInteger('uses_remaining')->default(1); + $table->timestamp('expires_at')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::create('redemption_code_uses', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('redemption_code_id'); + $table->unsignedInteger('user_id'); + $table->timestamps(); + + $table->foreign('redemption_code_id')->references('id')->on('redemption_codes')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->unique(['redemption_code_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('redemption_code_uses'); + Schema::dropIfExists('redemption_codes'); + } +}; diff --git a/database/migrations/2026_03_08_000004_create_store_tables.php b/database/migrations/2026_03_08_000004_create_store_tables.php new file mode 100644 index 0000000000..8a94c63a3d --- /dev/null +++ b/database/migrations/2026_03_08_000004_create_store_tables.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('type', 32)->default('points'); // points, server_days, custom + $table->integer('value')->default(0); // e.g. number of points or server days + $table->decimal('price', 10, 2)->default(0); + $table->string('currency', 8)->default('CNY'); + $table->boolean('is_active')->default(true); + $table->integer('sort_order')->default(0); + $table->timestamps(); + }); + + Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->string('order_no', 64)->unique(); + $table->unsignedInteger('user_id'); + $table->unsignedBigInteger('product_id')->nullable(); + $table->string('subject')->default(''); + $table->decimal('amount', 10, 2)->default(0); + $table->string('currency', 8)->default('CNY'); + $table->string('status', 32)->default('pending'); // pending, paid, cancelled, refunded + $table->string('payment_method', 32)->nullable(); // alipay, alipay_face, wechat + $table->string('payment_trade_no', 128)->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('product_id')->references('id')->on('products')->onDelete('set null'); + }); + } + + public function down(): void + { + Schema::dropIfExists('orders'); + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2026_03_08_000005_add_points_per_day_to_servers_table.php b/database/migrations/2026_03_08_000005_add_points_per_day_to_servers_table.php new file mode 100644 index 0000000000..58cb66f3e2 --- /dev/null +++ b/database/migrations/2026_03_08_000005_add_points_per_day_to_servers_table.php @@ -0,0 +1,24 @@ +integer('points_per_day')->nullable()->default(null)->after('expires_at'); + }); + } + + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('points_per_day'); + }); + } +}; diff --git a/database/migrations/2026_03_08_000006_expand_products_and_payment_settings.php b/database/migrations/2026_03_08_000006_expand_products_and_payment_settings.php new file mode 100644 index 0000000000..9c144e2df1 --- /dev/null +++ b/database/migrations/2026_03_08_000006_expand_products_and_payment_settings.php @@ -0,0 +1,57 @@ +unsignedInteger('location_id')->nullable()->after('sort_order'); + $table->unsignedBigInteger('node_id')->nullable()->after('location_id'); + $table->unsignedBigInteger('egg_id')->nullable()->after('node_id'); + $table->integer('cpu')->nullable()->comment('CPU limit % (100 = 1 core)')->after('egg_id'); + $table->integer('memory')->nullable()->comment('Memory limit MB')->after('cpu'); + $table->integer('disk')->nullable()->comment('Disk limit MB')->after('memory'); + $table->integer('databases')->nullable()->default(0)->after('disk'); + $table->integer('backups')->nullable()->default(0)->after('databases'); + $table->integer('allocations')->nullable()->default(1)->after('backups'); + }); + + // Payment gateway settings + Schema::create('payment_settings', function (Blueprint $table) { + $table->id(); + $table->string('key', 191)->unique(); + $table->text('value')->nullable(); + $table->timestamps(); + }); + + // Seed default payment settings (all disabled) + DB::table('payment_settings')->insert([ + ['key' => 'alipay_enabled', 'value' => '0', 'created_at' => now(), 'updated_at' => now()], + ['key' => 'alipay_app_id', 'value' => '', 'created_at' => now(), 'updated_at' => now()], + ['key' => 'alipay_private_key', 'value' => '', 'created_at' => now(), 'updated_at' => now()], + ['key' => 'alipay_public_key', 'value' => '', 'created_at' => now(), 'updated_at' => now()], + ['key' => 'alipay_face_enabled','value' => '0', 'created_at' => now(), 'updated_at' => now()], + ['key' => 'alipay_face_code', 'value' => '', 'created_at' => now(), 'updated_at' => now()], + ['key' => 'wechat_enabled', 'value' => '0', 'created_at' => now(), 'updated_at' => now()], + ['key' => 'wechat_app_id', 'value' => '', 'created_at' => now(), 'updated_at' => now()], + ['key' => 'wechat_mch_id', 'value' => '', 'created_at' => now(), 'updated_at' => now()], + ['key' => 'wechat_api_key', 'value' => '', 'created_at' => now(), 'updated_at' => now()], + ]); + } + + public function down(): void + { + Schema::dropIfExists('payment_settings'); + + Schema::table('products', function (Blueprint $table) { + $table->dropColumn(['location_id', 'node_id', 'egg_id', 'cpu', 'memory', 'disk', 'databases', 'backups', 'allocations']); + }); + } +}; diff --git a/database/migrations/2026_03_08_000007_create_tickets_tables.php b/database/migrations/2026_03_08_000007_create_tickets_tables.php new file mode 100644 index 0000000000..d944ea0148 --- /dev/null +++ b/database/migrations/2026_03_08_000007_create_tickets_tables.php @@ -0,0 +1,42 @@ +id(); + $table->unsignedInteger('user_id'); + $table->string('title', 191); + $table->text('content'); + $table->string('status', 32)->default('open'); // open, in_progress, closed + $table->string('priority', 16)->default('normal'); // low, normal, high, urgent + $table->timestamp('last_reply_at')->nullable(); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + + Schema::create('ticket_replies', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('ticket_id'); + $table->unsignedInteger('user_id'); + $table->text('content'); + $table->boolean('is_staff')->default(false); + $table->timestamps(); + + $table->foreign('ticket_id')->references('id')->on('tickets')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticket_replies'); + Schema::dropIfExists('tickets'); + } +}; diff --git a/database/migrations/2026_03_08_000008_add_points_rates_to_products.php b/database/migrations/2026_03_08_000008_add_points_rates_to_products.php new file mode 100644 index 0000000000..72283b856d --- /dev/null +++ b/database/migrations/2026_03_08_000008_add_points_rates_to_products.php @@ -0,0 +1,29 @@ +integer('points_cpu_rate')->nullable() + ->comment('Points per 100% CPU per day (overrides global rate)')->after('allocations'); + $table->integer('points_memory_rate')->nullable() + ->comment('Points per GB memory per day (overrides global rate)')->after('points_cpu_rate'); + $table->integer('points_disk_rate')->nullable() + ->comment('Points per GB disk per day (overrides global rate)')->after('points_memory_rate'); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn(['points_cpu_rate', 'points_memory_rate', 'points_disk_rate']); + }); + } +}; diff --git a/resources/scripts/api/createOrder.ts b/resources/scripts/api/createOrder.ts new file mode 100644 index 0000000000..46400c0d4c --- /dev/null +++ b/resources/scripts/api/createOrder.ts @@ -0,0 +1,41 @@ +import http from '@/api/http'; + +export interface OrderPaymentInfo { + type: string; + instructions: string; + qr_placeholder: string; +} + +export interface OrderResponse { + order_no: string; + amount: number; + currency: string; + payment_method: string; + payment_info: OrderPaymentInfo; +} + +export interface OrderStatus { + order_no: string; + status: string; + amount: number; + paid_at: string | null; + payment_info?: OrderPaymentInfo; +} + +export type PaymentMethod = 'alipay' | 'alipay_face' | 'wechat'; + +export const createOrder = (productId: number, paymentMethod: PaymentMethod): Promise => { + return new Promise((resolve, reject) => { + http.post('/api/client/store/order', { product_id: productId, payment_method: paymentMethod }) + .then(({ data }) => resolve(data)) + .catch(reject); + }); +}; + +export const queryOrder = (orderNo: string): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/client/store/order/${orderNo}`) + .then(({ data }) => resolve(data)) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/getOrders.ts b/resources/scripts/api/getOrders.ts new file mode 100644 index 0000000000..8c012099e9 --- /dev/null +++ b/resources/scripts/api/getOrders.ts @@ -0,0 +1,38 @@ +import http from '@/api/http'; + +export interface Order { + order_no: string; + subject: string; + product_name: string | null; + amount: number; + currency: string; + status: string; + payment_method: string | null; + paid_at: string | null; + created_at: string; +} + +export interface OrdersResult { + data: Order[]; + meta: { + total: number; + current_page: number; + last_page: number; + }; +} + +export const getOrders = (): Promise => { + return new Promise((resolve, reject) => { + http.get('/api/client/orders') + .then(({ data }) => resolve(data)) + .catch(reject); + }); +}; + +export const cancelOrder = (orderNo: string): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/client/orders/${orderNo}`) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/getPoints.ts b/resources/scripts/api/getPoints.ts new file mode 100644 index 0000000000..739ed2730d --- /dev/null +++ b/resources/scripts/api/getPoints.ts @@ -0,0 +1,22 @@ +import http from '@/api/http'; + +export interface PointTransaction { + id: number; + amount: number; + type: string; + description: string | null; + created_at: string; +} + +export interface PointsData { + balance: number; + transactions: PointTransaction[]; +} + +export default (): Promise => { + return new Promise((resolve, reject) => { + http.get('/api/client/points') + .then(({ data }) => resolve(data)) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/getStore.ts b/resources/scripts/api/getStore.ts new file mode 100644 index 0000000000..59f2c3ae21 --- /dev/null +++ b/resources/scripts/api/getStore.ts @@ -0,0 +1,36 @@ +import http from '@/api/http'; + +export interface ServerConfig { + location: { id: number; short: string; long: string } | null; + node: { id: number; name: string } | null; + cpu: number | null; + memory: number | null; + disk: number | null; + databases: number | null; + backups: number | null; + allocations: number | null; +} + +export interface Product { + id: number; + name: string; + description: string | null; + type: string; + value: number; + price: number; + currency: string; + server_config?: ServerConfig; +} + +export interface StoreData { + products: Product[]; + payment_methods: string[]; +} + +export default (): Promise => { + return new Promise((resolve, reject) => { + http.get('/api/client/store') + .then(({ data }) => resolve(data)) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/redeemCode.ts b/resources/scripts/api/redeemCode.ts new file mode 100644 index 0000000000..3b58f93a5d --- /dev/null +++ b/resources/scripts/api/redeemCode.ts @@ -0,0 +1,16 @@ +import http from '@/api/http'; + +export interface RedeemResult { + success: boolean; + type: string; + value: number; + message: string; +} + +export default (code: string): Promise => { + return new Promise((resolve, reject) => { + http.post('/api/client/redeem', { code }) + .then(({ data }) => resolve(data)) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/server/getServer.ts b/resources/scripts/api/server/getServer.ts index 16743ef870..3cd72b95d6 100644 --- a/resources/scripts/api/server/getServer.ts +++ b/resources/scripts/api/server/getServer.ts @@ -60,6 +60,8 @@ export interface Server { isTransferring: boolean; variables: ServerEggVariable[]; allocations: Allocation[]; + expiresAt: string | null; + pointsPerDay: number | null; } export const rawDataToServerObject = ({ attributes: data }: FractalResponseData): Server => ({ @@ -83,6 +85,8 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData) eggFeatures: data.egg_features || [], featureLimits: { ...data.feature_limits }, isTransferring: data.is_transferring, + expiresAt: data.expires_at || null, + pointsPerDay: data.points_per_day ?? null, variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map( rawDataToServerEggVariable ), diff --git a/resources/scripts/api/tickets.ts b/resources/scripts/api/tickets.ts new file mode 100644 index 0000000000..c08f900599 --- /dev/null +++ b/resources/scripts/api/tickets.ts @@ -0,0 +1,73 @@ +import http from '@/api/http'; + +export interface TicketSummary { + id: number; + title: string; + status: string; + priority: string; + replies_count: number; + last_reply_at: string | null; + created_at: string; +} + +export interface TicketReply { + id: number; + content: string; + is_staff: boolean; + user_name: string; + created_at: string; +} + +export interface TicketDetail extends TicketSummary { + content: string; + replies: TicketReply[]; +} + +export interface TicketsResult { + data: TicketSummary[]; + meta: { + total: number; + current_page: number; + last_page: number; + }; +} + +export const getTickets = (): Promise => { + return new Promise((resolve, reject) => { + http.get('/api/client/tickets') + .then(({ data }) => resolve(data)) + .catch(reject); + }); +}; + +export const getTicket = (id: number): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/client/tickets/${id}`) + .then(({ data }) => resolve(data.ticket)) + .catch(reject); + }); +}; + +export const createTicket = (title: string, content: string, priority?: string): Promise => { + return new Promise((resolve, reject) => { + http.post('/api/client/tickets', { title, content, priority: priority ?? 'normal' }) + .then(({ data }) => resolve(data.ticket)) + .catch(reject); + }); +}; + +export const replyTicket = (id: number, content: string): Promise => { + return new Promise((resolve, reject) => { + http.post(`/api/client/tickets/${id}/reply`, { content }) + .then(() => resolve()) + .catch(reject); + }); +}; + +export const closeTicket = (id: number): Promise => { + return new Promise((resolve, reject) => { + http.post(`/api/client/tickets/${id}/close`, {}) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/components/NavigationBar.tsx b/resources/scripts/components/NavigationBar.tsx index ba1177189b..89ecd8d213 100644 --- a/resources/scripts/components/NavigationBar.tsx +++ b/resources/scripts/components/NavigationBar.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useState } from 'react'; import { Link, NavLink } from 'react-router-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCogs, faLayerGroup, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'; +import { faCogs, faLayerGroup, faShoppingCart, faSignOutAlt, faStar, faTicketAlt } from '@fortawesome/free-solid-svg-icons'; import { useStoreState } from 'easy-peasy'; import { ApplicationStore } from '@/state'; import SearchContainer from '@/components/dashboard/search/SearchContainer'; @@ -69,6 +69,21 @@ export default () => { + + + + + + + + + + + + + + + {rootAdmin && ( diff --git a/resources/scripts/components/dashboard/PointsContainer.tsx b/resources/scripts/components/dashboard/PointsContainer.tsx new file mode 100644 index 0000000000..786dc74242 --- /dev/null +++ b/resources/scripts/components/dashboard/PointsContainer.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import useSWR from 'swr'; +import tw from 'twin.macro'; +import PageContentBlock from '@/components/elements/PageContentBlock'; +import Spinner from '@/components/elements/Spinner'; +import ContentBox from '@/components/elements/ContentBox'; +import getPoints, { PointsData } from '@/api/getPoints'; +import getServers from '@/api/getServers'; +import useFlash from '@/plugins/useFlash'; +import { PaginatedResult } from '@/api/http'; +import { Server } from '@/api/server/getServer'; + +const typeLabel: Record = { + earn: '购买获得', + spend: '消费', + refund: '退款', + admin_adjust: '管理员调整', + redeem_code: '兑换码', +}; + +export default () => { + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const { data, error } = useSWR('/api/client/points', () => getPoints()); + const { data: serversData } = useSWR>( + ['/api/client/servers', false, 1], + () => getServers({ page: 1 }) + ); + + React.useEffect(() => { + if (error) clearAndAddHttpError({ key: 'points', error }); + if (!error) clearFlashes('points'); + }, [error]); + + const serversWithCost = serversData?.items.filter((s) => s.pointsPerDay) ?? []; + const totalDailyCost = serversWithCost.reduce((sum, s) => sum + (s.pointsPerDay ?? 0), 0); + + return ( + + {!data ? ( + + ) : ( +
+ {/* Balance + daily cost card */} +
+
+
+

当前积分余额

+

{data.balance.toLocaleString()}

+
+
+
+
+
+

每日扣除(所有服务器)

+

{totalDailyCost.toLocaleString()}

+ {data.balance > 0 && totalDailyCost > 0 && ( +

+ 约可维持 {Math.floor(data.balance / totalDailyCost)} 天 + {serversData && serversData.pagination.total > serversData.pagination.perPage && ( + (仅计算当前页) + )} +

+ )} +
+
🕐
+
+
+ + {/* Per-server cost breakdown */} + {serversWithCost.length > 0 && ( + +
+ {serversWithCost.map((server) => ( +
+
+

{server.name}

+

+ CPU {server.limits.cpu}% / 内存 {server.limits.memory}MB / 磁盘 {server.limits.disk}MB +

+
+ + {server.pointsPerDay} 积分/天 + +
+ ))} +
+
+ )} + + {/* Transaction history */} + + {data.transactions.length === 0 ? ( +

暂无积分记录。

+ ) : ( +
+ {data.transactions.map((t) => ( +
+
+

+ {typeLabel[t.type] || t.type} +

+ {t.description && ( +

{t.description}

+ )} +

+ {new Date(t.created_at).toLocaleString('zh-CN')} +

+
+ = 0 ? tw`text-green-400` : tw`text-red-400`, + ]} + > + {t.amount >= 0 ? '+' : ''} + {t.amount} + +
+ ))} +
+ )} +
+
+ )} +
+ ); +}; diff --git a/resources/scripts/components/dashboard/RedeemContainer.tsx b/resources/scripts/components/dashboard/RedeemContainer.tsx new file mode 100644 index 0000000000..08906c703d --- /dev/null +++ b/resources/scripts/components/dashboard/RedeemContainer.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; +import tw from 'twin.macro'; +import PageContentBlock from '@/components/elements/PageContentBlock'; +import ContentBox from '@/components/elements/ContentBox'; +import redeemCode, { RedeemResult } from '@/api/redeemCode'; +import useFlash from '@/plugins/useFlash'; + +export default () => { + const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash(); + const [code, setCode] = useState(''); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!code.trim()) return; + + clearFlashes('redeem'); + setResult(null); + setLoading(true); + + try { + const res = await redeemCode(code.trim()); + setResult(res); + setCode(''); + addFlash({ + type: 'success', + key: 'redeem', + title: '兑换成功', + message: res.message, + }); + } catch (e) { + clearAndAddHttpError({ key: 'redeem', error: e as any }); + } finally { + setLoading(false); + } + }; + + return ( + +
+ +

+ 输入您的兑换码以获取积分、延长服务器有效期或其他奖励。 +

+
+
+ setCode(e.target.value)} + placeholder={'请输入兑换码…'} + css={tw`flex-1 bg-neutral-800 border border-neutral-600 text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-cyan-500 transition-colors duration-150`} + disabled={loading} + /> + +
+
+ + {result && ( +
+

{result.message}

+
+ )} +
+
+
+ ); +}; diff --git a/resources/scripts/components/dashboard/ServerRow.tsx b/resources/scripts/components/dashboard/ServerRow.tsx index ef76df6dfe..5c16066473 100644 --- a/resources/scripts/components/dashboard/ServerRow.tsx +++ b/resources/scripts/components/dashboard/ServerRow.tsx @@ -1,6 +1,6 @@ import React, { memo, useEffect, useRef, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faEthernet, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons'; +import { faCalendarAlt, faCoins, faEthernet, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons'; import { Link } from 'react-router-dom'; import { Server } from '@/api/server/getServer'; import getServerResourceUsage, { ServerPowerState, ServerStats } from '@/api/server/getServerResourceUsage'; @@ -15,6 +15,19 @@ import isEqual from 'react-fast-compare'; // than the more faded default style. const isAlarmState = (current: number, limit: number): boolean => limit > 0 && current / (limit * 1024 * 1024) >= 0.9; +const formatExpiry = (expiresAt: string | null): { label: string; isExpired: boolean; isExpiringSoon: boolean } => { + if (!expiresAt) return { label: '', isExpired: false, isExpiringSoon: false }; + const now = Date.now(); + const expiry = new Date(expiresAt).getTime(); + const diff = expiry - now; + const isExpired = diff <= 0; + const isExpiringSoon = !isExpired && diff < 7 * 24 * 60 * 60 * 1000; // within 7 days + + const date = new Date(expiresAt); + const label = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + return { label, isExpired, isExpiringSoon }; +}; + const Icon = memo( styled(FontAwesomeIcon)<{ $alarm: boolean }>` ${(props) => (props.$alarm ? tw`text-red-400` : tw`text-neutral-500`)}; @@ -88,6 +101,8 @@ export default ({ server, className }: { server: Server; className?: string }) = const memoryLimit = server.limits.memory !== 0 ? bytesToString(mbToBytes(server.limits.memory)) : '无限制'; const cpuLimit = server.limits.cpu !== 0 ? server.limits.cpu + ' %' : '无限制'; + const expiry = formatExpiry(server.expiresAt); + return (
@@ -99,6 +114,33 @@ export default ({ server, className }: { server: Server; className?: string }) = {!!server.description && (

{server.description}

)} + {expiry.label && ( +

+ + + {expiry.isExpired ? '已到期: ' : '到期: '} + {expiry.label} + +

+ )} + {server.pointsPerDay !== null && server.pointsPerDay !== undefined && ( +

+ + {server.pointsPerDay} 积分/天 +

+ )}
diff --git a/resources/scripts/components/dashboard/store/CheckoutModal.tsx b/resources/scripts/components/dashboard/store/CheckoutModal.tsx new file mode 100644 index 0000000000..030c17b2f9 --- /dev/null +++ b/resources/scripts/components/dashboard/store/CheckoutModal.tsx @@ -0,0 +1,172 @@ +import React, { useState } from 'react'; +import tw from 'twin.macro'; +import Modal from '@/components/elements/Modal'; +import { Product } from '@/api/getStore'; +import { OrderResponse, PaymentMethod } from '@/api/createOrder'; + +const ALL_PAYMENT_METHODS: { key: PaymentMethod; label: string; description: string }[] = [ + { + key: 'alipay', + label: '支付宝(在线)', + description: '使用支付宝 APP 扫码支付', + }, + { + key: 'alipay_face', + label: '支付宝(面对面)', + description: '出示付款码或扫描对方收款码', + }, + { + key: 'wechat', + label: '微信支付', + description: '使用微信扫描支付二维码', + }, +]; + +interface Props { + product: Product | null; + isOpen: boolean; + enabledMethods: string[]; + onClose: () => void; + onCheckout: (paymentMethod: PaymentMethod) => Promise; +} + +export default ({ product, isOpen, enabledMethods, onClose, onCheckout }: Props) => { + const available = ALL_PAYMENT_METHODS.filter((m) => enabledMethods.includes(m.key)); + const [selectedMethod, setSelectedMethod] = useState( + available.length > 0 ? available[0].key : 'alipay' + ); + const [loading, setLoading] = useState(false); + const [orderResult, setOrderResult] = useState(null); + + const handleSubmit = async () => { + setLoading(true); + try { + const result = await onCheckout(selectedMethod); + if (result) { + setOrderResult(result); + } + } catch { + // Error is handled by the parent (StoreContainer) via flash messages. + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setOrderResult(null); + onClose(); + }; + + if (!product) return null; + + return ( + +
+ {orderResult ? ( +
+

订单已创建

+
+

+ 订单号:{orderResult.order_no} +

+

+ 金额: + + ¥{orderResult.amount.toFixed(2)} + +

+ {orderResult.payment_info.instructions && ( +

{orderResult.payment_info.instructions}

+ )} + {orderResult.payment_info.qr_placeholder && ( +
+ {orderResult.payment_info.qr_placeholder.startsWith('data:image') ? ( + {'收款码'} + ) : ( + <> +

(支付二维码将在此显示)

+

+ {orderResult.payment_info.qr_placeholder} +

+ + )} +
+ )} +
+ +
+ ) : ( +
+

购买确认

+

+ {product.name} — ¥{product.price.toFixed(2)} +

+ + {available.length === 0 ? ( +
+

当前暂无可用的支付方式,请联系管理员开启支付方式。

+
+ ) : ( + <> +

请选择支付方式:

+
+ {available.map((m) => ( + + ))} +
+ + )} + +
+ + {available.length > 0 && ( + + )} +
+
+ )} +
+
+ ); +}; diff --git a/resources/scripts/components/dashboard/store/OrdersContainer.tsx b/resources/scripts/components/dashboard/store/OrdersContainer.tsx new file mode 100644 index 0000000000..1df32a9a40 --- /dev/null +++ b/resources/scripts/components/dashboard/store/OrdersContainer.tsx @@ -0,0 +1,188 @@ +import React, { useState } from 'react'; +import useSWR from 'swr'; +import tw from 'twin.macro'; +import PageContentBlock from '@/components/elements/PageContentBlock'; +import Spinner from '@/components/elements/Spinner'; +import ContentBox from '@/components/elements/ContentBox'; +import Modal from '@/components/elements/Modal'; +import { getOrders, cancelOrder, Order, OrdersResult } from '@/api/getOrders'; +import { queryOrder, OrderPaymentInfo } from '@/api/createOrder'; +import useFlash from '@/plugins/useFlash'; + +const statusLabel: Record = { + pending: { text: '待支付', color: '#f39c12' }, + paid: { text: '已支付', color: '#27ae60' }, + cancelled: { text: '已取消', color: '#7f8c8d' }, + refunded: { text: '已退款', color: '#2980b9' }, +}; + +const methodLabel: Record = { + alipay: '支付宝', + alipay_face: '支付宝面对面', + wechat: '微信支付', +}; + +interface ResumeState { + order: Order; + paymentInfo: OrderPaymentInfo; +} + +export default () => { + const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash(); + const [cancelling, setCancelling] = useState(null); + const [resuming, setResuming] = useState(null); + const [resumeModal, setResumeModal] = useState(null); + + const { data, error, mutate } = useSWR('/api/client/orders', () => getOrders()); + + React.useEffect(() => { + if (error) clearAndAddHttpError({ key: 'orders', error }); + if (!error) clearFlashes('orders'); + }, [error]); + + const handleCancel = async (orderNo: string) => { + if (!confirm('确定取消此订单?')) return; + setCancelling(orderNo); + try { + await cancelOrder(orderNo); + addFlash({ type: 'success', key: 'orders', title: '订单已取消', message: `订单 ${orderNo} 已成功取消。` }); + mutate(); + } catch (e) { + clearAndAddHttpError({ key: 'orders', error: e as any }); + } finally { + setCancelling(null); + } + }; + + const handleResume = async (order: Order) => { + setResuming(order.order_no); + try { + const status = await queryOrder(order.order_no); + if (status.payment_info) { + setResumeModal({ order, paymentInfo: status.payment_info }); + } else { + addFlash({ type: 'info', key: 'orders', title: '订单状态已更新', message: '该订单已不再是待支付状态,请刷新。' }); + mutate(); + } + } catch (e) { + clearAndAddHttpError({ key: 'orders', error: e as any }); + } finally { + setResuming(null); + } + }; + + return ( + + {/* Resume payment modal */} + {resumeModal && ( + setResumeModal(null)} dismissable> +
+

继续支付

+
+

+ 订单号:{resumeModal.order.order_no} +

+

+ 金额: + + {resumeModal.order.currency === 'CNY' ? '¥' : resumeModal.order.currency} + {resumeModal.order.amount.toFixed(2)} + +  · {resumeModal.order.payment_method ? (methodLabel[resumeModal.order.payment_method] ?? resumeModal.order.payment_method) : ''} +

+ {resumeModal.paymentInfo.instructions && ( +

{resumeModal.paymentInfo.instructions}

+ )} + {resumeModal.paymentInfo.qr_placeholder && ( +
+ {resumeModal.paymentInfo.qr_placeholder.startsWith('data:image') ? ( + {'收款码'} + ) : ( + <> +

(支付二维码将在此显示)

+

+ {resumeModal.paymentInfo.qr_placeholder} +

+ + )} +
+ )} +
+ +
+
+ )} + + {!data ? ( + + ) : data.data.length === 0 ? ( +

暂无订单记录。

+ ) : ( + +
+ {data.data.map((order: Order) => { + const st = statusLabel[order.status] ?? { text: order.status, color: '#888' }; + return ( +
+
+
+ {order.order_no} + + {st.text} + +
+

{order.product_name ?? order.subject}

+

+ {order.payment_method ? (methodLabel[order.payment_method] ?? order.payment_method) : '—'} +  ·  + {new Date(order.created_at).toLocaleDateString('zh-CN')} + {order.paid_at && ( +  · 支付于 {new Date(order.paid_at).toLocaleDateString('zh-CN')} + )} +

+
+
+ + {order.currency === 'CNY' ? '¥' : order.currency} + {order.amount.toFixed(2)} + + {order.status === 'pending' && ( + <> + + + + )} +
+
+ ); + })} +
+
+ )} +
+ ); +}; diff --git a/resources/scripts/components/dashboard/store/StoreContainer.tsx b/resources/scripts/components/dashboard/store/StoreContainer.tsx new file mode 100644 index 0000000000..36eb51af6d --- /dev/null +++ b/resources/scripts/components/dashboard/store/StoreContainer.tsx @@ -0,0 +1,167 @@ +import React, { useState } from 'react'; +import useSWR from 'swr'; +import tw from 'twin.macro'; +import PageContentBlock from '@/components/elements/PageContentBlock'; +import Spinner from '@/components/elements/Spinner'; +import getStore, { Product, StoreData } from '@/api/getStore'; +import { createOrder, PaymentMethod } from '@/api/createOrder'; +import useFlash from '@/plugins/useFlash'; +import ContentBox from '@/components/elements/ContentBox'; +import CheckoutModal from '@/components/dashboard/store/CheckoutModal'; + +export default () => { + const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash(); + const [selectedProduct, setSelectedProduct] = useState(null); + const [isCheckoutOpen, setIsCheckoutOpen] = useState(false); + + const { data, error } = useSWR('/api/client/store', () => getStore()); + + React.useEffect(() => { + if (error) clearAndAddHttpError({ key: 'store', error }); + if (!error) clearFlashes('store'); + }, [error]); + + const handleBuy = (product: Product) => { + setSelectedProduct(product); + setIsCheckoutOpen(true); + }; + + const handleCheckout = async (paymentMethod: PaymentMethod) => { + if (!selectedProduct) return; + clearFlashes('store'); + try { + const order = await createOrder(selectedProduct.id, paymentMethod); + setIsCheckoutOpen(false); + addFlash({ + type: 'success', + key: 'store', + title: '订单已创建', + message: `订单号:${order.order_no},请完成支付。`, + }); + return order; + } catch (e) { + clearAndAddHttpError({ key: 'store', error: e as any }); + } + }; + + const currencyLabel = (currency: string) => (currency === 'CNY' ? '¥' : currency); + + const typeLabel = (type: string, value: number) => { + switch (type) { + case 'points': + return `${value} 积分`; + case 'server_days': + return `延期 ${value} 天`; + case 'server': + return '服务器套餐'; + default: + return String(value); + } + }; + + return ( + + setIsCheckoutOpen(false)} + onCheckout={handleCheckout} + /> + {!data ? ( + + ) : data.products.length === 0 ? ( +

暂无在售商品。

+ ) : ( +
+ {data.products.map((product) => ( + +
+
+

{product.name}

+ + {product.type === 'points' ? '积分' + : product.type === 'server_days' ? '延期' + : product.type === 'server' ? '服务器' + : '其他'} + +
+ {product.description && ( +

{product.description}

+ )} +

+ 内容:{typeLabel(product.type, product.value)} +

+ + {/* Server package resource details */} + {product.type === 'server' && product.server_config && ( +
+ {product.server_config.location && ( +

+ 📍 {product.server_config.location.short} — {product.server_config.location.long} +

+ )} + {product.server_config.node && ( +

+ 🖥 节点:{product.server_config.node.name} +

+ )} +
+ {product.server_config.cpu != null && ( + + CPU {product.server_config.cpu}% + + )} + {product.server_config.memory != null && ( + + 内存 {(product.server_config.memory / 1024).toFixed(1)}GB + + )} + {product.server_config.disk != null && ( + + 磁盘 {(product.server_config.disk / 1024).toFixed(1)}GB + + )} + {product.server_config.databases != null && product.server_config.databases > 0 && ( + + DB×{product.server_config.databases} + + )} + {product.server_config.backups != null && product.server_config.backups > 0 && ( + + 备份×{product.server_config.backups} + + )} +
+
+ )} +
+
+ + {currencyLabel(product.currency)} + {product.price.toFixed(2)} + + +
+
+ ))} +
+ )} + {data && data.payment_methods.length === 0 && ( +

⚠ 管理员尚未开启任何支付方式,暂时无法购买。

+ )} +
+ ); +}; diff --git a/resources/scripts/components/dashboard/store/TicketDetailContainer.tsx b/resources/scripts/components/dashboard/store/TicketDetailContainer.tsx new file mode 100644 index 0000000000..556c9a932f --- /dev/null +++ b/resources/scripts/components/dashboard/store/TicketDetailContainer.tsx @@ -0,0 +1,179 @@ +import React, { useState } from 'react'; +import useSWR from 'swr'; +import tw from 'twin.macro'; +import { useParams, useHistory } from 'react-router-dom'; +import PageContentBlock from '@/components/elements/PageContentBlock'; +import Spinner from '@/components/elements/Spinner'; +import ContentBox from '@/components/elements/ContentBox'; +import { getTicket, replyTicket, closeTicket, TicketDetail } from '@/api/tickets'; +import useFlash from '@/plugins/useFlash'; + +const statusLabel: Record = { + open: '待处理', in_progress: '处理中', closed: '已关闭', +}; + +const priorityLabel: Record = { + low: '低', normal: '普通', high: '高', urgent: '紧急', +}; + +export default () => { + const { id } = useParams<{ id: string }>(); + const history = useHistory(); + const ticketId = parseInt(id, 10); + + const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash(); + const [replyContent, setReplyContent] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [closing, setClosing] = useState(false); + + const { data, error, mutate } = useSWR( + `/api/client/tickets/${ticketId}`, + () => getTicket(ticketId) + ); + + React.useEffect(() => { + if (error) clearAndAddHttpError({ key: 'ticket-detail', error }); + if (!error) clearFlashes('ticket-detail'); + }, [error]); + + const handleReply = async (e: React.FormEvent) => { + e.preventDefault(); + if (!replyContent.trim()) return; + + setSubmitting(true); + try { + await replyTicket(ticketId, replyContent.trim()); + setReplyContent(''); + addFlash({ type: 'success', key: 'ticket-detail', title: '回复已发送', message: '' }); + mutate(); + } catch (e) { + clearAndAddHttpError({ key: 'ticket-detail', error: e as any }); + } finally { + setSubmitting(false); + } + }; + + const handleClose = async () => { + if (!confirm('确定关闭此工单?关闭后将无法继续回复。')) return; + setClosing(true); + try { + await closeTicket(ticketId); + mutate(); + } catch (e) { + clearAndAddHttpError({ key: 'ticket-detail', error: e as any }); + } finally { + setClosing(false); + } + }; + + if (!data && !error) { + return ; + } + + if (!data) { + return null; + } + + return ( + +
+ {/* Back button */} + + + {/* Ticket header */} +
+
+
+

{data.title}

+
+ + 状态:{statusLabel[data.status] ?? data.status} + + + 优先级:{priorityLabel[data.priority] ?? data.priority} + + + 创建:{new Date(data.created_at).toLocaleString('zh-CN')} + +
+
+ {data.status !== 'closed' && ( + + )} +
+
+ + {/* Conversation */} +
+ {/* Initial message */} +
+

+ 初始消息 · {new Date(data.created_at).toLocaleString('zh-CN')} +

+

{data.content}

+
+ + {/* Replies */} + {data.replies.map((reply) => ( +
+

+ {reply.is_staff ? ( + 工作人员 + ) : ( + {reply.user_name} + )} + {' · '} + {new Date(reply.created_at).toLocaleString('zh-CN')} +

+

{reply.content}

+
+ ))} +
+ + {/* Reply form */} + {data.status !== 'closed' && ( + +
+ +

请粘贴不含头尾 -----BEGIN/END----- 行的纯私钥内容。

+
+
+ + +
+ +
+ + {{-- Alipay Face to Face --}} +
+

支付宝(面对面收款)

+
+
+
+ +
+
+
+ + +

用户选择此支付方式时将展示该收款码图片。

+
+ +
+ + {{-- WeChat Pay --}} +
+

微信支付

+
+
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + + +@endsection diff --git a/resources/views/admin/store/points/index.blade.php b/resources/views/admin/store/points/index.blade.php new file mode 100644 index 0000000000..d2d1964a86 --- /dev/null +++ b/resources/views/admin/store/points/index.blade.php @@ -0,0 +1,126 @@ +@extends('layouts.admin') + +@section('title') + 积分管理 +@endsection + +@section('content-header') +

积分管理查看和调整用户积分余额。

+
+@endsection + +@section('content') +
+
+
+
+

用户积分列表

+
+
+
+ +
+ +
+
+
+
+
+
+ + + + + + + + + + + + @foreach ($users as $user) + + + + + + + + @endforeach + +
ID邮箱用户名积分余额操作
{{ $user->id }}{{ $user->email }}{{ $user->username }} + {{ number_format($user->points_balance) }} + + +
+
+ @if($users->hasPages()) + + @endif +
+
+
+ +{{-- Adjust Points Modal --}} + +@endsection + +@section('footer-scripts') + @parent + +@endsection diff --git a/resources/views/admin/store/products/index.blade.php b/resources/views/admin/store/products/index.blade.php new file mode 100644 index 0000000000..a0562b03f0 --- /dev/null +++ b/resources/views/admin/store/products/index.blade.php @@ -0,0 +1,100 @@ +@extends('layouts.admin') + +@section('title') + 商品管理 +@endsection + +@section('content-header') +

商品管理管理商店中的商品。

+ +@endsection + +@section('content') +
+
+
+
+

积分费率说明

+
+
+

+ 当前积分计算费率(每天): + CPU {{ $cpu_rate }} 积分/核心  |  + 内存 {{ $memory_rate }} 积分/GB  |  + 磁盘 {{ $disk_rate }} 积分/GB +

+

+ 示例(取整后取最小值1):2核 CPU + 2GB 内存 + 10GB 磁盘 ≈ max(1, ceil(2×{{ $cpu_rate }} + 2×{{ $memory_rate }} + 10×{{ $disk_rate }})) = {{ max(1, (int) ceil(2 * $cpu_rate + 2 * $memory_rate + 10 * $disk_rate)) }} 积分/天 +

+
+
+
+
+
+
+
+
+

商品列表

+ +
+
+ + + + + + + + + + + + + + + @foreach ($products as $product) + + + + + + + + + + + @endforeach + +
ID名称类型内容价格状态排序
{{ $product->id }}{{ $product->name }} + @if($product->type === 'points') 积分 + @elseif($product->type === 'server_days') 服务器天数 + @else 自定义 + @endif + {{ $product->value }}{{ $product->currency === 'CNY' ? '¥' : $product->currency }}{{ number_format($product->price, 2) }} + @if($product->is_active) + 已上架 + @else + 已下架 + @endif + {{ $product->sort_order }} +
+ @csrf + @method('DELETE') + +
+
+
+ @if($products->hasPages()) + + @endif +
+
+
+@endsection diff --git a/resources/views/admin/store/products/new.blade.php b/resources/views/admin/store/products/new.blade.php new file mode 100644 index 0000000000..90ac4507d5 --- /dev/null +++ b/resources/views/admin/store/products/new.blade.php @@ -0,0 +1,189 @@ +@extends('layouts.admin') + +@section('title') + {{ $product ? '编辑商品' : '新建商品' }} +@endsection + +@section('content-header') +

{{ $product ? '编辑商品' : '新建商品' }}{{ $product ? '修改现有商品信息' : '在商店中新增商品' }}

+ +@endsection + +@section('content') +
+
+
+
+

商品信息

+
+
+ @csrf + @if($product) @method('POST') @endif +
+ {{-- Base fields --}} +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +

积分类型填积分数量;天数类型填天数;服务器套餐填 0。

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ is_active ?? 1) ? 'checked' : '' }}> + +
+
+ is_active ?? 1) ? 'checked' : '' }}> + +
+
+
+ + {{-- Server package fields (shown only when type = 'server') --}} +
+
+

服务器套餐配置

+

配置用户购买后自动创建的服务器参数(需要节点有空余配额)。

+
+
+ + +
+
+ + +

指定节点后忽略地域设置。

+
+
+ + +
+
+
+
+ + +

100 = 1核

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
每日积分消耗费率 (留空则使用全局费率;不同节点/地域可设置不同价格)
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+@endsection + +@section('footer-scripts') + @parent + +@endsection diff --git a/resources/views/admin/store/redemption-codes/index.blade.php b/resources/views/admin/store/redemption-codes/index.blade.php new file mode 100644 index 0000000000..7d6f6bd3dc --- /dev/null +++ b/resources/views/admin/store/redemption-codes/index.blade.php @@ -0,0 +1,114 @@ +@extends('layouts.admin') + +@section('title') + 兑换码管理 +@endsection + +@section('content-header') +

兑换码管理创建和管理用户兑换码。

+ +@endsection + +@section('content') +
+
+
+
+

创建兑换码

+
+
+ @csrf +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+

兑换码列表

+
+
+ + + + + + + + + + + + + + + @foreach ($codes as $code) + + + + + + + + + + + @endforeach + +
ID兑换码类型数量剩余次数到期时间状态
{{ $code->id }}{{ $code->code }}{{ $code->type === 'points' ? '积分' : '天数' }}{{ $code->value }}{{ $code->uses_remaining }} / {{ $code->uses_total }}{{ $code->expires_at ? $code->expires_at->format('Y-m-d H:i') : '永久' }} + @if($code->is_active && ($code->expires_at === null || $code->expires_at->isFuture()) && $code->uses_remaining > 0) + 有效 + @else + 已失效 + @endif + +
+ @csrf + @method('DELETE') + +
+
+
+ @if($codes->hasPages()) + + @endif +
+
+
+@endsection diff --git a/resources/views/admin/store/tickets/index.blade.php b/resources/views/admin/store/tickets/index.blade.php new file mode 100644 index 0000000000..124b20ed8f --- /dev/null +++ b/resources/views/admin/store/tickets/index.blade.php @@ -0,0 +1,108 @@ +@extends('layouts.admin') + +@section('title') + 工单管理 +@endsection + +@section('content-header') +

工单管理查看和处理用户提交的支持工单。

+ +@endsection + +@section('content') +
+
+
+
+

工单列表

+
+
+
+ + +
+ +
+
+
+
+
+
+ + + + + + + + + + + + + + + + @foreach ($tickets as $ticket) + + + + + + + + + + + + @endforeach + +
ID标题用户优先级状态回复数最后回复创建时间
#{{ $ticket->id }}{{ $ticket->title }} + @if($ticket->user) + {{ $ticket->user->email }} + @else + 已删除 + @endif + + @switch($ticket->priority) + @case('urgent') 紧急 @break + @case('high') @break + @case('normal') 普通 @break + @default + @endswitch + + @switch($ticket->status) + @case('open') 待处理 @break + @case('in_progress') 处理中 @break + @default 已关闭 + @endswitch + {{ $ticket->replies_count }}{{ $ticket->last_reply_at ? $ticket->last_reply_at->format('m-d H:i') : '—' }}{{ $ticket->created_at->format('m-d H:i') }} +
+ @csrf + @method('DELETE') + +
+
+
+ @if($tickets->hasPages()) + + @endif +
+
+
+@endsection diff --git a/resources/views/admin/store/tickets/view.blade.php b/resources/views/admin/store/tickets/view.blade.php new file mode 100644 index 0000000000..c95f560ec7 --- /dev/null +++ b/resources/views/admin/store/tickets/view.blade.php @@ -0,0 +1,117 @@ +@extends('layouts.admin') + +@section('title') + 工单 #{{ $ticket->id }} +@endsection + +@section('content-header') +

工单 #{{ $ticket->id }}{{ $ticket->title }}

+ +@endsection + +@section('content') +
+ {{-- Ticket info + status changer --}} +
+
+

工单信息

+
+
+
提交人
+
+ @if($ticket->user) + {{ $ticket->user->email }} + @else 已删除 @endif +
+
优先级
+
+ @switch($ticket->priority) + @case('urgent') 紧急 @break + @case('high') @break + @case('normal') 普通 @break + @default + @endswitch +
+
当前状态
+
+ @switch($ticket->status) + @case('open') 待处理 @break + @case('in_progress') 处理中 @break + @default 已关闭 + @endswitch +
+
创建时间
+
{{ $ticket->created_at->format('Y-m-d H:i') }}
+
+
+ +
+
+ + {{-- Conversation --}} +
+
+

工单内容与回复

+
+ {{-- Initial message --}} +
+
+ {{ $ticket->user?->username ?? '—' }} + {{ $ticket->created_at->format('Y-m-d H:i') }} +
+
+ {!! nl2br(e($ticket->content)) !!} +
+
+ + @foreach($ticket->replies as $reply) +
+
+ + {{ $reply->is_staff ? '工作人员' : ($reply->user?->username ?? '—') }} + @if($reply->is_staff) Staff @endif + + + {{ $reply->created_at->format('Y-m-d H:i') }} + +
+
+ {!! nl2br(e($reply->content)) !!} +
+
+ @endforeach +
+ @if($ticket->status !== 'closed') + + @endif +
+
+
+@endsection diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 34ae3b2ddb..1d0c197290 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -122,6 +122,37 @@ 预设组 +
  • 商店管理
  • +
  • + + 商品管理 + +
  • +
  • + + 兑换码管理 + +
  • +
  • + + 订单管理 + +
  • +
  • + + 积分管理 + +
  • +
  • + + 支付设置 + +
  • +
  • + + 工单管理 + +
  • diff --git a/routes/admin.php b/routes/admin.php index a1e102eed5..581c903bb0 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -2,6 +2,7 @@ use Illuminate\Support\Facades\Route; use Pterodactyl\Http\Controllers\Admin; +use Pterodactyl\Http\Controllers\Admin\Store; use Pterodactyl\Http\Middleware\Admin\Servers\ServerInstalled; Route::get('/', [Admin\BaseController::class, 'index'])->name('admin.index'); @@ -226,3 +227,50 @@ Route::delete('/egg/{egg:id}', [Admin\Nests\EggController::class, 'destroy']); Route::delete('/egg/{egg:id}/variables/{variable:id}', [Admin\Nests\EggVariableController::class, 'destroy']); }); + +/* +|-------------------------------------------------------------------------- +| Store Management Routes +|-------------------------------------------------------------------------- +| +| Endpoint: /admin/store +| +*/ + +Route::group(['prefix' => 'store'], function () { + // Products + Route::get('/products', [Store\ProductController::class, 'index'])->name('admin.store.products'); + Route::get('/products/new', [Store\ProductController::class, 'create'])->name('admin.store.products.new'); + Route::get('/products/{id}/edit', [Store\ProductController::class, 'edit'])->name('admin.store.products.edit'); + + Route::post('/products', [Store\ProductController::class, 'store']); + Route::post('/products/{id}', [Store\ProductController::class, 'update']); + Route::delete('/products/{id}', [Store\ProductController::class, 'destroy'])->name('admin.store.products.delete'); + + // Redemption Codes + Route::get('/redemption-codes', [Store\RedemptionCodeAdminController::class, 'index'])->name('admin.store.redemption-codes'); + Route::post('/redemption-codes', [Store\RedemptionCodeAdminController::class, 'store']); + Route::delete('/redemption-codes/{id}', [Store\RedemptionCodeAdminController::class, 'destroy'])->name('admin.store.redemption-codes.delete'); + + // Orders + Route::get('/orders', [Store\OrderAdminController::class, 'index'])->name('admin.store.orders'); + Route::post('/orders/{id}/complete', [Store\OrderAdminController::class, 'complete'])->name('admin.store.orders.complete'); + Route::post('/orders/{id}/cancel', [Store\OrderAdminController::class, 'cancel'])->name('admin.store.orders.cancel'); + Route::post('/orders/{id}/refund', [Store\OrderAdminController::class, 'refund'])->name('admin.store.orders.refund'); + Route::delete('/orders/{id}', [Store\OrderAdminController::class, 'destroy'])->name('admin.store.orders.delete'); + + // Points + Route::get('/points', [Store\PointsAdminController::class, 'index'])->name('admin.store.points'); + Route::post('/points/{userId}/adjust', [Store\PointsAdminController::class, 'adjust'])->name('admin.store.points.adjust'); + + // Payment Settings + Route::get('/payment-settings', [Store\PaymentSettingsController::class, 'index'])->name('admin.store.payment-settings'); + Route::post('/payment-settings', [Store\PaymentSettingsController::class, 'update']); + + // Tickets + Route::get('/tickets', [Store\TicketAdminController::class, 'index'])->name('admin.store.tickets'); + Route::get('/tickets/{id}', [Store\TicketAdminController::class, 'view'])->name('admin.store.tickets.view'); + Route::post('/tickets/{id}/reply', [Store\TicketAdminController::class, 'reply'])->name('admin.store.tickets.reply'); + Route::post('/tickets/{id}/status', [Store\TicketAdminController::class, 'updateStatus'])->name('admin.store.tickets.status'); + Route::delete('/tickets/{id}', [Store\TicketAdminController::class, 'destroy'])->name('admin.store.tickets.delete'); +}); diff --git a/routes/api-client.php b/routes/api-client.php index 02d3f6d2bb..5813b89b62 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -8,6 +8,7 @@ use Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication; use Pterodactyl\Http\Middleware\Api\Client\Server\ResourceBelongsToServer; use Pterodactyl\Http\Middleware\Api\Client\Server\AuthenticateServerAccess; +use Pterodactyl\Http\Middleware\Api\Client\RequireClientApiKey; /* |-------------------------------------------------------------------------- @@ -20,6 +21,71 @@ Route::get('/', [Client\ClientController::class, 'index'])->name('api:client.index'); Route::get('/permissions', [Client\ClientController::class, 'permissions']); +/* +|-------------------------------------------------------------------------- +| Points API +|-------------------------------------------------------------------------- +*/ +Route::prefix('/points')->group(function () { + Route::get('/', [Client\PointsController::class, 'index'])->name('api:client.points'); +}); + +/* +|-------------------------------------------------------------------------- +| Orders API +|-------------------------------------------------------------------------- +*/ +Route::prefix('/orders')->group(function () { + Route::get('/', [Client\OrderController::class, 'index'])->name('api:client.orders'); + Route::delete('/{orderNo}', [Client\OrderController::class, 'cancel'])->name('api:client.orders.cancel'); +}); + +/* +|-------------------------------------------------------------------------- +| Tickets API +|-------------------------------------------------------------------------- +*/ +Route::prefix('/tickets')->group(function () { + Route::get('/', [Client\TicketController::class, 'index'])->name('api:client.tickets'); + Route::post('/', [Client\TicketController::class, 'store'])->name('api:client.tickets.store'); + Route::get('/{id}', [Client\TicketController::class, 'show'])->name('api:client.tickets.show'); + Route::post('/{id}/reply', [Client\TicketController::class, 'reply'])->name('api:client.tickets.reply'); + Route::post('/{id}/close', [Client\TicketController::class, 'close'])->name('api:client.tickets.close'); +}); + +/* +|-------------------------------------------------------------------------- +| Redemption Codes API +|-------------------------------------------------------------------------- +*/ +Route::prefix('/redeem')->group(function () { + Route::post('/', [Client\RedemptionCodeController::class, 'redeem'])->name('api:client.redeem'); +}); + +/* +|-------------------------------------------------------------------------- +| Store API +|-------------------------------------------------------------------------- +*/ +Route::prefix('/store')->group(function () { + Route::get('/', [Client\StoreController::class, 'index'])->name('api:client.store'); + Route::post('/order', [Client\PaymentController::class, 'createOrder'])->name('api:client.store.order'); + Route::get('/order/{orderNo}', [Client\PaymentController::class, 'queryOrder'])->name('api:client.store.order.query'); +}); + +/* +|-------------------------------------------------------------------------- +| Payment Notify (webhook, no auth required) +|-------------------------------------------------------------------------- +| Payment gateway (Alipay / WeChat) servers POST here. Auth middleware is +| intentionally stripped — signature verification is performed inside the +| controller before any order is fulfilled. +|-------------------------------------------------------------------------- +*/ +Route::post('/payment/notify/{method}', [Client\PaymentController::class, 'notify']) + ->withoutMiddleware(['auth:sanctum', RequireClientApiKey::class, RequireTwoFactorAuthentication::class]) + ->name('api:client.payment.notify'); + Route::prefix('/account')->middleware(AccountSubject::class)->group(function () { Route::prefix('/')->withoutMiddleware(RequireTwoFactorAuthentication::class)->group(function () { Route::get('/', [Client\AccountController::class, 'index'])->name('api:client.account');