донат шоп, управление магазином, яндекс метрика

This commit is contained in:
2026-07-19 19:17:29 +04:00
parent 8bf1b852f0
commit df7f98fe8b
48 changed files with 1859 additions and 18 deletions
+22
View File
@@ -89,11 +89,18 @@ AZEROTHCORE_CHARACTERS_DB_PASSWORD=password
AZEROTHCORE_CHARACTERS_DB_SOCKET=
AZEROTHCORE_CHARACTERS_DB_CHARSET=utf8mb4
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_REALM_ID=-1
AZEROTHCORE_ACCOUNT_EXPANSION=2
AZEROTHCORE_ACCOUNT_ACCESS_COMMENT="registered via moonwell-web"
AZEROTHCORE_ENFORCE_UNIQUE_EMAIL=false
MOONWELL_REGISTRATION_REQUIRE_INVITE=true
MOONWELL_ADMIN_MIN_GMLEVEL=3
MOONWELL_ADMIN_ACCESS_COMMENT="updated via moonwell-web admin"
MOONWELL_BOT_ACCOUNT_PREFIX=RNDBOT
@@ -112,3 +119,18 @@ MOONWELL_CLIENT_OBJECT_KEY="World of Warcraft.zip"
MOONWELL_CLIENT_URL_TTL=30
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');
}
}
+7 -1
View File
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Http\Requests\ChangePasswordRequest;
use App\Services\AzerothCoreAccountService;
use App\Services\BalanceService;
use App\Services\GameClientDownloadService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -12,7 +13,7 @@ use Throwable;
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();
@@ -20,6 +21,11 @@ class CabinetController extends Controller
'user' => $user,
'characters' => $accounts->charactersForAccount($user->id),
'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'),
]);
}
+10 -5
View File
@@ -78,6 +78,7 @@ class LandingController extends Controller
'moon' => true,
],
'registerEndpoint' => route('game-account.store'),
'inviteRequired' => (bool) config('moonwell.registration.require_invite_code'),
'csrfToken' => csrf_token(),
'auth' => [
'authenticated' => $user !== null,
@@ -110,15 +111,19 @@ class LandingController extends Controller
): RedirectResponse
{
try {
$result = $inviteCodes->redeemForRegistration(
$request->string('invite_code')->toString(),
$request->string('username')->toString(),
fn (): array => $registrar->register(
$register = fn (): array => $registrar->register(
$request->string('username')->toString(),
$request->string('email')->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) {
return back()
->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', 'Оплата отменена или не была завершена.');
}
}
+22
View File
@@ -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 [
'username' => ['required', 'string', 'min:3', 'max:32', 'regex:/^[A-Za-z0-9]+$/'],
'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).+$/'],
'terms' => ['accepted'],
];
+29
View File
@@ -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'];
}
}
+27
View File
@@ -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'];
}
}
+33
View File
@@ -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');
}
}
+33
View File
@@ -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');
}
}
+164
View File
@@ -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, '.', '');
}
}
+218
View File
@@ -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}.");
}
}
}
}
+106
View File
@@ -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),
];
}
}
+6
View File
@@ -20,6 +20,11 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: env('TRUSTED_PROXIES', '*'));
$middleware->validateCsrfTokens(except: [
'payments/robokassa/success',
'payments/robokassa/fail',
]);
$middleware->api(prepend: [
\Illuminate\Http\Middleware\SetCacheHeaders::class . ':no_store',
\App\Http\Middleware\ForceJsonResponse::class,
@@ -28,6 +33,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->alias([
'gamemaster' => \App\Http\Middleware\EnsureGameMaster::class,
'launcher' => \App\Http\Middleware\EnsureLauncherRequest::class,
'game-server' => \App\Http\Middleware\EnsureGameServer::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
+16
View File
@@ -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' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
+1
View File
@@ -5,6 +5,7 @@ return [
'characters_connection' => env('AZEROTHCORE_CHARACTERS_CONNECTION', 'azerothcore_characters'),
'registration' => [
'require_invite_code' => (bool) env('MOONWELL_REGISTRATION_REQUIRE_INVITE', true),
'gmlevel' => (int) env('AZEROTHCORE_ACCOUNT_GMLEVEL', 0),
'realm_id' => (int) env('AZEROTHCORE_ACCOUNT_REALM_ID', -1),
'expansion' => (int) env('AZEROTHCORE_ACCOUNT_EXPANSION', 2),
+28
View File
@@ -1,5 +1,7 @@
<?php
$robokassaMode = env('ROBOKASSA_MODE', 'test');
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');
}
};
+48
View File
@@ -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`.
+66
View File
@@ -570,6 +570,60 @@ pre {
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,
.feed-preview {
margin: 0;
@@ -758,6 +812,18 @@ pre {
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 {
padding-top: 18px;
}
@@ -5,6 +5,7 @@ import { arrowSvg, iconPaths, moonMark } from './symbols';
const props = defineProps({
registerEndpoint: { type: String, required: true },
csrfToken: { type: String, required: true },
inviteRequired: { type: Boolean, default: true },
auth: { type: Object, required: true },
flash: { type: Object, required: true },
});
@@ -32,7 +33,7 @@ const clientErrors = computed(() => {
const errors = {};
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.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 символов.';
else if (form.password && !/[A-Za-z]/.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(
form.username &&
form.email &&
form.invite_code &&
(!props.inviteRequired || form.invite_code) &&
form.password &&
form.password_confirmation &&
form.terms &&
@@ -77,7 +78,7 @@ function submit(event) {
}
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;
});
}
@@ -104,8 +105,8 @@ function submit(event) {
<div style="height: 20px"></div>
<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>
<p class="lead" style="font-style: italic">Укажи инвайт-код, логин, почту и пароль - аккаунт будет готов к входу в игру сразу после регистрации.</p>
<div class="join-note">
<p class="lead" style="font-style: italic">{{ inviteRequired ? 'Укажи инвайт-код, логин, почту и пароль' : 'Укажи логин, почту и пароль' }} — аккаунт будет готов к входу в игру сразу после регистрации.</p>
<div v-if="inviteRequired" class="join-note">
<span class="join-note-k">Закрытый бета-доступ</span>
<span class="join-note-v">Пока идет закрытый этап: регистрация только по инвайт-кодам. Код можно получить у друзей-игроков или в нашем Discord.</span>
</div>
@@ -132,7 +133,7 @@ function submit(event) {
<div v-if="errorFor('email')" class="join-error">{{ errorFor('email') }}</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>
<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>
+2 -1
View File
@@ -19,6 +19,7 @@ const props = defineProps({
tweaks: { type: Object, default: () => ({}) },
registerEndpoint: { type: String, required: true },
csrfToken: { type: String, required: true },
inviteRequired: { type: Boolean, default: true },
auth: { type: Object, default: () => ({}) },
flash: { type: Object, default: () => ({}) },
});
@@ -58,7 +59,7 @@ onBeforeUnmount(() => revealObserver?.disconnect());
<Realm :realm="realm" />
<About />
<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" />
<Footer :play-url="playUrl" />
<Tweaks :tweaks="tweaks" />
@@ -5,7 +5,7 @@
<div class="section-heading">
<span class="eyebrow">Инвайты</span>
<h2>Управление инвайт-кодами</h2>
<p>Без действующего инвайт-кода регистрация нового аккаунта недоступна.</p>
<p>{{ config('moonwell.registration.require_invite_code') ? 'Регистрация требует действующий инвайт-код.' : 'Регистрация сейчас открыта без инвайт-кодов.' }}</p>
</div>
<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>
+1
View File
@@ -7,5 +7,6 @@
</head>
<body>
<x-inertia::app />
@include('partials.yandex-metrika')
</body>
</html>
+38
View File
@@ -56,6 +56,44 @@
</div>
</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">
<article class="content-card">
<span class="eyebrow">Безопасность</span>
+2
View File
@@ -25,5 +25,7 @@
@csrf
</form>
@endauth
@include('partials.yandex-metrika')
</body>
</html>
+1
View File
@@ -12,5 +12,6 @@
</head>
<body>
@yield('content')
@include('partials.yandex-metrika')
</body>
</html>
+2
View File
@@ -19,6 +19,8 @@
<a href="{{ route('cabinet.index') }}">Личный кабинет</a>
@if ($portalUser->canAccessAdminPanel())
<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>
@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>
+11
View File
@@ -1,8 +1,10 @@
<?php
use App\Http\Controllers\Api\GameBalanceController;
use App\Http\Controllers\Api\LauncherAuthController;
use App\Http\Controllers\Api\LauncherController;
use App\Http\Controllers\Api\LauncherNewsController;
use App\Http\Controllers\Api\RobokassaController;
use Illuminate\Support\Facades\Route;
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::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');
});
+21
View File
@@ -1,12 +1,15 @@
<?php
use App\Http\Controllers\Admin\AccountController;
use App\Http\Controllers\Admin\BalanceController;
use App\Http\Controllers\Admin\InviteCodeController;
use App\Http\Controllers\Admin\LoginScreenNewsController;
use App\Http\Controllers\Admin\NewsController;
use App\Http\Controllers\Admin\ShopController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\CabinetController;
use App\Http\Controllers\LandingController;
use App\Http\Controllers\PaymentController;
use App\Http\Controllers\WowLoginNewsFeedController;
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::post('/cabinet/password', [CabinetController::class, 'updatePassword'])->name('cabinet.password.update');
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'])
->prefix('admin')
->name('admin.')
@@ -38,6 +47,10 @@ Route::middleware(['auth', 'gamemaster'])
Route::get('/accounts', [AccountController::class, 'index'])->name('accounts.index');
Route::patch('/accounts/{accountId}/access', [AccountController::class, 'updateAccess'])->name('accounts.access.update');
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::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');
@@ -47,4 +60,12 @@ Route::middleware(['auth', 'gamemaster'])
Route::post('/news', [NewsController::class, 'store'])->name('news.store');
Route::patch('/news/{newsItem}', [NewsController::class, 'update'])->name('news.update');
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),
);
}
}
+81
View File
@@ -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', 'Проверка');
}
}
+7 -1
View File
@@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\GameAccountUser;
use App\Services\AzerothCoreAccountService;
use App\Services\BalanceService;
use App\Services\GameClientDownloadService;
use Illuminate\Support\Collection;
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->assertOk();
$response->assertSee('Arthion');
$response->assertSee('Получить ссылку на клиент');
$response->assertSee('1 250,00');
$response->assertSee('Скачать лаунчер');
}
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']);
}
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
{
$this->mock(InviteCodeService::class, function (MockInterface $mock): void {