diff --git a/.env.example b/.env.example index 821238e..5ce1f77 100644 --- a/.env.example +++ b/.env.example @@ -32,7 +32,13 @@ REVERB_SCHEME=http QUEUE_CONNECTION=redis CACHE_STORE=redis -LOTTERY_RISK_POOL_USE_REDIS_LUA=false +LOTTERY_RISK_POOL_USE_REDIS_LUA=true + +# 生产建议独立配置;留空则回落 MAIN_SITE_SSO_JWT_SECRET(勿在生产与 SSO 混用) +# LOTTERY_NATIVE_JWT_SECRET= + +# 预发可设为 false,禁止代理账期关账 +AGENT_SETTLEMENT_ALLOW_PRODUCTION_CLOSE=true REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0688fd0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: lotterLaravel CI + +on: + push: + branches: [main, master, develop] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + services: + redis: + image: redis:7 + ports: ["6379:6379"] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + extensions: pdo_sqlite, redis + coverage: none + - run: composer install --no-interaction --prefer-dist + - run: cp .env.example .env && php artisan key:generate + - run: php artisan test diff --git a/AGENTS.md b/AGENTS.md index c0316ac..bcf735d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ - 期号 `close_time`/`draw_time` UTC 存储;下注由 `DrawHallSnapshotBuilder` 实时判定;列表展示 DB `status`,详情 API 有 `hall_preview_status`。 - `AgentProfileCapabilityFilter` 仅作用于**已绑定代理节点**的经营账号(按档案 `can_create_*` 收紧权限);**禁止**对无代理绑定的平台账号(如 `site_admin`)套用,否则会误剥 `prd.agent.manage` 等权限。绑定经营代理主账号统一绑 `slug=agent`,模板仅含 `prd.settlement.agent.view`;登录态对绑定代理主账号自动补足 `settlement.agent.manage`,实际操作仍受直属边 + 收款方校验。 -- 站点管理员(`admin_user_site_roles` + `slug=site_admin|site_finance|site_cs`,且**未**绑 `admin_user_agents`)定位单站运营;`site_admin` 含代理树/玩家/信用结算/注单 + 本站钱包流水·对账·经营报表(可导出)·期号只读;`site_finance` 财务工作台 + 对账/报表/结算收付;`site_cs` 客服工作台 + 单玩家查询。数据范围仅绑定站点;不含开奖赔率等平台技术权限;开通一级代理线路仅超管(`prd.agent-line.provision`)。 +- 站点运营(`admin_user_site_roles` + `slug=site_admin|site_finance|site_cs`,且**未**绑 `admin_user_agents`)看**本站资金+信用**,数据范围仅绑定站点。`site_admin`:代理树/玩家/信用结算/注单 + 钱包流水·对账·经营报表(可导出)·期号只读;`site_finance`:财务工作台 + 对账/报表/结算收付;`site_cs`:客服工作台 + 单玩家查询。模板见 `Site*DefaultRolePermissions`/`SiteOperatorRoles`;不含开奖赔率等平台技术权限;开通一级代理线路仅超管(`prd.agent-line.provision`)。 - 结算中心登记收付/确认/坏账/补差 UI 需 `prd.settlement.agent.manage`(`canManage`);仅 view 时操作区静默隐藏。另需账单 `status` ∈ confirmed/partial_paid/overdue 且 `unpaid_amount > 0`。**坏账核销 / 补差冲正** 另需未绑定代理(站点财务,`canFinanceAdjustments`),绑定代理仅有收付/确认。绑定代理账单可见范围:**玩家账单**仅直属玩家;**代理账单**仅 `owner=本节点` 或 `counterparty=本节点`;登记收付/确认仅可操作 **收款方**。 - 收付/调账/坏账后端落库 `payment_records`、`settlement_adjustments`;账期详情 **收付与调账** Tab 查操作台账,**账务流水** 仅玩家信用变动;单张账单详情内另有该账单的收付列表。 - 线上生产:已有库用 `php artisan lottery:db-init --no-demo`(含 RBAC sync);常驻 `schedule:work`、`queue:work redis --queue=broadcasts:countdown,broadcasts,default`、`reverb:start`;`CACHE_STORE`/`QUEUE_CONNECTION` 须 Redis;先部署 lotterLaravel 再前端。 diff --git a/app/Services/Admin/AdminReportQueryService.php b/app/Services/Admin/AdminReportQueryService.php index 3d5d6f8..328c16a 100644 --- a/app/Services/Admin/AdminReportQueryService.php +++ b/app/Services/Admin/AdminReportQueryService.php @@ -620,7 +620,8 @@ final class AdminReportQueryService $q->whereDate('created_at', '>=', $dateFrom) ->whereDate('created_at', '<=', $dateTo); - foreach ($q->limit(5000)->get() as $log) { + $limited = \App\Support\LimitedQuery::get($q, 5000); + foreach ($limited['rows'] as $log) { $rows[] = [ (int) $log->id, (string) $log->operator_type, @@ -632,6 +633,10 @@ final class AdminReportQueryService ]; } + if ($limited['truncated']) { + $rows[] = ['警告', '审计日志已截断至 5000 条,请缩小日期范围后重试']; + } + return $rows; } @@ -782,6 +787,10 @@ final class AdminReportQueryService ]; } + if ($limited['truncated']) { + array_unshift($rows, ['警告', '审计日志已截断至 5000 条,请缩小日期范围后重试']); + } + return $rows; } diff --git a/app/Services/AgentSettlement/SettlementCenterLedgerService.php b/app/Services/AgentSettlement/SettlementCenterLedgerService.php index e630446..32c6737 100644 --- a/app/Services/AgentSettlement/SettlementCenterLedgerService.php +++ b/app/Services/AgentSettlement/SettlementCenterLedgerService.php @@ -6,6 +6,7 @@ use App\Models\AdminUser; use App\Support\AdminAgentSettlementScope; use App\Support\AgentSettlementPeriodWindow; use App\Support\CurrencyFormatter; +use App\Support\LimitedQuery; use App\Support\PlayerFundingMode; use Carbon\Carbon; use Illuminate\Support\Facades\DB; @@ -53,7 +54,9 @@ final class SettlementCenterLedgerService $periodId = $filters->settlementPeriodId; $range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo); $settledRange = $this->resolveSettledRange($periodId, $filters->createdFrom, $filters->createdTo); - $playerBills = $this->playerBillsMap($admin, $siteCode, $periodId); + $playerBillsResult = $this->playerBillsMap($admin, $siteCode, $periodId); + $playerBills = $playerBillsResult['map']; + $billsTruncated = $playerBillsResult['truncated']; $stubQueries = []; if ($this->shouldIncludeLedgerStub($filters, 'credit')) { @@ -130,6 +133,7 @@ final class SettlementCenterLedgerService 'page' => $page, 'per_page' => $perPage, 'ledger_source' => 'settlement_ledger', + 'truncated' => $billsTruncated || $total > 5000, ]; } @@ -153,8 +157,12 @@ final class SettlementCenterLedgerService ): array { $periodId = $filters->settlementPeriodId; $range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo); - $rows = $this->fetchBetFlowCreditRows($admin, $siteCode, $range, $filters); - $playerBills = $this->playerBillsMap($admin, $siteCode, $periodId); + $fetched = $this->fetchBetFlowCreditRows($admin, $siteCode, $range, $filters); + $rows = $fetched['rows']; + $sourceTruncated = $fetched['truncated']; + $playerBillsResult = $this->playerBillsMap($admin, $siteCode, $periodId); + $playerBills = $playerBillsResult['map']; + $billsTruncated = $playerBillsResult['truncated']; $ticketIds = []; foreach ($rows as $row) { @@ -207,6 +215,7 @@ final class SettlementCenterLedgerService 'page' => $page, 'per_page' => $perPage, 'ledger_source' => 'credit_ledger', + 'truncated' => $sourceTruncated || $billsTruncated, ]; } @@ -811,7 +820,7 @@ final class SettlementCenterLedgerService } /** - * @return array + * @return array{map: array, truncated: bool} */ private function playerBillsMap(AdminUser $admin, string $siteCode, ?int $periodId): array { @@ -841,8 +850,9 @@ final class SettlementCenterLedgerService AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p'); + $limited = LimitedQuery::get($query, 500); $map = []; - foreach ($query->limit(500)->get() as $bill) { + foreach ($limited['rows'] as $bill) { $pid = (int) $bill->player_id; if (! isset($map[$pid])) { $map[$pid] = $bill; @@ -859,7 +869,10 @@ final class SettlementCenterLedgerService } } - return $map; + return [ + 'map' => $map, + 'truncated' => $limited['truncated'], + ]; } /** @@ -921,7 +934,7 @@ final class SettlementCenterLedgerService /** * @param array{0: Carbon, 1: Carbon}|null $range - * @return list + * @return array{rows: list, truncated: bool} */ private function fetchBetFlowCreditRows( AdminUser $admin, @@ -975,7 +988,12 @@ final class SettlementCenterLedgerService $query->whereBetween('cl.created_at', $range); } - return $query->limit(5000)->get()->all(); + $limited = LimitedQuery::get($query, 5000); + + return [ + 'rows' => $limited['rows']->all(), + 'truncated' => $limited['truncated'], + ]; } /** diff --git a/app/Services/Draw/DrawTickService.php b/app/Services/Draw/DrawTickService.php index 9d25624..3d8827e 100644 --- a/app/Services/Draw/DrawTickService.php +++ b/app/Services/Draw/DrawTickService.php @@ -179,6 +179,11 @@ final class DrawTickService } } catch (\Throwable $e) { report($e); + \Illuminate\Support\Facades\Log::warning('draw_tick_settlement_failed', [ + 'draw_id' => $draw->id, + 'draw_no' => $draw->draw_no, + 'error' => $e->getMessage(), + ]); } } diff --git a/app/Services/Player/PlayerCreditService.php b/app/Services/Player/PlayerCreditService.php index 71e0249..2ec89a5 100644 --- a/app/Services/Player/PlayerCreditService.php +++ b/app/Services/Player/PlayerCreditService.php @@ -62,6 +62,23 @@ final class PlayerCreditService return CreditAmountScale::majorToMinor($this->availableCredit($player), $currency); } + /** 逾期/代理线门禁与可用额度预检(不占额)。 */ + public function assertCreditPreflight(Player $player, int $amountMinor): void + { + if (! PlayerFundingMode::usesCredit($player) || $amountMinor <= 0) { + return; + } + + $this->assertCreditGuards($player); + + $currency = (string) $player->default_currency; + if ($amountMinor > $this->availableCreditMinor($player, $currency)) { + throw ValidationException::withMessages([ + 'credit' => ['insufficient'], + ]); + } + } + public function holdForBet(Player $player, int $amountMinor): void { if ($amountMinor <= 0) { @@ -73,22 +90,45 @@ final class PlayerCreditService } $currency = (string) $player->default_currency; - $availableMinor = $this->availableCreditMinor($player, $currency); + $majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency); + $now = now(); + + $row = DB::table('player_credit_accounts') + ->where('player_id', $player->id) + ->lockForUpdate() + ->first(); + + if ($row === null) { + throw ValidationException::withMessages([ + 'credit' => ['insufficient'], + ]); + } + + $availableMajor = max( + 0, + (int) $row->credit_limit - (int) $row->used_credit - (int) $row->frozen_credit, + ); + $availableMinor = CreditAmountScale::majorToMinor($availableMajor, $currency); if ($amountMinor > $availableMinor) { throw ValidationException::withMessages([ 'credit' => ['insufficient'], ]); } - $majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency); - - DB::table('player_credit_accounts') + $updated = DB::table('player_credit_accounts') ->where('player_id', $player->id) + ->whereRaw('credit_limit - used_credit - frozen_credit >= ?', [$majorDelta]) ->update([ 'used_credit' => DB::raw('used_credit + '.$majorDelta), - 'updated_at' => now(), + 'updated_at' => $now, ]); + if ($updated !== 1) { + throw ValidationException::withMessages([ + 'credit' => ['insufficient'], + ]); + } + DB::table('credit_ledger')->insert([ 'owner_type' => 'player', 'owner_id' => $player->id, @@ -96,8 +136,8 @@ final class PlayerCreditService 'reason' => 'bet_hold', 'ref_type' => 'bet', 'ref_id' => null, - 'created_at' => now(), - 'updated_at' => now(), + 'created_at' => $now, + 'updated_at' => $now, ]); } @@ -113,12 +153,13 @@ final class PlayerCreditService $currency = (string) $player->default_currency; $majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency); + $now = now(); DB::table('player_credit_accounts') ->where('player_id', $player->id) ->update([ 'used_credit' => DB::raw('used_credit + '.$majorDelta), - 'updated_at' => now(), + 'updated_at' => $now, ]); DB::table('credit_ledger')->insert([ @@ -163,6 +204,16 @@ final class PlayerCreditService return; } + $this->assertCreditGuards($player); + $this->holdForBet($player, $amountMinor); + } + + private function assertCreditGuards(Player $player): void + { + if (! PlayerFundingMode::usesCredit($player)) { + return; + } + $overdue = DB::table('settlement_bills') ->where('owner_type', 'player') ->where('owner_id', $player->id) @@ -181,8 +232,6 @@ final class PlayerCreditService AgentOverdueGuard::assertAgentMayGrantCredit($agentNodeId); AgentOverdueGuard::assertAgentLineMayPlaceBet($agentNodeId); } - - $this->holdForBet($player, $amountMinor); } public function releaseBetHold(Player $player, int $amountMinor, int $ticketItemId): void @@ -205,6 +254,26 @@ final class PlayerCreditService ]); } + public function reverseBetHold(Player $player, int $amountMinor): void + { + if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) { + return; + } + + $this->decreaseUsedCredit($player, $amountMinor); + + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => $amountMinor, + 'reason' => 'bet_hold_release', + 'ref_type' => 'bet', + 'ref_id' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + public function releaseFromSettlement(Player $player, int $amountMinor, int $billId): void { if ($amountMinor <= 0) { @@ -254,12 +323,16 @@ final class PlayerCreditService } $playerId = (int) $player->id; - $row = DB::table('player_credit_accounts')->where('player_id', $playerId)->first(); + $majorDelta = CreditAmountScale::minorToMajor($amountMinor, (string) $player->default_currency); + + $row = DB::table('player_credit_accounts') + ->where('player_id', $playerId) + ->lockForUpdate() + ->first(); if ($row === null) { return; } - $majorDelta = CreditAmountScale::minorToMajor($amountMinor, (string) $player->default_currency); $next = max(0, (int) $row->used_credit - $majorDelta); DB::table('player_credit_accounts') ->where('player_id', $playerId) diff --git a/app/Services/Ticket/RiskPoolService.php b/app/Services/Ticket/RiskPoolService.php index 50e5c6a..c9d4175 100644 --- a/app/Services/Ticket/RiskPoolService.php +++ b/app/Services/Ticket/RiskPoolService.php @@ -148,30 +148,7 @@ final class RiskPoolService foreach ($locks as $lock) { $number4d = $lock['number_4d']; $amount = (int) $lock['amount']; - $pool = $this->firstOrMakePool($drawId, $number4d); - $key = $this->redisPoolKey($drawId, $number4d); - - Redis::eval( - $this->initLua(), - 1, - $key, - (int) $pool->total_cap_amount, - (int) $pool->locked_amount, - (int) $pool->version, - $this->redisPoolTtlSeconds(), - ); - - $result = $this->normalizeLuaResult(Redis::eval( - $this->acquireLua(), - 1, - $key, - $amount, - (int) $pool->version, - $this->redisPoolTtlSeconds(), - )); - if (($result['code'] ?? null) !== 'OK') { - throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value); - } + $this->acquireRedisLockForCombination($drawId, $number4d, $amount); $acquired[] = ['number_4d' => $number4d, 'amount' => $amount]; $total += $amount; @@ -188,6 +165,20 @@ final class RiskPoolService return $total; } + /** + * DB 事务回滚时补偿 Redis 侧已占用额度(DB 锁行会随事务回滚)。 + * + * @param list $locks + */ + public function compensateRedisAcquires(int $drawId, array $locks): void + { + if ($locks === [] || ! $this->shouldUseRedisAtomicLocks()) { + return; + } + + $this->releaseRedisLocks($drawId, $locks); + } + /** * @param list $locks */ @@ -202,6 +193,66 @@ final class RiskPoolService } } + private function acquireRedisLockForCombination(int $drawId, string $number4d, int $amount): void + { + for ($attempt = 0; $attempt < 2; $attempt++) { + $pool = $this->firstOrMakePool($drawId, $number4d); + $key = $this->redisPoolKey($drawId, $number4d); + + Redis::eval( + $this->initLua(), + 1, + $key, + (int) $pool->total_cap_amount, + (int) $pool->locked_amount, + (int) $pool->version, + $this->redisPoolTtlSeconds(), + ); + + $result = $this->normalizeLuaResult(Redis::eval( + $this->acquireLua(), + 1, + $key, + $amount, + (int) $pool->version, + $this->redisPoolTtlSeconds(), + )); + + if (($result['code'] ?? null) === 'OK') { + return; + } + + if (($result['code'] ?? null) === 'INSUFFICIENT_CAP') { + throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value); + } + + if ($attempt === 0 && in_array($result['code'] ?? '', ['VERSION_CONFLICT', 'POOL_NOT_INITIALIZED'], true)) { + $freshPool = RiskPool::query() + ->where('draw_id', $drawId) + ->where('normalized_number', $number4d) + ->firstOrFail(); + $this->syncRedisStateFromPool($freshPool); + + continue; + } + + $this->throwForRedisAcquireFailure($result); + } + } + + /** + * @param array{code:string, remaining:int, locked:int, version:int} $result + */ + private function throwForRedisAcquireFailure(array $result): void + { + throw new TicketOperationException( + 'risk_pool_unavailable', + ErrorCode::InternalError->value, + 503, + ['redis_code' => $result['code'] ?? 'unknown'], + ); + } + public function publishManualSoldOut(Draw $draw, string $normalizedNumber): void { $this->riskRealtime->publishManualSoldOut($draw, $normalizedNumber); diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php index 7f7dbed..883aebf 100644 --- a/app/Services/Ticket/TicketPlacementService.php +++ b/app/Services/Ticket/TicketPlacementService.php @@ -72,6 +72,8 @@ final class TicketPlacementService } try { + $riskRedisCompensation = []; + $placement = DB::transaction(function () use ( $player, $currencyCode, @@ -79,7 +81,17 @@ final class TicketPlacementService $expectedVersions, $clientTraceId, $drawNo, + &$riskRedisCompensation, ): array { + DB::afterRollback(function () use (&$riskRedisCompensation): void { + foreach ($riskRedisCompensation as $entry) { + $this->riskPoolService->compensateRedisAcquires( + (int) $entry['draw_id'], + $entry['locks'], + ); + } + }); + $draw = Draw::query() ->where('draw_no', $drawNo) ->lockForUpdate() @@ -162,7 +174,9 @@ final class TicketPlacementService $creditLine = PlayerFundingMode::usesCredit($player); - if (! $creditLine) { + if ($creditLine) { + $this->playerCreditService->assertCreditPreflight($player, $totalActualDeduct); + } else { $wallet = PlayerWallet::query() ->where('player_id', $player->id) ->where('wallet_type', 'lottery') @@ -295,6 +309,10 @@ final class TicketPlacementService $successTotalRebate += $rebateAmount; $successTotalActualDeduct += (int) $evaluated['actual_deduct_amount']; $successTotalEstimatedPayout += (int) $evaluated['estimated_max_payout']; + $riskRedisCompensation[] = [ + 'draw_id' => (int) $draw->id, + 'locks' => $locks, + ]; } if ($successfulItems === []) { @@ -370,7 +388,7 @@ final class TicketPlacementService ])->save(); }); } catch (\Throwable $e) { - DB::transaction(function () use ($order, $player): void { + DB::transaction(function () use ($order, $player, $placement): void { $items = TicketItem::query() ->where('order_id', $order->id) ->where('status', 'pending_confirm') @@ -401,6 +419,11 @@ final class TicketPlacementService if (! PlayerFundingMode::usesCredit($player)) { $this->ticketWalletService->reverseBetDeduct($order); $this->ticketWalletService->releaseReservedBetDeduct($order, 'wallet_deduct_failed_release'); + } else { + $this->playerCreditService->reverseBetHold( + $player, + (int) $placement['success_total_actual_deduct'], + ); } }); diff --git a/app/Services/Wallet/PlayerLedgerLogsService.php b/app/Services/Wallet/PlayerLedgerLogsService.php index d5d74ca..d671463 100644 --- a/app/Services/Wallet/PlayerLedgerLogsService.php +++ b/app/Services/Wallet/PlayerLedgerLogsService.php @@ -9,6 +9,7 @@ use App\Models\WalletTxn; use Illuminate\Support\Str; use App\Support\AdminDataScope; use App\Support\CurrencyFormatter; +use App\Support\LimitedQuery; use App\Support\PlayerFundingMode; use App\Services\AgentSettlement\CreditLedgerBetFlowPresenter; use App\Services\AgentSettlement\SettlementPartyEnrichment; @@ -100,14 +101,15 @@ final class PlayerLedgerLogsService } $currency = (string) $player->default_currency; - $rawRows = $this->creditLedgerQuery($player->id, [ + $limited = LimitedQuery::get($this->creditLedgerQuery($player->id, [ 'bet_hold', 'bet_hold_release', 'game_settlement_loss', 'game_settlement_win', 'settlement_confirm', 'settlement_payout', - ])->limit(5000)->get()->all(); + ]), 5000); + $rawRows = $limited['rows']->all(); $enriched = array_map(function (object $row) use ($player): object { return (object) [ @@ -196,6 +198,7 @@ final class PlayerLedgerLogsService 'total' => $total, 'page' => $page, 'per_page' => $perPage, + 'truncated' => $limited['truncated'], ]; } diff --git a/app/Support/LimitedQuery.php b/app/Support/LimitedQuery.php new file mode 100644 index 0000000..f477b9f --- /dev/null +++ b/app/Support/LimitedQuery.php @@ -0,0 +1,26 @@ +limit($limit + 1)->get(); + $truncated = $rows->count() > $limit; + + return [ + 'rows' => $truncated ? $rows->take($limit)->values() : $rows, + 'truncated' => $truncated, + ]; + } +} diff --git a/database/migrations/2026_06_16_120000_add_settlement_bills_owner_status_index.php b/database/migrations/2026_06_16_120000_add_settlement_bills_owner_status_index.php new file mode 100644 index 0000000..45eb98c --- /dev/null +++ b/database/migrations/2026_06_16_120000_add_settlement_bills_owner_status_index.php @@ -0,0 +1,25 @@ +index( + ['owner_type', 'owner_id', 'status'], + 'settlement_bills_owner_status_idx', + ); + }); + } + + public function down(): void + { + Schema::table('settlement_bills', function (Blueprint $table): void { + $table->dropIndex('settlement_bills_owner_status_idx'); + }); + } +}; diff --git a/phpunit.xml b/phpunit.xml index 8bd6ffe..afd7310 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -3,6 +3,7 @@ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true" + cacheDirectory=".phpunit.cache" > @@ -18,6 +19,7 @@ + diff --git a/resources/views/admin/integration-guide.blade.php b/resources/views/admin/integration-guide.blade.php deleted file mode 100644 index 3bc7a94..0000000 --- a/resources/views/admin/integration-guide.blade.php +++ /dev/null @@ -1,441 +0,0 @@ - - - - - - 彩票代理接入文档 - @vite(['resources/css/app.css', 'resources/js/app.js']) - - - @php - $sections = [ - [ - 'id' => 'overview', - 'title' => '1. 文档概览', - 'summary' => '说明接入目标、适用对象与总体范围。', - ], - [ - 'id' => 'architecture', - 'title' => '2. 接入架构', - 'summary' => '描述主站、彩票端与钱包系统之间的数据流向。', - ], - [ - 'id' => 'prerequisites', - 'title' => '3. 对接前准备', - 'summary' => '列出域名、密钥、接口地址和测试账号等准备项。', - ], - [ - 'id' => 'sso', - 'title' => '4. SSO 单点登录', - 'summary' => '说明客户如何生成 token 并跳转进入彩票端。', - ], - [ - 'id' => 'wallet', - 'title' => '5. 钱包接口', - 'summary' => '说明余额、扣款、加款三个接口的职责与约束。', - ], - [ - 'id' => 'signature', - 'title' => '6. 签名与安全', - 'summary' => '说明签名算法、密钥保护与重放防护。', - ], - [ - 'id' => 'errors', - 'title' => '7. 错误码与幂等', - 'summary' => '统一交易状态和重复请求处理方式。', - ], - [ - 'id' => 'testing', - 'title' => '8. 联调与验收', - 'summary' => '提供联调流程、测试清单和上线前核对项。', - ], - [ - 'id' => 'appendix', - 'title' => '9. 附录', - 'summary' => '给出标准报文示例和字段规范。', - ], - ]; - @endphp - -
- - -
-
-
-
-
-

LOTTERY INTEGRATION GUIDE

-

彩票代理接入技术文档

-

- 本页面用于给客户技术团队直接阅读和联调,按文档页方式展示接入说明,包含目录、数据流、接口约束、联调步骤和上线前检查项。 -

-
- -
-
-
接入方式
-
SSO + 钱包接口
-
-
-
适用对象
-
客户技术团队
-
-
-
文档形态
-
后台独立页面
-
-
-
-
- -
-
-
目录
-
- @foreach ($sections as $section) - - {{ $section['title'] }} - - @endforeach -
-
-
- -
-
-
-

1. 文档概览

-

- 本文档用于指导客户将自有主站接入我方彩票端,形成完整的登录、跳转、余额、投注扣款与派奖加款链路。 -

-
- -
-
-
登录接入
-
客户主站登录后,通过 SSO 进入彩票端,无需再次认证。
-
-
-
钱包接入
-
投注与派奖资金动作由我方调用客户钱包接口完成。
-
-
-
联调验收
-
客户、我方技术与测试按照同一套流程完成联调和上线验收。
-
-
-
适用范围
-
适用于 H5、Web、App 内嵌 WebView 等进入彩票端的接入方式。
-
-
-
- -
-
-

2. 接入架构

-

客户系统与彩票端的职责边界如下,登录由主站发起,资金由主站钱包记账,彩票端负责业务过程编排。

-
- -
-
-
01 客户主站
-
用户登录与身份来源
-
客户负责维护会员账号、登录态和唯一用户标识。
-
-
-
02 SSO 网关
-
生成短期 token
-
客户服务端生成签名凭证,浏览器携带后进入彩票端。
-
-
-
03 彩票端
-
验签并建立会话
-
我方校验 token 后创建会话,承接投注、撤单、派奖等业务流程。
-
-
-
04 客户钱包
-
余额与账务真理源
-
余额查询、扣款、加款由客户钱包接口统一响应并负责幂等记账。
-
-
- -
-
端到端链路
-
    -
  1. 1. 用户在客户主站完成登录。
  2. -
  3. 2. 客户服务端生成 SSO token。
  4. -
  5. 3. 浏览器跳转到彩票端入口地址。
  6. -
  7. 4. 彩票端校验 token 并建立用户会话。
  8. -
  9. 5. 用户查询余额、进行投注或等待派奖。
  10. -
  11. 6. 彩票端按业务场景调用客户钱包接口。
  12. -
  13. 7. 钱包返回处理结果,彩票端落业务状态并反馈前端。
  14. -
-
-
- -
-
-

3. 对接前准备

-

双方开始联调前,需要先完成以下准备项,避免联调阶段反复返工。

-
- -
-
-
客户需提供
-
    -
  • • 站点名称、站点编码、测试环境与生产环境标识。
  • -
  • • 技术联系人、测试联系人、上线当天紧急联系人。
  • -
  • • 客户主站域名、钱包接口域名、可嵌入来源域名。
  • -
  • • 测试账号、测试余额和可重复联调的测试场景说明。
  • -
-
-
-
双方需共同确认
-
    -
  • • SSO 密钥或 JWT 验签密钥。
  • -
  • • 钱包 API 鉴权方式、签名算法、请求头规范。
  • -
  • • 钱包三类接口地址:余额、扣款、加款。
  • -
  • • 请求超时、重试策略、幂等键和错误码表。
  • -
-
-
-
- -
-
-

4. SSO 单点登录

-

客户用户在主站登录后,通过服务端签发的短期 token 进入彩票端,我方校验通过后自动建立会话。

-
- -
-
-
推荐接入流程
-
    -
  1. 1. 客户主站用户完成登录。
  2. -
  3. 2. 客户服务端按约定字段组装 SSO 负载。
  4. -
  5. 3. 使用共享密钥签发 token,并设置短期过期时间。
  6. -
  7. 4. 浏览器跳转我方彩票端地址,携带 token 参数。
  8. -
  9. 5. 我方校验签名、时间戳、站点编码和用户标识后建立彩票端登录态。
  10. -
-
- -
-
SSO 关键约束
-
    -
  • • token 必须是短时有效,建议 60 到 300 秒。
  • -
  • • `user_id` 在客户站点内必须稳定且唯一。
  • -
  • • `site_code` 必须与约定站点保持一致。
  • -
  • • 生产密钥与测试密钥必须隔离。
  • -
  • • 不可把签名密钥暴露在前端代码里。
  • -
-
-
- -
-
SSO 负载示例
-
-
{
-  "user_id": "100001",
-  "username": "demo_user",
-  "site_code": "demo",
-  "timestamp": 1718000000,
-  "nonce": "N8F2X9Q1",
-  "currency": "CNY",
-  "device": "h5"
-}
-
-
-
- -
-
-

5. 钱包接口

-

我方会调用客户钱包完成账务动作。客户至少需要实现余额查询、扣款、加款三类能力。

-
- -
-
-
余额查询
-
用于进入彩票端、下注前或关键账务时机同步可用余额。
-
POST /wallet/balance
-
-
-
扣款接口
-
用于用户下注成功后的资金扣减,必须以交易号作为唯一幂等键。
-
POST /wallet/debit
-
-
-
加款接口
-
用于派奖、退款或撤单返还资金,重复请求不得重复记账。
-
POST /wallet/credit
-
-
- -
-
-
扣款报文示例
-
-
{
-  "site_code": "demo",
-  "user_id": "100001",
-  "transaction_id": "BET202606100001",
-  "order_id": "TICKET202606100001",
-  "amount": "20.00",
-  "timestamp": 1718000001,
-  "sign": "xxxxxx"
-}
-
-
-
-
成功返回示例
-
-
{
-  "code": 0,
-  "message": "success",
-  "data": {
-    "transaction_id": "BET202606100001",
-    "balance": "980.00"
-  }
-}
-
-
-
-
- -
-
-

6. 签名与安全

-

签名规则和密钥保护是接入稳定性的基础。推荐统一采用服务端签名并在所有敏感请求中校验。

-
- -
-
-
推荐签名规则
-
    -
  1. 1. 请求字段按字段名升序排序。
  2. -
  3. 2. 以 `key=value` 形式拼接原始串。
  4. -
  5. 3. 原始串尾部追加共享密钥。
  6. -
  7. 4. 使用 `HMAC-SHA256` 计算签名。
  8. -
  9. 5. 结果输出为十六进制小写字符串。
  10. -
-
-
-
安全要求
-
    -
  • • 所有请求必须使用 HTTPS。
  • -
  • • token 和钱包请求必须校验时间戳与签名。
  • -
  • • 建议增加 nonce 或 request_id 防止重放。
  • -
  • • 测试环境与生产环境密钥不得共用。
  • -
  • • 密钥轮换时必须预留并行切换窗口。
  • -
-
-
-
- -
-
-

7. 错误码与幂等

-

为了保证交易可重试、可审计,客户钱包接口必须统一错误码并支持强幂等。

-
- -
-
-
建议错误码
-
    -
  • • `0`:成功
  • -
  • • `1001`:参数错误
  • -
  • • `1002`:签名错误
  • -
  • • `1003`:用户不存在
  • -
  • • `1004`:余额不足
  • -
  • • `1006`:重复交易
  • -
  • • `1099`:系统异常
  • -
-
-
-
幂等处理要求
-
    -
  • • 扣款和加款必须使用 `transaction_id` 作为唯一交易键。
  • -
  • • 同一 `transaction_id` 的重复请求,不得重复扣款或重复加款。
  • -
  • • 已成功处理的交易,重复请求必须返回首次处理结果。
  • -
  • • 钱包超时或网络抖动时,我方可能发起重试,因此客户必须按幂等方式落账。
  • -
-
-
-
- -
-
-

8. 联调与验收

-

建议双方按固定节奏联调,先通登录链路,再通钱包链路,最后做异常回归和上线核验。

-
- -
-
-
联调顺序
-
    -
  1. 1. 域名连通与证书校验。
  2. -
  3. 2. SSO token 生成与跳转验证。
  4. -
  5. 3. 余额查询接口联调。
  6. -
  7. 4. 扣款和加款接口联调。
  8. -
  9. 5. 超时、重复请求和余额不足场景回归。
  10. -
-
-
-
上线前检查清单
-
    -
  • • 正式域名、正式密钥和正式白名单均已配置。
  • -
  • • 测试环境和生产环境参数已分离。
  • -
  • • 核心错误码、日志和告警已对齐。
  • -
  • • 关键交易链路已通过验收回归。
  • -
  • • 上线当天应急联系人与回滚预案已确认。
  • -
-
-
-
- -
-
-

9. 附录

-

为了减少不同系统之间的解析差异,建议双方统一基础字段格式。

-
- -
-
-
金额字段
-
统一使用字符串传输,例如 `1000.00`,避免浮点精度误差。
-
-
-
时间字段
-
建议使用 Unix 时间戳秒级,双方也可统一为 ISO8601。
-
-
-
报文格式
-
字符编码统一 UTF-8,请求内容类型统一 `application/json`。
-
-
-
-
-
-
-
- - \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 1f58aef..562e398 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,6 +7,12 @@ Route::get('/', function () { }); Route::prefix('admin/docs')->group(function (): void { - Route::view('integration-guide', 'admin.integration-guide') - ->name('admin.docs.integration-guide'); + Route::get('integration-guide', function () { + $adminDocs = rtrim( + (string) env('LOTTERY_ADMIN_DOCS_URL', 'https://lotteryadmin.tanumo.com'), + '/', + ); + + return redirect()->away($adminDocs.'/docs/integration'); + })->name('admin.docs.integration-guide'); }); diff --git a/tests/Feature/CreditHoldConcurrencyTest.php b/tests/Feature/CreditHoldConcurrencyTest.php new file mode 100644 index 0000000..af4716e --- /dev/null +++ b/tests/Feature/CreditHoldConcurrencyTest.php @@ -0,0 +1,99 @@ +where('is_default', true)->first(); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'), + 'site_player_id' => 'cl-race', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'clr1', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 10, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $credit = app(PlayerCreditService::class); + $credit->holdForBet($player, 800); + + expect(fn () => $credit->holdForBet($player, 300)) + ->toThrow(ValidationException::class); + + expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit')) + ->toBe(8); +}); + +test('credit preflight rejects hold when overdue bill exists', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => (int) $site->id, + 'period_start' => now()->subWeek(), + 'period_end' => now()->subDay(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'), + 'site_player_id' => 'cl-od', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'clod1', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 10000, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('settlement_bills')->insert([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => $player->agent_node_id, + 'gross_win_loss' => 1000, + 'rebate_amount' => 0, + 'adjustment_amount' => 0, + 'net_amount' => 1000, + 'paid_amount' => 0, + 'unpaid_amount' => 1000, + 'status' => 'overdue', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $credit = app(PlayerCreditService::class); + + expect(fn () => $credit->assertCreditPreflight($player, 100)) + ->toThrow(ValidationException::class); +});