feat: 添加新的错误码以支持配置版本管理,更新彩票配置以启用手动审核,增强 API 路由以支持玩法和赔率版本化管理
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Config;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\OddsVersion;
|
||||||
|
use App\Services\Config\OddsStreamService;
|
||||||
|
use App\Support\AdminConfigPresenter;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/** PUT /api/v1/admin/config/odds-versions/{id}/items */
|
||||||
|
final class OddsItemsReplaceController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, int $id, OddsStreamService $service): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var AdminUser $admin */
|
||||||
|
$admin = $request->lotteryAdmin();
|
||||||
|
|
||||||
|
/** @var OddsVersion $version */
|
||||||
|
$version = OddsVersion::query()->whereKey($id)->firstOrFail();
|
||||||
|
|
||||||
|
if ($version->status !== ConfigVersionStatus::Draft->value) {
|
||||||
|
return ApiResponse::error(
|
||||||
|
'version is not draft',
|
||||||
|
ErrorCode::ConfigVersionNotDraft->value,
|
||||||
|
null,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'items' => ['required', 'array', 'min:1'],
|
||||||
|
'items.*.play_code' => ['required', 'string', 'max:32', Rule::exists('play_types', 'play_code')],
|
||||||
|
'items.*.prize_scope' => ['required', 'string', 'max:32'],
|
||||||
|
'items.*.odds_value' => ['required', 'integer', 'min:0'],
|
||||||
|
'items.*.rebate_rate' => ['sometimes', 'numeric'],
|
||||||
|
'items.*.commission_rate' => ['sometimes', 'numeric'],
|
||||||
|
'items.*.currency_code' => ['required', 'string', 'max:16', Rule::exists('currencies', 'code')],
|
||||||
|
'items.*.extra_config_json' => ['sometimes', 'nullable', 'array'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service->replaceItems($version, $data['items'], $admin);
|
||||||
|
|
||||||
|
return ApiResponse::success(
|
||||||
|
AdminConfigPresenter::oddsVersionDetail($version->fresh()->load('items')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Config;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\OddsVersion;
|
||||||
|
use App\Services\Config\OddsStreamService;
|
||||||
|
use App\Support\AdminConfigPresenter;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/** GET /api/v1/admin/config/odds-versions */
|
||||||
|
final class OddsVersionIndexController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, OddsStreamService $service): JsonResponse
|
||||||
|
{
|
||||||
|
$perPage = min(max((int) $request->integer('per_page', 25), 1), 100);
|
||||||
|
$status = trim((string) $request->query('status', ''));
|
||||||
|
|
||||||
|
$paginator = $service->paginate($status === '' ? null : $status, $perPage);
|
||||||
|
|
||||||
|
return ApiResponse::success([
|
||||||
|
'items' => collect($paginator->items())->map(fn (OddsVersion $v) => AdminConfigPresenter::oddsVersionSummary($v))->all(),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $paginator->currentPage(),
|
||||||
|
'per_page' => $paginator->perPage(),
|
||||||
|
'total' => $paginator->total(),
|
||||||
|
'last_page' => $paginator->lastPage(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Config;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\OddsVersion;
|
||||||
|
use App\Services\Config\OddsStreamService;
|
||||||
|
use App\Support\AdminConfigPresenter;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/config/odds-versions/{id}/publish */
|
||||||
|
final class OddsVersionPublishController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, int $id, OddsStreamService $service): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var AdminUser $admin */
|
||||||
|
$admin = $request->lotteryAdmin();
|
||||||
|
|
||||||
|
/** @var OddsVersion $version */
|
||||||
|
$version = OddsVersion::query()->whereKey($id)->firstOrFail();
|
||||||
|
|
||||||
|
if ($version->status !== ConfigVersionStatus::Draft->value) {
|
||||||
|
return ApiResponse::error(
|
||||||
|
'version is not draft',
|
||||||
|
ErrorCode::ConfigVersionNotDraft->value,
|
||||||
|
null,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$service->publish($version, $admin, $request);
|
||||||
|
|
||||||
|
return ApiResponse::success(
|
||||||
|
AdminConfigPresenter::oddsVersionDetail($version->fresh()->load('items')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Config;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\OddsVersion;
|
||||||
|
use App\Support\AdminConfigPresenter;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
/** GET /api/v1/admin/config/odds-versions/{id} */
|
||||||
|
final class OddsVersionShowController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var OddsVersion $v */
|
||||||
|
$v = OddsVersion::query()->with('items')->whereKey($id)->firstOrFail();
|
||||||
|
|
||||||
|
return ApiResponse::success(AdminConfigPresenter::oddsVersionDetail($v));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Config;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Services\Config\OddsStreamService;
|
||||||
|
use App\Support\AdminConfigPresenter;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/config/odds-versions */
|
||||||
|
final class OddsVersionStoreController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, OddsStreamService $service): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var AdminUser $admin */
|
||||||
|
$admin = $request->lotteryAdmin();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'reason' => ['nullable', 'string', 'max:255'],
|
||||||
|
'clone_from_version_id' => ['nullable', 'integer', 'exists:odds_versions,id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = $service->createDraft(
|
||||||
|
$admin,
|
||||||
|
$data['reason'] ?? null,
|
||||||
|
$data['clone_from_version_id'] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiResponse::success(
|
||||||
|
AdminConfigPresenter::oddsVersionDetail($draft->load('items')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Config;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\PlayConfigVersion;
|
||||||
|
use App\Services\Config\PlayConfigStreamService;
|
||||||
|
use App\Support\AdminConfigPresenter;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/** PUT /api/v1/admin/config/play-versions/{id}/items */
|
||||||
|
final class PlayConfigItemsReplaceController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, int $id, PlayConfigStreamService $service): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var AdminUser $admin */
|
||||||
|
$admin = $request->lotteryAdmin();
|
||||||
|
|
||||||
|
/** @var PlayConfigVersion $version */
|
||||||
|
$version = PlayConfigVersion::query()->whereKey($id)->firstOrFail();
|
||||||
|
|
||||||
|
if ($version->status !== ConfigVersionStatus::Draft->value) {
|
||||||
|
return ApiResponse::error(
|
||||||
|
'version is not draft',
|
||||||
|
ErrorCode::ConfigVersionNotDraft->value,
|
||||||
|
null,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'items' => ['required', 'array', 'min:1'],
|
||||||
|
'items.*.play_code' => ['required', 'string', 'max:32', Rule::exists('play_types', 'play_code')],
|
||||||
|
'items.*.is_enabled' => ['sometimes', 'boolean'],
|
||||||
|
'items.*.min_bet_amount' => ['required', 'integer', 'min:0'],
|
||||||
|
'items.*.max_bet_amount' => ['required', 'integer', 'min:0'],
|
||||||
|
'items.*.display_order' => ['sometimes', 'integer'],
|
||||||
|
'items.*.rule_text_zh' => ['sometimes', 'nullable', 'string'],
|
||||||
|
'items.*.rule_text_en' => ['sometimes', 'nullable', 'string'],
|
||||||
|
'items.*.rule_text_ne' => ['sometimes', 'nullable', 'string'],
|
||||||
|
'items.*.extra_config_json' => ['sometimes', 'nullable', 'array'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service->replaceItems($version, $data['items'], $admin);
|
||||||
|
|
||||||
|
return ApiResponse::success(
|
||||||
|
AdminConfigPresenter::playConfigVersionDetail($version->fresh()->load('items')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Config;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\PlayConfigVersion;
|
||||||
|
use App\Services\Config\PlayConfigStreamService;
|
||||||
|
use App\Support\AdminConfigPresenter;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/** GET /api/v1/admin/config/play-versions */
|
||||||
|
final class PlayConfigVersionIndexController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, PlayConfigStreamService $service): JsonResponse
|
||||||
|
{
|
||||||
|
$perPage = min(max((int) $request->integer('per_page', 25), 1), 100);
|
||||||
|
$status = trim((string) $request->query('status', ''));
|
||||||
|
|
||||||
|
$paginator = $service->paginate($status === '' ? null : $status, $perPage);
|
||||||
|
|
||||||
|
return ApiResponse::success([
|
||||||
|
'items' => collect($paginator->items())->map(fn (PlayConfigVersion $v) => AdminConfigPresenter::playConfigVersionSummary($v))->all(),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $paginator->currentPage(),
|
||||||
|
'per_page' => $paginator->perPage(),
|
||||||
|
'total' => $paginator->total(),
|
||||||
|
'last_page' => $paginator->lastPage(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Config;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\PlayConfigVersion;
|
||||||
|
use App\Services\Config\PlayConfigStreamService;
|
||||||
|
use App\Support\AdminConfigPresenter;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/config/play-versions/{id}/publish */
|
||||||
|
final class PlayConfigVersionPublishController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, int $id, PlayConfigStreamService $service): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var AdminUser $admin */
|
||||||
|
$admin = $request->lotteryAdmin();
|
||||||
|
|
||||||
|
/** @var PlayConfigVersion $version */
|
||||||
|
$version = PlayConfigVersion::query()->whereKey($id)->firstOrFail();
|
||||||
|
|
||||||
|
if ($version->status !== ConfigVersionStatus::Draft->value) {
|
||||||
|
return ApiResponse::error(
|
||||||
|
'version is not draft',
|
||||||
|
ErrorCode::ConfigVersionNotDraft->value,
|
||||||
|
null,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$service->publish($version, $admin, $request);
|
||||||
|
|
||||||
|
return ApiResponse::success(
|
||||||
|
AdminConfigPresenter::playConfigVersionDetail($version->fresh()->load('items')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Config;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\PlayConfigVersion;
|
||||||
|
use App\Support\AdminConfigPresenter;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
/** GET /api/v1/admin/config/play-versions/{id} */
|
||||||
|
final class PlayConfigVersionShowController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var PlayConfigVersion $v */
|
||||||
|
$v = PlayConfigVersion::query()->with('items')->whereKey($id)->firstOrFail();
|
||||||
|
|
||||||
|
return ApiResponse::success(AdminConfigPresenter::playConfigVersionDetail($v));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Config;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Services\Config\PlayConfigStreamService;
|
||||||
|
use App\Support\AdminConfigPresenter;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/config/play-versions — 新建草稿(默认克隆当前 active,若无则按 play_types 生成)。 */
|
||||||
|
final class PlayConfigVersionStoreController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, PlayConfigStreamService $service): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var AdminUser $admin */
|
||||||
|
$admin = $request->lotteryAdmin();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'reason' => ['nullable', 'string', 'max:255'],
|
||||||
|
'clone_from_version_id' => ['nullable', 'integer', 'exists:play_config_versions,id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = $service->createDraft(
|
||||||
|
$admin,
|
||||||
|
$data['reason'] ?? null,
|
||||||
|
$data['clone_from_version_id'] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiResponse::success(
|
||||||
|
AdminConfigPresenter::playConfigVersionDetail($draft->load('items')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Config;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\RiskCapVersion;
|
||||||
|
use App\Services\Config\RiskCapStreamService;
|
||||||
|
use App\Support\AdminConfigPresenter;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/** PUT /api/v1/admin/config/risk-cap-versions/{id}/items */
|
||||||
|
final class RiskCapItemsReplaceController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, int $id, RiskCapStreamService $service): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var AdminUser $admin */
|
||||||
|
$admin = $request->lotteryAdmin();
|
||||||
|
|
||||||
|
/** @var RiskCapVersion $version */
|
||||||
|
$version = RiskCapVersion::query()->whereKey($id)->firstOrFail();
|
||||||
|
|
||||||
|
if ($version->status !== ConfigVersionStatus::Draft->value) {
|
||||||
|
return ApiResponse::error(
|
||||||
|
'version is not draft',
|
||||||
|
ErrorCode::ConfigVersionNotDraft->value,
|
||||||
|
null,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'items' => ['required', 'array', 'min:1'],
|
||||||
|
'items.*.draw_id' => ['sometimes', 'nullable', 'integer', 'exists:draws,id'],
|
||||||
|
'items.*.normalized_number' => ['required', 'string', 'size:4', 'regex:/^[0-9]{4}$/'],
|
||||||
|
'items.*.cap_amount' => ['required', 'integer', 'min:0'],
|
||||||
|
'items.*.cap_type' => ['required', 'string', 'max:16'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service->replaceItems($version, $data['items'], $admin);
|
||||||
|
|
||||||
|
return ApiResponse::success(
|
||||||
|
AdminConfigPresenter::riskCapVersionDetail($version->fresh()->load('items')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Config;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\RiskCapVersion;
|
||||||
|
use App\Services\Config\RiskCapStreamService;
|
||||||
|
use App\Support\AdminConfigPresenter;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/** GET /api/v1/admin/config/risk-cap-versions */
|
||||||
|
final class RiskCapVersionIndexController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, RiskCapStreamService $service): JsonResponse
|
||||||
|
{
|
||||||
|
$perPage = min(max((int) $request->integer('per_page', 25), 1), 100);
|
||||||
|
$status = trim((string) $request->query('status', ''));
|
||||||
|
|
||||||
|
$paginator = $service->paginate($status === '' ? null : $status, $perPage);
|
||||||
|
|
||||||
|
return ApiResponse::success([
|
||||||
|
'items' => collect($paginator->items())->map(fn (RiskCapVersion $v) => AdminConfigPresenter::riskCapVersionSummary($v))->all(),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $paginator->currentPage(),
|
||||||
|
'per_page' => $paginator->perPage(),
|
||||||
|
'total' => $paginator->total(),
|
||||||
|
'last_page' => $paginator->lastPage(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Config;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\RiskCapVersion;
|
||||||
|
use App\Services\Config\RiskCapStreamService;
|
||||||
|
use App\Support\AdminConfigPresenter;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/config/risk-cap-versions/{id}/publish */
|
||||||
|
final class RiskCapVersionPublishController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, int $id, RiskCapStreamService $service): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var AdminUser $admin */
|
||||||
|
$admin = $request->lotteryAdmin();
|
||||||
|
|
||||||
|
/** @var RiskCapVersion $version */
|
||||||
|
$version = RiskCapVersion::query()->whereKey($id)->firstOrFail();
|
||||||
|
|
||||||
|
if ($version->status !== ConfigVersionStatus::Draft->value) {
|
||||||
|
return ApiResponse::error(
|
||||||
|
'version is not draft',
|
||||||
|
ErrorCode::ConfigVersionNotDraft->value,
|
||||||
|
null,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$service->publish($version, $admin, $request);
|
||||||
|
|
||||||
|
return ApiResponse::success(
|
||||||
|
AdminConfigPresenter::riskCapVersionDetail($version->fresh()->load('items')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Config;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\RiskCapVersion;
|
||||||
|
use App\Support\AdminConfigPresenter;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
/** GET /api/v1/admin/config/risk-cap-versions/{id} */
|
||||||
|
final class RiskCapVersionShowController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var RiskCapVersion $v */
|
||||||
|
$v = RiskCapVersion::query()->with('items')->whereKey($id)->firstOrFail();
|
||||||
|
|
||||||
|
return ApiResponse::success(AdminConfigPresenter::riskCapVersionDetail($v));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Config;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Services\Config\RiskCapStreamService;
|
||||||
|
use App\Support\AdminConfigPresenter;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/config/risk-cap-versions */
|
||||||
|
final class RiskCapVersionStoreController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, RiskCapStreamService $service): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var AdminUser $admin */
|
||||||
|
$admin = $request->lotteryAdmin();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'reason' => ['nullable', 'string', 'max:255'],
|
||||||
|
'clone_from_version_id' => ['nullable', 'integer', 'exists:risk_cap_versions,id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = $service->createDraft(
|
||||||
|
$admin,
|
||||||
|
$data['reason'] ?? null,
|
||||||
|
$data['clone_from_version_id'] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiResponse::success(
|
||||||
|
AdminConfigPresenter::riskCapVersionDetail($draft->load('items')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\PlayType;
|
||||||
|
use App\Support\AdminConfigPresenter;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
/** GET /api/v1/admin/play-types */
|
||||||
|
final class PlayTypeIndexController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(): JsonResponse
|
||||||
|
{
|
||||||
|
$rows = PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get();
|
||||||
|
|
||||||
|
return ApiResponse::success([
|
||||||
|
'items' => $rows->map(fn (PlayType $t) => AdminConfigPresenter::playType($t))->values()->all(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\PlayType;
|
||||||
|
use App\Support\AdminConfigPresenter;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/** PATCH /api/v1/admin/play-types/{play_code} — 主目录层开关与展示名(不等同于版本化 items)。 */
|
||||||
|
final class PlayTypePatchController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, string $play_code): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var PlayType $type */
|
||||||
|
$type = PlayType::query()->where('play_code', $play_code)->firstOrFail();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'is_enabled' => ['sometimes', 'boolean'],
|
||||||
|
'sort_order' => ['sometimes', 'integer'],
|
||||||
|
'display_name_zh' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||||
|
'display_name_en' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||||
|
'display_name_ne' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||||
|
'supports_multi_number' => ['sometimes', 'boolean'],
|
||||||
|
'reserved_rule_json' => ['sometimes', 'nullable', 'array'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($data !== []) {
|
||||||
|
$type->fill($data);
|
||||||
|
$type->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse::success(AdminConfigPresenter::playType($type->fresh()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Play;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Services\Config\EffectivePlayCatalogService;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/play/effective — 当前生效的玩法目录 + 赔率 + 封顶(公开,无需登录)。
|
||||||
|
*/
|
||||||
|
final class PlayEffectiveCatalogController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, EffectivePlayCatalogService $catalog): JsonResponse
|
||||||
|
{
|
||||||
|
$currency = $request->query('currency');
|
||||||
|
$c = is_string($currency) && $currency !== '' ? $currency : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return ApiResponse::success($catalog->build($c));
|
||||||
|
} catch (ModelNotFoundException) {
|
||||||
|
return ApiResponse::error(
|
||||||
|
'effective config not initialized',
|
||||||
|
ErrorCode::NotFound->value,
|
||||||
|
null,
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
if ($e->getMessage() === 'currency') {
|
||||||
|
return ApiResponse::error(
|
||||||
|
'invalid or disabled currency',
|
||||||
|
ErrorCode::ConfigCurrencyInvalid->value,
|
||||||
|
null,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Lottery/ConfigVersionStatus.php
Normal file
18
app/Lottery/ConfigVersionStatus.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Lottery;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩法配置 / 赔率 / 风控封顶 共用版本状态(play_config_versions 等表 {@see status})。
|
||||||
|
*/
|
||||||
|
enum ConfigVersionStatus: string
|
||||||
|
{
|
||||||
|
/** 草稿:可整表替换 items */
|
||||||
|
case Draft = 'draft';
|
||||||
|
|
||||||
|
/** 当前对玩家与下注逻辑生效的版本(每类最多一条) */
|
||||||
|
case Active = 'active';
|
||||||
|
|
||||||
|
/** 已被新版本替代,仅作历史查询 */
|
||||||
|
case Archived = 'archived';
|
||||||
|
}
|
||||||
@@ -60,6 +60,15 @@ enum ErrorCode: int
|
|||||||
/** PRD:下注语境余额不足(可与 1001 同语义) */
|
/** PRD:下注语境余额不足(可与 1001 同语义) */
|
||||||
case BetInsufficientBalance = 2003;
|
case BetInsufficientBalance = 2003;
|
||||||
|
|
||||||
|
/** 配置版本不是草稿,无法整表替换 items 或发布 */
|
||||||
|
case ConfigVersionNotDraft = 2101;
|
||||||
|
|
||||||
|
/** items 中存在未知 play_code(未在 play_types 登记) */
|
||||||
|
case ConfigUnknownPlayCode = 2102;
|
||||||
|
|
||||||
|
/** 赔率 / 目录币种未启用或不可下注 */
|
||||||
|
case ConfigCurrencyInvalid = 2103;
|
||||||
|
|
||||||
/* ========== 8000–8999 玩家 SSO / Bearer 鉴权 ========== */
|
/* ========== 8000–8999 玩家 SSO / Bearer 鉴权 ========== */
|
||||||
|
|
||||||
/** 无 Bearer / 格式错误 / token 为空 */
|
/** 无 Bearer / 格式错误 / token 为空 */
|
||||||
|
|||||||
42
app/Models/OddsItem.php
Normal file
42
app/Models/OddsItem.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@see odds_items}
|
||||||
|
*
|
||||||
|
* `odds_value`:整数,**赔率乘数 × 10000**(例如 19500 表示 1.9500 倍)。
|
||||||
|
*/
|
||||||
|
class OddsItem extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'version_id',
|
||||||
|
'play_code',
|
||||||
|
'prize_scope',
|
||||||
|
'odds_value',
|
||||||
|
'rebate_rate',
|
||||||
|
'commission_rate',
|
||||||
|
'currency_code',
|
||||||
|
'extra_config_json',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'version_id' => 'integer',
|
||||||
|
'odds_value' => 'integer',
|
||||||
|
'rebate_rate' => 'decimal:4',
|
||||||
|
'commission_rate' => 'decimal:4',
|
||||||
|
'extra_config_json' => 'json',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<OddsVersion, OddsItem> */
|
||||||
|
public function version(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(OddsVersion::class, 'version_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Models/OddsVersion.php
Normal file
55
app/Models/OddsVersion.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/** {@see odds_versions} */
|
||||||
|
class OddsVersion extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'version_no',
|
||||||
|
'status',
|
||||||
|
'effective_at',
|
||||||
|
'updated_by',
|
||||||
|
'reason',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'version_no' => 'integer',
|
||||||
|
'effective_at' => 'datetime',
|
||||||
|
'updated_by' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (OddsVersion $m): void {
|
||||||
|
if ($m->status === null || $m->status === '') {
|
||||||
|
$m->status = ConfigVersionStatus::Draft->value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<AdminUser, OddsVersion> */
|
||||||
|
public function updatedByAdmin(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AdminUser::class, 'updated_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<OddsItem, OddsVersion> */
|
||||||
|
public function items(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(OddsItem::class, 'version_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDraft(): bool
|
||||||
|
{
|
||||||
|
return $this->status === ConfigVersionStatus::Draft->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Models/PlayConfigItem.php
Normal file
47
app/Models/PlayConfigItem.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/** {@see play_config_items} — 与 {@see PlayType::play_code} 对齐。 */
|
||||||
|
class PlayConfigItem extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'version_id',
|
||||||
|
'play_code',
|
||||||
|
'is_enabled',
|
||||||
|
'min_bet_amount',
|
||||||
|
'max_bet_amount',
|
||||||
|
'display_order',
|
||||||
|
'rule_text_zh',
|
||||||
|
'rule_text_en',
|
||||||
|
'rule_text_ne',
|
||||||
|
'extra_config_json',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'version_id' => 'integer',
|
||||||
|
'is_enabled' => 'boolean',
|
||||||
|
'min_bet_amount' => 'integer',
|
||||||
|
'max_bet_amount' => 'integer',
|
||||||
|
'display_order' => 'integer',
|
||||||
|
'extra_config_json' => 'json',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<PlayConfigVersion, PlayConfigItem> */
|
||||||
|
public function version(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PlayConfigVersion::class, 'version_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<PlayType, PlayConfigItem> */
|
||||||
|
public function playType(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PlayType::class, 'play_code', 'play_code');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Models/PlayConfigVersion.php
Normal file
55
app/Models/PlayConfigVersion.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/** {@see play_config_versions} */
|
||||||
|
class PlayConfigVersion extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'version_no',
|
||||||
|
'status',
|
||||||
|
'effective_at',
|
||||||
|
'updated_by',
|
||||||
|
'reason',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'version_no' => 'integer',
|
||||||
|
'effective_at' => 'datetime',
|
||||||
|
'updated_by' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (PlayConfigVersion $m): void {
|
||||||
|
if ($m->status === null || $m->status === '') {
|
||||||
|
$m->status = ConfigVersionStatus::Draft->value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<AdminUser, PlayConfigVersion> */
|
||||||
|
public function updatedByAdmin(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AdminUser::class, 'updated_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<PlayConfigItem, PlayConfigVersion> */
|
||||||
|
public function items(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PlayConfigItem::class, 'version_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDraft(): bool
|
||||||
|
{
|
||||||
|
return $this->status === ConfigVersionStatus::Draft->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Models/PlayType.php
Normal file
34
app/Models/PlayType.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/** 玩法主数据目录 {@see play_types}(与版本化配置 items 通过 play_code 关联)。 */
|
||||||
|
class PlayType extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'play_code',
|
||||||
|
'category',
|
||||||
|
'dimension',
|
||||||
|
'bet_mode',
|
||||||
|
'display_name_zh',
|
||||||
|
'display_name_en',
|
||||||
|
'display_name_ne',
|
||||||
|
'is_enabled',
|
||||||
|
'sort_order',
|
||||||
|
'supports_multi_number',
|
||||||
|
'reserved_rule_json',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'dimension' => 'integer',
|
||||||
|
'is_enabled' => 'boolean',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
'supports_multi_number' => 'boolean',
|
||||||
|
'reserved_rule_json' => 'json',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Models/RiskCapItem.php
Normal file
43
app/Models/RiskCapItem.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@see risk_cap_items}
|
||||||
|
*
|
||||||
|
* `cap_amount`:与钱包一致的最小货币单位(整数)。
|
||||||
|
*/
|
||||||
|
class RiskCapItem extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'version_id',
|
||||||
|
'draw_id',
|
||||||
|
'normalized_number',
|
||||||
|
'cap_amount',
|
||||||
|
'cap_type',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'version_id' => 'integer',
|
||||||
|
'draw_id' => 'integer',
|
||||||
|
'cap_amount' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<RiskCapVersion, RiskCapItem> */
|
||||||
|
public function version(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(RiskCapVersion::class, 'version_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Draw, RiskCapItem> */
|
||||||
|
public function draw(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Draw::class, 'draw_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Models/RiskCapVersion.php
Normal file
55
app/Models/RiskCapVersion.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/** {@see risk_cap_versions} */
|
||||||
|
class RiskCapVersion extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'version_no',
|
||||||
|
'status',
|
||||||
|
'effective_at',
|
||||||
|
'updated_by',
|
||||||
|
'reason',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'version_no' => 'integer',
|
||||||
|
'effective_at' => 'datetime',
|
||||||
|
'updated_by' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (RiskCapVersion $m): void {
|
||||||
|
if ($m->status === null || $m->status === '') {
|
||||||
|
$m->status = ConfigVersionStatus::Draft->value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<AdminUser, RiskCapVersion> */
|
||||||
|
public function updatedByAdmin(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AdminUser::class, 'updated_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<RiskCapItem, RiskCapVersion> */
|
||||||
|
public function items(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(RiskCapItem::class, 'version_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDraft(): bool
|
||||||
|
{
|
||||||
|
return $this->status === ConfigVersionStatus::Draft->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
183
app/Services/Config/EffectivePlayCatalogService.php
Normal file
183
app/Services/Config/EffectivePlayCatalogService.php
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Config;
|
||||||
|
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use App\Models\Currency;
|
||||||
|
use App\Models\OddsItem;
|
||||||
|
use App\Models\OddsVersion;
|
||||||
|
use App\Models\PlayConfigItem;
|
||||||
|
use App\Models\PlayConfigVersion;
|
||||||
|
use App\Models\PlayType;
|
||||||
|
use App\Models\RiskCapItem;
|
||||||
|
use App\Models\RiskCapVersion;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩家端:当前生效的玩法目录 + 三套版本快照(只读)。
|
||||||
|
*/
|
||||||
|
final class EffectivePlayCatalogService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function build(?string $currencyCode = null): array
|
||||||
|
{
|
||||||
|
$currency = $this->resolveBettableCurrency($currencyCode);
|
||||||
|
|
||||||
|
$playVersion = PlayConfigVersion::query()
|
||||||
|
->where('status', ConfigVersionStatus::Active->value)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$oddsVersion = OddsVersion::query()
|
||||||
|
->where('status', ConfigVersionStatus::Active->value)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$riskVersion = RiskCapVersion::query()
|
||||||
|
->where('status', ConfigVersionStatus::Active->value)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$playTypes = PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get();
|
||||||
|
|
||||||
|
/** @var Collection<string, PlayConfigItem> $configByCode */
|
||||||
|
$configByCode = PlayConfigItem::query()
|
||||||
|
->where('version_id', $playVersion->id)
|
||||||
|
->get()
|
||||||
|
->keyBy('play_code');
|
||||||
|
|
||||||
|
$oddsRows = OddsItem::query()
|
||||||
|
->where('version_id', $oddsVersion->id)
|
||||||
|
->where('currency_code', $currency->code)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
/** @var Collection<string, Collection<int, OddsItem>> */
|
||||||
|
$oddsByPlay = $oddsRows->groupBy('play_code');
|
||||||
|
|
||||||
|
$riskItems = RiskCapItem::query()
|
||||||
|
->where('version_id', $riskVersion->id)
|
||||||
|
->orderBy('normalized_number')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$plays = $playTypes->map(function (PlayType $pt) use ($configByCode, $oddsByPlay): array {
|
||||||
|
$c = $configByCode->get($pt->play_code);
|
||||||
|
$items = $oddsByPlay->get($pt->play_code, collect());
|
||||||
|
$o = $this->pickPrimaryOddsItem($items);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'play_code' => $pt->play_code,
|
||||||
|
'category' => $pt->category,
|
||||||
|
'dimension' => $pt->dimension,
|
||||||
|
'bet_mode' => $pt->bet_mode,
|
||||||
|
'display_name_zh' => $pt->display_name_zh,
|
||||||
|
'display_name_en' => $pt->display_name_en,
|
||||||
|
'display_name_ne' => $pt->display_name_ne,
|
||||||
|
'sort_order' => (int) $pt->sort_order,
|
||||||
|
'supports_multi_number' => (bool) $pt->supports_multi_number,
|
||||||
|
'master_enabled' => (bool) $pt->is_enabled,
|
||||||
|
'config' => $c === null ? null : $this->serializePlayConfigItem($c),
|
||||||
|
'odds' => $o === null ? null : $this->serializeOddsItem($o),
|
||||||
|
];
|
||||||
|
})->values()->all();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'currency_code' => $currency->code,
|
||||||
|
'effective_versions' => [
|
||||||
|
'play_config' => $this->serializeVersionHead($playVersion),
|
||||||
|
'odds' => $this->serializeVersionHead($oddsVersion),
|
||||||
|
'risk_cap' => $this->serializeVersionHead($riskVersion),
|
||||||
|
],
|
||||||
|
'plays' => $plays,
|
||||||
|
'risk_cap_items' => $riskItems->map(fn (RiskCapItem $r) => $this->serializeRiskItem($r))->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 大厅列表展示单档赔率:优先头奖档 {@see first},兼容历史 {@see default}。
|
||||||
|
*
|
||||||
|
* @param Collection<int, OddsItem> $items
|
||||||
|
*/
|
||||||
|
private function pickPrimaryOddsItem(Collection $items): ?OddsItem
|
||||||
|
{
|
||||||
|
if ($items->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['first', 'default', 'second', 'third', 'starter', 'consolation'] as $scope) {
|
||||||
|
$hit = $items->firstWhere('prize_scope', $scope);
|
||||||
|
if ($hit !== null) {
|
||||||
|
return $hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveBettableCurrency(?string $currencyCode): Currency
|
||||||
|
{
|
||||||
|
if ($currencyCode !== null && $currencyCode !== '') {
|
||||||
|
$row = Currency::query()->where('code', strtoupper($currencyCode))->first();
|
||||||
|
if ($row === null || ! $row->is_enabled || ! $row->is_bettable) {
|
||||||
|
throw new \InvalidArgumentException('currency');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Currency::query()
|
||||||
|
->where('is_enabled', true)
|
||||||
|
->where('is_bettable', true)
|
||||||
|
->orderBy('code')
|
||||||
|
->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function serializeVersionHead(PlayConfigVersion|OddsVersion|RiskCapVersion $v): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) $v->getKey(),
|
||||||
|
'version_no' => (int) $v->version_no,
|
||||||
|
'effective_at' => $v->effective_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function serializePlayConfigItem(PlayConfigItem $r): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_enabled' => (bool) $r->is_enabled,
|
||||||
|
'min_bet_amount' => (int) $r->min_bet_amount,
|
||||||
|
'max_bet_amount' => (int) $r->max_bet_amount,
|
||||||
|
'display_order' => (int) $r->display_order,
|
||||||
|
'rule_text_zh' => $r->rule_text_zh,
|
||||||
|
'rule_text_en' => $r->rule_text_en,
|
||||||
|
'rule_text_ne' => $r->rule_text_ne,
|
||||||
|
'extra_config_json' => $r->extra_config_json,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function serializeOddsItem(OddsItem $r): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'prize_scope' => $r->prize_scope,
|
||||||
|
'odds_value' => (int) $r->odds_value,
|
||||||
|
'rebate_rate' => (string) $r->rebate_rate,
|
||||||
|
'commission_rate' => (string) $r->commission_rate,
|
||||||
|
'currency_code' => $r->currency_code,
|
||||||
|
'extra_config_json' => $r->extra_config_json,
|
||||||
|
/** 赔率乘数小数位 = odds_value / 10000 */
|
||||||
|
'odds_multiplier' => round($r->odds_value / 10000, 4),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function serializeRiskItem(RiskCapItem $r): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'draw_id' => $r->draw_id,
|
||||||
|
'normalized_number' => $r->normalized_number,
|
||||||
|
'cap_amount' => (int) $r->cap_amount,
|
||||||
|
'cap_type' => $r->cap_type,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
163
app/Services/Config/OddsStreamService.php
Normal file
163
app/Services/Config/OddsStreamService.php
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Config;
|
||||||
|
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\Currency;
|
||||||
|
use App\Models\OddsItem;
|
||||||
|
use App\Models\OddsVersion;
|
||||||
|
use App\Models\PlayType;
|
||||||
|
use App\Services\AuditLogger;
|
||||||
|
use App\Support\OddsStandardScopes;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/** 后台:赔率版本({@see odds_versions} / {@see odds_items}) */
|
||||||
|
final class OddsStreamService
|
||||||
|
{
|
||||||
|
/** @return LengthAwarePaginator<int, OddsVersion> */
|
||||||
|
public function paginate(?string $status, int $perPage): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$q = OddsVersion::query()->orderByDesc('id');
|
||||||
|
if ($status !== null && $status !== '') {
|
||||||
|
$q->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $q->paginate($perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createDraft(AdminUser $admin, ?string $reason, ?int $cloneFromVersionId): OddsVersion
|
||||||
|
{
|
||||||
|
$nextNo = (int) (OddsVersion::query()->max('version_no') ?? 0) + 1;
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($admin, $reason, $cloneFromVersionId, $nextNo): OddsVersion {
|
||||||
|
$draft = OddsVersion::query()->create([
|
||||||
|
'version_no' => $nextNo,
|
||||||
|
'status' => ConfigVersionStatus::Draft->value,
|
||||||
|
'effective_at' => null,
|
||||||
|
'updated_by' => $admin->id,
|
||||||
|
'reason' => $reason,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$source = null;
|
||||||
|
if ($cloneFromVersionId !== null) {
|
||||||
|
$source = OddsVersion::query()->whereKey($cloneFromVersionId)->firstOrFail();
|
||||||
|
} else {
|
||||||
|
$source = OddsVersion::query()
|
||||||
|
->where('status', ConfigVersionStatus::Active->value)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($source !== null) {
|
||||||
|
foreach ($source->items()->orderBy('currency_code')->orderBy('play_code')->get() as $row) {
|
||||||
|
OddsItem::query()->create([
|
||||||
|
'version_id' => $draft->id,
|
||||||
|
'play_code' => $row->play_code,
|
||||||
|
'prize_scope' => $row->prize_scope,
|
||||||
|
'odds_value' => $row->odds_value,
|
||||||
|
'rebate_rate' => $row->rebate_rate,
|
||||||
|
'commission_rate' => $row->commission_rate,
|
||||||
|
'currency_code' => $row->currency_code,
|
||||||
|
'extra_config_json' => $row->extra_config_json,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$currency = Currency::query()->where('is_bettable', true)->where('is_enabled', true)->orderBy('code')->firstOrFail();
|
||||||
|
foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $pt) {
|
||||||
|
foreach (OddsStandardScopes::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) {
|
||||||
|
OddsItem::query()->create([
|
||||||
|
'version_id' => $draft->id,
|
||||||
|
'play_code' => $pt->play_code,
|
||||||
|
'prize_scope' => $scope,
|
||||||
|
'odds_value' => $oddsValue,
|
||||||
|
'rebate_rate' => 0,
|
||||||
|
'commission_rate' => 0,
|
||||||
|
'currency_code' => $currency->code,
|
||||||
|
'extra_config_json' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$draft->refresh();
|
||||||
|
OddsStandardScopes::syncMissingForVersion($draft);
|
||||||
|
|
||||||
|
return $draft->fresh(['items']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $items
|
||||||
|
*/
|
||||||
|
public function replaceItems(OddsVersion $draft, array $items, AdminUser $admin): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($draft, $items, $admin): void {
|
||||||
|
OddsItem::query()->where('version_id', $draft->id)->delete();
|
||||||
|
|
||||||
|
foreach ($items as $row) {
|
||||||
|
OddsItem::query()->create([
|
||||||
|
'version_id' => $draft->id,
|
||||||
|
'play_code' => (string) $row['play_code'],
|
||||||
|
'prize_scope' => (string) $row['prize_scope'],
|
||||||
|
'odds_value' => (int) $row['odds_value'],
|
||||||
|
'rebate_rate' => (float) ($row['rebate_rate'] ?? 0),
|
||||||
|
'commission_rate' => (float) ($row['commission_rate'] ?? 0),
|
||||||
|
'currency_code' => strtoupper((string) $row['currency_code']),
|
||||||
|
'extra_config_json' => $row['extra_config_json'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$draft->forceFill(['updated_by' => $admin->id])->save();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publish(OddsVersion $draft, AdminUser $admin, ?Request $request = null): void
|
||||||
|
{
|
||||||
|
$before = $this->snapshotVersion($draft);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($draft, $admin): void {
|
||||||
|
/** @var OddsVersion|null $current */
|
||||||
|
$current = OddsVersion::query()
|
||||||
|
->where('status', ConfigVersionStatus::Active->value)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($current !== null) {
|
||||||
|
$current->forceFill(['status' => ConfigVersionStatus::Archived->value])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$draft->forceFill([
|
||||||
|
'status' => ConfigVersionStatus::Active->value,
|
||||||
|
'effective_at' => now(),
|
||||||
|
'updated_by' => $admin->id,
|
||||||
|
])->save();
|
||||||
|
});
|
||||||
|
|
||||||
|
$after = $this->snapshotVersion($draft->fresh(['items']));
|
||||||
|
|
||||||
|
AuditLogger::recordForAdmin(
|
||||||
|
$admin,
|
||||||
|
$request,
|
||||||
|
moduleCode: 'odds',
|
||||||
|
actionCode: 'publish',
|
||||||
|
targetType: 'odds_version',
|
||||||
|
targetId: (string) $draft->id,
|
||||||
|
beforeJson: $before,
|
||||||
|
afterJson: $after,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function snapshotVersion(OddsVersion $v): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $v->id,
|
||||||
|
'version_no' => $v->version_no,
|
||||||
|
'status' => $v->status,
|
||||||
|
'effective_at' => $v->effective_at?->toIso8601String(),
|
||||||
|
'items_count' => $v->items()->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
161
app/Services/Config/PlayConfigStreamService.php
Normal file
161
app/Services/Config/PlayConfigStreamService.php
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Config;
|
||||||
|
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\PlayConfigItem;
|
||||||
|
use App\Models\PlayConfigVersion;
|
||||||
|
use App\Models\PlayType;
|
||||||
|
use App\Services\AuditLogger;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/** 后台:玩法配置版本({@see play_config_versions} / {@see play_config_items}) */
|
||||||
|
final class PlayConfigStreamService
|
||||||
|
{
|
||||||
|
/** @return LengthAwarePaginator<int, PlayConfigVersion> */
|
||||||
|
public function paginate(?string $status, int $perPage): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$q = PlayConfigVersion::query()->orderByDesc('id');
|
||||||
|
if ($status !== null && $status !== '') {
|
||||||
|
$q->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $q->paginate($perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createDraft(AdminUser $admin, ?string $reason, ?int $cloneFromVersionId): PlayConfigVersion
|
||||||
|
{
|
||||||
|
$nextNo = (int) (PlayConfigVersion::query()->max('version_no') ?? 0) + 1;
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($admin, $reason, $cloneFromVersionId, $nextNo): PlayConfigVersion {
|
||||||
|
$draft = PlayConfigVersion::query()->create([
|
||||||
|
'version_no' => $nextNo,
|
||||||
|
'status' => ConfigVersionStatus::Draft->value,
|
||||||
|
'effective_at' => null,
|
||||||
|
'updated_by' => $admin->id,
|
||||||
|
'reason' => $reason,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$source = null;
|
||||||
|
if ($cloneFromVersionId !== null) {
|
||||||
|
$source = PlayConfigVersion::query()->whereKey($cloneFromVersionId)->firstOrFail();
|
||||||
|
} else {
|
||||||
|
$source = PlayConfigVersion::query()
|
||||||
|
->where('status', ConfigVersionStatus::Active->value)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($source !== null) {
|
||||||
|
foreach ($source->items()->orderBy('play_code')->get() as $row) {
|
||||||
|
PlayConfigItem::query()->create([
|
||||||
|
'version_id' => $draft->id,
|
||||||
|
'play_code' => $row->play_code,
|
||||||
|
'is_enabled' => $row->is_enabled,
|
||||||
|
'min_bet_amount' => $row->min_bet_amount,
|
||||||
|
'max_bet_amount' => $row->max_bet_amount,
|
||||||
|
'display_order' => $row->display_order,
|
||||||
|
'rule_text_zh' => $row->rule_text_zh,
|
||||||
|
'rule_text_en' => $row->rule_text_en,
|
||||||
|
'rule_text_ne' => $row->rule_text_ne,
|
||||||
|
'extra_config_json' => $row->extra_config_json,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $pt) {
|
||||||
|
PlayConfigItem::query()->create([
|
||||||
|
'version_id' => $draft->id,
|
||||||
|
'play_code' => $pt->play_code,
|
||||||
|
'is_enabled' => (bool) $pt->is_enabled,
|
||||||
|
'min_bet_amount' => 100,
|
||||||
|
'max_bet_amount' => 500_000_000,
|
||||||
|
'display_order' => (int) $pt->sort_order,
|
||||||
|
'rule_text_zh' => null,
|
||||||
|
'rule_text_en' => null,
|
||||||
|
'rule_text_ne' => null,
|
||||||
|
'extra_config_json' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $draft->fresh(['items']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $items
|
||||||
|
*/
|
||||||
|
public function replaceItems(PlayConfigVersion $draft, array $items, AdminUser $admin): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($draft, $items, $admin): void {
|
||||||
|
PlayConfigItem::query()->where('version_id', $draft->id)->delete();
|
||||||
|
|
||||||
|
foreach ($items as $row) {
|
||||||
|
PlayConfigItem::query()->create([
|
||||||
|
'version_id' => $draft->id,
|
||||||
|
'play_code' => (string) $row['play_code'],
|
||||||
|
'is_enabled' => (bool) ($row['is_enabled'] ?? true),
|
||||||
|
'min_bet_amount' => (int) ($row['min_bet_amount'] ?? 0),
|
||||||
|
'max_bet_amount' => (int) ($row['max_bet_amount'] ?? 0),
|
||||||
|
'display_order' => (int) ($row['display_order'] ?? 0),
|
||||||
|
'rule_text_zh' => isset($row['rule_text_zh']) ? (string) $row['rule_text_zh'] : null,
|
||||||
|
'rule_text_en' => isset($row['rule_text_en']) ? (string) $row['rule_text_en'] : null,
|
||||||
|
'rule_text_ne' => isset($row['rule_text_ne']) ? (string) $row['rule_text_ne'] : null,
|
||||||
|
'extra_config_json' => $row['extra_config_json'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$draft->forceFill(['updated_by' => $admin->id])->save();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publish(PlayConfigVersion $draft, AdminUser $admin, ?Request $request = null): void
|
||||||
|
{
|
||||||
|
$before = $this->snapshotVersion($draft);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($draft, $admin): void {
|
||||||
|
/** @var PlayConfigVersion|null $current */
|
||||||
|
$current = PlayConfigVersion::query()
|
||||||
|
->where('status', ConfigVersionStatus::Active->value)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($current !== null) {
|
||||||
|
$current->forceFill(['status' => ConfigVersionStatus::Archived->value])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$draft->forceFill([
|
||||||
|
'status' => ConfigVersionStatus::Active->value,
|
||||||
|
'effective_at' => now(),
|
||||||
|
'updated_by' => $admin->id,
|
||||||
|
])->save();
|
||||||
|
});
|
||||||
|
|
||||||
|
$after = $this->snapshotVersion($draft->fresh(['items']));
|
||||||
|
|
||||||
|
AuditLogger::recordForAdmin(
|
||||||
|
$admin,
|
||||||
|
$request,
|
||||||
|
moduleCode: 'play_config',
|
||||||
|
actionCode: 'publish',
|
||||||
|
targetType: 'play_config_version',
|
||||||
|
targetId: (string) $draft->id,
|
||||||
|
beforeJson: $before,
|
||||||
|
afterJson: $after,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function snapshotVersion(PlayConfigVersion $v): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $v->id,
|
||||||
|
'version_no' => $v->version_no,
|
||||||
|
'status' => $v->status,
|
||||||
|
'effective_at' => $v->effective_at?->toIso8601String(),
|
||||||
|
'items_count' => $v->items()->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
145
app/Services/Config/RiskCapStreamService.php
Normal file
145
app/Services/Config/RiskCapStreamService.php
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Config;
|
||||||
|
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\RiskCapItem;
|
||||||
|
use App\Models\RiskCapVersion;
|
||||||
|
use App\Services\AuditLogger;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/** 后台:风控封顶版本({@see risk_cap_versions} / {@see risk_cap_items}) */
|
||||||
|
final class RiskCapStreamService
|
||||||
|
{
|
||||||
|
/** @return LengthAwarePaginator<int, RiskCapVersion> */
|
||||||
|
public function paginate(?string $status, int $perPage): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$q = RiskCapVersion::query()->orderByDesc('id');
|
||||||
|
if ($status !== null && $status !== '') {
|
||||||
|
$q->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $q->paginate($perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createDraft(AdminUser $admin, ?string $reason, ?int $cloneFromVersionId): RiskCapVersion
|
||||||
|
{
|
||||||
|
$nextNo = (int) (RiskCapVersion::query()->max('version_no') ?? 0) + 1;
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($admin, $reason, $cloneFromVersionId, $nextNo): RiskCapVersion {
|
||||||
|
$draft = RiskCapVersion::query()->create([
|
||||||
|
'version_no' => $nextNo,
|
||||||
|
'status' => ConfigVersionStatus::Draft->value,
|
||||||
|
'effective_at' => null,
|
||||||
|
'updated_by' => $admin->id,
|
||||||
|
'reason' => $reason,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$source = null;
|
||||||
|
if ($cloneFromVersionId !== null) {
|
||||||
|
$source = RiskCapVersion::query()->whereKey($cloneFromVersionId)->firstOrFail();
|
||||||
|
} else {
|
||||||
|
$source = RiskCapVersion::query()
|
||||||
|
->where('status', ConfigVersionStatus::Active->value)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($source !== null) {
|
||||||
|
foreach ($source->items()->orderBy('normalized_number')->get() as $row) {
|
||||||
|
RiskCapItem::query()->create([
|
||||||
|
'version_id' => $draft->id,
|
||||||
|
'draw_id' => $row->draw_id,
|
||||||
|
'normalized_number' => $row->normalized_number,
|
||||||
|
'cap_amount' => $row->cap_amount,
|
||||||
|
'cap_type' => $row->cap_type,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
foreach (['0000', '1234', '9999'] as $num) {
|
||||||
|
RiskCapItem::query()->create([
|
||||||
|
'version_id' => $draft->id,
|
||||||
|
'draw_id' => null,
|
||||||
|
'normalized_number' => $num,
|
||||||
|
'cap_amount' => 50_000_000_000,
|
||||||
|
'cap_type' => 'per_number',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $draft->fresh(['items']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $items
|
||||||
|
*/
|
||||||
|
public function replaceItems(RiskCapVersion $draft, array $items, AdminUser $admin): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($draft, $items, $admin): void {
|
||||||
|
RiskCapItem::query()->where('version_id', $draft->id)->delete();
|
||||||
|
|
||||||
|
foreach ($items as $row) {
|
||||||
|
RiskCapItem::query()->create([
|
||||||
|
'version_id' => $draft->id,
|
||||||
|
'draw_id' => isset($row['draw_id']) ? (int) $row['draw_id'] : null,
|
||||||
|
'normalized_number' => (string) $row['normalized_number'],
|
||||||
|
'cap_amount' => (int) $row['cap_amount'],
|
||||||
|
'cap_type' => (string) $row['cap_type'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$draft->forceFill(['updated_by' => $admin->id])->save();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publish(RiskCapVersion $draft, AdminUser $admin, ?Request $request = null): void
|
||||||
|
{
|
||||||
|
$before = $this->snapshotVersion($draft);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($draft, $admin): void {
|
||||||
|
/** @var RiskCapVersion|null $current */
|
||||||
|
$current = RiskCapVersion::query()
|
||||||
|
->where('status', ConfigVersionStatus::Active->value)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($current !== null) {
|
||||||
|
$current->forceFill(['status' => ConfigVersionStatus::Archived->value])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$draft->forceFill([
|
||||||
|
'status' => ConfigVersionStatus::Active->value,
|
||||||
|
'effective_at' => now(),
|
||||||
|
'updated_by' => $admin->id,
|
||||||
|
])->save();
|
||||||
|
});
|
||||||
|
|
||||||
|
$after = $this->snapshotVersion($draft->fresh(['items']));
|
||||||
|
|
||||||
|
AuditLogger::recordForAdmin(
|
||||||
|
$admin,
|
||||||
|
$request,
|
||||||
|
moduleCode: 'risk_cap',
|
||||||
|
actionCode: 'publish',
|
||||||
|
targetType: 'risk_cap_version',
|
||||||
|
targetId: (string) $draft->id,
|
||||||
|
beforeJson: $before,
|
||||||
|
afterJson: $after,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function snapshotVersion(RiskCapVersion $v): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $v->id,
|
||||||
|
'version_no' => $v->version_no,
|
||||||
|
'status' => $v->status,
|
||||||
|
'effective_at' => $v->effective_at?->toIso8601String(),
|
||||||
|
'items_count' => $v->items()->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
151
app/Support/AdminConfigPresenter.php
Normal file
151
app/Support/AdminConfigPresenter.php
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\OddsItem;
|
||||||
|
use App\Models\OddsVersion;
|
||||||
|
use App\Models\PlayConfigItem;
|
||||||
|
use App\Models\PlayConfigVersion;
|
||||||
|
use App\Models\PlayType;
|
||||||
|
use App\Models\RiskCapItem;
|
||||||
|
use App\Models\RiskCapVersion;
|
||||||
|
|
||||||
|
/** 后台 API:阶段 4 运营配置序列化(与其它 Admin*Controller 手写数组风格一致)。 */
|
||||||
|
final class AdminConfigPresenter
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function playType(PlayType $t): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) $t->id,
|
||||||
|
'play_code' => $t->play_code,
|
||||||
|
'category' => $t->category,
|
||||||
|
'dimension' => $t->dimension,
|
||||||
|
'bet_mode' => $t->bet_mode,
|
||||||
|
'display_name_zh' => $t->display_name_zh,
|
||||||
|
'display_name_en' => $t->display_name_en,
|
||||||
|
'display_name_ne' => $t->display_name_ne,
|
||||||
|
'is_enabled' => (bool) $t->is_enabled,
|
||||||
|
'sort_order' => (int) $t->sort_order,
|
||||||
|
'supports_multi_number' => (bool) $t->supports_multi_number,
|
||||||
|
'reserved_rule_json' => $t->reserved_rule_json,
|
||||||
|
'updated_at' => $t->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function playConfigVersionSummary(PlayConfigVersion $v): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) $v->id,
|
||||||
|
'version_no' => (int) $v->version_no,
|
||||||
|
'status' => $v->status,
|
||||||
|
'effective_at' => $v->effective_at?->toIso8601String(),
|
||||||
|
'updated_by' => $v->updated_by,
|
||||||
|
'reason' => $v->reason,
|
||||||
|
'created_at' => $v->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $v->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function playConfigVersionDetail(PlayConfigVersion $v): array
|
||||||
|
{
|
||||||
|
$base = self::playConfigVersionSummary($v);
|
||||||
|
$base['items'] = $v->items->map(fn (PlayConfigItem $r) => self::playConfigItem($r))->values()->all();
|
||||||
|
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function playConfigItem(PlayConfigItem $r): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) $r->id,
|
||||||
|
'play_code' => $r->play_code,
|
||||||
|
'is_enabled' => (bool) $r->is_enabled,
|
||||||
|
'min_bet_amount' => (int) $r->min_bet_amount,
|
||||||
|
'max_bet_amount' => (int) $r->max_bet_amount,
|
||||||
|
'display_order' => (int) $r->display_order,
|
||||||
|
'rule_text_zh' => $r->rule_text_zh,
|
||||||
|
'rule_text_en' => $r->rule_text_en,
|
||||||
|
'rule_text_ne' => $r->rule_text_ne,
|
||||||
|
'extra_config_json' => $r->extra_config_json,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function oddsVersionSummary(OddsVersion $v): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) $v->id,
|
||||||
|
'version_no' => (int) $v->version_no,
|
||||||
|
'status' => $v->status,
|
||||||
|
'effective_at' => $v->effective_at?->toIso8601String(),
|
||||||
|
'updated_by' => $v->updated_by,
|
||||||
|
'reason' => $v->reason,
|
||||||
|
'created_at' => $v->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $v->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function oddsVersionDetail(OddsVersion $v): array
|
||||||
|
{
|
||||||
|
$base = self::oddsVersionSummary($v);
|
||||||
|
$base['items'] = $v->items->map(fn (OddsItem $r) => self::oddsItem($r))->values()->all();
|
||||||
|
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function oddsItem(OddsItem $r): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) $r->id,
|
||||||
|
'play_code' => $r->play_code,
|
||||||
|
'prize_scope' => $r->prize_scope,
|
||||||
|
'odds_value' => (int) $r->odds_value,
|
||||||
|
'rebate_rate' => (string) $r->rebate_rate,
|
||||||
|
'commission_rate' => (string) $r->commission_rate,
|
||||||
|
'currency_code' => $r->currency_code,
|
||||||
|
'extra_config_json' => $r->extra_config_json,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function riskCapVersionSummary(RiskCapVersion $v): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) $v->id,
|
||||||
|
'version_no' => (int) $v->version_no,
|
||||||
|
'status' => $v->status,
|
||||||
|
'effective_at' => $v->effective_at?->toIso8601String(),
|
||||||
|
'updated_by' => $v->updated_by,
|
||||||
|
'reason' => $v->reason,
|
||||||
|
'created_at' => $v->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $v->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function riskCapVersionDetail(RiskCapVersion $v): array
|
||||||
|
{
|
||||||
|
$base = self::riskCapVersionSummary($v);
|
||||||
|
$base['items'] = $v->items->map(fn (RiskCapItem $r) => self::riskCapItem($r))->values()->all();
|
||||||
|
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function riskCapItem(RiskCapItem $r): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) $r->id,
|
||||||
|
'draw_id' => $r->draw_id,
|
||||||
|
'normalized_number' => $r->normalized_number,
|
||||||
|
'cap_amount' => (int) $r->cap_amount,
|
||||||
|
'cap_type' => $r->cap_type,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/Support/OddsStandardScopes.php
Normal file
103
app/Support/OddsStandardScopes.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\Currency;
|
||||||
|
use App\Models\OddsItem;
|
||||||
|
use App\Models\OddsVersion;
|
||||||
|
use App\Models\PlayType;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 界面文档 §5.5:每个玩法需具备的五档 prize_scope;odds_value = 乘数×10000。
|
||||||
|
*/
|
||||||
|
final class OddsStandardScopes
|
||||||
|
{
|
||||||
|
/** @var array<string, int> */
|
||||||
|
public const PRESET_ODDS_BY_SCOPE = [
|
||||||
|
'first' => 250_000,
|
||||||
|
'second' => 110_000,
|
||||||
|
'third' => 55_000,
|
||||||
|
'starter' => 22_000,
|
||||||
|
'consolation' => 6_500,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
public const SCOPE_KEYS = ['first', 'second', 'third', 'starter', 'consolation'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为指定赔率版本补全缺失的标准五档行(幂等)。
|
||||||
|
*
|
||||||
|
* 仅对「该版本里已出现过的 (play_code, currency_code)」补全,避免给从未配置过的玩法凭空加行。
|
||||||
|
* 若版本下无任何 odds_items,则用当前全部玩法 × 首个可下注币种补全。
|
||||||
|
*/
|
||||||
|
public static function syncMissingForVersion(OddsVersion $version): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($version): void {
|
||||||
|
$vid = (int) $version->id;
|
||||||
|
|
||||||
|
$pairs = OddsItem::query()
|
||||||
|
->where('version_id', $vid)
|
||||||
|
->select(['play_code', 'currency_code'])
|
||||||
|
->distinct()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($pairs->isEmpty()) {
|
||||||
|
$currencyCode = Currency::query()
|
||||||
|
->where('is_bettable', true)
|
||||||
|
->where('is_enabled', true)
|
||||||
|
->orderBy('code')
|
||||||
|
->value('code');
|
||||||
|
if ($currencyCode === null || $currencyCode === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$pairs = PlayType::query()
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->orderBy('play_code')
|
||||||
|
->get(['play_code'])
|
||||||
|
->map(fn (PlayType $pt) => (object) [
|
||||||
|
'play_code' => $pt->play_code,
|
||||||
|
'currency_code' => $currencyCode,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($pairs as $pair) {
|
||||||
|
$playCode = (string) $pair->play_code;
|
||||||
|
$currencyCode = strtoupper((string) $pair->currency_code);
|
||||||
|
|
||||||
|
$anchor = OddsItem::query()
|
||||||
|
->where('version_id', $vid)
|
||||||
|
->where('play_code', $playCode)
|
||||||
|
->where('currency_code', $currencyCode)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$rebate = (float) ($anchor?->rebate_rate ?? 0);
|
||||||
|
$commission = (float) ($anchor?->commission_rate ?? 0);
|
||||||
|
|
||||||
|
foreach (self::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) {
|
||||||
|
$exists = OddsItem::query()
|
||||||
|
->where('version_id', $vid)
|
||||||
|
->where('play_code', $playCode)
|
||||||
|
->where('currency_code', $currencyCode)
|
||||||
|
->where('prize_scope', $scope)
|
||||||
|
->exists();
|
||||||
|
if ($exists) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
OddsItem::query()->create([
|
||||||
|
'version_id' => $vid,
|
||||||
|
'play_code' => $playCode,
|
||||||
|
'prize_scope' => $scope,
|
||||||
|
'odds_value' => $oddsValue,
|
||||||
|
'rebate_rate' => $rebate,
|
||||||
|
'commission_rate' => $commission,
|
||||||
|
'currency_code' => $currencyCode,
|
||||||
|
'extra_config_json' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,7 +76,7 @@ return [
|
|||||||
/** 预生成尚未开奖的期号数量(调度补齐);生产可调大,本地/联测建议 6–12 */
|
/** 预生成尚未开奖的期号数量(调度补齐);生产可调大,本地/联测建议 6–12 */
|
||||||
'buffer_draws_ahead' => max(1, (int) env('LOTTERY_DRAW_BUFFER_AHEAD', 8)),
|
'buffer_draws_ahead' => max(1, (int) env('LOTTERY_DRAW_BUFFER_AHEAD', 8)),
|
||||||
/** true:RNG 后进入 review,需后台接口发布 */
|
/** true:RNG 后进入 review,需后台接口发布 */
|
||||||
'require_manual_review' => filter_var(env('LOTTERY_DRAW_REQUIRE_MANUAL_REVIEW', false), FILTER_VALIDATE_BOOLEAN),
|
'require_manual_review' => filter_var(env('LOTTERY_DRAW_REQUIRE_MANUAL_REVIEW', true), FILTER_VALIDATE_BOOLEAN),
|
||||||
/** 结果发布后的冷静期(分钟),{@see draws.cooling_end_time} */
|
/** 结果发布后的冷静期(分钟),{@see draws.cooling_end_time} */
|
||||||
'cooldown_minutes' => max(0, (int) env('LOTTERY_DRAW_COOLDOWN_MINUTES', 15)),
|
'cooldown_minutes' => max(0, (int) env('LOTTERY_DRAW_COOLDOWN_MINUTES', 15)),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class DatabaseSeeder extends Seeder
|
|||||||
$this->call([
|
$this->call([
|
||||||
CurrencySeeder::class,
|
CurrencySeeder::class,
|
||||||
PlayTypeSeeder::class,
|
PlayTypeSeeder::class,
|
||||||
|
OperationalConfigV1Seeder::class,
|
||||||
|
OddsPrizeScopesBackfillSeeder::class,
|
||||||
LotterySettingsSeeder::class,
|
LotterySettingsSeeder::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
20
database/seeders/OddsPrizeScopesBackfillSeeder.php
Normal file
20
database/seeders/OddsPrizeScopesBackfillSeeder.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\OddsVersion;
|
||||||
|
use App\Support\OddsStandardScopes;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为历史 odds_versions 补全 §5.5 五档 prize_scope(幂等,可重复执行)。
|
||||||
|
*/
|
||||||
|
class OddsPrizeScopesBackfillSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
foreach (OddsVersion::query()->orderBy('id')->cursor() as $version) {
|
||||||
|
OddsStandardScopes::syncMissingForVersion($version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
database/seeders/OperationalConfigV1Seeder.php
Normal file
95
database/seeders/OperationalConfigV1Seeder.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use App\Models\OddsItem;
|
||||||
|
use App\Models\OddsVersion;
|
||||||
|
use App\Models\PlayConfigItem;
|
||||||
|
use App\Models\PlayConfigVersion;
|
||||||
|
use App\Models\PlayType;
|
||||||
|
use App\Models\RiskCapItem;
|
||||||
|
use App\Models\RiskCapVersion;
|
||||||
|
use App\Support\OddsStandardScopes;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阶段 4:写入首套 **active** 玩法配置 / 赔率 / 风控封顶版本(依赖 {@see PlayTypeSeeder}、{@see CurrencySeeder})。
|
||||||
|
*/
|
||||||
|
class OperationalConfigV1Seeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
if (PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function (): void {
|
||||||
|
$playVersion = PlayConfigVersion::query()->create([
|
||||||
|
'version_no' => 1,
|
||||||
|
'status' => ConfigVersionStatus::Active->value,
|
||||||
|
'effective_at' => now(),
|
||||||
|
'updated_by' => null,
|
||||||
|
'reason' => 'seed:v1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $pt) {
|
||||||
|
PlayConfigItem::query()->create([
|
||||||
|
'version_id' => $playVersion->id,
|
||||||
|
'play_code' => $pt->play_code,
|
||||||
|
'is_enabled' => (bool) $pt->is_enabled,
|
||||||
|
'min_bet_amount' => 100,
|
||||||
|
'max_bet_amount' => 500_000_000,
|
||||||
|
'display_order' => (int) $pt->sort_order,
|
||||||
|
'rule_text_zh' => null,
|
||||||
|
'rule_text_en' => null,
|
||||||
|
'rule_text_ne' => null,
|
||||||
|
'extra_config_json' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$oddsVersion = OddsVersion::query()->create([
|
||||||
|
'version_no' => 1,
|
||||||
|
'status' => ConfigVersionStatus::Active->value,
|
||||||
|
'effective_at' => now(),
|
||||||
|
'updated_by' => null,
|
||||||
|
'reason' => 'seed:v1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** 对齐界面文档 §5.5:头/二/三/特别/安慰;odds_value = 乘数×10000(NPR 基准展示口径) */
|
||||||
|
foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $pt) {
|
||||||
|
foreach (OddsStandardScopes::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) {
|
||||||
|
OddsItem::query()->create([
|
||||||
|
'version_id' => $oddsVersion->id,
|
||||||
|
'play_code' => $pt->play_code,
|
||||||
|
'prize_scope' => $scope,
|
||||||
|
'odds_value' => $oddsValue,
|
||||||
|
'rebate_rate' => 0,
|
||||||
|
'commission_rate' => 0,
|
||||||
|
'currency_code' => 'NPR',
|
||||||
|
'extra_config_json' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$riskVersion = RiskCapVersion::query()->create([
|
||||||
|
'version_no' => 1,
|
||||||
|
'status' => ConfigVersionStatus::Active->value,
|
||||||
|
'effective_at' => now(),
|
||||||
|
'updated_by' => null,
|
||||||
|
'reason' => 'seed:v1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach (['0000', '1234', '9999'] as $num) {
|
||||||
|
RiskCapItem::query()->create([
|
||||||
|
'version_id' => $riskVersion->id,
|
||||||
|
'draw_id' => null,
|
||||||
|
'normalized_number' => $num,
|
||||||
|
'cap_amount' => 50_000_000_000,
|
||||||
|
'cap_type' => 'per_number',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,18 +2,36 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Api\V1\Admin\Auth\CaptchaController;
|
use App\Http\Controllers\Api\V1\Admin\Auth\CaptchaController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Auth\LoginController;
|
use App\Http\Controllers\Api\V1\Admin\Auth\LoginController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\Config\OddsItemsReplaceController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\Config\OddsVersionIndexController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\Config\OddsVersionPublishController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\Config\OddsVersionShowController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\Config\OddsVersionStoreController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigItemsReplaceController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigVersionIndexController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigVersionPublishController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigVersionShowController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigVersionStoreController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\Config\RiskCapItemsReplaceController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionIndexController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionPublishController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionShowController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionStoreController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawIndexController;
|
use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawIndexController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawResultBatchesIndexController;
|
use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawResultBatchesIndexController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawShowController;
|
use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawShowController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Draw\DrawResultBatchPublishController;
|
use App\Http\Controllers\Api\V1\Admin\Draw\DrawResultBatchPublishController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\PingController as AdminPingController;
|
use App\Http\Controllers\Api\V1\Admin\PingController as AdminPingController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Player\PlayerWalletShowController;
|
use App\Http\Controllers\Api\V1\Admin\Player\PlayerWalletShowController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\PlayTypeIndexController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\PlayTypePatchController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Wallet\TransferOrderListController;
|
use App\Http\Controllers\Api\V1\Admin\Wallet\TransferOrderListController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Wallet\WalletTransactionListController;
|
use App\Http\Controllers\Api\V1\Admin\Wallet\WalletTransactionListController;
|
||||||
use App\Http\Controllers\Api\V1\Draw\DrawCurrentController;
|
use App\Http\Controllers\Api\V1\Draw\DrawCurrentController;
|
||||||
use App\Http\Controllers\Api\V1\Draw\DrawResultShowController;
|
use App\Http\Controllers\Api\V1\Draw\DrawResultShowController;
|
||||||
use App\Http\Controllers\Api\V1\Draw\DrawResultsIndexController;
|
use App\Http\Controllers\Api\V1\Draw\DrawResultsIndexController;
|
||||||
use App\Http\Controllers\Api\V1\HealthController;
|
use App\Http\Controllers\Api\V1\HealthController;
|
||||||
|
use App\Http\Controllers\Api\V1\Play\PlayEffectiveCatalogController;
|
||||||
use App\Http\Controllers\Api\V1\Player\MeController;
|
use App\Http\Controllers\Api\V1\Player\MeController;
|
||||||
use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController;
|
use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController;
|
||||||
use App\Http\Controllers\Api\V1\Wallet\WalletBalanceController;
|
use App\Http\Controllers\Api\V1\Wallet\WalletBalanceController;
|
||||||
@@ -38,6 +56,9 @@ Route::prefix('v1')->group(function (): void {
|
|||||||
->where('draw_no', '[0-9]{8}-[0-9]{3}')
|
->where('draw_no', '[0-9]{8}-[0-9]{3}')
|
||||||
->name('api.v1.draw.results.show');
|
->name('api.v1.draw.results.show');
|
||||||
|
|
||||||
|
// 名称:生效玩法 / 赔率 / 封顶目录(阶段 4;公开)
|
||||||
|
Route::get('play/effective', PlayEffectiveCatalogController::class)->name('api.v1.play.effective');
|
||||||
|
|
||||||
Route::prefix('player')
|
Route::prefix('player')
|
||||||
->name('api.v1.player.')
|
->name('api.v1.player.')
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
@@ -98,6 +119,50 @@ Route::prefix('v1')->group(function (): void {
|
|||||||
'draws/{draw}/result-batches/{batch}/publish',
|
'draws/{draw}/result-batches/{batch}/publish',
|
||||||
DrawResultBatchPublishController::class,
|
DrawResultBatchPublishController::class,
|
||||||
)->name('draws.result-batches.publish');
|
)->name('draws.result-batches.publish');
|
||||||
|
|
||||||
|
// 阶段 4:玩法目录 + 赔率 + 风控封顶(版本化管理)
|
||||||
|
Route::get('play-types', PlayTypeIndexController::class)->name('play-types.index');
|
||||||
|
Route::patch('play-types/{play_code}', PlayTypePatchController::class)
|
||||||
|
->where('play_code', '[a-z0-9_]+')
|
||||||
|
->name('play-types.patch');
|
||||||
|
|
||||||
|
Route::prefix('config')->name('config.')->group(function (): void {
|
||||||
|
Route::get('play-versions', PlayConfigVersionIndexController::class)->name('play-versions.index');
|
||||||
|
Route::post('play-versions', PlayConfigVersionStoreController::class)->name('play-versions.store');
|
||||||
|
Route::get('play-versions/{id}', PlayConfigVersionShowController::class)
|
||||||
|
->whereNumber('id')
|
||||||
|
->name('play-versions.show');
|
||||||
|
Route::put('play-versions/{id}/items', PlayConfigItemsReplaceController::class)
|
||||||
|
->whereNumber('id')
|
||||||
|
->name('play-versions.items.replace');
|
||||||
|
Route::post('play-versions/{id}/publish', PlayConfigVersionPublishController::class)
|
||||||
|
->whereNumber('id')
|
||||||
|
->name('play-versions.publish');
|
||||||
|
|
||||||
|
Route::get('odds-versions', OddsVersionIndexController::class)->name('odds-versions.index');
|
||||||
|
Route::post('odds-versions', OddsVersionStoreController::class)->name('odds-versions.store');
|
||||||
|
Route::get('odds-versions/{id}', OddsVersionShowController::class)
|
||||||
|
->whereNumber('id')
|
||||||
|
->name('odds-versions.show');
|
||||||
|
Route::put('odds-versions/{id}/items', OddsItemsReplaceController::class)
|
||||||
|
->whereNumber('id')
|
||||||
|
->name('odds-versions.items.replace');
|
||||||
|
Route::post('odds-versions/{id}/publish', OddsVersionPublishController::class)
|
||||||
|
->whereNumber('id')
|
||||||
|
->name('odds-versions.publish');
|
||||||
|
|
||||||
|
Route::get('risk-cap-versions', RiskCapVersionIndexController::class)->name('risk-cap-versions.index');
|
||||||
|
Route::post('risk-cap-versions', RiskCapVersionStoreController::class)->name('risk-cap-versions.store');
|
||||||
|
Route::get('risk-cap-versions/{id}', RiskCapVersionShowController::class)
|
||||||
|
->whereNumber('id')
|
||||||
|
->name('risk-cap-versions.show');
|
||||||
|
Route::put('risk-cap-versions/{id}/items', RiskCapItemsReplaceController::class)
|
||||||
|
->whereNumber('id')
|
||||||
|
->name('risk-cap-versions.items.replace');
|
||||||
|
Route::post('risk-cap-versions/{id}/publish', RiskCapVersionPublishController::class)
|
||||||
|
->whereNumber('id')
|
||||||
|
->name('risk-cap-versions.publish');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
63
tests/Feature/OddsStandardScopesSyncTest.php
Normal file
63
tests/Feature/OddsStandardScopesSyncTest.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use App\Models\Currency;
|
||||||
|
use App\Models\OddsItem;
|
||||||
|
use App\Models\OddsVersion;
|
||||||
|
use App\Models\PlayType;
|
||||||
|
use App\Support\OddsStandardScopes;
|
||||||
|
use Database\Seeders\CurrencySeeder;
|
||||||
|
use Database\Seeders\PlayTypeSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('syncMissingForVersion adds five scopes from default-only rows', function (): void {
|
||||||
|
$this->seed(CurrencySeeder::class);
|
||||||
|
$this->seed(PlayTypeSeeder::class);
|
||||||
|
|
||||||
|
$currency = Currency::query()->where('is_bettable', true)->where('is_enabled', true)->orderBy('code')->firstOrFail();
|
||||||
|
|
||||||
|
$version = OddsVersion::query()->create([
|
||||||
|
'version_no' => 1,
|
||||||
|
'status' => ConfigVersionStatus::Active->value,
|
||||||
|
'effective_at' => now(),
|
||||||
|
'updated_by' => null,
|
||||||
|
'reason' => 'test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach (PlayType::query()->orderBy('play_code')->get() as $pt) {
|
||||||
|
OddsItem::query()->create([
|
||||||
|
'version_id' => $version->id,
|
||||||
|
'play_code' => $pt->play_code,
|
||||||
|
'prize_scope' => 'default',
|
||||||
|
'odds_value' => 19_500,
|
||||||
|
'rebate_rate' => 0.01,
|
||||||
|
'commission_rate' => 0,
|
||||||
|
'currency_code' => $currency->code,
|
||||||
|
'extra_config_json' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
OddsStandardScopes::syncMissingForVersion($version);
|
||||||
|
|
||||||
|
$playCount = PlayType::query()->count();
|
||||||
|
expect(OddsItem::query()->where('version_id', $version->id)->count())->toBe(
|
||||||
|
$playCount * (1 + count(OddsStandardScopes::SCOPE_KEYS)),
|
||||||
|
);
|
||||||
|
foreach (PlayType::query()->get() as $pt) {
|
||||||
|
foreach (OddsStandardScopes::SCOPE_KEYS as $scope) {
|
||||||
|
$row = OddsItem::query()
|
||||||
|
->where('version_id', $version->id)
|
||||||
|
->where('play_code', $pt->play_code)
|
||||||
|
->where('currency_code', $currency->code)
|
||||||
|
->where('prize_scope', $scope)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
expect((int) $row->odds_value)->toBe(OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$scope]);
|
||||||
|
/** @var string $rebate Eloquent `decimal:4` cast */
|
||||||
|
$rebate = (string) $row->rebate_rate;
|
||||||
|
expect($rebate)->toBe('0.0100');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
321
tests/Feature/OperationalConfigAcceptanceTest.php
Normal file
321
tests/Feature/OperationalConfigAcceptanceTest.php
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验收自动化(对应测试任务 §5 / 完成标准 §12.6):
|
||||||
|
*
|
||||||
|
* - 配置改动是否生效 → 赔率 / 玩法限额发布后 GET /api/v1/play/effective 可读出新值;PATCH play-types 可改目录开关。
|
||||||
|
* - 配置改动是否影响已下注订单 → ticket_items 行的 odds_snapshot_json 不因新赔率版本发布而被改写(注单链路尚未实现时,用数据不变量验证)。
|
||||||
|
* - 配置历史是否可追溯 → odds_versions 存在 archived + active;audit_logs 记录 publish。
|
||||||
|
*
|
||||||
|
* 运行:php artisan test tests/Feature/OperationalConfigAcceptanceTest.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use App\Lottery\DrawStatus;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\Draw;
|
||||||
|
use App\Models\OddsVersion;
|
||||||
|
use App\Models\Player;
|
||||||
|
use App\Models\PlayType;
|
||||||
|
use App\Models\RiskCapVersion;
|
||||||
|
use Database\Seeders\CurrencySeeder;
|
||||||
|
use Database\Seeders\OperationalConfigV1Seeder;
|
||||||
|
use Database\Seeders\PlayTypeSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->seed(CurrencySeeder::class);
|
||||||
|
$this->seed(PlayTypeSeeder::class);
|
||||||
|
$this->seed(OperationalConfigV1Seeder::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
function acceptanceMintAdminToken(): string
|
||||||
|
{
|
||||||
|
$admin = AdminUser::query()->create([
|
||||||
|
'username' => 'acceptance_admin',
|
||||||
|
'name' => 'Acceptance QA',
|
||||||
|
'email' => null,
|
||||||
|
'password' => Hash::make('secret-strong'),
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function oddsPutPayloadFromDetail(array $items): array
|
||||||
|
{
|
||||||
|
return collect($items)->map(fn (array $r) => [
|
||||||
|
'play_code' => $r['play_code'],
|
||||||
|
'prize_scope' => $r['prize_scope'],
|
||||||
|
'odds_value' => (int) $r['odds_value'],
|
||||||
|
'rebate_rate' => (float) $r['rebate_rate'],
|
||||||
|
'commission_rate' => (float) $r['commission_rate'],
|
||||||
|
'currency_code' => $r['currency_code'],
|
||||||
|
'extra_config_json' => $r['extra_config_json'] ?? null,
|
||||||
|
])->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('§12.6 published play limits are visible on public effective catalog without code deploy', function (): void {
|
||||||
|
$token = acceptanceMintAdminToken();
|
||||||
|
$auth = ['Authorization' => 'Bearer '.$token];
|
||||||
|
|
||||||
|
$create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'acceptance'], $auth);
|
||||||
|
$create->assertOk();
|
||||||
|
$draftId = (int) $create->json('data.id');
|
||||||
|
|
||||||
|
$itemPayload = [];
|
||||||
|
foreach (PlayType::query()->orderBy('play_code')->get() as $t) {
|
||||||
|
$itemPayload[] = [
|
||||||
|
'play_code' => $t->play_code,
|
||||||
|
'is_enabled' => true,
|
||||||
|
'min_bet_amount' => 777,
|
||||||
|
'max_bet_amount' => 400_000_000,
|
||||||
|
'display_order' => (int) $t->sort_order,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $itemPayload], $auth)->assertOk();
|
||||||
|
$this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk();
|
||||||
|
|
||||||
|
$plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
|
||||||
|
expect($plays->firstWhere('play_code', 'big')['config']['min_bet_amount'])->toBe(777);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('§12.6 published odds are visible on public effective catalog without code deploy', function (): void {
|
||||||
|
$token = acceptanceMintAdminToken();
|
||||||
|
$auth = ['Authorization' => 'Bearer '.$token];
|
||||||
|
|
||||||
|
$create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'acceptance odds'], $auth);
|
||||||
|
$create->assertOk();
|
||||||
|
$draftId = (int) $create->json('data.id');
|
||||||
|
|
||||||
|
$detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items');
|
||||||
|
$payload = oddsPutPayloadFromDetail($detail);
|
||||||
|
foreach ($payload as &$row) {
|
||||||
|
if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') {
|
||||||
|
$row['odds_value'] = 333_333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
|
$this->putJson('/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk();
|
||||||
|
$this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk();
|
||||||
|
|
||||||
|
$plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
|
||||||
|
expect($plays->firstWhere('play_code', 'big')['odds']['odds_value'])->toBe(333_333);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('§5 odds publish archives prior version lists history and writes audit log', function (): void {
|
||||||
|
$token = acceptanceMintAdminToken();
|
||||||
|
$auth = ['Authorization' => 'Bearer '.$token];
|
||||||
|
|
||||||
|
$beforeArchived = OddsVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count();
|
||||||
|
|
||||||
|
$create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'audit trail'], $auth);
|
||||||
|
$create->assertOk();
|
||||||
|
$draftId = (int) $create->json('data.id');
|
||||||
|
|
||||||
|
$detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items');
|
||||||
|
$this->putJson(
|
||||||
|
'/api/v1/admin/config/odds-versions/'.$draftId.'/items',
|
||||||
|
['items' => oddsPutPayloadFromDetail($detail)],
|
||||||
|
$auth,
|
||||||
|
)->assertOk();
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk();
|
||||||
|
|
||||||
|
expect(OddsVersion::query()->where('status', ConfigVersionStatus::Active->value)->count())->toBe(1);
|
||||||
|
expect(OddsVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count())->toBe($beforeArchived + 1);
|
||||||
|
|
||||||
|
$list = $this->getJson('/api/v1/admin/config/odds-versions?per_page=50', $auth)->assertOk()->json('data.items');
|
||||||
|
expect(count($list))->toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AuditLog::query()
|
||||||
|
->where('module_code', 'odds')
|
||||||
|
->where('action_code', 'publish')
|
||||||
|
->exists(),
|
||||||
|
)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('§5 existing ticket_items odds snapshot row is not mutated when new odds version publishes', function (): void {
|
||||||
|
$token = acceptanceMintAdminToken();
|
||||||
|
$auth = ['Authorization' => 'Bearer '.$token];
|
||||||
|
|
||||||
|
$snapshot = ['frozen_odds_first' => 250_000, 'note' => 'acceptance synthetic row'];
|
||||||
|
|
||||||
|
$player = Player::query()->create([
|
||||||
|
'site_code' => 'test',
|
||||||
|
'site_player_id' => 'p1',
|
||||||
|
'username' => 'u1',
|
||||||
|
'nickname' => null,
|
||||||
|
'default_currency' => 'NPR',
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draw = Draw::query()->create([
|
||||||
|
'draw_no' => 'ACC-001',
|
||||||
|
'business_date' => now()->toDateString(),
|
||||||
|
'sequence_no' => 1,
|
||||||
|
'status' => DrawStatus::Open->value,
|
||||||
|
'start_time' => null,
|
||||||
|
'close_time' => null,
|
||||||
|
'draw_time' => null,
|
||||||
|
'cooling_end_time' => null,
|
||||||
|
'result_source' => null,
|
||||||
|
'current_result_version' => 0,
|
||||||
|
'settle_version' => 0,
|
||||||
|
'is_reopened' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$now = now();
|
||||||
|
$orderId = DB::table('ticket_orders')->insertGetId([
|
||||||
|
'order_no' => 'ORD-ACC-001',
|
||||||
|
'player_id' => $player->id,
|
||||||
|
'draw_id' => $draw->id,
|
||||||
|
'currency_code' => 'NPR',
|
||||||
|
'total_bet_amount' => 100,
|
||||||
|
'total_rebate_amount' => 0,
|
||||||
|
'total_actual_deduct' => 100,
|
||||||
|
'total_estimated_payout' => 0,
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'submit_source' => 'h5',
|
||||||
|
'client_trace_id' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('ticket_items')->insert([
|
||||||
|
'ticket_no' => 'TICK-ACC-001',
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'player_id' => $player->id,
|
||||||
|
'draw_id' => $draw->id,
|
||||||
|
'original_number' => null,
|
||||||
|
'normalized_number' => '1234',
|
||||||
|
'play_code' => 'big',
|
||||||
|
'dimension' => 2,
|
||||||
|
'digit_slot' => null,
|
||||||
|
'bet_mode' => null,
|
||||||
|
'unit_bet_amount' => 100,
|
||||||
|
'total_bet_amount' => 100,
|
||||||
|
'rebate_rate_snapshot' => 0,
|
||||||
|
'commission_rate_snapshot' => 0,
|
||||||
|
'actual_deduct_amount' => 100,
|
||||||
|
'odds_snapshot_json' => json_encode($snapshot),
|
||||||
|
'rule_snapshot_json' => null,
|
||||||
|
'combination_count' => 1,
|
||||||
|
'estimated_max_payout' => 0,
|
||||||
|
'risk_locked_amount' => 0,
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'fail_reason_code' => null,
|
||||||
|
'fail_reason_text' => null,
|
||||||
|
'win_amount' => 0,
|
||||||
|
'jackpot_win_amount' => 0,
|
||||||
|
'settled_at' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'after ticket'], $auth);
|
||||||
|
$create->assertOk();
|
||||||
|
$draftId = (int) $create->json('data.id');
|
||||||
|
|
||||||
|
$detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items');
|
||||||
|
$payload = oddsPutPayloadFromDetail($detail);
|
||||||
|
foreach ($payload as &$row) {
|
||||||
|
if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') {
|
||||||
|
$row['odds_value'] = 9_999_999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
|
$this->putJson('/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk();
|
||||||
|
$this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk();
|
||||||
|
|
||||||
|
$stored = DB::table('ticket_items')->where('ticket_no', 'TICK-ACC-001')->value('odds_snapshot_json');
|
||||||
|
expect(json_decode((string) $stored, true))->toBe($snapshot);
|
||||||
|
|
||||||
|
$plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
|
||||||
|
expect($plays->firstWhere('play_code', 'big')['odds']['odds_value'])->toBe(9_999_999);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('§12.6 PATCH play-types controls master_enabled on public catalog without code deploy', function (): void {
|
||||||
|
$token = acceptanceMintAdminToken();
|
||||||
|
$auth = ['Authorization' => 'Bearer '.$token];
|
||||||
|
|
||||||
|
$this->patchJson('/api/v1/admin/play-types/big', ['is_enabled' => false], $auth)->assertOk();
|
||||||
|
|
||||||
|
$plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
|
||||||
|
expect($plays->firstWhere('play_code', 'big')['master_enabled'])->toBeFalse();
|
||||||
|
|
||||||
|
$this->patchJson('/api/v1/admin/play-types/big', ['is_enabled' => true], $auth)->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('§5 risk cap publish is audited and version history exists', function (): void {
|
||||||
|
$token = acceptanceMintAdminToken();
|
||||||
|
$auth = ['Authorization' => 'Bearer '.$token];
|
||||||
|
|
||||||
|
$beforeArchived = RiskCapVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count();
|
||||||
|
|
||||||
|
$create = $this->postJson('/api/v1/admin/config/risk-cap-versions', ['reason' => 'acceptance risk'], $auth);
|
||||||
|
$create->assertOk();
|
||||||
|
$draftId = (int) $create->json('data.id');
|
||||||
|
|
||||||
|
$rows = $this->getJson('/api/v1/admin/config/risk-cap-versions/'.$draftId, $auth)->assertOk()->json('data.items');
|
||||||
|
$payload = collect($rows)->map(fn (array $r) => [
|
||||||
|
'draw_id' => $r['draw_id'],
|
||||||
|
'normalized_number' => $r['normalized_number'],
|
||||||
|
'cap_amount' => (int) $r['cap_amount'],
|
||||||
|
'cap_type' => $r['cap_type'],
|
||||||
|
])->all();
|
||||||
|
|
||||||
|
$this->putJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk();
|
||||||
|
$this->postJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/publish', [], $auth)->assertOk();
|
||||||
|
|
||||||
|
expect(RiskCapVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count())->toBe($beforeArchived + 1);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AuditLog::query()
|
||||||
|
->where('module_code', 'risk_cap')
|
||||||
|
->where('action_code', 'publish')
|
||||||
|
->exists(),
|
||||||
|
)->toBeTrue();
|
||||||
|
|
||||||
|
$list = $this->getJson('/api/v1/admin/config/risk-cap-versions?per_page=50', $auth)->assertOk()->json('data.items');
|
||||||
|
expect(count($list))->toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('§5 play_config publish is audited', function (): void {
|
||||||
|
$token = acceptanceMintAdminToken();
|
||||||
|
$auth = ['Authorization' => 'Bearer '.$token];
|
||||||
|
|
||||||
|
$create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'audit play'], $auth);
|
||||||
|
$create->assertOk();
|
||||||
|
$draftId = (int) $create->json('data.id');
|
||||||
|
|
||||||
|
$itemPayload = [];
|
||||||
|
foreach (PlayType::query()->orderBy('play_code')->get() as $t) {
|
||||||
|
$itemPayload[] = [
|
||||||
|
'play_code' => $t->play_code,
|
||||||
|
'is_enabled' => true,
|
||||||
|
'min_bet_amount' => 100,
|
||||||
|
'max_bet_amount' => 500_000_000,
|
||||||
|
'display_order' => (int) $t->sort_order,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $itemPayload], $auth)->assertOk();
|
||||||
|
$this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AuditLog::query()
|
||||||
|
->where('module_code', 'play_config')
|
||||||
|
->where('action_code', 'publish')
|
||||||
|
->exists(),
|
||||||
|
)->toBeTrue();
|
||||||
|
});
|
||||||
87
tests/Feature/OperationalConfigApiTest.php
Normal file
87
tests/Feature/OperationalConfigApiTest.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\PlayConfigVersion;
|
||||||
|
use App\Models\PlayType;
|
||||||
|
use Database\Seeders\CurrencySeeder;
|
||||||
|
use Database\Seeders\OperationalConfigV1Seeder;
|
||||||
|
use Database\Seeders\PlayTypeSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->seed(CurrencySeeder::class);
|
||||||
|
$this->seed(PlayTypeSeeder::class);
|
||||||
|
$this->seed(OperationalConfigV1Seeder::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
function mintConfigAdminToken(): string
|
||||||
|
{
|
||||||
|
$admin = AdminUser::query()->create([
|
||||||
|
'username' => 'config_admin',
|
||||||
|
'name' => 'Config QA',
|
||||||
|
'email' => null,
|
||||||
|
'password' => Hash::make('secret-strong'),
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('play effective catalog is public and merged', function (): void {
|
||||||
|
$resp = $this->getJson('/api/v1/play/effective?currency=NPR');
|
||||||
|
$resp->assertOk()->assertJsonPath('data.currency_code', 'NPR');
|
||||||
|
|
||||||
|
$plays = $resp->json('data.plays');
|
||||||
|
expect($plays)->toBeArray()->not->toBeEmpty();
|
||||||
|
expect($plays[0])->toHaveKeys(['play_code', 'config', 'odds', 'master_enabled']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin play config draft publish flow', function (): void {
|
||||||
|
$token = mintConfigAdminToken();
|
||||||
|
$active = PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->firstOrFail();
|
||||||
|
|
||||||
|
$create = $this->postJson('/api/v1/admin/config/play-versions', [
|
||||||
|
'reason' => 'test draft',
|
||||||
|
], ['Authorization' => 'Bearer '.$token]);
|
||||||
|
$create->assertOk();
|
||||||
|
$draftId = (int) $create->json('data.id');
|
||||||
|
expect($draftId)->toBeGreaterThan(0);
|
||||||
|
expect($create->json('data.status'))->toBe(ConfigVersionStatus::Draft->value);
|
||||||
|
|
||||||
|
$types = PlayType::query()->orderBy('play_code')->get();
|
||||||
|
$itemPayload = [];
|
||||||
|
foreach ($types as $t) {
|
||||||
|
$itemPayload[] = [
|
||||||
|
'play_code' => $t->play_code,
|
||||||
|
'is_enabled' => true,
|
||||||
|
'min_bet_amount' => 200,
|
||||||
|
'max_bet_amount' => 400_000_000,
|
||||||
|
'display_order' => (int) $t->sort_order,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->putJson(
|
||||||
|
'/api/v1/admin/config/play-versions/'.$draftId.'/items',
|
||||||
|
['items' => $itemPayload],
|
||||||
|
['Authorization' => 'Bearer '.$token],
|
||||||
|
)->assertOk()->assertJsonPath('data.items.0.min_bet_amount', 200);
|
||||||
|
|
||||||
|
$this->postJson(
|
||||||
|
'/api/v1/admin/config/play-versions/'.$draftId.'/publish',
|
||||||
|
[],
|
||||||
|
['Authorization' => 'Bearer '.$token],
|
||||||
|
)->assertOk()->assertJsonPath('data.status', ConfigVersionStatus::Active->value);
|
||||||
|
|
||||||
|
$active->refresh();
|
||||||
|
expect($active->status)->toBe(ConfigVersionStatus::Archived->value);
|
||||||
|
|
||||||
|
expect(PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin play-types requires authentication', function (): void {
|
||||||
|
$this->getJson('/api/v1/admin/play-types')->assertUnauthorized();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user