diff --git a/app/Exceptions/WalletOperationException.php b/app/Exceptions/WalletOperationException.php new file mode 100644 index 0000000..7a442b0 --- /dev/null +++ b/app/Exceptions/WalletOperationException.php @@ -0,0 +1,23 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php new file mode 100644 index 0000000..904f17c --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php @@ -0,0 +1,130 @@ +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 + */ + 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(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php b/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php new file mode 100644 index 0000000..7e57568 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php @@ -0,0 +1,137 @@ +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 + */ + 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(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Player/MeController.php b/app/Http/Controllers/Api/V1/Player/MeController.php index 3990c2f..a50e9f1 100644 --- a/app/Http/Controllers/Api/V1/Player/MeController.php +++ b/app/Http/Controllers/Api/V1/Player/MeController.php @@ -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(), ]); } } diff --git a/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php b/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php index b469ffc..77ad56c 100644 --- a/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php +++ b/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php @@ -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, ]); } diff --git a/app/Http/Controllers/Api/V1/Wallet/WalletLogsController.php b/app/Http/Controllers/Api/V1/Wallet/WalletLogsController.php new file mode 100644 index 0000000..72a903e --- /dev/null +++ b/app/Http/Controllers/Api/V1/Wallet/WalletLogsController.php @@ -0,0 +1,174 @@ + ['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> + */ + 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|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 + */ + 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 + */ + 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(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Wallet/WalletTransferInController.php b/app/Http/Controllers/Api/V1/Wallet/WalletTransferInController.php new file mode 100644 index 0000000..0068a1d --- /dev/null +++ b/app/Http/Controllers/Api/V1/Wallet/WalletTransferInController.php @@ -0,0 +1,77 @@ +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; + } +} diff --git a/app/Http/Controllers/Api/V1/Wallet/WalletTransferOutController.php b/app/Http/Controllers/Api/V1/Wallet/WalletTransferOutController.php new file mode 100644 index 0000000..00798ec --- /dev/null +++ b/app/Http/Controllers/Api/V1/Wallet/WalletTransferOutController.php @@ -0,0 +1,77 @@ +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; + } +} diff --git a/app/Http/Requests/Wallet/WalletTransferRequest.php b/app/Http/Requests/Wallet/WalletTransferRequest.php new file mode 100644 index 0000000..d9bec8c --- /dev/null +++ b/app/Http/Requests/Wallet/WalletTransferRequest.php @@ -0,0 +1,28 @@ + + */ + public function rules(): array + { + return [ + 'amount' => ['required', 'integer', 'min:1'], + 'currency' => ['sometimes', 'nullable', 'string', 'max:16'], + 'idempotent_key' => ['required', 'string', 'max:64'], + ]; + } +} diff --git a/app/Lottery/ErrorCode.php b/app/Lottery/ErrorCode.php index baacc3d..0bb5750 100644 --- a/app/Lottery/ErrorCode.php +++ b/app/Lottery/ErrorCode.php @@ -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 无效 */ diff --git a/app/Models/Currency.php b/app/Models/Currency.php new file mode 100644 index 0000000..c6573be --- /dev/null +++ b/app/Models/Currency.php @@ -0,0 +1,25 @@ + 'boolean', + 'is_bettable' => 'boolean', + ]; + } +} diff --git a/app/Models/TransferOrder.php b/app/Models/TransferOrder.php new file mode 100644 index 0000000..927d5b3 --- /dev/null +++ b/app/Models/TransferOrder.php @@ -0,0 +1,40 @@ + 'integer', + 'external_request_payload' => 'array', + 'external_response_payload' => 'array', + 'finished_at' => 'datetime', + ]; + } + + public function player(): BelongsTo + { + return $this->belongsTo(Player::class); + } +} diff --git a/app/Models/WalletTxn.php b/app/Models/WalletTxn.php new file mode 100644 index 0000000..1233432 --- /dev/null +++ b/app/Models/WalletTxn.php @@ -0,0 +1,46 @@ + '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'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2b9490f..03b5eb1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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; + }); } /** diff --git a/app/Services/PlayerTokenResolver.php b/app/Services/PlayerTokenResolver.php index c0f1367..ead3ade 100644 --- a/app/Services/PlayerTokenResolver.php +++ b/app/Services/PlayerTokenResolver.php @@ -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, + ); + } } } diff --git a/app/Services/Wallet/HttpMainSiteWalletGateway.php b/app/Services/Wallet/HttpMainSiteWalletGateway.php new file mode 100644 index 0000000..aa27b0e --- /dev/null +++ b/app/Services/Wallet/HttpMainSiteWalletGateway.php @@ -0,0 +1,150 @@ +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'); + } +} diff --git a/app/Services/Wallet/LotteryTransferService.php b/app/Services/Wallet/LotteryTransferService.php new file mode 100644 index 0000000..7d8c56b --- /dev/null +++ b/app/Services/Wallet/LotteryTransferService.php @@ -0,0 +1,601 @@ + + */ + 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 + */ + 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 + */ + 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 + */ + 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, + ); + } +} diff --git a/app/Services/Wallet/MainSiteWalletGateway.php b/app/Services/Wallet/MainSiteWalletGateway.php new file mode 100644 index 0000000..0409875 --- /dev/null +++ b/app/Services/Wallet/MainSiteWalletGateway.php @@ -0,0 +1,33 @@ +|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); + } +} diff --git a/app/Services/Wallet/StubMainSiteWalletGateway.php b/app/Services/Wallet/StubMainSiteWalletGateway.php new file mode 100644 index 0000000..c5ff6fb --- /dev/null +++ b/app/Services/Wallet/StubMainSiteWalletGateway.php @@ -0,0 +1,65 @@ + 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 + */ + 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, + ], + ]; + } +} diff --git a/app/Support/LotteryMessage.php b/app/Support/LotteryMessage.php index 468f6ef..992c81f 100644 --- a/app/Support/LotteryMessage.php +++ b/app/Support/LotteryMessage.php @@ -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 { diff --git a/config/lottery.php b/config/lottery.php index bb89922..cfbccf8 100644 --- a/config/lottery.php +++ b/config/lottery.php @@ -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), diff --git a/database/seeders/LotterySettingsSeeder.php b/database/seeders/LotterySettingsSeeder.php index f138371..d91335d 100644 --- a/database/seeders/LotterySettingsSeeder.php +++ b/database/seeders/LotterySettingsSeeder.php @@ -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', diff --git a/lang/en/sso.php b/lang/en/sso.php index aa7d0f2..1e2835d 100644 --- a/lang/en/sso.php +++ b/lang/en/sso.php @@ -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', ]; diff --git a/lang/en/wallet.php b/lang/en/wallet.php index ef9eff4..534b04c 100644 --- a/lang/en/wallet.php +++ b/lang/en/wallet.php @@ -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', ]; diff --git a/lang/ne/sso.php b/lang/ne/sso.php index 7453bf1..c4796bb 100644 --- a/lang/ne/sso.php +++ b/lang/ne/sso.php @@ -8,4 +8,5 @@ return [ '8002' => 'टोकन अमान्य वा समयावधि सकियो', '8003' => 'खेलाडी दर्ता छैन', '8004' => 'SSO गोप्य सेट छैन', + '8005' => 'खाता निलम्बित वा लगइन असक्षम', ]; diff --git a/lang/ne/wallet.php b/lang/ne/wallet.php index 43e68c3..75c9c51 100644 --- a/lang/ne/wallet.php +++ b/lang/ne/wallet.php @@ -2,4 +2,15 @@ return [ 'invalid_currency' => 'मुद्रा कोड अमान्य', + + '1001' => 'लटरी वालेट ब्यालेन्स अपर्याप्त', + '1002' => 'अघिल्लो स्थानान्तरण अझै प्रक्रियामा छ, पछि प्रयास गर्नुहोस्', + '1003' => 'रकम सीमा नाघ्यो', + '1004' => 'जम्मा अस्थायी रूपमा बन्द छ', + '1005' => 'मुद्रा अमान्य वा असक्षम', + '1006' => 'निकासी अस्थायी रूपमा बन्द छ', + '1007' => 'लटरी वालेट फ्रोजन छ', + '1008' => 'रकम अमान्य', + '1009' => 'मुख्य वालेट असफल, पछि प्रयास गर्नुहोस्', + '1010' => 'एउटै कुञ्जी दोहोर्याउनुहुन्न', ]; diff --git a/lang/zh/sso.php b/lang/zh/sso.php index 30d728a..1742825 100644 --- a/lang/zh/sso.php +++ b/lang/zh/sso.php @@ -8,4 +8,5 @@ return [ '8002' => '令牌无效或已过期', '8003' => '玩家未建档', '8004' => '未配置 SSO 密钥', + '8005' => '账号已冻结或暂时无法登录', ]; diff --git a/lang/zh/wallet.php b/lang/zh/wallet.php index be32209..8398480 100644 --- a/lang/zh/wallet.php +++ b/lang/zh/wallet.php @@ -2,4 +2,15 @@ return [ 'invalid_currency' => '币种参数不合法', + + '1001' => '彩票钱包余额不足', + '1002' => '上一笔转账仍在处理中,请稍后重试', + '1003' => '单笔金额超出允许范围', + '1004' => '转入功能已暂时关闭', + '1005' => '币种无效或未启用', + '1006' => '转出功能已暂时关闭', + '1007' => '彩票钱包已冻结,无法划转', + '1008' => '金额无效,请输入正整数(最小货币单位)', + '1009' => '主站钱包处理失败,请稍后重试', + '1010' => '请勿重复使用幂等键发起不同金额的转账', ]; diff --git a/routes/api.php b/routes/api.php index 2c0fae0..36042c0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); }); }); }); diff --git a/tests/Feature/AdminWalletApiTest.php b/tests/Feature/AdminWalletApiTest.php new file mode 100644 index 0000000..feacfa5 --- /dev/null +++ b/tests/Feature/AdminWalletApiTest.php @@ -0,0 +1,205 @@ +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); +}); diff --git a/tests/Feature/PlayerFoundationTest.php b/tests/Feature/PlayerFoundationTest.php index 4269800..54e8d39 100644 --- a/tests/Feature/PlayerFoundationTest.php +++ b/tests/Feature/PlayerFoundationTest.php @@ -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')); +}); diff --git a/tests/Feature/WalletBalanceTest.php b/tests/Feature/WalletBalanceTest.php index e0eaf41..66d3d7b 100644 --- a/tests/Feature/WalletBalanceTest.php +++ b/tests/Feature/WalletBalanceTest.php @@ -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') diff --git a/tests/Feature/WalletLogsTest.php b/tests/Feature/WalletLogsTest.php new file mode 100644 index 0000000..fb7c5b4 --- /dev/null +++ b/tests/Feature/WalletLogsTest.php @@ -0,0 +1,82 @@ + 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'); +}); diff --git a/tests/Feature/WalletTransferTest.php b/tests/Feature/WalletTransferTest.php new file mode 100644 index 0000000..aa8b68e --- /dev/null +++ b/tests/Feature/WalletTransferTest.php @@ -0,0 +1,245 @@ + 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); +});