Files
lotteryLaravel/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php
kang 6b2ea39ea1
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
fix: 修复并发竞态与幂等性缺陷并优化子树查询
- 扩大玩家ID随机数位宽降低碰撞概率
- 授信额度变更加 lockForUpdate 防并发下调
- 结算冲正与入账利用唯一索引实现幂等闸门
- 失败登录计数加锁防丢失
- 代理子树查询改用子查询替代全表 pluck
- 修复返点默认值百分比单位转换
- 测试中全量 fake 广播事件防 CI 500
2026-06-18 09:38:24 +08:00

251 lines
9.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Controllers\Api\V1\Admin\Player;
use App\Models\Player;
use App\Models\AdminSite;
use App\Models\AgentNode;
use App\Models\AdminUser;
use App\Lottery\ErrorCode;
use App\Support\ApiMessage;
use App\Support\ApiResponse;
use App\Support\AdminSiteScope;
use App\Support\AdminAgentScope;
use App\Support\PlayerAuthSource;
use Illuminate\Http\JsonResponse;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB;
use App\Support\PlayerApiPresenter;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Hash;
use App\Services\Agent\AgentProfileService;
use App\Services\Agent\RebateLimitValidator;
use App\Services\Player\PlayerCreditService;
use Illuminate\Validation\ValidationException;
use App\Http\Requests\Admin\AdminPlayerStoreRequest;
/** POST /api/v1/admin/players */
final class AdminPlayerStoreController extends Controller
{
public function __invoke(
AdminPlayerStoreRequest $request,
PlayerCreditService $playerCreditService,
RebateLimitValidator $rebateLimitValidator,
AgentProfileService $agentProfileService,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
try {
$agentProfileService->assertActorMayCreatePlayer($admin);
} catch (ValidationException $e) {
return ApiMessage::errorResponse(
$request,
'admin.player_create_capability_forbidden',
ErrorCode::AdminForbidden->value,
$e->errors(),
403,
);
}
$siteCode = (string) $request->validated('site_code');
if (! AdminSiteScope::siteCodeAllowed($admin, $siteCode)) {
return ApiMessage::errorResponse($request, 'admin.player_create_site_forbidden', ErrorCode::AdminForbidden->value, null, 403);
}
$isNative = $request->filled('password');
$sitePlayerId = (string) ($request->validated('site_player_id') ?? '');
if ($isNative) {
$sitePlayerId = $sitePlayerId !== ''
? $sitePlayerId
: $this->generateNativeSitePlayerId($siteCode);
}
if ($sitePlayerId === '') {
return ApiMessage::errorResponse($request, 'admin.player_site_player_id_required', ErrorCode::ValidationFailed->value, null, 422);
}
$exists = Player::query()
->where('site_code', $siteCode)
->where('site_player_id', $sitePlayerId)
->exists();
if ($exists) {
return ApiMessage::errorResponse($request, 'admin.player_already_registered', ErrorCode::ValidationFailed->value, null, 422);
}
if ($isNative) {
$username = trim((string) $request->validated('username', ''));
if ($username === '') {
return ApiMessage::errorResponse($request, 'admin.player_native_username_required', ErrorCode::ValidationFailed->value, null, 422);
}
$usernameTaken = Player::query()
->where('site_code', $siteCode)
->where('username', $username)
->where('auth_source', PlayerAuthSource::LOTTERY_NATIVE)
->exists();
if ($usernameTaken) {
return ApiMessage::errorResponse($request, 'admin.player_username_taken', ErrorCode::ValidationFailed->value, null, 422);
}
}
$agentNodeId = $admin->isSuperAdmin()
? $this->resolveAgentNodeIdForSuperAdmin($request->validated('agent_node_id'), $siteCode)
: $this->resolveAgentNodeIdForNonSuperAdmin($admin, $request->validated('agent_node_id'), $siteCode);
if ($agentNodeId === null) {
return ApiMessage::errorResponse(
$request,
'admin.player_create_agent_required',
ErrorCode::ValidationFailed->value,
null,
422,
);
}
$agent = AgentNode::query()->findOrFail($agentNodeId);
$rebateRate = 0.0;
$extraRebateRate = 0.0;
if ($request->has('rebate_rate') || $request->has('extra_rebate_rate')) {
$rebateRate = (float) $request->input('rebate_rate', 0) / 100;
$extraRebateRate = (float) $request->input('extra_rebate_rate', 0) / 100;
$rebateLimitValidator->assertPlayerRebateWithinAgent(
$agent,
$rebateRate,
$extraRebateRate,
);
}
if (! $isNative && ($request->has('credit_limit') || $request->has('rebate_rate') || $request->has('extra_rebate_rate'))) {
throw ValidationException::withMessages([
'credit_limit' => ['wallet_player_prohibited'],
]);
}
$creditLimit = $request->has('credit_limit')
? (int) $request->input('credit_limit', 0)
: ($isNative ? 0 : 0);
$player = DB::transaction(function () use (
$agent,
$agentProfileService,
$playerCreditService,
$request,
$isNative,
$siteCode,
$agentNodeId,
$sitePlayerId,
$creditLimit,
$rebateRate,
$extraRebateRate,
): Player {
$agentProfileService->assertMayIncreasePlayerCredit($agent, $creditLimit);
$player = Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => $agentNodeId,
'site_player_id' => $sitePlayerId,
'auth_source' => $isNative ? PlayerAuthSource::LOTTERY_NATIVE : PlayerAuthSource::MAIN_SITE_SSO,
'funding_mode' => $isNative ? PlayerFundingMode::CREDIT : PlayerFundingMode::WALLET,
'username' => $isNative ? trim((string) $request->validated('username')) : $request->validated('username'),
'password_hash' => $isNative ? Hash::make((string) $request->validated('password')) : null,
'nickname' => $request->validated('nickname'),
'default_currency' => $request->validated('default_currency', 'NPR'),
'status' => $request->validated('status', 0),
]);
if ($request->has('credit_limit') || $isNative) {
$playerCreditService->upsertAccount($player, [
'credit_limit' => $creditLimit,
]);
}
$agentProfileService->refreshAllocatedCredit($agent);
if ($request->has('rebate_rate')) {
DB::table('player_rebate_profiles')->insert([
'player_id' => $player->id,
'game_type' => '*',
'inherit_from_agent' => false,
'rebate_rate' => $rebateRate,
'extra_rebate_rate' => $extraRebateRate,
'created_at' => now(),
'updated_at' => now(),
]);
}
return $player;
});
return ApiResponse::success(PlayerApiPresenter::listItem($player))->setStatusCode(201);
}
private function resolveAgentNodeIdForSuperAdmin(mixed $requested, string $siteCode): ?int
{
if ($requested !== null && (int) $requested > 0) {
return (int) $requested;
}
$siteId = AdminSite::query()->where('code', $siteCode)->value('id');
if ($siteId === null) {
return null;
}
$rootId = AgentNode::query()
->where('admin_site_id', (int) $siteId)
->where('depth', 0)
->value('id');
return $rootId !== null ? (int) $rootId : null;
}
private function resolveAgentNodeIdForNonSuperAdmin(AdminUser $admin, mixed $requested, string $siteCode): ?int
{
$agent = AdminAgentScope::primaryAgentNode($admin);
if ($agent !== null) {
if ($requested !== null && (int) $requested > 0 && (int) $requested !== (int) $agent->id) {
return null;
}
return (int) $agent->id;
}
if (! AdminAgentScope::isSiteOnlyOperator($admin)) {
return null;
}
if ($requested === null || (int) $requested <= 0) {
return null;
}
$accessibleSiteIds = $admin->accessibleAdminSiteIds() ?? [];
$node = AgentNode::query()->find((int) $requested);
if ($node === null || ! in_array((int) $node->admin_site_id, $accessibleSiteIds, true)) {
return null;
}
return (int) $requested;
}
private function generateNativeSitePlayerId(string $siteCode): string
{
$prefix = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $siteCode) ?: 'LP', 0, 2));
$prefix = str_pad($prefix, 2, 'P');
// 使用 10 位数字100 亿组合)显著降低并发碰撞概率;即使极小概率撞上,
// 上层 Player::create 有 (site_code, site_player_id) 唯一索引兜底,
// 错误会在调用方被显式处理为 422/500而非静默双写。
do {
$candidate = sprintf('%s%010d', $prefix, random_int(0, 9999999999));
$exists = Player::query()
->where('site_code', $siteCode)
->where('site_player_id', $candidate)
->exists();
} while ($exists);
return $candidate;
}
}