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

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Player;
use App\Http\Controllers\Controller;
use App\Models\Player;
use App\Models\PlayerWallet;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
/**
* 后台:按玩家查询钱包余额(`player_wallets` 全币种)。
*
* 路由:`GET /api/v1/admin/players/{player}/wallets`
*/
final class PlayerWalletShowController extends Controller
{
public function __invoke(Player $player): JsonResponse
{
$wallets = PlayerWallet::query()
->where('player_id', $player->id)
->orderBy('wallet_type')
->orderBy('currency_code')
->get();
$rows = $wallets->map(static function (PlayerWallet $w): array {
$bal = (int) $w->balance;
$frozen = (int) $w->frozen_balance;
return [
'id' => $w->id,
'wallet_type' => $w->wallet_type,
'currency_code' => $w->currency_code,
'balance' => $bal,
'frozen_balance' => $frozen,
'available_balance' => max(0, $bal - $frozen),
'status' => (int) $w->status,
'version' => (int) $w->version,
];
})->values()->all();
return ApiResponse::success([
'player' => [
'id' => $player->id,
'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id,
'username' => $player->username,
'nickname' => $player->nickname,
'default_currency' => $player->default_currency,
'status' => (int) $player->status,
],
'wallets' => $rows,
]);
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Wallet;
use App\Http\Controllers\Controller;
use App\Models\TransferOrder;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 后台:转账单列表(主站 彩票 {@see transfer_orders})。
* 检索口径对齐《01-界面文档》§5.11「钱包流水与对账」转账侧扩展。
*
* Query
* - `page`(默认 1
* - `per_page` `size`(每页条数,默认 20,最大 100
* - `player_id`(可选,按玩家主键)
* - `player_account`(可选,模糊匹配 `players.site_player_id` / `username`;与 `player_id` 同时传时以 `player_id` 为准)
* - `transfer_no`(可选,模糊匹配本地单号)
* - `external_ref_no`(可选,模糊匹配主站流水号)
* - `created_from` / `created_to`(可选,`Y-m-d`,筛选创建时间)
* - `status`(可选,逗号分隔:`processing`,`success`,`failed`,`pending_reconcile`
* - `abnormal=1`仅看异常processing / failed / pending_reconcile `status` 同时出现时优先)
*/
final class TransferOrderListController extends Controller
{
private const ALLOWED_STATUS = ['processing', 'success', 'failed', 'pending_reconcile'];
public function __invoke(Request $request): JsonResponse
{
$validated = validator($request->query(), [
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
'size' => ['sometimes', 'integer', 'min:1', 'max:100'],
'player_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'player_account' => ['sometimes', 'nullable', 'string', 'max:128'],
'transfer_no' => ['sometimes', 'nullable', 'string', 'max:96'],
'external_ref_no' => ['sometimes', 'nullable', 'string', 'max:96'],
'created_from' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
'created_to' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
'status' => ['sometimes', 'nullable', 'string', 'max:256'],
])->validate();
$perPage = (int) ($validated['per_page'] ?? $validated['size'] ?? 20);
$perPage = min(100, max(1, $perPage));
$page = max(1, (int) ($validated['page'] ?? 1));
$query = TransferOrder::query()
->with(['player:id,site_code,site_player_id,username,nickname'])
->orderByDesc('id');
if (! empty($validated['player_id'])) {
$query->where('player_id', (int) $validated['player_id']);
} elseif (! empty($validated['player_account'])) {
$term = '%'.addcslashes(trim($validated['player_account']), '%_\\').'%';
$query->whereHas('player', function ($q) use ($term): void {
$q->where('site_player_id', 'like', $term)
->orWhere('username', 'like', $term);
});
}
if (! empty($validated['transfer_no'])) {
$q = '%'.addcslashes(trim($validated['transfer_no']), '%_\\').'%';
$query->where('transfer_no', 'like', $q);
}
if (! empty($validated['external_ref_no'])) {
$q = '%'.addcslashes(trim($validated['external_ref_no']), '%_\\').'%';
$query->where('external_ref_no', 'like', $q);
}
if (! empty($validated['created_from'])) {
$query->where('created_at', '>=', $validated['created_from'].' 00:00:00');
}
if (! empty($validated['created_to'])) {
$query->where('created_at', '<=', $validated['created_to'].' 23:59:59');
}
if ($request->boolean('abnormal')) {
$query->whereIn('status', ['processing', 'failed', 'pending_reconcile']);
} elseif (! empty($validated['status'])) {
$parts = array_filter(array_map('trim', explode(',', (string) $validated['status'])));
$statuses = array_values(array_intersect($parts, self::ALLOWED_STATUS));
if ($statuses !== []) {
$query->whereIn('status', $statuses);
}
}
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
$items = $paginator->getCollection()->map(fn (TransferOrder $o) => $this->formatRow($o));
return ApiResponse::success([
'items' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
]);
}
/**
* @return array<string, mixed>
*/
private function formatRow(TransferOrder $o): array
{
$p = $o->player;
return [
'id' => $o->id,
'transfer_no' => $o->transfer_no,
'player_id' => $o->player_id,
'site_code' => $p?->site_code,
'site_player_id' => $p?->site_player_id,
'username' => $p?->username,
'nickname' => $p?->nickname,
'direction' => $o->direction,
'currency_code' => $o->currency_code,
'amount' => (int) $o->amount,
'idempotent_key' => $o->idempotent_key,
'status' => $o->status,
'external_ref_no' => $o->external_ref_no,
'external_request_payload' => $o->external_request_payload,
'external_response_payload' => $o->external_response_payload,
'fail_reason' => $o->fail_reason,
'created_at' => $o->created_at?->toIso8601String(),
'finished_at' => $o->finished_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Wallet;
use App\Http\Controllers\Controller;
use App\Models\WalletTxn;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 后台:彩票钱包流水列表 {@see wallet_txns}
* 列表字段对齐《01-界面文档》§5.11`updated_at` 作「完成时间」展示(`created_at` 为请求时间)。
*
* Query
* - `page``per_page` / `size`
* - `player_id`(可选)
* - `player_account`(可选,模糊匹配 `site_player_id` / `username`;与 `player_id` 同时传时以 `player_id` 为准)
* - `txn_no`(可选,模糊匹配流水号)
* - `external_ref_no`(可选,模糊匹配主站流水号)
* - `created_from` / `created_to`(可选,`Y-m-d`,按 `created_at`
* - `biz_type`(可选)
* - `status`(可选,逗号:`posted`,`pending_reconcile`
* - `abnormal=1`(仅 `pending_reconcile`,优先于 `status`
*/
final class WalletTransactionListController extends Controller
{
private const ALLOWED_STATUS = ['posted', 'pending_reconcile'];
public function __invoke(Request $request): JsonResponse
{
$validated = validator($request->query(), [
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
'size' => ['sometimes', 'integer', 'min:1', 'max:100'],
'player_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'player_account' => ['sometimes', 'nullable', 'string', 'max:128'],
'txn_no' => ['sometimes', 'nullable', 'string', 'max:96'],
'external_ref_no' => ['sometimes', 'nullable', 'string', 'max:96'],
'created_from' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
'created_to' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
'biz_type' => ['sometimes', 'nullable', 'string', 'max:64'],
'status' => ['sometimes', 'nullable', 'string', 'max:128'],
])->validate();
$perPage = (int) ($validated['per_page'] ?? $validated['size'] ?? 20);
$perPage = min(100, max(1, $perPage));
$page = max(1, (int) ($validated['page'] ?? 1));
$query = WalletTxn::query()
->with(['player:id,site_code,site_player_id,username,nickname'])
->orderByDesc('id');
if (! empty($validated['player_id'])) {
$query->where('player_id', (int) $validated['player_id']);
} elseif (! empty($validated['player_account'])) {
$term = '%'.addcslashes(trim($validated['player_account']), '%_\\').'%';
$query->whereHas('player', function ($q) use ($term): void {
$q->where('site_player_id', 'like', $term)
->orWhere('username', 'like', $term);
});
}
if (! empty($validated['txn_no'])) {
$q = '%'.addcslashes(trim($validated['txn_no']), '%_\\').'%';
$query->where('txn_no', 'like', $q);
}
if (! empty($validated['external_ref_no'])) {
$q = '%'.addcslashes(trim($validated['external_ref_no']), '%_\\').'%';
$query->where('external_ref_no', 'like', $q);
}
if (! empty($validated['created_from'])) {
$query->where('created_at', '>=', $validated['created_from'].' 00:00:00');
}
if (! empty($validated['created_to'])) {
$query->where('created_at', '<=', $validated['created_to'].' 23:59:59');
}
if (! empty($validated['biz_type'])) {
$query->where('biz_type', trim((string) $validated['biz_type']));
}
if ($request->boolean('abnormal')) {
$query->where('status', 'pending_reconcile');
} elseif (! empty($validated['status'])) {
$parts = array_filter(array_map('trim', explode(',', (string) $validated['status'])));
$statuses = array_values(array_intersect($parts, self::ALLOWED_STATUS));
if ($statuses !== []) {
$query->whereIn('status', $statuses);
}
}
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
$items = $paginator->getCollection()->map(fn (WalletTxn $t) => $this->formatRow($t));
return ApiResponse::success([
'items' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
]);
}
/**
* @return array<string, mixed>
*/
private function formatRow(WalletTxn $t): array
{
$p = $t->player;
return [
'id' => $t->id,
'txn_no' => $t->txn_no,
'player_id' => $t->player_id,
'site_code' => $p?->site_code,
'site_player_id' => $p?->site_player_id,
'username' => $p?->username,
'wallet_id' => $t->wallet_id,
'biz_type' => $t->biz_type,
'biz_no' => $t->biz_no,
'direction' => (int) $t->direction,
'amount' => (int) $t->amount,
'balance_before' => (int) $t->balance_before,
'balance_after' => (int) $t->balance_after,
'status' => $t->status,
'external_ref_no' => $t->external_ref_no,
'idempotent_key' => $t->idempotent_key,
'remark' => $t->remark,
'created_at' => $t->created_at?->toIso8601String(),
/** 展示用「完成时间」;无业务终态时可与 created_at 相同 */
'updated_at' => $t->updated_at?->toIso8601String(),
];
}
}