feat: Enhance service management and transaction handling

- Implemented client access token verification middleware to secure API endpoints.
- Updated ServiceController to fetch active hosting services for authenticated users.
- Refactored OrderController to ensure order retrieval is customer-specific.
- Improved error logging in VmController for unexpected vCenter responses.
- Disabled VNPay auto-deposit in TransactionController, switching to manual deposit flow.
- Enhanced ServiceClientDetailResource to include Plesk credentials for hosting accounts.
- Adjusted VpsServiceResource to dynamically set page type based on product type.
- Added logging for PowerShellService to capture errors in RDP port changes.
- Provisioning job now resolves service plans and logs relevant information for hosting accounts.
- Updated database migrations to add necessary fields for service plans and DNS servers.
- Refactored Service and ServicePlan models to establish relationships with hosting accounts and DNS servers.
This commit is contained in:
baodev0
2026-08-17 11:55:48 +07:00
parent b03ee9fc2d
commit 2deb4ad74e
28 changed files with 489 additions and 229 deletions
+3 -1
View File
@@ -47,7 +47,9 @@
"Bash(node -e \"console.log\\(require.resolve\\('ag-grid-react/package.json'\\)\\)\")",
"Bash(awk 'NR==1,NR==900' routes/api.php)",
"Bash(sed -i '' -e 's/#c93131/#F16723/g' -e 's/#FFF6F6/#FFF1E6/g' -e 's/#ea4544/#F16723/g' -e 's/#EA4544/#F16723/g' -e 's/#e0cccc/#e6e6e6/g' portal/index.html)",
"Bash(sed -i '' -e 's/#c93131/#F16723/g' -e 's/#FFF6F6/#FFF1E6/g' -e 's/#ea4544/#F16723/g' -e 's/#EA4544/#F16723/g' -e 's/#e0cccc/#e6e6e6/g' index.html)"
"Bash(sed -i '' -e 's/#c93131/#F16723/g' -e 's/#FFF6F6/#FFF1E6/g' -e 's/#ea4544/#F16723/g' -e 's/#EA4544/#F16723/g' -e 's/#e0cccc/#e6e6e6/g' index.html)",
"Bash(xargs -I{} basename {})",
"Bash(uniq -c -w7)"
]
}
}
+4
View File
@@ -83,6 +83,10 @@ VNPAY_API_URL=http://sandbox.vnpayment.vn/merchant_webapi/merchant.html
VNPAY_TRANSACTION_API=https://sandbox.vnpayment.vn/merchant_webapi/api/transaction
FRONTEND_URL=https://portal.test
# openssl rand -hex 32
CLIENT_API_ACCESS_KEY=
CLIENT_API_ACCESS_SECRET="tgs-client-api"
WMKS_HOST=10.10.10.120
@@ -22,6 +22,7 @@ class ServerController extends Controller
$perPage = min((int) $request->get('per_page', 15), 500);
$filter = $request->get('filter');
$status = $request->get('status');
$type = $request->get('type');
$query = $this->serverRepository->model->newQuery();
@@ -37,6 +38,14 @@ class ServerController extends Controller
$query->where('status', $status);
}
if ($type === 'hosting') {
$query->whereIn('type', ['hosting', 'both']);
} elseif ($type === 'domain') {
$query->whereIn('type', ['domain', 'both']);
} elseif ($type !== null && $type !== '') {
$query->where('type', $type);
}
$paginator = $query->orderByDesc('id')->paginate($perPage);
return response()->json([
@@ -70,6 +79,7 @@ class ServerController extends Controller
$validated = $request->validate([
'name' => 'required|string|max:255',
'ip_address' => 'required|string|max:255',
'panel_port' => 'nullable|integer|min:1|max:65535',
'username' => 'required|string|max:255',
'password' => 'required|string|max:255',
'api_key' => 'nullable|string',
@@ -83,6 +93,7 @@ class ServerController extends Controller
$server = $this->serverRepository->create([
'name' => $validated['name'],
'ip_address' => $validated['ip_address'],
'panel_port' => $validated['panel_port'] ?? 8880,
'username' => $validated['username'],
'password' => Crypt::encryptString($validated['password']),
'api_key' => $validated['api_key'] ?? null,
@@ -114,6 +125,7 @@ class ServerController extends Controller
$validated = $request->validate([
'name' => 'sometimes|required|string|max:255',
'ip_address' => 'sometimes|required|string|max:255',
'panel_port' => 'nullable|integer|min:1|max:65535',
'username' => 'sometimes|required|string|max:255',
'password' => 'nullable|string|max:255',
'api_key' => 'nullable|string',
@@ -182,6 +194,7 @@ class ServerController extends Controller
'id' => $server->id,
'name' => $server->name,
'ip_address' => $server->ip_address,
'panel_port' => $server->panel_port,
'username' => $server->username,
'api_key' => $server->api_key,
'status' => $server->status,
@@ -11,14 +11,14 @@ class ServicePlanController extends Controller
{
public function index(): JsonResponse
{
$plans = ServicePlan::with('plesk')->orderBy('title')->get();
$plans = ServicePlan::with('plesk', 'dnsServer')->orderBy('title')->get();
return response()->json(['error' => false, 'data' => $plans]);
}
public function show(int $id): JsonResponse
{
$plan = ServicePlan::with('plesk')->find($id);
$plan = ServicePlan::with('plesk', 'dnsServer')->find($id);
if (!$plan) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy gói hosting.'], 404);
@@ -30,21 +30,23 @@ class ServicePlanController extends Controller
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'vcpu' => 'required|integer|min:0',
'ram' => 'required|integer|min:0',
'disk' => 'required|integer|min:0',
'domain' => 'required|integer|min:0',
'plesk_plan_name' => 'nullable|string|max:255',
'note' => 'nullable|string',
'title' => 'required|string|max:255',
'vcpu' => 'required|integer|min:0',
'ram' => 'required|integer|min:0',
'disk' => 'required|integer|min:0',
'domain' => 'required|integer|min:0',
'dns_manager_server_id' => 'nullable|integer|exists:dns_manager_servers,id',
'plesk_plan_name' => 'nullable|string|max:255',
'note' => 'nullable|string',
]);
$plan = ServicePlan::create([
'title' => $validated['title'],
'vcpu' => $validated['vcpu'],
'ram' => $validated['ram'],
'disk' => $validated['disk'],
'domain' => $validated['domain'],
'title' => $validated['title'],
'vcpu' => $validated['vcpu'],
'ram' => $validated['ram'],
'disk' => $validated['disk'],
'domain' => $validated['domain'],
'dns_manager_server_id' => $validated['dns_manager_server_id'] ?? null,
]);
$this->savePlesk($plan, $validated);
@@ -52,7 +54,7 @@ class ServicePlanController extends Controller
return response()->json([
'error' => false,
'message' => 'Tạo gói hosting thành công.',
'data' => $plan->load('plesk'),
'data' => $plan->load('plesk', 'dnsServer'),
], 201);
}
@@ -65,21 +67,23 @@ class ServicePlanController extends Controller
}
$validated = $request->validate([
'title' => 'required|string|max:255',
'vcpu' => 'required|integer|min:0',
'ram' => 'required|integer|min:0',
'disk' => 'required|integer|min:0',
'domain' => 'required|integer|min:0',
'plesk_plan_name' => 'nullable|string|max:255',
'note' => 'nullable|string',
'title' => 'required|string|max:255',
'vcpu' => 'required|integer|min:0',
'ram' => 'required|integer|min:0',
'disk' => 'required|integer|min:0',
'domain' => 'required|integer|min:0',
'dns_manager_server_id' => 'nullable|integer|exists:dns_manager_servers,id',
'plesk_plan_name' => 'nullable|string|max:255',
'note' => 'nullable|string',
]);
$plan->update([
'title' => $validated['title'],
'vcpu' => $validated['vcpu'],
'ram' => $validated['ram'],
'disk' => $validated['disk'],
'domain' => $validated['domain'],
'title' => $validated['title'],
'vcpu' => $validated['vcpu'],
'ram' => $validated['ram'],
'disk' => $validated['disk'],
'domain' => $validated['domain'],
'dns_manager_server_id' => $validated['dns_manager_server_id'] ?? null,
]);
$this->savePlesk($plan, $validated);
@@ -87,7 +91,7 @@ class ServicePlanController extends Controller
return response()->json([
'error' => false,
'message' => 'Cập nhật gói hosting thành công.',
'data' => $plan->fresh('plesk'),
'data' => $plan->fresh(['plesk', 'dnsServer']),
]);
}
@@ -53,21 +53,17 @@ class ServiceController extends Controller
public function indexHosting(Request $request): JsonResponse
{
$perPage = min((int) $request->get('per_page', 15), 100);
$customer = $request->user();
return response()->json([
'error' => false,
'message' => 'Lấy dữ liệu thành công',
'page_type' => 'service_hosting',
'data' => [
'items' => [],
'pagination' => [
'total' => 0,
'per_page' => $perPage,
'current_page' => 1,
'last_page' => 1,
],
],
]);
$services = $this->serviceClientService->getActiveServices($customer->id, 'hosting', $perPage);
return (new VpsServiceCollection($services))
->additional([
'error' => false,
'message' => 'Lấy dữ liệu thành công',
'page_type' => 'service_hosting',
])
->response();
}
public function indexWebDesign(Request $request): JsonResponse
@@ -128,13 +128,14 @@ class OrderControllerRefactor extends Controller
public function retryOrderProvisioning(Request $request, int $id): JsonResponse
{
$order = $this->orderRepository->find($id);
$customerId = (int) $request->user()->id;
$order = $this->orderRepository->findForCustomer($id, $customerId);
if (! $order) {
return response()->json(['success' => false, 'message' => 'Đơn hàng không tồn tại.'], 404);
}
$result = $this->orderQueryService->retryProvisioning($order, (int) $request->user()->id);
$result = $this->orderQueryService->retryProvisioning($order, $customerId);
if (! $result['ok']) {
return response()->json(['success' => false, 'message' => $result['message']], $result['code']);
@@ -262,10 +262,14 @@ class VmController extends Controller
], $ok ? 200 : 400);
}
\Illuminate\Support\Facades\Log::error('changePort: unexpected vCenter response', [
'vm_id' => $vm->id,
'result' => $result,
]);
return response()->json([
'success' => false,
'message' => 'Phản hồi không hợp lệ từ vCenter.',
'data' => ['raw' => $result],
], 400);
}
@@ -280,9 +284,9 @@ class VmController extends Controller
return response()->json(['error' => true, 'message' => 'Please choose product']);
}
$vm = ModuleVmware::with('vCenter')->find((int) $productId);
if (!$vm) {
return response()->json(['error' => true, 'message' => "Can't find product"]);
$vm = $this->resolveVmById($productId, $request);
if ($vm instanceof JsonResponse) {
return $vm;
}
$result = $this->getConsoleTicket($serviceVCenter, $vm);
@@ -29,46 +29,52 @@ class TransactionController extends Controller
$this->activityLogger = $activityLogger;
}
// ponytail: VNPay auto-deposit disabled — nạp tiền hiện dùng luồng thủ công (ManualDepositController).
public function handle(DepositRequest $request)
{
$user = $request->user();
$transRef = time();
$uuid = Str::uuid();
$publicCode = 'DEP-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6));
$transaction = Transaction::create([
'uuid' => $uuid,
'public_code' => $publicCode,
'trans' => 'DEP' . $transRef,
'amount' => $request->amount,
'customer_id' => $user->id,
'status' => TransactionStatus::PENDING->value,
]);
$paymentUrl = $this->vnpayService->createPaymentUrl([
'amount' => $request->amount,
'transaction_ref' => $transRef,
'transaction_uuid' => $uuid,
]);
$this->activityLogger->log(
event: 'deposit_vnpay',
logName: 'Transaction',
description: "Customer {$user->email} tạo yêu cầu nạp tiền VNPay: " . number_format($request->amount) . ' VNĐ - Mã: ' . $publicCode,
subject: $transaction,
newValues: ['public_code' => $publicCode, 'amount' => $request->amount, 'type' => 'vnpay'],
);
return response()->json([
'error' => false,
'message' => 'Tạo yêu cầu nạp tiền thành công',
'data' => [
'transaction_id' => $transaction->trans,
'transaction_uuid' => $uuid,
'transaction_code' => $publicCode,
'payment_url' => $paymentUrl,
],
]);
'error' => true,
'message' => 'Nạp tiền qua VNPay hiện không khả dụng. Vui lòng sử dụng nạp tiền thủ công.',
], 410);
// $user = $request->user();
// $transRef = time();
// $uuid = Str::uuid();
// $publicCode = 'DEP-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6));
//
// $transaction = Transaction::create([
// 'uuid' => $uuid,
// 'public_code' => $publicCode,
// 'trans' => 'DEP' . $transRef,
// 'amount' => $request->amount,
// 'customer_id' => $user->id,
// 'status' => TransactionStatus::PENDING->value,
// ]);
//
// $paymentUrl = $this->vnpayService->createPaymentUrl([
// 'amount' => $request->amount,
// 'transaction_ref' => $transRef,
// 'transaction_uuid' => $uuid,
// ]);
//
// $this->activityLogger->log(
// event: 'deposit_vnpay',
// logName: 'Transaction',
// description: "Customer {$user->email} tạo yêu cầu nạp tiền VNPay: " . number_format($request->amount) . ' VNĐ - Mã: ' . $publicCode,
// subject: $transaction,
// newValues: ['public_code' => $publicCode, 'amount' => $request->amount, 'type' => 'vnpay'],
// );
//
// return response()->json([
// 'error' => false,
// 'message' => 'Tạo yêu cầu nạp tiền thành công',
// 'data' => [
// 'transaction_id' => $transaction->trans,
// 'transaction_uuid' => $uuid,
// 'transaction_code' => $publicCode,
// 'payment_url' => $paymentUrl,
// ],
// ]);
}
protected function redirectToFrontend(string $status = 'failed', ?string $transactionCode = null): \Illuminate\Http\RedirectResponse
@@ -85,110 +91,113 @@ class TransactionController extends Controller
return redirect()->away($url . '?' . http_build_query($params));
}
// ponytail: VNPay auto-deposit disabled — nạp tiền hiện dùng luồng thủ công (ManualDepositController).
public function returnCallback(Request $request)
{
$data = $request->all();
$vnp_TxnRef = $data['vnp_TxnRef'] ?? null;
$vnp_Amount = $data['vnp_Amount'] ?? 0;
FacadesLog::info('VNPay callback received', [
'vnp_TxnRef' => $vnp_TxnRef,
'vnp_Amount' => $vnp_Amount,
'user' => $request->user() ? $request->user()->id : 'not authenticated',
]);
$transaction = Transaction::where('trans', 'DEP' . $vnp_TxnRef)->first();
return $this->redirectToFrontend('failed');
if (!$transaction) {
return $this->redirectToFrontend('failed');
}
if ($transaction->status == TransactionStatus::COMPLETED->value) {
return $this->redirectToFrontend('success', $transaction->public_code);
}
$verify = $this->vnpayService->verifyPayment($data);
FacadesLog::info('VNPay callback debug', [
'isValid' => $verify['isValid'],
'isSuccess' => $verify['isSuccess'] ?? false,
'responseCode' => $data['vnp_ResponseCode'] ?? 'N/A',
]);
if (!$verify['isValid']) {
return $this->redirectToFrontend('failed', $transaction->public_code);
}
if (!$verify['isSuccess']) {
$transaction->update(['status' => TransactionStatus::FAILED->value]);
return $this->redirectToFrontend('failed', $transaction->public_code);
}
$originalAmount = $transaction->amount * 100;
if ($vnp_Amount != $originalAmount) {
$transaction->update(['status' => TransactionStatus::FAILED->value]);
return $this->redirectToFrontend('failed', $transaction->public_code);
}
try {
DB::beginTransaction();
$transaction->update(['status' => TransactionStatus::COMPLETED->value]);
$customer = Customer::find($transaction->customer_id);
FacadesLog::info('Customer update debug', [
'customer_id' => $transaction->customer_id,
'customer_found' => $customer ? true : false,
'transaction_amount' => $transaction->amount,
'current_total' => $customer->total ?? 'N/A',
'current_credit' => $customer->credit ?? 'N/A',
]);
$customer->total += $transaction->amount;
$customer->credit += $transaction->amount / 100;
$customer->save();
FacadesLog::info('Customer after update', [
'new_total' => $customer->total,
'new_credit' => $customer->credit,
]);
Log::create([
'agent' => 'api',
'client_ip' => $request->ip(),
'description' => 'Nạp tiền vào tài khoản: ' . number_format($transaction->amount) . ' VNĐ (+' . number_format($transaction->amount / 100) . ' credit)',
'customer_id' => $transaction->customer_id,
]);
$this->activityLogger->log(
event: 'deposit_success',
logName: 'Transaction',
description: "Customer {$customer->email} nạp tiền VNPay thành công: " . number_format($transaction->amount) . ' VNĐ - Mã: ' . $transaction->public_code,
subject: $transaction,
newValues: ['amount' => $transaction->amount, 'new_total' => $customer->total, 'public_code' => $transaction->public_code],
);
DepositSuccessJob::dispatch([
'customer_email' => $customer->email,
'customer_name' => $customer->fullname,
'transaction_code' => $transaction->public_code,
'amount' => $transaction->amount,
'credit' => $transaction->amount / 100,
'current_total' => $customer->total,
'current_credit' => $customer->credit,
'deposit_date' => now()->format('d/m/Y H:i:s'),
]);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
FacadesLog::error('Payment processing error: ' . $e->getMessage());
return $this->redirectToFrontend('failed', $transaction->public_code);
}
return $this->redirectToFrontend('success', $transaction->public_code);
// $data = $request->all();
//
// $vnp_TxnRef = $data['vnp_TxnRef'] ?? null;
// $vnp_Amount = $data['vnp_Amount'] ?? 0;
//
// FacadesLog::info('VNPay callback received', [
// 'vnp_TxnRef' => $vnp_TxnRef,
// 'vnp_Amount' => $vnp_Amount,
// 'user' => $request->user() ? $request->user()->id : 'not authenticated',
// ]);
//
// $transaction = Transaction::where('trans', 'DEP' . $vnp_TxnRef)->first();
//
// if (!$transaction) {
// return $this->redirectToFrontend('failed');
// }
//
// if ($transaction->status == TransactionStatus::COMPLETED->value) {
// return $this->redirectToFrontend('success', $transaction->public_code);
// }
//
// $verify = $this->vnpayService->verifyPayment($data);
//
// FacadesLog::info('VNPay callback debug', [
// 'isValid' => $verify['isValid'],
// 'isSuccess' => $verify['isSuccess'] ?? false,
// 'responseCode' => $data['vnp_ResponseCode'] ?? 'N/A',
// ]);
//
// if (!$verify['isValid']) {
// return $this->redirectToFrontend('failed', $transaction->public_code);
// }
//
// if (!$verify['isSuccess']) {
// $transaction->update(['status' => TransactionStatus::FAILED->value]);
// return $this->redirectToFrontend('failed', $transaction->public_code);
// }
//
// $originalAmount = $transaction->amount * 100;
// if ($vnp_Amount != $originalAmount) {
// $transaction->update(['status' => TransactionStatus::FAILED->value]);
// return $this->redirectToFrontend('failed', $transaction->public_code);
// }
//
// try {
// DB::beginTransaction();
//
// $transaction->update(['status' => TransactionStatus::COMPLETED->value]);
//
// $customer = Customer::find($transaction->customer_id);
//
// FacadesLog::info('Customer update debug', [
// 'customer_id' => $transaction->customer_id,
// 'customer_found' => $customer ? true : false,
// 'transaction_amount' => $transaction->amount,
// 'current_total' => $customer->total ?? 'N/A',
// 'current_credit' => $customer->credit ?? 'N/A',
// ]);
//
// $customer->total += $transaction->amount;
// $customer->credit += $transaction->amount / 100;
// $customer->save();
//
// FacadesLog::info('Customer after update', [
// 'new_total' => $customer->total,
// 'new_credit' => $customer->credit,
// ]);
//
// Log::create([
// 'agent' => 'api',
// 'client_ip' => $request->ip(),
// 'description' => 'Nạp tiền vào tài khoản: ' . number_format($transaction->amount) . ' VNĐ (+' . number_format($transaction->amount / 100) . ' credit)',
// 'customer_id' => $transaction->customer_id,
// ]);
//
// $this->activityLogger->log(
// event: 'deposit_success',
// logName: 'Transaction',
// description: "Customer {$customer->email} nạp tiền VNPay thành công: " . number_format($transaction->amount) . ' VNĐ - Mã: ' . $transaction->public_code,
// subject: $transaction,
// newValues: ['amount' => $transaction->amount, 'new_total' => $customer->total, 'public_code' => $transaction->public_code],
// );
//
// DepositSuccessJob::dispatch([
// 'customer_email' => $customer->email,
// 'customer_name' => $customer->fullname,
// 'transaction_code' => $transaction->public_code,
// 'amount' => $transaction->amount,
// 'credit' => $transaction->amount / 100,
// 'current_total' => $customer->total,
// 'current_credit' => $customer->credit,
// 'deposit_date' => now()->format('d/m/Y H:i:s'),
// ]);
//
// DB::commit();
// } catch (\Exception $e) {
// DB::rollBack();
// FacadesLog::error('Payment processing error: ' . $e->getMessage());
// return $this->redirectToFrontend('failed', $transaction->public_code);
// }
//
// return $this->redirectToFrontend('success', $transaction->public_code);
}
public function getTransactions(Request $request)
@@ -231,7 +240,7 @@ class TransactionController extends Controller
public function verifyPayment(Request $request)
{
$transRef = $request->query('vnp_TxnRef');
if (!$transRef) {
return response()->json([
'error' => true,
@@ -239,7 +248,9 @@ class TransactionController extends Controller
], 400);
}
$transaction = Transaction::where('trans', 'DEP' . $transRef)->first();
$transaction = Transaction::where('trans', 'DEP' . $transRef)
->where('customer_id', $request->user()->id)
->first();
if (!$transaction) {
return response()->json([
@@ -325,7 +336,9 @@ class TransactionController extends Controller
], 400);
}
$transaction = Transaction::where('public_code', $code)->first();
$transaction = Transaction::where('public_code', $code)
->where('customer_id', $request->user()->id)
->first();
if (!$transaction) {
return response()->json([
@@ -338,27 +351,13 @@ class TransactionController extends Controller
'error' => false,
'message' => 'Lấy thông tin giao dịch thành công',
'data' => [
'transaction' => [
'public_code' => $transaction->public_code,
'amount' => $transaction->amount,
'amount_formatted' => number_format($transaction->amount) . ' VNĐ',
'status' => $transaction->status,
'status_label' => TransactionStatus::from($transaction->status)->label(),
'created_at' => $transaction->created_at->format('Y-m-d H:i:s'),
'paid_at' => $transaction->updated_at->format('Y-m-d H:i:s'),
],
'customer' => [
'fullname' => $transaction->customer->fullname,
'email' => $transaction->customer->email,
'phone' => $transaction->customer->phone,
'address' => $transaction->customer->address,
],
'balance' => [
'total' => $transaction->customer->total,
'credit' => $transaction->customer->credit,
'total_formatted' => number_format($transaction->customer->total) . ' VNĐ',
'credit_formatted' => number_format($transaction->customer->credit) . ' điểm',
],
'public_code' => $transaction->public_code,
'amount' => $transaction->amount,
'amount_formatted' => number_format($transaction->amount) . ' VNĐ',
'status' => $transaction->status,
'status_label' => TransactionStatus::from($transaction->status)->label(),
'created_at' => $transaction->created_at->format('Y-m-d H:i:s'),
'paid_at' => $transaction->updated_at->format('Y-m-d H:i:s'),
],
]);
}
+1
View File
@@ -68,6 +68,7 @@ class Kernel extends HttpKernel
'Staff' => \App\Http\Middleware\StaffCheck::class,
'json.response' => \App\Http\Middleware\ForceJsonResponse::class,
'api.auth' => \App\Http\Middleware\ApiAuthCheck::class,
'client.access-token' => \App\Http\Middleware\ClientAccessTokenCheck::class,
'perm' => \App\Http\Middleware\CheckPermission::class,
'compression' => \App\Http\Middleware\CompressionMiddleware::class,
'compression.advanced' => \App\Http\Middleware\AdvancedCompressionMiddleware::class,
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class ClientAccessTokenCheck
{
/**
* Verify the `x-access-token` header contains an AES-256-CBC ciphertext
* (base64(iv . ciphertext)) of the shared secret, encrypted with the
* openssl key configured in services.client_api.key. Blocks callers
* that don't hold the shared key (external scraping/direct API calls).
*/
public function handle(Request $request, Closure $next)
{
$token = $request->header('x-access-token', '');
$key = config('services.client_api.key');
if (!$key || !$this->isValid($token, $key)) {
return response()->json([
'error' => true,
'message' => 'Invalid access token',
'code' => 403,
], 403);
}
return $next($request);
}
private function isValid(string $token, string $key): bool
{
$raw = base64_decode($token, true);
if ($raw === false || strlen($raw) <= 16) {
return false;
}
$iv = substr($raw, 0, 16);
$ciphertext = substr($raw, 16);
$plaintext = openssl_decrypt($ciphertext, 'aes-256-cbc', hex2bin($key), OPENSSL_RAW_DATA, $iv);
if ($plaintext === false) {
return false;
}
return hash_equals(config('services.client_api.secret'), $plaintext);
}
}
@@ -2,11 +2,31 @@
namespace App\Http\Resources\Api\Client\Service;
use App\Models\ModuleHostingPleskClient;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Crypt;
class ServiceClientDetailResource extends JsonResource
{
/** Plesk panel login (persisted, reusable) — separate from the one-time FTP password, which is never stored. */
private function resolvePleskCredentials($hosting): ?array
{
$client = ModuleHostingPleskClient::query()
->where('customer_id', $hosting->customer_id)
->where('dns_manager_server_id', $hosting->dns_manager_server_id)
->first();
if (!$client) {
return null;
}
return [
'login' => $client->login,
'password' => Crypt::decryptString($client->password),
];
}
/**
* Transform the resource into an array.
*
@@ -50,6 +70,8 @@ class ServiceClientDetailResource extends JsonResource
$order = $orderItem?->order;
$vm = $this->resource->vmware;
$product = $orderItem?->product;
$hosting = $this->resource->hostingAccount;
$pleskCredentials = $hosting ? $this->resolvePleskCredentials($hosting) : null;
return [
'id' => $this->id,
@@ -81,6 +103,17 @@ class ServiceClientDetailResource extends JsonResource
'ip' => $vm->ip ?? null,
'dns' => $vm->dns ?? null,
] : null,
'hosting' => $hosting ? [
'ip_address' => $hosting->server?->ip_address,
'plesk_url' => $hosting->server?->ip_address
? "http://{$hosting->server->ip_address}:{$hosting->server->panel_port}/"
: null,
'hosting_plan' => $hosting->hosting_plan,
'ftp_username' => $hosting->ftp_username,
'username' => $pleskCredentials['login'] ?? null,
'password' => $pleskCredentials['password'] ?? null,
'status' => $hosting->status,
] : null,
];
}
}
@@ -58,7 +58,7 @@ class VpsServiceResource extends JsonResource
'name' => $product->name,
'slug' => $product->slug,
'product_type' => $product->product_type,
'page_type' => 'service_vps',
'page_type' => 'service_' . str_replace(' ', '_', $product->product_type),
] : null,
'specs' => $this->resolveSpecs($product),
];
@@ -256,11 +256,15 @@ PS;
$data = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE) {
\Illuminate\Support\Facades\Log::error('changeRDPPortWindows: failed to parse PowerCLI output', [
'vm_name' => $vmName,
'raw_output' => $raw,
'json_error' => json_last_error_msg(),
]);
return [
'success' => false,
'error' => 'JSON parsing failed',
'raw_output' => $raw,
'json_error' => json_last_error_msg()
];
}
@@ -4,9 +4,8 @@ declare(strict_types=1);
namespace App\Jobs\Hosting;
use App\Enums\Server\ServerType;
use App\Models\DnsServer;
use App\Models\OrderItem;
use App\Models\ServicePlan;
use App\Services\Plesk\PleskHostingService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -49,14 +48,50 @@ class ProvisionHostingAccountJob implements ShouldQueue
return;
}
$server = DnsServer::query()
->where('status', 'activated')
->whereIn('type', [ServerType::HOSTING->value, ServerType::BOTH->value])
->first();
$servicePlanId = $this->item->product?->service_plan_id;
$servicePlan = $servicePlanId
? ServicePlan::with(['plesk', 'dnsServer'])->find($servicePlanId)
: null;
$hostingPlan = $servicePlan?->plesk?->plesk_plan_name;
$server = $servicePlan?->dnsServer;
Log::channel('Custom')->info('ProvisionHostingAccountJob resolved service plan', [
'order_item_id' => $this->item->id,
'product_id' => $this->item->product_id,
'product_name' => $this->item->product?->name,
'service_plan_id' => $servicePlanId,
'plesk_plan_name' => $hostingPlan,
'dns_manager_server_id' => $server?->id,
'dns_manager_server_ip' => $server?->ip_address,
]);
if (! $hostingPlan) {
Log::channel('Custom')->error('ProvisionHostingAccountJob failed: no service_plan_plesk mapping for this product', [
'order_item_id' => $this->item->id,
'product_id' => $this->item->product_id,
'service_plan_id' => $servicePlanId,
]);
return;
}
if (! $server) {
Log::channel('Custom')->error('ProvisionHostingAccountJob failed: no active hosting server available', [
'order_item_id' => $this->item->id,
Log::channel('Custom')->error('ProvisionHostingAccountJob failed: service plan has no dns_manager_server_id configured', [
'order_item_id' => $this->item->id,
'product_id' => $this->item->product_id,
'service_plan_id' => $servicePlanId,
]);
return;
}
if (! $server->servesHosting()) {
Log::channel('Custom')->error('ProvisionHostingAccountJob failed: configured server does not serve hosting', [
'order_item_id' => $this->item->id,
'service_plan_id' => $servicePlanId,
'dns_manager_server_id' => $server->id,
'server_type' => $server->type,
]);
return;
@@ -67,12 +102,13 @@ class ProvisionHostingAccountJob implements ShouldQueue
'service_id' => $service->id,
'dns_manager_server_id' => $server->id,
'domain_name' => $domainName,
'hosting_plan' => $this->item->product?->name ?? 'Default',
'hosting_plan' => $hostingPlan,
]);
if ($result['error']) {
Log::channel('Custom')->error('ProvisionHostingAccountJob failed', [
'order_item_id' => $this->item->id,
'hosting_plan' => $hostingPlan,
'message' => $result['message'] ?? 'Unknown error',
]);
+1
View File
@@ -16,6 +16,7 @@ class DnsServer extends Model
protected $fillable = [
'name',
'ip_address',
'panel_port',
'password',
'username',
'api_key',
+6
View File
@@ -70,6 +70,12 @@ class Service extends Model
return $this->belongsTo(ModuleVmware::class, 'vm_id');
}
/** The Plesk hosting account associated with this service, if type = hosting. */
public function hostingAccount()
{
return $this->hasOne(ModuleHostingAccount::class, 'service_id');
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
+6
View File
@@ -12,10 +12,16 @@ class ServicePlan extends Model
'ram',
'disk',
'domain',
'dns_manager_server_id',
];
public function plesk()
{
return $this->hasOne(ServicePlanPlesk::class);
}
public function dnsServer()
{
return $this->belongsTo(DnsServer::class, 'dns_manager_server_id');
}
}
@@ -70,6 +70,15 @@ class OrderRepository extends BaseApiRepository implements OrderRepositoryInterf
return $this->model->where('order_num', $orderNum)->where('customer_id', $customerId)->first();
}
// -------------------------------------------------------------------------
// findForCustomer
// -------------------------------------------------------------------------
public function findForCustomer(int $id, int $customerId): ?Order
{
return $this->model->where('id', $id)->where('customer_id', $customerId)->first();
}
// -------------------------------------------------------------------------
// getOrders
// -------------------------------------------------------------------------
@@ -29,9 +29,7 @@ class ServiceClientRepository extends BaseApiRepository implements ServiceClient
->orderBy('started_at', 'desc');
if ($productType) {
$query->whereHas('product', function ($builder) use ($productType) {
$builder->where('product_type', $productType);
});
$query->where('type', $productType);
}
return $query->paginate($limit);
@@ -66,6 +64,7 @@ class ServiceClientRepository extends BaseApiRepository implements ServiceClient
'orderItem.product',
'orderItem.order',
'vmware.ipAddress',
'hostingAccount.server',
])
->where('uuid', $uuid)
->where('customer_id', $customerId)
@@ -138,6 +138,7 @@ class DomainOrderService
$service = Service::create([
'customer_id' => $lockedCustomer->id,
'product_id' => $product->id,
'type' => ProductTypeEnums::DOMAIN->value,
'order_item_id' => $orderItem->id,
'invoice_id' => $invoice->id,
'server_name' => $domainName,
+1
View File
@@ -157,6 +157,7 @@ class OrderService
$service = Service::create([
'customer_id' => $customer->id,
'product_id' => $item->product_id,
'type' => $item->product?->product_type,
'order_item_id' => $item->id,
'invoice_id' => $invoice?->id,
'server_name' => $item->server_name,
+3 -1
View File
@@ -10,6 +10,7 @@ use App\Models\DnsServer;
use App\Models\ModuleHostingAccount;
use App\Repositories\DnsManager\Domain\DnsDomainRepositoryInterface;
use App\Repositories\Module\HostingAccount\HostingAccountRepositoryInterface;
use Illuminate\Support\Str;
/**
* Phase 1 orchestration: Create Customer -> Create Hosting Subscription ->
@@ -54,7 +55,8 @@ class PleskHostingService
$pleskLogin = $client['data']['login'];
$pleskPassword = $client['data']['password'];
$ftpLogin = str_replace('.', '_', $data['domain_name']);
// Suffix avoids a collision if the same domain is re-provisioned (e.g. after cancellation).
$ftpLogin = str_replace('.', '_', $data['domain_name']) . '_' . Str::lower(Str::random(4));
// Not persisted anywhere — the only record of it is this response, once.
$ftpPassword = $data['ftp_password'] ?? Helper::general_password();
+6
View File
@@ -39,4 +39,10 @@ return [
'wordpress_posts_url' => env('DOCS_WORDPRESS_POSTS_URL', 'https://tailieu.tgs.com.vn/wp-json/wp/v2/posts'),
],
'client_api' => [
// openssl rand -hex 32
'key' => env('CLIENT_API_ACCESS_KEY'),
'secret' => env('CLIENT_API_ACCESS_SECRET', 'tgs-client-api'),
],
];
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('services', function (Blueprint $table) {
$table->string('type', 50)->nullable()->after('product_id')->index();
});
// Backfill existing rows from products.product_type (vps / hosting / domain / ...).
DB::statement(<<<'SQL'
UPDATE services
JOIN products ON products.id = services.product_id
SET services.type = products.product_type
WHERE services.type IS NULL
SQL);
}
public function down(): void
{
Schema::table('services', function (Blueprint $table) {
$table->dropColumn('type');
});
}
};
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('service_plans', function (Blueprint $table) {
$table->foreignId('dns_manager_server_id')->nullable()->after('domain')
->constrained('dns_manager_servers')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('service_plans', function (Blueprint $table) {
$table->dropConstrainedForeignId('dns_manager_server_id');
});
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('dns_manager_servers', function (Blueprint $table) {
$table->unsignedInteger('panel_port')->default(8880)->after('ip_address');
});
}
public function down(): void
{
Schema::table('dns_manager_servers', function (Blueprint $table) {
$table->dropColumn('panel_port');
});
}
};
+2 -1
View File
@@ -24,6 +24,7 @@ use Illuminate\Support\Facades\Route;
Route::prefix('v1')
->as('api.')
->middleware(['client.access-token'])
->group(function () {
// Auth
Route::prefix('client')
@@ -263,7 +264,7 @@ Route::prefix('v1')
Route::get('v1/payment/vnpay/return', [TransactionController::class, 'returnCallback']);
Route::prefix('v1/system')
->middleware(['api.auth'])
->middleware(['client.access-token', 'api.auth'])
->group(function () {
// VM power & network operations
Route::controller(VmController::class)->group(function () {
+6 -1
View File
@@ -4597,11 +4597,16 @@ jquery.flot@^0.8.3:
resolved "https://registry.npmjs.org/jquery.flot/-/jquery.flot-0.8.3.tgz"
integrity sha512-/tEE8J5NjwvStHDaCHkvTJpD7wDS4hE1OEL8xEmhgQfUe0gLUem923PIceNez1mz4yBNx6Hjv7pJcowLNd+nbg==
jquery@*, jquery@>=1.2.6, jquery@>=1.7, jquery@>=1.8.3, jquery@^4.0.0:
jquery@*, jquery@>=1.2.6, jquery@>=1.7, jquery@>=1.8.3:
version "4.0.0"
resolved "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz"
integrity sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==
jquery@3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470"
integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==
jquery@^3.7.1:
version "3.7.1"
resolved "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz"