feat(core): harden sessions settlement and credit activity
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled

This commit is contained in:
wchino
2026-07-22 20:53:43 +08:00
parent 35f6e46958
commit 15bd997c4e
48 changed files with 1920 additions and 93 deletions

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

View File

@@ -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',

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

View File

@@ -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,

View File

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

View File

@@ -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);

View File

@@ -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);

View File

@@ -127,6 +127,9 @@ enum ErrorCode: int
/** 原生登录:验证码错误或过期 */
case PlayerCaptchaInvalid = 8009;
/** 原生登录:当前会话已被同账号的较新登录替换 */
case PlayerSessionReplaced = 8010;
/* ========== 81008199 管理端 API ========== */
/** 未登录或 Token 无效 */
@@ -144,6 +147,9 @@ enum ErrorCode: int
/** 已登录但无 RBAC 权限 */
case AdminForbidden = 8114;
/** 当前管理端会话已被同账号的较新登录替换 */
case AdminSessionReplaced = 8115;
/* ========== 90009999 系统 / 框架 ========== */
/** 表单或 Query 校验失败ValidationException → 422 */

View File

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

View File

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

View File

@@ -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

View File

@@ -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,

View File

@@ -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();

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

View File

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

View File

@@ -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,

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

View File

@@ -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

View File

@@ -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']],

View File

@@ -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' => '转账单',