feat(core): harden sessions settlement and credit activity
This commit is contained in:
@@ -67,6 +67,9 @@ LOTTERY_DRAW_INTERVAL_MINUTES=5
|
||||
LOTTERY_DRAW_BETTING_WINDOW_SECONDS=270
|
||||
LOTTERY_DRAW_CLOSE_BEFORE_SECONDS=30
|
||||
LOTTERY_DRAW_BUFFER_AHEAD=8
|
||||
# 自动派彩失败后按 60s、120s、240s... 指数退避,最长每小时重试一次
|
||||
LOTTERY_AUTO_PAYOUT_RETRY_BASE_SECONDS=60
|
||||
LOTTERY_AUTO_PAYOUT_RETRY_MAX_SECONDS=3600
|
||||
LOTTERY_PLAYER_AUTH_DEV_BYPASS=false
|
||||
ADMIN_API_TOKEN_TTL_DAYS=7
|
||||
|
||||
|
||||
53
app/Events/PlayerSessionReplacedBroadcast.php
Normal file
53
app/Events/PlayerSessionReplacedBroadcast.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
|
||||
/** 通知旧客户端:同一原生玩家账号已有较新的登录会话。 */
|
||||
final class PlayerSessionReplacedBroadcast implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public string $queue = 'broadcasts';
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 10;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $playerId,
|
||||
public readonly int $sessionVersion,
|
||||
public readonly int $emittedAtMs,
|
||||
) {}
|
||||
|
||||
public function retryUntil(): \DateTimeInterface
|
||||
{
|
||||
return now()->addSeconds(30);
|
||||
}
|
||||
|
||||
/** @return array<int, PrivateChannel> */
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('player.'.$this->playerId)];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'session.replaced';
|
||||
}
|
||||
|
||||
/** @return array{player_id: int, session_version: int, emitted_at_ms: int} */
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'player_id' => $this->playerId,
|
||||
'session_version' => $this->sessionVersion,
|
||||
'emitted_at_ms' => $this->emittedAtMs,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Support\Str;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Support\AdminAuthProfile;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Services\AdminCaptchaService;
|
||||
@@ -37,38 +38,59 @@ final class LoginController extends Controller
|
||||
|
||||
$normalizedAccount = Str::lower(trim($data['account']));
|
||||
|
||||
/** @var AdminUser|null $admin */
|
||||
$admin = AdminUser::query()->where('username', $normalizedAccount)->first();
|
||||
|
||||
$passwordOk = $admin !== null && Hash::check($data['password'], $admin->password);
|
||||
|
||||
if (! $passwordOk) {
|
||||
/** 统一措辞,弱化枚举用户 */
|
||||
return ApiResponse::error(
|
||||
trans('admin.invalid_credentials', [], $locale),
|
||||
ErrorCode::AdminCredentialsInvalid->value,
|
||||
null,
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
if ((int) $admin->status !== 0) {
|
||||
return ApiResponse::error(
|
||||
trans('admin.account_disabled', [], $locale),
|
||||
ErrorCode::AdminAccountDisabled->value,
|
||||
null,
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
$ttlDays = (int) config('lottery.admin_api.token_ttl_days', 7);
|
||||
$plainToken = $admin->createToken(
|
||||
'admin-api',
|
||||
['*'],
|
||||
now()->addDays(max(1, $ttlDays)),
|
||||
)->plainTextToken;
|
||||
|
||||
$admin->forceFill(['last_login_at' => now()])->save();
|
||||
/**
|
||||
* 串行化同账号登录,确保并发登录也只有版本号最大的会话有效。
|
||||
* 不立即删除旧 Token,旧端下一次请求时才能收到明确的 8115 业务码。
|
||||
*/
|
||||
$loginResult = DB::transaction(function () use ($normalizedAccount, $data, $ttlDays, $locale): array|JsonResponse {
|
||||
/** @var AdminUser|null $lockedAdmin */
|
||||
$lockedAdmin = AdminUser::query()
|
||||
->where('username', $normalizedAccount)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($lockedAdmin === null || ! Hash::check($data['password'], $lockedAdmin->password)) {
|
||||
/** 统一措辞,弱化枚举用户 */
|
||||
return ApiResponse::error(
|
||||
trans('admin.invalid_credentials', [], $locale),
|
||||
ErrorCode::AdminCredentialsInvalid->value,
|
||||
null,
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
if ((int) $lockedAdmin->status !== 0) {
|
||||
return ApiResponse::error(
|
||||
trans('admin.account_disabled', [], $locale),
|
||||
ErrorCode::AdminAccountDisabled->value,
|
||||
null,
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
$sessionVersion = (int) $lockedAdmin->admin_session_version + 1;
|
||||
$lockedAdmin->forceFill([
|
||||
'admin_session_version' => $sessionVersion,
|
||||
'last_login_at' => now(),
|
||||
])->save();
|
||||
|
||||
$token = $lockedAdmin->createToken(
|
||||
'admin-api',
|
||||
['admin-session:'.$sessionVersion],
|
||||
now()->addDays(max(1, $ttlDays)),
|
||||
)->plainTextToken;
|
||||
|
||||
return [$token, $lockedAdmin];
|
||||
});
|
||||
|
||||
if ($loginResult instanceof JsonResponse) {
|
||||
return $loginResult;
|
||||
}
|
||||
|
||||
[$plainToken, $admin] = $loginResult;
|
||||
|
||||
return ApiResponse::success([
|
||||
'token' => $plainToken,
|
||||
'token_type' => 'Bearer',
|
||||
|
||||
20
app/Http/Controllers/Api/V1/Admin/Auth/LogoutController.php
Normal file
20
app/Http/Controllers/Api/V1/Admin/Auth/LogoutController.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Auth;
|
||||
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
final class LogoutController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$request->lotteryAdmin()->tokens()
|
||||
->where('name', 'admin-api')
|
||||
->delete();
|
||||
|
||||
return ApiResponse::success(['logged_out' => true]);
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,18 @@
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\AdminUser;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\AdminUser;
|
||||
use App\Support\ApiMessage;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\SettlementBatch;
|
||||
use App\Support\AdminScopePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\AdminDrawResponsePolicy;
|
||||
use App\Support\AdminScopePolicy;
|
||||
use App\Support\ApiMessage;
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/draws/{draw}/finance-summary — 单期投注/派彩汇总(客服/财务视角,PRD §15.4)。
|
||||
@@ -22,7 +23,7 @@ use App\Support\ApiMessage;
|
||||
*/
|
||||
final class AdminDrawFinanceSummaryController extends Controller
|
||||
{
|
||||
public function __invoke(\Illuminate\Http\Request $request, Draw $draw): JsonResponse
|
||||
public function __invoke(Request $request, Draw $draw): JsonResponse
|
||||
{
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if(! $admin instanceof AdminUser, 401);
|
||||
@@ -58,14 +59,19 @@ final class AdminDrawFinanceSummaryController extends Controller
|
||||
$approxHouseGrossMinor = $totalBetMinor - $totalPayoutMinor;
|
||||
|
||||
$batches = SettlementBatch::query()
|
||||
->with('resultBatch:id,provider_code,provider_name,result_version')
|
||||
->where('draw_id', $drawId)
|
||||
->orderByDesc('id')
|
||||
->limit(30)
|
||||
->get(['id', 'status', 'total_ticket_count', 'total_win_count', 'total_payout_amount', 'total_jackpot_payout_amount', 'finished_at']);
|
||||
->get(['id', 'result_batch_id', 'settle_version', 'status', 'total_ticket_count', 'total_win_count', 'total_payout_amount', 'total_jackpot_payout_amount', 'finished_at']);
|
||||
|
||||
$batchRows = $batches->map(static function (SettlementBatch $b): array {
|
||||
return [
|
||||
'id' => (int) $b->id,
|
||||
'provider_code' => $b->resultBatch?->provider_code,
|
||||
'provider_name' => $b->resultBatch?->provider_name,
|
||||
'result_version' => $b->resultBatch !== null ? (int) $b->resultBatch->result_version : null,
|
||||
'settle_version' => (int) $b->settle_version,
|
||||
'status' => $b->status,
|
||||
'total_ticket_count' => (int) $b->total_ticket_count,
|
||||
'total_win_count' => (int) $b->total_win_count,
|
||||
|
||||
@@ -5,18 +5,21 @@ namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||
use App\Models\Draw;
|
||||
use App\Models\AdminUser;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\ApiMessage;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Settlement\SettlementOrchestrator;
|
||||
use App\Services\Settlement\DrawSettlementStartService;
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/draws/{draw}/settlement/run — 对 `settling` 期号执行结算(可关自动结算时手工触发)。
|
||||
* POST /api/v1/admin/draws/{draw}/settlement/run — 冷静期可提前结算,结算中可幂等重试。
|
||||
*/
|
||||
final class DrawSettlementRunController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrawSettlementStartService $startService,
|
||||
private readonly SettlementOrchestrator $orchestrator,
|
||||
) {}
|
||||
|
||||
@@ -32,7 +35,13 @@ final class DrawSettlementRunController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
$ran = $this->orchestrator->trySettleDraw($draw);
|
||||
try {
|
||||
$started = $this->startService->start($draw);
|
||||
} catch (\RuntimeException $e) {
|
||||
return ApiMessage::runtimeErrorResponse($request, $e);
|
||||
}
|
||||
|
||||
$ran = $this->orchestrator->trySettleDraw($started['draw']);
|
||||
|
||||
$draw->refresh();
|
||||
|
||||
@@ -44,6 +53,8 @@ final class DrawSettlementRunController extends Controller
|
||||
'draw_no' => $draw->draw_no,
|
||||
'status' => $draw->status,
|
||||
'settle_version' => (int) $draw->settle_version,
|
||||
'cooldown_skipped' => $started['cooldown_skipped'],
|
||||
'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(),
|
||||
],
|
||||
409,
|
||||
);
|
||||
@@ -54,6 +65,8 @@ final class DrawSettlementRunController extends Controller
|
||||
'draw_no' => $draw->draw_no,
|
||||
'status' => $draw->status,
|
||||
'settle_version' => (int) $draw->settle_version,
|
||||
'cooldown_skipped' => $started['cooldown_skipped'],
|
||||
'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ namespace App\Http\Controllers\Api\V1\Ticket;
|
||||
use App\Models\Player;
|
||||
use App\Models\TicketItem;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Support\TicketItemListFilters;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Support\PaginationTrait;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Support\CurrencyFormatter;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\TicketItemListFilters;
|
||||
|
||||
/**
|
||||
* `GET /api/v1/ticket/items` — 我的注单(注项列表,支持 `draw_no` 筛选)。
|
||||
@@ -37,6 +37,7 @@ final class TicketItemsIndexController extends Controller
|
||||
$statusInput,
|
||||
))) : [];
|
||||
$number = trim((string) $request->query('number', ''));
|
||||
$ticketNo = trim((string) $request->query('ticket_no', ''));
|
||||
$orderNo = trim((string) $request->query('order_no', ''));
|
||||
$startDate = $this->normalizeDate((string) $request->query('start_date', ''));
|
||||
$endDate = $this->normalizeDate((string) $request->query('end_date', ''));
|
||||
@@ -62,6 +63,10 @@ final class TicketItemsIndexController extends Controller
|
||||
$query->whereHas('order', fn ($q) => $q->where('order_no', $orderNo));
|
||||
}
|
||||
|
||||
if ($ticketNo !== '') {
|
||||
$query->where('ticket_items.ticket_no', $ticketNo);
|
||||
}
|
||||
|
||||
$this->applyTicketItemNumberSearch($query, $number);
|
||||
$this->applyOrderPlacedDateRange($query, $startDate, $endDate);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\AdminUser;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
@@ -37,6 +38,25 @@ final class EnsureAdminApi
|
||||
);
|
||||
}
|
||||
|
||||
$accessToken = $user->currentAccessToken();
|
||||
if (
|
||||
(int) $user->admin_session_version > 0
|
||||
&& $accessToken instanceof PersonalAccessToken
|
||||
&& $accessToken->name === 'admin-api'
|
||||
) {
|
||||
$expectedAbility = 'admin-session:'.(int) $user->admin_session_version;
|
||||
$abilities = is_array($accessToken->abilities) ? $accessToken->abilities : [];
|
||||
|
||||
if (! in_array($expectedAbility, $abilities, true)) {
|
||||
return ApiResponse::error(
|
||||
trans('admin.session_replaced', [], $request->lotteryLocale()),
|
||||
ErrorCode::AdminSessionReplaced->value,
|
||||
null,
|
||||
401,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$request->attributes->set('lottery_admin', $user);
|
||||
|
||||
return $next($request);
|
||||
|
||||
@@ -127,6 +127,9 @@ enum ErrorCode: int
|
||||
/** 原生登录:验证码错误或过期 */
|
||||
case PlayerCaptchaInvalid = 8009;
|
||||
|
||||
/** 原生登录:当前会话已被同账号的较新登录替换 */
|
||||
case PlayerSessionReplaced = 8010;
|
||||
|
||||
/* ========== 8100–8199 管理端 API ========== */
|
||||
|
||||
/** 未登录或 Token 无效 */
|
||||
@@ -144,6 +147,9 @@ enum ErrorCode: int
|
||||
/** 已登录但无 RBAC 权限 */
|
||||
case AdminForbidden = 8114;
|
||||
|
||||
/** 当前管理端会话已被同账号的较新登录替换 */
|
||||
case AdminSessionReplaced = 8115;
|
||||
|
||||
/* ========== 9000–9999 系统 / 框架 ========== */
|
||||
|
||||
/** 表单或 Query 校验失败(ValidationException → 422) */
|
||||
|
||||
@@ -30,6 +30,7 @@ final class AdminUser extends Authenticatable
|
||||
'password',
|
||||
'status',
|
||||
'is_super_admin',
|
||||
'admin_session_version',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -44,6 +45,7 @@ final class AdminUser extends Authenticatable
|
||||
'last_login_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_super_admin' => 'boolean',
|
||||
'admin_session_version' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ final class Player extends Model
|
||||
'login_failed_count',
|
||||
'login_locked_until',
|
||||
'native_token_version',
|
||||
'native_session_version',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -42,6 +43,7 @@ final class Player extends Model
|
||||
'login_failed_count' => 'integer',
|
||||
'login_locked_until' => 'datetime',
|
||||
'native_token_version' => 'integer',
|
||||
'native_session_version' => 'integer',
|
||||
'risk_tags' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ final class PlayerCreditService
|
||||
}
|
||||
}
|
||||
|
||||
public function holdForBet(Player $player, int $amountMinor): void
|
||||
public function holdForBet(Player $player, int $amountMinor, ?int $ticketOrderId = null): void
|
||||
{
|
||||
if ($amountMinor <= 0) {
|
||||
return;
|
||||
@@ -155,8 +155,8 @@ final class PlayerCreditService
|
||||
'owner_id' => $player->id,
|
||||
'amount' => -$amountMinor,
|
||||
'reason' => 'bet_hold',
|
||||
'ref_type' => 'bet',
|
||||
'ref_id' => null,
|
||||
'ref_type' => $ticketOrderId !== null && $ticketOrderId > 0 ? 'ticket_order' : 'bet',
|
||||
'ref_id' => $ticketOrderId !== null && $ticketOrderId > 0 ? $ticketOrderId : null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
@@ -289,14 +289,14 @@ final class PlayerCreditService
|
||||
}
|
||||
}
|
||||
|
||||
public function assertMayPlaceBet(Player $player, int $amountMinor): void
|
||||
public function assertMayPlaceBet(Player $player, int $amountMinor, ?int $ticketOrderId = null): void
|
||||
{
|
||||
if (! PlayerFundingMode::usesCredit($player)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->assertCreditGuards($player);
|
||||
$this->holdForBet($player, $amountMinor);
|
||||
$this->holdForBet($player, $amountMinor, $ticketOrderId);
|
||||
}
|
||||
|
||||
private function assertCreditGuards(Player $player): void
|
||||
|
||||
@@ -6,7 +6,9 @@ use Firebase\JWT\JWT;
|
||||
use App\Models\Player;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Events\PlayerSessionReplacedBroadcast;
|
||||
use App\Exceptions\PlayerAuthenticationException;
|
||||
|
||||
final class PlayerNativeAuthService
|
||||
@@ -63,17 +65,62 @@ final class PlayerNativeAuthService
|
||||
);
|
||||
}
|
||||
|
||||
$player->forceFill([
|
||||
'login_failed_count' => 0,
|
||||
'login_locked_until' => null,
|
||||
'last_login_at' => now(),
|
||||
])->save();
|
||||
|
||||
$ttl = (int) config('lottery.player_auth.native.ttl_seconds', 28800);
|
||||
$token = $this->issueToken($player, $ttl);
|
||||
|
||||
/** @var array{token: string, player: Player, session_version: int} $login */
|
||||
$login = DB::transaction(function () use ($player, $password, $ttl): array {
|
||||
$locked = Player::query()->lockForUpdate()->find($player->id);
|
||||
if ($locked === null
|
||||
|| ! $locked->isLotteryNative()
|
||||
|| ! is_string($locked->password_hash)
|
||||
|| ! Hash::check($password, $locked->password_hash)) {
|
||||
throw new PlayerAuthenticationException(
|
||||
'账号或密码错误',
|
||||
ErrorCode::PlayerCredentialsInvalid->value,
|
||||
);
|
||||
}
|
||||
|
||||
if ($locked->login_locked_until !== null && $locked->login_locked_until->isFuture()) {
|
||||
throw new PlayerAuthenticationException(
|
||||
'登录已锁定',
|
||||
ErrorCode::PlayerLoginLocked->value,
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
if ((int) $locked->status !== 0) {
|
||||
throw new PlayerAuthenticationException(
|
||||
'账号已冻结',
|
||||
ErrorCode::PlayerAccountSuspended->value,
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
$sessionVersion = (int) ($locked->native_session_version ?? 0) + 1;
|
||||
$locked->forceFill([
|
||||
'login_failed_count' => 0,
|
||||
'login_locked_until' => null,
|
||||
'last_login_at' => now(),
|
||||
'native_session_version' => $sessionVersion,
|
||||
])->save();
|
||||
|
||||
return [
|
||||
'token' => $this->issueToken($locked, $ttl),
|
||||
'player' => $locked->refresh(),
|
||||
'session_version' => $sessionVersion,
|
||||
];
|
||||
});
|
||||
|
||||
event(new PlayerSessionReplacedBroadcast(
|
||||
(int) $login['player']->id,
|
||||
$login['session_version'],
|
||||
(int) floor(microtime(true) * 1000),
|
||||
));
|
||||
|
||||
$player = $login['player'];
|
||||
|
||||
return [
|
||||
'access_token' => $token,
|
||||
'access_token' => $login['token'],
|
||||
'expires_in' => $ttl,
|
||||
'token_type' => 'Bearer',
|
||||
'player' => [
|
||||
@@ -100,6 +147,7 @@ final class PlayerNativeAuthService
|
||||
$playerIdKey => (int) $player->id,
|
||||
$authKey => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'token_version' => (int) ($player->native_token_version ?? 0),
|
||||
'session_version' => (int) ($player->native_session_version ?? 0),
|
||||
'site_code' => (string) $player->site_code,
|
||||
'iat' => $now,
|
||||
'exp' => $now + $ttl,
|
||||
|
||||
@@ -192,6 +192,14 @@ final class PlayerTokenResolver
|
||||
throw new PlayerAuthenticationException('Token 已因密码变更失效', ErrorCode::PlayerTokenInvalid->value);
|
||||
}
|
||||
|
||||
$sessionVersion = (int) data_get($claims, 'session_version', 0);
|
||||
if ($sessionVersion !== (int) ($player->native_session_version ?? 0)) {
|
||||
throw new PlayerAuthenticationException(
|
||||
'当前会话已被同账号的较新登录替换',
|
||||
ErrorCode::PlayerSessionReplaced->value,
|
||||
);
|
||||
}
|
||||
|
||||
$player->forceFill(['last_login_at' => now()])->save();
|
||||
|
||||
return $player->refresh();
|
||||
|
||||
57
app/Services/Settlement/DrawSettlementStartService.php
Normal file
57
app/Services/Settlement/DrawSettlementStartService.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Settlement;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\DrawResultBatch;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
|
||||
/**
|
||||
* 将期号推进到可结算状态。
|
||||
*
|
||||
* 冷静期内的人工操作只跳过当前期剩余的核错窗口,不修改全局冷静期配置。
|
||||
*/
|
||||
final class DrawSettlementStartService
|
||||
{
|
||||
/**
|
||||
* @return array{draw: Draw, cooldown_skipped: bool}
|
||||
*/
|
||||
public function start(Draw $draw): array
|
||||
{
|
||||
return DB::transaction(function () use ($draw): array {
|
||||
/** @var Draw $locked */
|
||||
$locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
if (! in_array($locked->status, [
|
||||
DrawStatus::Cooldown->value,
|
||||
DrawStatus::Settling->value,
|
||||
], true)) {
|
||||
throw new \RuntimeException('draw_not_ready_for_settlement');
|
||||
}
|
||||
|
||||
$hasPublishedResult = DrawResultBatch::query()
|
||||
->where('draw_id', $locked->id)
|
||||
->where('status', DrawResultBatchStatus::Published->value)
|
||||
->exists();
|
||||
|
||||
if (! $hasPublishedResult) {
|
||||
throw new \RuntimeException('draw_result_not_published');
|
||||
}
|
||||
|
||||
$cooldownSkipped = $locked->status === DrawStatus::Cooldown->value;
|
||||
if ($cooldownSkipped) {
|
||||
$locked->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'cooling_end_time' => now(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
return [
|
||||
'draw' => $locked->fresh(),
|
||||
'cooldown_skipped' => $cooldownSkipped,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services\Settlement;
|
||||
|
||||
use App\Services\AuditLogger;
|
||||
use Illuminate\Support\Carbon;
|
||||
use App\Models\SettlementBatch;
|
||||
use App\Services\LotterySettings;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -23,19 +24,21 @@ final class SettlementTickFinalizer
|
||||
$approved = 0;
|
||||
$paid = 0;
|
||||
$payoutFailed = 0;
|
||||
$finalizeLimit = (int) config('lottery.draw_tick_finalize_limit', 5);
|
||||
|
||||
$autoApprove = (bool) LotterySettings::get('settlement.auto_approve_on_tick', true);
|
||||
$pending = $autoApprove
|
||||
? SettlementBatch::query()
|
||||
->where('status', SettlementBatchStatus::PendingReview->value)
|
||||
->orderBy('id')
|
||||
->limit((int) config('lottery.draw_tick_finalize_limit', 5))
|
||||
->limit($finalizeLimit)
|
||||
->get()
|
||||
: collect();
|
||||
|
||||
// 除本轮待审核批次外,也恢复处理上轮已批准但尚未派彩的批次。
|
||||
// 这样进程即使在 approve 提交后、payout 开始前退出,下一 tick 仍能续跑。
|
||||
$approvedDrawIds = DB::table('settlement_batches as approved')
|
||||
$retryBaseSeconds = (int) config('lottery.auto_payout_retry_base_seconds', 60);
|
||||
$approvedDrawCandidates = DB::table('settlement_batches as approved')
|
||||
->where('approved.status', SettlementBatchStatus::Approved->value)
|
||||
->whereNotExists(function ($query): void {
|
||||
$query->selectRaw('1')
|
||||
@@ -47,10 +50,21 @@ final class SettlementTickFinalizer
|
||||
]);
|
||||
})
|
||||
->groupBy('approved.draw_id')
|
||||
->selectRaw('approved.draw_id, MAX(approved.auto_payout_attempts) AS max_auto_payout_attempts, MAX(approved.updated_at) AS last_attempt_at')
|
||||
->havingRaw(
|
||||
'(MAX(approved.auto_payout_attempts) = 0 OR MAX(approved.updated_at) <= ?)',
|
||||
[now()->subSeconds($retryBaseSeconds)],
|
||||
)
|
||||
->orderByRaw('MAX(approved.auto_payout_attempts)')
|
||||
->orderByRaw('MIN(approved.id)')
|
||||
->limit((int) config('lottery.draw_tick_finalize_limit', 5))
|
||||
->pluck('approved.draw_id');
|
||||
->cursor();
|
||||
$approvedDrawIds = $approvedDrawCandidates
|
||||
->filter(fn (object $candidate): bool => $this->autoPayoutRetryIsDue(
|
||||
(int) $candidate->max_auto_payout_attempts,
|
||||
$candidate->last_attempt_at !== null ? (string) $candidate->last_attempt_at : null,
|
||||
))
|
||||
->take($finalizeLimit)
|
||||
->pluck('draw_id');
|
||||
$candidateDrawIds = $pending->pluck('draw_id')
|
||||
->merge($approvedDrawIds)
|
||||
->map(fn ($id): int => (int) $id)
|
||||
@@ -139,4 +153,20 @@ final class SettlementTickFinalizer
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private function autoPayoutRetryIsDue(int $attempts, ?string $lastAttemptAt): bool
|
||||
{
|
||||
if ($attempts <= 0 || $lastAttemptAt === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$baseSeconds = (int) config('lottery.auto_payout_retry_base_seconds', 60);
|
||||
$maxSeconds = max($baseSeconds, (int) config('lottery.auto_payout_retry_max_seconds', 3600));
|
||||
$exponent = min($attempts - 1, 20);
|
||||
$delaySeconds = min($maxSeconds, $baseSeconds * (2 ** $exponent));
|
||||
|
||||
return now()->greaterThanOrEqualTo(
|
||||
Carbon::parse($lastAttemptAt)->addSeconds($delaySeconds),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +341,11 @@ final class TicketPlacementService
|
||||
])->save();
|
||||
|
||||
if ($creditLine) {
|
||||
$this->playerCreditService->assertMayPlaceBet($player, $successTotalActualDeduct);
|
||||
$this->playerCreditService->assertMayPlaceBet(
|
||||
$player,
|
||||
$successTotalActualDeduct,
|
||||
(int) $order->id,
|
||||
);
|
||||
} else {
|
||||
$this->ticketWalletService->reserveBetDeduct(
|
||||
$player,
|
||||
|
||||
425
app/Services/Wallet/PlayerCreditActivityService.php
Normal file
425
app/Services/Wallet/PlayerCreditActivityService.php
Normal file
@@ -0,0 +1,425 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Wallet;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use App\Models\Player;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 将信用盘底层账务过程整理成玩家能理解的业务活动。
|
||||
*
|
||||
* 原始 credit_ledger 仍由管理端与审计使用;玩家端按订单、开奖结果和账期收付展示。
|
||||
*/
|
||||
final class PlayerCreditActivityService
|
||||
{
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function build(Player $player): array
|
||||
{
|
||||
$orders = DB::table('ticket_orders as o')
|
||||
->leftJoin('draws as d', 'd.id', '=', 'o.draw_id')
|
||||
->where('o.player_id', (int) $player->id)
|
||||
->where('o.total_actual_deduct', '>', 0)
|
||||
->orderByDesc('o.id')
|
||||
->get([
|
||||
'o.id',
|
||||
'o.order_no',
|
||||
'o.currency_code',
|
||||
'o.total_actual_deduct',
|
||||
'o.status',
|
||||
'o.created_at',
|
||||
'o.updated_at',
|
||||
'd.draw_no',
|
||||
]);
|
||||
|
||||
$orderActivities = $this->orderActivities($player, $orders);
|
||||
$paymentActivities = $this->paymentActivities($player);
|
||||
$activities = array_merge($orderActivities, $paymentActivities);
|
||||
|
||||
usort($activities, static function (array $left, array $right): int {
|
||||
$leftTime = isset($left['created_at']) ? strtotime((string) $left['created_at']) : 0;
|
||||
$rightTime = isset($right['created_at']) ? strtotime((string) $right['created_at']) : 0;
|
||||
if ($leftTime === $rightTime) {
|
||||
return strcmp((string) ($right['log_id'] ?? ''), (string) ($left['log_id'] ?? ''));
|
||||
}
|
||||
|
||||
return $rightTime <=> $leftTime;
|
||||
});
|
||||
|
||||
return $activities;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, object> $orders
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function orderActivities(Player $player, Collection $orders): array
|
||||
{
|
||||
if ($orders->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$orderIds = $orders->pluck('id')->map(static fn ($id): int => (int) $id)->all();
|
||||
$items = DB::table('ticket_items')
|
||||
->whereIn('order_id', $orderIds)
|
||||
->where('actual_deduct_amount', '>', 0)
|
||||
->orderBy('id')
|
||||
->get([
|
||||
'id',
|
||||
'order_id',
|
||||
'ticket_no',
|
||||
'actual_deduct_amount',
|
||||
'status',
|
||||
'win_amount',
|
||||
'jackpot_win_amount',
|
||||
'settled_at',
|
||||
'updated_at',
|
||||
]);
|
||||
$itemsByOrder = $items->groupBy(static fn (object $item): int => (int) $item->order_id);
|
||||
$ticketIds = $items->pluck('id')->map(static fn ($id): int => (int) $id)->all();
|
||||
|
||||
$activeShares = $this->activeShareRows($ticketIds)->keyBy('ticket_item_id');
|
||||
$activeRebates = $this->activeRebateTotals($ticketIds);
|
||||
$availableDeltas = $this->ticketAvailableDeltas((int) $player->id, $ticketIds);
|
||||
|
||||
$activities = [];
|
||||
foreach ($orders as $order) {
|
||||
/** @var Collection<int, object> $orderItems */
|
||||
$orderItems = $itemsByOrder->get((int) $order->id, collect());
|
||||
if ($orderItems->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stakeAmount = (int) $orderItems->sum(
|
||||
static fn (object $item): int => (int) $item->actual_deduct_amount,
|
||||
);
|
||||
$rebateAmount = 0;
|
||||
$gameWinLoss = 0;
|
||||
$winAmount = 0;
|
||||
$availableDelta = 0;
|
||||
$hasSettlement = false;
|
||||
$hasPendingPayout = false;
|
||||
$latestSettlementAt = null;
|
||||
|
||||
foreach ($orderItems as $item) {
|
||||
$ticketId = (int) $item->id;
|
||||
$share = $activeShares->get($ticketId);
|
||||
if ($share !== null) {
|
||||
$ticketGameWinLoss = (int) $share->game_win_loss;
|
||||
$gameWinLoss += $ticketGameWinLoss;
|
||||
$winAmount += max(0, -$ticketGameWinLoss);
|
||||
$hasSettlement = true;
|
||||
$latestSettlementAt = $this->latestTimestamp(
|
||||
$latestSettlementAt,
|
||||
$share->settled_at ?? $share->created_at ?? null,
|
||||
);
|
||||
} elseif (in_array((string) $item->status, ['settled_win', 'settled_lose', 'pending_payout'], true)) {
|
||||
$fallbackWin = (int) $item->win_amount + (int) $item->jackpot_win_amount;
|
||||
$ticketGameWinLoss = (string) $item->status === 'settled_lose'
|
||||
? (int) $item->actual_deduct_amount
|
||||
: -$fallbackWin;
|
||||
$gameWinLoss += $ticketGameWinLoss;
|
||||
$winAmount += max(0, -$ticketGameWinLoss);
|
||||
$hasSettlement = true;
|
||||
}
|
||||
|
||||
$rebateAmount += (int) ($activeRebates[$ticketId] ?? 0);
|
||||
$availableDelta += (int) ($availableDeltas[$ticketId] ?? 0);
|
||||
$hasPendingPayout = $hasPendingPayout || (string) $item->status === 'pending_payout';
|
||||
$latestSettlementAt = $this->latestTimestamp(
|
||||
$latestSettlementAt,
|
||||
$item->settled_at ?? $item->updated_at ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
$refunded = in_array((string) $order->status, ['refunded', 'cancelled'], true)
|
||||
|| $orderItems->every(
|
||||
static fn (object $item): bool => in_array((string) $item->status, ['refunded', 'failed'], true),
|
||||
);
|
||||
$ticketCount = $orderItems->count();
|
||||
$firstTicketNo = $ticketCount === 1 ? (string) ($orderItems->first()->ticket_no ?? '') : null;
|
||||
|
||||
if ($refunded) {
|
||||
$activities[] = $this->formatActivity(
|
||||
logId: 'CA-ORDER-'.$order->id,
|
||||
type: 'reversal',
|
||||
bizType: 'bet_refund',
|
||||
activityKind: 'refund',
|
||||
activityStatus: 'reversed',
|
||||
amount: $stakeAmount,
|
||||
currency: (string) $order->currency_code,
|
||||
availableDelta: 0,
|
||||
createdAt: $this->isoTimestamp($order->updated_at ?? $order->created_at ?? null),
|
||||
order: $order,
|
||||
ticketCount: $ticketCount,
|
||||
ticketNo: $firstTicketNo,
|
||||
stakeAmount: $stakeAmount,
|
||||
winAmount: 0,
|
||||
rebateAmount: 0,
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($hasSettlement) {
|
||||
$netAmount = -$gameWinLoss + $rebateAmount;
|
||||
$activities[] = $this->formatActivity(
|
||||
logId: 'CA-ORDER-'.$order->id,
|
||||
type: 'game_settlement',
|
||||
bizType: $netAmount > 0 ? 'settled_win' : ($netAmount < 0 ? 'settled_loss' : 'settled_even'),
|
||||
activityKind: 'draw_result',
|
||||
activityStatus: $hasPendingPayout ? 'pending' : 'completed',
|
||||
amount: $netAmount,
|
||||
currency: (string) $order->currency_code,
|
||||
availableDelta: $availableDelta,
|
||||
createdAt: $this->isoTimestamp($latestSettlementAt ?? $order->updated_at ?? null),
|
||||
order: $order,
|
||||
ticketCount: $ticketCount,
|
||||
ticketNo: $firstTicketNo,
|
||||
stakeAmount: $stakeAmount,
|
||||
winAmount: $winAmount,
|
||||
rebateAmount: $rebateAmount,
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$activities[] = $this->formatActivity(
|
||||
logId: 'CA-ORDER-'.$order->id,
|
||||
type: 'bet',
|
||||
bizType: 'bet_pending',
|
||||
activityKind: 'bet',
|
||||
activityStatus: 'pending',
|
||||
amount: -$stakeAmount,
|
||||
currency: (string) $order->currency_code,
|
||||
availableDelta: -$stakeAmount,
|
||||
createdAt: $this->isoTimestamp($order->created_at ?? null),
|
||||
order: $order,
|
||||
ticketCount: $ticketCount,
|
||||
ticketNo: $firstTicketNo,
|
||||
stakeAmount: $stakeAmount,
|
||||
winAmount: 0,
|
||||
rebateAmount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
return $activities;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function paymentActivities(Player $player): array
|
||||
{
|
||||
$rows = DB::table('payment_records as pr')
|
||||
->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
|
||||
->where('sb.bill_type', 'player')
|
||||
->where('sb.owner_type', 'player')
|
||||
->where('sb.owner_id', (int) $player->id)
|
||||
->where('pr.status', 'confirmed')
|
||||
->orderByDesc('pr.id')
|
||||
->get([
|
||||
'pr.id',
|
||||
'pr.settlement_bill_id',
|
||||
'pr.payer_type',
|
||||
'pr.payer_id',
|
||||
'pr.payee_type',
|
||||
'pr.payee_id',
|
||||
'pr.amount',
|
||||
'pr.confirmed_at',
|
||||
'pr.created_at',
|
||||
]);
|
||||
|
||||
$activities = [];
|
||||
foreach ($rows as $row) {
|
||||
$playerPaid = (string) $row->payer_type === 'player'
|
||||
&& (int) $row->payer_id === (int) $player->id;
|
||||
$playerReceived = (string) $row->payee_type === 'player'
|
||||
&& (int) $row->payee_id === (int) $player->id;
|
||||
if (! $playerPaid && ! $playerReceived) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = (int) $row->amount * ($playerReceived ? 1 : -1);
|
||||
$activities[] = [
|
||||
'log_id' => 'CA-PAYMENT-'.$row->id,
|
||||
'type' => 'bill_settlement',
|
||||
'biz_type' => $playerReceived ? 'period_received' : 'period_paid',
|
||||
'activity_kind' => 'period_settlement',
|
||||
'activity_status' => 'completed',
|
||||
'amount' => $amount,
|
||||
'amount_abs' => abs($amount),
|
||||
'direction' => $amount >= 0 ? 'in' : 'out',
|
||||
'currency_code' => (string) $player->default_currency,
|
||||
'balance_after' => null,
|
||||
'affects_available_credit' => $playerPaid,
|
||||
'_available_delta' => $playerPaid ? (int) $row->amount : 0,
|
||||
'ref_id' => 'settlement_bill#'.$row->settlement_bill_id,
|
||||
'settlement_bill_id' => (int) $row->settlement_bill_id,
|
||||
'order_no' => null,
|
||||
'draw_no' => null,
|
||||
'ticket_no' => null,
|
||||
'ticket_count' => 0,
|
||||
'stake_amount' => 0,
|
||||
'win_amount' => 0,
|
||||
'rebate_amount' => 0,
|
||||
'net_amount' => $amount,
|
||||
'idempotent_key' => null,
|
||||
'external_ref_no' => null,
|
||||
'status' => 'posted',
|
||||
'remark' => null,
|
||||
'created_at' => $this->isoTimestamp($row->confirmed_at ?? $row->created_at ?? null),
|
||||
'ledger_source' => 'credit_activity',
|
||||
'funding_mode' => (string) $player->funding_mode,
|
||||
'auth_source' => $player->auth_source,
|
||||
];
|
||||
}
|
||||
|
||||
return $activities;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $ticketIds
|
||||
* @return Collection<int, object>
|
||||
*/
|
||||
private function activeShareRows(array $ticketIds): Collection
|
||||
{
|
||||
if ($ticketIds === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return DB::table('share_ledger as sl')
|
||||
->whereIn('sl.ticket_item_id', $ticketIds)
|
||||
->whereNull('sl.reversal_of_id')
|
||||
->whereNotExists(function ($query): void {
|
||||
$query->selectRaw('1')
|
||||
->from('share_ledger as reversal')
|
||||
->whereColumn('reversal.reversal_of_id', 'sl.id');
|
||||
})
|
||||
->get(['sl.ticket_item_id', 'sl.game_win_loss', 'sl.settled_at', 'sl.created_at']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $ticketIds
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function activeRebateTotals(array $ticketIds): array
|
||||
{
|
||||
if ($ticketIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('rebate_records as rr')
|
||||
->whereIn('rr.ticket_item_id', $ticketIds)
|
||||
->whereNull('rr.reversal_of_id')
|
||||
->whereNotExists(function ($query): void {
|
||||
$query->selectRaw('1')
|
||||
->from('rebate_records as reversal')
|
||||
->whereColumn('reversal.reversal_of_id', 'rr.id');
|
||||
})
|
||||
->selectRaw('rr.ticket_item_id, SUM(rr.rebate_amount) as total_rebate')
|
||||
->groupBy('rr.ticket_item_id')
|
||||
->pluck('total_rebate', 'ticket_item_id')
|
||||
->map(static fn ($amount): int => (int) $amount)
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $ticketIds
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function ticketAvailableDeltas(int $playerId, array $ticketIds): array
|
||||
{
|
||||
if ($ticketIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('credit_ledger')
|
||||
->where('owner_type', 'player')
|
||||
->where('owner_id', $playerId)
|
||||
->where('ref_type', 'ticket_item')
|
||||
->whereIn('ref_id', $ticketIds)
|
||||
->whereIn('reason', ['game_settlement_loss', 'game_settlement_win', 'game_settlement_reversal'])
|
||||
->selectRaw('ref_id, SUM(amount) as available_delta')
|
||||
->groupBy('ref_id')
|
||||
->pluck('available_delta', 'ref_id')
|
||||
->map(static fn ($amount): int => (int) $amount)
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatActivity(
|
||||
string $logId,
|
||||
string $type,
|
||||
string $bizType,
|
||||
string $activityKind,
|
||||
string $activityStatus,
|
||||
int $amount,
|
||||
string $currency,
|
||||
int $availableDelta,
|
||||
?string $createdAt,
|
||||
object $order,
|
||||
int $ticketCount,
|
||||
?string $ticketNo,
|
||||
int $stakeAmount,
|
||||
int $winAmount,
|
||||
int $rebateAmount,
|
||||
): array {
|
||||
return [
|
||||
'log_id' => $logId,
|
||||
'type' => $type,
|
||||
'biz_type' => $bizType,
|
||||
'activity_kind' => $activityKind,
|
||||
'activity_status' => $activityStatus,
|
||||
'amount' => $amount,
|
||||
'amount_abs' => abs($amount),
|
||||
'direction' => $amount >= 0 ? 'in' : 'out',
|
||||
'currency_code' => $currency,
|
||||
'balance_after' => null,
|
||||
'affects_available_credit' => $availableDelta !== 0,
|
||||
'_available_delta' => $availableDelta,
|
||||
'ref_id' => (string) $order->order_no,
|
||||
'order_no' => (string) $order->order_no,
|
||||
'draw_no' => $order->draw_no !== null ? (string) $order->draw_no : null,
|
||||
'ticket_no' => $ticketNo !== '' ? $ticketNo : null,
|
||||
'ticket_count' => $ticketCount,
|
||||
'stake_amount' => $stakeAmount,
|
||||
'win_amount' => $winAmount,
|
||||
'rebate_amount' => $rebateAmount,
|
||||
'net_amount' => $amount,
|
||||
'settlement_bill_id' => null,
|
||||
'idempotent_key' => null,
|
||||
'external_ref_no' => null,
|
||||
'status' => $activityStatus === 'pending' ? 'pending_reconcile' : ($activityStatus === 'reversed' ? 'reversed' : 'posted'),
|
||||
'remark' => null,
|
||||
'created_at' => $createdAt,
|
||||
'ledger_source' => 'credit_activity',
|
||||
];
|
||||
}
|
||||
|
||||
private function latestTimestamp(mixed $current, mixed $candidate): mixed
|
||||
{
|
||||
if ($candidate === null || $candidate === '') {
|
||||
return $current;
|
||||
}
|
||||
if ($current === null || $current === '') {
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
return strtotime((string) $candidate) > strtotime((string) $current) ? $candidate : $current;
|
||||
}
|
||||
|
||||
private function isoTimestamp(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Carbon::parse((string) $value)->toIso8601String();
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,20 @@
|
||||
namespace App\Services\Wallet;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Player;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\WalletTxn;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Support\AdminDataScope;
|
||||
use App\Support\CurrencyFormatter;
|
||||
use App\Support\LimitedQuery;
|
||||
use App\Support\AdminDataScope;
|
||||
use App\Support\CreditAmountScale;
|
||||
use App\Support\CurrencyFormatter;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use App\Services\AgentSettlement\CreditLedgerBetFlowPresenter;
|
||||
use App\Services\AgentSettlement\SettlementPartyEnrichment;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Services\AgentSettlement\SettlementPartyEnrichment;
|
||||
use App\Services\AgentSettlement\CreditLedgerBetFlowPresenter;
|
||||
|
||||
/**
|
||||
* 玩家流水:钱包玩家读 {@see wallet_txns},信用盘玩家读 {@see credit_ledger}。
|
||||
@@ -48,6 +49,7 @@ final class PlayerLedgerLogsService
|
||||
|
||||
public function __construct(
|
||||
private readonly PlayerCreditService $playerCreditService,
|
||||
private readonly PlayerCreditActivityService $creditActivityService,
|
||||
private readonly CreditLedgerBetFlowPresenter $betFlowPresenter,
|
||||
private readonly SettlementPartyEnrichment $partyEnrichment,
|
||||
) {}
|
||||
@@ -430,6 +432,127 @@ final class PlayerLedgerLogsService
|
||||
int $page,
|
||||
int $perPage,
|
||||
string $typeFilterRaw,
|
||||
): array {
|
||||
$activities = $this->creditActivityService->build($player);
|
||||
if ($activities !== []) {
|
||||
return $this->paginateCreditActivities($player, $activities, $page, $perPage, $typeFilterRaw);
|
||||
}
|
||||
|
||||
return $this->paginateLegacyCreditLedger($player, $page, $perPage, $typeFilterRaw);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $activities
|
||||
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
|
||||
*/
|
||||
private function paginateCreditActivities(
|
||||
Player $player,
|
||||
array $activities,
|
||||
int $page,
|
||||
int $perPage,
|
||||
string $typeFilterRaw,
|
||||
): array {
|
||||
$currency = (string) $player->default_currency;
|
||||
$creditLimitMinor = $this->creditLimitMinor($player, $currency);
|
||||
$runningMinor = $this->clampCreditAvailableMinor(
|
||||
$this->playerCreditService->availableCreditMinor($player, $currency),
|
||||
$creditLimitMinor,
|
||||
);
|
||||
|
||||
// 先按完整活动时间线回推每笔的变更后额度,再做筛选和分页。
|
||||
// 否则筛选掉的较新活动会让历史记录的 balance_after 偏离真实值。
|
||||
foreach ($activities as &$activity) {
|
||||
$activity['balance_after'] = $runningMinor;
|
||||
$activity['balance_after_formatted'] = CurrencyFormatter::fromMinor($runningMinor);
|
||||
$activity['funding_mode'] = (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT);
|
||||
$activity['auth_source'] = $player->auth_source;
|
||||
$runningMinor = $this->clampCreditAvailableMinor(
|
||||
$runningMinor - (int) ($activity['_available_delta'] ?? 0),
|
||||
$creditLimitMinor,
|
||||
);
|
||||
unset($activity['_available_delta']);
|
||||
}
|
||||
unset($activity);
|
||||
|
||||
$activities = array_values(array_filter(
|
||||
$activities,
|
||||
fn (array $activity): bool => $this->creditActivityMatchesFilter($activity, $typeFilterRaw),
|
||||
));
|
||||
$offset = max(0, ($page - 1) * $perPage);
|
||||
|
||||
$items = array_slice($activities, $offset, $perPage);
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => count($activities),
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $activity
|
||||
*/
|
||||
private function creditActivityMatchesFilter(array $activity, string $typeFilterRaw): bool
|
||||
{
|
||||
$raw = trim($typeFilterRaw);
|
||||
if ($raw === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$parts = array_values(array_filter(array_map(
|
||||
static fn (string $part): string => Str::lower(trim($part)),
|
||||
explode(',', $raw),
|
||||
)));
|
||||
if ($parts === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$kind = (string) ($activity['activity_kind'] ?? '');
|
||||
$status = (string) ($activity['activity_status'] ?? '');
|
||||
$netAmount = (int) ($activity['net_amount'] ?? 0);
|
||||
$rebateAmount = (int) ($activity['rebate_amount'] ?? 0);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if ($part === 'bet' && $kind === 'bet') {
|
||||
return true;
|
||||
}
|
||||
if ($part === 'game_settlement' && $kind === 'draw_result') {
|
||||
return true;
|
||||
}
|
||||
if ($part === 'bill_settlement' && $kind === 'period_settlement') {
|
||||
return true;
|
||||
}
|
||||
if ($part === 'rebate' && $kind === 'draw_result' && $rebateAmount > 0) {
|
||||
return true;
|
||||
}
|
||||
if ($part === 'reversal' && ($kind === 'refund' || $status === 'reversed')) {
|
||||
return true;
|
||||
}
|
||||
if ($part === 'refund' && $kind === 'refund') {
|
||||
return true;
|
||||
}
|
||||
if ($part === 'win_credit' && $kind === 'draw_result' && $netAmount > 0) {
|
||||
return true;
|
||||
}
|
||||
if ($part === 'credit_release' && in_array($kind, ['draw_result', 'refund', 'period_settlement'], true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容没有订单领域记录的旧数据与孤立测试数据;真实玩家活动优先走聚合视图。
|
||||
*
|
||||
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
|
||||
*/
|
||||
private function paginateLegacyCreditLedger(
|
||||
Player $player,
|
||||
int $page,
|
||||
int $perPage,
|
||||
string $typeFilterRaw,
|
||||
): array {
|
||||
$reasonFilter = $this->resolveCreditReasonFilter($typeFilterRaw);
|
||||
$includeRebates = $this->creditFilterIncludesRebates($typeFilterRaw);
|
||||
@@ -732,7 +855,7 @@ final class PlayerLedgerLogsService
|
||||
return 0;
|
||||
}
|
||||
|
||||
return \App\Support\CreditAmountScale::majorToMinor((int) $credit->credit_limit, $currency);
|
||||
return CreditAmountScale::majorToMinor((int) $credit->credit_limit, $currency);
|
||||
}
|
||||
|
||||
private function clampCreditAvailableMinor(int $amountMinor, int $creditLimitMinor): int
|
||||
|
||||
@@ -407,6 +407,7 @@ final class AdminAuthorizationRegistry
|
||||
['code' => 'admin.dashboard', 'module_code' => 'dashboard', 'name' => '后台仪表盘', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard', 'route_name' => 'api.v1.admin.dashboard', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['dashboard.view']],
|
||||
['code' => 'admin.dashboard.analytics', 'module_code' => 'dashboard', 'name' => '仪表盘分析', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard/analytics', 'route_name' => 'api.v1.admin.dashboard.analytics', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['dashboard.view']],
|
||||
['code' => 'admin.auth.me', 'module_code' => 'system', 'name' => '后台当前管理员摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/auth/me', 'route_name' => 'api.v1.admin.auth.me', 'auth_mode' => 'login_only', 'is_audit_required' => false],
|
||||
['code' => 'admin.auth.logout', 'module_code' => 'system', 'name' => '后台退出登录', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/auth/logout', 'route_name' => 'api.v1.admin.auth.logout', 'auth_mode' => 'login_only', 'is_audit_required' => false],
|
||||
['code' => 'admin.audit.index', 'module_code' => 'audit', 'name' => '审计日志查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/audit-logs', 'route_name' => 'api.v1.admin.audit-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.audit.view']],
|
||||
|
||||
['code' => 'admin.admin-users.index', 'module_code' => 'system', 'name' => '管理员列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/admin-users', 'route_name' => 'api.v1.admin.admin-users.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.admin_user.manage']],
|
||||
@@ -527,7 +528,7 @@ final class AdminAuthorizationRegistry
|
||||
['code' => 'admin.draws.risk-pools.recover', 'module_code' => 'risk', 'name' => '恢复风控池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}/recover', 'route_name' => 'api.v1.admin.draws.risk-pools.recover', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.risk.manage']],
|
||||
['code' => 'admin.draws.cancel', 'module_code' => 'draw', 'name' => '取消开奖', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/cancel', 'route_name' => 'api.v1.admin.draws.cancel', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||
['code' => 'admin.draws.rng', 'module_code' => 'draw', 'name' => '执行开奖 RNG', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/rng', 'route_name' => 'api.v1.admin.draws.rng', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||
['code' => 'admin.draws.settlement.run', 'module_code' => 'settlement', 'name' => '执行结算', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/settlement/run', 'route_name' => 'api.v1.admin.draws.settlement.run', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.batch.manage', 'settlement.batch.review']],
|
||||
['code' => 'admin.draws.settlement.run', 'module_code' => 'settlement', 'name' => '执行结算', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/settlement/run', 'route_name' => 'api.v1.admin.draws.settlement.run', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.batch.manage']],
|
||||
|
||||
['code' => 'admin.settlement-batches.index', 'module_code' => 'settlement', 'name' => '结算批次列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-batches', 'route_name' => 'api.v1.admin.settlement-batches.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']],
|
||||
['code' => 'admin.settlement-batches.show', 'module_code' => 'settlement', 'name' => '结算批次详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}', 'route_name' => 'api.v1.admin.settlement-batches.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']],
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Player;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
@@ -59,6 +59,8 @@ final class AuditLogApiPresenter
|
||||
'sync_permissions' => '同步权限',
|
||||
'batch_update' => '批量更新设置',
|
||||
'payout_adjustment' => '派彩调整',
|
||||
'auto_payout' => '自动派彩成功',
|
||||
'auto_payout_failed' => '自动派彩失败',
|
||||
'rotate_secrets' => '轮换密钥',
|
||||
'toggle_active' => '切换启用状态',
|
||||
'enqueue' => '提交报表导出',
|
||||
@@ -88,6 +90,7 @@ final class AuditLogApiPresenter
|
||||
'play_config_item' => '玩法',
|
||||
'play_config_version' => '玩法配置版本',
|
||||
'settlement_batch_adjustment' => '结算调整单',
|
||||
'settlement_batch' => '结算批次',
|
||||
'report_job' => '报表任务',
|
||||
'reconcile_job' => '对账任务',
|
||||
'transfer_no' => '转账单',
|
||||
|
||||
@@ -267,10 +267,14 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$withSingleServerLock($schedule->command('settlement:mark-overdue-bills --days=7')
|
||||
->dailyAt('02:00')
|
||||
->withoutOverlapping());
|
||||
/** @see docs/01-界面文档.md §2.1 `draw.countdown` */
|
||||
/**
|
||||
* @see docs/01-界面文档.md §2.1 `draw.countdown`
|
||||
* 前端按服务器时间每秒本地计时;后端每 5 秒校准快照和期号边界,
|
||||
* 避免每秒重复启动完整 Artisan 进程。
|
||||
*/
|
||||
if (config('lottery.realtime_hall_countdown', true)) {
|
||||
$withSingleServerLock($schedule->command('lottery:hall-countdown')
|
||||
->everySecond()
|
||||
->everyFiveSeconds()
|
||||
->withoutOverlapping(expiresAt: 5));
|
||||
}
|
||||
})
|
||||
|
||||
@@ -133,5 +133,7 @@ return [
|
||||
'draw_tick_stage_warn_threshold_ms' => max(50, (int) env('LOTTERY_DRAW_TICK_STAGE_WARN_THRESHOLD_MS', 500)),
|
||||
'draw_tick_settle_limit' => max(1, (int) env('LOTTERY_DRAW_TICK_SETTLE_LIMIT', 3)),
|
||||
'draw_tick_finalize_limit' => max(1, (int) env('LOTTERY_DRAW_TICK_FINALIZE_LIMIT', 5)),
|
||||
'auto_payout_retry_base_seconds' => max(10, (int) env('LOTTERY_AUTO_PAYOUT_RETRY_BASE_SECONDS', 60)),
|
||||
'auto_payout_retry_max_seconds' => max(60, (int) env('LOTTERY_AUTO_PAYOUT_RETRY_MAX_SECONDS', 3600)),
|
||||
'draw_tick_rng_limit' => max(1, (int) env('LOTTERY_DRAW_TICK_RNG_LIMIT', 10)),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('players', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('native_session_version')
|
||||
->default(0)
|
||||
->after('native_token_version');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('players', function (Blueprint $table): void {
|
||||
$table->dropColumn('native_session_version');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('admin_users', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('admin_session_version')
|
||||
->default(0)
|
||||
->after('last_login_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('admin_users', function (Blueprint $table): void {
|
||||
$table->dropColumn('admin_session_version');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@ return [
|
||||
'invalid_captcha' => 'Invalid or expired captcha.',
|
||||
'invalid_credentials' => 'Invalid account or password.',
|
||||
'account_disabled' => 'This account has been disabled.',
|
||||
'session_replaced' => 'This account was signed in elsewhere. The current session has been signed out.',
|
||||
'permission_denied' => 'You do not have permission to perform this action.',
|
||||
'forbidden' => 'You do not have permission to perform this action.',
|
||||
'settlement_run_skipped' => 'Settlement was not run for this draw (check draw status and published result batch).',
|
||||
|
||||
@@ -41,6 +41,7 @@ return [
|
||||
'jackpot_already_allocated_for_draw' => 'Jackpot was already allocated for this draw.',
|
||||
'draw_not_ready_for_jackpot_burst' => 'Draw is not in settling or settled status.',
|
||||
'draw_result_not_published' => 'Draw results have not been published.',
|
||||
'draw_not_ready_for_settlement' => 'This draw cannot be settled in its current state. Settlement may only be started early during cooldown or retried while settling.',
|
||||
'settlement_batch_not_found' => 'Settlement batch not found for this draw.',
|
||||
'settlement_not_pending_review' => 'Settlement batch is not pending review.',
|
||||
'settlement_not_approved' => 'Settlement batch is not approved.',
|
||||
|
||||
@@ -16,4 +16,5 @@ return [
|
||||
'8007' => 'Too many failed attempts. Try again later',
|
||||
'8008' => 'Please sign in through the main site',
|
||||
'8009' => 'Invalid or expired captcha.',
|
||||
'8010' => 'This account was signed in on another device. Please sign in again.',
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ return [
|
||||
'invalid_captcha' => 'क्याप्चा गलत वा म्याद सकिएको छ।',
|
||||
'invalid_credentials' => 'खाता वा पासवर्ड गलत छ।',
|
||||
'account_disabled' => 'यो खाता निष्क्रिय गरिएको छ।',
|
||||
'session_replaced' => 'यो खाता अर्को स्थानमा लगइन भएको छ। हालको सत्र लगआउट गरिएको छ।',
|
||||
'permission_denied' => 'यो कार्य गर्ने अनुमति छैन।',
|
||||
'forbidden' => 'यो कार्य गर्ने अनुमति छैन।',
|
||||
'settlement_run_skipped' => 'यो ड्रको सेटलमेन्ट चलाइएन (ड्र स्थिति र प्रकाशित नतिजा जाँच गर्नुहोस्)।',
|
||||
|
||||
@@ -41,6 +41,7 @@ return [
|
||||
'jackpot_already_allocated_for_draw' => 'यो ड्रमा ज्याकपोट पहिले नै बाँडिएको छ।',
|
||||
'draw_not_ready_for_jackpot_burst' => 'ड्र settling वा settled अवस्थामा छैन।',
|
||||
'draw_result_not_published' => 'ड्र नतिजा प्रकाशित भएको छैन।',
|
||||
'draw_not_ready_for_settlement' => 'हालको अवस्थामा यो ड्र सेटल गर्न मिल्दैन। कूलडाउनमा मात्र छिटो सेटल गर्न वा सेटल हुँदै गर्दा पुनः प्रयास गर्न सकिन्छ।',
|
||||
'settlement_batch_not_found' => 'यो ड्रको सेटलमेन्ट ब्याच फेला परेन।',
|
||||
'settlement_not_pending_review' => 'सेटलमेन्ट ब्याच समीक्षामा छैन।',
|
||||
'settlement_not_approved' => 'सेटलमेन्ट ब्याच स्वीकृत छैन।',
|
||||
|
||||
@@ -13,4 +13,5 @@ return [
|
||||
'8007' => 'धेरै असफल प्रयास। पछि फेरि प्रयास गर्नुहोस्',
|
||||
'8008' => 'कृपया मुख्य साइटबाट लगइन गर्नुहोस्',
|
||||
'8009' => 'क्याप्चा गलत वा म्याद सकिएको छ।',
|
||||
'8010' => 'यो खाता अर्को उपकरणमा लगइन गरिएको छ। कृपया फेरि लगइन गर्नुहोस्।',
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ return [
|
||||
'invalid_captcha' => '验证码错误或已过期,请重试。',
|
||||
'invalid_credentials' => '账号或密码错误。',
|
||||
'account_disabled' => '该账号已被禁用。',
|
||||
'session_replaced' => '该账号已在其他地方登录,当前会话已退出。',
|
||||
'permission_denied' => '当前账号无此操作权限。',
|
||||
'forbidden' => '当前账号无此操作权限。',
|
||||
'settlement_run_skipped' => '本期未执行结算(请检查期号状态与已发布开奖批次)。',
|
||||
|
||||
@@ -41,6 +41,7 @@ return [
|
||||
'jackpot_already_allocated_for_draw' => '该期已分配过奖池派彩。',
|
||||
'draw_not_ready_for_jackpot_burst' => '期号尚未进入结算中或已结算,无法手动爆池。',
|
||||
'draw_result_not_published' => '该期开奖结果尚未发布。',
|
||||
'draw_not_ready_for_settlement' => '当前期号状态不允许结算;仅可在冷静期提前结算,或在结算中重新触发。',
|
||||
'settlement_batch_not_found' => '未找到该期的结算批次。',
|
||||
'settlement_not_pending_review' => '结算批次不在待审核状态。',
|
||||
'settlement_not_approved' => '结算批次尚未审核通过。',
|
||||
|
||||
@@ -13,4 +13,5 @@ return [
|
||||
'8007' => '登录失败次数过多,请稍后再试',
|
||||
'8008' => '请使用主站登录进入彩票',
|
||||
'8009' => '验证码错误或已过期,请重试。',
|
||||
'8010' => '该账号已在其他设备登录,请重新登录。',
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\Api\V1\Admin\Auth\MeController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Auth\LogoutController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Audit\AuditLogIndexController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Dashboard\AdminDashboardAnalyticsController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Dashboard\AdminDashboardController;
|
||||
@@ -30,6 +31,10 @@ Route::get('auth/me', MeController::class)
|
||||
->middleware('admin.api-resource')
|
||||
->name('api.v1.admin.auth.me');
|
||||
|
||||
Route::post('auth/logout', LogoutController::class)
|
||||
->middleware('admin.api-resource')
|
||||
->name('api.v1.admin.auth.logout');
|
||||
|
||||
// 审计日志
|
||||
Route::middleware('admin.api-resource')
|
||||
->get('audit-logs', AuditLogIndexController::class)
|
||||
|
||||
@@ -51,7 +51,7 @@ Route::middleware('lottery.player')->group(function (): void {
|
||||
->where('ticket_no', 'TK[0-9]+')
|
||||
->name('items.show');
|
||||
Route::get('draws/{draw_no}/my-match', TicketDrawMyMatchController::class)
|
||||
->where('draw_no', '[0-9]{8}-[0-9]{3}')
|
||||
->where('draw_no', '[0-9]{8}-[0-9]{3,}')
|
||||
->name('draws.my-match');
|
||||
});
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@ use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\Api\V1\HealthController;
|
||||
use App\Http\Controllers\Api\V1\Draw\DrawCurrentController;
|
||||
use App\Http\Controllers\Api\V1\Draw\DrawResultShowController;
|
||||
use App\Http\Controllers\Api\V1\Setting\SettingIndexController;
|
||||
use App\Http\Controllers\Api\V1\Draw\DrawResultsIndexController;
|
||||
use App\Http\Controllers\Api\V1\Currency\CurrencyIndexController;
|
||||
use App\Http\Controllers\Api\V1\Jackpot\JackpotSummaryController;
|
||||
use App\Http\Controllers\Api\V1\Player\PlayerAuthLoginController;
|
||||
use App\Http\Controllers\Api\V1\Player\PlayerAuthCaptchaController;
|
||||
use App\Http\Controllers\Api\V1\Play\PlayEffectiveCatalogController;
|
||||
use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController;
|
||||
use App\Http\Controllers\Api\V1\Player\PlayerAuthCaptchaController;
|
||||
use App\Http\Controllers\Api\V1\Player\PlayerAuthLoginController;
|
||||
use App\Http\Controllers\Api\V1\Setting\SettingIndexController;
|
||||
use App\Http\Controllers\Api\V1\Integration\IntegrationRuntimeOriginsController;
|
||||
|
||||
/**
|
||||
@@ -28,7 +28,7 @@ Route::get('currencies', CurrencyIndexController::class)->name('api.v1.currencie
|
||||
Route::get('draw/current', DrawCurrentController::class)->name('api.v1.draw.current');
|
||||
Route::get('draw/results', DrawResultsIndexController::class)->name('api.v1.draw.results');
|
||||
Route::get('draw/results/{draw_no}', DrawResultShowController::class)
|
||||
->where('draw_no', '[0-9]{8}-[0-9]{3}')
|
||||
->where('draw_no', '[0-9]{8}-[0-9]{3,}')
|
||||
->name('api.v1.draw.results.show');
|
||||
|
||||
// 奖池水位(公开)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Lottery\DrawStatus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Support\AdminPermissionBridge;
|
||||
@@ -95,6 +97,25 @@ test('admin api resource middleware denies wallet reconcile resource without per
|
||||
->assertJsonPath('code', ErrorCode::AdminForbidden->value);
|
||||
});
|
||||
|
||||
test('admin api resource middleware denies settlement review only account from running draw settlement', function (): void {
|
||||
$token = mintAdminTokenWithLegacySlugs('resource_settlement_reviewer', ['prd.payout.review']);
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260722-2001',
|
||||
'business_date' => '2026-07-22',
|
||||
'sequence_no' => 2001,
|
||||
'status' => DrawStatus::Cooldown->value,
|
||||
'cooling_end_time' => now()->addMinutes(10),
|
||||
'settle_version' => 0,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run")
|
||||
->assertForbidden()
|
||||
->assertJsonPath('code', ErrorCode::AdminForbidden->value);
|
||||
|
||||
expect($draw->fresh()->status)->toBe(DrawStatus::Cooldown->value);
|
||||
});
|
||||
|
||||
test('admin api resource middleware allows wallet reconcile resource with mapped permission', function (): void {
|
||||
$token = mintAdminTokenWithLegacySlugs('resource_wallet_viewer', ['prd.wallet_reconcile.view']);
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\AdminUser;
|
||||
use App\Support\AuditLogApiPresenter;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Services\AuditLogger;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Services\AuditLogger;
|
||||
use App\Support\AuditLogApiPresenter;
|
||||
use App\Services\Agent\AgentNodeService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -36,6 +37,24 @@ test('audit log presenter maps business action and entity target', function ():
|
||||
->and($payload['summary_label'])->toBe('代理管理 · 同步代理角色权限(角色 #5)');
|
||||
});
|
||||
|
||||
test('audit log presenter translates automatic payout failures', function (): void {
|
||||
$row = new AuditLog([
|
||||
'operator_type' => 'system',
|
||||
'operator_id' => 0,
|
||||
'module_code' => 'settlement',
|
||||
'action_code' => 'auto_payout_failed',
|
||||
'target_type' => 'settlement_batch',
|
||||
'target_id' => '1107',
|
||||
]);
|
||||
$row->id = 2;
|
||||
|
||||
$payload = AuditLogApiPresenter::row($row);
|
||||
|
||||
expect($payload['action_label'])->toBe('自动派彩失败')
|
||||
->and($payload['target_label'])->toBe('结算批次 #1107')
|
||||
->and($payload['summary_label'])->toBe('派彩结算 · 自动派彩失败(结算批次 #1107)');
|
||||
});
|
||||
|
||||
test('audit log presenter uses admin api resource name for middleware style rows', function (): void {
|
||||
$resourceName = (string) DB::table('admin_api_resources')
|
||||
->where('code', 'admin.agent-roles.permissions.sync')
|
||||
@@ -63,7 +82,7 @@ test('audit log presenter uses admin api resource name for middleware style rows
|
||||
test('agent role permission sync records one business audit and skips middleware duplicate', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
$service = app(AgentNodeService::class);
|
||||
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'audit_dedup_super',
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\SitePlatformRole;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Support\SitePlatformRole;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -92,6 +92,89 @@ test('admin login returns bearer token when captcha passes validation', function
|
||||
->assertJsonPath('data.scope', 'admin');
|
||||
});
|
||||
|
||||
test('later admin login replaces the previous browser session', function () {
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'single_session_admin',
|
||||
'name' => '单会话管理员',
|
||||
'email' => null,
|
||||
'password' => 'secret-strong',
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
|
||||
$login = function () {
|
||||
$captchaKey = (string) Str::uuid();
|
||||
Cache::put(
|
||||
'admin_captcha:'.$captchaKey,
|
||||
hash_hmac('sha256', 'xwz2', (string) config('app.key')),
|
||||
now()->addSeconds(120),
|
||||
);
|
||||
|
||||
return $this->postJson('/api/v1/admin/auth/login', [
|
||||
'account' => 'single_session_admin',
|
||||
'password' => 'secret-strong',
|
||||
'captcha_key' => $captchaKey,
|
||||
'captcha_code' => 'xwz2',
|
||||
])->assertOk();
|
||||
};
|
||||
|
||||
$firstToken = (string) $login()->json('data.token');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$firstToken)
|
||||
->getJson('/api/v1/admin/ping')
|
||||
->assertOk();
|
||||
|
||||
$secondToken = (string) $login()->json('data.token');
|
||||
|
||||
app('auth')->forgetGuards();
|
||||
$this->withHeader('Authorization', 'Bearer '.$firstToken)
|
||||
->getJson('/api/v1/admin/ping')
|
||||
->assertUnauthorized()
|
||||
->assertJsonPath('code', ErrorCode::AdminSessionReplaced->value);
|
||||
|
||||
app('auth')->forgetGuards();
|
||||
$this->withHeader('Authorization', 'Bearer '.$secondToken)
|
||||
->getJson('/api/v1/admin/ping')
|
||||
->assertOk()
|
||||
->assertJsonPath('code', ErrorCode::Success->value);
|
||||
|
||||
expect($admin->fresh()->admin_session_version)->toBe(2)
|
||||
->and($admin->tokens()->where('name', 'admin-api')->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('admin logout revokes browser sessions without deleting programmatic tokens', function () {
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'logout_admin',
|
||||
'name' => '退出管理员',
|
||||
'email' => null,
|
||||
'password' => 'secret-strong',
|
||||
'status' => 0,
|
||||
'admin_session_version' => 1,
|
||||
]);
|
||||
|
||||
$browserToken = $admin->createToken(
|
||||
'admin-api',
|
||||
['admin-session:1'],
|
||||
now()->addDay(),
|
||||
)->plainTextToken;
|
||||
$admin->createToken('test', ['*'], now()->addDay());
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$browserToken)
|
||||
->postJson('/api/v1/admin/auth/logout')
|
||||
->assertOk()
|
||||
->assertJsonPath('code', ErrorCode::Success->value)
|
||||
->assertJsonPath('data.logged_out', true);
|
||||
|
||||
expect($admin->tokens()->where('name', 'admin-api')->count())->toBe(0)
|
||||
->and($admin->tokens()->where('name', 'test')->count())->toBe(1);
|
||||
|
||||
app('auth')->forgetGuards();
|
||||
$this->withHeader('Authorization', 'Bearer '.$browserToken)
|
||||
->getJson('/api/v1/admin/ping')
|
||||
->assertUnauthorized()
|
||||
->assertJsonPath('code', ErrorCode::AdminUnauthenticated->value);
|
||||
});
|
||||
|
||||
test('agent operator auth me omits platform-only navigation', function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
use App\Models\Player;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -345,3 +346,505 @@ test('credit wallet logs page 2 balance_after continues from page 1 baseline', f
|
||||
// Page 2 must continue the running balance from page 1, capped by credit limit.
|
||||
expect($page2FirstBalanceAfter)->toBe(min($page1LastBalanceAfter + 1000, 50000));
|
||||
});
|
||||
|
||||
test('credit player activity merges hold release loss and rebate into one order result', function (): void {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'default_site',
|
||||
'site_player_id' => 'native:activity-loss',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'credit_activity_loss',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 500,
|
||||
'used_credit' => 21,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$drawId = (int) DB::table('draws')->insertGetId([
|
||||
'draw_no' => 'ACTIVITY-LOSS-001',
|
||||
'business_date' => now()->toDateString(),
|
||||
'sequence_no' => 1,
|
||||
'status' => 'settled',
|
||||
'created_at' => now()->subMinutes(10),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$orderId = (int) DB::table('ticket_orders')->insertGetId([
|
||||
'order_no' => 'ORDER-ACTIVITY-LOSS-001',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 2100,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 2100,
|
||||
'total_estimated_payout' => 50000,
|
||||
'status' => 'placed',
|
||||
'created_at' => now()->subMinutes(10),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$ticketItemId = (int) DB::table('ticket_items')->insertGetId([
|
||||
'ticket_no' => 'TICKET-ACTIVITY-LOSS-001',
|
||||
'order_id' => $orderId,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => '4d',
|
||||
'total_bet_amount' => 2100,
|
||||
'actual_deduct_amount' => 2100,
|
||||
'status' => 'settled_lose',
|
||||
'win_amount' => 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
'settled_at' => now(),
|
||||
'created_at' => now()->subMinutes(10),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('share_ledger')->insert([
|
||||
'ticket_item_id' => $ticketItemId,
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => null,
|
||||
'game_win_loss' => 2100,
|
||||
'basic_rebate' => 11,
|
||||
'shared_net_win_loss' => 2089,
|
||||
'settled_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('rebate_records')->insert([
|
||||
'player_id' => $player->id,
|
||||
'ticket_item_id' => $ticketItemId,
|
||||
'game_type' => '4d',
|
||||
'valid_bet_amount' => 2100,
|
||||
'rebate_rate' => 0.0052,
|
||||
'rebate_amount' => 11,
|
||||
'rebate_type' => 'basic',
|
||||
'owner_agent_id' => null,
|
||||
'status' => 'accrued',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('credit_ledger')->insert([
|
||||
[
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => -2100,
|
||||
'reason' => 'bet_hold',
|
||||
'ref_type' => 'ticket_order',
|
||||
'ref_id' => $orderId,
|
||||
'created_at' => now()->subMinutes(10),
|
||||
'updated_at' => now()->subMinutes(10),
|
||||
],
|
||||
[
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => 2100,
|
||||
'reason' => 'bet_hold_release',
|
||||
'ref_type' => 'ticket_item',
|
||||
'ref_id' => $ticketItemId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => -2100,
|
||||
'reason' => 'game_settlement_loss',
|
||||
'ref_type' => 'ticket_item',
|
||||
'ref_id' => $ticketItemId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?page=1&size=10')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonPath('data.items.0.activity_kind', 'draw_result')
|
||||
->assertJsonPath('data.items.0.biz_type', 'settled_loss')
|
||||
->assertJsonPath('data.items.0.order_no', 'ORDER-ACTIVITY-LOSS-001')
|
||||
->assertJsonPath('data.items.0.draw_no', 'ACTIVITY-LOSS-001')
|
||||
->assertJsonPath('data.items.0.ticket_no', 'TICKET-ACTIVITY-LOSS-001')
|
||||
->assertJsonPath('data.items.0.stake_amount', 2100)
|
||||
->assertJsonPath('data.items.0.rebate_amount', 11)
|
||||
->assertJsonPath('data.items.0.net_amount', -2089)
|
||||
->assertJsonPath('data.items.0.balance_after', 47900);
|
||||
|
||||
expect($response->json('data.items'))->toHaveCount(1);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?type=bet')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 0);
|
||||
|
||||
$newerOrderId = (int) DB::table('ticket_orders')->insertGetId([
|
||||
'order_no' => 'ORDER-ACTIVITY-PENDING-001',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 100,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 100,
|
||||
'total_estimated_payout' => 1000,
|
||||
'status' => 'placed',
|
||||
'created_at' => now()->addSecond(),
|
||||
'updated_at' => now()->addSecond(),
|
||||
]);
|
||||
DB::table('ticket_items')->insert([
|
||||
'ticket_no' => 'TICKET-ACTIVITY-PENDING-001',
|
||||
'order_id' => $newerOrderId,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'original_number' => '5678',
|
||||
'normalized_number' => '5678',
|
||||
'play_code' => '4d',
|
||||
'total_bet_amount' => 100,
|
||||
'actual_deduct_amount' => 100,
|
||||
'status' => 'pending_draw',
|
||||
'created_at' => now()->addSecond(),
|
||||
'updated_at' => now()->addSecond(),
|
||||
]);
|
||||
DB::table('player_credit_accounts')
|
||||
->where('player_id', $player->id)
|
||||
->update(['used_credit' => 22, 'updated_at' => now()]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?type=game_settlement')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonPath('data.items.0.balance_after', 47900);
|
||||
});
|
||||
|
||||
test('credit player activities describe wins refunds and settlement reversals in player terms', function (): void {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'default_site',
|
||||
'site_player_id' => 'native:activity-outcomes',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'credit_activity_outcomes',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 1000,
|
||||
'used_credit' => 7,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$drawId = (int) DB::table('draws')->insertGetId([
|
||||
'draw_no' => 'ACTIVITY-OUTCOMES-001',
|
||||
'business_date' => now()->toDateString(),
|
||||
'sequence_no' => 1,
|
||||
'status' => 'open',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$createOrder = function (
|
||||
string $orderNo,
|
||||
string $ticketNo,
|
||||
string $orderStatus,
|
||||
string $ticketStatus,
|
||||
int $stake,
|
||||
int $minutesAgo,
|
||||
) use ($player, $drawId): array {
|
||||
$createdAt = now()->subMinutes($minutesAgo);
|
||||
$orderId = (int) DB::table('ticket_orders')->insertGetId([
|
||||
'order_no' => $orderNo,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => $stake,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => $stake,
|
||||
'total_estimated_payout' => 5000,
|
||||
'status' => $orderStatus,
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $createdAt,
|
||||
]);
|
||||
$ticketId = (int) DB::table('ticket_items')->insertGetId([
|
||||
'ticket_no' => $ticketNo,
|
||||
'order_id' => $orderId,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => '4d',
|
||||
'total_bet_amount' => $stake,
|
||||
'actual_deduct_amount' => $stake,
|
||||
'status' => $ticketStatus,
|
||||
'win_amount' => $ticketStatus === 'settled_win' ? 4000 : 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
'settled_at' => $ticketStatus === 'settled_win' ? $createdAt : null,
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $createdAt,
|
||||
]);
|
||||
|
||||
return [$orderId, $ticketId];
|
||||
};
|
||||
|
||||
[, $winningTicketId] = $createOrder(
|
||||
'ORDER-ACTIVITY-WIN-001',
|
||||
'TICKET-ACTIVITY-WIN-001',
|
||||
'placed',
|
||||
'settled_win',
|
||||
1000,
|
||||
3,
|
||||
);
|
||||
DB::table('share_ledger')->insert([
|
||||
'ticket_item_id' => $winningTicketId,
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => null,
|
||||
'game_win_loss' => -4000,
|
||||
'basic_rebate' => 0,
|
||||
'shared_net_win_loss' => -4000,
|
||||
'settled_at' => now()->subMinutes(3),
|
||||
'created_at' => now()->subMinutes(3),
|
||||
'updated_at' => now()->subMinutes(3),
|
||||
]);
|
||||
|
||||
$createOrder(
|
||||
'ORDER-ACTIVITY-REFUND-001',
|
||||
'TICKET-ACTIVITY-REFUND-001',
|
||||
'refunded',
|
||||
'refunded',
|
||||
500,
|
||||
2,
|
||||
);
|
||||
|
||||
[, $reversedTicketId] = $createOrder(
|
||||
'ORDER-ACTIVITY-REVERSED-001',
|
||||
'TICKET-ACTIVITY-REVERSED-001',
|
||||
'placed',
|
||||
'pending_draw',
|
||||
700,
|
||||
1,
|
||||
);
|
||||
$originalShareId = (int) DB::table('share_ledger')->insertGetId([
|
||||
'ticket_item_id' => $reversedTicketId,
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => null,
|
||||
'game_win_loss' => 700,
|
||||
'basic_rebate' => 0,
|
||||
'shared_net_win_loss' => 700,
|
||||
'settled_at' => now()->subMinute(),
|
||||
'created_at' => now()->subMinute(),
|
||||
'updated_at' => now()->subMinute(),
|
||||
]);
|
||||
DB::table('share_ledger')->insert([
|
||||
'ticket_item_id' => $reversedTicketId,
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => null,
|
||||
'game_win_loss' => -700,
|
||||
'basic_rebate' => 0,
|
||||
'shared_net_win_loss' => -700,
|
||||
'reversal_of_id' => $originalShareId,
|
||||
'settled_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$items = collect($this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?page=1&size=10')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 3)
|
||||
->json('data.items'))
|
||||
->keyBy('order_no');
|
||||
|
||||
expect($items['ORDER-ACTIVITY-WIN-001']['activity_kind'])->toBe('draw_result')
|
||||
->and($items['ORDER-ACTIVITY-WIN-001']['biz_type'])->toBe('settled_win')
|
||||
->and($items['ORDER-ACTIVITY-WIN-001']['win_amount'])->toBe(4000)
|
||||
->and($items['ORDER-ACTIVITY-WIN-001']['net_amount'])->toBe(4000)
|
||||
->and($items['ORDER-ACTIVITY-REFUND-001']['activity_kind'])->toBe('refund')
|
||||
->and($items['ORDER-ACTIVITY-REFUND-001']['biz_type'])->toBe('bet_refund')
|
||||
->and($items['ORDER-ACTIVITY-REFUND-001']['net_amount'])->toBe(500)
|
||||
->and($items['ORDER-ACTIVITY-REVERSED-001']['activity_kind'])->toBe('bet')
|
||||
->and($items['ORDER-ACTIVITY-REVERSED-001']['activity_status'])->toBe('pending')
|
||||
->and($items['ORDER-ACTIVITY-REVERSED-001']['net_amount'])->toBe(-700);
|
||||
});
|
||||
|
||||
test('credit hold records the real ticket order reference when provided', function (): void {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'default_site',
|
||||
'site_player_id' => 'native:hold-order-ref',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'credit_hold_order_ref',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 500,
|
||||
'used_credit' => 0,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
app(PlayerCreditService::class)->holdForBet($player, 2100, 77);
|
||||
|
||||
$row = DB::table('credit_ledger')->where('owner_id', $player->id)->first();
|
||||
expect($row?->ref_type)->toBe('ticket_order')
|
||||
->and((int) $row?->ref_id)->toBe(77);
|
||||
});
|
||||
|
||||
test('credit player activity shows each partial period payment as one player-facing record', function (): void {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'default_site',
|
||||
'site_player_id' => 'native:period-payments',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'credit_period_payments',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 500,
|
||||
'used_credit' => 0,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$siteId = (int) DB::table('admin_sites')->where('code', 'default_site')->value('id');
|
||||
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => $siteId,
|
||||
'period_start' => now()->subWeek(),
|
||||
'period_end' => now(),
|
||||
'status' => 'closed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$billId = (int) DB::table('settlement_bills')->insertGetId([
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'player',
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'counterparty_type' => 'agent',
|
||||
'counterparty_id' => 0,
|
||||
'net_amount' => 300,
|
||||
'paid_amount' => 300,
|
||||
'unpaid_amount' => 0,
|
||||
'status' => 'settled',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('payment_records')->insert([
|
||||
[
|
||||
'settlement_bill_id' => $billId,
|
||||
'payer_type' => 'player',
|
||||
'payer_id' => $player->id,
|
||||
'payee_type' => 'agent',
|
||||
'payee_id' => 0,
|
||||
'amount' => 100,
|
||||
'status' => 'confirmed',
|
||||
'confirmed_at' => now()->subMinute(),
|
||||
'created_at' => now()->subMinute(),
|
||||
'updated_at' => now()->subMinute(),
|
||||
],
|
||||
[
|
||||
'settlement_bill_id' => $billId,
|
||||
'payer_type' => 'player',
|
||||
'payer_id' => $player->id,
|
||||
'payee_type' => 'agent',
|
||||
'payee_id' => 0,
|
||||
'amount' => 200,
|
||||
'status' => 'confirmed',
|
||||
'confirmed_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?type=bill_settlement')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 2)
|
||||
->assertJsonPath('data.items.0.activity_kind', 'period_settlement')
|
||||
->assertJsonPath('data.items.0.biz_type', 'period_paid')
|
||||
->assertJsonPath('data.items.0.net_amount', -200)
|
||||
->assertJsonPath('data.items.1.net_amount', -100);
|
||||
});
|
||||
|
||||
test('credit player activity paginates after grouping orders', function (): void {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'default_site',
|
||||
'site_player_id' => 'native:activity-pages',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'credit_activity_pages',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 500,
|
||||
'used_credit' => 11,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$drawId = (int) DB::table('draws')->insertGetId([
|
||||
'draw_no' => 'ACTIVITY-PAGE-001',
|
||||
'business_date' => now()->toDateString(),
|
||||
'sequence_no' => 1,
|
||||
'status' => 'open',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
for ($i = 1; $i <= 11; $i++) {
|
||||
$placedAt = now()->subMinutes(11 - $i);
|
||||
$orderId = (int) DB::table('ticket_orders')->insertGetId([
|
||||
'order_no' => 'ORDER-ACTIVITY-PAGE-'.$i,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 100,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 100,
|
||||
'total_estimated_payout' => 1000,
|
||||
'status' => 'placed',
|
||||
'created_at' => $placedAt,
|
||||
'updated_at' => $placedAt,
|
||||
]);
|
||||
DB::table('ticket_items')->insert([
|
||||
'ticket_no' => 'TICKET-ACTIVITY-PAGE-'.$i,
|
||||
'order_id' => $orderId,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => '4d',
|
||||
'total_bet_amount' => 100,
|
||||
'actual_deduct_amount' => 100,
|
||||
'status' => 'pending_draw',
|
||||
'created_at' => $placedAt,
|
||||
'updated_at' => $placedAt,
|
||||
]);
|
||||
}
|
||||
|
||||
$page1 = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?page=1&size=10')
|
||||
->assertOk()
|
||||
->json('data');
|
||||
$page2 = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?page=2&size=10')
|
||||
->assertOk()
|
||||
->json('data');
|
||||
|
||||
expect($page1['total'])->toBe(11)
|
||||
->and($page1['items'])->toHaveCount(10)
|
||||
->and($page2['items'])->toHaveCount(1)
|
||||
->and($page2['items'][0]['activity_kind'])->toBe('bet');
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\BetProvider;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\BetProvider;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
@@ -134,6 +134,29 @@ test('draw result show includes neighbor draw numbers', function (): void {
|
||||
->assertJsonPath('data.next_draw_no', '20260509-102');
|
||||
});
|
||||
|
||||
test('draw result show accepts sequence numbers longer than three digits', function (): void {
|
||||
seedMinimalPublishedDraw([
|
||||
'draw_no' => '20260509-1001',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 1001,
|
||||
'status' => DrawStatus::Settled->value,
|
||||
'start_time' => now()->subHour(),
|
||||
'close_time' => now()->subMinutes(45),
|
||||
'draw_time' => now()->subMinutes(30),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => 'rng',
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 1,
|
||||
'is_reopened' => false,
|
||||
], '4');
|
||||
|
||||
$this->getJson('/api/v1/draw/results/20260509-1001')
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.draw_no', '20260509-1001')
|
||||
->assertJsonPath('data.results.1st', '4444');
|
||||
});
|
||||
|
||||
test('draw results include all published provider batches for the same draw', function (): void {
|
||||
BetProvider::query()->updateOrCreate(['code' => 'SG'], ['name' => 'Singapore', 'is_enabled' => true, 'sort_order' => 10]);
|
||||
BetProvider::query()->updateOrCreate(['code' => 'MY'], ['name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]);
|
||||
|
||||
158
tests/Feature/DrawSettlementRunApiTest.php
Normal file
158
tests/Feature/DrawSettlementRunApiTest.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\AdminUser;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\SettlementBatch;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Support\AdminAuthorizationRegistry;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createDrawForManualSettlement(
|
||||
string $status = 'cooldown',
|
||||
bool $withPublishedResult = true,
|
||||
): Draw {
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260722-'.random_int(1000, 9999),
|
||||
'business_date' => '2026-07-22',
|
||||
'sequence_no' => random_int(1000, 9999),
|
||||
'status' => $status,
|
||||
'start_time' => now()->subMinutes(20),
|
||||
'close_time' => now()->subMinutes(10),
|
||||
'draw_time' => now()->subMinutes(9),
|
||||
'cooling_end_time' => now()->addMinutes(10),
|
||||
'current_result_version' => $withPublishedResult ? 1 : 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
if ($withPublishedResult) {
|
||||
DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'provider_code' => 'SG',
|
||||
'provider_name' => 'Singapore',
|
||||
'result_version' => 1,
|
||||
'source_type' => 'rng',
|
||||
'status' => DrawResultBatchStatus::Published->value,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $draw;
|
||||
}
|
||||
|
||||
function manualSettlementAdminToken(): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'manual_settle_'.bin2hex(random_bytes(3)),
|
||||
'name' => 'Manual Settlement',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
|
||||
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
}
|
||||
|
||||
test('payout manager may skip cooldown and settlement retry is idempotent', function (): void {
|
||||
$draw = createDrawForManualSettlement();
|
||||
$originalCoolingEnd = $draw->cooling_end_time;
|
||||
$token = manualSettlementAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.ran', true)
|
||||
->assertJsonPath('data.cooldown_skipped', true)
|
||||
->assertJsonPath('data.status', DrawStatus::Settling->value)
|
||||
->assertJsonPath('data.settle_version', 1);
|
||||
|
||||
$draw->refresh();
|
||||
expect($draw->status)->toBe(DrawStatus::Settling->value)
|
||||
->and($draw->cooling_end_time)->not->toBeNull()
|
||||
->and($draw->cooling_end_time->lessThan($originalCoolingEnd))->toBeTrue()
|
||||
->and(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.ran', true)
|
||||
->assertJsonPath('data.cooldown_skipped', false)
|
||||
->assertJsonPath('data.settle_version', 1);
|
||||
|
||||
expect(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('manual settlement rejects missing results and invalid draw states without mutation', function (): void {
|
||||
$token = manualSettlementAdminToken();
|
||||
$missingResult = createDrawForManualSettlement(withPublishedResult: false);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$missingResult->id}/settlement/run")
|
||||
->assertStatus(409)
|
||||
->assertJsonPath('data.reason', 'draw_result_not_published');
|
||||
|
||||
expect($missingResult->fresh()->status)->toBe(DrawStatus::Cooldown->value)
|
||||
->and(SettlementBatch::query()->where('draw_id', $missingResult->id)->exists())->toBeFalse();
|
||||
|
||||
$openDraw = createDrawForManualSettlement(status: DrawStatus::Open->value);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$openDraw->id}/settlement/run")
|
||||
->assertStatus(409)
|
||||
->assertJsonPath('data.reason', 'draw_not_ready_for_settlement');
|
||||
|
||||
expect($openDraw->fresh()->status)->toBe(DrawStatus::Open->value)
|
||||
->and(SettlementBatch::query()->where('draw_id', $openDraw->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('manual settlement resource is restricted to settlement management and audited', function (): void {
|
||||
$resource = collect(AdminAuthorizationRegistry::resources())
|
||||
->firstWhere('code', 'admin.draws.settlement.run');
|
||||
|
||||
expect($resource)->not->toBeNull()
|
||||
->and($resource['permission_codes'])->toBe(['settlement.batch.manage'])
|
||||
->and($resource['is_audit_required'])->toBeTrue();
|
||||
|
||||
$draw = createDrawForManualSettlement();
|
||||
$token = manualSettlementAdminToken();
|
||||
$before = AuditLog::query()->count();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run")
|
||||
->assertOk();
|
||||
|
||||
expect(AuditLog::query()->count())->toBe($before + 1);
|
||||
});
|
||||
|
||||
test('draw finance summary exposes provider and result settlement versions', function (): void {
|
||||
$draw = createDrawForManualSettlement(status: DrawStatus::Settling->value);
|
||||
$result = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
SettlementBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_batch_id' => $result->id,
|
||||
'settle_version' => 3,
|
||||
'status' => 'pending_review',
|
||||
'total_ticket_count' => 0,
|
||||
'total_win_count' => 0,
|
||||
'total_payout_amount' => 0,
|
||||
'total_jackpot_payout_amount' => 0,
|
||||
'started_at' => now(),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
$token = manualSettlementAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson("/api/v1/admin/draws/{$draw->id}/finance-summary")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.settlement_batches.0.provider_code', 'SG')
|
||||
->assertJsonPath('data.settlement_batches.0.provider_name', 'Singapore')
|
||||
->assertJsonPath('data.settlement_batches.0.result_version', 1)
|
||||
->assertJsonPath('data.settlement_batches.0.settle_version', 3);
|
||||
});
|
||||
@@ -10,8 +10,10 @@ use Illuminate\Support\Facades\DB;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use App\Events\PlayerSessionReplacedBroadcast;
|
||||
use App\Services\Player\PlayerNativeAuthService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
@@ -244,6 +246,63 @@ test('native player can login and access me', function (): void {
|
||||
->assertJsonPath('data.auth_source', PlayerAuthSource::LOTTERY_NATIVE);
|
||||
});
|
||||
|
||||
test('latest native login replaces the previous device session', function (): void {
|
||||
Event::fake([PlayerSessionReplacedBroadcast::class]);
|
||||
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => (string) $site->code,
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'native:single-session',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'single_session_player',
|
||||
'password_hash' => Hash::make('secret-pass'),
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$firstLogin = $this->postJson('/api/v1/player/auth/login', array_merge([
|
||||
'username' => 'single_session_player',
|
||||
'password' => 'secret-pass',
|
||||
], playerLoginCaptcha()))->assertOk();
|
||||
$firstToken = (string) $firstLogin->json('data.access_token');
|
||||
|
||||
expect($player->fresh()->native_session_version)->toBe(1);
|
||||
$this->withHeader('Authorization', 'Bearer '.$firstToken)
|
||||
->getJson('/api/v1/player/me')
|
||||
->assertOk();
|
||||
|
||||
$secondLogin = $this->postJson('/api/v1/player/auth/login', array_merge([
|
||||
'username' => 'single_session_player',
|
||||
'password' => 'secret-pass',
|
||||
], playerLoginCaptcha()))->assertOk();
|
||||
$secondToken = (string) $secondLogin->json('data.access_token');
|
||||
|
||||
expect($secondToken)->not->toBe($firstToken)
|
||||
->and($player->fresh()->native_session_version)->toBe(2);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$firstToken)
|
||||
->getJson('/api/v1/player/me')
|
||||
->assertStatus(401)
|
||||
->assertJsonPath('code', ErrorCode::PlayerSessionReplaced->value);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$secondToken)
|
||||
->getJson('/api/v1/player/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.id', $player->id);
|
||||
|
||||
Event::assertDispatchedTimes(PlayerSessionReplacedBroadcast::class, 2);
|
||||
Event::assertDispatched(
|
||||
PlayerSessionReplacedBroadcast::class,
|
||||
fn (PlayerSessionReplacedBroadcast $event): bool => $event->playerId === (int) $player->id
|
||||
&& $event->sessionVersion === 2,
|
||||
);
|
||||
});
|
||||
|
||||
test('credit player wallet transfer in is rejected', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Events\PlayCatalogUpdatedBroadcast;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use App\Services\Config\RiskCapStreamService;
|
||||
use App\Events\PlayerSessionReplacedBroadcast;
|
||||
use App\Services\Wallet\LotteryTransferService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use App\Services\Wallet\WalletBalanceRealtimeNotifier;
|
||||
@@ -39,6 +40,21 @@ test('balance update broadcasts only on the player private channel', function ()
|
||||
->and($channels[0]->name)->toBe('private-player.42');
|
||||
});
|
||||
|
||||
test('session replacement broadcasts the monotonic version on the player private channel', function (): void {
|
||||
$event = new PlayerSessionReplacedBroadcast(42, 3, 1_234_567);
|
||||
$channels = $event->broadcastOn();
|
||||
|
||||
expect($channels)->toHaveCount(1)
|
||||
->and($channels[0])->toBeInstanceOf(PrivateChannel::class)
|
||||
->and($channels[0]->name)->toBe('private-player.42')
|
||||
->and($event->broadcastAs())->toBe('session.replaced')
|
||||
->and($event->broadcastWith())->toBe([
|
||||
'player_id' => 42,
|
||||
'session_version' => 3,
|
||||
'emitted_at_ms' => 1_234_567,
|
||||
]);
|
||||
});
|
||||
|
||||
test('player private channel auth allows only the matching bearer player', function (): void {
|
||||
config([
|
||||
'broadcasting.default' => 'reverb',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\TicketItem;
|
||||
@@ -444,9 +445,24 @@ test('settlement tick finalizer keeps approved batch retryable when payout throw
|
||||
|
||||
expect($result['payout_failed'])->toBe(1)
|
||||
->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value)
|
||||
->and($batch->fresh()->review_remark)->toContain('auto_payout_failed');
|
||||
->and($batch->fresh()->review_remark)->toContain('auto_payout_failed')
|
||||
->and((int) $batch->fresh()->auto_payout_attempts)->toBe(1)
|
||||
->and(AuditLog::query()
|
||||
->where('action_code', 'auto_payout_failed')
|
||||
->where('target_id', (string) $batch->id)
|
||||
->count())->toBe(1);
|
||||
|
||||
$immediateRetry = app(SettlementTickFinalizer::class)->finalizePendingBatches();
|
||||
|
||||
expect($immediateRetry)->toMatchArray(['approved' => 0, 'paid' => 0, 'payout_failed' => 0])
|
||||
->and((int) $batch->fresh()->auto_payout_attempts)->toBe(1)
|
||||
->and(AuditLog::query()
|
||||
->where('action_code', 'auto_payout_failed')
|
||||
->where('target_id', (string) $batch->id)
|
||||
->count())->toBe(1);
|
||||
|
||||
DB::table('ticket_items')->where('id', $orphanItemId)->delete();
|
||||
$this->travel((int) config('lottery.auto_payout_retry_base_seconds'))->seconds();
|
||||
$retry = app(SettlementTickFinalizer::class)->finalizePendingBatches();
|
||||
|
||||
expect($retry)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0])
|
||||
|
||||
@@ -2,24 +2,26 @@
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\TicketItem;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\JackpotPool;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\SettlementBatch;
|
||||
use App\Services\Draw\DrawPrizeLayout;
|
||||
use App\Services\Settlement\SettlementOrchestrator;
|
||||
use App\Services\Settlement\SettlementBatchWorkflowService;
|
||||
use App\Models\TicketCombination;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\PlayTypeSeeder;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Services\Draw\DrawPrizeLayout;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Database\Seeders\OperationalConfigV1Seeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use App\Services\Settlement\SettlementOrchestrator;
|
||||
use App\Services\Settlement\SettlementBatchWorkflowService;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -171,6 +173,12 @@ test('ticket items index returns placed ticket for player', function (): void {
|
||||
->assertJsonPath('data.ticket_no', $ticketNo)
|
||||
->assertJsonPath('data.combinations.0.number_4d', '1234');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/ticket/items?ticket_no='.urlencode($ticketNo))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonPath('data.items.0.ticket_no', $ticketNo);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/ticket/items?draw_no='.urlencode('20260511-777'))
|
||||
->assertOk()
|
||||
@@ -313,7 +321,7 @@ test('ticket item show returns match result and timeline', function (): void {
|
||||
|
||||
ticketItemsPublishAndSettle($draw, '1234');
|
||||
|
||||
$ticketNo = \App\Models\TicketItem::query()->where('draw_id', $draw->id)->value('ticket_no');
|
||||
$ticketNo = TicketItem::query()->where('draw_id', $draw->id)->value('ticket_no');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/ticket/items/'.$ticketNo)
|
||||
@@ -348,9 +356,9 @@ test('my-match returns hit numbers when draw settled with winning ticket', funct
|
||||
]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260511-778',
|
||||
'draw_no' => '20260511-1778',
|
||||
'business_date' => '2026-05-11',
|
||||
'sequence_no' => 778,
|
||||
'sequence_no' => 1778,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => now()->subMinutes(2),
|
||||
'close_time' => now()->addMinutes(5),
|
||||
@@ -364,7 +372,7 @@ test('my-match returns hit numbers when draw settled with winning ticket', funct
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => '20260511-778',
|
||||
'draw_id' => '20260511-1778',
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'match-trace-1',
|
||||
'lines' => [
|
||||
@@ -376,7 +384,7 @@ test('my-match returns hit numbers when draw settled with winning ticket', funct
|
||||
ticketItemsPublishAndSettle($draw, '1234');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/ticket/draws/20260511-778/my-match')
|
||||
->getJson('/api/v1/ticket/draws/20260511-1778/my-match')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.has_bets', true)
|
||||
->assertJsonPath('data.winning_ticket_count', 1)
|
||||
@@ -441,7 +449,7 @@ test('my-match only highlights settled winning tickets', function (): void {
|
||||
'client_trace_id' => 'pending-match',
|
||||
]);
|
||||
|
||||
$item = \App\Models\TicketItem::query()->create([
|
||||
$item = TicketItem::query()->create([
|
||||
'ticket_no' => 'TKPENDINGMATCH',
|
||||
'order_id' => $order->id,
|
||||
'player_id' => $player->id,
|
||||
@@ -467,7 +475,7 @@ test('my-match only highlights settled winning tickets', function (): void {
|
||||
'jackpot_win_amount' => 0,
|
||||
]);
|
||||
|
||||
\App\Models\TicketCombination::query()->create([
|
||||
TicketCombination::query()->create([
|
||||
'ticket_item_id' => $item->id,
|
||||
'combination_no' => 0,
|
||||
'number_4d' => '1234',
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Contracts\WalletApiDnsResolver;
|
||||
use App\Events\DrawStatusChangeBroadcast;
|
||||
use App\Events\PlayCatalogUpdatedBroadcast;
|
||||
use App\Events\DrawResultPublishedBroadcast;
|
||||
use App\Events\PlayerSessionReplacedBroadcast;
|
||||
use App\Support\Integration\WalletApiRequestGuard;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
@@ -208,6 +209,7 @@ function allBroadcastEvents(): array
|
||||
OddsUpdateBroadcast::class,
|
||||
PlayCatalogUpdatedBroadcast::class,
|
||||
PlayToggleBroadcast::class,
|
||||
PlayerSessionReplacedBroadcast::class,
|
||||
RiskSoldOutBroadcast::class,
|
||||
RiskWarningBroadcast::class,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user