feat: 添加新的错误码以支持配置版本管理,更新彩票配置以启用手动审核,增强 API 路由以支持玩法和赔率版本化管理

This commit is contained in:
2026-05-11 10:08:48 +08:00
parent aeaf124096
commit 067c2b39f5
41 changed files with 2578 additions and 1 deletions

View File

@@ -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')),
);
}
}

View File

@@ -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(),
],
]);
}
}

View File

@@ -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')),
);
}
}

View File

@@ -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));
}
}

View File

@@ -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')),
);
}
}

View File

@@ -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')),
);
}
}

View File

@@ -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(),
],
]);
}
}

View File

@@ -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')),
);
}
}

View File

@@ -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));
}
}

View File

@@ -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')),
);
}
}

View File

@@ -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')),
);
}
}

View File

@@ -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(),
],
]);
}
}

View File

@@ -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')),
);
}
}

View File

@@ -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));
}
}

View File

@@ -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')),
);
}
}

View File

@@ -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(),
]);
}
}

View File

@@ -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()));
}
}

View File

@@ -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;
}
}
}

View 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';
}

View File

@@ -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;
/* ========== 80008999 玩家 SSO / Bearer 鉴权 ========== */ /* ========== 80008999 玩家 SSO / Bearer 鉴权 ========== */
/** 无 Bearer / 格式错误 / token 为空 */ /** 无 Bearer / 格式错误 / token 为空 */

42
app/Models/OddsItem.php Normal file
View 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');
}
}

View 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;
}
}

View 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');
}
}

View 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
View 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',
];
}
}

View 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');
}
}

View 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;
}
}

View 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,
];
}
}

View 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(),
];
}
}

View 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(),
];
}
}

View 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(),
];
}
}

View 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,
];
}
}

View 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_scopeodds_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,
]);
}
}
});
}
}

View File

@@ -76,7 +76,7 @@ return [
/** 预生成尚未开奖的期号数量(调度补齐);生产可调大,本地/联测建议 612 */ /** 预生成尚未开奖的期号数量(调度补齐);生产可调大,本地/联测建议 612 */
'buffer_draws_ahead' => max(1, (int) env('LOTTERY_DRAW_BUFFER_AHEAD', 8)), 'buffer_draws_ahead' => max(1, (int) env('LOTTERY_DRAW_BUFFER_AHEAD', 8)),
/** trueRNG 后进入 review需后台接口发布 */ /** trueRNG 后进入 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)),
], ],

View File

@@ -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,
]); ]);

View 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);
}
}
}

View 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 = 乘数×10000NPR 基准展示口径) */
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',
]);
}
});
}
}

View File

@@ -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');
});
}); });
}); });
}); });

View 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');
}
}
});

View 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 + activeaudit_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();
});

View 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();
});