feat: 增强代理和玩家管理功能

- 在多个控制器中更新权限检查逻辑,确保管理员能够更灵活地管理代理和玩家。
- 在 AdminPlayerStoreController 中引入对玩家创建能力的验证,确保只有具备相应权限的管理员能够创建玩家。
- 更新请求验证逻辑,新增 credit_limit、rebate_rate 和 extra_rebate_rate 字段,以支持更细粒度的玩家管理。
- 在 AgentNodeProfileController 中添加对父代理能力授予的验证,确保子代理的权限在父代理范围内。
- 引入 AgentProfileFieldRules 以简化代理资料更新请求的规则定义,提升代码复用性。
This commit is contained in:
2026-06-04 18:00:50 +08:00
parent 96545f87f6
commit a44679665d
183 changed files with 10054 additions and 857 deletions

View File

@@ -26,7 +26,7 @@ final class AgentLineStoreController extends Controller
$site = $result['site'];
$node = $result['agent_node'];
$payload = AgentLinePresenter::provisioned($site, $node, $result['secrets']);
$payload = AgentLinePresenter::provisioned($site, $node);
AuditLogger::recordForAdmin(
$admin,

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
@@ -22,10 +23,21 @@ final class AgentNodeChildrenController extends Controller
return $denied;
}
$items = $agent_node->children()
->orderBy('code')
$children = $agent_node->children()->orderBy('code')->get();
$profiles = AgentProfile::query()
->whereIn('agent_node_id', $children->pluck('id'))
->get()
->map(static fn (AgentNode $child): array => AgentNodePresenter::item($child))
->keyBy('agent_node_id');
$items = $children
->map(static function (AgentNode $child) use ($profiles): array {
$profile = $profiles->get($child->id);
return AgentNodePresenter::item(
$child,
$profile instanceof AgentProfile ? $profile : null,
);
})
->all();
return ApiResponse::success(['items' => $items]);

View File

@@ -3,11 +3,12 @@
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Http\Controllers\Controller;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Http\Requests\Admin\AdminAgentProfileUpdateRequest;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Services\Agent\AgentNodeService;
use App\Services\Agent\AgentProfileService;
use App\Services\AuditLogger;
use App\Support\AdminAgentScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
@@ -22,20 +23,28 @@ final class AgentNodeProfileController extends Controller
abort_if($admin === null, 401);
abort_if(! AdminAgentScope::nodeVisibleTo($admin, $agent_node), 403);
$service = app(AgentProfileService::class);
$profile = AgentProfile::query()->firstOrNew(['agent_node_id' => $agent_node->id]);
$parent = $agent_node->parent_id !== null
? AgentNode::query()->find($agent_node->parent_id)
: null;
return ApiResponse::success(app(AgentProfileService::class)->present($profile));
return ApiResponse::success([
...$service->present($profile),
'parent_caps' => $service->parentCapsForNode($parent),
'risk_tags' => $agent_node->risk_tags ?? [],
]);
}
public function update(
AdminAgentProfileUpdateRequest $request,
AgentNode $agent_node,
AgentProfileService $service,
AgentNodeService $agentNodeService,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
abort_if(! AdminAgentScope::nodeVisibleTo($admin, $agent_node), 403);
abort_if(! AdminAgentScope::nodeProfileEditableBy($admin, $agent_node), 403);
$parent = $agent_node->parent_id !== null
? AgentNode::query()->find($agent_node->parent_id)
@@ -46,9 +55,33 @@ final class AgentNodeProfileController extends Controller
$service->assertChildCapabilityGrantsWithinParent($parent, $payload, $admin);
}
$profile = $service->upsertForNode($agent_node, $payload, $parent);
$agentNodeService->syncPrimaryOwnerRoleFromProfile($agent_node, $profile);
$beforeProfile = AgentProfile::query()->where('agent_node_id', $agent_node->id)->first();
$beforeJson = $beforeProfile !== null ? $service->present($beforeProfile) : [];
return ApiResponse::success($service->present($profile));
if ($request->has('risk_tags')) {
$agent_node->risk_tags = array_values(array_unique(array_filter(
array_map('strval', $request->input('risk_tags', [])),
)));
$agent_node->save();
}
$profile = $service->upsertForNode($agent_node, $payload, $parent);
$afterJson = array_merge($service->present($profile), [
'risk_tags' => $agent_node->risk_tags ?? [],
]);
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'agent',
actionCode: 'agent_profile.update',
targetType: 'agent_node',
targetId: (string) $agent_node->id,
beforeJson: $beforeJson,
afterJson: $afterJson,
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return ApiResponse::success($afterJson);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Services\AgentSettlement\SettlementCenterLedgerService;
use App\Services\AgentSettlement\SettlementLedgerListFilters;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use App\Support\PaginationTrait;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 结算中心:信用盘玩家实时流水({@see credit_ledger}),与关账后 {@see settlement_bills} 不同。
*/
final class AdminCreditLedgerIndexController extends Controller
{
use PaginationTrait;
public function __construct(
private readonly SettlementCenterLedgerService $ledgerService,
) {}
public function __invoke(Request $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$adminSiteId = (int) $request->query('admin_site_id', 0);
abort_if($adminSiteId <= 0, 422, 'admin_site_id required');
abort_if(! AdminAgentSettlementScope::siteAccessible($admin, $adminSiteId), 403);
$siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
abort_if($siteCode === '', 422, 'admin_site not found');
$periodId = (int) $request->query('settlement_period_id', 0);
if ($periodId > 0) {
abort_if(! AdminAgentSettlementScope::periodAccessible($admin, $periodId), 403);
}
$filters = SettlementLedgerListFilters::fromQuery(array_merge(
$request->query(),
$periodId > 0 ? ['settlement_period_id' => $periodId] : [],
));
$perPage = $this->perPage($request, 'per_page', 20, 100);
$page = $this->page($request);
$result = $this->ledgerService->listUnified(
$admin,
$siteCode,
$page,
$perPage,
$filters,
);
return ApiResponse::success($result);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class AgentSettlementAdjustmentIndexController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$periodId = (int) $request->query('settlement_period_id', 0);
$adminSiteId = (int) $request->query('admin_site_id', 0);
$adjustmentType = trim((string) $request->query('adjustment_type', ''));
$query = DB::table('settlement_adjustments as sa')
->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id')
->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id')
->select([
'sa.*',
'sp.period_start',
'sp.period_end',
'sp.admin_site_id',
'sb.bill_type as original_bill_type',
'sb.owner_type as original_owner_type',
'sb.owner_id as original_owner_id',
])
->orderByDesc('sa.id');
if ($periodId > 0) {
$query->where('sa.settlement_period_id', $periodId);
}
if ($adminSiteId > 0) {
$query->where('sp.admin_site_id', $adminSiteId);
}
if ($adjustmentType !== '') {
$query->where('sa.adjustment_type', $adjustmentType);
}
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds !== null) {
if ($siteIds === []) {
$query->whereRaw('0 = 1');
} else {
$query->whereIn('sp.admin_site_id', $siteIds);
}
}
return ApiResponse::success([
'items' => $query->limit(200)->get(),
]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Http\Requests\Admin\AdminSettlementBillAdjustmentRequest;
use App\Services\AgentSettlement\AgentSettlementBillAdjustmentService;
use App\Services\AuditLogger;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
final class AgentSettlementBillAdjustmentController extends Controller
{
public function __invoke(
AdminSettlementBillAdjustmentRequest $request,
int $settlement_bill,
AgentSettlementBillAdjustmentService $adjustments,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($before === null, 404);
$newBillId = $adjustments->createAdjustment(
$settlement_bill,
(int) $request->validated('amount'),
(string) ($request->validated('adjustment_type') ?? 'adjustment'),
$request->validated('reason'),
(int) $admin->id,
);
$after = DB::table('settlement_bills')->where('id', $newBillId)->first();
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'settlement',
actionCode: 'settlement_bill.adjustment',
targetType: 'settlement_bill',
targetId: (string) $newBillId,
beforeJson: (array) $before,
afterJson: (array) $after,
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return ApiResponse::success([
'original_bill_id' => $settlement_bill,
'adjustment_bill_id' => $newBillId,
'bill' => $after,
]);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Http\Requests\Admin\AdminSettlementBillBadDebtRequest;
use App\Services\AgentSettlement\AgentSettlementBadDebtService;
use App\Services\AuditLogger;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
final class AgentSettlementBillBadDebtWriteOffController extends Controller
{
public function __invoke(
AdminSettlementBillBadDebtRequest $request,
int $settlement_bill,
AgentSettlementBadDebtService $badDebt,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($before === null, 404);
$archiveBillId = $badDebt->writeOff(
$settlement_bill,
$request->validated('reason'),
(int) $admin->id,
);
$after = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'settlement',
actionCode: 'settlement_bill.bad_debt',
targetType: 'settlement_bill',
targetId: (string) $settlement_bill,
beforeJson: (array) $before,
afterJson: (array) $after,
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return ApiResponse::success([
'original_bill_id' => $settlement_bill,
'bad_debt_bill_id' => $archiveBillId,
'bill' => $after,
]);
}
}

View File

@@ -4,9 +4,8 @@ namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Models\Player;
use App\Services\AgentSettlement\SettlementPaymentService;
use App\Services\AuditLogger;
use App\Services\Player\PlayerCreditService;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
@@ -18,7 +17,7 @@ final class AgentSettlementBillConfirmController extends Controller
public function __invoke(
Request $request,
int $settlement_bill,
PlayerCreditService $creditService,
SettlementPaymentService $payments,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
@@ -28,21 +27,7 @@ final class AgentSettlementBillConfirmController extends Controller
$bill = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($bill === null, 404);
$unpaid = (int) $bill->unpaid_amount;
DB::table('settlement_bills')->where('id', $settlement_bill)->update([
'paid_amount' => (int) $bill->paid_amount + $unpaid,
'unpaid_amount' => 0,
'status' => 'confirmed',
'confirmed_at' => now(),
'updated_at' => now(),
]);
if ($bill->owner_type === 'player' && (int) $bill->owner_id > 0) {
$player = Player::query()->find((int) $bill->owner_id);
if ($player !== null) {
$creditService->releaseFromSettlement($player, $unpaid, $settlement_bill);
}
}
$payments->confirmBill($settlement_bill);
AuditLogger::recordForAdmin(
$admin,
@@ -51,8 +36,8 @@ final class AgentSettlementBillConfirmController extends Controller
actionCode: 'settlement_bill.confirm',
targetType: 'settlement_bill',
targetId: (string) $settlement_bill,
beforeJson: ['status' => (string) $bill->status, 'unpaid_amount' => $unpaid],
afterJson: ['status' => 'confirmed', 'paid_amount' => (int) $bill->paid_amount + $unpaid],
beforeJson: ['status' => (string) $bill->status],
afterJson: ['status' => 'confirmed'],
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);

View File

@@ -7,6 +7,7 @@ use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class AgentSettlementBillIndexController extends Controller
@@ -17,15 +18,142 @@ final class AgentSettlementBillIndexController extends Controller
abort_if($admin === null, 401);
$periodId = (int) $request->query('settlement_period_id', 0);
$query = DB::table('settlement_bills')->orderByDesc('id');
$adminSiteId = (int) $request->query('admin_site_id', 0);
$query = DB::table('settlement_bills as sb')
->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
->select([
'sb.*',
'sp.period_start',
'sp.period_end',
'sp.admin_site_id',
])
->orderByDesc('sb.id');
if ($periodId > 0) {
$query->where('settlement_period_id', $periodId);
$query->where('sb.settlement_period_id', $periodId);
}
AdminAgentSettlementScope::applyToBillsQuery($query, $admin);
if ($adminSiteId > 0) {
$query->where('sp.admin_site_id', $adminSiteId);
}
$billType = (string) $request->query('bill_type', '');
if ($billType !== '') {
$query->where('sb.bill_type', $billType);
}
$scope = (string) $request->query('scope', '');
match ($scope) {
'pending_confirm' => $query->where('sb.status', 'pending_confirm'),
'awaiting_payment' => $query
->whereIn('sb.status', ['confirmed', 'partial_paid', 'overdue'])
->where('sb.unpaid_amount', '>', 0),
'settled' => $query->where('sb.status', 'settled'),
'adjustment' => $query->whereIn('sb.bill_type', ['adjustment', 'reversal']),
default => null,
};
AdminAgentSettlementScope::applyToBillsQuery($query, $admin, 'sb');
/** @var Collection<int, object> $items */
$items = $query->limit(200)->get();
return ApiResponse::success([
'items' => $query->limit(100)->get(),
'items' => $this->enrichBillRows($items),
]);
}
/**
* @param Collection<int, object> $items
* @return list<array<string, mixed>>
*/
private function enrichBillRows(Collection $items): array
{
if ($items->isEmpty()) {
return [];
}
$playerIds = [];
$agentIds = [];
foreach ($items as $row) {
if ((string) $row->owner_type === 'player') {
$playerIds[] = (int) $row->owner_id;
} elseif ((string) $row->owner_type === 'agent') {
$agentIds[] = (int) $row->owner_id;
}
if ((string) $row->counterparty_type === 'agent' && (int) $row->counterparty_id > 0) {
$agentIds[] = (int) $row->counterparty_id;
}
}
$players = $playerIds !== []
? DB::table('players')
->whereIn('id', array_unique($playerIds))
->select(['id', 'username', 'site_player_id', 'funding_mode', 'auth_source'])
->get()
->keyBy('id')
: collect();
$agents = $agentIds !== []
? DB::table('agent_nodes')->whereIn('id', array_unique($agentIds))->get()->keyBy('id')
: collect();
$out = [];
foreach ($items as $row) {
$item = (array) $row;
$item['owner_label'] = $this->resolvePartyLabel(
(string) $row->owner_type,
(int) $row->owner_id,
$players,
$agents,
);
$item['counterparty_label'] = $this->resolvePartyLabel(
(string) $row->counterparty_type,
(int) $row->counterparty_id,
$players,
$agents,
);
if ((string) $row->owner_type === 'player') {
$player = $players->get((int) $row->owner_id);
$item['owner_funding_mode'] = $player !== null ? (string) ($player->funding_mode ?? '') : null;
$item['owner_auth_source'] = $player !== null ? $player->auth_source : null;
}
$out[] = $item;
}
return $out;
}
/**
* @param Collection<int, object> $players
* @param Collection<int, object> $agents
*/
private function resolvePartyLabel(
string $type,
int $id,
Collection $players,
Collection $agents,
): string {
if ($type === 'platform' || $id <= 0) {
return 'platform';
}
if ($type === 'player') {
$player = $players->get($id);
return $player !== null
? (string) ($player->username ?: $player->site_player_id)
: "player#{$id}";
}
if ($type === 'agent') {
$agent = $agents->get($id);
return $agent !== null
? (string) ($agent->name ?: $agent->code)
: "agent#{$id}";
}
return "{$type}#{$id}";
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Http\Requests\Admin\AdminSettlementBillPaymentRequest;
use App\Services\AgentSettlement\SettlementPaymentService;
use App\Services\AuditLogger;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
final class AgentSettlementBillPaymentController extends Controller
{
public function __invoke(
AdminSettlementBillPaymentRequest $request,
int $settlement_bill,
SettlementPaymentService $payments,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($before === null, 404);
$validated = $request->validated();
$payments->recordPayment(
$settlement_bill,
(int) $validated['amount'],
(int) $admin->id,
[
'method' => $validated['method'] ?? null,
'proof' => $validated['proof'] ?? null,
'remark' => $validated['remark'] ?? null,
],
);
$after = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'settlement',
actionCode: 'settlement_bill.payment',
targetType: 'settlement_bill',
targetId: (string) $settlement_bill,
beforeJson: (array) $before,
afterJson: (array) $after,
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return ApiResponse::success(['bill' => $after]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class AgentSettlementBillShowController extends Controller
{
public function __invoke(Request $request, int $settlement_bill): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
$bill = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($bill === null, 404);
$payments = DB::table('payment_records')
->where('settlement_bill_id', $settlement_bill)
->orderBy('id')
->get();
$rebateAllocations = DB::table('rebate_allocations')
->where('settlement_bill_id', $settlement_bill)
->orderBy('id')
->get();
$adjustments = DB::table('settlement_adjustments')
->where('original_bill_id', $settlement_bill)
->orderByDesc('id')
->get();
$meta = $bill->meta_json ?? null;
$tierSettlements = null;
if (is_string($meta) && $meta !== '') {
$decoded = json_decode($meta, true);
$tierSettlements = is_array($decoded) ? ($decoded['edge'] ?? $decoded['tier_settlements'] ?? null) : null;
}
return ApiResponse::success([
'bill' => $bill,
'payments' => $payments,
'rebate_allocations' => $rebateAllocations,
'adjustments' => $adjustments,
'tier_edge' => $tierSettlements,
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class AgentSettlementPaymentIndexController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$periodId = (int) $request->query('settlement_period_id', 0);
$adminSiteId = (int) $request->query('admin_site_id', 0);
$query = DB::table('payment_records as pr')
->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
->select([
'pr.*',
'sb.bill_type',
'sb.owner_type',
'sb.owner_id',
'sb.counterparty_type',
'sb.counterparty_id',
'sp.period_start',
'sp.period_end',
'sp.admin_site_id',
])
->orderByDesc('pr.id');
if ($periodId > 0) {
$query->where('sb.settlement_period_id', $periodId);
}
if ($adminSiteId > 0) {
$query->where('sp.admin_site_id', $adminSiteId);
}
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds !== null) {
if ($siteIds === []) {
$query->whereRaw('0 = 1');
} else {
$query->whereIn('sp.admin_site_id', $siteIds);
}
}
return ApiResponse::success([
'items' => $query->limit(200)->get(),
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Services\AgentSettlement\AgentSettlementPeriodSummaryService;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class AgentSettlementPeriodIndexController extends Controller
{
public function __invoke(
Request $request,
AgentSettlementPeriodSummaryService $summaryService,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$query = DB::table('settlement_periods')->orderByDesc('id');
AdminAgentSettlementScope::applyToPeriodsQuery($query, $admin);
$siteId = (int) $request->query('admin_site_id', 0);
if ($siteId > 0) {
$query->where('admin_site_id', $siteId);
}
$periods = $query->limit(100)->get();
return ApiResponse::success([
'items' => $summaryService->attachToPeriodRows($periods),
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Services\AgentSettlement\AgentSettlementReportQueryService;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/** @deprecated 请用 AgentSettlementReportShowController?type=summary */
final class AgentSettlementReportIndexController extends Controller
{
public function __invoke(Request $request, AgentSettlementReportQueryService $reports): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$periodId = (int) $request->query('settlement_period_id', 0);
return ApiResponse::success($reports->summary($admin, $periodId));
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Services\AgentSettlement\AgentSettlementReportQueryService;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class AgentSettlementReportShowController extends Controller
{
private const TYPES = [
'summary',
'player_win_loss',
'agent_share',
'rebate',
'credit',
'unpaid_bills',
'overdue',
'platform_pnl',
'draw_period',
];
public function __invoke(Request $request, AgentSettlementReportQueryService $reports): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$type = (string) $request->query('type', 'summary');
abort_unless(in_array($type, self::TYPES, true), 404);
$periodId = (int) $request->query('settlement_period_id', 0);
$period = $this->resolvePeriod($periodId, $request);
$data = match ($type) {
'summary' => $reports->summary($admin, $periodId),
'player_win_loss' => [
'items' => $reports->playerWinLoss($admin, $periodId, $period['start'], $period['end']),
],
'agent_share' => [
'items' => $reports->agentShare($admin, $period['start'], $period['end']),
],
'rebate' => $reports->rebate($admin, $periodId, $period['start'], $period['end']),
'credit' => $reports->credit($admin),
'unpaid_bills' => [
'items' => $reports->unpaidBills($admin, $periodId),
],
'overdue' => [
'items' => $reports->overdue($admin),
],
'platform_pnl' => $periodId > 0
? $reports->platformPnl($admin, $periodId)
: ['error' => 'settlement_period_id_required'],
'draw_period' => [
'items' => $reports->drawPeriod($admin, $period['start'], $period['end']),
],
default => [],
};
return ApiResponse::success([
'type' => $type,
'settlement_period_id' => $periodId > 0 ? $periodId : null,
'period_start' => $period['start'],
'period_end' => $period['end'],
'data' => $data,
'footnote' => $type === 'summary'
? null
: 'agent_credit_line_settlement',
]);
}
/**
* @return array{start: string, end: string}
*/
private function resolvePeriod(int $periodId, Request $request): array
{
if ($periodId > 0) {
$row = DB::table('settlement_periods')->where('id', $periodId)->first();
abort_if($row === null, 404);
return [
'start' => (string) $row->period_start,
'end' => (string) $row->period_end,
];
}
$request->validate([
'period_start' => ['required_with:period_end', 'date'],
'period_end' => ['required_with:period_start', 'date', 'after_or_equal:period_start'],
]);
$start = $request->query('period_start');
$end = $request->query('period_end');
if ($start && $end) {
return ['start' => (string) $start, 'end' => (string) $end];
}
$now = now();
return [
'start' => $now->copy()->subDays(7)->toDateTimeString(),
'end' => $now->toDateTimeString(),
];
}
}

View File

@@ -10,7 +10,10 @@ use App\Support\ApiResponse;
use App\Models\SettlementBatch;
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)。
@@ -23,6 +26,17 @@ final class AdminDrawFinanceSummaryController extends Controller
{
$admin = $request->lotteryAdmin();
abort_if(! $admin instanceof AdminUser, 401);
if (! AdminDrawResponsePolicy::canViewDrawFinance($admin)) {
return ApiMessage::errorResponse(
$request,
'admin.permission_denied',
ErrorCode::AdminForbidden->value,
null,
403,
);
}
$scope = AdminScopePolicy::resolveContext($request, $admin);
$drawId = (int) $draw->id;

View File

@@ -2,7 +2,6 @@
namespace App\Http\Controllers\Api\V1\Admin\Draw;
use Carbon\Carbon;
use App\Models\AdminUser;
use App\Models\Draw;
use App\Models\TicketItem;
@@ -10,6 +9,8 @@ use App\Models\TicketOrder;
use Illuminate\Http\Request;
use App\Support\AdminApiList;
use App\Support\AdminScopeContext;
use App\Support\AdminDrawApiPresenter;
use App\Support\AdminDrawResponsePolicy;
use App\Support\AdminScopePolicy;
use App\Services\LotterySettings;
use Illuminate\Http\JsonResponse;
@@ -44,19 +45,30 @@ final class AdminDrawIndexController extends Controller
/** @var LengthAwarePaginator $paginator */
$paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']);
$statsByDrawId = $this->aggregateListStats(
$paginator->getCollection()->pluck('id')->map(fn ($id) => (int) $id)->all(),
$scope,
);
$statsByDrawId = AdminDrawResponsePolicy::canViewDrawFinance($admin)
? $this->aggregateListStats(
$paginator->getCollection()->pluck('id')->map(fn ($id) => (int) $id)->all(),
$scope,
)
: [];
return AdminApiList::jsonWith($paginator, fn (Draw $row) => $this->row($row, $statsByDrawId), [
'schedule' => [
'timezone' => LotterySettings::drawTimezone(),
'interval_minutes' => LotterySettings::drawIntervalMinutes(),
'betting_window_seconds' => LotterySettings::drawBettingWindowSeconds(),
'close_before_draw_seconds' => LotterySettings::drawCloseBeforeDrawSeconds(),
return AdminApiList::jsonWith(
$paginator,
fn (Draw $row): array => AdminDrawApiPresenter::listRow(
$row,
$statsByDrawId[(int) $row->id] ?? null,
$admin,
),
[
'schedule' => [
'timezone' => LotterySettings::drawTimezone(),
'interval_minutes' => LotterySettings::drawIntervalMinutes(),
'betting_window_seconds' => LotterySettings::drawBettingWindowSeconds(),
'close_before_draw_seconds' => LotterySettings::drawCloseBeforeDrawSeconds(),
],
'capabilities' => AdminDrawResponsePolicy::capabilities($admin),
],
]);
);
}
/**
@@ -129,38 +141,4 @@ final class AdminDrawIndexController extends Controller
});
}
/**
* @param array<int, array{total_bet_minor: int, total_payout_minor: int, profit_loss_minor: int}> $statsByDrawId
* @return array<string, mixed>
*/
private function row(Draw $draw, array $statsByDrawId): array
{
$stats = $statsByDrawId[(int) $draw->id] ?? [
'total_bet_minor' => 0,
'total_payout_minor' => 0,
'profit_loss_minor' => 0,
];
return [
'id' => (int) $draw->id,
'draw_no' => $draw->draw_no,
'business_date' => $draw->business_date instanceof Carbon
? $draw->business_date->format('Y-m-d')
: (string) $draw->business_date,
'sequence_no' => (int) $draw->sequence_no,
'status' => $draw->status,
'start_time' => $draw->start_time?->toIso8601String(),
'close_time' => $draw->close_time?->toIso8601String(),
'draw_time' => $draw->draw_time?->toIso8601String(),
'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(),
'result_source' => $draw->result_source,
'current_result_version' => (int) $draw->current_result_version,
'settle_version' => (int) $draw->settle_version,
'is_reopened' => (bool) $draw->is_reopened,
'total_bet_minor' => $stats['total_bet_minor'],
'total_payout_minor' => $stats['total_payout_minor'],
'profit_loss_minor' => $stats['profit_loss_minor'],
'updated_at' => $draw->updated_at?->toIso8601String(),
];
}
}

View File

@@ -4,56 +4,46 @@ namespace App\Http\Controllers\Api\V1\Admin\Draw;
use App\Models\Draw;
use App\Support\ApiResponse;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminDrawApiPresenter;
use App\Support\AdminDrawResponsePolicy;
use App\Lottery\DrawResultBatchStatus;
/**
* GET /api/v1/admin/draws/{draw}/result-batches 开奖批次与号码(审核/结果核对)。
*/
final class AdminDrawResultBatchesIndexController extends Controller
{
public function __invoke(Draw $draw): JsonResponse
public function __invoke(Request $request, Draw $draw): JsonResponse
{
$batches = $draw->resultBatches()
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$manage = AdminDrawResponsePolicy::canManageDrawResults($admin);
$query = $draw->resultBatches()
->with(['items' => function ($q): void {
$q->orderBy('prize_type')->orderBy('prize_index');
}])
->orderByDesc('result_version')
->get();
->orderByDesc('result_version');
if (! $manage) {
$query->where('status', DrawResultBatchStatus::Published->value);
}
$batches = $query->get();
return ApiResponse::success([
'draw_id' => (int) $draw->id,
'draw_no' => $draw->draw_no,
'draw_status' => $draw->status,
'batches' => $batches->map(fn (DrawResultBatch $b) => $this->serializeBatch($b))->all(),
'capabilities' => AdminDrawResponsePolicy::capabilities($admin),
'batches' => $batches
->map(fn (DrawResultBatch $b): array => AdminDrawApiPresenter::resultBatch($b, $admin))
->all(),
]);
}
/** @return array<string, mixed> */
private function serializeBatch(DrawResultBatch $batch): array
{
return [
'id' => (int) $batch->id,
'result_version' => (int) $batch->result_version,
'source_type' => $batch->source_type,
'rng_seed_hash' => $batch->rng_seed_hash,
'status' => $batch->status,
'created_by' => $batch->created_by,
'confirmed_by' => $batch->confirmed_by,
'confirmed_at' => $batch->confirmed_at?->toIso8601String(),
'created_at' => $batch->created_at?->toIso8601String(),
'updated_at' => $batch->updated_at?->toIso8601String(),
'items' => $batch->items->map(fn (DrawResultItem $item) => [
'prize_type' => $item->prize_type,
'prize_index' => (int) $item->prize_index,
'number_4d' => $item->number_4d,
'suffix_3d' => $item->suffix_3d,
'suffix_2d' => $item->suffix_2d,
'head_digit' => $item->head_digit,
'tail_digit' => $item->tail_digit,
])->values()->all(),
];
}
}

View File

@@ -2,12 +2,12 @@
namespace App\Http\Controllers\Api\V1\Admin\Draw;
use Carbon\Carbon;
use App\Models\Draw;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Lottery\DrawResultBatchStatus;
use App\Support\AdminDrawApiPresenter;
use App\Services\Draw\DrawHallSnapshotBuilder;
/**
@@ -19,41 +19,13 @@ final class AdminDrawShowController extends Controller
private readonly DrawHallSnapshotBuilder $hallPreview,
) {}
public function __invoke(Draw $draw): JsonResponse
public function __invoke(Request $request, Draw $draw): JsonResponse
{
$nowUtc = now()->utc();
$batchCounts = [
'total' => $draw->resultBatches()->count(),
'pending_review' => $draw->resultBatches()
->where('status', DrawResultBatchStatus::PendingReview->value)
->count(),
'published' => $draw->resultBatches()
->where('status', DrawResultBatchStatus::Published->value)
->count(),
];
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
return ApiResponse::success([
'id' => (int) $draw->id,
'draw_no' => $draw->draw_no,
'business_date' => $draw->business_date instanceof Carbon
? $draw->business_date->format('Y-m-d')
: (string) $draw->business_date,
'sequence_no' => (int) $draw->sequence_no,
/** 数据库当期状态(权威) */
'status' => $draw->status,
/** 与玩家大厅 snapshot 对齐的展示态(未跑 tick 时可能与 status 不一致) */
'hall_preview_status' => $this->hallPreview->effectiveHallDisplayStatus($draw, $nowUtc),
'start_time' => $draw->start_time?->toIso8601String(),
'close_time' => $draw->close_time?->toIso8601String(),
'draw_time' => $draw->draw_time?->toIso8601String(),
'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(),
'result_source' => $draw->result_source,
'current_result_version' => (int) $draw->current_result_version,
'settle_version' => (int) $draw->settle_version,
'is_reopened' => (bool) $draw->is_reopened,
'created_at' => $draw->created_at?->toIso8601String(),
'updated_at' => $draw->updated_at?->toIso8601String(),
'result_batch_counts' => $batchCounts,
]);
return ApiResponse::success(
AdminDrawApiPresenter::show($draw, $admin, $this->hallPreview),
);
}
}

View File

@@ -6,6 +6,7 @@ use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Models\AgentNode;
use App\Support\AdminIntegrationSiteAccess;
use App\Support\AdminIntegrationSitePresenter;
@@ -16,9 +17,18 @@ final class AdminIntegrationSiteIndexController extends Controller
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$items = AdminIntegrationSiteAccess::queryFor($admin)
->get()
->map(static fn ($site): array => AdminIntegrationSitePresenter::listItem($site))
$sites = AdminIntegrationSiteAccess::queryFor($admin)->get();
$rootSiteIds = AgentNode::query()
->where('depth', 0)
->whereIn('admin_site_id', $sites->pluck('id'))
->pluck('admin_site_id')
->flip();
$items = $sites
->map(static fn ($site): array => AdminIntegrationSitePresenter::listItem(
$site,
isset($rootSiteIds[(int) $site->id]),
))
->all();
return ApiResponse::success(['items' => $items]);

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Integration;
use App\Http\Controllers\Controller;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Models\AdminSite;
use App\Lottery\ErrorCode;
use App\Services\AuditLogger;
use App\Support\AdminIntegrationSiteAccess;
use App\Support\ApiMessage;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/** GET /api/v1/admin/integration-sites/{admin_site}/secrets */
final class AdminIntegrationSiteSecretsController extends Controller
{
public function __invoke(Request $request, AdminSite $admin_site): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if (! AdminIntegrationSiteAccess::canAccess($admin, $admin_site)) {
return ApiMessage::errorResponse($request, 'admin.site_access_denied', ErrorCode::AdminForbidden->value, null, 403);
}
if (! $admin->isSuperAdmin() && ! $admin->hasPermissionCode('integration.site.manage')) {
return ApiMessage::errorResponse($request, 'admin.permission_denied', ErrorCode::AdminForbidden->value, null, 403);
}
$sso = $admin_site->decryptedSsoJwtSecret();
$wallet = $admin_site->decryptedWalletApiKey();
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'integration',
actionCode: 'reveal_secrets',
targetType: 'admin_site',
targetId: (string) $admin_site->id,
afterJson: ['code' => $admin_site->code],
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return ApiResponse::success([
'sso_jwt_secret' => is_string($sso) ? $sso : '',
'wallet_api_key' => is_string($wallet) ? $wallet : '',
]);
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Api\V1\Admin\Integration;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\AuditLogger;
@@ -11,8 +10,6 @@ use App\Services\Integration\IntegrationSiteService;
use App\Support\AdminIntegrationSitePresenter;
use App\Http\Requests\Admin\AdminIntegrationSiteStoreRequest;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Lottery\ErrorCode;
use App\Support\ApiMessage;
final class AdminIntegrationSiteStoreController extends Controller
{
@@ -23,19 +20,6 @@ final class AdminIntegrationSiteStoreController extends Controller
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if (! $admin->isSuperAdmin()) {
return ApiMessage::errorResponse(
$request,
'admin.integration_site_store_deprecated',
ErrorCode::AdminForbidden->value,
['hint' => 'Use POST /api/v1/admin/agent-lines to provision a new agent line.'],
403,
)->withHeaders([
'Deprecation' => 'true',
'Link' => '</api/v1/admin/agent-lines>; rel="successor-version"',
]);
}
$result = $service->create($request->validated());
$site = $result['site'];

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\V1\Admin\Player;
use App\Models\Player;
use App\Models\TicketOrder;
use App\Lottery\ErrorCode;
use App\Support\ApiMessage;
use App\Support\ApiResponse;
@@ -10,7 +11,7 @@ use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminSiteScope;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Builder;
/** DELETE /api/v1/admin/players/{player} */
final class AdminPlayerDestroyController extends Controller
@@ -26,16 +27,15 @@ final class AdminPlayerDestroyController extends Controller
$hasWallets = Player::query()
->whereKey($player->getKey())
->whereHas('wallets', static fn (HasMany $q) => $q->whereRaw('balance != 0'))
->whereHas('wallets', static fn (Builder $q) => $q->where('balance', '!=', 0))
->exists();
if ($hasWallets) {
return ApiMessage::errorResponse($request, 'admin.player_wallet_balance_blocks_delete', ErrorCode::ValidationFailed->value, null, 422);
}
$hasTickets = Player::query()
->whereKey($player->getKey())
->whereHas('ticketOrders')
$hasTickets = TicketOrder::query()
->where('player_id', $player->getKey())
->exists();
if ($hasTickets) {

View File

@@ -37,7 +37,11 @@ final class AdminPlayerIndexController extends Controller
$term = '%'.addcslashes($keyword, '%_\\').'%';
$q->where(static function ($sub) use ($term): void {
$sub->where('site_player_id', 'like', $term)
->orWhere('username', 'like', $term);
->orWhere('username', 'like', $term)
->orWhere('nickname', 'like', $term);
if (ctype_digit($keyword)) {
$sub->orWhere('id', (int) $keyword);
}
});
}

View File

@@ -16,6 +16,10 @@ use App\Models\AgentNode;
use App\Services\Agent\AgentProfileService;
use App\Services\Agent\RebateLimitValidator;
use App\Services\Player\PlayerCreditService;
use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/** POST /api/v1/admin/players */
final class AdminPlayerStoreController extends Controller
@@ -46,15 +50,44 @@ final class AdminPlayerStoreController extends Controller
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
: 'native:'.Str::lower(Str::ulid());
}
if ($sitePlayerId === '') {
return ApiMessage::errorResponse($request, 'admin.player_site_player_id_required', ErrorCode::ValidationFailed->value, null, 422);
}
$exists = Player::query()
->where('site_code', $request->validated('site_code'))
->where('site_player_id', $request->validated('site_player_id'))
->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)
: $admin->primaryAgentNodeId();
@@ -85,33 +118,58 @@ final class AdminPlayerStoreController extends Controller
);
}
$player = Player::query()->create([
'site_code' => $request->validated('site_code'),
'agent_node_id' => $agentNodeId,
'site_player_id' => $request->validated('site_player_id'),
'username' => $request->validated('username'),
'nickname' => $request->validated('nickname'),
'default_currency' => $request->validated('default_currency', 'NPR'),
'status' => $request->validated('status', 0),
]);
$creditLimit = $request->has('credit_limit')
? (int) $request->input('credit_limit', 0)
: ($isNative ? 0 : 0);
if ($request->has('credit_limit')) {
$playerCreditService->upsertAccount($player, [
'credit_limit' => (int) $request->input('credit_limit', 0),
]);
}
$player = \Illuminate\Support\Facades\DB::transaction(function () use (
$agent,
$agentProfileService,
$playerCreditService,
$request,
$isNative,
$siteCode,
$agentNodeId,
$sitePlayerId,
$creditLimit,
): Player {
$agentProfileService->assertMayIncreasePlayerCredit($agent, $creditLimit);
if ($request->has('rebate_rate')) {
\Illuminate\Support\Facades\DB::table('player_rebate_profiles')->insert([
'player_id' => $player->id,
'game_type' => '*',
'inherit_from_agent' => false,
'rebate_rate' => (float) $request->input('rebate_rate', 0),
'extra_rebate_rate' => (float) $request->input('extra_rebate_rate', 0),
'created_at' => now(),
'updated_at' => now(),
$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')) {
\Illuminate\Support\Facades\DB::table('player_rebate_profiles')->insert([
'player_id' => $player->id,
'game_type' => '*',
'inherit_from_agent' => false,
'rebate_rate' => (float) $request->input('rebate_rate', 0),
'extra_rebate_rate' => (float) $request->input('extra_rebate_rate', 0),
'created_at' => now(),
'updated_at' => now(),
]);
}
return $player;
});
return ApiResponse::success(PlayerApiPresenter::listItem($player))->setStatusCode(201);
}

View File

@@ -2,20 +2,31 @@
namespace App\Http\Controllers\Api\V1\Admin\Player;
use App\Models\Player;
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminSiteScope;
use App\Support\PlayerApiPresenter;
use App\Http\Requests\Admin\AdminPlayerUpdateRequest;
use App\Models\AgentNode;
use App\Models\Player;
use App\Services\Agent\AgentProfileService;
use App\Services\Agent\RebateLimitValidator;
use App\Services\Player\PlayerCreditService;
use App\Services\Player\PlayerRebateProfileService;
use App\Support\AdminSiteScope;
use App\Support\ApiResponse;
use App\Support\PlayerApiPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/** PUT /api/v1/admin/players/{player} */
final class AdminPlayerUpdateController extends Controller
{
public function __invoke(AdminPlayerUpdateRequest $request, Player $player): JsonResponse
{
public function __invoke(
AdminPlayerUpdateRequest $request,
Player $player,
AgentProfileService $agentProfileService,
PlayerCreditService $playerCreditService,
RebateLimitValidator $rebateLimitValidator,
PlayerRebateProfileService $rebateProfileService,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
@@ -29,7 +40,57 @@ final class AdminPlayerUpdateController extends Controller
$data['status'] = (int) $data['status'];
}
$player->fill(array_filter($data, static fn ($v) => $v !== ''));
$agent = $player->agent_node_id !== null
? AgentNode::query()->find((int) $player->agent_node_id)
: null;
if ($agent !== null && $request->has('rebate_rate')) {
$rebateLimitValidator->assertPlayerRebateWithinAgent(
$agent,
(float) $request->input('rebate_rate', 0),
(float) $request->input('extra_rebate_rate', 0),
);
}
if ($request->has('credit_limit') && $agent !== null) {
$newLimit = (int) $request->input('credit_limit', 0);
$creditRow = DB::table('player_credit_accounts')->where('player_id', $player->id)->first();
$previous = (int) ($creditRow->credit_limit ?? 0);
$usedCredit = (int) ($creditRow->used_credit ?? 0) + (int) ($creditRow->frozen_credit ?? 0);
$agentProfileService->adjustPlayerCreditAllocation($agent, $previous, $newLimit, $usedCredit);
$playerCreditService->upsertAccount($player, ['credit_limit' => $newLimit]);
$agentProfileService->refreshAllocatedCredit($agent);
unset($data['credit_limit']);
}
if ($request->has('rebate_rate')) {
DB::table('player_rebate_profiles')->updateOrInsert(
['player_id' => $player->id, 'game_type' => '*'],
[
'inherit_from_agent' => false,
'rebate_rate' => (float) $request->input('rebate_rate', 0),
'extra_rebate_rate' => (float) $request->input('extra_rebate_rate', 0),
'updated_at' => now(),
'created_at' => now(),
],
);
unset($data['rebate_rate'], $data['extra_rebate_rate']);
}
if ($request->has('rebate_profiles') && $agent !== null) {
$rebateProfileService->syncProfiles($player->id, $agent, $request->input('rebate_profiles', []));
unset($data['rebate_profiles']);
}
if ($request->has('risk_tags')) {
$player->risk_tags = array_values(array_unique(array_filter(
array_map('strval', $request->input('risk_tags', [])),
)));
unset($data['risk_tags']);
}
unset($data['rebate_profiles']);
$player->fill(array_filter($data, static fn ($v) => $v !== '' && ! is_array($v)));
$player->save();
return ApiResponse::success(PlayerApiPresenter::listItem($player));

View File

@@ -32,7 +32,7 @@ final class AdminReportRebateCommissionController extends Controller
$scope,
);
return AdminApiList::json($paginator, static function (object $row): array {
$response = AdminApiList::json($paginator, static function (object $row): array {
return [
'play_code' => (string) $row->play_code,
'total_rebate_minor' => (int) $row->total_rebate_minor,
@@ -40,5 +40,13 @@ final class AdminReportRebateCommissionController extends Controller
'ticket_item_count' => (int) $row->ticket_item_count,
];
});
$payload = $response->getData(true);
if (is_array($payload)) {
$payload['disclaimer'] = 'wallet_instant_rebate_not_agent_period_settlement';
$response->setData($payload);
}
return $response;
}
}

View File

@@ -12,6 +12,7 @@ use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminAccountScopeGuard;
use App\Support\AdminRoleApiPresenter;
use App\Support\PlatformSystemRoles;
final class AdminRoleDestroyController extends Controller
{
@@ -19,8 +20,8 @@ final class AdminRoleDestroyController extends Controller
{
AdminAccountScopeGuard::assertSystemRole($admin_role);
if ($admin_role->slug === AdminRole::ROLE_SUPER_ADMIN) {
return ApiMessage::errorResponse($request, 'admin.role_cannot_delete_super_admin', ErrorCode::ValidationFailed->value, null, 422);
if (PlatformSystemRoles::isFixedSlug((string) $admin_role->slug)) {
return ApiMessage::errorResponse($request, 'admin.role_builtin_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
}
if ((bool) $admin_role->is_system) {
return ApiMessage::errorResponse($request, 'admin.role_builtin_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);

View File

@@ -7,6 +7,7 @@ use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminRoleApiPresenter;
use App\Support\PlatformSystemRoles;
final class AdminRoleIndexController extends Controller
{
@@ -14,6 +15,7 @@ final class AdminRoleIndexController extends Controller
{
$roles = AdminRole::query()
->where('scope_type', AdminRole::SCOPE_SYSTEM)
->whereIn('slug', PlatformSystemRoles::fixedSlugs())
->orderBy('sort_order')
->orderBy('id')
->get();

View File

@@ -9,8 +9,11 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use App\Support\AdminPermissionInheritance;
use App\Lottery\ErrorCode;
use App\Support\AdminAccountScopeGuard;
use App\Support\AdminRoleApiPresenter;
use App\Support\ApiMessage;
use App\Support\PlatformSystemRoles;
use App\Http\Requests\Admin\AdminRolePermissionSyncRequest;
final class AdminRolePermissionSyncController extends Controller
@@ -19,6 +22,16 @@ final class AdminRolePermissionSyncController extends Controller
{
AdminAccountScopeGuard::assertSystemRole($admin_role);
if ($admin_role->slug === PlatformSystemRoles::SLUG_SUPER_ADMIN) {
return ApiMessage::errorResponse(
$request,
'admin.role_super_admin_permissions_fixed',
ErrorCode::ValidationFailed->value,
null,
422,
);
}
$slugs = AdminPermissionInheritance::expand(
array_values(array_unique($request->validated('permission_slugs', []))),
);

View File

@@ -2,53 +2,22 @@
namespace App\Http\Controllers\Api\V1\Admin\User;
use App\Models\AdminRole;
use App\Support\ApiResponse;
use App\Services\AuditLogger;
use App\Lottery\ErrorCode;
use App\Support\ApiMessage;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use App\Support\AdminPermissionInheritance;
use App\Support\AdminRoleApiPresenter;
use App\Http\Requests\Admin\AdminRoleStoreRequest;
final class AdminRoleStoreController extends Controller
{
public function __invoke(AdminRoleStoreRequest $request): JsonResponse
{
$permissionSlugs = AdminPermissionInheritance::expand(
array_values(array_unique($request->validated('permission_slugs', []))),
);
$role = DB::transaction(function () use ($request, $permissionSlugs): AdminRole {
$role = AdminRole::query()->create([
'slug' => $request->validated('slug'),
'code' => $request->validated('slug'),
'name' => $request->validated('name'),
'description' => $request->validated('description'),
'status' => $request->validated('status', 1),
'is_system' => false,
'sort_order' => 0,
'scope_type' => AdminRole::SCOPE_SYSTEM,
'owner_agent_id' => null,
'delegated_from_role_id' => null,
]);
$role->syncLegacyPermissionSlugs($permissionSlugs);
return $role->fresh();
});
AuditLogger::recordForAdmin(
$request->lotteryAdmin(),
return ApiMessage::errorResponse(
$request,
'system',
'admin_role.create',
'admin_role',
(string) $role->id,
'admin.platform_roles_fixed',
ErrorCode::ValidationFailed->value,
null,
AdminRoleApiPresenter::item($role),
422,
);
return ApiResponse::success(AdminRoleApiPresenter::item($role));
}
}

View File

@@ -3,12 +3,15 @@
namespace App\Http\Controllers\Api\V1\Admin\User;
use App\Models\AdminRole;
use App\Lottery\ErrorCode;
use App\Support\ApiMessage;
use App\Support\ApiResponse;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminAccountScopeGuard;
use App\Support\AdminRoleApiPresenter;
use App\Support\PlatformSystemRoles;
use App\Http\Requests\Admin\AdminRoleUpdateRequest;
final class AdminRoleUpdateController extends Controller
@@ -17,6 +20,16 @@ final class AdminRoleUpdateController extends Controller
{
AdminAccountScopeGuard::assertSystemRole($admin_role);
if ($admin_role->slug === PlatformSystemRoles::SLUG_SUPER_ADMIN) {
return ApiMessage::errorResponse(
$request,
'admin.role_super_admin_metadata_fixed',
ErrorCode::ValidationFailed->value,
null,
422,
);
}
$before = AdminRoleApiPresenter::item($admin_role);
$payload = [];

View File

@@ -8,6 +8,7 @@ use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminAccountScopeGuard;
use App\Support\AdminUserApiPresenter;
use App\Support\AdminPlatformUserSiteGuard;
use App\Http\Requests\Admin\AdminUserRoleSyncRequest;
/** PUT /api/v1/admin/admin-users/{admin_user}/roles */
@@ -15,10 +16,16 @@ final class AdminUserRoleSyncController extends Controller
{
public function __invoke(AdminUserRoleSyncRequest $request, AdminUser $admin_user): JsonResponse
{
/** @var AdminUser $actor */
$actor = $request->lotteryAdmin();
AdminAccountScopeGuard::assertPlatformAccount($admin_user);
$siteId = (int) $request->validated('admin_site_id');
AdminPlatformUserSiteGuard::assertActorCanAssignSite($actor, $siteId);
$slugs = array_values(array_unique($request->validated('role_slugs')));
$admin_user->syncSystemRoleSlugs($slugs);
$admin_user->syncSystemRoleSlugsForSite($siteId, $slugs);
$admin_user->load('roles');

View File

@@ -9,6 +9,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use App\Support\AdminUserApiPresenter;
use App\Support\AdminPlatformUserSiteGuard;
use App\Http\Requests\Admin\AdminUserStoreRequest;
/**
@@ -28,8 +29,10 @@ final class AdminUserStoreController extends Controller
: null;
$roleSlugs = array_values(array_unique($request->validated('role_slugs')));
$siteId = (int) $request->validated('admin_site_id');
AdminPlatformUserSiteGuard::assertActorCanAssignSite($actor, $siteId);
$user = DB::transaction(function () use ($request, $email, $roleSlugs): AdminUser {
$user = DB::transaction(function () use ($request, $email, $roleSlugs, $siteId): AdminUser {
$created = AdminUser::query()->create([
'username' => $request->validated('username'),
'name' => $request->validated('nickname'),
@@ -37,7 +40,7 @@ final class AdminUserStoreController extends Controller
'password' => $request->validated('password'),
'status' => $request->validated('status', 0),
]);
$created->syncSystemRoleSlugs($roleSlugs);
$created->syncSystemRoleSlugsForSite($siteId, $roleSlugs);
return $created;
});

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\V1\Admin\Wallet;
use App\Models\Player;
use App\Models\WalletTxn;
use App\Support\ApiResponse;
use App\Support\PaginationTrait;
@@ -9,8 +10,10 @@ use Illuminate\Http\JsonResponse;
use App\Support\AdminScopePolicy;
use App\Support\AgentNodeApiPresenter;
use App\Support\CurrencyFormatter;
use App\Support\PlayerFundingMode;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\WalletTransactionListRequest;
use App\Services\Wallet\PlayerLedgerLogsService;
/**
* 后台:彩票钱包流水列表 {@see wallet_txns}
@@ -33,6 +36,10 @@ final class WalletTransactionListController extends Controller
private const ALLOWED_STATUS = ['posted', 'pending_reconcile', 'reversed'];
public function __construct(
private readonly PlayerLedgerLogsService $ledgerLogs,
) {}
public function __invoke(WalletTransactionListRequest $request): JsonResponse
{
$admin = $request->lotteryAdmin();
@@ -44,6 +51,20 @@ final class WalletTransactionListController extends Controller
$perPage = $this->perPage($request, 'per_page', 10, 100);
$page = $this->page($request);
if (! empty($validated['player_id'])) {
$player = Player::query()->find((int) $validated['player_id']);
if ($player !== null && PlayerFundingMode::usesCredit($player)) {
$credit = $this->ledgerLogs->listForAdminPlayer(
$player,
$page,
$perPage,
isset($validated['biz_type']) ? (string) $validated['biz_type'] : null,
);
return ApiResponse::success($credit);
}
}
$query = WalletTxn::query()
->with([
'player:id,site_code,site_player_id,username,nickname,agent_node_id',
@@ -141,6 +162,9 @@ final class WalletTransactionListController extends Controller
'remark' => $t->remark,
'created_at' => $t->created_at?->toIso8601String(),
'updated_at' => $t->updated_at?->toIso8601String(),
'ledger_source' => 'wallet_txn',
'funding_mode' => $p?->funding_mode,
'auth_source' => $p?->auth_source,
];
}
}

View File

@@ -27,6 +27,8 @@ final class MeController extends Controller
'id' => $player->id,
'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id,
'auth_source' => $player->auth_source,
'funding_mode' => $player->funding_mode,
'username' => $player->username,
'nickname' => $player->nickname,
'default_currency' => $player->default_currency,

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Api\V1\Player;
use App\Http\Controllers\Controller;
use App\Http\Requests\Player\PlayerAuthLoginRequest;
use App\Services\Player\PlayerNativeAuthService;
use App\Support\ApiResponse;
use App\Exceptions\PlayerAuthenticationException;
use Illuminate\Http\JsonResponse;
/** POST /api/v1/player/auth/login — 代理线下玩家账号密码登录 */
final class PlayerAuthLoginController extends Controller
{
public function __invoke(PlayerAuthLoginRequest $request, PlayerNativeAuthService $auth): JsonResponse
{
try {
$data = $auth->login(
(string) $request->validated('site_code'),
(string) $request->validated('username'),
(string) $request->validated('password'),
);
} catch (PlayerAuthenticationException $e) {
return ApiResponse::error(
$e->getMessage(),
$e->lotteryCode,
null,
$e->httpStatus,
);
}
return ApiResponse::success($data);
}
}

View File

@@ -7,9 +7,12 @@ use App\Lottery\ErrorCode;
use App\Models\PlayerWallet;
use App\Support\ApiMessage;
use App\Support\ApiResponse;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;
use App\Support\CurrencyResolver;
use Illuminate\Http\JsonResponse;
use App\Support\CreditAmountScale;
use App\Support\CurrencyFormatter;
use App\Http\Controllers\Controller;
use App\Services\Wallet\HttpMainSiteWalletBalanceClient;
@@ -44,6 +47,37 @@ final class WalletBalanceController extends Controller
return $currencyCode;
}
if (PlayerFundingMode::usesCredit($player)) {
$credit = DB::table('player_credit_accounts')->where('player_id', $player->id)->first();
$limitMajor = (int) ($credit->credit_limit ?? 0);
$usedMajor = (int) ($credit->used_credit ?? 0);
$frozenMajor = (int) ($credit->frozen_credit ?? 0);
$availableMajor = max(0, $limitMajor - $usedMajor - $frozenMajor);
$limitMinor = CreditAmountScale::majorToMinor($limitMajor, $currencyCode);
$usedMinor = CreditAmountScale::majorToMinor($usedMajor, $currencyCode);
$frozenMinor = CreditAmountScale::majorToMinor($frozenMajor, $currencyCode);
$availableMinor = CreditAmountScale::majorToMinor($availableMajor, $currencyCode);
return ApiResponse::success([
'balance' => $limitMinor,
'balance_formatted' => CurrencyFormatter::fromMinor($limitMinor),
'available_balance' => $availableMinor,
'available_balance_formatted' => CurrencyFormatter::fromMinor($availableMinor),
'credit_limit' => $limitMinor,
'used_credit' => $usedMinor,
'credit_line_mode' => true,
'funding_mode' => PlayerFundingMode::CREDIT,
'auth_source' => $player->auth_source,
'main_balance' => null,
'main_balance_formatted' => null,
'currency_code' => $currencyCode,
'wallet_type' => self::WALLET_TYPE_LOTTERY,
'frozen_balance' => $frozenMinor,
'frozen_balance_formatted' => CurrencyFormatter::fromMinor($frozenMinor),
]);
}
$wallet = PlayerWallet::query()->firstOrCreate(
[
'player_id' => $player->id,
@@ -69,6 +103,9 @@ final class WalletBalanceController extends Controller
'balance_formatted' => CurrencyFormatter::fromMinor($balance),
'available_balance' => $available,
'available_balance_formatted' => CurrencyFormatter::fromMinor($available),
'credit_line_mode' => false,
'funding_mode' => PlayerFundingMode::WALLET,
'auth_source' => $player->auth_source,
'main_balance' => $mainBalance,
'main_balance_formatted' => $mainBalance !== null
? CurrencyFormatter::fromMinor($mainBalance)

View File

@@ -2,18 +2,17 @@
namespace App\Http\Controllers\Api\V1\Wallet;
use App\Models\WalletTxn;
use Illuminate\Support\Str;
use App\Models\TransferOrder;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use App\Models\TransferOrder;
use App\Support\PaginationTrait;
use Illuminate\Http\JsonResponse;
use App\Support\CurrencyFormatter;
use App\Http\Controllers\Controller;
use App\Services\Wallet\PlayerLedgerLogsService;
/**
* PRD §10.1.1`GET /api/v1/wallet/logs` 钱包流水
* PRD §10.1.1`GET /api/v1/wallet/logs` 钱包/信用流水(按玩家资金模式分表)
*
* Query`page``size`(每页条数,默认 20)、`type`逗号分隔transfer_in,transfer_out,bet,prize,refund,reversal
*/
@@ -21,15 +20,9 @@ final class WalletLogsController extends Controller
{
use PaginationTrait;
/** PRD 对外类型 → 本地 biz_type */
private const TYPE_TO_BIZ = [
'transfer_in' => ['transfer_in'],
'transfer_out' => ['transfer_out'],
'refund' => ['transfer_out_refund'],
'reversal' => ['reversal', 'bet_reverse'],
'bet' => ['bet_deduct', 'bet'],
'prize' => ['settle_payout', 'prize', 'jackpot_manual_payout'],
];
public function __construct(
private readonly PlayerLedgerLogsService $ledgerLogs,
) {}
public function __invoke(Request $request): JsonResponse
{
@@ -38,45 +31,21 @@ final class WalletLogsController extends Controller
$perPage = $this->perPage($request, 'size', 20, 100);
$page = $this->page($request);
$currencyCode = strtoupper(trim((string) $request->query('currency', '')));
$typeFilter = (string) $request->query('type', '');
$pendingPayload = $this->pendingReconcilePayload((int) $player->id, $currencyCode);
$bizFilter = $this->resolveBizTypeFilter((string) $request->query('type', ''));
if (is_array($bizFilter) && $bizFilter === []) {
return ApiResponse::success([
'items' => [],
'total' => 0,
'page' => $page,
'per_page' => $perPage,
'pending_reconcile' => $pendingPayload,
]);
}
$query = WalletTxn::query()
->where('player_id', $player->id)
->with('wallet')
->orderByDesc('id');
if ($currencyCode !== '') {
$query->whereHas('wallet', fn ($q) => $q->where('currency_code', $currencyCode));
}
if ($bizFilter !== null) {
$query->whereIn('biz_type', $bizFilter);
}
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
$items = $paginator->getCollection()->map(fn (WalletTxn $txn) => $this->formatTxn($txn));
$result = $this->ledgerLogs->listForPlayerApi($player, $page, $perPage, $currencyCode, $typeFilter);
return ApiResponse::success([
'items' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'items' => $result['items'],
'total' => $result['total'],
'page' => $result['page'],
'per_page' => $result['per_page'],
'ledger_source' => $result['ledger_source'],
'funding_mode' => $result['funding_mode'],
'auth_source' => $result['auth_source'],
'pending_reconcile' => $pendingPayload,
]);
}
@@ -97,84 +66,6 @@ final class WalletLogsController extends Controller
->all();
}
/**
* @return list<string>|null null 表示不过滤;空列表表示过滤后无合法 type结果应为空
*/
private function resolveBizTypeFilter(string $raw): ?array
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
$parts = array_filter(array_map('trim', explode(',', $raw)));
if ($parts === []) {
return null;
}
$biz = [];
foreach ($parts as $p) {
$key = Str::lower($p);
if (! isset(self::TYPE_TO_BIZ[$key])) {
continue;
}
foreach (self::TYPE_TO_BIZ[$key] as $b) {
$biz[] = $b;
}
}
return array_values(array_unique($biz));
}
/**
* @return array<string, mixed>
*/
private function formatTxn(WalletTxn $txn): array
{
$currency = $txn->wallet?->currency_code ?? '';
$amount = (int) $txn->amount;
$balanceAfter = (int) $txn->balance_after;
return [
'log_id' => $txn->txn_no,
'type' => $this->bizToPublicType((string) $txn->biz_type),
'biz_type' => $txn->biz_type,
'amount' => $this->signedAmount($txn),
'amount_formatted' => CurrencyFormatter::fromMinor($amount),
'amount_abs' => $amount,
'amount_abs_formatted' => CurrencyFormatter::fromMinor($amount),
'direction' => (int) $txn->direction === 1 ? 'in' : 'out',
'currency_code' => $currency,
'balance_after' => $balanceAfter,
'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfter),
'ref_id' => $txn->biz_no,
'idempotent_key' => $txn->idempotent_key,
'external_ref_no' => $txn->external_ref_no,
'status' => $txn->status,
'remark' => $txn->remark,
'created_at' => $txn->created_at?->toIso8601String(),
];
}
private function bizToPublicType(string $biz): string
{
return match ($biz) {
'transfer_out_refund' => 'refund',
'bet_deduct', 'bet' => 'bet',
'bet_reverse' => 'reversal',
'settle_payout', 'prize', 'jackpot_manual_payout' => 'prize',
'reversal' => 'reversal',
default => $biz,
};
}
private function signedAmount(WalletTxn $txn): int
{
$a = (int) $txn->amount;
return (int) $txn->direction === 1 ? $a : -$a;
}
/**
* @return array<string, mixed>
*/