Files
lotteryLaravel/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileItemIndexController.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

163 lines
6.1 KiB
PHP

<?php
namespace App\Http\Controllers\Api\V1\Admin\Reconcile;
use App\Models\AdminSite;
use App\Models\AdminUser;
use App\Models\TransferOrder;
use App\Models\ReconcileJob;
use Illuminate\Http\Request;
use App\Models\ReconcileItem;
use App\Support\AdminAgentScope;
use App\Support\AdminApiList;
use App\Support\AdminReconcileScope;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminTransferOrderCapabilities;
use App\Services\Wallet\LotteryTransferService;
use App\Services\Wallet\HttpMainSiteWalletIdempotentProbeClient;
use App\Services\Wallet\MainSiteWalletIdempotentProbeResult;
/** GET /api/v1/admin/reconcile-jobs/{reconcile_job}/items */
final class ReconcileItemIndexController extends Controller
{
public function __construct(
private readonly LotteryTransferService $transferService,
private readonly HttpMainSiteWalletIdempotentProbeClient $mainSiteProbeClient,
) {}
public function __invoke(Request $request, ReconcileJob $reconcile_job): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if ($denied = AdminReconcileScope::denyUnlessJobAccessible($admin, $reconcile_job)) {
return $denied;
}
$p = AdminApiList::readPaging($request, 50, 200);
$itemsQuery = $reconcile_job->items()->orderBy('id');
if (! $admin->isSuperAdmin()) {
$siteIds = $admin->accessibleAdminSiteIds() ?? [];
$siteCodes = $siteIds === []
? []
: AdminSite::query()
->whereIn('id', $siteIds)
->pluck('code')
->map(static fn ($code): string => (string) $code)
->all();
$agent = AdminAgentScope::primaryAgentNode($admin);
AdminReconcileScope::applyItemsTransferPlayerScope($itemsQuery, $siteCodes, $agent);
}
$paginator = $itemsQuery->paginate($p['perPage'], ['*'], 'page', $p['page']);
$transferNos = collect($paginator->items())
->map(fn (ReconcileItem $item) => $item->side_a_ref)
->filter(fn ($value) => is_string($value) && $value !== '')
->values()
->all();
$transferOrders = $transferNos === []
? collect()
: TransferOrder::query()
->with(['player:id,site_code,site_player_id'])
->whereIn('transfer_no', $transferNos)
->get()
->keyBy('transfer_no');
return AdminApiList::jsonWith($paginator, function (ReconcileItem $r) use ($transferOrders, $admin): array {
$transferNo = (string) ($r->side_a_ref ?? '');
/** @var TransferOrder|null $order */
$order = $transferNo !== '' ? $transferOrders->get($transferNo) : null;
$resolvedStatuses = ['success', 'reversed', 'manually_processed'];
$currentStatus = $order?->status;
$isResolved = $r->resolved_at !== null
|| in_array($currentStatus, $resolvedStatuses, true);
$capabilities = $order instanceof TransferOrder
? AdminTransferOrderCapabilities::forOrder(
$order,
$admin,
$this->transferService,
)
: [
'can_reverse' => false,
'can_complete_credit' => false,
'can_manually_process' => false,
];
$mainSiteCheck = $this->resolveMainSiteCheck($order);
return [
'id' => (int) $r->id,
'side_a_ref' => $r->side_a_ref,
'side_b_ref' => $r->side_b_ref,
'external_ref_no' => $order?->external_ref_no,
'difference_amount' => (int) $r->difference_amount,
'status' => $r->status,
'resolved_at' => $r->resolved_at?->toIso8601String(),
'is_resolved' => $isResolved,
'current_transfer_status' => $currentStatus,
'transfer_direction' => $order?->direction,
'transfer_fail_reason' => $order?->fail_reason,
'main_site_check' => $mainSiteCheck['status'],
'main_site_check_message' => $mainSiteCheck['message'],
'main_site_external_ref_no' => $mainSiteCheck['external_ref_no'],
...$capabilities,
'created_at' => $r->created_at?->toIso8601String(),
];
}, [
'job_id' => (int) $reconcile_job->id,
'job_no' => $reconcile_job->job_no,
]);
}
/**
* @return array{status: string, message: ?string, external_ref_no: ?string}
*/
private function resolveMainSiteCheck(?TransferOrder $order): array
{
if ($order === null || $order->player === null) {
return [
'status' => MainSiteWalletIdempotentProbeResult::STATUS_SKIPPED,
'message' => null,
'external_ref_no' => null,
];
}
if (trim((string) $order->idempotent_key) === '') {
return [
'status' => MainSiteWalletIdempotentProbeResult::STATUS_SKIPPED,
'message' => null,
'external_ref_no' => null,
];
}
$shouldProbe = trim((string) $order->external_ref_no) !== ''
|| $order->status === 'success'
|| (
$order->direction === 'in'
&& $order->status === 'pending_reconcile'
&& $order->fail_reason === 'lottery_credit_failed'
);
if (! $shouldProbe) {
return [
'status' => MainSiteWalletIdempotentProbeResult::STATUS_SKIPPED,
'message' => null,
'external_ref_no' => null,
];
}
$probe = $this->mainSiteProbeClient->probe($order->player, (string) $order->idempotent_key);
return [
'status' => $probe->status,
'message' => $probe->message,
'external_ref_no' => $probe->externalRefNo ?? $order->external_ref_no,
];
}
}