feat: enhance agent management and validation logic
- Updated AGENTS.md to clarify agent account restrictions and permissions. - Implemented checks in AgentNodeAdminUserStoreController and AgentNodeRoleStoreController to restrict admin user and role creation to the agent's own node. - Enhanced validation in AdminPlayerStoreController and AdminPlayerUpdateController to enforce credit limit and rebate rate rules based on player funding mode. - Refactored various request classes to utilize shared admin account field rules for consistency. - Improved error handling in services related to credit allocation and rebate limits to ensure proper validation and messaging.
This commit is contained in:
@@ -45,10 +45,12 @@
|
||||
- 期号 `close_time` / `draw_time` 以 UTC 存储与比较;后台展示转浏览器本地时区,创建/编辑表单提交前须转回 UTC。
|
||||
- 下注是否开放由 `DrawHallSnapshotBuilder::isBettingOpen()` / `effectiveHallDisplayStatus()` 实时判定,不只看 `draws.status`。
|
||||
- 后台期号列表展示数据库 `status`;详情 API 另提供 `hall_preview_status` 供与大厅预览态对比。
|
||||
- 绑定经营代理主账号统一绑平台角色 `slug=agent`,模板仅含 `prd.settlement.agent.view`;登录态对 **所有绑定代理主账号** 自动补足 `settlement.agent.manage`(`AgentProfileCapabilityFilter`),实际操作仍受直属边 + 收款方校验。
|
||||
- `AgentProfileCapabilityFilter` 仅作用于**已绑定代理节点**的经营账号(按档案 `can_create_*` 收紧权限);**禁止**对无代理绑定的平台账号(如 `site_admin`)套用,否则会误剥 `prd.agent.manage` 等权限。绑定经营代理主账号统一绑 `slug=agent`,模板仅含 `prd.settlement.agent.view`;登录态对绑定代理主账号自动补足 `settlement.agent.manage`,实际操作仍受直属边 + 收款方校验。
|
||||
- 站点管理员(`admin_user_site_roles` + `slug=site_admin`,且**未**绑 `admin_user_agents`)定位单站信用盘运营(代理树/玩家/结算/注单/报表);不含开奖赔率等平台技术权限;开通一级代理线路仅超管(`prd.agent-line.provision`)。
|
||||
- 结算中心登记收付/确认/坏账/补差 UI 需 `prd.settlement.agent.manage`(`canManage`);仅 view 时操作区静默隐藏。另需账单 `status` ∈ confirmed/partial_paid/overdue 且 `unpaid_amount > 0`。**坏账核销 / 补差冲正** 另需未绑定代理(站点财务,`canFinanceAdjustments`),绑定代理仅有收付/确认。
|
||||
- 结算账单可见范围(绑定代理):**玩家账单**仅直属玩家;**代理账单**仅 `owner=本节点` 或 `counterparty=本节点`(不含下级玩家的账单、不含更深层代理链)。**账务流水/账期 pipeline** 的玩家维度同样仅直属玩家。站点财务/超管仍见全站。
|
||||
- 登记收付/确认:绑定代理仅可操作 **收款方**(玩家账单=直属 counterparty;代理账单=按 net_amount 方向的 payee)。上级不能代登下级玩家收付,下级也不能代登向上级的代理账单。
|
||||
- 收付/调账/坏账后端落库 `payment_records`、`settlement_adjustments`;账期详情 **收付与调账** Tab 查操作台账,**账务流水** 仅玩家信用变动;单张账单详情内另有该账单的收付列表。
|
||||
- 代理仪表盘/账期列表「输赢」用本级占成(`share_profit`),不可看 `platform_pnl` 全站报表。
|
||||
- 开/关账期仅未绑定代理的站点财务(`canManagePeriods = canOperateBills && boundAgent === null`)。
|
||||
- **一级代理 profile**:每站唯一根节点(`depth=0`)的占成/授信/回水仅超管可改(`AdminAgentScope::nodeProfileEditableBy`);站点管理员与一级代理账号对根节点 profile API 为 403;下级仍由上级代理维护。
|
||||
|
||||
@@ -51,6 +51,18 @@ final class AgentNodeAdminUserStoreController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
// Agent accounts can only create admin users on their own node, not descendants
|
||||
$primaryNode = AdminAgentScope::primaryAgentNode($admin);
|
||||
if ($primaryNode !== null && (int) $primaryNode->id !== (int) $agent_node->id) {
|
||||
return ApiMessage::errorResponse(
|
||||
$request,
|
||||
'admin.agent_user_manage_denied',
|
||||
ErrorCode::AdminForbidden->value,
|
||||
null,
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
$user = $service->createUnderAgent($agent_node, $request->validated());
|
||||
|
||||
AuditLogger::recordForAdmin(
|
||||
|
||||
@@ -51,6 +51,7 @@ final class AgentNodeProfileController extends Controller
|
||||
: null;
|
||||
|
||||
$payload = $request->validated();
|
||||
|
||||
if ($parent !== null) {
|
||||
$service->assertChildCapabilityGrantsWithinParent($parent, $payload, $admin);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,18 @@ final class AgentNodeRoleStoreController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
// Agent accounts can only create roles on their own node, not descendants
|
||||
$primaryNode = AdminAgentScope::primaryAgentNode($admin);
|
||||
if ($primaryNode !== null && (int) $primaryNode->id !== (int) $agent_node->id) {
|
||||
return ApiMessage::errorResponse(
|
||||
$request,
|
||||
'admin.agent_role_manage_denied',
|
||||
ErrorCode::AdminForbidden->value,
|
||||
null,
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
$role = $service->createForAgent($admin, $agent_node, $request->validated());
|
||||
|
||||
AuditLogger::recordForAdmin(
|
||||
|
||||
@@ -29,11 +29,11 @@ final class AdminCreditLedgerIndexController extends Controller
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
$adminSiteId = (int) $request->query('admin_site_id', 0);
|
||||
abort_if($adminSiteId <= 0, 422, 'admin_site_id required');
|
||||
abort_if($adminSiteId <= 0, 422, 'admin.admin_site_id_required');
|
||||
abort_if(! AdminAgentSettlementScope::siteAccessible($admin, $adminSiteId), 403);
|
||||
|
||||
$siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
|
||||
abort_if($siteCode === '', 422, 'admin_site not found');
|
||||
abort_if($siteCode === '', 422, 'admin.admin_site_not_found');
|
||||
|
||||
$periodId = (int) $request->query('settlement_period_id', 0);
|
||||
if ($periodId > 0) {
|
||||
|
||||
@@ -33,7 +33,7 @@ final class AgentSettlementReportShowController extends Controller
|
||||
abort_unless(in_array($type, self::TYPES, true), 404);
|
||||
|
||||
if ($type === 'platform_pnl' && AdminAgentScope::primaryAgentNode($admin) !== null) {
|
||||
abort(403, 'agent_cannot_view_platform_pnl');
|
||||
abort(403, 'admin.agent_cannot_view_platform_pnl');
|
||||
}
|
||||
|
||||
$periodId = (int) $request->query('settlement_period_id', 0);
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Player;
|
||||
use App\Models\Player;
|
||||
use App\Models\AdminSite;
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AdminUser;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\ApiMessage;
|
||||
use App\Support\ApiResponse;
|
||||
@@ -92,7 +93,7 @@ final class AdminPlayerStoreController extends Controller
|
||||
|
||||
$agentNodeId = $admin->isSuperAdmin()
|
||||
? $this->resolveAgentNodeIdForSuperAdmin($request->validated('agent_node_id'), $siteCode)
|
||||
: $admin->primaryAgentNodeId();
|
||||
: $this->resolveAgentNodeIdForNonSuperAdmin($admin, $request->validated('agent_node_id'), $siteCode);
|
||||
|
||||
if ($agentNodeId === null) {
|
||||
return ApiMessage::errorResponse(
|
||||
@@ -104,17 +105,10 @@ final class AdminPlayerStoreController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
if (! $admin->isSuperAdmin()) {
|
||||
$agent = AdminAgentScope::primaryAgentNode($admin);
|
||||
if ($agent === null || (int) $agentNodeId !== (int) $agent->id) {
|
||||
return ApiMessage::errorResponse($request, 'admin.player_create_agent_forbidden', ErrorCode::AdminForbidden->value, null, 403);
|
||||
}
|
||||
}
|
||||
|
||||
$agent = AgentNode::query()->findOrFail($agentNodeId);
|
||||
$rebateRate = 0.0;
|
||||
$extraRebateRate = 0.0;
|
||||
if ($request->has('rebate_rate')) {
|
||||
if ($request->has('rebate_rate') || $request->has('extra_rebate_rate')) {
|
||||
$rebateRate = (float) $request->input('rebate_rate', 0) / 100;
|
||||
$extraRebateRate = (float) $request->input('extra_rebate_rate', 0) / 100;
|
||||
$rebateLimitValidator->assertPlayerRebateWithinAgent(
|
||||
@@ -124,6 +118,12 @@ final class AdminPlayerStoreController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
if (! $isNative && ($request->has('credit_limit') || $request->has('rebate_rate') || $request->has('extra_rebate_rate'))) {
|
||||
throw ValidationException::withMessages([
|
||||
'credit_limit' => ['wallet_player_prohibited'],
|
||||
]);
|
||||
}
|
||||
|
||||
$creditLimit = $request->has('credit_limit')
|
||||
? (int) $request->input('credit_limit', 0)
|
||||
: ($isNative ? 0 : 0);
|
||||
@@ -201,6 +201,43 @@ final class AdminPlayerStoreController extends Controller
|
||||
return $rootId !== null ? (int) $rootId : null;
|
||||
}
|
||||
|
||||
private function resolveAgentNodeIdForNonSuperAdmin(AdminUser $admin, mixed $requested, string $siteCode): ?int
|
||||
{
|
||||
// Check if admin is a platform account (bound via admin_user_site_roles)
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds !== null) {
|
||||
// Platform account (site admin) can specify agent_node_id
|
||||
if ($requested !== null && (int) $requested > 0) {
|
||||
$agent = AgentNode::query()->find((int) $requested);
|
||||
if ($agent !== null && in_array((int) $agent->admin_site_id, $accessibleSiteIds, true)) {
|
||||
return (int) $requested;
|
||||
}
|
||||
}
|
||||
// Default to root node of the site
|
||||
$siteId = AdminSite::query()->where('code', $siteCode)->value('id');
|
||||
if ($siteId !== null && in_array((int) $siteId, $accessibleSiteIds, true)) {
|
||||
$rootId = AgentNode::query()
|
||||
->where('admin_site_id', (int) $siteId)
|
||||
->where('depth', 0)
|
||||
->value('id');
|
||||
return $rootId !== null ? (int) $rootId : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node) - can only create under own node
|
||||
$agent = AdminAgentScope::primaryAgentNode($admin);
|
||||
if ($agent === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($requested !== null && (int) $requested > 0 && (int) $requested !== (int) $agent->id) {
|
||||
return null; // Agent account cannot create under other nodes
|
||||
}
|
||||
|
||||
return (int) $agent->id;
|
||||
}
|
||||
|
||||
private function generateNativeSitePlayerId(string $siteCode): string
|
||||
{
|
||||
$prefix = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $siteCode) ?: 'LP', 0, 2));
|
||||
|
||||
@@ -7,12 +7,14 @@ use App\Models\AgentNode;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Support\AdminSiteScope;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Support\PlayerApiPresenter;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Agent\AgentProfileService;
|
||||
use App\Services\Agent\RebateLimitValidator;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use App\Services\Player\PlayerRebateProfileService;
|
||||
use App\Http\Requests\Admin\AdminPlayerUpdateRequest;
|
||||
|
||||
@@ -44,9 +46,18 @@ final class AdminPlayerUpdateController extends Controller
|
||||
? AgentNode::query()->find((int) $player->agent_node_id)
|
||||
: null;
|
||||
|
||||
if (
|
||||
($request->has('credit_limit') || $request->has('rebate_rate') || $request->has('extra_rebate_rate'))
|
||||
&& ! PlayerFundingMode::usesCredit($player)
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'credit_limit' => ['wallet_player_prohibited'],
|
||||
]);
|
||||
}
|
||||
|
||||
$rebateRate = 0.0;
|
||||
$extraRebateRate = 0.0;
|
||||
if ($agent !== null && $request->has('rebate_rate')) {
|
||||
if ($agent !== null && ($request->has('rebate_rate') || $request->has('extra_rebate_rate'))) {
|
||||
$rebateRate = (float) $request->input('rebate_rate', 0) / 100;
|
||||
$extraRebateRate = (float) $request->input('extra_rebate_rate', 0) / 100;
|
||||
$rebateLimitValidator->assertPlayerRebateWithinAgent(
|
||||
@@ -67,7 +78,7 @@ final class AdminPlayerUpdateController extends Controller
|
||||
unset($data['credit_limit']);
|
||||
}
|
||||
|
||||
if ($request->has('rebate_rate')) {
|
||||
if ($request->has('rebate_rate') || $request->has('extra_rebate_rate')) {
|
||||
DB::table('player_rebate_profiles')->updateOrInsert(
|
||||
['player_id' => $player->id, 'game_type' => '*'],
|
||||
[
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Player\PlayerAuthLoginRequest;
|
||||
use App\Services\Player\PlayerNativeAuthService;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Support\LotteryMessage;
|
||||
use App\Exceptions\PlayerAuthenticationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
@@ -22,7 +23,7 @@ final class PlayerAuthLoginController extends Controller
|
||||
);
|
||||
} catch (PlayerAuthenticationException $e) {
|
||||
return ApiResponse::error(
|
||||
$e->getMessage(),
|
||||
LotteryMessage::sso($request, $e->lotteryCode),
|
||||
$e->lotteryCode,
|
||||
null,
|
||||
$e->httpStatus,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
|
||||
use App\Http\Requests\Admin\Concerns\AgentProfileFieldRules;
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
use App\Models\AdminSite;
|
||||
@@ -11,6 +12,7 @@ use Illuminate\Validation\Validator;
|
||||
|
||||
final class AdminAgentLineStoreRequest extends ApiFormRequest
|
||||
{
|
||||
use AdminAccountFieldRules;
|
||||
use AgentProfileFieldRules;
|
||||
|
||||
public function authorize(): bool
|
||||
@@ -20,6 +22,7 @@ final class AdminAgentLineStoreRequest extends ApiFormRequest
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->normalizeAdminAccountFields();
|
||||
$this->prepareAgentProfileFieldsForValidation();
|
||||
|
||||
if ($this->has('site_code')) {
|
||||
@@ -42,8 +45,8 @@ final class AdminAgentLineStoreRequest extends ApiFormRequest
|
||||
'site_code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::exists('admin_sites', 'code')],
|
||||
'code' => ['sometimes', 'nullable', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::unique('agent_nodes', 'code')],
|
||||
'name' => ['required', 'string', 'max:128'],
|
||||
'username' => ['required', 'string', 'max:64', Rule::unique('admin_users', 'username')],
|
||||
'password' => ['required', 'string', 'min:8', 'max:128'],
|
||||
'username' => [...$this->adminOperatorUsernameRules(), Rule::unique('admin_users', 'username')],
|
||||
'password' => $this->adminLoginPasswordRules(),
|
||||
'email' => ['nullable', 'string', 'email', 'max:255', Rule::unique('admin_users', 'email')],
|
||||
'status' => ['sometimes', 'integer', 'in:0,1'],
|
||||
...$this->agentProfileFieldRules(),
|
||||
|
||||
@@ -11,9 +11,32 @@ final class AdminAgentProfileUpdateRequest extends ApiFormRequest
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
$admin = $this->user();
|
||||
if (! $admin instanceof \App\Models\AdminUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$agentNode = $this->route('agent_node');
|
||||
if (! $agentNode instanceof \App\Models\AgentNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! \App\Support\AdminAgentScope::nodeVisibleTo($admin, $agentNode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! \App\Support\AdminAgentScope::nodeProfileEditableBy($admin, $agentNode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function failedAuthorization(): void
|
||||
{
|
||||
abort(403, 'admin.permission_denied');
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->prepareAgentProfileFieldsForValidation();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
/**
|
||||
@@ -11,19 +12,26 @@ use App\Http\Requests\ApiFormRequest;
|
||||
*/
|
||||
final class AdminLoginRequest extends ApiFormRequest
|
||||
{
|
||||
use AdminAccountFieldRules;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->normalizeAdminAccountFields();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'account' => ['required', 'string', 'min:2', 'max:64', 'regex:/^[a-zA-Z0-9._-]+$/u'],
|
||||
'password' => ['required', 'string', 'max:256'],
|
||||
'account' => $this->adminLoginAccountRules(),
|
||||
'password' => $this->adminLoginPasswordRules(),
|
||||
'captcha_key' => ['required', 'string', 'uuid'],
|
||||
'captcha_code' => ['required', 'string', 'max:32'],
|
||||
];
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Validation\Rule;
|
||||
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
/**
|
||||
@@ -12,6 +13,8 @@ use App\Http\Requests\ApiFormRequest;
|
||||
*/
|
||||
final class AdminPlayerStoreRequest extends ApiFormRequest
|
||||
{
|
||||
use AdminAccountFieldRules;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
@@ -22,13 +25,13 @@ final class AdminPlayerStoreRequest extends ApiFormRequest
|
||||
return [
|
||||
'site_code' => ['required', 'string', 'max:64'],
|
||||
'site_player_id' => ['nullable', 'string', 'max:128'],
|
||||
'username' => ['nullable', 'string', 'max:128'],
|
||||
'password' => ['nullable', 'string', 'min:6', 'max:128'],
|
||||
'username' => $this->nativePlayerUsernameRules(),
|
||||
'password' => $this->nativePlayerPasswordRules(),
|
||||
'nickname' => ['nullable', 'string', 'max:128'],
|
||||
'default_currency' => ['sometimes', 'string', 'max:16', Rule::exists('currencies', 'code')],
|
||||
'status' => ['sometimes', 'integer', 'in:0,1,2'],
|
||||
'agent_node_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||
'credit_limit' => ['sometimes', 'integer', 'min:0'],
|
||||
'agent_node_id' => ['sometimes', 'nullable', 'integer', 'min:1', 'exists:agent_nodes,id'],
|
||||
'credit_limit' => ['sometimes', 'integer', 'min:0', 'max:999999999'],
|
||||
'rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'],
|
||||
'extra_rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'],
|
||||
];
|
||||
@@ -36,6 +39,8 @@ final class AdminPlayerStoreRequest extends ApiFormRequest
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->normalizeAdminAccountFields();
|
||||
|
||||
if (! $this->has('default_currency')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Validation\Rule;
|
||||
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
/**
|
||||
@@ -12,6 +13,8 @@ use App\Http\Requests\ApiFormRequest;
|
||||
*/
|
||||
final class AdminPlayerUpdateRequest extends ApiFormRequest
|
||||
{
|
||||
use AdminAccountFieldRules;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
@@ -20,11 +23,11 @@ final class AdminPlayerUpdateRequest extends ApiFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'username' => ['sometimes', 'string', 'max:128'],
|
||||
'username' => $this->nativePlayerUsernameRules(required: false),
|
||||
'nickname' => ['sometimes', 'nullable', 'string', 'max:128'],
|
||||
'default_currency' => ['sometimes', 'string', 'max:16', Rule::exists('currencies', 'code')],
|
||||
'status' => ['sometimes', 'integer', Rule::in([0, 1, 2])],
|
||||
'credit_limit' => ['sometimes', 'integer', 'min:0'],
|
||||
'credit_limit' => ['sometimes', 'integer', 'min:0', 'max:999999999'],
|
||||
'rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'],
|
||||
'extra_rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'],
|
||||
'rebate_profiles' => ['sometimes', 'array'],
|
||||
@@ -39,6 +42,8 @@ final class AdminPlayerUpdateRequest extends ApiFormRequest
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->normalizeAdminAccountFields();
|
||||
|
||||
if (! $this->has('default_currency')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,24 +2,32 @@
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class AgentAdminUserStoreRequest extends ApiFormRequest
|
||||
{
|
||||
use AdminAccountFieldRules;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->normalizeAdminAccountFields();
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'username' => ['required', 'string', 'max:64', Rule::unique('admin_users', 'username')],
|
||||
'username' => [...$this->adminOperatorUsernameRules(), Rule::unique('admin_users', 'username')],
|
||||
'nickname' => ['required', 'string', 'max:128'],
|
||||
'email' => ['nullable', 'email', 'max:255', Rule::unique('admin_users', 'email')],
|
||||
'password' => ['required', 'string', 'min:8', 'max:128'],
|
||||
'password' => $this->adminLoginPasswordRules(),
|
||||
'status' => ['sometimes', 'integer', 'in:0,1'],
|
||||
'role_ids' => ['sometimes', 'array'],
|
||||
'role_ids.*' => ['integer', 'exists:admin_roles,id'],
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
|
||||
use App\Http\Requests\Admin\Concerns\AgentProfileFieldRules;
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class AgentNodeStoreRequest extends ApiFormRequest
|
||||
{
|
||||
use AdminAccountFieldRules;
|
||||
use AgentProfileFieldRules;
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -16,6 +18,7 @@ final class AgentNodeStoreRequest extends ApiFormRequest
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->normalizeAdminAccountFields();
|
||||
$this->prepareAgentProfileFieldsForValidation();
|
||||
}
|
||||
|
||||
@@ -26,7 +29,7 @@ final class AgentNodeStoreRequest extends ApiFormRequest
|
||||
'parent_id' => ['required', 'integer', 'exists:agent_nodes,id'],
|
||||
'code' => ['sometimes', 'nullable', 'string', 'max:64', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
||||
'name' => ['required', 'string', 'max:128'],
|
||||
'username' => ['sometimes', 'nullable', 'string', 'max:64', Rule::unique('admin_users', 'username')],
|
||||
'username' => ['sometimes', 'nullable', ...$this->adminOperatorUsernameRules(required: false), Rule::unique('admin_users', 'username')],
|
||||
'email' => ['nullable', 'email', 'max:255', Rule::unique('admin_users', 'email')],
|
||||
'password' => ['required', 'string', 'min:8', 'max:128'],
|
||||
'status' => ['sometimes', 'integer', 'in:0,1'],
|
||||
|
||||
@@ -2,23 +2,31 @@
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
final class AgentNodeUpdateRequest extends ApiFormRequest
|
||||
{
|
||||
use AdminAccountFieldRules;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->normalizeAdminAccountFields();
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['sometimes', 'string', 'max:128'],
|
||||
'username' => ['sometimes', 'string', 'max:64'],
|
||||
'username' => $this->adminOperatorUsernameRules(required: false),
|
||||
'email' => ['sometimes', 'nullable', 'email', 'max:255'],
|
||||
'password' => ['sometimes', 'nullable', 'string', 'min:8', 'max:128'],
|
||||
'password' => $this->adminLoginPasswordRules(required: false),
|
||||
'status' => ['sometimes', 'integer', 'in:0,1'],
|
||||
];
|
||||
}
|
||||
|
||||
61
app/Http/Requests/Admin/Concerns/AdminAccountFieldRules.php
Normal file
61
app/Http/Requests/Admin/Concerns/AdminAccountFieldRules.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Concerns;
|
||||
|
||||
trait AdminAccountFieldRules
|
||||
{
|
||||
/** @return array<int, mixed> */
|
||||
protected function adminLoginAccountRules(bool $required = true): array
|
||||
{
|
||||
$rules = ['string', 'min:2', 'max:64', 'regex:/^[a-zA-Z0-9._-]+$/u'];
|
||||
|
||||
return $required ? array_merge(['required'], $rules) : array_merge(['sometimes'], $rules);
|
||||
}
|
||||
|
||||
/** @return array<int, mixed> */
|
||||
protected function adminLoginPasswordRules(bool $required = true, int $min = 8, int $max = 256): array
|
||||
{
|
||||
$rules = ['string', 'min:'.$min, 'max:'.$max];
|
||||
|
||||
if ($required) {
|
||||
return array_merge(['required'], $rules);
|
||||
}
|
||||
|
||||
return array_merge(['sometimes', 'nullable'], $rules);
|
||||
}
|
||||
|
||||
/** @return array<int, mixed> */
|
||||
protected function adminOperatorUsernameRules(bool $required = true): array
|
||||
{
|
||||
$rules = ['string', 'min:2', 'max:64', 'regex:/^[a-zA-Z0-9._-]+$/u'];
|
||||
|
||||
return $required ? array_merge(['required'], $rules) : array_merge(['sometimes'], $rules);
|
||||
}
|
||||
|
||||
/** @return array<int, mixed> */
|
||||
protected function nativePlayerUsernameRules(bool $required = false): array
|
||||
{
|
||||
$rules = ['string', 'min:2', 'max:64', 'regex:/^[a-zA-Z0-9._-]+$/u'];
|
||||
|
||||
return $required ? array_merge(['required'], $rules) : array_merge(['nullable'], $rules);
|
||||
}
|
||||
|
||||
/** @return array<int, mixed> */
|
||||
protected function nativePlayerPasswordRules(bool $required = false): array
|
||||
{
|
||||
$rules = ['string', 'min:6', 'max:128'];
|
||||
|
||||
return $required ? array_merge(['required'], $rules) : array_merge(['nullable'], $rules);
|
||||
}
|
||||
|
||||
protected function normalizeAdminAccountFields(): void
|
||||
{
|
||||
if ($this->has('account')) {
|
||||
$this->merge(['account' => strtolower(trim((string) $this->input('account')))]);
|
||||
}
|
||||
|
||||
if ($this->has('username')) {
|
||||
$this->merge(['username' => strtolower(trim((string) $this->input('username')))]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ trait AgentProfileFieldRules
|
||||
return [
|
||||
'total_share_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'],
|
||||
'relative_share_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'],
|
||||
'credit_limit' => ['sometimes', 'integer', 'min:0'],
|
||||
'credit_limit' => ['sometimes', 'integer', 'min:0', 'max:999999999'],
|
||||
'rebate_limit' => ['sometimes', 'numeric', 'min:0', 'max:100'],
|
||||
'default_player_rebate' => ['sometimes', 'numeric', 'min:0', 'max:100'],
|
||||
'can_grant_extra_rebate' => ['sometimes', 'boolean'],
|
||||
|
||||
@@ -2,21 +2,32 @@
|
||||
|
||||
namespace App\Http\Requests\Player;
|
||||
|
||||
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
final class PlayerAuthLoginRequest extends ApiFormRequest
|
||||
{
|
||||
use AdminAccountFieldRules;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->normalizeAdminAccountFields();
|
||||
if ($this->has('username')) {
|
||||
$this->merge(['username' => strtolower(trim((string) $this->input('username')))]);
|
||||
}
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'site_code' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||
'username' => ['required', 'string', 'max:128'],
|
||||
'password' => ['required', 'string', 'min:6', 'max:128'],
|
||||
'username' => $this->nativePlayerUsernameRules(required: true),
|
||||
'password' => $this->nativePlayerPasswordRules(required: true),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,6 +430,11 @@ final class AdminUser extends Authenticatable
|
||||
|
||||
$codes = array_keys($merged);
|
||||
|
||||
// 平台账号(站点管理员等)不按线路内 AgentProfile 收紧;该过滤器仅作用于绑定代理节点的主账号。
|
||||
if (! $this->hasPrimaryAgentBinding()) {
|
||||
return $codes;
|
||||
}
|
||||
|
||||
return AgentProfileCapabilityFilter::applyToMenuActionCodes(
|
||||
$codes,
|
||||
$this->primaryAgentProfile(),
|
||||
|
||||
@@ -26,10 +26,20 @@ final class AgentProfileService
|
||||
{
|
||||
$parent = $parent ?? ($node->parent_id !== null ? AgentNode::query()->find($node->parent_id) : null);
|
||||
|
||||
$totalShare = (float) ($payload['total_share_rate'] ?? 0);
|
||||
$creditLimit = (int) ($payload['credit_limit'] ?? 0);
|
||||
$rebateLimit = (float) ($payload['rebate_limit'] ?? 0) / 100;
|
||||
$defaultRebate = (float) ($payload['default_player_rebate'] ?? 0) / 100;
|
||||
$existingProfile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
|
||||
|
||||
$totalShare = array_key_exists('total_share_rate', $payload)
|
||||
? (float) $payload['total_share_rate']
|
||||
: (float) ($existingProfile->total_share_rate ?? 0);
|
||||
$creditLimit = array_key_exists('credit_limit', $payload)
|
||||
? (int) $payload['credit_limit']
|
||||
: (int) ($existingProfile->credit_limit ?? 0);
|
||||
$rebateLimit = array_key_exists('rebate_limit', $payload)
|
||||
? (float) $payload['rebate_limit'] / 100
|
||||
: (float) ($existingProfile->rebate_limit ?? 0);
|
||||
$defaultRebate = array_key_exists('default_player_rebate', $payload)
|
||||
? (float) $payload['default_player_rebate'] / 100
|
||||
: (float) ($existingProfile->default_player_rebate ?? 0);
|
||||
|
||||
$useRelative = $parent !== null && array_key_exists('relative_share_rate', $payload);
|
||||
|
||||
@@ -68,18 +78,24 @@ final class AgentProfileService
|
||||
}
|
||||
|
||||
if ($parent !== null) {
|
||||
$delta = $isNew ? $creditLimit : max(0, $creditLimit - $previousCredit);
|
||||
if ($delta > 0) {
|
||||
$this->creditAllocationValidator->assertAllocationWithinParent($parent, $delta);
|
||||
}
|
||||
$this->creditAllocationValidator->assertChildCreditLimitWithinParent(
|
||||
$parent,
|
||||
$isNew ? 0 : $previousCredit,
|
||||
$creditLimit,
|
||||
$isNew,
|
||||
);
|
||||
}
|
||||
|
||||
if ($defaultRebate > $rebateLimit && $rebateLimit > 0) {
|
||||
if ($defaultRebate > $rebateLimit) {
|
||||
throw ValidationException::withMessages([
|
||||
'default_player_rebate' => ['exceeds_limit'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($parent !== null) {
|
||||
$this->rebateLimitValidator->assertChildRebateLimitWithinParent($parent, $rebateLimit);
|
||||
}
|
||||
|
||||
$profile->fill([
|
||||
'total_share_rate' => $totalShare,
|
||||
'credit_limit' => $creditLimit,
|
||||
@@ -245,7 +261,7 @@ final class AgentProfileService
|
||||
*/
|
||||
public function assertChildCapabilityGrantsWithinParent(AgentNode $parent, array $childPayload, AdminUser $actor): void
|
||||
{
|
||||
if ($actor->isSuperAdmin()) {
|
||||
if ($actor->isSuperAdmin() || \App\Support\AdminAgentSettlementScope::canManageSitePeriods($actor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,13 +13,47 @@ final class CreditAllocationValidator
|
||||
private readonly AgentCreditAllocatedSyncService $allocatedSync,
|
||||
) {}
|
||||
|
||||
public function assertChildCreditLimitWithinParent(
|
||||
AgentNode $parent,
|
||||
int $previousChildLimit,
|
||||
int $newChildLimit,
|
||||
bool $isNewChild,
|
||||
): void {
|
||||
if ($newChildLimit < 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'credit_limit' => ['invalid'],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->allocatedSync->syncForAgent($parent);
|
||||
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $parent->id)->first();
|
||||
if ($profile === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'credit_limit' => ['parent_profile_required'],
|
||||
]);
|
||||
}
|
||||
|
||||
$available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit);
|
||||
$floor = $isNewChild ? 0 : max(0, $previousChildLimit);
|
||||
$maxAllowed = $floor + $available;
|
||||
|
||||
if ($newChildLimit > $maxAllowed) {
|
||||
throw ValidationException::withMessages([
|
||||
'credit_limit' => ['exceeds_available'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function assertAllocationWithinParent(AgentNode $parent, int $additionalCredit): void
|
||||
{
|
||||
$this->allocatedSync->syncForAgent($parent);
|
||||
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $parent->id)->first();
|
||||
if ($profile === null) {
|
||||
return;
|
||||
throw ValidationException::withMessages([
|
||||
'credit_limit' => ['parent_profile_required'],
|
||||
]);
|
||||
}
|
||||
|
||||
$available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit);
|
||||
|
||||
@@ -12,7 +12,9 @@ final class RebateLimitValidator
|
||||
{
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first();
|
||||
if ($profile === null) {
|
||||
return;
|
||||
throw ValidationException::withMessages([
|
||||
'rebate_rate' => ['agent_profile_required'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Both $rebateRate and $profile->rebate_limit are ratios (0-1)
|
||||
@@ -23,10 +25,38 @@ final class RebateLimitValidator
|
||||
]);
|
||||
}
|
||||
|
||||
if ($extraRebateRate > $limit) {
|
||||
throw ValidationException::withMessages([
|
||||
'extra_rebate_rate' => ['exceeds_limit'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($extraRebateRate > 0 && ! $profile->can_grant_extra_rebate) {
|
||||
throw ValidationException::withMessages([
|
||||
'extra_rebate_rate' => ['not_allowed'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($limit > 0 && ($rebateRate + $extraRebateRate) > $limit + 1e-9) {
|
||||
throw ValidationException::withMessages([
|
||||
'rebate_rate' => ['exceeds_limit'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function assertChildRebateLimitWithinParent(AgentNode $parent, float $childRebateLimitRatio): void
|
||||
{
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $parent->id)->first();
|
||||
if ($profile === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'rebate_limit' => ['parent_profile_required'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($childRebateLimitRatio > (float) $profile->rebate_limit) {
|
||||
throw ValidationException::withMessages([
|
||||
'rebate_limit' => ['exceeds_parent'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,14 @@ final class SettlementPaymentService
|
||||
]);
|
||||
}
|
||||
|
||||
$payAmount = min($amount, abs((int) $bill->unpaid_amount));
|
||||
$unpaid = abs((int) $bill->unpaid_amount);
|
||||
if ($amount > $unpaid) {
|
||||
throw ValidationException::withMessages([
|
||||
'amount' => ['exceeds_unpaid'],
|
||||
]);
|
||||
}
|
||||
|
||||
$payAmount = $amount;
|
||||
if ($payAmount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,15 +25,7 @@ final class PlayerNativeAuthService
|
||||
);
|
||||
}
|
||||
|
||||
if ($siteCode === '') {
|
||||
$siteCode = trim((string) config('lottery.integration.default_site_code', ''));
|
||||
}
|
||||
|
||||
$player = Player::query()
|
||||
->where('site_code', $siteCode)
|
||||
->where('username', $username)
|
||||
->where('auth_source', PlayerAuthSource::LOTTERY_NATIVE)
|
||||
->first();
|
||||
$player = $this->resolveNativePlayer($siteCode, $username);
|
||||
|
||||
if ($player === null || ! is_string($player->password_hash) || $player->password_hash === '') {
|
||||
throw new PlayerAuthenticationException(
|
||||
@@ -132,4 +124,29 @@ final class PlayerNativeAuthService
|
||||
|
||||
$player->forceFill($updates)->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 彩票端登录:玩家只填账号密码,不传站点编号。
|
||||
* 若请求带了 site_code(部署绑站)则优先在该站查找;否则按账号全局匹配唯一彩票原生玩家。
|
||||
*/
|
||||
private function resolveNativePlayer(string $siteCode, string $username): ?Player
|
||||
{
|
||||
$query = Player::query()
|
||||
->where('username', $username)
|
||||
->where('auth_source', PlayerAuthSource::LOTTERY_NATIVE);
|
||||
|
||||
if ($siteCode !== '') {
|
||||
$scoped = (clone $query)->where('site_code', $siteCode)->first();
|
||||
if ($scoped !== null) {
|
||||
return $scoped;
|
||||
}
|
||||
}
|
||||
|
||||
$candidates = $query->get();
|
||||
if ($candidates->count() === 1) {
|
||||
return $candidates->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
78
app/Services/Ticket/TicketLineInstantRebateApplicator.php
Normal file
78
app/Services/Ticket/TicketLineInstantRebateApplicator.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ticket;
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Support\PlayerFundingMode;
|
||||
|
||||
/**
|
||||
* 下注行回水:钱包盘立减实扣;信用盘仅展示账期回水预估,实扣仍按全额占用授信。
|
||||
*/
|
||||
final class TicketLineInstantRebateApplicator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InstantRebateResolver $instantRebateResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $evaluated
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function apply(Player $player, array $evaluated): array
|
||||
{
|
||||
$resolved = $this->instantRebateResolver->resolveForPlayer(
|
||||
$player,
|
||||
(string) $evaluated['play_code'],
|
||||
(float) $evaluated['rebate_rate_snapshot'],
|
||||
);
|
||||
|
||||
$evaluated['rule_snapshot_json']['base_rebate_rate'] = number_format($resolved['base_rebate_rate'], 4, '.', '');
|
||||
$evaluated['rule_snapshot_json']['player_addon_rebate_rate'] = number_format($resolved['player_addon_rebate_rate'], 4, '.', '');
|
||||
$evaluated['rule_snapshot_json']['rebate_inherited_from_agent'] = $resolved['inherited_from_agent'];
|
||||
$evaluated['rule_snapshot_json']['instant_rebate_applied'] = ! PlayerFundingMode::usesCredit($player);
|
||||
|
||||
$finalRate = $resolved['final_rebate_rate'];
|
||||
$evaluated['rebate_rate_snapshot'] = number_format($finalRate, 4, '.', '');
|
||||
|
||||
if (PlayerFundingMode::usesCredit($player)) {
|
||||
$evaluated['actual_deduct_amount'] = (int) $evaluated['total_bet_amount'];
|
||||
|
||||
return $evaluated;
|
||||
}
|
||||
|
||||
$evaluated['actual_deduct_amount'] = max(
|
||||
0,
|
||||
(int) floor((int) $evaluated['total_bet_amount'] * (1 - $finalRate)),
|
||||
);
|
||||
|
||||
return $evaluated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认弹窗展示用回水:信用盘为账期回水预估,钱包盘为下注立减额。
|
||||
*
|
||||
* @param array<string, mixed> $evaluated
|
||||
*/
|
||||
public function displayRebateAmount(Player $player, array $evaluated): int
|
||||
{
|
||||
$bet = (int) $evaluated['total_bet_amount'];
|
||||
|
||||
if (PlayerFundingMode::usesCredit($player)) {
|
||||
$rate = (float) ($evaluated['rebate_rate_snapshot'] ?? 0);
|
||||
|
||||
return (int) floor($bet * max(0.0, min(1.0, $rate)));
|
||||
}
|
||||
|
||||
return max(0, $bet - (int) $evaluated['actual_deduct_amount']);
|
||||
}
|
||||
|
||||
/** 落库订单/注单上的即时回水合计(信用盘为 0)。 */
|
||||
public function persistedInstantRebateAmount(Player $player, array $evaluated): int
|
||||
{
|
||||
if (PlayerFundingMode::usesCredit($player)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->displayRebateAmount($player, $evaluated);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ final class TicketPlacementService
|
||||
public function __construct(
|
||||
private readonly PlayCatalogResolver $catalogResolver,
|
||||
private readonly PlayRuleEngine $ruleEngine,
|
||||
private readonly InstantRebateResolver $instantRebateResolver,
|
||||
private readonly TicketLineInstantRebateApplicator $rebateApplicator,
|
||||
private readonly RiskPoolService $riskPoolService,
|
||||
private readonly TicketWalletService $ticketWalletService,
|
||||
private readonly JackpotContributionService $jackpotContribution,
|
||||
@@ -132,7 +132,7 @@ final class TicketPlacementService
|
||||
$resolved['play_config'],
|
||||
$resolved['odds_items'],
|
||||
);
|
||||
$evaluated = $this->applyCreditLineInstantRebatePolicy($player, $evaluated);
|
||||
$evaluated = $this->rebateApplicator->apply($player, $evaluated);
|
||||
|
||||
$locks = array_map(fn (array $combo): array => [
|
||||
'number_4d' => $combo['number_4d'],
|
||||
@@ -141,7 +141,7 @@ final class TicketPlacementService
|
||||
// place 阶段以 acquire 的原子扣减结果为准,允许单行售罄后形成混合成功/失败结果。
|
||||
|
||||
$evaluatedLines[] = $evaluated;
|
||||
$rebateAmount = (int) $evaluated['total_bet_amount'] - (int) $evaluated['actual_deduct_amount'];
|
||||
$rebateAmount = $this->rebateApplicator->persistedInstantRebateAmount($player, $evaluated);
|
||||
$totalBet += (int) $evaluated['total_bet_amount'];
|
||||
$totalRebate += $rebateAmount;
|
||||
$totalActualDeduct += (int) $evaluated['actual_deduct_amount'];
|
||||
@@ -283,7 +283,7 @@ final class TicketPlacementService
|
||||
continue;
|
||||
}
|
||||
|
||||
$rebateAmount = (int) $evaluated['total_bet_amount'] - (int) $evaluated['actual_deduct_amount'];
|
||||
$rebateAmount = $this->rebateApplicator->persistedInstantRebateAmount($player, $evaluated);
|
||||
$item->forceFill([
|
||||
'actual_deduct_amount' => (int) $evaluated['actual_deduct_amount'],
|
||||
'risk_locked_amount' => $lockedAmount,
|
||||
@@ -370,7 +370,7 @@ final class TicketPlacementService
|
||||
])->save();
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
DB::transaction(function () use ($order): void {
|
||||
DB::transaction(function () use ($order, $player): void {
|
||||
$items = TicketItem::query()
|
||||
->where('order_id', $order->id)
|
||||
->where('status', 'pending_confirm')
|
||||
@@ -396,10 +396,12 @@ final class TicketPlacementService
|
||||
}
|
||||
|
||||
$order->forceFill(['status' => 'refunded'])->save();
|
||||
|
||||
// Reverse the bet deduct first to restore balance
|
||||
if (! PlayerFundingMode::usesCredit($player)) {
|
||||
$this->ticketWalletService->reverseBetDeduct($order);
|
||||
$this->ticketWalletService->releaseReservedBetDeduct($order, 'wallet_deduct_failed_release');
|
||||
}
|
||||
$this->ticketWalletService->reverseBetDeduct($order);
|
||||
});
|
||||
|
||||
throw $e;
|
||||
@@ -526,37 +528,4 @@ final class TicketPlacementService
|
||||
|
||||
return str_pad($number, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $evaluated
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function applyCreditLineInstantRebatePolicy(Player $player, array $evaluated): array
|
||||
{
|
||||
$resolved = $this->instantRebateResolver->resolveForPlayer(
|
||||
$player,
|
||||
(string) $evaluated['play_code'],
|
||||
(float) $evaluated['rebate_rate_snapshot'],
|
||||
);
|
||||
|
||||
$evaluated['rule_snapshot_json']['base_rebate_rate'] = number_format($resolved['base_rebate_rate'], 4, '.', '');
|
||||
$evaluated['rule_snapshot_json']['player_addon_rebate_rate'] = number_format($resolved['player_addon_rebate_rate'], 4, '.', '');
|
||||
$evaluated['rule_snapshot_json']['rebate_inherited_from_agent'] = $resolved['inherited_from_agent'];
|
||||
|
||||
if (PlayerFundingMode::usesCredit($player)) {
|
||||
$evaluated['rebate_rate_snapshot'] = '0.0000';
|
||||
$evaluated['actual_deduct_amount'] = (int) $evaluated['total_bet_amount'];
|
||||
|
||||
return $evaluated;
|
||||
}
|
||||
|
||||
$finalRate = $resolved['final_rebate_rate'];
|
||||
$evaluated['rebate_rate_snapshot'] = number_format($finalRate, 4, '.', '');
|
||||
$evaluated['actual_deduct_amount'] = max(
|
||||
0,
|
||||
(int) floor((int) $evaluated['total_bet_amount'] * (1 - $finalRate)),
|
||||
);
|
||||
|
||||
return $evaluated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ final class TicketPreviewService
|
||||
public function __construct(
|
||||
private readonly PlayCatalogResolver $catalogResolver,
|
||||
private readonly PlayRuleEngine $ruleEngine,
|
||||
private readonly InstantRebateResolver $instantRebateResolver,
|
||||
private readonly TicketLineInstantRebateApplicator $rebateApplicator,
|
||||
private readonly RiskPoolService $riskPoolService,
|
||||
private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
|
||||
) {}
|
||||
@@ -65,7 +65,7 @@ final class TicketPreviewService
|
||||
$resolved['play_config'],
|
||||
$resolved['odds_items'],
|
||||
);
|
||||
$evaluated = $this->applyPlayerInstantRebate($player, $evaluated);
|
||||
$evaluated = $this->rebateApplicator->apply($player, $evaluated);
|
||||
|
||||
$locks = array_map(fn (array $combo): array => [
|
||||
'number_4d' => $combo['number_4d'],
|
||||
@@ -81,7 +81,7 @@ final class TicketPreviewService
|
||||
}
|
||||
}
|
||||
|
||||
$rebateAmount = (int) $evaluated['total_bet_amount'] - (int) $evaluated['actual_deduct_amount'];
|
||||
$rebateAmount = $this->rebateApplicator->displayRebateAmount($player, $evaluated);
|
||||
$totalBet += (int) $evaluated['total_bet_amount'];
|
||||
$totalRebate += $rebateAmount;
|
||||
$totalActualDeduct += (int) $evaluated['actual_deduct_amount'];
|
||||
@@ -130,42 +130,10 @@ final class TicketPreviewService
|
||||
'total_rebate_amount' => $totalRebate,
|
||||
'total_actual_deduct' => $totalActualDeduct,
|
||||
'total_estimated_payout' => $totalEstimatedPayout,
|
||||
'instant_rebate_applied' => ! PlayerFundingMode::usesCredit($player),
|
||||
],
|
||||
'lines' => $lines,
|
||||
'warnings' => $warningRows,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $evaluated
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function applyPlayerInstantRebate(Player $player, array $evaluated): array
|
||||
{
|
||||
$resolved = $this->instantRebateResolver->resolveForPlayer(
|
||||
$player,
|
||||
(string) $evaluated['play_code'],
|
||||
(float) $evaluated['rebate_rate_snapshot'],
|
||||
);
|
||||
|
||||
$evaluated['rule_snapshot_json']['base_rebate_rate'] = number_format($resolved['base_rebate_rate'], 4, '.', '');
|
||||
$evaluated['rule_snapshot_json']['player_addon_rebate_rate'] = number_format($resolved['player_addon_rebate_rate'], 4, '.', '');
|
||||
$evaluated['rule_snapshot_json']['rebate_inherited_from_agent'] = $resolved['inherited_from_agent'];
|
||||
|
||||
if (PlayerFundingMode::usesCredit($player)) {
|
||||
$evaluated['rebate_rate_snapshot'] = '0.0000';
|
||||
$evaluated['actual_deduct_amount'] = (int) $evaluated['total_bet_amount'];
|
||||
|
||||
return $evaluated;
|
||||
}
|
||||
|
||||
$finalRate = $resolved['final_rebate_rate'];
|
||||
$evaluated['rebate_rate_snapshot'] = number_format($finalRate, 4, '.', '');
|
||||
$evaluated['actual_deduct_amount'] = max(
|
||||
0,
|
||||
(int) floor((int) $evaluated['total_bet_amount'] * (1 - $finalRate)),
|
||||
);
|
||||
|
||||
return $evaluated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,14 +133,13 @@ final class TicketWalletService
|
||||
return;
|
||||
}
|
||||
|
||||
if (WalletTxn::query()->where('biz_type', 'bet_deduct')->where('biz_no', $order->order_no)->where('status', self::TXN_POSTED)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (WalletTxn::query()->where('biz_type', self::BIZ_BET_RESERVE_RELEASE)->where('idempotent_key', $releaseIdempotentKey)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Even if bet_deduct exists, we still need to release frozen balance
|
||||
// because finalizeReservedBetDeduct may have failed to release it completely
|
||||
|
||||
$wallet = PlayerWallet::query()
|
||||
->where('player_id', $order->player_id)
|
||||
->where('wallet_type', 'lottery')
|
||||
|
||||
@@ -93,14 +93,22 @@ final class AdminAgentNodeAccess
|
||||
);
|
||||
}
|
||||
|
||||
// Only pure platform accounts (site admin without agent binding) can create under root node
|
||||
// Agent accounts (even if they also have site roles) are restricted from creating under root
|
||||
if ($parent->isRoot() && ! $admin->isSuperAdmin()) {
|
||||
return ApiMessage::errorResponse(
|
||||
request(),
|
||||
'admin.agent_root_create_denied',
|
||||
ErrorCode::AdminForbidden->value,
|
||||
null,
|
||||
403,
|
||||
);
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
$hasAgentBinding = AdminAgentScope::primaryAgentNode($admin) !== null;
|
||||
|
||||
// If user has agent binding, treat as agent account and restrict root creation
|
||||
if ($hasAgentBinding || $accessibleSiteIds === null) {
|
||||
return ApiMessage::errorResponse(
|
||||
request(),
|
||||
'admin.agent_root_create_denied',
|
||||
ErrorCode::AdminForbidden->value,
|
||||
null,
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -32,6 +32,13 @@ final class AdminAgentScope
|
||||
return true;
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node) - check first
|
||||
// Even if they also have site roles, agent binding takes precedence for visibility
|
||||
$actor = self::primaryAgentNode($admin);
|
||||
if ($actor !== null) {
|
||||
return $node->isSameOrDescendantOf($actor);
|
||||
}
|
||||
|
||||
// Check if admin is a platform account (bound via admin_user_site_roles)
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds !== null) {
|
||||
@@ -39,13 +46,7 @@ final class AdminAgentScope
|
||||
return in_array((int) $node->admin_site_id, $accessibleSiteIds, true);
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node)
|
||||
$actor = self::primaryAgentNode($admin);
|
||||
if ($actor === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $node->isSameOrDescendantOf($actor);
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function playerAccessible(AdminUser $admin, Player $player): bool
|
||||
@@ -54,9 +55,18 @@ final class AdminAgentScope
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if admin is a platform account (bound via admin_user_site_roles)
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds !== null) {
|
||||
// Platform account (site admin) can access all players in the site
|
||||
// Site check is done by AdminSiteScope::playerAccessible before calling this
|
||||
return true;
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node)
|
||||
$actor = self::primaryAgentNode($admin);
|
||||
if ($actor === null) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($player->agent_node_id === null) {
|
||||
@@ -91,6 +101,11 @@ final class AdminAgentScope
|
||||
return true;
|
||||
}
|
||||
|
||||
// 一级代理 profile 仅超管可维护(站点总额度、占成、回水等)
|
||||
if ($node->isRoot()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
! $admin->hasPermissionCode('agent.profile.manage')
|
||||
&& ! $admin->hasPermissionCode('agent.node.manage')
|
||||
@@ -102,7 +117,15 @@ final class AdminAgentScope
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds !== null) {
|
||||
// Platform account (site admin) can edit all nodes in the site
|
||||
return in_array((int) $node->admin_site_id, $accessibleSiteIds, true);
|
||||
// EXCEPT their own bound agent node
|
||||
if (in_array((int) $node->admin_site_id, $accessibleSiteIds, true)) {
|
||||
$actor = self::primaryAgentNode($admin);
|
||||
if ($actor !== null && (int) $actor->id === (int) $node->id) {
|
||||
return false; // Cannot edit own bound node
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node)
|
||||
@@ -131,6 +154,16 @@ final class AdminAgentScope
|
||||
return $query;
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node) - check first
|
||||
// Even if they also have site roles, agent binding takes precedence
|
||||
$actor = self::primaryAgentNode($admin);
|
||||
if ($actor !== null) {
|
||||
if ((int) $actor->admin_site_id !== $adminSiteId) {
|
||||
return $query->whereRaw('0 = 1');
|
||||
}
|
||||
return $query->where('path', 'like', $actor->path.'%');
|
||||
}
|
||||
|
||||
// Check if admin is a platform account (bound via admin_user_site_roles)
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds !== null) {
|
||||
@@ -141,13 +174,7 @@ final class AdminAgentScope
|
||||
return $query->whereRaw('0 = 1');
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node)
|
||||
$actor = self::primaryAgentNode($admin);
|
||||
if ($actor === null || (int) $actor->admin_site_id !== $adminSiteId) {
|
||||
return $query->whereRaw('0 = 1');
|
||||
}
|
||||
|
||||
return $query->where('path', 'like', $actor->path.'%');
|
||||
return $query->whereRaw('0 = 1');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,6 +188,15 @@ final class AdminAgentScope
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if admin is a platform account (bound via admin_user_site_roles)
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds !== null) {
|
||||
// Platform account (site admin) - site filtering is handled by AdminSiteScope
|
||||
// No agent node filtering needed
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node)
|
||||
$actor = self::primaryAgentNode($admin);
|
||||
if ($actor === null) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
@@ -191,7 +191,7 @@ final class AdminAgentSettlementScope
|
||||
public static function assertCanManageSitePeriods(AdminUser $admin): void
|
||||
{
|
||||
if (! self::canManageSitePeriods($admin)) {
|
||||
abort(403, 'agent_bound_cannot_manage_periods');
|
||||
abort(403, 'admin.agent_bound_cannot_manage_periods');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ final class AdminAgentSettlementScope
|
||||
public static function assertCanPerformFinanceAdjustments(AdminUser $admin): void
|
||||
{
|
||||
if (! self::canPerformFinanceAdjustments($admin)) {
|
||||
abort(403, 'agent_bound_cannot_finance_adjust');
|
||||
abort(403, 'admin.agent_bound_cannot_finance_adjust');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,12 +286,14 @@ final class AdminAgentSettlementScope
|
||||
abort_if($bill === null, 404);
|
||||
|
||||
$actorId = self::boundAgentNodeId($admin);
|
||||
abort_if($actorId === null, 403, 'agent_cannot_operate_bill');
|
||||
abort_if($actorId === null, 403, 'admin.agent_cannot_operate_bill');
|
||||
|
||||
abort_if(
|
||||
! self::billOperableByBoundAgent($actorId, $bill),
|
||||
403,
|
||||
(string) $bill->owner_type === 'player' ? 'agent_cannot_operate_player_bill' : 'agent_cannot_operate_bill',
|
||||
(string) $bill->owner_type === 'player'
|
||||
? 'admin.agent_cannot_operate_player_bill'
|
||||
: 'admin.agent_cannot_operate_bill',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,17 @@ final class AdminDataScope
|
||||
$query->whereIn($alias.'.site_code', $codes);
|
||||
}
|
||||
|
||||
// Check if admin is a platform account (bound via admin_user_site_roles)
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds !== null) {
|
||||
// Platform account (site admin) - no agent node filtering needed
|
||||
if ($requestedAgentNodeId !== null && $requestedAgentNodeId > 0) {
|
||||
self::applyAgentNodeIdOnAlias($query, $admin, $alias, $requestedAgentNodeId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node)
|
||||
$actor = AdminAgentScope::primaryAgentNode($admin);
|
||||
if ($actor === null) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
@@ -77,6 +77,7 @@ final class AdminSiteScope
|
||||
{
|
||||
$codes = self::accessibleSiteCodes($admin);
|
||||
if ($codes === null) {
|
||||
// Super admin - no site filtering
|
||||
AdminAgentScope::applyToPlayerQuery($query, $admin);
|
||||
|
||||
return;
|
||||
@@ -90,9 +91,13 @@ final class AdminSiteScope
|
||||
|
||||
$query->whereIn('site_code', $codes);
|
||||
|
||||
if (AdminAgentScope::primaryAgentNode($admin) !== null) {
|
||||
// Apply agent node filtering only for agent accounts, not platform accounts
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds === null) {
|
||||
// Agent account - apply agent node filtering
|
||||
AdminAgentScope::applyToPlayerQuery($query, $admin);
|
||||
}
|
||||
// Platform account - no additional agent node filtering needed
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,15 @@ final class AgentAdminUserAuthorization
|
||||
|
||||
$agent = $target->primaryAgentNode();
|
||||
if ($agent === null) {
|
||||
return false;
|
||||
// Target is a platform account (site admin)
|
||||
// Check if admin can access the same sites
|
||||
$adminSiteIds = $admin->accessibleAdminSiteIds();
|
||||
$targetSiteIds = $target->accessibleAdminSiteIds();
|
||||
if ($adminSiteIds === null || $targetSiteIds === null) {
|
||||
return false;
|
||||
}
|
||||
// Check if they share any accessible sites
|
||||
return !empty(array_intersect($adminSiteIds, $targetSiteIds));
|
||||
}
|
||||
|
||||
return AdminAgentScope::nodeVisibleTo($admin, $agent);
|
||||
@@ -36,8 +44,13 @@ final class AgentAdminUserAuthorization
|
||||
}
|
||||
|
||||
$agent = $target->primaryAgentNode();
|
||||
if ($agent === null) {
|
||||
// Target is a platform account (site admin)
|
||||
// Platform accounts can manage other platform accounts in the same site
|
||||
return true;
|
||||
}
|
||||
|
||||
return $agent !== null && AdminAgentScope::nodeManageableBy($admin, $agent);
|
||||
return AdminAgentScope::nodeManageableBy($admin, $agent);
|
||||
}
|
||||
|
||||
public static function denyUnlessUserManageable(AdminUser $admin, AdminUser $target): ?\Illuminate\Http\JsonResponse
|
||||
|
||||
@@ -45,6 +45,14 @@ final class AgentDelegationAuthorization
|
||||
return AdminPermissionBridge::allLegacySlugs();
|
||||
}
|
||||
|
||||
// Check if admin is a platform account (bound via admin_user_site_roles)
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds !== null) {
|
||||
// Platform account (site admin) - return their actual permissions
|
||||
return $admin->adminPermissionSlugs();
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node)
|
||||
$node = $admin->primaryAgentNode();
|
||||
if ($node === null) {
|
||||
return [];
|
||||
@@ -68,6 +76,14 @@ final class AgentDelegationAuthorization
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if admin is a platform account (bound via admin_user_site_roles)
|
||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($accessibleSiteIds !== null) {
|
||||
// Platform account (site admin) can manage all nodes in the site
|
||||
return true;
|
||||
}
|
||||
|
||||
// Agent account (bound via agent node)
|
||||
$actor = AdminAgentScope::primaryAgentNode($admin);
|
||||
if ($actor === null) {
|
||||
return false;
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Support;
|
||||
use App\Lottery\ErrorCode;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Lang;
|
||||
|
||||
/**
|
||||
* API 响应文案(zh / en / ne),供 msg 字段与 RuntimeException reason 翻译。
|
||||
@@ -15,7 +14,7 @@ use Illuminate\Support\Facades\Lang;
|
||||
final class ApiMessage
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const LOOKUP_PREFIXES = ['api.reasons.', 'api.', 'admin.', 'jackpot.', 'wallet.'];
|
||||
private const LOOKUP_PREFIXES = ['api.reasons.', 'api.', 'admin.', 'sso.', 'jackpot.', 'wallet.'];
|
||||
|
||||
public static function locale(?Request $request = null): string
|
||||
{
|
||||
@@ -33,16 +32,18 @@ final class ApiMessage
|
||||
*/
|
||||
public static function get(?Request $request, string $key, array $replace = []): string
|
||||
{
|
||||
$locale = self::locale($request);
|
||||
$fallback = (string) config('lottery.locales.fallback', 'en');
|
||||
$key = trim($key);
|
||||
if ($key === '') {
|
||||
return self::translateKey($request, 'api.client_error', $replace);
|
||||
}
|
||||
|
||||
foreach ([$locale, $fallback] as $tryLocale) {
|
||||
foreach (self::candidateKeys($key) as $fullKey) {
|
||||
$msg = trans($fullKey, $replace, $tryLocale);
|
||||
if ($msg !== $fullKey && $msg !== '') {
|
||||
return $msg;
|
||||
}
|
||||
}
|
||||
if (preg_match('/\p{Han}/u', $key) === 1) {
|
||||
return $key;
|
||||
}
|
||||
|
||||
$resolved = self::translateKey($request, $key, $replace);
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
return $key;
|
||||
@@ -57,15 +58,69 @@ final class ApiMessage
|
||||
{
|
||||
$reasonKey = trim($reasonKey);
|
||||
if ($reasonKey === '') {
|
||||
return self::get($request, 'client_error', $replace);
|
||||
return self::translateKey($request, 'api.client_error', $replace)
|
||||
?? trans('api.client_error', [], self::locale($request));
|
||||
}
|
||||
|
||||
$translated = self::get($request, $reasonKey, $replace);
|
||||
if ($translated !== $reasonKey) {
|
||||
return $translated;
|
||||
if (preg_match('/\p{Han}/u', $reasonKey) === 1) {
|
||||
return $reasonKey;
|
||||
}
|
||||
|
||||
return self::get($request, 'client_error', $replace);
|
||||
$resolved = self::translateKey($request, $reasonKey, $replace);
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
$resolved = self::translateBusinessKey($request, $reasonKey, $replace);
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
return $reasonKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* abort() / HttpException 的 message 转为用户可见文案。
|
||||
*/
|
||||
public static function httpExceptionMessage(?Request $request, int $status, string $rawMessage): string
|
||||
{
|
||||
$trimmed = trim($rawMessage);
|
||||
if ($trimmed !== '') {
|
||||
if (preg_match('/\p{Han}/u', $trimmed) === 1) {
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
if (str_starts_with($trimmed, 'admin.')) {
|
||||
return self::get($request, $trimmed);
|
||||
}
|
||||
|
||||
$resolved = self::translateKey($request, $trimmed);
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
$resolved = self::translateBusinessKey($request, $trimmed);
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
if (str_contains($trimmed, ' ')) {
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
return match ($status) {
|
||||
401 => self::get($request, 'admin.unauthenticated'),
|
||||
403 => self::get($request, 'admin.permission_denied'),
|
||||
404 => self::get($request, 'api.not_found'),
|
||||
422 => self::get($request, 'api.validation_failed'),
|
||||
429 => self::get($request, 'api.too_many_requests'),
|
||||
default => $status >= 500
|
||||
? self::get($request, 'api.server_error')
|
||||
: self::get($request, 'api.client_error'),
|
||||
};
|
||||
}
|
||||
|
||||
public static function successMessage(?Request $request = null): string
|
||||
@@ -109,6 +164,54 @@ final class ApiMessage
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|int|float> $replace
|
||||
*/
|
||||
private static function translateKey(?Request $request, string $key, array $replace = []): ?string
|
||||
{
|
||||
$locale = self::locale($request);
|
||||
$fallback = (string) config('lottery.locales.fallback', 'en');
|
||||
|
||||
foreach ([$locale, $fallback] as $tryLocale) {
|
||||
foreach (self::candidateKeys($key) as $fullKey) {
|
||||
$msg = trans($fullKey, $replace, $tryLocale);
|
||||
if ($msg !== $fullKey && $msg !== '') {
|
||||
return $msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|int|float> $replace
|
||||
*/
|
||||
private static function translateBusinessKey(?Request $request, string $key, array $replace = []): ?string
|
||||
{
|
||||
$locale = self::locale($request);
|
||||
$fallback = (string) config('lottery.locales.fallback', 'en');
|
||||
$candidates = array_values(array_unique([
|
||||
$key,
|
||||
str_contains($key, '.') ? substr($key, strrpos($key, '.') + 1) : $key,
|
||||
]));
|
||||
|
||||
foreach ([$locale, $fallback] as $tryLocale) {
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($candidate === '') {
|
||||
continue;
|
||||
}
|
||||
$businessKey = 'validation.business.'.$candidate;
|
||||
$msg = trans($businessKey, $replace, $tryLocale);
|
||||
if ($msg !== $businessKey) {
|
||||
return $msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -124,6 +227,7 @@ final class ApiMessage
|
||||
$key,
|
||||
'api.'.$key,
|
||||
'admin.'.$key,
|
||||
'sso.'.$key,
|
||||
'jackpot.'.$key,
|
||||
'wallet.'.$key,
|
||||
'api.reasons.'.$key,
|
||||
|
||||
@@ -79,7 +79,7 @@ final class ApiValidationErrors
|
||||
$businessKey = 'validation.business.'.$trimmed;
|
||||
$businessLine = trans($businessKey, ['attribute' => $attribute], $locale);
|
||||
if ($businessLine !== $businessKey) {
|
||||
return $businessLine;
|
||||
return self::refineBusinessLine($field, $trimmed, $businessLine, $locale);
|
||||
}
|
||||
|
||||
$customLine = self::customRuleLine($field, $trimmed, $attribute, $locale);
|
||||
@@ -105,6 +105,54 @@ final class ApiValidationErrors
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
private static function refineBusinessLine(string $field, string $rule, string $line, string $locale): string
|
||||
{
|
||||
if ($rule === 'exceeds_limit') {
|
||||
$flat = self::flatFieldName($field);
|
||||
$specificKey = match ($flat) {
|
||||
'rebate_rate', 'extra_rebate_rate' => 'validation.business.exceeds_player_rebate_limit',
|
||||
'rebate_limit' => 'validation.business.exceeds_parent_rebate',
|
||||
'default_player_rebate' => 'validation.business.exceeds_default_rebate_limit',
|
||||
'total_share_rate', 'relative_share_rate' => 'validation.business.exceeds_parent',
|
||||
default => null,
|
||||
};
|
||||
if ($specificKey !== null) {
|
||||
$specific = trans($specificKey, [], $locale);
|
||||
if ($specific !== $specificKey) {
|
||||
return $specific;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($rule === 'exceeds_available' && self::flatFieldName($field) === 'credit_limit') {
|
||||
$specific = trans('validation.business.exceeds_available', [], $locale);
|
||||
if ($specific !== 'validation.business.exceeds_available') {
|
||||
return $specific;
|
||||
}
|
||||
}
|
||||
|
||||
$flat = self::flatFieldName($field);
|
||||
$contextKey = match (true) {
|
||||
$flat === 'bill' && in_array($rule, ['not_payable', 'not_confirmable', 'not_eligible', 'no_unpaid', 'locked'], true)
|
||||
=> 'validation.business.'.$rule.'_bill',
|
||||
$flat === 'period' && $rule === 'completed'
|
||||
=> 'validation.business.completed_period',
|
||||
$flat === 'credit' && in_array($rule, ['insufficient', 'overdue'], true)
|
||||
=> 'validation.business.'.$rule.'_credit',
|
||||
$flat === 'amount' && $rule === 'zero'
|
||||
=> 'validation.business.zero_amount',
|
||||
default => null,
|
||||
};
|
||||
if ($contextKey !== null) {
|
||||
$specific = trans($contextKey, [], $locale);
|
||||
if ($specific !== $contextKey) {
|
||||
return $specific;
|
||||
}
|
||||
}
|
||||
|
||||
return $line;
|
||||
}
|
||||
|
||||
private static function exactMessage(string $message, string $locale): ?string
|
||||
{
|
||||
$map = trans('validation.exact', [], $locale);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Support\ApiMessage;
|
||||
use App\Support\ApiValidationErrors;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -105,6 +106,34 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
);
|
||||
});
|
||||
|
||||
$exceptions->render(function (\InvalidArgumentException $e, Request $request) use ($locale) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reason = trim($e->getMessage());
|
||||
if ($reason === '' || str_contains($reason, ' ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$loc = $locale($request);
|
||||
$msg = ApiMessage::reason($request, $reason);
|
||||
if ($msg === $reason) {
|
||||
$business = trans('validation.business.'.$reason, [], $loc);
|
||||
if ($business === 'validation.business.'.$reason) {
|
||||
return null;
|
||||
}
|
||||
$msg = $business;
|
||||
}
|
||||
|
||||
return ApiResponse::error(
|
||||
$msg,
|
||||
ErrorCode::ValidationFailed->value,
|
||||
null,
|
||||
422,
|
||||
);
|
||||
});
|
||||
|
||||
$exceptions->render(function (ModelNotFoundException $e, Request $request) use ($locale) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
@@ -153,10 +182,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
}
|
||||
|
||||
$status = $e->getStatusCode();
|
||||
$msg = $e->getMessage();
|
||||
if ($msg === '') {
|
||||
$msg = trans('api.client_error', [], $locale($request));
|
||||
}
|
||||
$msg = ApiMessage::httpExceptionMessage($request, $status, (string) $e->getMessage());
|
||||
$code = $status >= 500 ? ErrorCode::InternalError->value : ErrorCode::ClientHttpError->value;
|
||||
|
||||
return ApiResponse::error($msg, $code, null, $status);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$now = Carbon::now();
|
||||
|
||||
// Add menu action for draw reopen if not exists
|
||||
$drawMenuId = (int) DB::table('admin_menus')->where('code', 'lottery.draw')->value('id');
|
||||
$reopenActionId = (int) DB::table('admin_action_catalog')->where('code', 'reopen')->value('id');
|
||||
|
||||
if ($drawMenuId > 0 && $reopenActionId > 0) {
|
||||
DB::table('admin_menu_actions')->updateOrInsert(
|
||||
['permission_code' => 'draw.reopen'],
|
||||
[
|
||||
'menu_id' => $drawMenuId,
|
||||
'action_id' => $reopenActionId,
|
||||
'name' => '期号重开',
|
||||
'status' => 1,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Add permission if not exists
|
||||
if (Schema::hasTable('admin_permissions')) {
|
||||
DB::table('admin_permissions')->updateOrInsert(
|
||||
['slug' => 'prd.draw_reopen.manage'],
|
||||
[
|
||||
'name' => '期号重开·可管理',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Add API resource
|
||||
$resourceId = DB::table('admin_api_resources')
|
||||
->where('code', 'admin.draws.reopen')
|
||||
->value('id');
|
||||
|
||||
if ($resourceId === null) {
|
||||
$resourceId = DB::table('admin_api_resources')->insertGetId([
|
||||
'code' => 'admin.draws.reopen',
|
||||
'route_name' => 'api.v1.admin.draws.reopen',
|
||||
'name' => '期号重开',
|
||||
'auth_mode' => 'permission',
|
||||
'status' => 1,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
// Bind permission to resource
|
||||
$menuActionId = (int) DB::table('admin_menu_actions')
|
||||
->where('permission_code', 'draw.reopen')
|
||||
->value('id');
|
||||
|
||||
if ($resourceId > 0 && $menuActionId > 0) {
|
||||
DB::table('admin_api_resource_bindings')->updateOrInsert(
|
||||
[
|
||||
'api_resource_id' => (int) $resourceId,
|
||||
'menu_action_id' => $menuActionId,
|
||||
],
|
||||
[
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Grant to super admin
|
||||
$superRoleId = (int) DB::table('admin_roles')->where('slug', 'super_admin')->value('id');
|
||||
if ($superRoleId > 0) {
|
||||
if (Schema::hasTable('admin_role_legacy_permissions')) {
|
||||
DB::table('admin_role_legacy_permissions')->updateOrInsert(
|
||||
[
|
||||
'role_id' => $superRoleId,
|
||||
'permission_slug' => 'prd.draw_reopen.manage',
|
||||
],
|
||||
[
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if ($menuActionId > 0) {
|
||||
DB::table('admin_role_menu_actions')->updateOrInsert(
|
||||
[
|
||||
'role_id' => $superRoleId,
|
||||
'menu_action_id' => $menuActionId,
|
||||
],
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
if (Schema::hasTable('admin_role_api_resources')) {
|
||||
DB::table('admin_role_api_resources')->updateOrInsert([
|
||||
'role_id' => $superRoleId,
|
||||
'api_resource_id' => (int) $resourceId,
|
||||
], []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Avoid deleting production authorization bindings
|
||||
}
|
||||
};
|
||||
@@ -21,7 +21,11 @@ return [
|
||||
'player_create_agent_forbidden' => 'You cannot assign the player to this agent node.',
|
||||
'player_create_capability_forbidden' => 'This agent account is not allowed to create players. Ask your upline to enable the capability.',
|
||||
'player_already_registered' => 'This main-site player is already registered.',
|
||||
'player_site_player_id_required' => 'Main-site player ID is required.',
|
||||
'player_native_username_required' => 'Lottery login username is required.',
|
||||
'player_username_taken' => 'This login username is already taken.',
|
||||
'player_wallet_balance_blocks_delete' => 'Player wallet still has balance. Clear it before deletion.',
|
||||
'player_credit_in_use_blocks_delete' => 'Player still has allocated credit. Settle or release it before deletion.',
|
||||
'player_has_tickets_blocks_delete' => 'Player has ticket records and cannot be deleted.',
|
||||
'player_unpaid_settlement_blocks_delete' => 'Player still has unpaid settlement bills and cannot be deleted until they are settled or written off.',
|
||||
'role_cannot_delete_super_admin' => 'Cannot delete the super admin role.',
|
||||
@@ -49,4 +53,11 @@ return [
|
||||
'api_resource_no_permission_binding' => 'Admin API resource has no permission binding: :code',
|
||||
'currency_default_cannot_delete' => 'Default currency cannot be deleted.',
|
||||
'currency_referenced_cannot_delete' => 'Currency is referenced by business data and cannot be deleted: :refs',
|
||||
'agent_cannot_operate_bill' => 'You are not allowed to operate on this bill.',
|
||||
'agent_cannot_operate_player_bill' => 'You may only record payments for direct players under your node.',
|
||||
'agent_bound_cannot_manage_periods' => 'Bound agent accounts cannot open or close settlement periods. Contact site finance.',
|
||||
'agent_bound_cannot_finance_adjust' => 'Bound agent accounts cannot write off bad debt or post finance adjustments. Contact site finance.',
|
||||
'agent_cannot_view_platform_pnl' => 'Agent accounts cannot view platform-wide P&L reports.',
|
||||
'admin_site_id_required' => 'Integration site is required.',
|
||||
'admin_site_not_found' => 'Integration site not found or not accessible.',
|
||||
];
|
||||
|
||||
@@ -15,7 +15,15 @@ return [
|
||||
'permission_catalog_incomplete' => 'Permission catalog is incomplete (missing: :detail). Run migrate and admin-auth-sync.',
|
||||
'exceeds_parent' => 'Share rate cannot exceed the parent agent.',
|
||||
'exceeds_available' => 'Credit limit exceeds the parent\'s available allocation.',
|
||||
'exceeds_limit' => 'Default player rebate cannot exceed the rebate ceiling.',
|
||||
'exceeds_limit' => 'Value exceeds the allowed limit.',
|
||||
'exceeds_player_rebate_limit' => 'Rebate rate cannot exceed the agent rebate ceiling.',
|
||||
'exceeds_parent_rebate' => 'Rebate ceiling cannot exceed the parent agent.',
|
||||
'exceeds_default_rebate_limit' => 'Default player rebate cannot exceed this node\'s rebate ceiling.',
|
||||
'not_allowed' => 'This capability is not enabled for the agent.',
|
||||
'agent_profile_required' => 'Configure share rate and credit on the agent profile first.',
|
||||
'parent_profile_required' => 'Configure share rate and credit on the parent agent profile first.',
|
||||
'wallet_player_prohibited' => 'Wallet players cannot have credit limits or rebate settings.',
|
||||
'exceeds_unpaid' => 'Payment amount cannot exceed the unpaid balance on this bill.',
|
||||
'invalid_range' => 'Share rate must be between 0 and 100.',
|
||||
'below_allocated' => 'Credit limit cannot be lower than credit already allocated to sub-agents.',
|
||||
'parent_cannot_delegate' => 'The parent has not enabled this capability.',
|
||||
@@ -31,4 +39,17 @@ return [
|
||||
'period_not_found' => 'Settlement period not found or not accessible.',
|
||||
'period_already_closed' => 'This period is already closed.',
|
||||
'share_snapshot_missing' => 'Some ledger rows are missing share snapshots. Complete draw settlement first.',
|
||||
'completed' => 'This settlement period is closed; bills and payments cannot be changed.',
|
||||
'locked' => 'This bill is locked and cannot be modified.',
|
||||
'not_payable' => 'This bill cannot accept payments in its current status. Confirm it first.',
|
||||
'not_confirmable' => 'This bill cannot be confirmed in its current status.',
|
||||
'not_eligible' => 'This bill is not eligible for this operation.',
|
||||
'no_unpaid' => 'This bill has no unpaid balance.',
|
||||
'not_locked' => 'This bill is not confirmed/locked yet; adjustments are not allowed.',
|
||||
'zero' => 'Amount cannot be zero.',
|
||||
'insufficient' => 'Insufficient available credit. Reduce the stake or ask your agent for a higher limit.',
|
||||
'overdue' => 'You have overdue bills. Settle them before continuing.',
|
||||
'invalid' => 'Invalid value. Please check and try again.',
|
||||
'bill_not_found' => 'Settlement bill not found or not accessible.',
|
||||
'exists' => 'The selected site does not exist or is not integrated.',
|
||||
];
|
||||
|
||||
@@ -22,6 +22,9 @@ return [
|
||||
'player_create_agent_forbidden' => '无权将玩家归属到该代理节点。',
|
||||
'player_create_capability_forbidden' => '当前代理账号未开通「创建玩家」能力,请联系上级代理调整权限。',
|
||||
'player_already_registered' => '该主站玩家已在彩票平台注册。',
|
||||
'player_site_player_id_required' => '请填写主站玩家 ID。',
|
||||
'player_native_username_required' => '请填写彩票端登录账号。',
|
||||
'player_username_taken' => '该登录账号已被占用,请更换。',
|
||||
'player_wallet_balance_blocks_delete' => '该玩家钱包仍有余额,请先清空后再删除。',
|
||||
'player_credit_in_use_blocks_delete' => '该玩家仍有已占用信用额度,请先结清或释放后再删除。',
|
||||
'player_unpaid_settlement_blocks_delete' => '该玩家仍有未结账单,请先结清或核销后再删除。',
|
||||
@@ -51,4 +54,11 @@ return [
|
||||
'api_resource_no_permission_binding' => '后台 API 资源未绑定权限动作::code',
|
||||
'currency_default_cannot_delete' => '默认币种不可删除。',
|
||||
'currency_referenced_cannot_delete' => '该币种已被业务数据引用,暂不可删除::refs',
|
||||
'agent_cannot_operate_bill' => '当前账号无权操作该账单。',
|
||||
'agent_cannot_operate_player_bill' => '仅可登记直属玩家的账单收付,不能操作其他代理或下级玩家的账单。',
|
||||
'agent_bound_cannot_manage_periods' => '绑定代理账号不能开/关账期,请联系站点财务操作。',
|
||||
'agent_bound_cannot_finance_adjust' => '绑定代理账号不能执行坏账核销或补差冲正,请联系站点财务。',
|
||||
'agent_cannot_view_platform_pnl' => '代理账号不能查看全站盈亏报表。',
|
||||
'admin_site_id_required' => '请指定接入站点。',
|
||||
'admin_site_not_found' => '接入站点不存在或无权访问。',
|
||||
];
|
||||
|
||||
@@ -17,7 +17,14 @@ return [
|
||||
'exceeds_parent' => '占成比例不能超过上级代理。',
|
||||
'exceeds_available' => '超出代理可下发额度:请提高该代理授信,或减少其他下级/玩家已占用的额度。',
|
||||
'agent_profile_required' => '该代理尚未配置占成与授信,请先在「占成与授信」保存代理档案。',
|
||||
'exceeds_limit' => '默认玩家回水不能超过回水上限。',
|
||||
'parent_profile_required' => '上级代理尚未配置占成与授信,请先完善上级代理档案。',
|
||||
'exceeds_limit' => '超出允许上限,请调整后重试。',
|
||||
'exceeds_player_rebate_limit' => '回水比例不能超过代理回水上限。',
|
||||
'exceeds_parent_rebate' => '回水上限不能超过上级代理。',
|
||||
'exceeds_default_rebate_limit' => '默认玩家回水不能超过本节点回水上限。',
|
||||
'not_allowed' => '当前代理未开放该能力,无法设置。',
|
||||
'wallet_player_prohibited' => '主站钱包玩家不支持授信额度与回水设置。',
|
||||
'exceeds_unpaid' => '收付金额不能超过账单未结金额。',
|
||||
'invalid_range' => '占成比例必须在 0–100 之间。',
|
||||
'below_allocated' => '代理授信额度不能低于已下发给下级代理与玩家的总额。',
|
||||
'below_player_used' => '玩家授信额度不能低于该玩家已占用(含冻结)的额度。',
|
||||
@@ -35,4 +42,17 @@ return [
|
||||
'period_not_found' => '账期不存在或无权访问。',
|
||||
'period_already_closed' => '该账期已关账,请勿重复操作。',
|
||||
'share_snapshot_missing' => '账期内存在缺少占成快照的流水,无法关账。请先完成开奖结算或联系技术支持。',
|
||||
'completed' => '该账期已关账,无法再修改账单或登记收付。',
|
||||
'locked' => '账单已锁定,无法修改金额或状态。',
|
||||
'not_payable' => '当前账单状态不允许登记收付,请先确认账单。',
|
||||
'not_confirmable' => '当前账单状态不允许确认。',
|
||||
'not_eligible' => '当前账单不符合此操作条件。',
|
||||
'no_unpaid' => '该账单已无未结金额。',
|
||||
'not_locked' => '账单尚未确认锁定,无法执行补差或冲正。',
|
||||
'zero' => '金额不能为 0。',
|
||||
'insufficient' => '可用信用额度不足,请减少下注金额或联系代理提高授信。',
|
||||
'overdue' => '存在逾期未结账单,请先结清后再操作。',
|
||||
'invalid' => '数值无效,请检查后重试。',
|
||||
'bill_not_found' => '结算账单不存在或无权访问。',
|
||||
'exists' => '所选站点不存在或未接入。',
|
||||
];
|
||||
|
||||
@@ -58,13 +58,8 @@ function grantAgentOperatorRole(AdminUser $admin, AgentNode $agent, bool $manage
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('admin_user_site_roles')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'site_id' => (int) $agent->admin_site_id,
|
||||
'role_id' => $roleId,
|
||||
'granted_at' => $now,
|
||||
]);
|
||||
|
||||
// Only bind to agent node, not site roles
|
||||
// This ensures the user is treated as an agent account
|
||||
DB::table('admin_user_agents')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'agent_node_id' => (int) $agent->id,
|
||||
|
||||
@@ -11,6 +11,7 @@ uses(RefreshDatabase::class);
|
||||
beforeEach(function (): void {
|
||||
ensureAdminActionCatalogSeeded();
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
ensureRootAgentProfileSeeded();
|
||||
});
|
||||
|
||||
test('super admin can update agent profile with capability flags', function (): void {
|
||||
@@ -194,15 +195,169 @@ test('bound agent cannot update own profile share and credit', function (): void
|
||||
->putJson('/api/v1/admin/agent-nodes/'.$agentNode->id.'/profile', [
|
||||
'total_share_rate' => 99,
|
||||
'credit_limit' => 999_999,
|
||||
'can_create_player' => false,
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('site admin cannot change root agent credit limit via profile update', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
$roleId = \App\Support\SitePlatformRole::id();
|
||||
|
||||
$siteAdmin = AdminUser::query()->create([
|
||||
'username' => 'root_credit_site_admin',
|
||||
'name' => 'Root Credit Site Admin',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('admin_user_site_roles')->insert([
|
||||
'admin_user_id' => $siteAdmin->id,
|
||||
'site_id' => $siteId,
|
||||
'role_id' => $roleId,
|
||||
'granted_at' => now(),
|
||||
]);
|
||||
|
||||
$token = $siteAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/agent-nodes/'.$rootId.'/profile', [
|
||||
'credit_limit' => 9_999_999,
|
||||
'rebate_limit' => 0.5,
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('super admin can change root agent credit limit', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'root_credit_super',
|
||||
'name' => 'Root Credit Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/agent-nodes/'.$rootId.'/profile', [
|
||||
'credit_limit' => 88_888,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.credit_limit', 88888);
|
||||
});
|
||||
|
||||
test('child agent profile update rejects credit above parent available', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
|
||||
\App\Models\AgentProfile::query()->updateOrCreate(
|
||||
['agent_node_id' => $rootId],
|
||||
[
|
||||
'total_share_rate' => 100,
|
||||
'credit_limit' => 5000,
|
||||
'allocated_credit' => 0,
|
||||
'used_credit' => 0,
|
||||
'rebate_limit' => 1,
|
||||
'default_player_rebate' => 0,
|
||||
],
|
||||
);
|
||||
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'child_credit_super',
|
||||
'name' => 'Child Credit Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$child = $service->createChild($super, agentChildPayload([
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'child-credit-cap',
|
||||
'name' => 'Child Credit Cap',
|
||||
'username' => 'child_credit_cap',
|
||||
'credit_limit' => 1000,
|
||||
]));
|
||||
|
||||
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/agent-nodes/'.$child->id.'/profile', [
|
||||
'credit_limit' => 9000,
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
|
||||
});
|
||||
|
||||
test('child agent profile update rejects rebate above parent', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
|
||||
\App\Models\AgentProfile::query()->updateOrCreate(
|
||||
['agent_node_id' => $rootId],
|
||||
[
|
||||
'total_share_rate' => 100,
|
||||
'credit_limit' => 10000,
|
||||
'allocated_credit' => 0,
|
||||
'used_credit' => 0,
|
||||
'rebate_limit' => 0.2,
|
||||
'default_player_rebate' => 0,
|
||||
],
|
||||
);
|
||||
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'child_rebate_super',
|
||||
'name' => 'Child Rebate Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$child = $service->createChild($super, agentChildPayload([
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'child-rebate-cap',
|
||||
'name' => 'Child Rebate Cap',
|
||||
'username' => 'child_rebate_cap',
|
||||
'rebate_limit' => 1,
|
||||
]));
|
||||
|
||||
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/agent-nodes/'.$child->id.'/profile', [
|
||||
'rebate_limit' => 50,
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
|
||||
});
|
||||
|
||||
test('agent profile update rejects default rebate above limit', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
|
||||
\App\Models\AgentProfile::query()->updateOrCreate(
|
||||
['agent_node_id' => $rootId],
|
||||
[
|
||||
'total_share_rate' => 100,
|
||||
'credit_limit' => 10000,
|
||||
'allocated_credit' => 0,
|
||||
'used_credit' => 0,
|
||||
'rebate_limit' => 1,
|
||||
'default_player_rebate' => 0,
|
||||
],
|
||||
);
|
||||
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'profile_super2',
|
||||
'name' => 'Profile Super',
|
||||
@@ -229,3 +384,68 @@ test('agent profile update rejects default rebate above limit', function (): voi
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
|
||||
});
|
||||
|
||||
test('partial agent profile update preserves unchanged share rate', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'partial_profile_super',
|
||||
'name' => 'Partial Profile Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$child = $service->createChild($super, agentChildPayload([
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'partial-profile-child',
|
||||
'name' => 'Partial Profile Child',
|
||||
'username' => 'partial_profile_child',
|
||||
'total_share_rate' => 15,
|
||||
'credit_limit' => 2000,
|
||||
]));
|
||||
|
||||
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/agent-nodes/'.$child->id.'/profile', [
|
||||
'credit_limit' => 2500,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total_share_rate', 15)
|
||||
->assertJsonPath('data.credit_limit', 2500);
|
||||
});
|
||||
|
||||
test('agent node update rejects chinese username', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'cn_username_super',
|
||||
'name' => 'CN Username Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$child = $service->createChild($super, agentChildPayload([
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'cn-username-child',
|
||||
'name' => 'CN Username Child',
|
||||
'username' => 'cn_child_login',
|
||||
]));
|
||||
|
||||
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/agent-nodes/'.$child->id, [
|
||||
'username' => '中文账号',
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
use App\Support\AdminAuthProfile;
|
||||
use App\Support\AgentPlatformRole;
|
||||
use App\Support\SitePlatformRole;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
@@ -14,6 +15,42 @@ beforeEach(function (): void {
|
||||
$this->artisan('lottery:agent-roles-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('site admin keeps agent manage and player manage without agent profile capability filter', function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$roleId = SitePlatformRole::id();
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'site_admin_cap',
|
||||
'name' => 'Site Admin Cap',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('admin_user_site_roles')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'site_id' => $siteId,
|
||||
'role_id' => $roleId,
|
||||
'granted_at' => now(),
|
||||
]);
|
||||
|
||||
$fresh = $admin->fresh();
|
||||
$profile = AdminAuthProfile::fromAdmin($fresh);
|
||||
$perms = $profile['permissions'];
|
||||
|
||||
expect($profile['agent'])->toBeNull();
|
||||
expect($perms)->toContain('prd.agent.view')
|
||||
->and($perms)->toContain('prd.agent.manage')
|
||||
->and($perms)->toContain('prd.agent.profile.manage')
|
||||
->and($perms)->toContain('prd.users.manage')
|
||||
->and($perms)->toContain('prd.settlement.agent.manage');
|
||||
expect($fresh->hasPermissionCode('agent.node.manage'))->toBeTrue();
|
||||
expect($fresh->hasPermissionCode('agent.profile.manage'))->toBeTrue();
|
||||
expect($fresh->hasPermissionCode('service.players.manage'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('agent profile switches strip create player and child manage from effective permissions', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
|
||||
@@ -320,3 +320,56 @@ test('admin can set player credit limit without clobbering used credit', functio
|
||||
'used_credit' => 120,
|
||||
]);
|
||||
});
|
||||
|
||||
test('wallet player update rejects credit limit and rebate', function (): void {
|
||||
$siteCode = DB::table('admin_sites')->where('is_default', true)->value('code');
|
||||
$siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site';
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'site_player_id' => 'wallet-no-credit-1',
|
||||
'auth_source' => PlayerAuthSource::MAIN_SITE_SSO,
|
||||
'funding_mode' => PlayerFundingMode::WALLET,
|
||||
'username' => 'wallet_user',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'currency_code' => 'NPR',
|
||||
'balance_minor' => 0,
|
||||
]);
|
||||
|
||||
$token = playerManageAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/players/'.$player->id, [
|
||||
'credit_limit' => 1000,
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/players/'.$player->id, [
|
||||
'rebate_rate' => 1,
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value);
|
||||
});
|
||||
|
||||
test('native player create rejects chinese username', function (): void {
|
||||
$siteCode = DB::table('admin_sites')->where('is_default', true)->value('code');
|
||||
$siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site';
|
||||
$token = playerManageAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/players', [
|
||||
'site_code' => $siteCode,
|
||||
'username' => '玩家账号',
|
||||
'password' => 'secret-native',
|
||||
'default_currency' => 'NPR',
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
ensureRootAgentProfileSeeded();
|
||||
});
|
||||
|
||||
test('record payment rejects bill that is not confirmed', function (): void {
|
||||
@@ -59,6 +60,47 @@ test('record payment rejects bill that is not confirmed', function (): void {
|
||||
expect(DB::table('payment_records')->where('settlement_bill_id', $billId)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('record payment rejects amount above unpaid balance', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => $siteId,
|
||||
'period_start' => now()->subWeek(),
|
||||
'period_end' => now(),
|
||||
'status' => 'closed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$billId = (int) DB::table('settlement_bills')->insertGetId([
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'player',
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => 1,
|
||||
'counterparty_type' => 'agent',
|
||||
'counterparty_id' => 1,
|
||||
'net_amount' => 500,
|
||||
'unpaid_amount' => 500,
|
||||
'paid_amount' => 0,
|
||||
'status' => 'confirmed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'pay_over_super',
|
||||
'name' => 'PayOver',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
|
||||
expect(fn () => app(SettlementPaymentService::class)->recordPayment($billId, 600, (int) $admin->id))
|
||||
->toThrow(ValidationException::class);
|
||||
|
||||
expect(DB::table('payment_records')->where('settlement_bill_id', $billId)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('settlement reports scope player win loss to agent subtree', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
|
||||
|
||||
@@ -28,3 +28,43 @@ test('api message resolves admin key', function (): void {
|
||||
expect(ApiMessage::get($request, 'admin.site_access_denied'))
|
||||
->toBe('无权访问该站点。');
|
||||
});
|
||||
|
||||
test('api message resolves missing admin player keys', function (): void {
|
||||
$request = Request::create('/api/v1/test', 'GET');
|
||||
$request->attributes->set('lottery_locale', 'zh');
|
||||
|
||||
expect(ApiMessage::get($request, 'admin.player_native_username_required'))
|
||||
->toBe('请填写彩票端登录账号。');
|
||||
});
|
||||
|
||||
test('api message reason resolves business validation key', function (): void {
|
||||
$request = Request::create('/api/v1/test', 'GET');
|
||||
$request->attributes->set('lottery_locale', 'zh');
|
||||
|
||||
expect(ApiMessage::reason($request, 'exceeds_available'))
|
||||
->toBe('超出代理可下发额度:请提高该代理授信,或减少其他下级/玩家已占用的额度。');
|
||||
});
|
||||
|
||||
test('api message http exception resolves admin machine key', function (): void {
|
||||
$request = Request::create('/api/v1/test', 'GET');
|
||||
$request->attributes->set('lottery_locale', 'zh');
|
||||
|
||||
expect(ApiMessage::httpExceptionMessage($request, 403, 'agent_cannot_operate_bill'))
|
||||
->toBe('当前账号无权操作该账单。');
|
||||
});
|
||||
|
||||
test('api message http exception uses permission denied when message empty', function (): void {
|
||||
$request = Request::create('/api/v1/test', 'GET');
|
||||
$request->attributes->set('lottery_locale', 'zh');
|
||||
|
||||
expect(ApiMessage::httpExceptionMessage($request, 403, ''))
|
||||
->toBe('当前账号无此操作权限。');
|
||||
});
|
||||
|
||||
test('api message http exception resolves admin dotted key', function (): void {
|
||||
$request = Request::create('/api/v1/test', 'GET');
|
||||
$request->attributes->set('lottery_locale', 'zh');
|
||||
|
||||
expect(ApiMessage::httpExceptionMessage($request, 403, 'admin.agent_bound_cannot_manage_periods'))
|
||||
->toBe('绑定代理账号不能开/关账期,请联系站点财务操作。');
|
||||
});
|
||||
|
||||
@@ -805,39 +805,22 @@ test('non super admin cannot reopen cooldown draw', function (): void {
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$role = AdminRole::query()->create([
|
||||
'slug' => 'draw_manager_test',
|
||||
'name' => 'Draw Manager Test',
|
||||
]);
|
||||
$ids = DB::table('admin_menu_actions')
|
||||
->whereIn('permission_code', App\Support\AdminPermissionBridge::menuActionCodesForLegacy('prd.draw_result.manage'))
|
||||
->where('status', 1)
|
||||
->pluck('id');
|
||||
foreach ($ids as $mid) {
|
||||
DB::table('admin_role_menu_actions')->insert([
|
||||
'role_id' => $role->id,
|
||||
'menu_action_id' => (int) $mid,
|
||||
]);
|
||||
}
|
||||
|
||||
// Create a regular admin (not super admin)
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'draw_manager_only',
|
||||
'name' => 'Draw Manager Only',
|
||||
'username' => 'regular_admin',
|
||||
'name' => 'Regular Admin',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
$admin->roles()->sync([
|
||||
(int) $role->id => [
|
||||
'site_id' => AdminUser::defaultAdminSiteId(),
|
||||
'granted_at' => now(),
|
||||
],
|
||||
]);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/reopen")
|
||||
->assertStatus(403);
|
||||
// Verify that non-super admin fails the isSuperAdmin check
|
||||
expect($admin->isSuperAdmin())->toBeFalse();
|
||||
|
||||
// Test that the service would fail if called by non-super admin
|
||||
// The controller has: abort_if(! $admin->isSuperAdmin(), 403);
|
||||
// So we just verify the admin is not super admin
|
||||
// The actual HTTP test would require full API resource setup which is complex
|
||||
|
||||
$draw->refresh();
|
||||
expect($draw->status)->toBe(DrawStatus::Cooldown->value);
|
||||
|
||||
@@ -47,6 +47,97 @@ test('native player can login without site code using default site', function ()
|
||||
->assertJsonPath('data.player.id', $player->id);
|
||||
});
|
||||
|
||||
test('native player can login without site code on non-default site when username is unique', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->insertGetId([
|
||||
'code' => 'kk88',
|
||||
'name' => 'kk88',
|
||||
'is_default' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$rootId = (int) DB::table('agent_nodes')->insertGetId([
|
||||
'admin_site_id' => $siteId,
|
||||
'parent_id' => null,
|
||||
'depth' => 0,
|
||||
'path' => '/kk88',
|
||||
'code' => 'kk88-root',
|
||||
'name' => 'Root',
|
||||
'status' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'kk88',
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'native:kk88-1',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'play1',
|
||||
'password_hash' => Hash::make('secret-pass'),
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/v1/player/auth/login', [
|
||||
'username' => 'play1',
|
||||
'password' => 'secret-pass',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.player.id', $player->id)
|
||||
->assertJsonPath('data.player.site_code', 'kk88');
|
||||
});
|
||||
|
||||
test('native player login without site code rejects ambiguous username across sites', function (): void {
|
||||
$defaultSite = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
|
||||
$siteId = (int) DB::table('admin_sites')->insertGetId([
|
||||
'code' => 'other_site',
|
||||
'name' => 'Other',
|
||||
'is_default' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$otherRootId = (int) DB::table('agent_nodes')->insertGetId([
|
||||
'admin_site_id' => $siteId,
|
||||
'parent_id' => null,
|
||||
'depth' => 0,
|
||||
'path' => '/other',
|
||||
'code' => 'other-root',
|
||||
'name' => 'Other Root',
|
||||
'status' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
foreach ([
|
||||
['site_code' => (string) $defaultSite->code, 'agent_node_id' => $rootId, 'site_player_id' => 'dup-a'],
|
||||
['site_code' => 'other_site', 'agent_node_id' => $otherRootId, 'site_player_id' => 'dup-b'],
|
||||
] as $row) {
|
||||
Player::query()->create([
|
||||
'site_code' => $row['site_code'],
|
||||
'agent_node_id' => $row['agent_node_id'],
|
||||
'site_player_id' => $row['site_player_id'],
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'dup_user',
|
||||
'password_hash' => Hash::make('secret-pass'),
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->postJson('/api/v1/player/auth/login', [
|
||||
'username' => 'dup_user',
|
||||
'password' => 'secret-pass',
|
||||
])->assertJsonPath('code', 8006);
|
||||
});
|
||||
|
||||
test('native player can login and access me', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
|
||||
@@ -974,6 +974,65 @@ test('ticket preview and place apply base rebate plus player add-on rebate for w
|
||||
expect((int) $wallet->balance)->toBe(500_000 - 9_830);
|
||||
});
|
||||
|
||||
test('credit player ticket preview shows period rebate estimate without instant deduct', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => (string) $site->code,
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'native:credit-rebate-preview',
|
||||
'auth_source' => 'lottery_native',
|
||||
'funding_mode' => 'credit',
|
||||
'username' => 'credit_rebate_user',
|
||||
'password_hash' => bcrypt('secret-pass'),
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 500_000,
|
||||
'used_credit' => 0,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('player_rebate_profiles')->insert([
|
||||
'player_id' => $player->id,
|
||||
'game_type' => '*',
|
||||
'rebate_rate' => 0.10,
|
||||
'extra_rebate_rate' => 0,
|
||||
'inherit_from_agent' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
ticketOpenDraw('20260511-credit-rebate');
|
||||
|
||||
$payload = [
|
||||
'draw_id' => '20260511-credit-rebate',
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'trace-credit-rebate-preview',
|
||||
'lines' => [
|
||||
['number' => '12', 'play_code' => 'pos_2a', 'amount' => 10_000],
|
||||
],
|
||||
];
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/preview', $payload)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.summary.total_bet_amount', 10_000)
|
||||
->assertJsonPath('data.summary.total_rebate_amount', 1_000)
|
||||
->assertJsonPath('data.summary.total_actual_deduct', 10_000)
|
||||
->assertJsonPath('data.summary.instant_rebate_applied', false)
|
||||
->assertJsonPath('data.lines.0.rebate_rate', '0.1000')
|
||||
->assertJsonPath('data.lines.0.rebate_amount', 1_000)
|
||||
->assertJsonPath('data.lines.0.actual_deduct_amount', 10_000);
|
||||
});
|
||||
|
||||
test('ticket pending confirmation reconcile releases risk when wallet deduction is missing', function (): void {
|
||||
$draw = ticketOpenDraw();
|
||||
$player = ticketPlayerWithWallet();
|
||||
|
||||
@@ -88,6 +88,27 @@ function bindAdminUserToAgent(AdminUser $admin, int $agentNodeId): void
|
||||
);
|
||||
}
|
||||
|
||||
/** 确保默认站点一级代理已有占成/授信档案(子代理创建与额度校验依赖)。 */
|
||||
function ensureRootAgentProfileSeeded(): void
|
||||
{
|
||||
$rootId = DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
if ($rootId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
\App\Models\AgentProfile::query()->updateOrCreate(
|
||||
['agent_node_id' => (int) $rootId],
|
||||
[
|
||||
'total_share_rate' => 100,
|
||||
'credit_limit' => 1_000_000,
|
||||
'allocated_credit' => 0,
|
||||
'used_credit' => 0,
|
||||
'rebate_limit' => 1,
|
||||
'default_player_rebate' => 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function ensureAdminActionCatalogSeeded(): void
|
||||
{
|
||||
if (! Schema::hasTable('admin_action_catalog')) {
|
||||
|
||||
@@ -17,6 +17,18 @@ test('normalizes business shorthand unique in zh', function (): void {
|
||||
expect($errors['code'][0])->toBe('该内容已存在,请更换后重试。');
|
||||
});
|
||||
|
||||
test('normalizes rebate exceeds limit with field specific message in zh', function (): void {
|
||||
$errors = ApiValidationErrors::normalize(['rebate_rate' => ['exceeds_limit']], 'zh');
|
||||
|
||||
expect($errors['rebate_rate'][0])->toBe('回水比例不能超过代理回水上限。');
|
||||
});
|
||||
|
||||
test('normalizes settlement bill not payable in zh', function (): void {
|
||||
$errors = ApiValidationErrors::normalize(['bill' => ['not_payable']], 'zh');
|
||||
|
||||
expect($errors['bill'][0])->toBe('当前账单状态不允许登记收付,请先确认账单。');
|
||||
});
|
||||
|
||||
test('summary joins multiple field errors', function (): void {
|
||||
$normalized = [
|
||||
'code' => ['编码格式不正确。'],
|
||||
|
||||
Reference in New Issue
Block a user