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

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

View File

@@ -0,0 +1,174 @@
<?php
namespace App\Http\Controllers\Api\V1\Wallet;
use App\Http\Controllers\Controller;
use App\Models\TransferOrder;
use App\Models\WalletTxn;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
/**
* PRD §10.1.1`GET /api/v1/wallet/logs` 钱包流水。
*
* Query`page``size`(每页条数,默认 20)、`type`逗号分隔transfer_in,transfer_out,bet,prize,refund
*/
class WalletLogsController extends Controller
{
/** PRD 对外类型 → 本地 biz_type */
private const TYPE_TO_BIZ = [
'transfer_in' => ['transfer_in'],
'transfer_out' => ['transfer_out'],
'refund' => ['transfer_out_refund'],
'bet' => ['bet'],
'prize' => ['prize'],
];
public function __invoke(Request $request): JsonResponse
{
$player = $request->lotteryPlayer();
abort_if($player === null, 500, 'lottery_player missing');
$perPage = min(100, max(1, (int) $request->query('size', $request->query('per_page', 20))));
$page = max(1, (int) $request->query('page', 1));
$pendingPayload = $this->pendingReconcilePayload((int) $player->id);
$bizFilter = $this->resolveBizTypeFilter((string) $request->query('type', ''));
if (is_array($bizFilter) && $bizFilter === []) {
return ApiResponse::success([
'items' => [],
'total' => 0,
'page' => $page,
'per_page' => $perPage,
'pending_reconcile' => $pendingPayload,
]);
}
$query = WalletTxn::query()
->where('player_id', $player->id)
->with('wallet')
->orderByDesc('id');
if ($bizFilter !== null) {
$query->whereIn('biz_type', $bizFilter);
}
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
$items = $paginator->getCollection()->map(fn (WalletTxn $txn) => $this->formatTxn($txn));
return ApiResponse::success([
'items' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'pending_reconcile' => $pendingPayload,
]);
}
/**
* @return list<array<string, mixed>>
*/
private function pendingReconcilePayload(int $playerId): array
{
return TransferOrder::query()
->where('player_id', $playerId)
->where('status', 'pending_reconcile')
->orderByDesc('id')
->limit(50)
->get()
->map(fn (TransferOrder $o) => $this->formatPendingOrder($o))
->all();
}
/**
* @return list<string>|null null 表示不过滤;空列表表示过滤后无合法 type结果应为空
*/
private function resolveBizTypeFilter(string $raw): ?array
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
$parts = array_filter(array_map('trim', explode(',', $raw)));
if ($parts === []) {
return null;
}
$biz = [];
foreach ($parts as $p) {
$key = Str::lower($p);
if (! isset(self::TYPE_TO_BIZ[$key])) {
continue;
}
foreach (self::TYPE_TO_BIZ[$key] as $b) {
$biz[] = $b;
}
}
return array_values(array_unique($biz));
}
/**
* @return array<string, mixed>
*/
private function formatTxn(WalletTxn $txn): array
{
$currency = $txn->wallet?->currency_code ?? '';
return [
'log_id' => $txn->txn_no,
'type' => $this->bizToPublicType((string) $txn->biz_type),
'biz_type' => $txn->biz_type,
'amount' => $this->signedAmount($txn),
'amount_abs' => (int) $txn->amount,
'direction' => (int) $txn->direction === 1 ? 'in' : 'out',
'currency_code' => $currency,
'balance_after' => (int) $txn->balance_after,
'ref_id' => $txn->biz_no,
'idempotent_key' => $txn->idempotent_key,
'external_ref_no' => $txn->external_ref_no,
'status' => $txn->status,
'remark' => $txn->remark,
'created_at' => $txn->created_at?->toIso8601String(),
];
}
private function bizToPublicType(string $biz): string
{
return match ($biz) {
'transfer_out_refund' => 'refund',
default => $biz,
};
}
private function signedAmount(WalletTxn $txn): int
{
$a = (int) $txn->amount;
return (int) $txn->direction === 1 ? $a : -$a;
}
/**
* @return array<string, mixed>
*/
private function formatPendingOrder(TransferOrder $order): array
{
return [
'transfer_no' => $order->transfer_no,
'direction' => $order->direction,
'type' => $order->direction === 'in' ? 'transfer_in' : 'transfer_out',
'currency_code' => $order->currency_code,
'amount' => (int) $order->amount,
'status' => $order->status,
'fail_reason' => $order->fail_reason,
'idempotent_key' => $order->idempotent_key,
'created_at' => $order->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Api\V1\Wallet;
use App\Exceptions\WalletOperationException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Wallet\WalletTransferRequest;
use App\Lottery\ErrorCode;
use App\Models\Player;
use App\Services\Wallet\LotteryTransferService;
use App\Support\ApiResponse;
use App\Support\LotteryMessage;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 转入:主站扣款 彩票钱包加款。
*
* `POST /api/v1/wallet/transfer-in` body JSON`amount`(最小货币单位整数), `idempotent_key`, `currency`(可选)
*/
class WalletTransferInController extends Controller
{
public function __construct(
private readonly LotteryTransferService $transferService,
) {}
public function __invoke(WalletTransferRequest $request): JsonResponse
{
$player = $request->lotteryPlayer();
abort_if($player === null, 500, 'lottery_player missing');
$currency = $this->resolveCurrencyCode($request, $player);
if ($currency instanceof JsonResponse) {
return $currency;
}
try {
$data = $this->transferService->transferIn(
$player,
$currency,
(int) $request->validated('amount'),
(string) $request->validated('idempotent_key'),
);
} catch (WalletOperationException $e) {
return ApiResponse::error(
LotteryMessage::wallet($request, $e->lotteryCode),
$e->lotteryCode,
null,
$e->httpStatus,
);
}
return ApiResponse::success($data);
}
private function resolveCurrencyCode(Request $request, Player $player): string|JsonResponse
{
$raw = $request->input('currency');
if (is_string($raw) && $raw !== '') {
$code = strtoupper(substr(trim($raw), 0, 16));
} else {
$fallback = $player->default_currency ?? config('lottery.default_currency', 'NPR');
$code = strtoupper(substr(trim((string) $fallback), 0, 16));
}
if (! preg_match('/^[A-Z0-9]{1,16}$/', $code)) {
return ApiResponse::error(
__('wallet.invalid_currency'),
ErrorCode::WalletInvalidCurrency->value,
null,
400,
);
}
return $code;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Api\V1\Wallet;
use App\Exceptions\WalletOperationException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Wallet\WalletTransferRequest;
use App\Lottery\ErrorCode;
use App\Models\Player;
use App\Services\Wallet\LotteryTransferService;
use App\Support\ApiResponse;
use App\Support\LotteryMessage;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 转出:彩票钱包扣款 主站加款;主站失败则冲正彩票余额。
*
* `POST /api/v1/wallet/transfer-out`
*/
class WalletTransferOutController extends Controller
{
public function __construct(
private readonly LotteryTransferService $transferService,
) {}
public function __invoke(WalletTransferRequest $request): JsonResponse
{
$player = $request->lotteryPlayer();
abort_if($player === null, 500, 'lottery_player missing');
$currency = $this->resolveCurrencyCode($request, $player);
if ($currency instanceof JsonResponse) {
return $currency;
}
try {
$data = $this->transferService->transferOut(
$player,
$currency,
(int) $request->validated('amount'),
(string) $request->validated('idempotent_key'),
);
} catch (WalletOperationException $e) {
return ApiResponse::error(
LotteryMessage::wallet($request, $e->lotteryCode),
$e->lotteryCode,
null,
$e->httpStatus,
);
}
return ApiResponse::success($data);
}
private function resolveCurrencyCode(Request $request, Player $player): string|JsonResponse
{
$raw = $request->input('currency');
if (is_string($raw) && $raw !== '') {
$code = strtoupper(substr(trim($raw), 0, 16));
} else {
$fallback = $player->default_currency ?? config('lottery.default_currency', 'NPR');
$code = strtoupper(substr(trim((string) $fallback), 0, 16));
}
if (! preg_match('/^[A-Z0-9]{1,16}$/', $code)) {
return ApiResponse::error(
__('wallet.invalid_currency'),
ErrorCode::WalletInvalidCurrency->value,
null,
400,
);
}
return $code;
}
}