донат шоп, управление магазином, яндекс метрика
This commit is contained in:
@@ -89,11 +89,18 @@ AZEROTHCORE_CHARACTERS_DB_PASSWORD=password
|
|||||||
AZEROTHCORE_CHARACTERS_DB_SOCKET=
|
AZEROTHCORE_CHARACTERS_DB_SOCKET=
|
||||||
AZEROTHCORE_CHARACTERS_DB_CHARSET=utf8mb4
|
AZEROTHCORE_CHARACTERS_DB_CHARSET=utf8mb4
|
||||||
AZEROTHCORE_CHARACTERS_DB_COLLATION=utf8mb4_unicode_ci
|
AZEROTHCORE_CHARACTERS_DB_COLLATION=utf8mb4_unicode_ci
|
||||||
|
STORE_DB_HOST=host.docker.internal
|
||||||
|
STORE_DB_PORT=3306
|
||||||
|
STORE_DB_DATABASE=store
|
||||||
|
STORE_DB_USERNAME=root
|
||||||
|
STORE_DB_PASSWORD=password
|
||||||
|
STORE_DB_SOCKET=
|
||||||
AZEROTHCORE_ACCOUNT_GMLEVEL=0
|
AZEROTHCORE_ACCOUNT_GMLEVEL=0
|
||||||
AZEROTHCORE_ACCOUNT_REALM_ID=-1
|
AZEROTHCORE_ACCOUNT_REALM_ID=-1
|
||||||
AZEROTHCORE_ACCOUNT_EXPANSION=2
|
AZEROTHCORE_ACCOUNT_EXPANSION=2
|
||||||
AZEROTHCORE_ACCOUNT_ACCESS_COMMENT="registered via moonwell-web"
|
AZEROTHCORE_ACCOUNT_ACCESS_COMMENT="registered via moonwell-web"
|
||||||
AZEROTHCORE_ENFORCE_UNIQUE_EMAIL=false
|
AZEROTHCORE_ENFORCE_UNIQUE_EMAIL=false
|
||||||
|
MOONWELL_REGISTRATION_REQUIRE_INVITE=true
|
||||||
MOONWELL_ADMIN_MIN_GMLEVEL=3
|
MOONWELL_ADMIN_MIN_GMLEVEL=3
|
||||||
MOONWELL_ADMIN_ACCESS_COMMENT="updated via moonwell-web admin"
|
MOONWELL_ADMIN_ACCESS_COMMENT="updated via moonwell-web admin"
|
||||||
MOONWELL_BOT_ACCOUNT_PREFIX=RNDBOT
|
MOONWELL_BOT_ACCOUNT_PREFIX=RNDBOT
|
||||||
@@ -112,3 +119,18 @@ MOONWELL_CLIENT_OBJECT_KEY="World of Warcraft.zip"
|
|||||||
MOONWELL_CLIENT_URL_TTL=30
|
MOONWELL_CLIENT_URL_TTL=30
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
|
||||||
|
ROBOKASSA_MODE=test
|
||||||
|
ROBOKASSA_TEST_LOGIN=
|
||||||
|
ROBOKASSA_TEST_PASSWORD1=
|
||||||
|
ROBOKASSA_TEST_PASSWORD2=
|
||||||
|
ROBOKASSA_TEST_HASH_ALGORITHM=md5
|
||||||
|
ROBOKASSA_PRODUCTION_LOGIN=
|
||||||
|
ROBOKASSA_PRODUCTION_PASSWORD1=
|
||||||
|
ROBOKASSA_PRODUCTION_PASSWORD2=
|
||||||
|
ROBOKASSA_PRODUCTION_HASH_ALGORITHM=md5
|
||||||
|
ROBOKASSA_MINIMUM_AMOUNT=10
|
||||||
|
ROBOKASSA_MAXIMUM_AMOUNT=100000
|
||||||
|
MOONWELL_TEARS_PER_RUBLE=10
|
||||||
|
GAME_SERVER_BALANCE_API_TOKEN=
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Admin\AdjustBalanceRequest;
|
||||||
|
use App\Services\AzerothCoreAccountService;
|
||||||
|
use App\Services\BalanceService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class BalanceController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request, BalanceService $balances): View
|
||||||
|
{
|
||||||
|
$search = trim((string) $request->query('search', ''));
|
||||||
|
|
||||||
|
return view('admin.balances.index', [
|
||||||
|
'transactions' => $balances->adminTransactions($search !== '' ? $search : null),
|
||||||
|
'search' => $search,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adjust(
|
||||||
|
AdjustBalanceRequest $request,
|
||||||
|
AzerothCoreAccountService $accounts,
|
||||||
|
BalanceService $balances,
|
||||||
|
): RedirectResponse {
|
||||||
|
$accountInput = trim($request->string('account')->toString());
|
||||||
|
$account = ctype_digit($accountInput)
|
||||||
|
? $accounts->findUserById((int) $accountInput)
|
||||||
|
: $accounts->findUserByUsername($accountInput);
|
||||||
|
|
||||||
|
if (! $account) {
|
||||||
|
return back()->withInput()->withErrors(['account' => 'Игровой аккаунт не найден.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$transaction = $balances->adjustByAdmin(
|
||||||
|
$account->id,
|
||||||
|
$request->string('amount')->toString(),
|
||||||
|
$request->string('direction')->toString(),
|
||||||
|
$request->user()->id,
|
||||||
|
$request->user()->username,
|
||||||
|
$request->string('reason')->toString(),
|
||||||
|
);
|
||||||
|
} catch (InvalidArgumentException $exception) {
|
||||||
|
return back()->withInput()->withErrors(['amount' => $exception->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('status', sprintf(
|
||||||
|
'Счёт %s изменён. Теперь на нём %s Слёз Элуны.',
|
||||||
|
$account->username,
|
||||||
|
number_format((float) $transaction->balance_after, 2, ',', ' '),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Admin\SaveShopCategoryRequest;
|
||||||
|
use App\Http\Requests\Admin\SaveShopProductRequest;
|
||||||
|
use App\Models\ShopCategory;
|
||||||
|
use App\Models\ShopProduct;
|
||||||
|
use App\Services\ShopCatalogService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class ShopController extends Controller
|
||||||
|
{
|
||||||
|
public function index(ShopCatalogService $catalog): View
|
||||||
|
{
|
||||||
|
return view('admin.shop.index', [
|
||||||
|
'categories' => $catalog->categories(),
|
||||||
|
'products' => $catalog->products(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeCategory(SaveShopCategoryRequest $request, ShopCatalogService $catalog): RedirectResponse
|
||||||
|
{
|
||||||
|
$catalog->createCategory($request->validated());
|
||||||
|
|
||||||
|
return back()->with('status', 'Категория создана.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateCategory(ShopCategory $category, SaveShopCategoryRequest $request, ShopCatalogService $catalog): RedirectResponse
|
||||||
|
{
|
||||||
|
$catalog->updateCategory($category, $request->validated());
|
||||||
|
|
||||||
|
return back()->with('status', 'Категория обновлена.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroyCategory(ShopCategory $category, ShopCatalogService $catalog): RedirectResponse
|
||||||
|
{
|
||||||
|
$catalog->deleteCategory($category);
|
||||||
|
|
||||||
|
return back()->with('status', 'Категория удалена.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeProduct(SaveShopProductRequest $request, ShopCatalogService $catalog): RedirectResponse
|
||||||
|
{
|
||||||
|
$catalog->createProduct($request->validated());
|
||||||
|
|
||||||
|
return back()->with('status', 'Товар создан.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateProduct(ShopProduct $product, SaveShopProductRequest $request, ShopCatalogService $catalog): RedirectResponse
|
||||||
|
{
|
||||||
|
$catalog->updateProduct($product, $request->validated());
|
||||||
|
|
||||||
|
return back()->with('status', 'Товар обновлён.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroyProduct(ShopProduct $product, ShopCatalogService $catalog): RedirectResponse
|
||||||
|
{
|
||||||
|
$catalog->deleteProduct($product);
|
||||||
|
|
||||||
|
return back()->with('status', 'Товар удалён.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\BalanceService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class GameBalanceController extends Controller
|
||||||
|
{
|
||||||
|
public function show(int $accountId, BalanceService $balances): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(['account_id' => $accountId, 'balance' => $balances->balance($accountId)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function spend(Request $request, int $accountId, BalanceService $balances): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'amount' => ['required', 'numeric', 'decimal:0,2', 'gt:0'],
|
||||||
|
'idempotency_key' => ['required', 'string', 'max:160'],
|
||||||
|
'description' => ['nullable', 'string', 'max:255'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$transaction = $balances->spend(
|
||||||
|
$accountId,
|
||||||
|
(string) $data['amount'],
|
||||||
|
'game:'.$data['idempotency_key'],
|
||||||
|
$data['description'] ?? null,
|
||||||
|
);
|
||||||
|
} catch (InvalidArgumentException $exception) {
|
||||||
|
return response()->json(['message' => $exception->getMessage()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'transaction_id' => $transaction->id,
|
||||||
|
'account_id' => $transaction->account_id,
|
||||||
|
'amount' => $transaction->amount,
|
||||||
|
'balance' => $transaction->balance_after,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\RobokassaService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class RobokassaController extends Controller
|
||||||
|
{
|
||||||
|
public function result(Request $request, RobokassaService $robokassa): Response
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$invoice = $robokassa->processResult($request->all());
|
||||||
|
} catch (InvalidArgumentException $exception) {
|
||||||
|
report($exception);
|
||||||
|
|
||||||
|
return response('bad signature', 400)->header('Content-Type', 'text/plain');
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
report($exception);
|
||||||
|
|
||||||
|
return response('temporary error', 503)->header('Content-Type', 'text/plain');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response('OK'.$invoice->id)->header('Content-Type', 'text/plain');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Http\Requests\ChangePasswordRequest;
|
use App\Http\Requests\ChangePasswordRequest;
|
||||||
use App\Services\AzerothCoreAccountService;
|
use App\Services\AzerothCoreAccountService;
|
||||||
|
use App\Services\BalanceService;
|
||||||
use App\Services\GameClientDownloadService;
|
use App\Services\GameClientDownloadService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -12,7 +13,7 @@ use Throwable;
|
|||||||
|
|
||||||
class CabinetController extends Controller
|
class CabinetController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request, AzerothCoreAccountService $accounts): View
|
public function index(Request $request, AzerothCoreAccountService $accounts, BalanceService $balances): View
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
@@ -20,6 +21,11 @@ class CabinetController extends Controller
|
|||||||
'user' => $user,
|
'user' => $user,
|
||||||
'characters' => $accounts->charactersForAccount($user->id),
|
'characters' => $accounts->charactersForAccount($user->id),
|
||||||
'clientFilename' => config('moonwell.client.object_key'),
|
'clientFilename' => config('moonwell.client.object_key'),
|
||||||
|
'balance' => $balances->balance($user->id),
|
||||||
|
'balanceTransactions' => $balances->recentTransactions($user->id),
|
||||||
|
'minimumDeposit' => config('services.robokassa.minimum_amount'),
|
||||||
|
'maximumDeposit' => config('services.robokassa.maximum_amount'),
|
||||||
|
'tearsPerRuble' => config('services.robokassa.tears_per_ruble'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ class LandingController extends Controller
|
|||||||
'moon' => true,
|
'moon' => true,
|
||||||
],
|
],
|
||||||
'registerEndpoint' => route('game-account.store'),
|
'registerEndpoint' => route('game-account.store'),
|
||||||
|
'inviteRequired' => (bool) config('moonwell.registration.require_invite_code'),
|
||||||
'csrfToken' => csrf_token(),
|
'csrfToken' => csrf_token(),
|
||||||
'auth' => [
|
'auth' => [
|
||||||
'authenticated' => $user !== null,
|
'authenticated' => $user !== null,
|
||||||
@@ -110,15 +111,19 @@ class LandingController extends Controller
|
|||||||
): RedirectResponse
|
): RedirectResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$result = $inviteCodes->redeemForRegistration(
|
$register = fn (): array => $registrar->register(
|
||||||
$request->string('invite_code')->toString(),
|
|
||||||
$request->string('username')->toString(),
|
|
||||||
fn (): array => $registrar->register(
|
|
||||||
$request->string('username')->toString(),
|
$request->string('username')->toString(),
|
||||||
$request->string('email')->toString(),
|
$request->string('email')->toString(),
|
||||||
$request->string('password')->toString(),
|
$request->string('password')->toString(),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$result = config('moonwell.registration.require_invite_code')
|
||||||
|
? $inviteCodes->redeemForRegistration(
|
||||||
|
$request->string('invite_code')->toString(),
|
||||||
|
$request->string('username')->toString(),
|
||||||
|
$register,
|
||||||
|
)
|
||||||
|
: $register();
|
||||||
} catch (DuplicateGameAccountException $exception) {
|
} catch (DuplicateGameAccountException $exception) {
|
||||||
return back()
|
return back()
|
||||||
->withErrors([$exception->field => $exception->getMessage()])
|
->withErrors([$exception->field => $exception->getMessage()])
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Services\RobokassaService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class PaymentController extends Controller
|
||||||
|
{
|
||||||
|
public function create(Request $request, RobokassaService $robokassa): View
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'amount' => [
|
||||||
|
'required',
|
||||||
|
'numeric',
|
||||||
|
'decimal:0,2',
|
||||||
|
Rule::numeric()->min((float) config('services.robokassa.minimum_amount'))->max((float) config('services.robokassa.maximum_amount')),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
$invoice = $robokassa->createInvoice($user->id, (string) $validated['amount']);
|
||||||
|
|
||||||
|
return view('payments.redirect', [
|
||||||
|
'paymentUrl' => config('services.robokassa.payment_url'),
|
||||||
|
'parameters' => $robokassa->paymentParameters($invoice, $user->email),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function success(): RedirectResponse
|
||||||
|
{
|
||||||
|
return redirect()->route('cabinet.index')->with('status', 'Пополнение принято. Слёзы Элуны скоро появятся на счёте.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fail(): RedirectResponse
|
||||||
|
{
|
||||||
|
return redirect()->route('cabinet.index')->with('error', 'Оплата отменена или не была завершена.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureGameServer
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$expected = (string) config('services.game_server.balance_api_token');
|
||||||
|
$provided = (string) $request->bearerToken();
|
||||||
|
|
||||||
|
if ($expected === '' || $provided === '' || ! hash_equals($expected, $provided)) {
|
||||||
|
abort(401, 'Неверный токен игрового сервера.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class AdjustBalanceRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'account' => ['required', 'string', 'max:255'],
|
||||||
|
'direction' => ['required', Rule::in(['credit', 'debit'])],
|
||||||
|
'amount' => ['required', 'numeric', 'decimal:0,2', 'gt:0', 'max:1000000'],
|
||||||
|
'reason' => ['required', 'string', 'min:3', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class SaveShopCategoryRequest extends FormRequest
|
||||||
|
{
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge(['enabled' => $this->boolean('enabled')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:120'],
|
||||||
|
'icon' => ['nullable', 'string', 'max:255'],
|
||||||
|
'requiredRank' => ['required', 'integer', 'min:0', 'max:255'],
|
||||||
|
'flags' => ['required', 'integer', 'min:0'],
|
||||||
|
'enabled' => ['boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class SaveShopProductRequest extends FormRequest
|
||||||
|
{
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge(['new' => $this->boolean('new'), 'enabled' => $this->boolean('enabled')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'category_ids' => ['required', 'array', 'min:1'],
|
||||||
|
'category_ids.*' => ['integer', Rule::exists('store.store_categories', 'id')],
|
||||||
|
'type' => ['required', 'integer', Rule::in([1, 3, 4, 5, 7, 8, 9])],
|
||||||
|
'name' => ['required', 'string', 'max:765'],
|
||||||
|
'tooltipName' => ['nullable', 'string', 'max:765'],
|
||||||
|
'tooltipType' => ['nullable', 'string', 'max:765'],
|
||||||
|
'tooltipText' => ['nullable', 'string', 'max:10000'],
|
||||||
|
'icon' => ['nullable', 'string', 'max:765'],
|
||||||
|
'price' => ['required', 'integer', 'min:1', 'max:100000000'],
|
||||||
|
'hyperlinkId' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'creatureEntry' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'discountAmount' => ['nullable', 'integer', 'min:0', 'max:100'],
|
||||||
|
'flags' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'new' => ['boolean'],
|
||||||
|
'enabled' => ['boolean'],
|
||||||
|
'reward_1' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'reward_2' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'reward_3' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'reward_4' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'reward_5' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'reward_6' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'reward_7' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'reward_8' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'rewardcount_1' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'rewardcount_2' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'rewardcount_3' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'rewardcount_4' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'rewardcount_5' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'rewardcount_6' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'rewardcount_7' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'rewardcount_8' => ['nullable', 'integer', 'min:0'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ class RegisterGameAccountRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'username' => ['required', 'string', 'min:3', 'max:32', 'regex:/^[A-Za-z0-9]+$/'],
|
'username' => ['required', 'string', 'min:3', 'max:32', 'regex:/^[A-Za-z0-9]+$/'],
|
||||||
'email' => ['required', 'string', 'email:rfc', 'max:255'],
|
'email' => ['required', 'string', 'email:rfc', 'max:255'],
|
||||||
'invite_code' => ['required', 'string', 'min:6', 'max:32'],
|
'invite_code' => [config('moonwell.registration.require_invite_code') ? 'required' : 'nullable', 'string', 'min:6', 'max:32'],
|
||||||
'password' => ['required', 'string', 'min:8', 'max:32', 'confirmed', 'regex:/^(?=.*[A-Za-z])(?=.*\d).+$/'],
|
'password' => ['required', 'string', 'min:8', 'max:32', 'confirmed', 'regex:/^(?=.*[A-Za-z])(?=.*\d).+$/'],
|
||||||
'terms' => ['accepted'],
|
'terms' => ['accepted'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class BalanceTransaction extends Model
|
||||||
|
{
|
||||||
|
public const TYPE_DEPOSIT = 'deposit';
|
||||||
|
|
||||||
|
public const TYPE_SPEND = 'spend';
|
||||||
|
|
||||||
|
public const TYPE_ADMIN_CREDIT = 'admin_credit';
|
||||||
|
|
||||||
|
public const TYPE_ADMIN_DEBIT = 'admin_debit';
|
||||||
|
|
||||||
|
protected $connection = 'azerothcore_auth';
|
||||||
|
|
||||||
|
protected $table = 'balance_transactions';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
public const UPDATED_AT = null;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['amount' => 'decimal:2', 'balance_after' => 'decimal:2', 'metadata' => 'array'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class PaymentInvoice extends Model
|
||||||
|
{
|
||||||
|
public const STATUS_PENDING = 'pending';
|
||||||
|
|
||||||
|
public const STATUS_PAID = 'paid';
|
||||||
|
|
||||||
|
public const MODE_TEST = 'test';
|
||||||
|
|
||||||
|
public const MODE_PRODUCTION = 'production';
|
||||||
|
|
||||||
|
protected $connection = 'azerothcore_auth';
|
||||||
|
|
||||||
|
protected $table = 'payment_invoices';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['amount' => 'decimal:2', 'paid_at' => 'datetime'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
||||||
|
class ShopCategory extends Model
|
||||||
|
{
|
||||||
|
protected $connection = 'store';
|
||||||
|
|
||||||
|
protected $table = 'store_categories';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = ['name', 'icon', 'requiredRank', 'flags', 'enabled'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['requiredRank' => 'integer', 'flags' => 'integer', 'enabled' => 'bool'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function products(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(ShopProduct::class, 'store_category_service_link', 'category', 'service');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOrdered(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->orderBy('id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
||||||
|
class ShopProduct extends Model
|
||||||
|
{
|
||||||
|
protected $connection = 'store';
|
||||||
|
|
||||||
|
protected $table = 'store_services';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $guarded = ['id'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['price' => 'integer', 'discountAmount' => 'integer', 'new' => 'bool', 'enabled' => 'bool'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function categories(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(ShopCategory::class, 'store_category_service_link', 'service', 'category');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOrdered(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->orderBy('id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\BalanceTransaction;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class BalanceService
|
||||||
|
{
|
||||||
|
public function balance(int $accountId): string
|
||||||
|
{
|
||||||
|
$value = DB::connection('azerothcore_auth')->table('account_balances')
|
||||||
|
->where('account_id', $accountId)
|
||||||
|
->value('balance');
|
||||||
|
|
||||||
|
return $this->fromCents($this->toCents((string) ($value ?? '0')));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, BalanceTransaction> */
|
||||||
|
public function recentTransactions(int $accountId, int $limit = 10): Collection
|
||||||
|
{
|
||||||
|
return BalanceTransaction::query()
|
||||||
|
->where('account_id', $accountId)
|
||||||
|
->latest('id')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function spend(int $accountId, string $amount, string $reference, ?string $description = null): BalanceTransaction
|
||||||
|
{
|
||||||
|
$cents = $this->toCents($amount);
|
||||||
|
if ($cents <= 0) {
|
||||||
|
throw new InvalidArgumentException('Сумма списания должна быть больше нуля.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::connection('azerothcore_auth')->transaction(function () use ($accountId, $cents, $reference, $description): BalanceTransaction {
|
||||||
|
$existing = BalanceTransaction::query()->where('reference', $reference)->first();
|
||||||
|
if ($existing) {
|
||||||
|
if ($existing->account_id !== $accountId || $this->toCents((string) $existing->amount) !== -$cents) {
|
||||||
|
throw new InvalidArgumentException('Ключ операции уже использован с другими параметрами.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
$balance = DB::connection('azerothcore_auth')->table('account_balances')
|
||||||
|
->where('account_id', $accountId)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
$current = $this->toCents((string) ($balance->balance ?? '0'));
|
||||||
|
|
||||||
|
if ($current < $cents) {
|
||||||
|
throw new InvalidArgumentException('Недостаточно средств.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$after = $this->fromCents($current - $cents);
|
||||||
|
DB::connection('azerothcore_auth')->table('account_balances')
|
||||||
|
->where('account_id', $accountId)
|
||||||
|
->update(['balance' => $after, 'updated_at' => now()]);
|
||||||
|
|
||||||
|
return BalanceTransaction::query()->create([
|
||||||
|
'account_id' => $accountId,
|
||||||
|
'type' => BalanceTransaction::TYPE_SPEND,
|
||||||
|
'amount' => $this->fromCents(-$cents),
|
||||||
|
'balance_after' => $after,
|
||||||
|
'reference' => $reference,
|
||||||
|
'metadata' => array_filter(['description' => $description]),
|
||||||
|
]);
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adjustByAdmin(
|
||||||
|
int $accountId,
|
||||||
|
string $amount,
|
||||||
|
string $direction,
|
||||||
|
int $adminAccountId,
|
||||||
|
string $adminUsername,
|
||||||
|
string $reason,
|
||||||
|
): BalanceTransaction {
|
||||||
|
$cents = $this->toCents($amount);
|
||||||
|
if ($cents <= 0 || ! in_array($direction, ['credit', 'debit'], true)) {
|
||||||
|
throw new InvalidArgumentException('Некорректная корректировка баланса.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::connection('azerothcore_auth')->transaction(function () use ($accountId, $cents, $direction, $adminAccountId, $adminUsername, $reason): BalanceTransaction {
|
||||||
|
DB::connection('azerothcore_auth')->table('account_balances')->insertOrIgnore([
|
||||||
|
'account_id' => $accountId,
|
||||||
|
'balance' => '0.00',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
$balance = DB::connection('azerothcore_auth')->table('account_balances')
|
||||||
|
->where('account_id', $accountId)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
$current = $this->toCents((string) $balance->balance);
|
||||||
|
$delta = $direction === 'credit' ? $cents : -$cents;
|
||||||
|
|
||||||
|
if ($current + $delta < 0) {
|
||||||
|
throw new InvalidArgumentException('Недостаточно средств для ручного списания.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$after = $this->fromCents($current + $delta);
|
||||||
|
DB::connection('azerothcore_auth')->table('account_balances')
|
||||||
|
->where('account_id', $accountId)
|
||||||
|
->update(['balance' => $after, 'updated_at' => now()]);
|
||||||
|
|
||||||
|
return BalanceTransaction::query()->create([
|
||||||
|
'account_id' => $accountId,
|
||||||
|
'type' => $direction === 'credit' ? BalanceTransaction::TYPE_ADMIN_CREDIT : BalanceTransaction::TYPE_ADMIN_DEBIT,
|
||||||
|
'amount' => $this->fromCents($delta),
|
||||||
|
'balance_after' => $after,
|
||||||
|
'reference' => 'admin:'.Str::uuid(),
|
||||||
|
'metadata' => [
|
||||||
|
'admin_account_id' => $adminAccountId,
|
||||||
|
'admin_username' => $adminUsername,
|
||||||
|
'reason' => $reason,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, BalanceTransaction> */
|
||||||
|
public function adminTransactions(?string $search = null, int $limit = 100): Collection
|
||||||
|
{
|
||||||
|
return BalanceTransaction::query()
|
||||||
|
->leftJoin('account', 'account.id', '=', 'balance_transactions.account_id')
|
||||||
|
->select('balance_transactions.*', 'account.username')
|
||||||
|
->when($search, function ($query, string $search): void {
|
||||||
|
$query->where(function ($query) use ($search): void {
|
||||||
|
$query->where('account.username', 'like', '%'.$search.'%');
|
||||||
|
if (ctype_digit($search)) {
|
||||||
|
$query->orWhere('balance_transactions.account_id', (int) $search);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->latest('balance_transactions.id')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toCents(string $amount): int
|
||||||
|
{
|
||||||
|
$normalized = str_replace(',', '.', trim($amount));
|
||||||
|
if (! preg_match('/^-?\d+(?:\.\d{1,6})?$/', $normalized)) {
|
||||||
|
throw new InvalidArgumentException('Некорректная сумма.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$negative = str_starts_with($normalized, '-');
|
||||||
|
$normalized = ltrim($normalized, '-');
|
||||||
|
[$whole, $fraction] = array_pad(explode('.', $normalized, 2), 2, '');
|
||||||
|
$cents = ((int) $whole * 100) + (int) str_pad(substr($fraction, 0, 2), 2, '0');
|
||||||
|
|
||||||
|
return $negative ? -$cents : $cents;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fromCents(int $cents): string
|
||||||
|
{
|
||||||
|
return number_format($cents / 100, 2, '.', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\BalanceTransaction;
|
||||||
|
use App\Models\PaymentInvoice;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class RobokassaService
|
||||||
|
{
|
||||||
|
public function createInvoice(int $accountId, string $amount): PaymentInvoice
|
||||||
|
{
|
||||||
|
$mode = $this->currentMode();
|
||||||
|
$this->assertConfigured($mode);
|
||||||
|
|
||||||
|
return PaymentInvoice::query()->create([
|
||||||
|
'account_id' => $accountId,
|
||||||
|
'amount' => $this->normalizeAmount($amount),
|
||||||
|
'mode' => $mode,
|
||||||
|
'status' => PaymentInvoice::STATUS_PENDING,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
public function paymentParameters(PaymentInvoice $invoice, string $email = ''): array
|
||||||
|
{
|
||||||
|
$mode = (string) $invoice->mode;
|
||||||
|
$this->assertConfigured($mode);
|
||||||
|
|
||||||
|
$custom = ['Shp_account' => (string) $invoice->account_id];
|
||||||
|
$amount = $this->normalizeAmount((string) $invoice->amount);
|
||||||
|
|
||||||
|
return array_filter([
|
||||||
|
'MerchantLogin' => $this->credential($mode, 'login'),
|
||||||
|
'OutSum' => $amount,
|
||||||
|
'InvId' => (string) $invoice->id,
|
||||||
|
'Description' => 'Пополнение Слёз Элуны',
|
||||||
|
'SignatureValue' => $this->hash($this->signatureBase(
|
||||||
|
[$this->credential($mode, 'login'), $amount, (string) $invoice->id, $this->credential($mode, 'password1')],
|
||||||
|
$custom,
|
||||||
|
), $mode),
|
||||||
|
'Email' => $email,
|
||||||
|
'Culture' => 'ru',
|
||||||
|
'Encoding' => 'utf-8',
|
||||||
|
'IsTest' => $mode === PaymentInvoice::MODE_TEST ? '1' : '0',
|
||||||
|
...$custom,
|
||||||
|
], static fn (string $value): bool => $value !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $payload */
|
||||||
|
public function processResult(array $payload): PaymentInvoice
|
||||||
|
{
|
||||||
|
$invoiceId = $this->value($payload, 'InvId');
|
||||||
|
$amount = $this->value($payload, 'OutSum');
|
||||||
|
$signature = $this->value($payload, 'SignatureValue');
|
||||||
|
$accountId = $this->value($payload, 'Shp_account');
|
||||||
|
|
||||||
|
if (! ctype_digit($invoiceId) || ! ctype_digit($accountId) || $signature === '') {
|
||||||
|
throw new InvalidArgumentException('Некорректные параметры уведомления Robokassa.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoiceForSignature = PaymentInvoice::query()->findOrFail((int) $invoiceId);
|
||||||
|
$mode = (string) $invoiceForSignature->mode;
|
||||||
|
$this->assertConfigured($mode);
|
||||||
|
|
||||||
|
$expected = $this->hash($this->signatureBase(
|
||||||
|
[$amount, $invoiceId, $this->credential($mode, 'password2')],
|
||||||
|
['Shp_account' => $accountId],
|
||||||
|
), $mode);
|
||||||
|
|
||||||
|
if (! hash_equals(strtolower($expected), strtolower($signature))) {
|
||||||
|
throw new InvalidArgumentException('Некорректная подпись Robokassa.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::connection('azerothcore_auth')->transaction(function () use ($invoiceId, $accountId, $amount): PaymentInvoice {
|
||||||
|
$invoice = PaymentInvoice::query()->lockForUpdate()->findOrFail((int) $invoiceId);
|
||||||
|
|
||||||
|
if ($invoice->account_id !== (int) $accountId || $this->normalizeAmount((string) $invoice->amount) !== $this->normalizeAmount($amount)) {
|
||||||
|
throw new InvalidArgumentException('Сумма или получатель платежа не совпадают со счётом.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($invoice->status === PaymentInvoice::STATUS_PAID) {
|
||||||
|
return $invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::connection('azerothcore_auth')->table('account_balances')->insertOrIgnore([
|
||||||
|
'account_id' => $invoice->account_id,
|
||||||
|
'balance' => '0.00',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
$balance = DB::connection('azerothcore_auth')->table('account_balances')
|
||||||
|
->where('account_id', $invoice->account_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
$current = (string) $balance->balance;
|
||||||
|
|
||||||
|
$tearsAmount = $this->multiplyAmount(
|
||||||
|
(string) $invoice->amount,
|
||||||
|
(int) config('services.robokassa.tears_per_ruble'),
|
||||||
|
);
|
||||||
|
$after = $this->addAmounts($current, $tearsAmount);
|
||||||
|
DB::connection('azerothcore_auth')->table('account_balances')
|
||||||
|
->where('account_id', $invoice->account_id)
|
||||||
|
->update(['balance' => $after, 'updated_at' => now()]);
|
||||||
|
|
||||||
|
BalanceTransaction::query()->create([
|
||||||
|
'account_id' => $invoice->account_id,
|
||||||
|
'type' => BalanceTransaction::TYPE_DEPOSIT,
|
||||||
|
'amount' => $tearsAmount,
|
||||||
|
'balance_after' => $after,
|
||||||
|
'reference' => 'robokassa:'.$invoice->id,
|
||||||
|
'metadata' => [
|
||||||
|
'invoice_id' => $invoice->id,
|
||||||
|
'paid_rubles' => $invoice->amount,
|
||||||
|
'tears_per_ruble' => (int) config('services.robokassa.tears_per_ruble'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$invoice->update(['status' => PaymentInvoice::STATUS_PAID, 'paid_at' => now()]);
|
||||||
|
|
||||||
|
return $invoice->refresh();
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeAmount(string $amount): string
|
||||||
|
{
|
||||||
|
if (! is_numeric($amount) || (float) $amount < 0) {
|
||||||
|
throw new InvalidArgumentException('Некорректная сумма.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return number_format((float) $amount, 2, '.', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addAmounts(string $left, string $right): string
|
||||||
|
{
|
||||||
|
$toCents = static function (string $value): int {
|
||||||
|
[$whole, $fraction] = explode('.', number_format((float) $value, 2, '.', ''));
|
||||||
|
|
||||||
|
return ((int) $whole * 100) + (int) $fraction;
|
||||||
|
};
|
||||||
|
|
||||||
|
return number_format(($toCents($left) + $toCents($right)) / 100, 2, '.', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function multiplyAmount(string $amount, int $multiplier): string
|
||||||
|
{
|
||||||
|
if ($multiplier <= 0) {
|
||||||
|
throw new RuntimeException('Некорректный курс Слёз Элуны.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return number_format((float) $amount * $multiplier, 2, '.', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $parts @param array<string, string> $custom */
|
||||||
|
private function signatureBase(array $parts, array $custom): string
|
||||||
|
{
|
||||||
|
ksort($custom, SORT_STRING);
|
||||||
|
|
||||||
|
return implode(':', [...$parts, ...array_map(
|
||||||
|
static fn (string $name, string $value): string => $name.'='.$value,
|
||||||
|
array_keys($custom),
|
||||||
|
array_values($custom),
|
||||||
|
)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hash(string $value, ?string $mode = null): string
|
||||||
|
{
|
||||||
|
$algorithm = strtolower($this->credential($mode ?? $this->currentMode(), 'hash_algorithm'));
|
||||||
|
if (! in_array($algorithm, hash_algos(), true)) {
|
||||||
|
throw new RuntimeException('Неизвестный алгоритм подписи Robokassa.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash($algorithm, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $payload */
|
||||||
|
private function value(array $payload, string $key): string
|
||||||
|
{
|
||||||
|
foreach ($payload as $payloadKey => $value) {
|
||||||
|
if (strcasecmp((string) $payloadKey, $key) === 0 && is_scalar($value)) {
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentMode(): string
|
||||||
|
{
|
||||||
|
$mode = (string) config('services.robokassa.mode');
|
||||||
|
if (! in_array($mode, [PaymentInvoice::MODE_TEST, PaymentInvoice::MODE_PRODUCTION], true)) {
|
||||||
|
throw new RuntimeException('ROBOKASSA_MODE должен быть test или production.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function credential(string $mode, string $key): string
|
||||||
|
{
|
||||||
|
return (string) config("services.robokassa.environments.{$mode}.{$key}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertConfigured(string $mode): void
|
||||||
|
{
|
||||||
|
if (! in_array($mode, [PaymentInvoice::MODE_TEST, PaymentInvoice::MODE_PRODUCTION], true)) {
|
||||||
|
throw new RuntimeException('Некорректный режим Robokassa.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['login', 'password1', 'password2'] as $key) {
|
||||||
|
if ($this->credential($mode, $key) === '') {
|
||||||
|
throw new RuntimeException("Robokassa не настроена для режима {$mode}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\ShopCategory;
|
||||||
|
use App\Models\ShopProduct;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ShopCatalogService
|
||||||
|
{
|
||||||
|
/** @return Collection<int, ShopCategory> */
|
||||||
|
public function categories(): Collection
|
||||||
|
{
|
||||||
|
return ShopCategory::query()->ordered()->withCount('products')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, ShopProduct> */
|
||||||
|
public function products(): Collection
|
||||||
|
{
|
||||||
|
return ShopProduct::query()->with('categories')->ordered()->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createCategory(array $data): ShopCategory
|
||||||
|
{
|
||||||
|
return ShopCategory::query()->create($this->categoryData($data));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateCategory(ShopCategory $category, array $data): ShopCategory
|
||||||
|
{
|
||||||
|
$category->update($this->categoryData($data));
|
||||||
|
|
||||||
|
return $category->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteCategory(ShopCategory $category): void
|
||||||
|
{
|
||||||
|
DB::connection('store')->transaction(function () use ($category): void {
|
||||||
|
$category->products()->detach();
|
||||||
|
$category->delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createProduct(array $data): ShopProduct
|
||||||
|
{
|
||||||
|
return DB::connection('store')->transaction(function () use ($data): ShopProduct {
|
||||||
|
$product = ShopProduct::query()->create($this->productData($data));
|
||||||
|
$product->categories()->sync($data['category_ids']);
|
||||||
|
|
||||||
|
return $product->load('categories');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateProduct(ShopProduct $product, array $data): ShopProduct
|
||||||
|
{
|
||||||
|
return DB::connection('store')->transaction(function () use ($product, $data): ShopProduct {
|
||||||
|
$product->update($this->productData($data));
|
||||||
|
$product->categories()->sync($data['category_ids']);
|
||||||
|
|
||||||
|
return $product->refresh()->load('categories');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteProduct(ShopProduct $product): void
|
||||||
|
{
|
||||||
|
DB::connection('store')->transaction(function () use ($product): void {
|
||||||
|
$product->categories()->detach();
|
||||||
|
$product->delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function categoryData(array $data): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => trim($data['name']),
|
||||||
|
'icon' => trim((string) ($data['icon'] ?? '')),
|
||||||
|
'requiredRank' => (int) $data['requiredRank'],
|
||||||
|
'flags' => (int) $data['flags'],
|
||||||
|
'enabled' => (int) ($data['enabled'] ?? false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function productData(array $data): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => (int) $data['type'],
|
||||||
|
'name' => trim($data['name']),
|
||||||
|
'tooltipName' => trim((string) ($data['tooltipName'] ?? '')),
|
||||||
|
'tooltipType' => trim((string) ($data['tooltipType'] ?? '')),
|
||||||
|
'tooltipText' => trim((string) ($data['tooltipText'] ?? '')),
|
||||||
|
'icon' => trim((string) ($data['icon'] ?? '')),
|
||||||
|
'price' => (int) $data['price'],
|
||||||
|
'currency' => 1,
|
||||||
|
'hyperlinkId' => (int) ($data['hyperlinkId'] ?? 0),
|
||||||
|
'creatureEntry' => (int) ($data['creatureEntry'] ?? 0),
|
||||||
|
'discountAmount' => (int) ($data['discountAmount'] ?? 0),
|
||||||
|
'flags' => (int) ($data['flags'] ?? 0),
|
||||||
|
...collect(range(1, 8))->flatMap(fn (int $slot): array => [
|
||||||
|
'reward_'.$slot => (int) ($data['reward_'.$slot] ?? 0),
|
||||||
|
'rewardcount_'.$slot => (int) ($data['rewardcount_'.$slot] ?? 0),
|
||||||
|
])->all(),
|
||||||
|
'new' => (int) ($data['new'] ?? false),
|
||||||
|
'enabled' => (int) ($data['enabled'] ?? false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->trustProxies(at: env('TRUSTED_PROXIES', '*'));
|
$middleware->trustProxies(at: env('TRUSTED_PROXIES', '*'));
|
||||||
|
|
||||||
|
$middleware->validateCsrfTokens(except: [
|
||||||
|
'payments/robokassa/success',
|
||||||
|
'payments/robokassa/fail',
|
||||||
|
]);
|
||||||
|
|
||||||
$middleware->api(prepend: [
|
$middleware->api(prepend: [
|
||||||
\Illuminate\Http\Middleware\SetCacheHeaders::class . ':no_store',
|
\Illuminate\Http\Middleware\SetCacheHeaders::class . ':no_store',
|
||||||
\App\Http\Middleware\ForceJsonResponse::class,
|
\App\Http\Middleware\ForceJsonResponse::class,
|
||||||
@@ -28,6 +33,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'gamemaster' => \App\Http\Middleware\EnsureGameMaster::class,
|
'gamemaster' => \App\Http\Middleware\EnsureGameMaster::class,
|
||||||
'launcher' => \App\Http\Middleware\EnsureLauncherRequest::class,
|
'launcher' => \App\Http\Middleware\EnsureLauncherRequest::class,
|
||||||
|
'game-server' => \App\Http\Middleware\EnsureGameServer::class,
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
|
|||||||
@@ -104,6 +104,22 @@ return [
|
|||||||
]) : [],
|
]) : [],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'store' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'host' => env('STORE_DB_HOST', env('AZEROTHCORE_AUTH_DB_HOST', '127.0.0.1')),
|
||||||
|
'port' => env('STORE_DB_PORT', env('AZEROTHCORE_AUTH_DB_PORT', '3306')),
|
||||||
|
'database' => env('STORE_DB_DATABASE', 'store'),
|
||||||
|
'username' => env('STORE_DB_USERNAME', env('AZEROTHCORE_AUTH_DB_USERNAME', 'root')),
|
||||||
|
'password' => env('STORE_DB_PASSWORD', env('AZEROTHCORE_AUTH_DB_PASSWORD', 'password')),
|
||||||
|
'unix_socket' => env('STORE_DB_SOCKET', ''),
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
'collation' => 'utf8mb4_general_ci',
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
],
|
||||||
|
|
||||||
'mariadb' => [
|
'mariadb' => [
|
||||||
'driver' => 'mariadb',
|
'driver' => 'mariadb',
|
||||||
'url' => env('DB_URL'),
|
'url' => env('DB_URL'),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ return [
|
|||||||
'characters_connection' => env('AZEROTHCORE_CHARACTERS_CONNECTION', 'azerothcore_characters'),
|
'characters_connection' => env('AZEROTHCORE_CHARACTERS_CONNECTION', 'azerothcore_characters'),
|
||||||
|
|
||||||
'registration' => [
|
'registration' => [
|
||||||
|
'require_invite_code' => (bool) env('MOONWELL_REGISTRATION_REQUIRE_INVITE', true),
|
||||||
'gmlevel' => (int) env('AZEROTHCORE_ACCOUNT_GMLEVEL', 0),
|
'gmlevel' => (int) env('AZEROTHCORE_ACCOUNT_GMLEVEL', 0),
|
||||||
'realm_id' => (int) env('AZEROTHCORE_ACCOUNT_REALM_ID', -1),
|
'realm_id' => (int) env('AZEROTHCORE_ACCOUNT_REALM_ID', -1),
|
||||||
'expansion' => (int) env('AZEROTHCORE_ACCOUNT_EXPANSION', 2),
|
'expansion' => (int) env('AZEROTHCORE_ACCOUNT_EXPANSION', 2),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
$robokassaMode = env('ROBOKASSA_MODE', 'test');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -35,4 +37,30 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'robokassa' => [
|
||||||
|
'mode' => $robokassaMode,
|
||||||
|
'environments' => [
|
||||||
|
'test' => [
|
||||||
|
'login' => env('ROBOKASSA_TEST_LOGIN', env('ROBOKASSA_LOGIN')),
|
||||||
|
'password1' => env('ROBOKASSA_TEST_PASSWORD1', env('ROBOKASSA_PASSWORD1')),
|
||||||
|
'password2' => env('ROBOKASSA_TEST_PASSWORD2', env('ROBOKASSA_PASSWORD2')),
|
||||||
|
'hash_algorithm' => env('ROBOKASSA_TEST_HASH_ALGORITHM', env('ROBOKASSA_HASH_ALGORITHM', 'md5')),
|
||||||
|
],
|
||||||
|
'production' => [
|
||||||
|
'login' => env('ROBOKASSA_PRODUCTION_LOGIN'),
|
||||||
|
'password1' => env('ROBOKASSA_PRODUCTION_PASSWORD1'),
|
||||||
|
'password2' => env('ROBOKASSA_PRODUCTION_PASSWORD2'),
|
||||||
|
'hash_algorithm' => env('ROBOKASSA_PRODUCTION_HASH_ALGORITHM', 'md5'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'payment_url' => env('ROBOKASSA_PAYMENT_URL', 'https://auth.robokassa.ru/Merchant/Index.aspx'),
|
||||||
|
'minimum_amount' => env('ROBOKASSA_MINIMUM_AMOUNT', 10),
|
||||||
|
'maximum_amount' => env('ROBOKASSA_MAXIMUM_AMOUNT', 100000),
|
||||||
|
'tears_per_ruble' => env('MOONWELL_TEARS_PER_RUBLE', 10),
|
||||||
|
],
|
||||||
|
|
||||||
|
'game_server' => [
|
||||||
|
'balance_api_token' => env('GAME_SERVER_BALANCE_API_TOKEN'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function getConnection(): ?string
|
||||||
|
{
|
||||||
|
return app()->environment('testing') ? config('database.default') : 'azerothcore_auth';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::connection($this->getConnection())->create('account_balances', function (Blueprint $table): void {
|
||||||
|
$table->unsignedInteger('account_id')->primary();
|
||||||
|
$table->decimal('balance', 14, 2)->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::connection($this->getConnection())->create('payment_invoices', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedInteger('account_id')->index();
|
||||||
|
$table->decimal('amount', 14, 2);
|
||||||
|
$table->string('mode', 20)->default('test');
|
||||||
|
$table->string('status', 20)->default('pending')->index();
|
||||||
|
$table->timestamp('paid_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::connection($this->getConnection())->create('balance_transactions', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedInteger('account_id')->index();
|
||||||
|
$table->string('type', 20);
|
||||||
|
$table->decimal('amount', 14, 2);
|
||||||
|
$table->decimal('balance_after', 14, 2);
|
||||||
|
$table->string('reference', 191)->unique();
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::connection($this->getConnection())->dropIfExists('balance_transactions');
|
||||||
|
Schema::connection($this->getConnection())->dropIfExists('payment_invoices');
|
||||||
|
Schema::connection($this->getConnection())->dropIfExists('account_balances');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Интеграция Слёз Элуны
|
||||||
|
|
||||||
|
Слёзы Элуны хранятся в `acore_auth.account_balances`, поэтому сайт и игровой сервер используют один источник истины. Все движения валюты записываются в `acore_auth.balance_transactions`.
|
||||||
|
|
||||||
|
Курс пополнения задаётся через `MOONWELL_TEARS_PER_RUBLE` и по умолчанию равен `10`: **1 рубль = 10 Слёз Элуны**. Суммы API игрового сервера и ручные операции администратора всегда указываются в Слёзах Элуны, а не в рублях.
|
||||||
|
|
||||||
|
## Robokassa
|
||||||
|
|
||||||
|
В технических настройках магазина укажите:
|
||||||
|
|
||||||
|
- ResultURL: `https://ВАШ-ДОМЕН/api/payments/robokassa/result`, метод POST;
|
||||||
|
- SuccessURL: `https://ВАШ-ДОМЕН/payments/robokassa/success`;
|
||||||
|
- FailURL: `https://ВАШ-ДОМЕН/payments/robokassa/fail`;
|
||||||
|
- алгоритм подписи, совпадающий с `ROBOKASSA_HASH_ALGORITHM`.
|
||||||
|
|
||||||
|
Доступны два независимых режима:
|
||||||
|
|
||||||
|
- `ROBOKASSA_MODE=test` использует `ROBOKASSA_TEST_LOGIN`, `ROBOKASSA_TEST_PASSWORD1`, `ROBOKASSA_TEST_PASSWORD2` и передаёт `IsTest=1`;
|
||||||
|
- `ROBOKASSA_MODE=production` использует отдельные `ROBOKASSA_PRODUCTION_*` значения и передаёт `IsTest=0`.
|
||||||
|
|
||||||
|
Текущие старые переменные `ROBOKASSA_LOGIN`, `ROBOKASSA_PASSWORD1`, `ROBOKASSA_PASSWORD2` поддерживаются как тестовые для обратной совместимости. Перед боевым запуском заполните production-переменные, переключите `ROBOKASSA_MODE=production` и выполните `php artisan config:clear`.
|
||||||
|
|
||||||
|
Режим сохраняется в каждом выставленном счёте. Поэтому отложенный тестовый callback продолжит проверяться тестовым Паролем №2 даже после переключения приложения в production.
|
||||||
|
|
||||||
|
## API игрового сервера
|
||||||
|
|
||||||
|
Задайте длинный случайный `GAME_SERVER_BALANCE_API_TOKEN` и передавайте его как `Authorization: Bearer TOKEN`.
|
||||||
|
|
||||||
|
Получение количества Слёз Элуны:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/game/balance/7
|
||||||
|
Authorization: Bearer TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
Списание:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/game/balance/7/spend
|
||||||
|
Authorization: Bearer TOKEN
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"amount":"25.50","idempotency_key":"shop-purchase-42","description":"Название предмета"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`idempotency_key` должен быть уникальным для каждой игровой покупки. `amount` — количество списываемых Слёз Элуны. Повтор запроса с тем же ключом возвращает ту же операцию и не списывает валюту второй раз. Ответ `422` означает недостаточное количество или конфликт ключа; `401` — неверный серверный токен.
|
||||||
|
|
||||||
|
После развёртывания выполните `php artisan migrate --force`. Миграция создаёт таблицы непосредственно в подключении `azerothcore_auth`.
|
||||||
@@ -570,6 +570,60 @@ pre {
|
|||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.balance-operation {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-operation div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-operation span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-operation--positive {
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-ledger {
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-ledger__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 1fr) minmax(240px, 2fr) minmax(160px, auto);
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-ledger__row > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-ledger__row span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-ledger__amount {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-reward-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.code-block,
|
.code-block,
|
||||||
.feed-preview {
|
.feed-preview {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -758,6 +812,18 @@ pre {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.balance-ledger__row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-reward-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-ledger__amount {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
padding-top: 18px;
|
padding-top: 18px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { arrowSvg, iconPaths, moonMark } from './symbols';
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
registerEndpoint: { type: String, required: true },
|
registerEndpoint: { type: String, required: true },
|
||||||
csrfToken: { type: String, required: true },
|
csrfToken: { type: String, required: true },
|
||||||
|
inviteRequired: { type: Boolean, default: true },
|
||||||
auth: { type: Object, required: true },
|
auth: { type: Object, required: true },
|
||||||
flash: { type: Object, required: true },
|
flash: { type: Object, required: true },
|
||||||
});
|
});
|
||||||
@@ -32,7 +33,7 @@ const clientErrors = computed(() => {
|
|||||||
const errors = {};
|
const errors = {};
|
||||||
if (form.username && !/^[a-zA-Z0-9]{3,32}$/.test(form.username)) errors.username = 'Только латиница и цифры, 3-32 символа.';
|
if (form.username && !/^[a-zA-Z0-9]{3,32}$/.test(form.username)) errors.username = 'Только латиница и цифры, 3-32 символа.';
|
||||||
if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) errors.email = 'Похоже на некорректный email.';
|
if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) errors.email = 'Похоже на некорректный email.';
|
||||||
if (form.invite_code && !/^[A-Za-z0-9-]{6,32}$/.test(form.invite_code)) errors.invite_code = 'Код из букв, цифр и тире, 6-32 символа.';
|
if (props.inviteRequired && form.invite_code && !/^[A-Za-z0-9-]{6,32}$/.test(form.invite_code)) errors.invite_code = 'Код из букв, цифр и тире, 6-32 символа.';
|
||||||
if (form.password && form.password.length < 8) errors.password = 'Минимум 8 символов.';
|
if (form.password && form.password.length < 8) errors.password = 'Минимум 8 символов.';
|
||||||
else if (form.password && !/[A-Za-z]/.test(form.password)) errors.password = 'Должна быть хотя бы одна буква.';
|
else if (form.password && !/[A-Za-z]/.test(form.password)) errors.password = 'Должна быть хотя бы одна буква.';
|
||||||
else if (form.password && !/\d/.test(form.password)) errors.password = 'Должна быть хотя бы одна цифра.';
|
else if (form.password && !/\d/.test(form.password)) errors.password = 'Должна быть хотя бы одна цифра.';
|
||||||
@@ -43,7 +44,7 @@ const clientErrors = computed(() => {
|
|||||||
const valid = computed(() => Boolean(
|
const valid = computed(() => Boolean(
|
||||||
form.username &&
|
form.username &&
|
||||||
form.email &&
|
form.email &&
|
||||||
form.invite_code &&
|
(!props.inviteRequired || form.invite_code) &&
|
||||||
form.password &&
|
form.password &&
|
||||||
form.password_confirmation &&
|
form.password_confirmation &&
|
||||||
form.terms &&
|
form.terms &&
|
||||||
@@ -77,7 +78,7 @@ function submit(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
['username', 'email', 'invite_code', 'password', 'password_confirmation', 'terms'].forEach((field) => {
|
['username', 'email', ...(props.inviteRequired ? ['invite_code'] : []), 'password', 'password_confirmation', 'terms'].forEach((field) => {
|
||||||
touched[field] = true;
|
touched[field] = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -104,8 +105,8 @@ function submit(event) {
|
|||||||
<div style="height: 20px"></div>
|
<div style="height: 20px"></div>
|
||||||
<h2 class="h-display" style="font-size: clamp(36px, 5vw, 56px)">Создай игровой аккаунт</h2>
|
<h2 class="h-display" style="font-size: clamp(36px, 5vw, 56px)">Создай игровой аккаунт</h2>
|
||||||
<div class="divider-ornament" style="justify-content: flex-start; margin: 22px 0"><span style="opacity: 0.6">✦</span></div>
|
<div class="divider-ornament" style="justify-content: flex-start; margin: 22px 0"><span style="opacity: 0.6">✦</span></div>
|
||||||
<p class="lead" style="font-style: italic">Укажи инвайт-код, логин, почту и пароль - аккаунт будет готов к входу в игру сразу после регистрации.</p>
|
<p class="lead" style="font-style: italic">{{ inviteRequired ? 'Укажи инвайт-код, логин, почту и пароль' : 'Укажи логин, почту и пароль' }} — аккаунт будет готов к входу в игру сразу после регистрации.</p>
|
||||||
<div class="join-note">
|
<div v-if="inviteRequired" class="join-note">
|
||||||
<span class="join-note-k">Закрытый бета-доступ</span>
|
<span class="join-note-k">Закрытый бета-доступ</span>
|
||||||
<span class="join-note-v">Пока идет закрытый этап: регистрация только по инвайт-кодам. Код можно получить у друзей-игроков или в нашем Discord.</span>
|
<span class="join-note-v">Пока идет закрытый этап: регистрация только по инвайт-кодам. Код можно получить у друзей-игроков или в нашем Discord.</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,7 +133,7 @@ function submit(event) {
|
|||||||
<div v-if="errorFor('email')" class="join-error">{{ errorFor('email') }}</div>
|
<div v-if="errorFor('email')" class="join-error">{{ errorFor('email') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :class="['join-field', { 'has-error': errorFor('invite_code') }]">
|
<div v-if="inviteRequired" :class="['join-field', { 'has-error': errorFor('invite_code') }]">
|
||||||
<label class="join-label">Инвайт-код</label>
|
<label class="join-label">Инвайт-код</label>
|
||||||
<div class="join-input">
|
<div class="join-input">
|
||||||
<span class="join-input-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" v-html="iconPaths.mask"></svg></span>
|
<span class="join-input-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" v-html="iconPaths.mask"></svg></span>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const props = defineProps({
|
|||||||
tweaks: { type: Object, default: () => ({}) },
|
tweaks: { type: Object, default: () => ({}) },
|
||||||
registerEndpoint: { type: String, required: true },
|
registerEndpoint: { type: String, required: true },
|
||||||
csrfToken: { type: String, required: true },
|
csrfToken: { type: String, required: true },
|
||||||
|
inviteRequired: { type: Boolean, default: true },
|
||||||
auth: { type: Object, default: () => ({}) },
|
auth: { type: Object, default: () => ({}) },
|
||||||
flash: { type: Object, default: () => ({}) },
|
flash: { type: Object, default: () => ({}) },
|
||||||
});
|
});
|
||||||
@@ -58,7 +59,7 @@ onBeforeUnmount(() => revealObserver?.disconnect());
|
|||||||
<Realm :realm="realm" />
|
<Realm :realm="realm" />
|
||||||
<About />
|
<About />
|
||||||
<Features />
|
<Features />
|
||||||
<Join :register-endpoint="registerEndpoint" :csrf-token="csrfToken" :auth="auth" :flash="flash" />
|
<Join :register-endpoint="registerEndpoint" :csrf-token="csrfToken" :invite-required="inviteRequired" :auth="auth" :flash="flash" />
|
||||||
<News :posts="posts" />
|
<News :posts="posts" />
|
||||||
<Footer :play-url="playUrl" />
|
<Footer :play-url="playUrl" />
|
||||||
<Tweaks :tweaks="tweaks" />
|
<Tweaks :tweaks="tweaks" />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<span class="eyebrow">Инвайты</span>
|
<span class="eyebrow">Инвайты</span>
|
||||||
<h2>Управление инвайт-кодами</h2>
|
<h2>Управление инвайт-кодами</h2>
|
||||||
<p>Без действующего инвайт-кода регистрация нового аккаунта недоступна.</p>
|
<p>{{ config('moonwell.registration.require_invite_code') ? 'Регистрация требует действующий инвайт-код.' : 'Регистрация сейчас открыта без инвайт-кодов.' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="portal-grid">
|
<div class="portal-grid">
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
@extends('layouts.portal')
|
||||||
|
|
||||||
|
@section('portal-content')
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="eyebrow">Финансы</span>
|
||||||
|
<h2>Слёзы Элуны</h2>
|
||||||
|
<p>Ручные операции сохраняются в общем журнале с администратором и причиной изменения.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="portal-grid">
|
||||||
|
<article class="content-card">
|
||||||
|
<h3>Изменить количество</h3>
|
||||||
|
<form class="register-form" method="POST" action="{{ route('admin.balances.adjust') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>ID или логин аккаунта</span>
|
||||||
|
<input class="form-control @error('account') is-invalid @enderror" name="account" value="{{ old('account') }}" required>
|
||||||
|
@error('account')<span class="form-error">{{ $message }}</span>@enderror
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Операция</span>
|
||||||
|
<select class="form-control @error('direction') is-invalid @enderror" name="direction" required>
|
||||||
|
<option value="credit" @selected(old('direction') === 'credit')>Начислить</option>
|
||||||
|
<option value="debit" @selected(old('direction') === 'debit')>Снять</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Количество Слёз Элуны</span>
|
||||||
|
<input class="form-control @error('amount') is-invalid @enderror" type="number" name="amount" min="0.01" max="1000000" step="0.01" value="{{ old('amount') }}" required>
|
||||||
|
@error('amount')<span class="form-error">{{ $message }}</span>@enderror
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Причина</span>
|
||||||
|
<input class="form-control @error('reason') is-invalid @enderror" name="reason" maxlength="255" value="{{ old('reason') }}" placeholder="Компенсация, возврат, нарушение…" required>
|
||||||
|
@error('reason')<span class="form-error">{{ $message }}</span>@enderror
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="button button--gold button--full" type="submit">Провести операцию</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="content-card">
|
||||||
|
<h3>Правила операций</h3>
|
||||||
|
<p>Количество нельзя сделать отрицательным. Каждая корректировка содержит ID администратора, логин, причину и итоговое значение.</p>
|
||||||
|
<p>Операции необратимы в журнале: ошибочное начисление исправляется отдельным списанием.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="eyebrow">Аудит</span>
|
||||||
|
<h2>Последние транзакции</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="search-form" method="GET" action="{{ route('admin.balances.index') }}">
|
||||||
|
<input class="form-control" type="search" name="search" value="{{ $search }}" placeholder="Логин или ID аккаунта">
|
||||||
|
<button class="button button--gold" type="submit">Найти</button>
|
||||||
|
@if ($search !== '')
|
||||||
|
<a class="button button--ghost" href="{{ route('admin.balances.index') }}">Сбросить</a>
|
||||||
|
@endif
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="account-list balance-ledger">
|
||||||
|
@forelse ($transactions as $transaction)
|
||||||
|
@php($meta = $transaction->metadata ?? [])
|
||||||
|
<article class="content-card balance-ledger__row">
|
||||||
|
<div>
|
||||||
|
<strong>{{ $transaction->username ?: 'Аккаунт удалён' }}</strong>
|
||||||
|
<span>ID #{{ $transaction->account_id }} · {{ $transaction->created_at->format('d.m.Y H:i:s') }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{{ match ($transaction->type) {
|
||||||
|
'deposit' => 'Пополнение',
|
||||||
|
'spend' => 'Покупка в игре',
|
||||||
|
'admin_credit' => 'Начисление администратором',
|
||||||
|
'admin_debit' => 'Списание администратором',
|
||||||
|
default => $transaction->type,
|
||||||
|
} }}</strong>
|
||||||
|
<span>{{ $meta['reason'] ?? $meta['description'] ?? $transaction->reference }}</span>
|
||||||
|
@if (isset($meta['admin_username']))
|
||||||
|
<span>Администратор: {{ $meta['admin_username'] }} (#{{ $meta['admin_account_id'] }})</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="balance-ledger__amount">
|
||||||
|
<strong class="{{ (float) $transaction->amount >= 0 ? 'balance-operation--positive' : '' }}">
|
||||||
|
{{ (float) $transaction->amount >= 0 ? '+' : '' }}{{ number_format((float) $transaction->amount, 2, ',', ' ') }}
|
||||||
|
</strong>
|
||||||
|
<span>Слёз Элуны: {{ number_format((float) $transaction->balance_after, 2, ',', ' ') }}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
@empty
|
||||||
|
<article class="content-card empty-state"><p>Транзакции не найдены.</p></article>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
@extends('layouts.portal')
|
||||||
|
|
||||||
|
@section('portal-content')
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="eyebrow">Игровой сервер</span>
|
||||||
|
<h2>Магазин</h2>
|
||||||
|
<p>Изменения записываются прямо в базу <code>store</code>. После сохранения перезагрузи данные магазина командой игрового модуля или перезапусти worldserver.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="portal-grid">
|
||||||
|
<article class="content-card">
|
||||||
|
<h3>Новая категория</h3>
|
||||||
|
<form class="stack-form" method="POST" action="{{ route('admin.shop.categories.store') }}">
|
||||||
|
@csrf
|
||||||
|
@include('admin.shop.partials.category-fields', ['category' => null])
|
||||||
|
<button class="button button--gold" type="submit">Создать категорию</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="content-card">
|
||||||
|
<h3>Новый товар или услуга</h3>
|
||||||
|
@if ($categories->isEmpty())
|
||||||
|
<p>Сначала создай категорию.</p>
|
||||||
|
@else
|
||||||
|
<form class="stack-form" method="POST" action="{{ route('admin.shop.products.store') }}">
|
||||||
|
@csrf
|
||||||
|
@include('admin.shop.partials.product-fields', ['product' => null])
|
||||||
|
<button class="button button--gold" type="submit">Создать позицию</button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-heading"><span class="eyebrow">Навигация магазина</span><h2>Категории</h2></div>
|
||||||
|
<div class="account-list">
|
||||||
|
@forelse ($categories as $category)
|
||||||
|
<article class="content-card">
|
||||||
|
<div class="account-card__header"><h3>#{{ $category->id }} · {{ $category->name }}</h3><span class="tag">{{ $category->products_count }} позиций</span></div>
|
||||||
|
<form class="stack-form" method="POST" action="{{ route('admin.shop.categories.update', $category) }}">
|
||||||
|
@csrf @method('PATCH')
|
||||||
|
@include('admin.shop.partials.category-fields', ['category' => $category])
|
||||||
|
<button class="button button--gold" type="submit">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="{{ route('admin.shop.categories.destroy', $category) }}">@csrf @method('DELETE')<button class="button button--ghost" onclick="return confirm('Удалить категорию и её связи с товарами?')">Удалить</button></form>
|
||||||
|
</article>
|
||||||
|
@empty
|
||||||
|
<article class="content-card empty-state"><p>Категорий пока нет.</p></article>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-heading"><span class="eyebrow">Контент магазина</span><h2>Товары и услуги</h2></div>
|
||||||
|
<div class="account-list">
|
||||||
|
@forelse ($products as $product)
|
||||||
|
<article class="content-card news-card">
|
||||||
|
<div class="account-card__header">
|
||||||
|
<div><h3>#{{ $product->id }} · {!! nl2br(e($product->name)) !!}</h3><p>{{ $product->categories->pluck('name')->join(', ') ?: 'Без категории' }} · тип {{ $product->type }}</p></div>
|
||||||
|
<span class="tag">{{ $product->price }} Слёз Элуны</span>
|
||||||
|
</div>
|
||||||
|
<form class="stack-form" method="POST" action="{{ route('admin.shop.products.update', $product) }}">
|
||||||
|
@csrf @method('PATCH')
|
||||||
|
@include('admin.shop.partials.product-fields', ['product' => $product])
|
||||||
|
<button class="button button--gold" type="submit">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="{{ route('admin.shop.products.destroy', $product) }}">@csrf @method('DELETE')<button class="button button--ghost" onclick="return confirm('Удалить позицию магазина?')">Удалить</button></form>
|
||||||
|
</article>
|
||||||
|
@empty
|
||||||
|
<article class="content-card empty-state"><p>Позиций пока нет.</p></article>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@php($category = $category ?? null)
|
||||||
|
<label class="form-field"><span>Название</span><input class="form-control" name="name" value="{{ $category?->name }}" maxlength="120" required></label>
|
||||||
|
<label class="form-field"><span>Иконка клиента</span><input class="form-control" name="icon" value="{{ $category?->icon }}" placeholder="inv_helmet_96" maxlength="255"></label>
|
||||||
|
<label class="form-field"><span>Требуемый ранг</span><input class="form-control" type="number" name="requiredRank" value="{{ $category?->requiredRank ?? 0 }}" min="0" max="255" required></label>
|
||||||
|
<label class="form-field"><span>Флаги</span><input class="form-control" type="number" name="flags" value="{{ $category?->flags ?? 0 }}" min="0" required></label>
|
||||||
|
<label class="checkbox-inline"><input type="checkbox" name="enabled" value="1" @checked($category?->enabled ?? true)><span>Включена</span></label>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
@php($product = $product ?? null)
|
||||||
|
@php($selectedCategories = $product?->categories?->pluck('id')->all() ?? [])
|
||||||
|
<label class="form-field"><span>Категории</span><select class="form-control" name="category_ids[]" multiple size="{{ min(max($categories->count(), 2), 8) }}" required>@foreach ($categories as $category)<option value="{{ $category->id }}" @selected(in_array($category->id, $selectedCategories, true))>{{ $category->name }}</option>@endforeach</select><small>Можно выбрать несколько с Ctrl/Cmd.</small></label>
|
||||||
|
<label class="form-field"><span>Тип</span><select class="form-control" name="type" required>@foreach ([1 => 'Предмет', 3 => 'Маунт', 4 => 'Питомец', 5 => 'Бафф', 7 => 'Услуга персонажа', 8 => 'Буст', 9 => 'Титул'] as $type => $label)<option value="{{ $type }}" @selected(($product?->type ?? 1) === $type)>{{ $label }} ({{ $type }})</option>@endforeach</select></label>
|
||||||
|
<label class="form-field"><span>Название</span><textarea class="form-control" name="name" required>{{ $product?->name }}</textarea></label>
|
||||||
|
<label class="form-field"><span>Название подсказки</span><input class="form-control" name="tooltipName" value="{{ $product?->tooltipName }}"></label>
|
||||||
|
<label class="form-field"><span>Тип подсказки</span><input class="form-control" name="tooltipType" value="{{ $product?->tooltipType }}" placeholder="item или spell"></label>
|
||||||
|
<label class="form-field"><span>Текст подсказки</span><textarea class="form-control form-control--textarea" name="tooltipText">{{ $product?->tooltipText }}</textarea></label>
|
||||||
|
<label class="form-field"><span>Иконка клиента</span><input class="form-control" name="icon" value="{{ $product?->icon }}" placeholder="inv_sword_22"></label>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="form-field"><span>Цена, Слёзы Элуны</span><input class="form-control" type="number" name="price" value="{{ $product?->price ?? 1 }}" min="1" required></label>
|
||||||
|
<label class="form-field"><span>Скидка, %</span><input class="form-control" type="number" name="discountAmount" value="{{ $product?->discountAmount ?? 0 }}" min="0" max="100"></label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="form-field"><span>Hyperlink ID</span><input class="form-control" type="number" name="hyperlinkId" value="{{ $product?->hyperlinkId ?? 0 }}" min="0"></label>
|
||||||
|
<label class="form-field"><span>Creature Entry</span><input class="form-control" type="number" name="creatureEntry" value="{{ $product?->creatureEntry ?? 0 }}" min="0"></label>
|
||||||
|
<label class="form-field"><span>Флаги</span><input class="form-control" type="number" name="flags" value="{{ $product?->flags ?? 0 }}" min="0"></label>
|
||||||
|
</div>
|
||||||
|
<div class="shop-reward-grid">
|
||||||
|
@foreach (range(1, 8) as $slot)
|
||||||
|
<label class="form-field"><span>Награда {{ $slot }}</span><input class="form-control" type="number" name="reward_{{ $slot }}" value="{{ $product?->{'reward_'.$slot} ?? 0 }}" min="0"></label>
|
||||||
|
<label class="form-field"><span>Количество {{ $slot }}</span><input class="form-control" type="number" name="rewardcount_{{ $slot }}" value="{{ $product?->{'rewardcount_'.$slot} ?? 0 }}" min="0"></label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<label class="checkbox-inline"><input type="checkbox" name="new" value="1" @checked($product?->new ?? false)><span>Пометка «Новинка»</span></label>
|
||||||
|
<label class="checkbox-inline"><input type="checkbox" name="enabled" value="1" @checked($product?->enabled ?? true)><span>Включён</span></label>
|
||||||
|
</div>
|
||||||
@@ -7,5 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<x-inertia::app />
|
<x-inertia::app />
|
||||||
|
@include('partials.yandex-metrika')
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -56,6 +56,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="section section--split">
|
||||||
|
<article class="content-card">
|
||||||
|
<span class="eyebrow">Слёзы Элуны</span>
|
||||||
|
<h2>{{ number_format((float) $balance, 2, ',', ' ') }}</h2>
|
||||||
|
<p>Пополняй счёт и используй Слёзы Элуны для покупок в игре.</p>
|
||||||
|
|
||||||
|
<form class="register-form" method="POST" action="{{ route('cabinet.balance.deposit') }}">
|
||||||
|
@csrf
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Сумма пополнения, ₽</span>
|
||||||
|
<input class="form-control @error('amount') is-invalid @enderror" type="number" name="amount" min="{{ $minimumDeposit }}" max="{{ $maximumDeposit }}" step="0.01" value="{{ old('amount', 500) }}" required>
|
||||||
|
@error('amount')<span class="form-error">{{ $message }}</span>@enderror
|
||||||
|
</label>
|
||||||
|
<button class="button button--gold button--full" type="submit">Пополнить</button>
|
||||||
|
<span class="muted-note">1 ₽ = {{ $tearsPerRuble }} Слёз Элуны</span>
|
||||||
|
<span class="muted-note">От {{ $minimumDeposit }} до {{ number_format((float) $maximumDeposit, 0, ',', ' ') }} ₽</span>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="content-card">
|
||||||
|
<span class="eyebrow">История</span>
|
||||||
|
<h2>Последние операции</h2>
|
||||||
|
@forelse ($balanceTransactions as $transaction)
|
||||||
|
<div class="balance-operation">
|
||||||
|
<div>
|
||||||
|
<strong>{{ $transaction->type === 'deposit' ? 'Пополнение' : 'Покупка в игре' }}</strong>
|
||||||
|
<span>{{ $transaction->created_at->format('d.m.Y H:i') }}</span>
|
||||||
|
</div>
|
||||||
|
<strong class="{{ (float) $transaction->amount >= 0 ? 'balance-operation--positive' : '' }}">
|
||||||
|
{{ (float) $transaction->amount >= 0 ? '+' : '' }}{{ number_format((float) $transaction->amount, 2, ',', ' ') }}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="empty-inline">Операций пока нет.</div>
|
||||||
|
@endforelse
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="section section--split">
|
<section class="section section--split">
|
||||||
<article class="content-card">
|
<article class="content-card">
|
||||||
<span class="eyebrow">Безопасность</span>
|
<span class="eyebrow">Безопасность</span>
|
||||||
|
|||||||
@@ -25,5 +25,7 @@
|
|||||||
@csrf
|
@csrf
|
||||||
</form>
|
</form>
|
||||||
@endauth
|
@endauth
|
||||||
|
|
||||||
|
@include('partials.yandex-metrika')
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,5 +12,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@yield('content')
|
@yield('content')
|
||||||
|
@include('partials.yandex-metrika')
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
<a href="{{ route('cabinet.index') }}">Личный кабинет</a>
|
<a href="{{ route('cabinet.index') }}">Личный кабинет</a>
|
||||||
@if ($portalUser->canAccessAdminPanel())
|
@if ($portalUser->canAccessAdminPanel())
|
||||||
<a href="{{ route('admin.accounts.index') }}">Аккаунты</a>
|
<a href="{{ route('admin.accounts.index') }}">Аккаунты</a>
|
||||||
|
<a href="{{ route('admin.balances.index') }}">Слёзы Элуны</a>
|
||||||
|
<a href="{{ route('admin.shop.index') }}">Магазин</a>
|
||||||
<a href="{{ route('admin.login-screen-news.index') }}">Новости WoW</a>
|
<a href="{{ route('admin.login-screen-news.index') }}">Новости WoW</a>
|
||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!-- Yandex.Metrika counter -->
|
||||||
|
<script type="text/javascript">
|
||||||
|
(function(m,e,t,r,i,k,a){
|
||||||
|
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||||
|
m[i].l=1*new Date();
|
||||||
|
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||||||
|
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
|
||||||
|
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=109338352', 'ym');
|
||||||
|
|
||||||
|
ym(109338352, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
|
||||||
|
</script>
|
||||||
|
<noscript><div><img src="https://mc.yandex.ru/watch/109338352" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
||||||
|
<!-- /Yandex.Metrika counter -->
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Переход к оплате</title></head>
|
||||||
|
<body>
|
||||||
|
<p>Переходим к пополнению…</p>
|
||||||
|
<form id="robokassa-payment" method="POST" action="{{ $paymentUrl }}">
|
||||||
|
@foreach ($parameters as $name => $value)
|
||||||
|
<input type="hidden" name="{{ $name }}" value="{{ $value }}">
|
||||||
|
@endforeach
|
||||||
|
<button type="submit">Перейти к оплате</button>
|
||||||
|
</form>
|
||||||
|
<script>document.getElementById('robokassa-payment').submit();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\GameBalanceController;
|
||||||
use App\Http\Controllers\Api\LauncherAuthController;
|
use App\Http\Controllers\Api\LauncherAuthController;
|
||||||
use App\Http\Controllers\Api\LauncherController;
|
use App\Http\Controllers\Api\LauncherController;
|
||||||
use App\Http\Controllers\Api\LauncherNewsController;
|
use App\Http\Controllers\Api\LauncherNewsController;
|
||||||
|
use App\Http\Controllers\Api\RobokassaController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::post('launcher/login', [LauncherAuthController::class, 'login'])
|
Route::post('launcher/login', [LauncherAuthController::class, 'login'])
|
||||||
@@ -18,3 +20,12 @@ Route::middleware('auth:api')->prefix('launcher')->group(function () {
|
|||||||
Route::middleware(['launcher'])->prefix('service')->name('service.')->group(function () {
|
Route::middleware(['launcher'])->prefix('service')->name('service.')->group(function () {
|
||||||
Route::post('/update-appcast', [LauncherController::class, 'updateAppcast']);
|
Route::post('/update-appcast', [LauncherController::class, 'updateAppcast']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::match(['get', 'post'], 'payments/robokassa/result', [RobokassaController::class, 'result'])
|
||||||
|
->middleware('throttle:60,1')
|
||||||
|
->name('payments.robokassa.result');
|
||||||
|
|
||||||
|
Route::middleware(['game-server', 'throttle:120,1'])->prefix('game/balance')->group(function (): void {
|
||||||
|
Route::get('{accountId}', [GameBalanceController::class, 'show'])->whereNumber('accountId');
|
||||||
|
Route::post('{accountId}/spend', [GameBalanceController::class, 'spend'])->whereNumber('accountId');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Admin\AccountController;
|
use App\Http\Controllers\Admin\AccountController;
|
||||||
|
use App\Http\Controllers\Admin\BalanceController;
|
||||||
use App\Http\Controllers\Admin\InviteCodeController;
|
use App\Http\Controllers\Admin\InviteCodeController;
|
||||||
use App\Http\Controllers\Admin\LoginScreenNewsController;
|
use App\Http\Controllers\Admin\LoginScreenNewsController;
|
||||||
use App\Http\Controllers\Admin\NewsController;
|
use App\Http\Controllers\Admin\NewsController;
|
||||||
|
use App\Http\Controllers\Admin\ShopController;
|
||||||
use App\Http\Controllers\Auth\LoginController;
|
use App\Http\Controllers\Auth\LoginController;
|
||||||
use App\Http\Controllers\CabinetController;
|
use App\Http\Controllers\CabinetController;
|
||||||
use App\Http\Controllers\LandingController;
|
use App\Http\Controllers\LandingController;
|
||||||
|
use App\Http\Controllers\PaymentController;
|
||||||
use App\Http\Controllers\WowLoginNewsFeedController;
|
use App\Http\Controllers\WowLoginNewsFeedController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -29,8 +32,14 @@ Route::middleware('auth')->group(function (): void {
|
|||||||
Route::get('/cabinet', [CabinetController::class, 'index'])->name('cabinet.index');
|
Route::get('/cabinet', [CabinetController::class, 'index'])->name('cabinet.index');
|
||||||
Route::post('/cabinet/password', [CabinetController::class, 'updatePassword'])->name('cabinet.password.update');
|
Route::post('/cabinet/password', [CabinetController::class, 'updatePassword'])->name('cabinet.password.update');
|
||||||
Route::get('/cabinet/client', [CabinetController::class, 'client'])->name('cabinet.client');
|
Route::get('/cabinet/client', [CabinetController::class, 'client'])->name('cabinet.client');
|
||||||
|
Route::post('/cabinet/balance/deposit', [PaymentController::class, 'create'])
|
||||||
|
->middleware('throttle:10,1')
|
||||||
|
->name('cabinet.balance.deposit');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::match(['get', 'post'], '/payments/robokassa/success', [PaymentController::class, 'success'])->name('payments.robokassa.success');
|
||||||
|
Route::match(['get', 'post'], '/payments/robokassa/fail', [PaymentController::class, 'fail'])->name('payments.robokassa.fail');
|
||||||
|
|
||||||
Route::middleware(['auth', 'gamemaster'])
|
Route::middleware(['auth', 'gamemaster'])
|
||||||
->prefix('admin')
|
->prefix('admin')
|
||||||
->name('admin.')
|
->name('admin.')
|
||||||
@@ -38,6 +47,10 @@ Route::middleware(['auth', 'gamemaster'])
|
|||||||
Route::get('/accounts', [AccountController::class, 'index'])->name('accounts.index');
|
Route::get('/accounts', [AccountController::class, 'index'])->name('accounts.index');
|
||||||
Route::patch('/accounts/{accountId}/access', [AccountController::class, 'updateAccess'])->name('accounts.access.update');
|
Route::patch('/accounts/{accountId}/access', [AccountController::class, 'updateAccess'])->name('accounts.access.update');
|
||||||
Route::post('/invite-codes', [InviteCodeController::class, 'store'])->name('invite-codes.store');
|
Route::post('/invite-codes', [InviteCodeController::class, 'store'])->name('invite-codes.store');
|
||||||
|
Route::get('/balances', [BalanceController::class, 'index'])->name('balances.index');
|
||||||
|
Route::post('/balances/adjust', [BalanceController::class, 'adjust'])
|
||||||
|
->middleware('throttle:30,1')
|
||||||
|
->name('balances.adjust');
|
||||||
Route::get('/login-screen-news', [LoginScreenNewsController::class, 'index'])->name('login-screen-news.index');
|
Route::get('/login-screen-news', [LoginScreenNewsController::class, 'index'])->name('login-screen-news.index');
|
||||||
Route::post('/login-screen-news', [LoginScreenNewsController::class, 'store'])->name('login-screen-news.store');
|
Route::post('/login-screen-news', [LoginScreenNewsController::class, 'store'])->name('login-screen-news.store');
|
||||||
Route::patch('/login-screen-news/{newsItem}', [LoginScreenNewsController::class, 'update'])->name('login-screen-news.update');
|
Route::patch('/login-screen-news/{newsItem}', [LoginScreenNewsController::class, 'update'])->name('login-screen-news.update');
|
||||||
@@ -47,4 +60,12 @@ Route::middleware(['auth', 'gamemaster'])
|
|||||||
Route::post('/news', [NewsController::class, 'store'])->name('news.store');
|
Route::post('/news', [NewsController::class, 'store'])->name('news.store');
|
||||||
Route::patch('/news/{newsItem}', [NewsController::class, 'update'])->name('news.update');
|
Route::patch('/news/{newsItem}', [NewsController::class, 'update'])->name('news.update');
|
||||||
Route::delete('/news/{newsItem}', [NewsController::class, 'destroy'])->name('news.destroy');
|
Route::delete('/news/{newsItem}', [NewsController::class, 'destroy'])->name('news.destroy');
|
||||||
|
|
||||||
|
Route::get('/shop', [ShopController::class, 'index'])->name('shop.index');
|
||||||
|
Route::post('/shop/categories', [ShopController::class, 'storeCategory'])->name('shop.categories.store');
|
||||||
|
Route::patch('/shop/categories/{category}', [ShopController::class, 'updateCategory'])->name('shop.categories.update');
|
||||||
|
Route::delete('/shop/categories/{category}', [ShopController::class, 'destroyCategory'])->name('shop.categories.destroy');
|
||||||
|
Route::post('/shop/products', [ShopController::class, 'storeProduct'])->name('shop.products.store');
|
||||||
|
Route::patch('/shop/products/{product}', [ShopController::class, 'updateProduct'])->name('shop.products.update');
|
||||||
|
Route::delete('/shop/products/{product}', [ShopController::class, 'destroyProduct'])->name('shop.products.destroy');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\BalanceTransaction;
|
||||||
|
use App\Models\GameAccountUser;
|
||||||
|
use App\Services\AzerothCoreAccountService;
|
||||||
|
use App\Services\BalanceService;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdminBalancesFeatureTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_regular_player_cannot_open_balance_admin(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->makeUser(0))->get(route('admin.balances.index'))->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_view_transaction_log(): void
|
||||||
|
{
|
||||||
|
$this->mock(BalanceService::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('adminTransactions')->once()->with(null)->andReturn(new Collection());
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->actingAs($this->makeUser(3))
|
||||||
|
->get(route('admin.balances.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Слёзы Элуны')
|
||||||
|
->assertSee('Последние транзакции');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_credit_account_balance(): void
|
||||||
|
{
|
||||||
|
$admin = $this->makeUser(3);
|
||||||
|
$player = $this->makeUser(0, 7, 'PLAYER');
|
||||||
|
|
||||||
|
$this->mock(AzerothCoreAccountService::class, function (MockInterface $mock) use ($player): void {
|
||||||
|
$mock->shouldReceive('findUserByUsername')->once()->with('PLAYER')->andReturn($player);
|
||||||
|
});
|
||||||
|
$this->mock(BalanceService::class, function (MockInterface $mock) use ($admin): void {
|
||||||
|
$transaction = new BalanceTransaction([
|
||||||
|
'account_id' => 7,
|
||||||
|
'balance_after' => '150.00',
|
||||||
|
]);
|
||||||
|
$mock->shouldReceive('adjustByAdmin')
|
||||||
|
->once()
|
||||||
|
->with(7, '50.00', 'credit', $admin->id, $admin->username, 'Компенсация')
|
||||||
|
->andReturn($transaction);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->from(route('admin.balances.index'))
|
||||||
|
->actingAs($admin)
|
||||||
|
->post(route('admin.balances.adjust'), [
|
||||||
|
'account' => 'PLAYER',
|
||||||
|
'direction' => 'credit',
|
||||||
|
'amount' => '50.00',
|
||||||
|
'reason' => 'Компенсация',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('admin.balances.index'))
|
||||||
|
->assertSessionHas('status', 'Счёт PLAYER изменён. Теперь на нём 150,00 Слёз Элуны.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeUser(int $gmLevel, int $id = 1, string $username = 'ADMIN'): GameAccountUser
|
||||||
|
{
|
||||||
|
return new GameAccountUser(
|
||||||
|
id: $id,
|
||||||
|
username: $username,
|
||||||
|
email: $username.'@EXAMPLE.COM',
|
||||||
|
regMail: $username.'@EXAMPLE.COM',
|
||||||
|
gmLevel: $gmLevel,
|
||||||
|
locked: false,
|
||||||
|
lastLoginAt: '2026-03-13 18:00:00',
|
||||||
|
joinedAt: '2026-03-12 12:00:00',
|
||||||
|
saltHex: str_repeat('AA', 32),
|
||||||
|
verifierHex: str_repeat('BB', 32),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\GameAccountUser;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdminShopFeatureTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
config(['database.connections.store' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]);
|
||||||
|
DB::purge('store');
|
||||||
|
|
||||||
|
Schema::connection('store')->create('store_categories', function (Blueprint $table): void {
|
||||||
|
$table->increments('id');
|
||||||
|
$table->string('name')->nullable();
|
||||||
|
$table->text('icon')->nullable();
|
||||||
|
$table->integer('requiredRank')->nullable();
|
||||||
|
$table->unsignedInteger('flags')->default(0);
|
||||||
|
$table->unsignedInteger('enabled')->default(1);
|
||||||
|
});
|
||||||
|
Schema::connection('store')->create('store_services', function (Blueprint $table): void {
|
||||||
|
$table->increments('id');
|
||||||
|
foreach (['type', 'price', 'currency', 'hyperlinkId', 'creatureEntry', 'discountAmount', 'flags'] as $column) {
|
||||||
|
$table->integer($column)->nullable();
|
||||||
|
}
|
||||||
|
foreach (['name', 'tooltipName', 'tooltipType', 'tooltipText', 'icon'] as $column) {
|
||||||
|
$table->text($column)->nullable();
|
||||||
|
}
|
||||||
|
foreach (range(1, 8) as $slot) {
|
||||||
|
$table->unsignedInteger('reward_'.$slot)->nullable();
|
||||||
|
$table->unsignedInteger('rewardcount_'.$slot)->nullable();
|
||||||
|
}
|
||||||
|
$table->unsignedInteger('new')->default(0);
|
||||||
|
$table->unsignedInteger('enabled')->nullable();
|
||||||
|
});
|
||||||
|
Schema::connection('store')->create('store_category_service_link', function (Blueprint $table): void {
|
||||||
|
$table->unsignedInteger('category');
|
||||||
|
$table->unsignedInteger('service');
|
||||||
|
$table->primary(['category', 'service']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_manage_game_store_category_and_product(): void
|
||||||
|
{
|
||||||
|
$admin = $this->admin();
|
||||||
|
$this->actingAs($admin)->post(route('admin.shop.categories.store'), [
|
||||||
|
'name' => 'Маунты', 'icon' => 'ability_mount', 'requiredRank' => 0, 'flags' => 0, 'enabled' => 1,
|
||||||
|
])->assertSessionHas('status', 'Категория создана.');
|
||||||
|
|
||||||
|
$categoryId = (int) DB::connection('store')->table('store_categories')->value('id');
|
||||||
|
$payload = [
|
||||||
|
'category_ids' => [$categoryId], 'type' => 3, 'name' => 'Спектральный тигр',
|
||||||
|
'tooltipName' => 'Маунт', 'tooltipType' => 'spell', 'tooltipText' => 'Редкий транспорт',
|
||||||
|
'icon' => 'ability_mount_spectraltiger', 'price' => 3000, 'hyperlinkId' => 42777,
|
||||||
|
'creatureEntry' => 24004, 'discountAmount' => 0, 'flags' => 0,
|
||||||
|
'reward_1' => 42777, 'rewardcount_1' => 1, 'new' => 1, 'enabled' => 1,
|
||||||
|
];
|
||||||
|
$this->actingAs($admin)->post(route('admin.shop.products.store'), $payload)
|
||||||
|
->assertSessionHas('status', 'Товар создан.');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('store_services', ['name' => 'Спектральный тигр', 'price' => 3000], 'store');
|
||||||
|
$this->assertDatabaseHas('store_category_service_link', ['category' => $categoryId], 'store');
|
||||||
|
$this->actingAs($admin)->get(route('admin.shop.index'))->assertOk()->assertSee('Спектральный тигр');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_regular_player_cannot_manage_store(): void
|
||||||
|
{
|
||||||
|
$this->actingAs($this->admin(0))->get(route('admin.shop.index'))->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function admin(int $gmLevel = 3): GameAccountUser
|
||||||
|
{
|
||||||
|
return new GameAccountUser(1, 'ADMIN', 'ADMIN@EXAMPLE.COM', 'ADMIN@EXAMPLE.COM', $gmLevel, false, null, null, str_repeat('AA', 32), str_repeat('BB', 32));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Services\BalanceService;
|
||||||
|
use App\Services\RobokassaService;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class BalancePaymentsFeatureTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
config([
|
||||||
|
'database.default' => 'azerothcore_auth',
|
||||||
|
'database.connections.azerothcore_auth' => [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'database' => ':memory:',
|
||||||
|
'prefix' => '',
|
||||||
|
'foreign_key_constraints' => true,
|
||||||
|
],
|
||||||
|
'services.robokassa.mode' => 'test',
|
||||||
|
'services.robokassa.environments.test' => [
|
||||||
|
'login' => 'moonwell-test',
|
||||||
|
'password1' => 'password-one',
|
||||||
|
'password2' => 'password-two',
|
||||||
|
'hash_algorithm' => 'md5',
|
||||||
|
],
|
||||||
|
'services.robokassa.environments.production' => [
|
||||||
|
'login' => 'moonwell-production',
|
||||||
|
'password1' => 'production-one',
|
||||||
|
'password2' => 'production-two',
|
||||||
|
'hash_algorithm' => 'sha256',
|
||||||
|
],
|
||||||
|
'services.robokassa.tears_per_ruble' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::purge('azerothcore_auth');
|
||||||
|
$migration = require database_path('migrations/2026_07_19_000001_create_moonwell_balance_tables.php');
|
||||||
|
$migration->up();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_valid_callback_credits_balance_only_once(): void
|
||||||
|
{
|
||||||
|
$robokassa = app(RobokassaService::class);
|
||||||
|
$invoice = $robokassa->createInvoice(7, '100.00');
|
||||||
|
$parameters = $robokassa->paymentParameters($invoice, 'player@example.com');
|
||||||
|
$callback = [
|
||||||
|
'OutSum' => '100.00',
|
||||||
|
'InvId' => (string) $invoice->id,
|
||||||
|
'Shp_account' => '7',
|
||||||
|
'SignatureValue' => md5('100.00:'.$invoice->id.':password-two:Shp_account=7'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$robokassa->processResult($callback);
|
||||||
|
$robokassa->processResult($callback);
|
||||||
|
|
||||||
|
$this->assertSame('1', $parameters['IsTest']);
|
||||||
|
$this->assertSame('1000.00', app(BalanceService::class)->balance(7));
|
||||||
|
$this->assertDatabaseCount('balance_transactions', 1, 'azerothcore_auth');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_game_spending_is_atomic_and_idempotent(): void
|
||||||
|
{
|
||||||
|
DB::connection('azerothcore_auth')->table('account_balances')->insert([
|
||||||
|
'account_id' => 7,
|
||||||
|
'balance' => '100.00',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$balances = app(BalanceService::class);
|
||||||
|
$first = $balances->spend(7, '25.50', 'game:purchase-42', 'Игровой предмет');
|
||||||
|
$second = $balances->spend(7, '25.50', 'game:purchase-42', 'Игровой предмет');
|
||||||
|
|
||||||
|
$this->assertSame($first->id, $second->id);
|
||||||
|
$this->assertSame('74.50', $balances->balance(7));
|
||||||
|
$this->assertDatabaseCount('balance_transactions', 1, 'azerothcore_auth');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_production_mode_uses_production_credentials_and_disables_test_flag(): void
|
||||||
|
{
|
||||||
|
config(['services.robokassa.mode' => 'production']);
|
||||||
|
$robokassa = app(RobokassaService::class);
|
||||||
|
$invoice = $robokassa->createInvoice(7, '50.00');
|
||||||
|
$parameters = $robokassa->paymentParameters($invoice);
|
||||||
|
|
||||||
|
$this->assertSame('production', $invoice->mode);
|
||||||
|
$this->assertSame('moonwell-production', $parameters['MerchantLogin']);
|
||||||
|
$this->assertSame('0', $parameters['IsTest']);
|
||||||
|
$this->assertSame(
|
||||||
|
hash('sha256', 'moonwell-production:50.00:'.$invoice->id.':production-one:Shp_account=7'),
|
||||||
|
$parameters['SignatureValue'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_credit_and_debit_with_audit_metadata(): void
|
||||||
|
{
|
||||||
|
$balances = app(BalanceService::class);
|
||||||
|
|
||||||
|
$credit = $balances->adjustByAdmin(7, '50.00', 'credit', 1, 'ADMIN', 'Компенсация');
|
||||||
|
$debit = $balances->adjustByAdmin(7, '12.50', 'debit', 1, 'ADMIN', 'Исправление');
|
||||||
|
|
||||||
|
$this->assertSame('37.50', $balances->balance(7));
|
||||||
|
$this->assertSame('admin_credit', $credit->type);
|
||||||
|
$this->assertSame('admin_debit', $debit->type);
|
||||||
|
$this->assertSame('ADMIN', $debit->metadata['admin_username']);
|
||||||
|
$this->assertSame('Исправление', $debit->metadata['reason']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_cannot_debit_more_than_available_balance(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Недостаточно средств');
|
||||||
|
|
||||||
|
app(BalanceService::class)->adjustByAdmin(7, '1.00', 'debit', 1, 'ADMIN', 'Проверка');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace Tests\Feature;
|
|||||||
|
|
||||||
use App\Models\GameAccountUser;
|
use App\Models\GameAccountUser;
|
||||||
use App\Services\AzerothCoreAccountService;
|
use App\Services\AzerothCoreAccountService;
|
||||||
|
use App\Services\BalanceService;
|
||||||
use App\Services\GameClientDownloadService;
|
use App\Services\GameClientDownloadService;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Mockery\MockInterface;
|
use Mockery\MockInterface;
|
||||||
@@ -39,12 +40,17 @@ class CabinetFeatureTest extends TestCase
|
|||||||
],
|
],
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
$this->mock(BalanceService::class, function (MockInterface $mock) use ($user): void {
|
||||||
|
$mock->shouldReceive('balance')->once()->with($user->id)->andReturn('1250.00');
|
||||||
|
$mock->shouldReceive('recentTransactions')->once()->with($user->id)->andReturn(new Collection);
|
||||||
|
});
|
||||||
|
|
||||||
$response = $this->actingAs($user)->get(route('cabinet.index'));
|
$response = $this->actingAs($user)->get(route('cabinet.index'));
|
||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
$response->assertSee('Arthion');
|
$response->assertSee('Arthion');
|
||||||
$response->assertSee('Получить ссылку на клиент');
|
$response->assertSee('1 250,00');
|
||||||
|
$response->assertSee('Скачать лаунчер');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_authenticated_user_can_change_password(): void
|
public function test_authenticated_user_can_change_password(): void
|
||||||
|
|||||||
@@ -56,6 +56,29 @@ class GameAccountRegistrationTest extends TestCase
|
|||||||
$response->assertSessionHasErrors(['username', 'email', 'invite_code', 'password', 'terms']);
|
$response->assertSessionHasErrors(['username', 'email', 'invite_code', 'password', 'terms']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_registration_without_invite_when_requirement_is_disabled(): void
|
||||||
|
{
|
||||||
|
config(['moonwell.registration.require_invite_code' => false]);
|
||||||
|
|
||||||
|
$this->mock(InviteCodeService::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldNotReceive('redeemForRegistration');
|
||||||
|
});
|
||||||
|
$this->mock(AzerothCoreAccountRegistrar::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('register')
|
||||||
|
->once()
|
||||||
|
->with('playerone', 'player@example.com', 'Pass1234')
|
||||||
|
->andReturn(['id' => 77, 'username' => 'PLAYERONE']);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->post(route('game-account.store'), [
|
||||||
|
'username' => 'playerone',
|
||||||
|
'email' => 'player@example.com',
|
||||||
|
'password' => 'Pass1234',
|
||||||
|
'password_confirmation' => 'Pass1234',
|
||||||
|
'terms' => '1',
|
||||||
|
])->assertSessionHas('registration_success');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_duplicate_username_is_reported_back_to_the_form(): void
|
public function test_duplicate_username_is_reported_back_to_the_form(): void
|
||||||
{
|
{
|
||||||
$this->mock(InviteCodeService::class, function (MockInterface $mock): void {
|
$this->mock(InviteCodeService::class, function (MockInterface $mock): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user