feat: 增强玩家 API,新增 locale 和时间字段,更新钱包 API 以支持可用余额计算,添加错误码与多语言支持
This commit is contained in:
23
app/Exceptions/WalletOperationException.php
Normal file
23
app/Exceptions/WalletOperationException.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\ApiResponse;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 玩家钱包划转等业务失败时抛出,由控制器捕获并转为 {@see ApiResponse} JSON。
|
||||
*
|
||||
* @property-read int $lotteryCode {@see ErrorCode}
|
||||
*/
|
||||
final class WalletOperationException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly int $lotteryCode,
|
||||
public readonly int $httpStatus = 400,
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Player;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Player;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* 后台:按玩家查询钱包余额(`player_wallets` 全币种)。
|
||||
*
|
||||
* 路由:`GET /api/v1/admin/players/{player}/wallets`
|
||||
*/
|
||||
final class PlayerWalletShowController extends Controller
|
||||
{
|
||||
public function __invoke(Player $player): JsonResponse
|
||||
{
|
||||
$wallets = PlayerWallet::query()
|
||||
->where('player_id', $player->id)
|
||||
->orderBy('wallet_type')
|
||||
->orderBy('currency_code')
|
||||
->get();
|
||||
|
||||
$rows = $wallets->map(static function (PlayerWallet $w): array {
|
||||
$bal = (int) $w->balance;
|
||||
$frozen = (int) $w->frozen_balance;
|
||||
|
||||
return [
|
||||
'id' => $w->id,
|
||||
'wallet_type' => $w->wallet_type,
|
||||
'currency_code' => $w->currency_code,
|
||||
'balance' => $bal,
|
||||
'frozen_balance' => $frozen,
|
||||
'available_balance' => max(0, $bal - $frozen),
|
||||
'status' => (int) $w->status,
|
||||
'version' => (int) $w->version,
|
||||
];
|
||||
})->values()->all();
|
||||
|
||||
return ApiResponse::success([
|
||||
'player' => [
|
||||
'id' => $player->id,
|
||||
'site_code' => $player->site_code,
|
||||
'site_player_id' => $player->site_player_id,
|
||||
'username' => $player->username,
|
||||
'nickname' => $player->nickname,
|
||||
'default_currency' => $player->default_currency,
|
||||
'status' => (int) $player->status,
|
||||
],
|
||||
'wallets' => $rows,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Wallet;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TransferOrder;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 后台:转账单列表(主站 ↔ 彩票 {@see transfer_orders})。
|
||||
* 检索口径对齐《01-界面文档》§5.11「钱包流水与对账」转账侧扩展。
|
||||
*
|
||||
* Query:
|
||||
* - `page`(默认 1)
|
||||
* - `per_page` 或 `size`(每页条数,默认 20,最大 100)
|
||||
* - `player_id`(可选,按玩家主键)
|
||||
* - `player_account`(可选,模糊匹配 `players.site_player_id` / `username`;与 `player_id` 同时传时以 `player_id` 为准)
|
||||
* - `transfer_no`(可选,模糊匹配本地单号)
|
||||
* - `external_ref_no`(可选,模糊匹配主站流水号)
|
||||
* - `created_from` / `created_to`(可选,`Y-m-d`,筛选创建时间)
|
||||
* - `status`(可选,逗号分隔:`processing`,`success`,`failed`,`pending_reconcile`)
|
||||
* - `abnormal=1`(仅看异常:processing / failed / pending_reconcile,与 `status` 同时出现时优先)
|
||||
*/
|
||||
final class TransferOrderListController extends Controller
|
||||
{
|
||||
private const ALLOWED_STATUS = ['processing', 'success', 'failed', 'pending_reconcile'];
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$validated = validator($request->query(), [
|
||||
'page' => ['sometimes', 'integer', 'min:1'],
|
||||
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||
'size' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||
'player_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||
'player_account' => ['sometimes', 'nullable', 'string', 'max:128'],
|
||||
'transfer_no' => ['sometimes', 'nullable', 'string', 'max:96'],
|
||||
'external_ref_no' => ['sometimes', 'nullable', 'string', 'max:96'],
|
||||
'created_from' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
|
||||
'created_to' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
|
||||
'status' => ['sometimes', 'nullable', 'string', 'max:256'],
|
||||
])->validate();
|
||||
|
||||
$perPage = (int) ($validated['per_page'] ?? $validated['size'] ?? 20);
|
||||
$perPage = min(100, max(1, $perPage));
|
||||
$page = max(1, (int) ($validated['page'] ?? 1));
|
||||
|
||||
$query = TransferOrder::query()
|
||||
->with(['player:id,site_code,site_player_id,username,nickname'])
|
||||
->orderByDesc('id');
|
||||
|
||||
if (! empty($validated['player_id'])) {
|
||||
$query->where('player_id', (int) $validated['player_id']);
|
||||
} elseif (! empty($validated['player_account'])) {
|
||||
$term = '%'.addcslashes(trim($validated['player_account']), '%_\\').'%';
|
||||
$query->whereHas('player', function ($q) use ($term): void {
|
||||
$q->where('site_player_id', 'like', $term)
|
||||
->orWhere('username', 'like', $term);
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($validated['transfer_no'])) {
|
||||
$q = '%'.addcslashes(trim($validated['transfer_no']), '%_\\').'%';
|
||||
$query->where('transfer_no', 'like', $q);
|
||||
}
|
||||
|
||||
if (! empty($validated['external_ref_no'])) {
|
||||
$q = '%'.addcslashes(trim($validated['external_ref_no']), '%_\\').'%';
|
||||
$query->where('external_ref_no', 'like', $q);
|
||||
}
|
||||
|
||||
if (! empty($validated['created_from'])) {
|
||||
$query->where('created_at', '>=', $validated['created_from'].' 00:00:00');
|
||||
}
|
||||
|
||||
if (! empty($validated['created_to'])) {
|
||||
$query->where('created_at', '<=', $validated['created_to'].' 23:59:59');
|
||||
}
|
||||
|
||||
if ($request->boolean('abnormal')) {
|
||||
$query->whereIn('status', ['processing', 'failed', 'pending_reconcile']);
|
||||
} elseif (! empty($validated['status'])) {
|
||||
$parts = array_filter(array_map('trim', explode(',', (string) $validated['status'])));
|
||||
$statuses = array_values(array_intersect($parts, self::ALLOWED_STATUS));
|
||||
if ($statuses !== []) {
|
||||
$query->whereIn('status', $statuses);
|
||||
}
|
||||
}
|
||||
|
||||
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
|
||||
$items = $paginator->getCollection()->map(fn (TransferOrder $o) => $this->formatRow($o));
|
||||
|
||||
return ApiResponse::success([
|
||||
'items' => $items,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatRow(TransferOrder $o): array
|
||||
{
|
||||
$p = $o->player;
|
||||
|
||||
return [
|
||||
'id' => $o->id,
|
||||
'transfer_no' => $o->transfer_no,
|
||||
'player_id' => $o->player_id,
|
||||
'site_code' => $p?->site_code,
|
||||
'site_player_id' => $p?->site_player_id,
|
||||
'username' => $p?->username,
|
||||
'nickname' => $p?->nickname,
|
||||
'direction' => $o->direction,
|
||||
'currency_code' => $o->currency_code,
|
||||
'amount' => (int) $o->amount,
|
||||
'idempotent_key' => $o->idempotent_key,
|
||||
'status' => $o->status,
|
||||
'external_ref_no' => $o->external_ref_no,
|
||||
'external_request_payload' => $o->external_request_payload,
|
||||
'external_response_payload' => $o->external_response_payload,
|
||||
'fail_reason' => $o->fail_reason,
|
||||
'created_at' => $o->created_at?->toIso8601String(),
|
||||
'finished_at' => $o->finished_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Wallet;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\WalletTxn;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 后台:彩票钱包流水列表 {@see wallet_txns}。
|
||||
* 列表字段对齐《01-界面文档》§5.11;`updated_at` 作「完成时间」展示(`created_at` 为请求时间)。
|
||||
*
|
||||
* Query:
|
||||
* - `page`、`per_page` / `size`
|
||||
* - `player_id`(可选)
|
||||
* - `player_account`(可选,模糊匹配 `site_player_id` / `username`;与 `player_id` 同时传时以 `player_id` 为准)
|
||||
* - `txn_no`(可选,模糊匹配流水号)
|
||||
* - `external_ref_no`(可选,模糊匹配主站流水号)
|
||||
* - `created_from` / `created_to`(可选,`Y-m-d`,按 `created_at`)
|
||||
* - `biz_type`(可选)
|
||||
* - `status`(可选,逗号:`posted`,`pending_reconcile`)
|
||||
* - `abnormal=1`(仅 `pending_reconcile`,优先于 `status`)
|
||||
*/
|
||||
final class WalletTransactionListController extends Controller
|
||||
{
|
||||
private const ALLOWED_STATUS = ['posted', 'pending_reconcile'];
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$validated = validator($request->query(), [
|
||||
'page' => ['sometimes', 'integer', 'min:1'],
|
||||
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||
'size' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||
'player_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||
'player_account' => ['sometimes', 'nullable', 'string', 'max:128'],
|
||||
'txn_no' => ['sometimes', 'nullable', 'string', 'max:96'],
|
||||
'external_ref_no' => ['sometimes', 'nullable', 'string', 'max:96'],
|
||||
'created_from' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
|
||||
'created_to' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
|
||||
'biz_type' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||
'status' => ['sometimes', 'nullable', 'string', 'max:128'],
|
||||
])->validate();
|
||||
|
||||
$perPage = (int) ($validated['per_page'] ?? $validated['size'] ?? 20);
|
||||
$perPage = min(100, max(1, $perPage));
|
||||
$page = max(1, (int) ($validated['page'] ?? 1));
|
||||
|
||||
$query = WalletTxn::query()
|
||||
->with(['player:id,site_code,site_player_id,username,nickname'])
|
||||
->orderByDesc('id');
|
||||
|
||||
if (! empty($validated['player_id'])) {
|
||||
$query->where('player_id', (int) $validated['player_id']);
|
||||
} elseif (! empty($validated['player_account'])) {
|
||||
$term = '%'.addcslashes(trim($validated['player_account']), '%_\\').'%';
|
||||
$query->whereHas('player', function ($q) use ($term): void {
|
||||
$q->where('site_player_id', 'like', $term)
|
||||
->orWhere('username', 'like', $term);
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($validated['txn_no'])) {
|
||||
$q = '%'.addcslashes(trim($validated['txn_no']), '%_\\').'%';
|
||||
$query->where('txn_no', 'like', $q);
|
||||
}
|
||||
|
||||
if (! empty($validated['external_ref_no'])) {
|
||||
$q = '%'.addcslashes(trim($validated['external_ref_no']), '%_\\').'%';
|
||||
$query->where('external_ref_no', 'like', $q);
|
||||
}
|
||||
|
||||
if (! empty($validated['created_from'])) {
|
||||
$query->where('created_at', '>=', $validated['created_from'].' 00:00:00');
|
||||
}
|
||||
|
||||
if (! empty($validated['created_to'])) {
|
||||
$query->where('created_at', '<=', $validated['created_to'].' 23:59:59');
|
||||
}
|
||||
|
||||
if (! empty($validated['biz_type'])) {
|
||||
$query->where('biz_type', trim((string) $validated['biz_type']));
|
||||
}
|
||||
|
||||
if ($request->boolean('abnormal')) {
|
||||
$query->where('status', 'pending_reconcile');
|
||||
} elseif (! empty($validated['status'])) {
|
||||
$parts = array_filter(array_map('trim', explode(',', (string) $validated['status'])));
|
||||
$statuses = array_values(array_intersect($parts, self::ALLOWED_STATUS));
|
||||
if ($statuses !== []) {
|
||||
$query->whereIn('status', $statuses);
|
||||
}
|
||||
}
|
||||
|
||||
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
|
||||
$items = $paginator->getCollection()->map(fn (WalletTxn $t) => $this->formatRow($t));
|
||||
|
||||
return ApiResponse::success([
|
||||
'items' => $items,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatRow(WalletTxn $t): array
|
||||
{
|
||||
$p = $t->player;
|
||||
|
||||
return [
|
||||
'id' => $t->id,
|
||||
'txn_no' => $t->txn_no,
|
||||
'player_id' => $t->player_id,
|
||||
'site_code' => $p?->site_code,
|
||||
'site_player_id' => $p?->site_player_id,
|
||||
'username' => $p?->username,
|
||||
'wallet_id' => $t->wallet_id,
|
||||
'biz_type' => $t->biz_type,
|
||||
'biz_no' => $t->biz_no,
|
||||
'direction' => (int) $t->direction,
|
||||
'amount' => (int) $t->amount,
|
||||
'balance_before' => (int) $t->balance_before,
|
||||
'balance_after' => (int) $t->balance_after,
|
||||
'status' => $t->status,
|
||||
'external_ref_no' => $t->external_ref_no,
|
||||
'idempotent_key' => $t->idempotent_key,
|
||||
'remark' => $t->remark,
|
||||
'created_at' => $t->created_at?->toIso8601String(),
|
||||
/** 展示用「完成时间」;无业务终态时可与 created_at 相同 */
|
||||
'updated_at' => $t->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ use Illuminate\Http\Request;
|
||||
* 鉴权自检:返回当前 Token 对应的玩家公开字段(不含密码)。
|
||||
*
|
||||
* 路由:GET /api/v1/player/me ,需 middleware lottery.player。
|
||||
*
|
||||
* 补充字段与 PRD「识别玩家」及前端引导一致:`locale` 为当次 API 实际使用的语言(与 `NegotiateLotteryLocale` 一致);
|
||||
* 时间类为 ISO 8601 字符串,便于 H5 展示与排错。
|
||||
*/
|
||||
class MeController extends Controller
|
||||
{
|
||||
@@ -28,6 +31,9 @@ class MeController extends Controller
|
||||
'nickname' => $player->nickname,
|
||||
'default_currency' => $player->default_currency,
|
||||
'status' => $player->status,
|
||||
'locale' => $request->lotteryLocale(),
|
||||
'last_login_at' => $player->last_login_at?->toIso8601String(),
|
||||
'created_at' => $player->created_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use Illuminate\Http\Request;
|
||||
* 【行为要点】
|
||||
* - 币种:优先 Query `currency`,否则使用玩家 `default_currency`,再回退 `config('lottery.default_currency')`
|
||||
* - 若尚无 `player_wallets` 记录:按 `wallet_type=lottery` + 币种 **首次开立**一行(余额 0),便于新玩家直接进入查询
|
||||
* - `available_balance`:`balance - frozen_balance`,表示当前可用于下注的整数最小货币单位(不为负)
|
||||
* - `main_balance`:主站钱包余额占位,接入主站 API 后再返回实数;当前固定 `null`
|
||||
*/
|
||||
class WalletBalanceController extends Controller
|
||||
@@ -49,12 +50,16 @@ class WalletBalanceController extends Controller
|
||||
],
|
||||
);
|
||||
|
||||
$balance = (int) $wallet->balance;
|
||||
$frozen = (int) $wallet->frozen_balance;
|
||||
|
||||
return ApiResponse::success([
|
||||
'balance' => $wallet->balance,
|
||||
'balance' => $balance,
|
||||
'available_balance' => max(0, $balance - $frozen),
|
||||
'main_balance' => null,
|
||||
'currency_code' => $wallet->currency_code,
|
||||
'wallet_type' => $wallet->wallet_type,
|
||||
'frozen_balance' => $wallet->frozen_balance,
|
||||
'frozen_balance' => $frozen,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
174
app/Http/Controllers/Api/V1/Wallet/WalletLogsController.php
Normal file
174
app/Http/Controllers/Api/V1/Wallet/WalletLogsController.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Wallet;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TransferOrder;
|
||||
use App\Models\WalletTxn;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* PRD §10.1.1:`GET /api/v1/wallet/logs` — 钱包流水。
|
||||
*
|
||||
* Query:`page`、`size`(每页条数,默认 20)、`type`(逗号分隔:transfer_in,transfer_out,bet,prize,refund)
|
||||
*/
|
||||
class WalletLogsController extends Controller
|
||||
{
|
||||
/** PRD 对外类型 → 本地 biz_type */
|
||||
private const TYPE_TO_BIZ = [
|
||||
'transfer_in' => ['transfer_in'],
|
||||
'transfer_out' => ['transfer_out'],
|
||||
'refund' => ['transfer_out_refund'],
|
||||
'bet' => ['bet'],
|
||||
'prize' => ['prize'],
|
||||
];
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$player = $request->lotteryPlayer();
|
||||
abort_if($player === null, 500, 'lottery_player missing');
|
||||
|
||||
$perPage = min(100, max(1, (int) $request->query('size', $request->query('per_page', 20))));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$pendingPayload = $this->pendingReconcilePayload((int) $player->id);
|
||||
|
||||
$bizFilter = $this->resolveBizTypeFilter((string) $request->query('type', ''));
|
||||
|
||||
if (is_array($bizFilter) && $bizFilter === []) {
|
||||
return ApiResponse::success([
|
||||
'items' => [],
|
||||
'total' => 0,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'pending_reconcile' => $pendingPayload,
|
||||
]);
|
||||
}
|
||||
|
||||
$query = WalletTxn::query()
|
||||
->where('player_id', $player->id)
|
||||
->with('wallet')
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($bizFilter !== null) {
|
||||
$query->whereIn('biz_type', $bizFilter);
|
||||
}
|
||||
|
||||
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$items = $paginator->getCollection()->map(fn (WalletTxn $txn) => $this->formatTxn($txn));
|
||||
|
||||
return ApiResponse::success([
|
||||
'items' => $items,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'pending_reconcile' => $pendingPayload,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function pendingReconcilePayload(int $playerId): array
|
||||
{
|
||||
return TransferOrder::query()
|
||||
->where('player_id', $playerId)
|
||||
->where('status', 'pending_reconcile')
|
||||
->orderByDesc('id')
|
||||
->limit(50)
|
||||
->get()
|
||||
->map(fn (TransferOrder $o) => $this->formatPendingOrder($o))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>|null null 表示不过滤;空列表表示过滤后无合法 type(结果应为空)
|
||||
*/
|
||||
private function resolveBizTypeFilter(string $raw): ?array
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = array_filter(array_map('trim', explode(',', $raw)));
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$biz = [];
|
||||
foreach ($parts as $p) {
|
||||
$key = Str::lower($p);
|
||||
if (! isset(self::TYPE_TO_BIZ[$key])) {
|
||||
continue;
|
||||
}
|
||||
foreach (self::TYPE_TO_BIZ[$key] as $b) {
|
||||
$biz[] = $b;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($biz));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatTxn(WalletTxn $txn): array
|
||||
{
|
||||
$currency = $txn->wallet?->currency_code ?? '';
|
||||
|
||||
return [
|
||||
'log_id' => $txn->txn_no,
|
||||
'type' => $this->bizToPublicType((string) $txn->biz_type),
|
||||
'biz_type' => $txn->biz_type,
|
||||
'amount' => $this->signedAmount($txn),
|
||||
'amount_abs' => (int) $txn->amount,
|
||||
'direction' => (int) $txn->direction === 1 ? 'in' : 'out',
|
||||
'currency_code' => $currency,
|
||||
'balance_after' => (int) $txn->balance_after,
|
||||
'ref_id' => $txn->biz_no,
|
||||
'idempotent_key' => $txn->idempotent_key,
|
||||
'external_ref_no' => $txn->external_ref_no,
|
||||
'status' => $txn->status,
|
||||
'remark' => $txn->remark,
|
||||
'created_at' => $txn->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function bizToPublicType(string $biz): string
|
||||
{
|
||||
return match ($biz) {
|
||||
'transfer_out_refund' => 'refund',
|
||||
default => $biz,
|
||||
};
|
||||
}
|
||||
|
||||
private function signedAmount(WalletTxn $txn): int
|
||||
{
|
||||
$a = (int) $txn->amount;
|
||||
|
||||
return (int) $txn->direction === 1 ? $a : -$a;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatPendingOrder(TransferOrder $order): array
|
||||
{
|
||||
return [
|
||||
'transfer_no' => $order->transfer_no,
|
||||
'direction' => $order->direction,
|
||||
'type' => $order->direction === 'in' ? 'transfer_in' : 'transfer_out',
|
||||
'currency_code' => $order->currency_code,
|
||||
'amount' => (int) $order->amount,
|
||||
'status' => $order->status,
|
||||
'fail_reason' => $order->fail_reason,
|
||||
'idempotent_key' => $order->idempotent_key,
|
||||
'created_at' => $order->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Wallet;
|
||||
|
||||
use App\Exceptions\WalletOperationException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Wallet\WalletTransferRequest;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\Player;
|
||||
use App\Services\Wallet\LotteryTransferService;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Support\LotteryMessage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 转入:主站扣款 → 彩票钱包加款。
|
||||
*
|
||||
* `POST /api/v1/wallet/transfer-in` ,body JSON:`amount`(最小货币单位整数), `idempotent_key`, `currency`(可选)
|
||||
*/
|
||||
class WalletTransferInController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LotteryTransferService $transferService,
|
||||
) {}
|
||||
|
||||
public function __invoke(WalletTransferRequest $request): JsonResponse
|
||||
{
|
||||
$player = $request->lotteryPlayer();
|
||||
abort_if($player === null, 500, 'lottery_player missing');
|
||||
|
||||
$currency = $this->resolveCurrencyCode($request, $player);
|
||||
if ($currency instanceof JsonResponse) {
|
||||
return $currency;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = $this->transferService->transferIn(
|
||||
$player,
|
||||
$currency,
|
||||
(int) $request->validated('amount'),
|
||||
(string) $request->validated('idempotent_key'),
|
||||
);
|
||||
} catch (WalletOperationException $e) {
|
||||
return ApiResponse::error(
|
||||
LotteryMessage::wallet($request, $e->lotteryCode),
|
||||
$e->lotteryCode,
|
||||
null,
|
||||
$e->httpStatus,
|
||||
);
|
||||
}
|
||||
|
||||
return ApiResponse::success($data);
|
||||
}
|
||||
|
||||
private function resolveCurrencyCode(Request $request, Player $player): string|JsonResponse
|
||||
{
|
||||
$raw = $request->input('currency');
|
||||
if (is_string($raw) && $raw !== '') {
|
||||
$code = strtoupper(substr(trim($raw), 0, 16));
|
||||
} else {
|
||||
$fallback = $player->default_currency ?? config('lottery.default_currency', 'NPR');
|
||||
$code = strtoupper(substr(trim((string) $fallback), 0, 16));
|
||||
}
|
||||
|
||||
if (! preg_match('/^[A-Z0-9]{1,16}$/', $code)) {
|
||||
return ApiResponse::error(
|
||||
__('wallet.invalid_currency'),
|
||||
ErrorCode::WalletInvalidCurrency->value,
|
||||
null,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Wallet;
|
||||
|
||||
use App\Exceptions\WalletOperationException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Wallet\WalletTransferRequest;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\Player;
|
||||
use App\Services\Wallet\LotteryTransferService;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Support\LotteryMessage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 转出:彩票钱包扣款 → 主站加款;主站失败则冲正彩票余额。
|
||||
*
|
||||
* `POST /api/v1/wallet/transfer-out`
|
||||
*/
|
||||
class WalletTransferOutController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LotteryTransferService $transferService,
|
||||
) {}
|
||||
|
||||
public function __invoke(WalletTransferRequest $request): JsonResponse
|
||||
{
|
||||
$player = $request->lotteryPlayer();
|
||||
abort_if($player === null, 500, 'lottery_player missing');
|
||||
|
||||
$currency = $this->resolveCurrencyCode($request, $player);
|
||||
if ($currency instanceof JsonResponse) {
|
||||
return $currency;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = $this->transferService->transferOut(
|
||||
$player,
|
||||
$currency,
|
||||
(int) $request->validated('amount'),
|
||||
(string) $request->validated('idempotent_key'),
|
||||
);
|
||||
} catch (WalletOperationException $e) {
|
||||
return ApiResponse::error(
|
||||
LotteryMessage::wallet($request, $e->lotteryCode),
|
||||
$e->lotteryCode,
|
||||
null,
|
||||
$e->httpStatus,
|
||||
);
|
||||
}
|
||||
|
||||
return ApiResponse::success($data);
|
||||
}
|
||||
|
||||
private function resolveCurrencyCode(Request $request, Player $player): string|JsonResponse
|
||||
{
|
||||
$raw = $request->input('currency');
|
||||
if (is_string($raw) && $raw !== '') {
|
||||
$code = strtoupper(substr(trim($raw), 0, 16));
|
||||
} else {
|
||||
$fallback = $player->default_currency ?? config('lottery.default_currency', 'NPR');
|
||||
$code = strtoupper(substr(trim((string) $fallback), 0, 16));
|
||||
}
|
||||
|
||||
if (! preg_match('/^[A-Z0-9]{1,16}$/', $code)) {
|
||||
return ApiResponse::error(
|
||||
__('wallet.invalid_currency'),
|
||||
ErrorCode::WalletInvalidCurrency->value,
|
||||
null,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
28
app/Http/Requests/Wallet/WalletTransferRequest.php
Normal file
28
app/Http/Requests/Wallet/WalletTransferRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Wallet;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 转入 / 转出共用请求体:最小货币单位金额、幂等键、可选币种。
|
||||
*/
|
||||
class WalletTransferRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount' => ['required', 'integer', 'min:1'],
|
||||
'currency' => ['sometimes', 'nullable', 'string', 'max:16'],
|
||||
'idempotent_key' => ['required', 'string', 'max:64'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,29 @@ enum ErrorCode: int
|
||||
/** PRD:金额超出限制 */
|
||||
case WalletAmountExceedsLimit = 1003;
|
||||
|
||||
/** 后台关闭玩家转入(lottery_settings.wallet.transfer_in_enabled=false) */
|
||||
case WalletTransferInDisabled = 1004;
|
||||
|
||||
/**
|
||||
* PRD:钱包查询等场景下请求参数无效;当前用于 `currency` 非法(与 1003 语义区分)。
|
||||
*/
|
||||
case WalletInvalidCurrency = 1005;
|
||||
|
||||
/** 后台关闭玩家转出 */
|
||||
case WalletTransferOutDisabled = 1006;
|
||||
|
||||
/** 彩票钱包已冻结,不可划转 */
|
||||
case WalletLotteryFrozen = 1007;
|
||||
|
||||
/** 金额非法:须为正整数最小货币单位 */
|
||||
case WalletInvalidAmount = 1008;
|
||||
|
||||
/** 主站钱包接口拒绝或不可用(扣款/加款失败) */
|
||||
case WalletExternalRejected = 1009;
|
||||
|
||||
/** 幂等键与已有订单冲突(金额/币种/方向不一致) */
|
||||
case WalletIdempotentConflict = 1010;
|
||||
|
||||
/* ========== 2000–2999 下注 / 注单(PRD 保留,业务未实现时亦可提前登记) ========== */
|
||||
|
||||
/** PRD:当期已封盘 */
|
||||
@@ -56,6 +74,9 @@ enum ErrorCode: int
|
||||
/** 未配置 `MAIN_SITE_SSO_JWT_SECRET`(通常 HTTP 503) */
|
||||
case PlayerSsoSecretNotConfigured = 8004;
|
||||
|
||||
/** 账号已冻结或禁止登录(status ≠ active) */
|
||||
case PlayerAccountSuspended = 8005;
|
||||
|
||||
/* ========== 8100–8199 管理端 API ========== */
|
||||
|
||||
/** 未登录或 Token 无效 */
|
||||
|
||||
25
app/Models/Currency.php
Normal file
25
app/Models/Currency.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** 币种枚举表 {@see currencies} */
|
||||
class Currency extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
'decimal_places',
|
||||
'is_enabled',
|
||||
'is_bettable',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_enabled' => 'boolean',
|
||||
'is_bettable' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
40
app/Models/TransferOrder.php
Normal file
40
app/Models/TransferOrder.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/** 主站 ↔ 彩票划转订单 {@see transfer_orders} */
|
||||
class TransferOrder extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'transfer_no',
|
||||
'player_id',
|
||||
'direction',
|
||||
'currency_code',
|
||||
'amount',
|
||||
'idempotent_key',
|
||||
'status',
|
||||
'external_request_payload',
|
||||
'external_response_payload',
|
||||
'external_ref_no',
|
||||
'fail_reason',
|
||||
'finished_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount' => 'integer',
|
||||
'external_request_payload' => 'array',
|
||||
'external_response_payload' => 'array',
|
||||
'finished_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function player(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Player::class);
|
||||
}
|
||||
}
|
||||
46
app/Models/WalletTxn.php
Normal file
46
app/Models/WalletTxn.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/** 彩票钱包流水 {@see wallet_txns} */
|
||||
class WalletTxn extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'txn_no',
|
||||
'player_id',
|
||||
'wallet_id',
|
||||
'biz_type',
|
||||
'biz_no',
|
||||
'direction',
|
||||
'amount',
|
||||
'balance_before',
|
||||
'balance_after',
|
||||
'status',
|
||||
'external_ref_no',
|
||||
'idempotent_key',
|
||||
'remark',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'direction' => 'integer',
|
||||
'amount' => 'integer',
|
||||
'balance_before' => 'integer',
|
||||
'balance_after' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function player(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Player::class);
|
||||
}
|
||||
|
||||
public function wallet(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PlayerWallet::class, 'wallet_id');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ namespace App\Providers;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Player;
|
||||
use App\Services\Wallet\HttpMainSiteWalletGateway;
|
||||
use App\Services\Wallet\MainSiteWalletGateway;
|
||||
use App\Services\Wallet\StubMainSiteWalletGateway;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
@@ -16,7 +19,14 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
$this->app->singleton(MainSiteWalletGateway::class, function (): MainSiteWalletGateway {
|
||||
$url = config('lottery.main_site.wallet_api_url');
|
||||
if (! is_string($url) || trim($url) === '') {
|
||||
return new StubMainSiteWalletGateway;
|
||||
}
|
||||
|
||||
return new HttpMainSiteWalletGateway;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,21 +7,34 @@ use App\Lottery\ErrorCode;
|
||||
use App\Models\Player;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 从请求头解析玩家身份,返回已落库的 {@see Player}。
|
||||
*
|
||||
* 两种模式(互斥优先级:先判断 dev,再走 JWT):
|
||||
* 1) 开发绕过:当 `LOTTERY_PLAYER_AUTH_DEV_BYPASS=true` 且运行环境为 `local` 或 **`testing`**(PHPUnit)时,
|
||||
* 接受 `Authorization: Bearer dev:{players.id}`,直连主键查库(**禁止在生产开启**)。
|
||||
* 2) 生产:使用 `MAIN_SITE_SSO_JWT_SECRET` 验签 JWT(默认 HS256),
|
||||
* 从 payload 读取 `site_code`、`site_player_id`(字段名可 env 覆盖)再查 players 表。
|
||||
* ## Dev bypass(开发绕过)与正式 JWT 的互斥关系
|
||||
*
|
||||
* 错误码约定(见 {@see ErrorCode} 玩家 SSO 段):
|
||||
* | 条件 | Authorization 示例 | 行为 |
|
||||
* | :--- | :--- | :--- |
|
||||
* | `LOTTERY_PLAYER_AUTH_DEV_BYPASS=true` 且 `APP_ENV` 为 `local` 或 `testing`,且 token 以 `dev:` 开头 | `Bearer dev:12` | **仅走 dev**:按 `players.id` 查库,不验 JWT,不要求配置 `MAIN_SITE_SSO_JWT_SECRET`。 |
|
||||
* | 同上环境且开关为 true,但 token **不是** `dev:` | `Bearer eyJ...` | **走正式 JWT**:验签、建档逻辑见下;`dev:` 前缀不会当作 JWT 解析。 |
|
||||
* | `dev_bypass` 为 false 或非 local/testing | `Bearer dev:12` | **不走 dev**:整段当作 JWT 解码,必然失败(8002)。 |
|
||||
* | 任意环境 | `Bearer eyJ...` | **走正式 JWT**:必须配置 `MAIN_SITE_SSO_JWT_SECRET`;验签成功后按 `site_code` + `site_player_id` **首次自动建档**(PRD),并刷新 `last_login_at`。 |
|
||||
*
|
||||
* ## 正式 JWT(主站 SSO)
|
||||
*
|
||||
* - 成功验签后:若库中无映射行则 `firstOrCreate`(默认币种 `lottery.default_currency`、status=active)。
|
||||
* - 并发首登可能触发唯一键冲突,会捕获后重查。
|
||||
* - 解析成功后校验 {@see Player::status}:非 active 时抛 {@see ErrorCode::PlayerAccountSuspended}(HTTP 403)。
|
||||
*
|
||||
* 错误码见 {@see ErrorCode} 玩家 SSO 段(8001–8005)。
|
||||
*/
|
||||
final class PlayerTokenResolver
|
||||
{
|
||||
/** players.status:与迁移注释一致 */
|
||||
private const PLAYER_STATUS_ACTIVE = 0;
|
||||
|
||||
public function resolve(Request $request): Player
|
||||
{
|
||||
$header = $request->header('Authorization', '');
|
||||
@@ -38,22 +51,33 @@ final class PlayerTokenResolver
|
||||
throw new PlayerAuthenticationException('Token 为空', ErrorCode::PlayerAuthorizationInvalid->value);
|
||||
}
|
||||
|
||||
// 本地 dev: 优先于 JWT,避免未配密钥时仍能测需登录接口
|
||||
// 仅当 dev bypass 开启且 token 形如 dev:{id} 时走开发分支;否则一律按 JWT 处理
|
||||
if ($this->devBypassAllowed() && str_starts_with($token, 'dev:')) {
|
||||
return $this->resolveDevToken($token);
|
||||
$player = $this->resolveDevToken($token);
|
||||
} else {
|
||||
// 与 .env 中 MAIN_SITE_SSO_JWT_SECRET 一致,用于 firebase/php-jwt 验签
|
||||
$secret = config('lottery.main_site.sso_jwt_secret');
|
||||
if (! is_string($secret) || $secret === '') {
|
||||
throw new PlayerAuthenticationException(
|
||||
'SSO 未配置(MAIN_SITE_SSO_JWT_SECRET)',
|
||||
ErrorCode::PlayerSsoSecretNotConfigured->value,
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
$player = $this->resolveJwt($token, $secret);
|
||||
}
|
||||
|
||||
// 与 .env 中 MAIN_SITE_SSO_JWT_SECRET 一致,用于 firebase/php-jwt 验签
|
||||
$secret = config('lottery.main_site.sso_jwt_secret');
|
||||
if (! is_string($secret) || $secret === '') {
|
||||
throw new PlayerAuthenticationException(
|
||||
'SSO 未配置(MAIN_SITE_SSO_JWT_SECRET)',
|
||||
ErrorCode::PlayerSsoSecretNotConfigured->value,
|
||||
503,
|
||||
);
|
||||
$this->assertPlayerActive($player);
|
||||
|
||||
// 正式 JWT 已在 resolveJwt 内写入 last_login_at;dev 仅在此处写入
|
||||
if ($this->devBypassAllowed() && str_starts_with($token, 'dev:')) {
|
||||
$player->forceFill(['last_login_at' => now()])->save();
|
||||
|
||||
return $player->refresh();
|
||||
}
|
||||
|
||||
return $this->resolveJwt($token, $secret);
|
||||
return $player;
|
||||
}
|
||||
|
||||
private function devBypassAllowed(): bool
|
||||
@@ -102,16 +126,49 @@ final class PlayerTokenResolver
|
||||
throw new PlayerAuthenticationException('JWT 缺少站点或玩家标识', ErrorCode::PlayerTokenInvalid->value);
|
||||
}
|
||||
|
||||
// 首期:库中必须先有该行;若需「首次进入自动建档」可在此处 firstOrCreate
|
||||
$player = Player::query()
|
||||
->where('site_code', $siteCode)
|
||||
->where('site_player_id', $sitePlayerId)
|
||||
->first();
|
||||
$now = now();
|
||||
$defaults = [
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => (string) config('lottery.default_currency', 'NPR'),
|
||||
'status' => self::PLAYER_STATUS_ACTIVE,
|
||||
'last_login_at' => $now,
|
||||
];
|
||||
|
||||
if ($player === null) {
|
||||
throw new PlayerAuthenticationException('玩家未建档', ErrorCode::PlayerNotRegistered->value);
|
||||
try {
|
||||
$player = Player::query()->firstOrCreate(
|
||||
[
|
||||
'site_code' => $siteCode,
|
||||
'site_player_id' => $sitePlayerId,
|
||||
],
|
||||
$defaults,
|
||||
);
|
||||
} catch (QueryException $e) {
|
||||
// 并发首登时可能重复插入唯一键,改为加载已有行
|
||||
$player = Player::query()
|
||||
->where('site_code', $siteCode)
|
||||
->where('site_player_id', $sitePlayerId)
|
||||
->first();
|
||||
if ($player === null) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
return $player;
|
||||
if (! $player->wasRecentlyCreated) {
|
||||
$player->forceFill(['last_login_at' => $now])->save();
|
||||
}
|
||||
|
||||
return $player->refresh();
|
||||
}
|
||||
|
||||
private function assertPlayerActive(Player $player): void
|
||||
{
|
||||
if ((int) $player->status !== self::PLAYER_STATUS_ACTIVE) {
|
||||
throw new PlayerAuthenticationException(
|
||||
'账号已冻结或不可用',
|
||||
ErrorCode::PlayerAccountSuspended->value,
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
150
app/Services/Wallet/HttpMainSiteWalletGateway.php
Normal file
150
app/Services/Wallet/HttpMainSiteWalletGateway.php
Normal file
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Wallet;
|
||||
|
||||
use App\Models\Player;
|
||||
use GuzzleHttp\Exception\ConnectException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* 通过 HTTP 调用主站钱包 API(路径见 config lottery.main_site.wallet_*_path)。
|
||||
*/
|
||||
final class HttpMainSiteWalletGateway implements MainSiteWalletGateway
|
||||
{
|
||||
public function debitMainForLotteryDeposit(
|
||||
Player $player,
|
||||
string $currencyCode,
|
||||
int $amountMinor,
|
||||
string $idempotentKey,
|
||||
): MainSiteWalletResult {
|
||||
return $this->post(
|
||||
(string) config('lottery.main_site.wallet_debit_path'),
|
||||
$player,
|
||||
$currencyCode,
|
||||
$amountMinor,
|
||||
$idempotentKey,
|
||||
);
|
||||
}
|
||||
|
||||
public function creditMainForLotteryWithdraw(
|
||||
Player $player,
|
||||
string $currencyCode,
|
||||
int $amountMinor,
|
||||
string $idempotentKey,
|
||||
): MainSiteWalletResult {
|
||||
return $this->post(
|
||||
(string) config('lottery.main_site.wallet_credit_path'),
|
||||
$player,
|
||||
$currencyCode,
|
||||
$amountMinor,
|
||||
$idempotentKey,
|
||||
);
|
||||
}
|
||||
|
||||
private function post(
|
||||
string $path,
|
||||
Player $player,
|
||||
string $currencyCode,
|
||||
int $amountMinor,
|
||||
string $idempotentKey,
|
||||
): MainSiteWalletResult {
|
||||
$base = rtrim((string) config('lottery.main_site.wallet_api_url'), '/');
|
||||
$url = $base.'/'.ltrim($path, '/');
|
||||
$timeout = (int) config('lottery.main_site.wallet_timeout', 10);
|
||||
$apiKey = config('lottery.main_site.wallet_api_key');
|
||||
|
||||
$requestBody = [
|
||||
'site_code' => $player->site_code,
|
||||
'site_player_id' => $player->site_player_id,
|
||||
'player_id' => $player->id,
|
||||
'currency_code' => $currencyCode,
|
||||
'amount_minor' => $amountMinor,
|
||||
'idempotent_key' => $idempotentKey,
|
||||
];
|
||||
|
||||
$requestSnapshot = array_merge($requestBody, [
|
||||
'_meta' => [
|
||||
'method' => 'POST',
|
||||
'path' => '/'.ltrim($path, '/'),
|
||||
],
|
||||
]);
|
||||
|
||||
$headers = [];
|
||||
if (is_string($apiKey) && $apiKey !== '') {
|
||||
$headers['Authorization'] = 'Bearer '.$apiKey;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders($headers)
|
||||
->timeout($timeout)
|
||||
->acceptJson()
|
||||
->asJson()
|
||||
->post($url, $requestBody);
|
||||
} catch (\Throwable $e) {
|
||||
return MainSiteWalletResult::failure(
|
||||
$e->getMessage(),
|
||||
null,
|
||||
self::isUncertainTransportFailure($e),
|
||||
$requestSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
$payload = $response->json();
|
||||
if (! is_array($payload)) {
|
||||
$payload = ['raw' => $response->body()];
|
||||
}
|
||||
|
||||
$status = $response->status();
|
||||
if ($status === 408 || $status === 504) {
|
||||
return MainSiteWalletResult::failure(
|
||||
'HTTP '.$status,
|
||||
$payload,
|
||||
true,
|
||||
$requestSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
return MainSiteWalletResult::failure(
|
||||
is_string(data_get($payload, 'message'))
|
||||
? (string) data_get($payload, 'message')
|
||||
: ('HTTP '.$status),
|
||||
$payload,
|
||||
false,
|
||||
$requestSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
$ok = (bool) data_get($payload, 'success', true);
|
||||
if (! $ok) {
|
||||
return MainSiteWalletResult::failure(
|
||||
is_string(data_get($payload, 'message'))
|
||||
? (string) data_get($payload, 'message')
|
||||
: 'main_site_rejected',
|
||||
$payload,
|
||||
false,
|
||||
$requestSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
$ref = data_get($payload, 'external_ref_no')
|
||||
?? data_get($payload, 'data.external_ref_no')
|
||||
?? data_get($payload, 'ref');
|
||||
|
||||
return MainSiteWalletResult::success(is_string($ref) ? $ref : null, $payload, $requestSnapshot);
|
||||
}
|
||||
|
||||
private static function isUncertainTransportFailure(\Throwable $e): bool
|
||||
{
|
||||
if ($e instanceof ConnectException) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$msg = strtolower($e->getMessage());
|
||||
|
||||
return str_contains($msg, 'curl error 28')
|
||||
|| str_contains($msg, 'connection timed out')
|
||||
|| str_contains($msg, 'timed out')
|
||||
|| str_contains($msg, 'operation timed out');
|
||||
}
|
||||
}
|
||||
601
app/Services/Wallet/LotteryTransferService.php
Normal file
601
app/Services/Wallet/LotteryTransferService.php
Normal file
@@ -0,0 +1,601 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Wallet;
|
||||
|
||||
use App\Exceptions\WalletOperationException;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\Currency;
|
||||
use App\Models\Player;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\TransferOrder;
|
||||
use App\Models\WalletTxn;
|
||||
use App\Services\LotterySettings;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 主站 ↔ 彩票钱包:转入 / 转出(幂等键 + 流水 + 订单)。
|
||||
*/
|
||||
final class LotteryTransferService
|
||||
{
|
||||
private const WALLET_TYPE_LOTTERY = 'lottery';
|
||||
|
||||
private const DIR_IN = 'in';
|
||||
|
||||
private const DIR_OUT = 'out';
|
||||
|
||||
private const ST_PROCESSING = 'processing';
|
||||
|
||||
private const ST_SUCCESS = 'success';
|
||||
|
||||
private const ST_FAILED = 'failed';
|
||||
|
||||
/** PRD §6.2/6.7:主站超时待对账 */
|
||||
private const ST_PENDING_RECONCILE = 'pending_reconcile';
|
||||
|
||||
private const BIZ_TRANSFER_IN = 'transfer_in';
|
||||
|
||||
private const BIZ_TRANSFER_OUT = 'transfer_out';
|
||||
|
||||
private const BIZ_TRANSFER_OUT_REFUND = 'transfer_out_refund';
|
||||
|
||||
private const TXN_POSTED = 'posted';
|
||||
|
||||
private const TXN_PENDING_RECONCILE = 'pending_reconcile';
|
||||
|
||||
private const TXN_DIR_IN = 1;
|
||||
|
||||
private const TXN_DIR_OUT = 2;
|
||||
|
||||
public function __construct(
|
||||
private readonly MainSiteWalletGateway $mainSite,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 转入:主站扣款成功后增加彩票余额。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function transferIn(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): array
|
||||
{
|
||||
$this->assertPositiveAmount($amountMinor);
|
||||
$currencyCode = $this->normalizeCurrency($currencyCode);
|
||||
$this->assertCurrencyEnabled($currencyCode);
|
||||
$this->assertTransferInEnabled();
|
||||
$this->assertLotteryWalletNotFrozen($player, $currencyCode);
|
||||
$this->assertTransferAmountLimits(self::DIR_IN, $currencyCode, $amountMinor);
|
||||
|
||||
$existing = TransferOrder::query()->where('idempotent_key', $idempotentKey)->first();
|
||||
if ($existing !== null) {
|
||||
return $this->existingOrderResponse($existing, $player, self::DIR_IN, $currencyCode, $amountMinor);
|
||||
}
|
||||
|
||||
$transferNo = $this->newTransferNo('TI');
|
||||
|
||||
try {
|
||||
TransferOrder::query()->create([
|
||||
'transfer_no' => $transferNo,
|
||||
'player_id' => $player->id,
|
||||
'direction' => self::DIR_IN,
|
||||
'currency_code' => $currencyCode,
|
||||
'amount' => $amountMinor,
|
||||
'idempotent_key' => $idempotentKey,
|
||||
'status' => self::ST_PROCESSING,
|
||||
]);
|
||||
} catch (QueryException $e) {
|
||||
$existing = TransferOrder::query()->where('idempotent_key', $idempotentKey)->first();
|
||||
if ($existing !== null) {
|
||||
return $this->existingOrderResponse($existing, $player, self::DIR_IN, $currencyCode, $amountMinor);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
/** @var TransferOrder $order */
|
||||
$order = TransferOrder::query()->where('transfer_no', $transferNo)->firstOrFail();
|
||||
|
||||
$main = $this->mainSite->debitMainForLotteryDeposit($player, $currencyCode, $amountMinor, $idempotentKey);
|
||||
|
||||
if (! $main->ok) {
|
||||
if ($main->uncertain) {
|
||||
$order->forceFill([
|
||||
'status' => self::ST_PENDING_RECONCILE,
|
||||
'external_request_payload' => $main->requestPayload,
|
||||
'external_response_payload' => $main->responsePayload,
|
||||
'fail_reason' => 'main_site_timeout',
|
||||
'finished_at' => null,
|
||||
])->save();
|
||||
|
||||
throw new WalletOperationException(
|
||||
'pending_reconcile',
|
||||
ErrorCode::WalletTransferPending->value,
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
$order->forceFill([
|
||||
'status' => self::ST_FAILED,
|
||||
'external_request_payload' => $main->requestPayload,
|
||||
'external_response_payload' => $main->responsePayload,
|
||||
'fail_reason' => $main->errorMessage ?? 'main_site_failed',
|
||||
'finished_at' => now(),
|
||||
])->save();
|
||||
|
||||
throw new WalletOperationException(
|
||||
$main->errorMessage ?? 'main_site_failed',
|
||||
ErrorCode::WalletExternalRejected->value,
|
||||
);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($player, $currencyCode, $amountMinor, $order, $main, $transferNo, $idempotentKey): void {
|
||||
$wallet = $this->lockLotteryWallet($player, $currencyCode);
|
||||
$before = (int) $wallet->balance;
|
||||
$after = $before + $amountMinor;
|
||||
$wallet->forceFill([
|
||||
'balance' => $after,
|
||||
'version' => (int) $wallet->version + 1,
|
||||
])->save();
|
||||
|
||||
WalletTxn::query()->create([
|
||||
'txn_no' => $this->newTxnNo(),
|
||||
'player_id' => $player->id,
|
||||
'wallet_id' => $wallet->id,
|
||||
'biz_type' => self::BIZ_TRANSFER_IN,
|
||||
'biz_no' => $transferNo,
|
||||
'direction' => self::TXN_DIR_IN,
|
||||
'amount' => $amountMinor,
|
||||
'balance_before' => $before,
|
||||
'balance_after' => $after,
|
||||
'status' => self::TXN_POSTED,
|
||||
'external_ref_no' => $main->externalRefNo,
|
||||
'idempotent_key' => $idempotentKey,
|
||||
'remark' => null,
|
||||
]);
|
||||
|
||||
$order->forceFill([
|
||||
'status' => self::ST_SUCCESS,
|
||||
'external_ref_no' => $main->externalRefNo,
|
||||
'external_request_payload' => $main->requestPayload,
|
||||
'external_response_payload' => $main->responsePayload,
|
||||
'finished_at' => now(),
|
||||
])->save();
|
||||
});
|
||||
|
||||
return $this->successPayload(
|
||||
TransferOrder::query()->where('transfer_no', $transferNo)->firstOrFail(),
|
||||
$player,
|
||||
$currencyCode,
|
||||
self::BIZ_TRANSFER_IN,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转出:先扣彩票余额,再调用主站加款;失败则冲正彩票余额。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function transferOut(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): array
|
||||
{
|
||||
$this->assertPositiveAmount($amountMinor);
|
||||
$currencyCode = $this->normalizeCurrency($currencyCode);
|
||||
$this->assertCurrencyEnabled($currencyCode);
|
||||
$this->assertTransferOutEnabled();
|
||||
$this->assertLotteryWalletNotFrozen($player, $currencyCode);
|
||||
$this->assertTransferAmountLimits(self::DIR_OUT, $currencyCode, $amountMinor);
|
||||
|
||||
$existing = TransferOrder::query()->where('idempotent_key', $idempotentKey)->first();
|
||||
if ($existing !== null) {
|
||||
return $this->existingOrderResponse($existing, $player, self::DIR_OUT, $currencyCode, $amountMinor);
|
||||
}
|
||||
|
||||
$transferNo = $this->newTransferNo('TO');
|
||||
|
||||
try {
|
||||
TransferOrder::query()->create([
|
||||
'transfer_no' => $transferNo,
|
||||
'player_id' => $player->id,
|
||||
'direction' => self::DIR_OUT,
|
||||
'currency_code' => $currencyCode,
|
||||
'amount' => $amountMinor,
|
||||
'idempotent_key' => $idempotentKey,
|
||||
'status' => self::ST_PROCESSING,
|
||||
]);
|
||||
} catch (QueryException $e) {
|
||||
$existing = TransferOrder::query()->where('idempotent_key', $idempotentKey)->first();
|
||||
if ($existing !== null) {
|
||||
return $this->existingOrderResponse($existing, $player, self::DIR_OUT, $currencyCode, $amountMinor);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
/** @var TransferOrder $order */
|
||||
$order = TransferOrder::query()->where('transfer_no', $transferNo)->firstOrFail();
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($player, $currencyCode, $amountMinor, $transferNo, $idempotentKey): void {
|
||||
$wallet = $this->lockLotteryWallet($player, $currencyCode);
|
||||
$before = (int) $wallet->balance;
|
||||
if ($before < $amountMinor) {
|
||||
throw new WalletOperationException(
|
||||
'insufficient_balance',
|
||||
ErrorCode::WalletInsufficientBalance->value,
|
||||
);
|
||||
}
|
||||
$after = $before - $amountMinor;
|
||||
$wallet->forceFill([
|
||||
'balance' => $after,
|
||||
'version' => (int) $wallet->version + 1,
|
||||
])->save();
|
||||
|
||||
WalletTxn::query()->create([
|
||||
'txn_no' => $this->newTxnNo(),
|
||||
'player_id' => $player->id,
|
||||
'wallet_id' => $wallet->id,
|
||||
'biz_type' => self::BIZ_TRANSFER_OUT,
|
||||
'biz_no' => $transferNo,
|
||||
'direction' => self::TXN_DIR_OUT,
|
||||
'amount' => $amountMinor,
|
||||
'balance_before' => $before,
|
||||
'balance_after' => $after,
|
||||
'status' => self::TXN_POSTED,
|
||||
'external_ref_no' => null,
|
||||
'idempotent_key' => $idempotentKey,
|
||||
'remark' => null,
|
||||
]);
|
||||
});
|
||||
} catch (WalletOperationException $e) {
|
||||
if ($e->lotteryCode === ErrorCode::WalletInsufficientBalance->value) {
|
||||
$order->forceFill([
|
||||
'status' => self::ST_FAILED,
|
||||
'fail_reason' => 'insufficient_balance',
|
||||
'finished_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$main = $this->mainSite->creditMainForLotteryWithdraw($player, $currencyCode, $amountMinor, $idempotentKey);
|
||||
|
||||
if (! $main->ok) {
|
||||
if ($main->uncertain) {
|
||||
DB::transaction(function () use ($player, $transferNo, $order, $main): void {
|
||||
WalletTxn::query()
|
||||
->where('player_id', $player->id)
|
||||
->where('biz_no', $transferNo)
|
||||
->where('biz_type', self::BIZ_TRANSFER_OUT)
|
||||
->update(['status' => self::TXN_PENDING_RECONCILE]);
|
||||
|
||||
$order->forceFill([
|
||||
'status' => self::ST_PENDING_RECONCILE,
|
||||
'external_request_payload' => $main->requestPayload,
|
||||
'external_response_payload' => $main->responsePayload,
|
||||
'fail_reason' => 'main_site_timeout',
|
||||
'finished_at' => null,
|
||||
])->save();
|
||||
});
|
||||
|
||||
throw new WalletOperationException(
|
||||
'pending_reconcile',
|
||||
ErrorCode::WalletTransferPending->value,
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($player, $currencyCode, $amountMinor, $transferNo, $idempotentKey, $order, $main): void {
|
||||
$wallet = $this->lockLotteryWallet($player, $currencyCode);
|
||||
$before = (int) $wallet->balance;
|
||||
$after = $before + $amountMinor;
|
||||
$wallet->forceFill([
|
||||
'balance' => $after,
|
||||
'version' => (int) $wallet->version + 1,
|
||||
])->save();
|
||||
|
||||
WalletTxn::query()->create([
|
||||
'txn_no' => $this->newTxnNo(),
|
||||
'player_id' => $player->id,
|
||||
'wallet_id' => $wallet->id,
|
||||
'biz_type' => self::BIZ_TRANSFER_OUT_REFUND,
|
||||
'biz_no' => $transferNo,
|
||||
'direction' => self::TXN_DIR_IN,
|
||||
'amount' => $amountMinor,
|
||||
'balance_before' => $before,
|
||||
'balance_after' => $after,
|
||||
'status' => self::TXN_POSTED,
|
||||
'external_ref_no' => null,
|
||||
'idempotent_key' => $idempotentKey,
|
||||
'remark' => 'withdraw_failed_refund',
|
||||
]);
|
||||
|
||||
$order->forceFill([
|
||||
'status' => self::ST_FAILED,
|
||||
'external_request_payload' => $main->requestPayload,
|
||||
'external_response_payload' => $main->responsePayload,
|
||||
'fail_reason' => $main->errorMessage ?? 'main_site_failed',
|
||||
'finished_at' => now(),
|
||||
])->save();
|
||||
});
|
||||
|
||||
throw new WalletOperationException(
|
||||
$main->errorMessage ?? 'main_site_failed',
|
||||
ErrorCode::WalletExternalRejected->value,
|
||||
);
|
||||
}
|
||||
|
||||
$order->forceFill([
|
||||
'status' => self::ST_SUCCESS,
|
||||
'external_ref_no' => $main->externalRefNo,
|
||||
'external_request_payload' => $main->requestPayload,
|
||||
'external_response_payload' => $main->responsePayload,
|
||||
'finished_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $this->successPayload(
|
||||
TransferOrder::query()->where('transfer_no', $transferNo)->firstOrFail(),
|
||||
$player,
|
||||
$currencyCode,
|
||||
self::BIZ_TRANSFER_OUT,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function existingOrderResponse(
|
||||
TransferOrder $order,
|
||||
Player $player,
|
||||
string $expectedDirection,
|
||||
string $currencyCode,
|
||||
int $amountMinor,
|
||||
): array {
|
||||
if ((int) $order->player_id !== (int) $player->id
|
||||
|| $order->direction !== $expectedDirection
|
||||
|| strtoupper($order->currency_code) !== $currencyCode
|
||||
|| (int) $order->amount !== $amountMinor
|
||||
) {
|
||||
throw new WalletOperationException(
|
||||
'idempotent_conflict',
|
||||
ErrorCode::WalletIdempotentConflict->value,
|
||||
);
|
||||
}
|
||||
|
||||
return match ($order->status) {
|
||||
self::ST_SUCCESS => $this->successPayload(
|
||||
$order,
|
||||
$player,
|
||||
$currencyCode,
|
||||
$expectedDirection === self::DIR_IN ? self::BIZ_TRANSFER_IN : self::BIZ_TRANSFER_OUT,
|
||||
),
|
||||
self::ST_PROCESSING => throw new WalletOperationException(
|
||||
'pending',
|
||||
ErrorCode::WalletTransferPending->value,
|
||||
409,
|
||||
),
|
||||
self::ST_PENDING_RECONCILE => throw new WalletOperationException(
|
||||
'pending_reconcile',
|
||||
ErrorCode::WalletTransferPending->value,
|
||||
409,
|
||||
),
|
||||
self::ST_FAILED => throw $this->failedOrderToException($order),
|
||||
default => throw new WalletOperationException(
|
||||
'unknown_order_status',
|
||||
ErrorCode::WalletExternalRejected->value,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function successPayload(TransferOrder $order, Player $player, string $currencyCode, string $primaryBizType): array
|
||||
{
|
||||
$wallet = PlayerWallet::query()->where([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => self::WALLET_TYPE_LOTTERY,
|
||||
'currency_code' => $currencyCode,
|
||||
])->first();
|
||||
|
||||
$balance = $wallet !== null ? (int) $wallet->balance : 0;
|
||||
$frozen = $wallet !== null ? (int) $wallet->frozen_balance : 0;
|
||||
|
||||
$logId = WalletTxn::query()
|
||||
->where('player_id', $player->id)
|
||||
->where('biz_no', $order->transfer_no)
|
||||
->where('biz_type', $primaryBizType)
|
||||
->value('txn_no');
|
||||
|
||||
return [
|
||||
'transfer_no' => $order->transfer_no,
|
||||
'direction' => $order->direction,
|
||||
'currency_code' => $order->currency_code,
|
||||
'amount' => (int) $order->amount,
|
||||
'status' => $order->status,
|
||||
'external_ref_no' => $order->external_ref_no,
|
||||
/** PRD §10.1.1 示例字段名 */
|
||||
'balance' => $balance,
|
||||
'log_id' => $logId,
|
||||
'lottery_balance_after' => $balance,
|
||||
'lottery_available_after' => max(0, $balance - $frozen),
|
||||
'finished_at' => $order->finished_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function lockLotteryWallet(Player $player, string $currencyCode): PlayerWallet
|
||||
{
|
||||
$wallet = PlayerWallet::query()
|
||||
->where([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => self::WALLET_TYPE_LOTTERY,
|
||||
'currency_code' => $currencyCode,
|
||||
])
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($wallet === null) {
|
||||
return PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => self::WALLET_TYPE_LOTTERY,
|
||||
'currency_code' => $currencyCode,
|
||||
'balance' => 0,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
if ((int) $wallet->status !== 0) {
|
||||
throw new WalletOperationException(
|
||||
'lottery_wallet_frozen',
|
||||
ErrorCode::WalletLotteryFrozen->value,
|
||||
);
|
||||
}
|
||||
|
||||
return $wallet;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转出扣款前钱包必须存在且余额足够;若不存在则余额视为 0。
|
||||
*/
|
||||
private function assertLotteryWalletNotFrozen(Player $player, string $currencyCode): void
|
||||
{
|
||||
$wallet = PlayerWallet::query()->where([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => self::WALLET_TYPE_LOTTERY,
|
||||
'currency_code' => $currencyCode,
|
||||
])->first();
|
||||
|
||||
if ($wallet !== null && (int) $wallet->status !== 0) {
|
||||
throw new WalletOperationException(
|
||||
'lottery_wallet_frozen',
|
||||
ErrorCode::WalletLotteryFrozen->value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertPositiveAmount(int $amountMinor): void
|
||||
{
|
||||
if ($amountMinor < 1) {
|
||||
throw new WalletOperationException(
|
||||
'invalid_amount',
|
||||
ErrorCode::WalletInvalidAmount->value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeCurrency(string $code): string
|
||||
{
|
||||
return strtoupper(substr(trim($code), 0, 16));
|
||||
}
|
||||
|
||||
private function assertCurrencyEnabled(string $currencyCode): void
|
||||
{
|
||||
if (! preg_match('/^[A-Z0-9]{1,16}$/', $currencyCode)) {
|
||||
throw new WalletOperationException(
|
||||
'invalid_currency',
|
||||
ErrorCode::WalletInvalidCurrency->value,
|
||||
);
|
||||
}
|
||||
|
||||
$ok = Currency::query()->where('code', $currencyCode)->where('is_enabled', true)->exists();
|
||||
if (! $ok) {
|
||||
throw new WalletOperationException(
|
||||
'invalid_currency',
|
||||
ErrorCode::WalletInvalidCurrency->value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertTransferInEnabled(): void
|
||||
{
|
||||
if (! (bool) LotterySettings::get('wallet.transfer_in_enabled', true)) {
|
||||
throw new WalletOperationException(
|
||||
'transfer_in_disabled',
|
||||
ErrorCode::WalletTransferInDisabled->value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertTransferOutEnabled(): void
|
||||
{
|
||||
if (! (bool) LotterySettings::get('wallet.transfer_out_enabled', true)) {
|
||||
throw new WalletOperationException(
|
||||
'transfer_out_disabled',
|
||||
ErrorCode::WalletTransferOutDisabled->value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PRD §6.2:最小/最大金额(最小货币单位);可按币种覆盖 JSON。
|
||||
*
|
||||
* @see LotterySettingsSeeder 默认键;可选 `wallet.transfer_limits_by_currency` = {"NPR":{"in_min":100,...}}
|
||||
*/
|
||||
private function assertTransferAmountLimits(string $direction, string $currencyCode, int $amountMinor): void
|
||||
{
|
||||
$limits = $this->transferLimitsForCurrency($currencyCode);
|
||||
$min = $direction === self::DIR_IN ? $limits['in_min'] : $limits['out_min'];
|
||||
$max = $direction === self::DIR_IN ? $limits['in_max'] : $limits['out_max'];
|
||||
|
||||
if ($amountMinor < $min || $amountMinor > $max) {
|
||||
throw new WalletOperationException(
|
||||
'amount_out_of_limits',
|
||||
ErrorCode::WalletAmountExceedsLimit->value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{in_min: int, in_max: int, out_min: int, out_max: int}
|
||||
*/
|
||||
private function transferLimitsForCurrency(string $currencyCode): array
|
||||
{
|
||||
$defaults = [
|
||||
'in_min' => max(1, (int) LotterySettings::get('wallet.transfer_in_min_minor', 100)),
|
||||
'in_max' => (int) LotterySettings::get('wallet.transfer_in_max_minor', 9_999_999_999_999_999),
|
||||
'out_min' => max(1, (int) LotterySettings::get('wallet.transfer_out_min_minor', 100)),
|
||||
'out_max' => (int) LotterySettings::get('wallet.transfer_out_max_minor', 9_999_999_999_999_999),
|
||||
];
|
||||
|
||||
$raw = LotterySettings::get('wallet.transfer_limits_by_currency');
|
||||
if (is_string($raw)) {
|
||||
$raw = json_decode($raw, true);
|
||||
}
|
||||
if (! is_array($raw) || ! isset($raw[$currencyCode]) || ! is_array($raw[$currencyCode])) {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
$patch = $raw[$currencyCode];
|
||||
|
||||
return [
|
||||
'in_min' => isset($patch['in_min']) ? max(1, (int) $patch['in_min']) : $defaults['in_min'],
|
||||
'in_max' => isset($patch['in_max']) ? max(1, (int) $patch['in_max']) : $defaults['in_max'],
|
||||
'out_min' => isset($patch['out_min']) ? max(1, (int) $patch['out_min']) : $defaults['out_min'],
|
||||
'out_max' => isset($patch['out_max']) ? max(1, (int) $patch['out_max']) : $defaults['out_max'],
|
||||
];
|
||||
}
|
||||
|
||||
private function newTransferNo(string $prefix): string
|
||||
{
|
||||
return $prefix.'_'.Str::lower(str_replace('-', '', Str::uuid()->toString()));
|
||||
}
|
||||
|
||||
private function newTxnNo(): string
|
||||
{
|
||||
return 'WX_'.Str::lower(str_replace('-', '', Str::uuid()->toString()));
|
||||
}
|
||||
|
||||
private function failedOrderToException(TransferOrder $order): WalletOperationException
|
||||
{
|
||||
if (($order->fail_reason ?? '') === 'insufficient_balance') {
|
||||
return new WalletOperationException(
|
||||
'insufficient_balance',
|
||||
ErrorCode::WalletInsufficientBalance->value,
|
||||
);
|
||||
}
|
||||
|
||||
return new WalletOperationException(
|
||||
(string) ($order->fail_reason ?? 'failed'),
|
||||
ErrorCode::WalletExternalRejected->value,
|
||||
);
|
||||
}
|
||||
}
|
||||
33
app/Services/Wallet/MainSiteWalletGateway.php
Normal file
33
app/Services/Wallet/MainSiteWalletGateway.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Wallet;
|
||||
|
||||
use App\Models\Player;
|
||||
|
||||
/**
|
||||
* 主站钱包对接:转入时扣主站余额,转出时给主站加款。
|
||||
*
|
||||
* 幂等键由彩票侧生成并与 transfer_orders.idempotent_key 对齐,主站应按同一键去重。
|
||||
*/
|
||||
interface MainSiteWalletGateway
|
||||
{
|
||||
/**
|
||||
* 转入场景:主站钱包扣款 → 彩票钱包加款。
|
||||
*/
|
||||
public function debitMainForLotteryDeposit(
|
||||
Player $player,
|
||||
string $currencyCode,
|
||||
int $amountMinor,
|
||||
string $idempotentKey,
|
||||
): MainSiteWalletResult;
|
||||
|
||||
/**
|
||||
* 转出场景:给主站钱包加款(彩票侧已在本地扣减)。
|
||||
*/
|
||||
public function creditMainForLotteryWithdraw(
|
||||
Player $player,
|
||||
string $currencyCode,
|
||||
int $amountMinor,
|
||||
string $idempotentKey,
|
||||
): MainSiteWalletResult;
|
||||
}
|
||||
36
app/Services/Wallet/MainSiteWalletResult.php
Normal file
36
app/Services/Wallet/MainSiteWalletResult.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Wallet;
|
||||
|
||||
use App\Models\TransferOrder;
|
||||
|
||||
/**
|
||||
* 调用主站钱包 HTTP 后的统一结果(转入扣主站 / 转出加主站)。
|
||||
*/
|
||||
final readonly class MainSiteWalletResult
|
||||
{
|
||||
public function __construct(
|
||||
public bool $ok,
|
||||
public ?string $externalRefNo,
|
||||
/** @var array<string, mixed>|null */
|
||||
public ?array $responsePayload,
|
||||
/** 发往主站的 JSON 请求体快照,写入 {@see TransferOrder::external_request_payload} */
|
||||
public ?array $requestPayload,
|
||||
public ?string $errorMessage,
|
||||
/**
|
||||
* 网络超时/连接类不确定结果:由 {@see HttpMainSiteWalletGateway} 设置,上层将订单标为 pending_reconcile,
|
||||
* 不重复扣加款(PRD §6.2 / §6.7)。
|
||||
*/
|
||||
public bool $uncertain = false,
|
||||
) {}
|
||||
|
||||
public static function success(?string $ref, ?array $payload = null, ?array $requestPayload = null): self
|
||||
{
|
||||
return new self(true, $ref, $payload, $requestPayload, null, false);
|
||||
}
|
||||
|
||||
public static function failure(string $message, ?array $payload = null, bool $uncertain = false, ?array $requestPayload = null): self
|
||||
{
|
||||
return new self(false, null, $payload, $requestPayload, $message, $uncertain);
|
||||
}
|
||||
}
|
||||
65
app/Services/Wallet/StubMainSiteWalletGateway.php
Normal file
65
app/Services/Wallet/StubMainSiteWalletGateway.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Wallet;
|
||||
|
||||
use App\Models\Player;
|
||||
|
||||
/**
|
||||
* 未配置 `MAIN_SITE_WALLET_API_URL` 或仅本地联调时使用:主站永远返回成功。
|
||||
*/
|
||||
final class StubMainSiteWalletGateway implements MainSiteWalletGateway
|
||||
{
|
||||
public function debitMainForLotteryDeposit(
|
||||
Player $player,
|
||||
string $currencyCode,
|
||||
int $amountMinor,
|
||||
string $idempotentKey,
|
||||
): MainSiteWalletResult {
|
||||
$req = self::requestSnapshot($player, $currencyCode, $amountMinor, $idempotentKey, 'stub_debit');
|
||||
|
||||
return MainSiteWalletResult::success('stub-debit:'.$idempotentKey, [
|
||||
'stub' => true,
|
||||
'currency' => $currencyCode,
|
||||
'amount_minor' => $amountMinor,
|
||||
], $req);
|
||||
}
|
||||
|
||||
public function creditMainForLotteryWithdraw(
|
||||
Player $player,
|
||||
string $currencyCode,
|
||||
int $amountMinor,
|
||||
string $idempotentKey,
|
||||
): MainSiteWalletResult {
|
||||
$req = self::requestSnapshot($player, $currencyCode, $amountMinor, $idempotentKey, 'stub_credit');
|
||||
|
||||
return MainSiteWalletResult::success('stub-credit:'.$idempotentKey, [
|
||||
'stub' => true,
|
||||
'currency' => $currencyCode,
|
||||
'amount_minor' => $amountMinor,
|
||||
], $req);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function requestSnapshot(
|
||||
Player $player,
|
||||
string $currencyCode,
|
||||
int $amountMinor,
|
||||
string $idempotentKey,
|
||||
string $stubOp,
|
||||
): array {
|
||||
return [
|
||||
'site_code' => $player->site_code,
|
||||
'site_player_id' => $player->site_player_id,
|
||||
'player_id' => $player->id,
|
||||
'currency_code' => $currencyCode,
|
||||
'amount_minor' => $amountMinor,
|
||||
'idempotent_key' => $idempotentKey,
|
||||
'_meta' => [
|
||||
'stub' => true,
|
||||
'operation' => $stubOp,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,35 @@ use Illuminate\Http\Request;
|
||||
* 【业务文案翻译辅助类】
|
||||
*
|
||||
* 从 lang/{locale}/sso.php 等语言包取字符串,供 JSON 里「msg」字段使用。
|
||||
* `App\Lottery\ErrorCode` 中玩家 SSO 段(8001–8004)的各语言 `msg`。
|
||||
* `App\Lottery\ErrorCode` 中玩家 SSO 段(8001–8005)的各语言 `msg`。
|
||||
*
|
||||
* 依赖 NegotiateLotteryLocale 已写入 lottery_locale;若未写则使用 fallback 语言包。
|
||||
*/
|
||||
final class LotteryMessage
|
||||
{
|
||||
/**
|
||||
* 取钱包划转类错误的用户可见文案(lang/{locale}/wallet.php 键名为数字错误码)。
|
||||
*
|
||||
* @param int $code {@see ErrorCode} 钱包段(1001–1010 等与钱包相关的业务码)
|
||||
*/
|
||||
public static function wallet(Request $request, int $code): string
|
||||
{
|
||||
$fallback = (string) config('lottery.locales.fallback', 'en');
|
||||
$locale = (string) ($request->attributes->get('lottery_locale') ?? LotteryLocale::resolve($request));
|
||||
$key = 'wallet.'.$code;
|
||||
|
||||
$msg = trans($key, [], $locale);
|
||||
if ($msg !== $key) {
|
||||
return $msg;
|
||||
}
|
||||
|
||||
return trans($key, [], $fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取 SSO 鉴权类错误的用户可见文案(与 ApiResponse 的 msg 对应)。
|
||||
*
|
||||
* @param int $code {@see ErrorCode} 玩家 SSO 段(8001–8004)
|
||||
* @param int $code {@see ErrorCode} 玩家 SSO 段(8001–8005)
|
||||
*/
|
||||
public static function sso(Request $request, int $code): string
|
||||
{
|
||||
|
||||
@@ -30,14 +30,17 @@ return [
|
||||
'wallet_api_url' => env('MAIN_SITE_WALLET_API_URL'),
|
||||
'wallet_api_key' => env('MAIN_SITE_WALLET_API_KEY'),
|
||||
'wallet_timeout' => (int) env('MAIN_SITE_WALLET_TIMEOUT', 10),
|
||||
/** 主站钱包 HTTP 相对路径(拼接在 wallet_api_url 后);Stub 模式下忽略 */
|
||||
'wallet_debit_path' => env('MAIN_SITE_WALLET_DEBIT_PATH', '/wallet/debit-for-lottery'),
|
||||
'wallet_credit_path' => env('MAIN_SITE_WALLET_CREDIT_PATH', '/wallet/credit-from-lottery'),
|
||||
],
|
||||
|
||||
/*
|
||||
| player_auth:配合 app/Services/PlayerTokenResolver.php
|
||||
|
|
||||
| dev_bypass:仅当 APP_ENV∈{local, testing} 且 LOTTERY_PLAYER_AUTH_DEV_BYPASS=true 时,
|
||||
| 允许 Authorization: Bearer dev:{players.id}
|
||||
| jwt.* :主站签发的 JWT 内取站点、玩家字段的路径名(与主站约定一致)
|
||||
| 允许 Authorization: Bearer dev:{players.id}(否则 dev: 会被当成 JWT 解析并报 8002)
|
||||
| jwt.* :主站签发的 JWT 内取站点、玩家字段的路径名(与主站约定一致);验签通过后若无映射行则自动建档
|
||||
*/
|
||||
'player_auth' => [
|
||||
'dev_bypass' => env('LOTTERY_PLAYER_AUTH_DEV_BYPASS', false),
|
||||
|
||||
@@ -26,6 +26,34 @@ class LotterySettingsSeeder extends Seeder
|
||||
'是否允许玩家端发起转出',
|
||||
);
|
||||
|
||||
LotterySettings::put(
|
||||
'wallet.transfer_in_min_minor',
|
||||
100,
|
||||
'wallet',
|
||||
'转入单笔最小金额(最小货币单位)',
|
||||
);
|
||||
|
||||
LotterySettings::put(
|
||||
'wallet.transfer_in_max_minor',
|
||||
9_999_999_999_999_999,
|
||||
'wallet',
|
||||
'转入单笔最大金额(最小货币单位)',
|
||||
);
|
||||
|
||||
LotterySettings::put(
|
||||
'wallet.transfer_out_min_minor',
|
||||
100,
|
||||
'wallet',
|
||||
'转出单笔最小金额',
|
||||
);
|
||||
|
||||
LotterySettings::put(
|
||||
'wallet.transfer_out_max_minor',
|
||||
9_999_999_999_999_999,
|
||||
'wallet',
|
||||
'转出单笔最大金额',
|
||||
);
|
||||
|
||||
LotterySettings::put(
|
||||
'app.display_name_for_client',
|
||||
'Lottery',
|
||||
|
||||
@@ -11,4 +11,5 @@ return [
|
||||
'8002' => 'Token invalid or expired', // JWT 无效、过期或 dev: 格式错误等
|
||||
'8003' => 'Player not registered', // 库中无对应玩家
|
||||
'8004' => 'SSO secret not configured', // 未配置 MAIN_SITE_SSO_JWT_SECRET(通常返回 503)
|
||||
'8005' => 'Account suspended or login disabled',
|
||||
];
|
||||
|
||||
@@ -3,4 +3,15 @@
|
||||
/** PRD 钱包段多语言;NegotiateLotteryLocale 后由 __() 选用 */
|
||||
return [
|
||||
'invalid_currency' => 'Invalid currency code',
|
||||
|
||||
'1001' => 'Insufficient lottery wallet balance',
|
||||
'1002' => 'A previous transfer is still processing; please retry shortly',
|
||||
'1003' => 'Amount exceeds the allowed limit',
|
||||
'1004' => 'Deposits are temporarily disabled',
|
||||
'1005' => 'Invalid or disabled currency',
|
||||
'1006' => 'Withdrawals are temporarily disabled',
|
||||
'1007' => 'Lottery wallet is frozen; transfers are blocked',
|
||||
'1008' => 'Invalid amount; enter a positive integer in minor units',
|
||||
'1009' => 'Main wallet operation failed; please try again later',
|
||||
'1010' => 'Do not reuse an idempotency key with different transfer parameters',
|
||||
];
|
||||
|
||||
@@ -8,4 +8,5 @@ return [
|
||||
'8002' => 'टोकन अमान्य वा समयावधि सकियो',
|
||||
'8003' => 'खेलाडी दर्ता छैन',
|
||||
'8004' => 'SSO गोप्य सेट छैन',
|
||||
'8005' => 'खाता निलम्बित वा लगइन असक्षम',
|
||||
];
|
||||
|
||||
@@ -2,4 +2,15 @@
|
||||
|
||||
return [
|
||||
'invalid_currency' => 'मुद्रा कोड अमान्य',
|
||||
|
||||
'1001' => 'लटरी वालेट ब्यालेन्स अपर्याप्त',
|
||||
'1002' => 'अघिल्लो स्थानान्तरण अझै प्रक्रियामा छ, पछि प्रयास गर्नुहोस्',
|
||||
'1003' => 'रकम सीमा नाघ्यो',
|
||||
'1004' => 'जम्मा अस्थायी रूपमा बन्द छ',
|
||||
'1005' => 'मुद्रा अमान्य वा असक्षम',
|
||||
'1006' => 'निकासी अस्थायी रूपमा बन्द छ',
|
||||
'1007' => 'लटरी वालेट फ्रोजन छ',
|
||||
'1008' => 'रकम अमान्य',
|
||||
'1009' => 'मुख्य वालेट असफल, पछि प्रयास गर्नुहोस्',
|
||||
'1010' => 'एउटै कुञ्जी दोहोर्याउनुहुन्न',
|
||||
];
|
||||
|
||||
@@ -8,4 +8,5 @@ return [
|
||||
'8002' => '令牌无效或已过期',
|
||||
'8003' => '玩家未建档',
|
||||
'8004' => '未配置 SSO 密钥',
|
||||
'8005' => '账号已冻结或暂时无法登录',
|
||||
];
|
||||
|
||||
@@ -2,4 +2,15 @@
|
||||
|
||||
return [
|
||||
'invalid_currency' => '币种参数不合法',
|
||||
|
||||
'1001' => '彩票钱包余额不足',
|
||||
'1002' => '上一笔转账仍在处理中,请稍后重试',
|
||||
'1003' => '单笔金额超出允许范围',
|
||||
'1004' => '转入功能已暂时关闭',
|
||||
'1005' => '币种无效或未启用',
|
||||
'1006' => '转出功能已暂时关闭',
|
||||
'1007' => '彩票钱包已冻结,无法划转',
|
||||
'1008' => '金额无效,请输入正整数(最小货币单位)',
|
||||
'1009' => '主站钱包处理失败,请稍后重试',
|
||||
'1010' => '请勿重复使用幂等键发起不同金额的转账',
|
||||
];
|
||||
|
||||
@@ -3,10 +3,16 @@
|
||||
use App\Http\Controllers\Api\V1\Admin\Auth\CaptchaController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Auth\LoginController;
|
||||
use App\Http\Controllers\Api\V1\Admin\PingController as AdminPingController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Player\PlayerWalletShowController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Wallet\TransferOrderListController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Wallet\WalletTransactionListController;
|
||||
use App\Http\Controllers\Api\V1\HealthController;
|
||||
use App\Http\Controllers\Api\V1\Player\MeController;
|
||||
use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController;
|
||||
use App\Http\Controllers\Api\V1\Wallet\WalletBalanceController;
|
||||
use App\Http\Controllers\Api\V1\Wallet\WalletLogsController;
|
||||
use App\Http\Controllers\Api\V1\Wallet\WalletTransferInController;
|
||||
use App\Http\Controllers\Api\V1\Wallet\WalletTransferOutController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
@@ -37,6 +43,12 @@ Route::prefix('v1')->group(function (): void {
|
||||
->group(function (): void {
|
||||
// 名称:彩票钱包余额查询
|
||||
Route::get('balance', WalletBalanceController::class)->name('balance');
|
||||
// 名称:钱包流水(PRD §10.1.1)
|
||||
Route::get('logs', WalletLogsController::class)->name('logs');
|
||||
// 名称:主站 → 彩票 转入
|
||||
Route::post('transfer-in', WalletTransferInController::class)->name('transfer-in');
|
||||
// 名称:彩票 → 主站 转出
|
||||
Route::post('transfer-out', WalletTransferOutController::class)->name('transfer-out');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +66,13 @@ Route::prefix('v1')->group(function (): void {
|
||||
Route::middleware(['auth:sanctum', 'lottery.admin'])->group(function (): void {
|
||||
// 名称:后台接口连通性探测(需 Bearer Token)
|
||||
Route::get('ping', AdminPingController::class)->name('ping');
|
||||
// 资金:转账单 / 流水 / 玩家钱包
|
||||
Route::get('wallet/transfer-orders', TransferOrderListController::class)
|
||||
->name('wallet.transfer-orders');
|
||||
Route::get('wallet/transactions', WalletTransactionListController::class)
|
||||
->name('wallet.transactions');
|
||||
Route::get('players/{player}/wallets', PlayerWalletShowController::class)
|
||||
->name('players.wallets');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
205
tests/Feature/AdminWalletApiTest.php
Normal file
205
tests/Feature/AdminWalletApiTest.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Player;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\TransferOrder;
|
||||
use App\Models\WalletTxn;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeAdminToken(): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'wallet_admin',
|
||||
'name' => 'Wallet',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
}
|
||||
|
||||
test('admin wallet transfer orders list requires authentication', function (): void {
|
||||
$this->getJson('/api/v1/admin/wallet/transfer-orders')
|
||||
->assertUnauthorized()
|
||||
->assertJsonPath('code', ErrorCode::AdminUnauthenticated->value);
|
||||
});
|
||||
|
||||
test('admin lists transfer orders with player info', function (): void {
|
||||
$token = makeAdminToken();
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'p99',
|
||||
'username' => 'u99',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
TransferOrder::query()->create([
|
||||
'transfer_no' => 'TI_test_order_1',
|
||||
'player_id' => $player->id,
|
||||
'direction' => 'in',
|
||||
'currency_code' => 'NPR',
|
||||
'amount' => 1000,
|
||||
'idempotent_key' => 'adm-test-1',
|
||||
'status' => 'success',
|
||||
'external_request_payload' => null,
|
||||
'external_response_payload' => null,
|
||||
'external_ref_no' => 'ref1',
|
||||
'fail_reason' => null,
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/wallet/transfer-orders?per_page=10')
|
||||
->assertOk()
|
||||
->assertJsonPath('code', ErrorCode::Success->value)
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonPath('data.items.0.transfer_no', 'TI_test_order_1')
|
||||
->assertJsonPath('data.items.0.site_player_id', 'p99')
|
||||
->assertJsonPath('data.items.0.status', 'success');
|
||||
});
|
||||
|
||||
test('admin filters abnormal transfer orders', function (): void {
|
||||
$token = makeAdminToken();
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'pa',
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
foreach (
|
||||
[
|
||||
['TI_ok', 'success'],
|
||||
['TI_bad', 'failed'],
|
||||
['TI_wait', 'pending_reconcile'],
|
||||
] as [$no, $st]
|
||||
) {
|
||||
TransferOrder::query()->create([
|
||||
'transfer_no' => $no,
|
||||
'player_id' => $player->id,
|
||||
'direction' => 'in',
|
||||
'currency_code' => 'NPR',
|
||||
'amount' => 100,
|
||||
'idempotent_key' => 'k-'.$no,
|
||||
'status' => $st,
|
||||
'external_request_payload' => null,
|
||||
'external_response_payload' => null,
|
||||
'external_ref_no' => null,
|
||||
'fail_reason' => null,
|
||||
'finished_at' => $st === 'success' ? now() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
$resp = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/wallet/transfer-orders?abnormal=1');
|
||||
|
||||
$resp->assertOk()->assertJsonPath('data.total', 2);
|
||||
});
|
||||
|
||||
test('admin lists wallet transactions and filters abnormal', function (): void {
|
||||
$token = makeAdminToken();
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'pb',
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$wallet = PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => 5000,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 1,
|
||||
]);
|
||||
|
||||
WalletTxn::query()->create([
|
||||
'txn_no' => 'WX_posted_1',
|
||||
'player_id' => $player->id,
|
||||
'wallet_id' => $wallet->id,
|
||||
'biz_type' => 'transfer_in',
|
||||
'biz_no' => 'TI_x',
|
||||
'direction' => 1,
|
||||
'amount' => 100,
|
||||
'balance_before' => 0,
|
||||
'balance_after' => 100,
|
||||
'status' => 'posted',
|
||||
'external_ref_no' => null,
|
||||
'idempotent_key' => 'ik1',
|
||||
'remark' => null,
|
||||
]);
|
||||
|
||||
WalletTxn::query()->create([
|
||||
'txn_no' => 'WX_pending_1',
|
||||
'player_id' => $player->id,
|
||||
'wallet_id' => $wallet->id,
|
||||
'biz_type' => 'transfer_out',
|
||||
'biz_no' => 'TO_x',
|
||||
'direction' => 2,
|
||||
'amount' => 50,
|
||||
'balance_before' => 100,
|
||||
'balance_after' => 50,
|
||||
'status' => 'pending_reconcile',
|
||||
'external_ref_no' => null,
|
||||
'idempotent_key' => 'ik2',
|
||||
'remark' => null,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/wallet/transactions')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 2);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/wallet/transactions?abnormal=1')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonPath('data.items.0.status', 'pending_reconcile');
|
||||
});
|
||||
|
||||
test('admin shows player wallets', function (): void {
|
||||
$token = makeAdminToken();
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'pc',
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => 77700,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/players/'.$player->id.'/wallets')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.player.site_player_id', 'pc')
|
||||
->assertJsonPath('data.wallets.0.balance', 77700)
|
||||
->assertJsonPath('data.wallets.0.available_balance', 77700);
|
||||
});
|
||||
@@ -18,13 +18,26 @@ test('player me returns profile with dev bearer', function () {
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer dev:'.$player->id,
|
||||
'X-Locale' => 'zh',
|
||||
])
|
||||
->getJson('/api/v1/player/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('code', ErrorCode::Success->value)
|
||||
->assertJsonPath('data.id', $player->id)
|
||||
->assertJsonPath('data.site_player_id', 'uid-42')
|
||||
->assertJsonPath('data.username', 'alice');
|
||||
->assertJsonPath('data.username', 'alice')
|
||||
->assertJsonPath('data.locale', 'zh')
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'last_login_at',
|
||||
'created_at',
|
||||
],
|
||||
]);
|
||||
|
||||
$player->refresh();
|
||||
expect($player->last_login_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('player auth missing bearer returns localized sso 8001', function () {
|
||||
@@ -68,3 +81,45 @@ test('player me works with main site jwt when dev bypass is off', function () {
|
||||
->assertOk()
|
||||
->assertJsonPath('data.site_player_id', 'jwt-user-1');
|
||||
});
|
||||
|
||||
test('jwt first successful login auto-registers player mapping', function () {
|
||||
config(['lottery.player_auth.dev_bypass' => false]);
|
||||
config(['lottery.main_site.sso_jwt_secret' => 'jwt-test-secret']);
|
||||
|
||||
expect(Player::query()->count())->toBe(0);
|
||||
|
||||
$jwt = JWT::encode([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'brand-new-sso-1',
|
||||
'exp' => time() + 3600,
|
||||
], 'jwt-test-secret', 'HS256');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$jwt)
|
||||
->getJson('/api/v1/player/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.site_player_id', 'brand-new-sso-1')
|
||||
->assertJsonPath('data.default_currency', 'NPR');
|
||||
|
||||
expect(Player::query()->where('site_player_id', 'brand-new-sso-1')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('player me rejects non-active status with 8005', function () {
|
||||
$code = ErrorCode::PlayerAccountSuspended->value;
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'frozen-1',
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer dev:'.$player->id,
|
||||
'Accept-Language' => 'zh-CN,zh;q=0.9',
|
||||
])
|
||||
->getJson('/api/v1/player/me')
|
||||
->assertStatus(403)
|
||||
->assertJsonPath('code', $code)
|
||||
->assertJsonPath('msg', __("sso.$code", [], 'zh'));
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ test('wallet balance creates lottery wallet row and returns zeros', function ()
|
||||
$response->assertOk()
|
||||
->assertJsonPath('code', ErrorCode::Success->value)
|
||||
->assertJsonPath('data.balance', 0)
|
||||
->assertJsonPath('data.available_balance', 0)
|
||||
->assertJsonPath('data.frozen_balance', 0)
|
||||
->assertJsonPath('data.currency_code', 'NPR')
|
||||
->assertJsonPath('data.wallet_type', 'lottery')
|
||||
|
||||
82
tests/Feature/WalletLogsTest.php
Normal file
82
tests/Feature/WalletLogsTest.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\Player;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\WalletTxn;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
config(['lottery.main_site.wallet_api_url' => null]);
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->seed(LotterySettingsSeeder::class);
|
||||
});
|
||||
|
||||
test('wallet logs returns transfer rows and pagination', function () {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'wl1',
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/wallet/transfer-in', [
|
||||
'amount' => 300,
|
||||
'idempotent_key' => 'log-test-1',
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?page=1&size=10')
|
||||
->assertOk()
|
||||
->assertJsonPath('code', ErrorCode::Success->value)
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonPath('data.items.0.type', 'transfer_in');
|
||||
|
||||
expect(WalletTxn::query()->where('player_id', $player->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('wallet logs filters by type refund', function () {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'wl2',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => 500,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
config(['lottery.main_site.wallet_api_url' => 'http://failure.test']);
|
||||
Http::fake([
|
||||
'failure.test/*' => Http::response(['success' => false, 'message' => 'no'], 200),
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/wallet/transfer-out', [
|
||||
'amount' => 200,
|
||||
'idempotent_key' => 'out-fail',
|
||||
])
|
||||
->assertStatus(400);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?type=refund')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonPath('data.items.0.type', 'refund');
|
||||
});
|
||||
245
tests/Feature/WalletTransferTest.php
Normal file
245
tests/Feature/WalletTransferTest.php
Normal file
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\Player;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\TransferOrder;
|
||||
use App\Models\WalletTxn;
|
||||
use App\Services\LotterySettings;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
config(['lottery.main_site.wallet_api_url' => null]);
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->seed(LotterySettingsSeeder::class);
|
||||
});
|
||||
|
||||
test('transfer in credits lottery wallet via stub main site', function () {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'u1',
|
||||
'username' => 'a',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$key = 'idem-'.uniqid('', true);
|
||||
|
||||
$response = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/wallet/transfer-in', [
|
||||
'amount' => 500,
|
||||
'currency' => 'NPR',
|
||||
'idempotent_key' => $key,
|
||||
]);
|
||||
$response->assertOk()
|
||||
->assertJsonPath('code', ErrorCode::Success->value)
|
||||
->assertJsonPath('data.amount', 500)
|
||||
->assertJsonPath('data.currency_code', 'NPR')
|
||||
->assertJsonPath('data.balance', 500)
|
||||
->assertJsonPath('data.lottery_balance_after', 500);
|
||||
|
||||
expect((string) $response->json('data.log_id'))->toStartWith('WX_');
|
||||
|
||||
$wallet = PlayerWallet::query()->where('player_id', $player->id)->first();
|
||||
expect($wallet)->not->toBeNull()
|
||||
->and((int) $wallet->balance)->toBe(500);
|
||||
|
||||
$order = TransferOrder::query()->where('idempotent_key', $key)->first();
|
||||
expect($order?->status)->toBe('success')
|
||||
->and($order->external_request_payload)->not->toBeNull()
|
||||
->and($order->external_request_payload['amount_minor'])->toBe(500)
|
||||
->and($order->external_request_payload['_meta']['stub'] ?? null)->toBeTrue();
|
||||
expect(WalletTxn::query()->where('biz_type', 'transfer_in')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('transfer in idempotent replay returns same success shape', function () {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'u2',
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$key = 'idem-replay-1';
|
||||
|
||||
$first = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/wallet/transfer-in', [
|
||||
'amount' => 100,
|
||||
'idempotent_key' => $key,
|
||||
]);
|
||||
|
||||
$second = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/wallet/transfer-in', [
|
||||
'amount' => 100,
|
||||
'idempotent_key' => $key,
|
||||
]);
|
||||
|
||||
$first->assertOk();
|
||||
$second->assertOk();
|
||||
|
||||
expect((string) $first->json('data.transfer_no'))->toBe((string) $second->json('data.transfer_no'))
|
||||
->and(PlayerWallet::query()->where('player_id', $player->id)->first()?->balance)->toBe(100);
|
||||
});
|
||||
|
||||
test('transfer out debits lottery and matches stub credit', function () {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'u3',
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => 1000,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
$key = 'idem-out-1';
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/wallet/transfer-out', [
|
||||
'amount' => 400,
|
||||
'idempotent_key' => $key,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.lottery_balance_after', 600);
|
||||
|
||||
expect((int) PlayerWallet::query()->where('player_id', $player->id)->first()?->balance)->toBe(600);
|
||||
});
|
||||
|
||||
test('transfer out insufficient balance fails with 1001', function () {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'u4',
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$key = 'idem-out-broke';
|
||||
|
||||
$code = ErrorCode::WalletInsufficientBalance->value;
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer dev:'.$player->id,
|
||||
'X-Locale' => 'zh',
|
||||
])
|
||||
->postJson('/api/v1/wallet/transfer-out', [
|
||||
'amount' => 100,
|
||||
'idempotent_key' => $key,
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertJsonPath('code', $code)
|
||||
->assertJsonPath('msg', __("wallet.$code", [], 'zh'));
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer dev:'.$player->id,
|
||||
'X-Locale' => 'zh',
|
||||
])
|
||||
->postJson('/api/v1/wallet/transfer-out', [
|
||||
'amount' => 100,
|
||||
'idempotent_key' => $key,
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertJsonPath('code', $code);
|
||||
});
|
||||
|
||||
test('transfer in disabled returns 1004', function () {
|
||||
LotterySettings::put('wallet.transfer_in_enabled', false, 'wallet');
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'u5',
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$code = ErrorCode::WalletTransferInDisabled->value;
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer dev:'.$player->id,
|
||||
'Accept-Language' => 'en',
|
||||
])
|
||||
->postJson('/api/v1/wallet/transfer-in', [
|
||||
'amount' => 10,
|
||||
'idempotent_key' => 'disabled-test',
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertJsonPath('code', $code);
|
||||
});
|
||||
|
||||
test('transfer in below min amount returns 1003', function () {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'u-min',
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$code = ErrorCode::WalletAmountExceedsLimit->value;
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer dev:'.$player->id,
|
||||
'X-Locale' => 'zh',
|
||||
])
|
||||
->postJson('/api/v1/wallet/transfer-in', [
|
||||
'amount' => 50,
|
||||
'idempotent_key' => 'below-min',
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertJsonPath('code', $code);
|
||||
});
|
||||
|
||||
test('transfer in http 504 marks order pending reconcile', function () {
|
||||
Http::fake([
|
||||
'fake-main.test/*' => Http::response([], 504),
|
||||
]);
|
||||
config(['lottery.main_site.wallet_api_url' => 'http://fake-main.test']);
|
||||
config(['lottery.main_site.wallet_debit_path' => 'debit']);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'u504',
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$key = 'idem-504-'.uniqid('', true);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/wallet/transfer-in', [
|
||||
'amount' => 500,
|
||||
'currency' => 'NPR',
|
||||
'idempotent_key' => $key,
|
||||
])
|
||||
->assertStatus(409)
|
||||
->assertJsonPath('code', ErrorCode::WalletTransferPending->value);
|
||||
|
||||
$order = TransferOrder::query()->where('idempotent_key', $key)->first();
|
||||
expect($order?->status)->toBe('pending_reconcile')
|
||||
->and($order->external_request_payload['currency_code'])->toBe('NPR')
|
||||
->and($order->external_request_payload['_meta']['path'] ?? null)->toBe('/debit');
|
||||
expect(WalletTxn::query()->where('player_id', $player->id)->count())->toBe(0);
|
||||
});
|
||||
Reference in New Issue
Block a user