feat: 增强玩家 API,新增 locale 和时间字段,更新钱包 API 以支持可用余额计算,添加错误码与多语言支持

This commit is contained in:
2026-05-09 15:05:46 +08:00
parent f1b38ef421
commit a0f86a4e36
36 changed files with 2523 additions and 34 deletions

View File

@@ -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 80018005
*/
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_atdev 仅在此处写入
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,
);
}
}
}

View 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');
}
}

View 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,
);
}
}

View 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;
}

View 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);
}
}

View 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,
],
];
}
}