From e65c3474bdca6cd7ea0829945e379c6f87920ce3 Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Sat, 30 May 2026 11:09:54 +0800 Subject: [PATCH] =?UTF-8?q?1.=E4=BF=AE=E6=94=B9=E7=94=B5=E8=AF=9D=E5=8F=B7?= =?UTF-8?q?=E7=A0=81=E6=A0=BC=E5=BC=8F=E4=B8=BA60=E5=89=8D=E7=BC=80?= =?UTF-8?q?=EF=BC=8C=E9=A9=AC=E6=9D=A5=E8=A5=BF=E4=BA=9A=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=202.=E4=BC=98=E5=8C=96=E6=B8=A0=E9=81=93=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E5=88=86=E7=BA=A2=E6=96=B9=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E6=9F=A5=E7=9C=8B=E6=B8=B8=E7=8E=A9=E8=AF=A6?= =?UTF-8?q?=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/controller/Channel.php | 540 +++++++++++++- app/admin/controller/Dashboard.php | 2 +- app/admin/controller/game/PlayRecord.php | 6 +- .../controller/operation/UserNoticeRead.php | 2 +- .../controller/order/AdminWithdrawOrder.php | 56 +- app/admin/controller/order/BetOrder.php | 2 +- app/admin/controller/order/DepositOrder.php | 4 +- app/admin/controller/order/WithdrawOrder.php | 4 +- .../controller/user/UserWalletRecord.php | 2 +- app/admin/lang/zh-cn/dashboard.php | 2 +- app/api/controller/Account.php | 3 +- app/api/controller/Auth.php | 3 +- app/api/controller/User.php | 3 +- app/api/lang/en.php | 2 +- app/api/lang/zh-cn.php | 2 +- app/common/controller/Backend.php | 48 +- app/common/lang/en/admin_rule_title.php | 5 + app/common/lang/en/service.php | 1 + app/common/lang/zh-cn/admin_rule_title.php | 4 + app/common/lang/zh-cn/service.php | 1 + app/common/library/Auth.php | 14 +- app/common/library/MalaysiaMobilePhone.php | 29 + .../service/AdminChannelScopeService.php | 123 ++++ docs/36字花-数据库与实施计划.md | 7 +- docs/36字花-移动端接口设计草案.md | 2 +- docs/en/channel-admin-guide.md | 88 +++ docs/en/commission-share-guide.md | 10 +- docs/分红说明文档.md | 10 +- docs/后端.md | 10 +- docs/渠道管理后台说明.md | 158 ++++ support/bootstrap/ValidateInit.php | 4 + web/src/lang/backend/en/channel.ts | 27 + web/src/lang/backend/zh-cn/channel.ts | 27 + web/src/lang/common/en/validate.ts | 2 +- web/src/lang/common/zh-cn/validate.ts | 2 +- web/src/utils/validate.ts | 7 +- web/src/views/backend/channel/index.vue | 673 +++++++++++++++++- 37 files changed, 1772 insertions(+), 113 deletions(-) create mode 100644 app/common/library/MalaysiaMobilePhone.php create mode 100644 app/common/service/AdminChannelScopeService.php create mode 100644 docs/en/channel-admin-guide.md create mode 100644 docs/渠道管理后台说明.md diff --git a/app/admin/controller/Channel.php b/app/admin/controller/Channel.php index 250c624..5d44cb7 100644 --- a/app/admin/controller/Channel.php +++ b/app/admin/controller/Channel.php @@ -4,6 +4,7 @@ namespace app\admin\controller; use Throwable; use app\common\controller\Backend; +use app\common\service\AdminChannelScopeService; use app\common\service\ChannelSettlementService; use support\think\Db; use support\Response; @@ -17,7 +18,16 @@ class Channel extends Backend /** * 预览接口与手动结算共用「手动结算」按钮权限(避免额外菜单节点) */ - protected array $noNeedPermission = ['manualSettlePreview', 'channelAdminShareList', 'saveChannelAdminShare', 'batchSettlePending', 'settleStats']; + protected array $noNeedPermission = [ + 'manualSettlePreview', + 'channelAdminShareList', + 'saveChannelAdminShare', + 'batchSettlePending', + 'settleStats', + 'dividendRecordList', + 'directBetRecordList', + 'settlementBetRecordList', + ]; /** * Channel模型对象 @@ -34,15 +44,40 @@ class Channel extends Backend protected bool $modelSceneValidate = true; - private array $currentChannelIds = []; + /** @var array 当前管理员绑定的渠道(写操作范围) */ + private array $ownChannelIds = []; protected function initController(WebmanRequest $request): ?Response { $this->model = new \app\common\model\Channel(); - $this->currentChannelIds = $this->getCurrentChannelIds(); + $this->ownChannelIds = $this->resolveOwnChannelIds(); return null; } + /** + * 列表/统计按可读渠道收窄;null 表示不限制 + * + * @param array> $where + */ + private function appendReadableChannelWhere(array &$where, array $alias, string $tableKey = 'channel'): void + { + $scope = $this->readableChannelIds(); + if ($scope !== null) { + $where[] = [$alias[$tableKey] . '.id', 'in', $scope]; + } + } + + /** + * @param \think\db\BaseQuery|\think\db\Query $query + */ + private function applyReadableChannelScope($query, string $column): void + { + $scope = $this->readableChannelIds(); + if ($scope !== null) { + $query->where($column, 'in', $scope); + } + } + /** * 渠道-管理员树(父级=渠道,子级=管理员,仅可选择子级) */ @@ -54,9 +89,7 @@ class Channel extends Backend $query = Db::name('channel') ->field(['id', 'name']) ->order('id', 'asc'); - if (!$this->auth->isSuperAdmin()) { - $query = $query->where('id', 'in', $this->currentChannelIds ?: [0]); - } + $this->applyReadableChannelScope($query, 'id'); $channels = $query->select()->toArray(); $tree = []; @@ -161,7 +194,7 @@ class Channel extends Backend if (!$row) { return $this->error(__('Record not found')); } - if (!$this->auth->isSuperAdmin() && !in_array($row['id'], $this->currentChannelIds, true)) { + if (!$this->assertChannelVisible((int) $row['id'])) { return $this->error(__('You have no permission')); } @@ -171,6 +204,9 @@ class Channel extends Backend } if ($this->request && $this->request->method() === 'POST') { + if (!$this->assertChannelWritable((int) $row['id'])) { + return $this->error(__('You have no permission')); + } $data = $this->request->post(); if (!$data) { return $this->error(__('Parameter %s can not be empty', [''])); @@ -220,6 +256,21 @@ class Channel extends Backend ]); } + /** + * 删除:仅允许删除本人绑定渠道(查看所有渠道不扩大写权限) + */ + protected function _del(): Response + { + $ids = $this->request ? ($this->request->post('ids') ?? $this->request->get('ids') ?? []) : []; + $ids = is_array($ids) ? $ids : []; + foreach ($ids as $id) { + if (!$this->assertChannelWritable((int) $id)) { + return $this->error(__('You have no permission')); + } + } + return parent::_del(); + } + /** * 查看 * @throws Throwable @@ -231,9 +282,7 @@ class Channel extends Backend } list($where, $alias, $limit, $order) = $this->queryBuilder(); - if (!$this->auth->isSuperAdmin()) { - $where[] = [$alias['channel'] . '.id', 'in', $this->currentChannelIds ?: [0]]; - } + $this->appendReadableChannelWhere($where, $alias); $res = $this->model ->alias($alias) ->where($where) @@ -269,12 +318,18 @@ class Channel extends Backend ->where('status', 2) ->group('channel_id') ->column('SUM(total_amount - win_amount - jackpot_extra_amount) AS p', 'channel_id'); + $directBetMap = Db::name('game_play_record') + ->where('channel_id', 'in', $channelIds) + ->group('channel_id') + ->column('SUM(total_amount) AS s', 'channel_id'); foreach ($items as $k => $item) { $cid = intval($item['id'] ?? 0); if ($cid <= 0) { continue; } $items[$k]['user_count'] = intval($userCountMap[$cid] ?? 0); + $directBet = strval($directBetMap[$cid] ?? '0.00'); + $items[$k]['direct_bet_amount'] = bcadd($directBet, '0', 2); $profit = strval($profitMap[$cid] ?? '0.00'); $items[$k]['profit_amount'] = bcadd($profit, '0', 2); if (!isset($items[$k]['total_profit_amount']) || $items[$k]['total_profit_amount'] === null || $items[$k]['total_profit_amount'] === '') { @@ -302,7 +357,7 @@ class Channel extends Backend if ($response !== null) { return $response; } - if (!$this->auth->check('channel/manualSettle')) { + if (!$this->canManualSettle()) { return $this->error(__('You have no permission')); } @@ -314,7 +369,7 @@ class Channel extends Backend if (!$row) { return $this->error(__('Record not found')); } - if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) { + if (!$this->assertChannelVisible((int) $row['id'])) { return $this->error(__('You have no permission')); } @@ -346,7 +401,7 @@ class Channel extends Backend if (!$row) { return $this->error(__('Record not found')); } - if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) { + if (!$this->assertChannelVisible((int) $row['id'])) { return $this->error(__('You have no permission')); } @@ -537,7 +592,7 @@ class Channel extends Backend if (!$row) { return $this->error(__('Record not found')); } - if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) { + if (!$this->assertChannelWritable((int) $row['id'])) { return $this->error(__('You have no permission')); } $rowsRaw = $request->post('list', []); @@ -613,8 +668,8 @@ class Channel extends Backend if ($response !== null) { return $response; } - if (!$this->auth->isSuperAdmin()) { - return $this->error(__('Only super admin can settle; after settlement, commissions will be automatically paid to admin wallets')); + if (!$this->canManualSettle()) { + return $this->error(__('You have no permission')); } $id = (int) ($request->post('id', $request->get('id', 0))); @@ -625,7 +680,7 @@ class Channel extends Backend if (!$row) { return $this->error(__('Record not found')); } - if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) { + if (!$this->assertChannelVisible((int) $row['id'])) { return $this->error(__('You have no permission')); } @@ -635,7 +690,7 @@ class Channel extends Backend if (($res['ok'] ?? false) !== true) { return $this->error((string) ($res['msg'] ?? __('Settlement failed'))); } - return $this->success(__('Super admin settlement completed; paid automatically by share ratios')); + return $this->success(__('Settlement completed; commissions paid automatically by share ratios')); } /** @@ -665,9 +720,7 @@ class Channel extends Backend return $response; } $query = Db::name('channel'); - if (!$this->auth->isSuperAdmin()) { - $query->where('id', 'in', $this->currentChannelIds ?: [0]); - } + $this->applyReadableChannelScope($query, 'id'); $rows = $query->field(['id', 'status', 'carryover_balance'])->select()->toArray(); $total = count($rows); $enabled = 0; @@ -675,7 +728,12 @@ class Channel extends Backend $carryoverPositiveCount = 0; $carryoverTotal = '0.00'; $carryoverPositiveTotal = '0.00'; + $channelIdList = []; foreach ($rows as $row) { + $cid = intval($row['id'] ?? 0); + if ($cid > 0) { + $channelIdList[] = $cid; + } $status = intval($row['status'] ?? 0); if ($status === 1) { $enabled++; @@ -689,6 +747,15 @@ class Channel extends Backend $carryoverPositiveTotal = bcadd($carryoverPositiveTotal, $carry, 2); } } + $paidDividendTotal = '0.00'; + if ($channelIdList !== []) { + $paidRow = Db::name('agent_commission_record') + ->where('channel_id', 'in', $channelIdList) + ->where('status', 1) + ->field('SUM(commission_amount) AS s') + ->find(); + $paidDividendTotal = bcadd(strval(is_array($paidRow) ? ($paidRow['s'] ?? '0') : '0'), '0', 2); + } return $this->success('', [ 'channel_total' => $total, 'enabled_count' => $enabled, @@ -696,9 +763,418 @@ class Channel extends Backend 'carryover_positive_count' => $carryoverPositiveCount, 'carryover_total' => $carryoverTotal, 'carryover_positive_total' => $carryoverPositiveTotal, + 'paid_dividend_total' => $paidDividendTotal, ]); } + /** + * 已分红记录列表(顶部统计卡片点击) + */ + public function dividendRecordList(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if (!$this->auth->check('channel/viewDividendRecords')) { + return $this->error(__('You have no permission')); + } + + [$page, $limit] = $this->parseListPageParams($request); + $channelId = (int) ($request->get('channel_id', 0)); + + $query = Db::name('agent_commission_record')->alias('acr') + ->leftJoin('agent_settlement_period asp', 'acr.settlement_period_id = asp.id') + ->leftJoin('channel c', 'acr.channel_id = c.id') + ->leftJoin('admin a', 'acr.admin_id = a.id') + ->where('acr.status', 1); + $this->applyReadableChannelScope($query, 'acr.channel_id'); + if ($channelId > 0) { + if (!$this->assertChannelAccessible($channelId)) { + return $this->error(__('You have no permission')); + } + $query->where('acr.channel_id', $channelId); + } + + $total = (int) $query->count('acr.id'); + $list = $query + ->field([ + 'acr.id', + 'acr.channel_id', + 'acr.admin_id', + 'acr.commission_amount', + 'acr.settled_at', + 'acr.remark', + 'asp.settlement_no', + 'asp.period_start_at', + 'asp.period_end_at', + 'c.name as channel_name', + 'a.username as admin_username', + ]) + ->order('acr.settled_at', 'desc') + ->order('acr.id', 'desc') + ->page($page, $limit) + ->select() + ->toArray(); + + foreach ($list as $idx => $row) { + if (!is_array($row)) { + continue; + } + $startTs = intval($row['period_start_at'] ?? 0); + $endTs = intval($row['period_end_at'] ?? 0); + $list[$idx]['period_start_at'] = $startTs > 0 ? date('Y-m-d H:i:s', $startTs) : ''; + $list[$idx]['period_end_at'] = $endTs > 0 ? date('Y-m-d H:i:s', $endTs) : ''; + $settledTs = intval($row['settled_at'] ?? 0); + $list[$idx]['settled_at'] = $settledTs > 0 ? date('Y-m-d H:i:s', $settledTs) : ''; + $list[$idx]['commission_amount'] = bcadd(strval($row['commission_amount'] ?? '0'), '0', 2); + } + + return $this->success('', [ + 'list' => $list, + 'total' => $total, + ]); + } + + /** + * 渠道直属玩家下注记录(直属投注额列点击) + */ + public function directBetRecordList(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if (!$this->auth->check('channel/viewDirectBetRecords')) { + return $this->error(__('You have no permission')); + } + + $channelId = (int) ($request->get('channel_id', 0)); + if ($channelId <= 0 || !$this->assertChannelAccessible($channelId)) { + return $this->error(__('You have no permission')); + } + + return $this->success('', $this->fetchChannelPlayRecordListPayload($request, $channelId, false)); + } + + /** + * 参与分红口径的下注记录(操作列「查看总投注金额」) + */ + public function settlementBetRecordList(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if (!$this->auth->check('channel/viewSettlementBetRecords')) { + return $this->error(__('You have no permission')); + } + + $channelId = (int) ($request->get('channel_id', 0)); + if ($channelId <= 0 || !$this->assertChannelAccessible($channelId)) { + return $this->error(__('You have no permission')); + } + + return $this->success('', $this->fetchChannelPlayRecordListPayload($request, $channelId, true)); + } + + /** + * @return array{list: array>, total: int, summary: array{record_count:int,total_bet_amount:string,total_win_amount:string}} + */ + private function fetchChannelPlayRecordListPayload(WebmanRequest $request, int $channelId, bool $settledOnly): array + { + [$page, $limit] = $this->parseListPageParams($request); + $filters = $this->parsePlayRecordListFilters($request); + + $listQuery = $this->buildChannelPlayRecordQuery($channelId, $settledOnly); + $this->applyPlayRecordListFilters($listQuery, $filters); + $total = (int) $listQuery->count('pr.id'); + $list = $listQuery + ->field($this->channelPlayRecordListFields()) + ->order('pr.id', 'desc') + ->page($page, $limit) + ->select() + ->toArray(); + + $summaryQuery = $this->buildChannelPlayRecordQuery($channelId, $settledOnly); + $this->applyPlayRecordListFilters($summaryQuery, $filters); + $summary = $this->summarizePlayRecordQuery($summaryQuery); + + return [ + 'list' => $this->normalizePlayRecordListRows($list), + 'total' => $total, + 'summary' => $summary, + ]; + } + + /** + * @return array{period_no: string, user_keyword: string, result_number: string, pick_number: string, win_hit: string} + */ + private function parsePlayRecordListFilters(WebmanRequest $request): array + { + $winHit = trim((string) $request->get('win_hit', '')); + if (!in_array($winHit, ['won', 'lost', 'pending'], true)) { + $winHit = ''; + } + + return [ + 'period_no' => trim((string) $request->get('period_no', '')), + 'user_keyword' => trim((string) $request->get('user_keyword', '')), + 'result_number' => trim((string) $request->get('result_number', '')), + 'pick_number' => trim((string) $request->get('pick_number', '')), + 'win_hit' => $winHit, + ]; + } + + /** + * @param \think\db\Query $query + * @param array{period_no: string, user_keyword: string, result_number: string, pick_number: string, win_hit: string} $filters + */ + private function applyPlayRecordListFilters($query, array $filters): void + { + if ($filters['period_no'] !== '') { + $like = '%' . $this->escapeLikeKeyword($filters['period_no']) . '%'; + $query->where(function ($sub) use ($like) { + $sub->where('pr.period_no', 'like', $like)->whereOr('gr.period_no', 'like', $like); + }); + } + if ($filters['user_keyword'] !== '') { + $like = '%' . $this->escapeLikeKeyword($filters['user_keyword']) . '%'; + $query->where(function ($sub) use ($like) { + $sub->where('u.username', 'like', $like)->whereOr('u.phone', 'like', $like); + }); + } + if ($filters['result_number'] !== '') { + $query->where('gr.result_number', $filters['result_number']); + } + if ($filters['pick_number'] !== '') { + $like = '%' . $this->escapeLikeKeyword($filters['pick_number']) . '%'; + $query->where('pr.pick_numbers', 'like', $like); + } + if ($filters['win_hit'] === 'won') { + $query->where('pr.status', 2) + ->whereRaw('(pr.win_amount + IFNULL(pr.jackpot_extra_amount, 0)) > 0'); + } elseif ($filters['win_hit'] === 'lost') { + $query->where('pr.status', 2) + ->whereRaw('(pr.win_amount + IFNULL(pr.jackpot_extra_amount, 0)) <= 0'); + } elseif ($filters['win_hit'] === 'pending') { + $query->where('pr.status', '<>', 2); + } + } + + private function escapeLikeKeyword(string $keyword): string + { + return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $keyword); + } + + /** + * @return array{0:int,1:int} + */ + private function parseListPageParams(WebmanRequest $request): array + { + $pageRaw = $request->get('page', 1); + $page = is_numeric((string) $pageRaw) ? (int) $pageRaw : 1; + if ($page < 1) { + $page = 1; + } + $limitRaw = $request->get('limit', 20); + $limit = is_numeric((string) $limitRaw) ? (int) $limitRaw : 20; + if ($limit < 1) { + $limit = 1; + } + if ($limit > 200) { + $limit = 200; + } + return [$page, $limit]; + } + + private function assertChannelVisible(int $channelId): bool + { + if ($channelId <= 0) { + return false; + } + $scope = $this->readableChannelIds(); + if ($scope === null) { + return Db::name('channel')->where('id', $channelId)->value('id') !== null; + } + + return in_array($channelId, $scope, true); + } + + private function assertChannelWritable(int $channelId): bool + { + if ($channelId <= 0) { + return false; + } + if ($this->auth->isSuperAdmin()) { + return Db::name('channel')->where('id', $channelId)->value('id') !== null; + } + return in_array($channelId, $this->ownChannelIds, true); + } + + private function assertChannelAccessible(int $channelId): bool + { + return $this->assertChannelVisible($channelId); + } + + /** + * @return \think\db\Query + */ + private function canManualSettle(): bool + { + if ($this->auth === null) { + return false; + } + if ($this->auth->isSuperAdmin()) { + return true; + } + + return $this->auth->check('channel/manualSettle'); + } + + private function buildChannelPlayRecordQuery(int $channelId, bool $settledOnly) + { + $query = Db::name('game_play_record')->alias('pr') + ->leftJoin('user u', 'u.id = pr.user_id') + ->leftJoin('game_record gr', 'gr.id = pr.period_id') + ->leftJoin('channel c', 'c.id = pr.channel_id') + ->where('pr.channel_id', $channelId); + if ($settledOnly) { + $query->where('pr.status', 2); + } + return $query; + } + + /** + * @return array + */ + private function channelPlayRecordListFields(): array + { + return [ + 'pr.id', + 'pr.period_no', + 'pr.period_id', + 'pr.user_id', + 'pr.pick_numbers', + 'pr.total_amount', + 'pr.win_amount', + 'pr.jackpot_extra_amount', + 'pr.status', + 'pr.create_time', + 'u.username as user_username', + 'c.name as channel_name', + 'gr.period_no as game_record_period_no', + 'gr.result_number', + 'gr.status as game_record_status', + ]; + } + + /** + * @param array> $list + * @return array> + */ + private function normalizePlayRecordListRows(array $list): array + { + $out = []; + foreach ($list as $row) { + if (!is_array($row)) { + continue; + } + $periodNo = trim((string) ($row['period_no'] ?? '')); + if ($periodNo === '') { + $periodNo = trim((string) ($row['game_record_period_no'] ?? '')); + } + $win = bcadd(strval($row['win_amount'] ?? '0'), strval($row['jackpot_extra_amount'] ?? '0'), 2); + $playStatus = intval($row['status'] ?? 0); + $out[] = [ + 'id' => intval($row['id'] ?? 0), + 'period_no' => $periodNo, + 'user_username' => (string) ($row['user_username'] ?? ''), + 'channel_name' => (string) ($row['channel_name'] ?? ''), + 'pick_numbers' => $this->formatPickNumbersForList($row['pick_numbers'] ?? null), + 'result_number' => $this->formatResultNumberForList($row['result_number'] ?? null), + 'win_hit' => $this->resolveWinHitCode($win, $playStatus), + 'play_status' => $playStatus, + 'total_amount' => bcadd(strval($row['total_amount'] ?? '0'), '0', 2), + 'win_amount' => bcadd($win, '0', 2), + 'create_time' => intval($row['create_time'] ?? 0), + ]; + } + return $out; + } + + private function formatPickNumbersForList(mixed $pickNumbers): string + { + if ($pickNumbers === null || $pickNumbers === '') { + return ''; + } + if (is_string($pickNumbers)) { + $trimmed = trim($pickNumbers); + if ($trimmed === '') { + return ''; + } + $decoded = json_decode($trimmed, true); + if (is_array($decoded)) { + $pickNumbers = $decoded; + } else { + return $trimmed; + } + } + if (!is_array($pickNumbers)) { + return ''; + } + $parts = []; + foreach ($pickNumbers as $num) { + if ($num === null || $num === '') { + continue; + } + $parts[] = (string) $num; + } + + return implode(',', $parts); + } + + private function formatResultNumberForList(mixed $resultNumber): string + { + if ($resultNumber === null || $resultNumber === '') { + return ''; + } + + return (string) $resultNumber; + } + + private function resolveWinHitCode(string $winAmount, int $playStatus): string + { + if ($playStatus > 0 && $playStatus !== 2) { + return 'pending'; + } + if (bccomp($winAmount, '0', 2) > 0) { + return 'won'; + } + + return 'lost'; + } + + /** + * @param \think\db\Query $query + * @return array{record_count:int,total_bet_amount:string,total_win_amount:string} + */ + private function summarizePlayRecordQuery($query): array + { + $row = $query + ->field('COUNT(pr.id) AS c, SUM(pr.total_amount) AS tb, SUM(pr.win_amount) AS tw, SUM(pr.jackpot_extra_amount) AS tj') + ->find(); + $count = is_array($row) ? intval($row['c'] ?? 0) : 0; + $tb = is_array($row) ? strval($row['tb'] ?? '0') : '0'; + $tw = is_array($row) ? strval($row['tw'] ?? '0') : '0'; + $tj = is_array($row) ? strval($row['tj'] ?? '0') : '0'; + return [ + 'record_count' => $count, + 'total_bet_amount' => bcadd($tb, '0', 2), + 'total_win_amount' => bcadd(bcadd($tw, $tj, 2), '0', 2), + ]; + } + /** * @return array|string 成功返回预览数据数组,失败返回错误文案 */ @@ -936,19 +1412,31 @@ class Channel extends Backend return $chosen; } - private function getCurrentChannelIds(): array + /** + * @return array + */ + /** + * 写操作可作用的渠道(角色组绑定渠道 + 账号 channel_id,不含全平台只读) + * + * @return array + */ + private function resolveOwnChannelIds(): array { - if ($this->auth->isSuperAdmin()) { - return Db::name('channel')->column('id'); + if ($this->auth === null) { + return []; + } + $ids = AdminChannelScopeService::resolveBoundGroupChannelIds($this->auth); + if ($ids !== []) { + return $ids; } $admin = Db::name('admin') ->field(['id', 'channel_id']) ->where('id', $this->auth->id) ->find(); - $ids = []; if ($admin && !empty($admin['channel_id'])) { - $ids[] = $admin['channel_id']; + $ids[] = (int) $admin['channel_id']; } + return array_values(array_unique($ids)); } diff --git a/app/admin/controller/Dashboard.php b/app/admin/controller/Dashboard.php index 6412bd3..f0f6e8c 100644 --- a/app/admin/controller/Dashboard.php +++ b/app/admin/controller/Dashboard.php @@ -379,7 +379,7 @@ class Dashboard extends Backend */ private function ownerAdminIdOrNull(): ?int { - if (!$this->auth || $this->auth->isSuperAdmin()) { + if (!$this->auth || $this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) { return null; } $idRaw = $this->auth->id; diff --git a/app/admin/controller/game/PlayRecord.php b/app/admin/controller/game/PlayRecord.php index 09dfb7c..b9551f3 100644 --- a/app/admin/controller/game/PlayRecord.php +++ b/app/admin/controller/game/PlayRecord.php @@ -76,7 +76,7 @@ class PlayRecord extends Backend list($where, $alias, $limit, $order) = $this->queryBuilder(); $table = strtolower($this->model->getTable()); $mainShort = $alias[$table] ?? ''; - if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) { + if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) { $where[] = ['user.admin_id', 'in', $this->scopedAdminIds()]; } @@ -182,7 +182,7 @@ class PlayRecord extends Backend $remark = is_string($remarkRaw) ? trim($remarkRaw) : ''; // 权限范围校验:复用列表逻辑(非超管只能操作其下辖用户) - if ($this->auth && !$this->auth->isSuperAdmin()) { + if ($this->shouldApplyUserAdminScope()) { $uidRaw = Db::name('game_play_record')->where('id', $id)->value('user_id'); $uid = ($uidRaw === null || $uidRaw === '' || !is_numeric(strval($uidRaw))) ? 0 : (int) $uidRaw; if ($uid <= 0) { @@ -240,7 +240,7 @@ class PlayRecord extends Backend return $this->error(__('Please provide reject reason')); } - if ($this->auth && !$this->auth->isSuperAdmin()) { + if ($this->shouldApplyUserAdminScope()) { $uidRaw = Db::name('game_play_record')->where('id', $id)->value('user_id'); $uid = ($uidRaw === null || $uidRaw === '' || !is_numeric(strval($uidRaw))) ? 0 : (int) $uidRaw; if ($uid <= 0) { diff --git a/app/admin/controller/operation/UserNoticeRead.php b/app/admin/controller/operation/UserNoticeRead.php index 1d788c4..c77f2d7 100644 --- a/app/admin/controller/operation/UserNoticeRead.php +++ b/app/admin/controller/operation/UserNoticeRead.php @@ -78,7 +78,7 @@ class UserNoticeRead extends Backend list($where, $alias, $limit, $order) = $this->queryBuilder(); $table = strtolower($this->model->getTable()); $mainShort = $alias[$table] ?? ''; - if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) { + if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) { $where[] = ['user.admin_id', 'in', $this->scopedAdminIds()]; } diff --git a/app/admin/controller/order/AdminWithdrawOrder.php b/app/admin/controller/order/AdminWithdrawOrder.php index 971335f..4d673c6 100644 --- a/app/admin/controller/order/AdminWithdrawOrder.php +++ b/app/admin/controller/order/AdminWithdrawOrder.php @@ -42,8 +42,9 @@ class AdminWithdrawOrder extends Backend list($where, $alias, $limit, $order) = $this->queryBuilder(); $table = strtolower($this->model->getTable()); $mainShort = $alias[$table] ?? ''; - if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) { - $where[] = [$mainShort . '.channel_id', 'in', $this->getCurrentAdminChannelIds()]; + $channelScope = $this->readableChannelIds(); + if ($mainShort !== '' && $channelScope !== null) { + $where[] = [$mainShort . '.channel_id', 'in', $channelScope]; } $res = $this->model ->withJoin($this->withJoinTable, $this->withJoinType) @@ -170,8 +171,9 @@ class AdminWithdrawOrder extends Backend return $response; } $query = Db::name('admin_withdraw_order'); - if ($this->auth && !$this->auth->isSuperAdmin()) { - $query->where('channel_id', 'in', $this->getCurrentAdminChannelIds()); + $channelScope = $this->readableChannelIds(); + if ($channelScope !== null) { + $query->where('channel_id', 'in', $channelScope); } $rows = $query->field(['status', 'amount', 'actual_amount'])->select()->toArray(); $total = count($rows); @@ -227,53 +229,19 @@ class AdminWithdrawOrder extends Backend if (!$this->auth) { return false; } - if ($this->auth->isSuperAdmin()) { + if ($this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) { return true; } $channelId = intval($order['channel_id'] ?? 0); if ($channelId <= 0) { return false; } - $allowed = $this->getCurrentAdminChannelIds(); + $allowed = $this->readableChannelIds(); + if ($allowed === null) { + return true; + } + return in_array($channelId, $allowed, true); } - - /** - * 当前管理员可审核的渠道(优先取自身 channel_id,同时兼容角色组继承链上的 channel_id) - * - * @return int[] - */ - private function getCurrentAdminChannelIds(): array - { - $uid = intval($this->auth->id ?? 0); - if ($uid <= 0) { - return [0]; - } - $channelIds = []; - - $selfChannelId = intval(Db::name('admin')->where('id', $uid)->value('channel_id') ?? 0); - if ($selfChannelId > 0) { - $channelIds[] = $selfChannelId; - } - - $groupIds = Db::name('admin_group_access')->where('uid', $uid)->column('group_id'); - if ($groupIds !== []) { - $groupIds = array_values(array_unique(array_merge($groupIds, $this->auth->getAdminChildGroups()))); - $rows = Db::name('admin_group') - ->field(['id', 'channel_id']) - ->where('id', 'in', $groupIds) - ->whereNotNull('channel_id') - ->select() - ->toArray(); - foreach ($rows as $row) { - $cid = intval($row['channel_id'] ?? 0); - if ($cid > 0) { - $channelIds[] = $cid; - } - } - } - - return $channelIds === [] ? [0] : array_values(array_unique($channelIds)); - } } diff --git a/app/admin/controller/order/BetOrder.php b/app/admin/controller/order/BetOrder.php index c27643f..59d6f96 100644 --- a/app/admin/controller/order/BetOrder.php +++ b/app/admin/controller/order/BetOrder.php @@ -77,7 +77,7 @@ class BetOrder extends Backend list($where, $alias, $limit, $order) = $this->queryBuilder(); $table = strtolower($this->model->getTable()); $mainShort = $alias[$table] ?? ''; - if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) { + if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) { $where[] = ['user.admin_id', 'in', $this->scopedAdminIds()]; } diff --git a/app/admin/controller/order/DepositOrder.php b/app/admin/controller/order/DepositOrder.php index 238e25e..4e41632 100644 --- a/app/admin/controller/order/DepositOrder.php +++ b/app/admin/controller/order/DepositOrder.php @@ -48,7 +48,7 @@ class DepositOrder extends Backend list($where, $alias, $limit, $order) = $this->queryBuilder(); $table = strtolower($this->model->getTable()); $mainShort = $alias[$table] ?? ''; - if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) { + if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) { $where[] = ['user.admin_id', 'in', $this->scopedAdminIds()]; } $this->appendDepositOrderIndexWhere($where, $mainShort); @@ -129,7 +129,7 @@ class DepositOrder extends Backend private function checkChannelScoped(array $row): bool { - if (!$this->auth || $this->auth->isSuperAdmin()) { + if (!$this->auth || $this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) { return true; } $userRow = $row['user'] ?? null; diff --git a/app/admin/controller/order/WithdrawOrder.php b/app/admin/controller/order/WithdrawOrder.php index 203c874..69c6946 100644 --- a/app/admin/controller/order/WithdrawOrder.php +++ b/app/admin/controller/order/WithdrawOrder.php @@ -49,7 +49,7 @@ class WithdrawOrder extends Backend list($where, $alias, $limit, $order) = $this->queryBuilder(); $table = strtolower($this->model->getTable()); $mainShort = $alias[$table] ?? ''; - if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) { + if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) { $where[] = ['user.admin_id', 'in', $this->scopedAdminIds()]; } @@ -658,7 +658,7 @@ class WithdrawOrder extends Backend private function checkChannelScoped(array|object $row): bool { - if (!$this->auth || $this->auth->isSuperAdmin()) { + if (!$this->auth || $this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) { return true; } $uidRaw = is_array($row) ? ($row['user_id'] ?? null) : ($row->user_id ?? null); diff --git a/app/admin/controller/user/UserWalletRecord.php b/app/admin/controller/user/UserWalletRecord.php index 9f76bf9..06f9a5c 100644 --- a/app/admin/controller/user/UserWalletRecord.php +++ b/app/admin/controller/user/UserWalletRecord.php @@ -77,7 +77,7 @@ class UserWalletRecord extends Backend list($where, $alias, $limit, $order) = $this->queryBuilder(); $table = strtolower($this->model->getTable()); $mainShort = $alias[$table] ?? ''; - if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) { + if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) { $where[] = ['user.admin_id', 'in', $this->scopedAdminIds()]; } diff --git a/app/admin/lang/zh-cn/dashboard.php b/app/admin/lang/zh-cn/dashboard.php index e4eaff2..782be29 100644 --- a/app/admin/lang/zh-cn/dashboard.php +++ b/app/admin/lang/zh-cn/dashboard.php @@ -1,4 +1,4 @@ '开源等于互助;开源需要大家一起来支持,支持的方式有很多种,比如使用、推荐、写教程、保护生态、贡献代码、回答问题、分享经验、打赏赞助等;欢迎您加入我们!', + 'Remark lang' => 'telegram@zhenhui666', ]; \ No newline at end of file diff --git a/app/api/controller/Account.php b/app/api/controller/Account.php index 844cfbf..3f80417 100644 --- a/app/api/controller/Account.php +++ b/app/api/controller/Account.php @@ -5,6 +5,7 @@ namespace app\api\controller; use ba\Date; use ba\Captcha; use ba\Random; +use app\common\library\MalaysiaMobilePhone; use app\common\library\finance\WithdrawFlow; use app\common\model\User; use app\common\facade\Token; @@ -235,7 +236,7 @@ class Account extends Frontend return $this->error(__('Email') . ' ' . __('already exists')); } } else { - Validator::make($params, ['mobile' => 'required|regex:/^1[3-9]\d{9}$/'])->validate(); + Validator::make($params, ['mobile' => 'required|regex:' . MalaysiaMobilePhone::PATTERN])->validate(); if (User::where('mobile', $params['mobile'])->find()) { return $this->error(__('Mobile') . ' ' . __('already exists')); } diff --git a/app/api/controller/Auth.php b/app/api/controller/Auth.php index 71b5e27..6fb6138 100644 --- a/app/api/controller/Auth.php +++ b/app/api/controller/Auth.php @@ -6,6 +6,7 @@ namespace app\api\controller; use app\common\facade\Token; use app\common\library\Auth as UserAuth; +use app\common\library\MalaysiaMobilePhone; use app\common\model\User; use app\common\service\MobileAuthDeviceService; use ba\Random; @@ -38,7 +39,7 @@ class Auth extends MobileBase if ($inviteCode === '') { return $this->mobileError(1001, 'Invite code required'); } - if (!preg_match('/^1[3-9]\d{9}$/', $username)) { + if (!MalaysiaMobilePhone::isValid($username)) { return $this->mobileError(1003, 'Please enter the correct mobile number'); } diff --git a/app/api/controller/User.php b/app/api/controller/User.php index 58e304c..13b81c4 100644 --- a/app/api/controller/User.php +++ b/app/api/controller/User.php @@ -2,6 +2,7 @@ namespace app\api\controller; +use app\common\library\MalaysiaMobilePhone; use ba\Captcha; use ba\ClickCaptcha; use app\common\controller\Frontend; @@ -138,7 +139,7 @@ class User extends Frontend 'password' => 'required|string|regex:/^(?!.*[&<>"\'\n\r]).{6,32}$/', 'registerType' => 'required|in:email,mobile', 'email' => 'required_if:registerType,email|email|unique:user,email', - 'mobile' => 'required_if:registerType,mobile|regex:/^1[3-9]\d{9}$/|unique:user,mobile', + 'mobile' => 'required_if:registerType,mobile|regex:' . MalaysiaMobilePhone::PATTERN . '|unique:user,mobile', 'captcha' => 'required|string', 'invite_code' => 'nullable|string|max:64', ], diff --git a/app/api/lang/en.php b/app/api/lang/en.php index 92b8a56..876ea09 100644 --- a/app/api/lang/en.php +++ b/app/api/lang/en.php @@ -30,7 +30,7 @@ return [ 'Invite code not bound to channel' => 'This invite code is not bound to a valid channel', 'Channel disabled' => 'Channel is disabled', 'Account already registered' => 'This phone number is already registered. Please sign in.', - 'Please enter the correct mobile number' => 'Please enter the correct mobile number', + 'Please enter the correct mobile number' => 'Please enter a valid Malaysia mobile number starting with 60', 'Registered successfully but login failed' => 'Registered successfully but login failed', 'Incorrect account or password' => 'Incorrect account or password', 'Login status has expired' => 'Login status has expired', diff --git a/app/api/lang/zh-cn.php b/app/api/lang/zh-cn.php index 7d24038..4134f52 100644 --- a/app/api/lang/zh-cn.php +++ b/app/api/lang/zh-cn.php @@ -62,7 +62,7 @@ return [ 'Invite code not bound to channel' => '该邀请码未绑定有效渠道', 'Channel disabled' => '渠道已关闭', 'Account already registered' => '该手机号已注册,请直接登录', - 'Please enter the correct mobile number' => '请输入正确的手机号', + 'Please enter the correct mobile number' => '请输入正确的马来西亚手机号(60开头)', 'Registered successfully but login failed' => '注册成功但登录失败', 'Incorrect account or password' => '账号或密码错误', 'Login status has expired' => '登录状态已过期', diff --git a/app/common/controller/Backend.php b/app/common/controller/Backend.php index e0f8249..aaf51b8 100644 --- a/app/common/controller/Backend.php +++ b/app/common/controller/Backend.php @@ -6,6 +6,7 @@ namespace app\common\controller; use Throwable; use app\admin\library\Auth; +use app\common\service\AdminChannelScopeService; use app\common\library\token\TokenExpirationException; use app\admin\library\traits\Backend as BackendTrait; use support\Response; @@ -421,7 +422,7 @@ class Backend extends Api */ protected function getDataLimitAdminIds(): array { - if (!$this->dataLimit || !$this->auth || $this->auth->isSuperAdmin()) { + if (!$this->dataLimit || !$this->auth || $this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) { return []; } $adminIds = []; @@ -475,6 +476,51 @@ class Backend extends Api return get_controller_path($request); } + /** + * 全平台只读范围:角色组均未绑定渠道,或拥有查看所有渠道权限,或超管 + */ + protected function hasGlobalReadScope(): bool + { + return $this->auth !== null && AdminChannelScopeService::hasGlobalReadScope($this->auth); + } + + /** + * 是否按「名下管理员/用户」收窄列表(非超管且非全平台只读范围) + */ + protected function shouldApplyUserAdminScope(): bool + { + if ($this->auth === null || !$this->auth->isLogin()) { + return false; + } + if ($this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) { + return false; + } + + return true; + } + + /** + * 可读渠道 ID;null 表示全部渠道 + * + * @return array|null + */ + protected function readableChannelIds(): ?array + { + if ($this->auth === null) { + return [0]; + } + + return AdminChannelScopeService::readableChannelIds($this->auth); + } + + /** + * 列表是否需要按 channel_id 收窄 + */ + protected function shouldApplyChannelIdScope(): bool + { + return $this->readableChannelIds() !== null; + } + /** * 构造权限节点候选:兼容 snake_case 与 camelCase 节点名 * diff --git a/app/common/lang/en/admin_rule_title.php b/app/common/lang/en/admin_rule_title.php index 39bf3f7..66661b9 100644 --- a/app/common/lang/en/admin_rule_title.php +++ b/app/common/lang/en/admin_rule_title.php @@ -120,6 +120,11 @@ return [ 'createNextManual' => 'Create next period manually', 'periodSettings' => 'Period settings', 'manualSettle' => 'Manual settle', + 'batchSettlePending' => 'Batch settle pending channels', + 'viewDividendRecords' => 'View paid dividend records', + 'viewDirectBetRecords' => 'View direct bet records', + 'viewSettlementBetRecords' => 'View settlement-scope bets', + 'viewAllChannels' => 'View all channels', // 其它中文按钮文案 '期号开关' => 'Period toggle', diff --git a/app/common/lang/en/service.php b/app/common/lang/en/service.php index 4254874..e978fdd 100644 --- a/app/common/lang/en/service.php +++ b/app/common/lang/en/service.php @@ -115,6 +115,7 @@ return [ 'Only super admin can settle; after settlement, commissions will be automatically paid to admin wallets' => 'Only super admin can settle; after settlement, commissions will be automatically paid to admin wallets', 'Settlement failed' => 'Settlement failed', 'Super admin settlement completed; paid automatically by share ratios' => 'Super admin settlement completed; paid automatically by share ratios', + 'Settlement completed; commissions paid automatically by share ratios' => 'Settlement completed; commissions paid automatically by share ratios', 'Batch settlement completed' => 'Batch settlement completed', 'Invalid settlement cycle' => 'Invalid settlement cycle', diff --git a/app/common/lang/zh-cn/admin_rule_title.php b/app/common/lang/zh-cn/admin_rule_title.php index 82f1bd8..96fa4ee 100644 --- a/app/common/lang/zh-cn/admin_rule_title.php +++ b/app/common/lang/zh-cn/admin_rule_title.php @@ -53,6 +53,10 @@ return [ 'periodSettings' => '期号设置', 'manualSettle' => '手动结算', 'batchSettlePending' => '批量结算待结算渠道', + 'viewDividendRecords' => '查看已分红记录', + 'viewDirectBetRecords' => '查看直属投注记录', + 'viewSettlementBetRecords' => '查看总投注金额', + 'viewAllChannels' => '查看所有渠道', 'walletAdjust' => '钱包加减点', 'Markdown文档' => 'Markdown文档', '分红说明文档' => '分红说明文档', diff --git a/app/common/lang/zh-cn/service.php b/app/common/lang/zh-cn/service.php index f93be61..a04785a 100644 --- a/app/common/lang/zh-cn/service.php +++ b/app/common/lang/zh-cn/service.php @@ -115,6 +115,7 @@ return [ 'Only super admin can settle; after settlement, commissions will be automatically paid to admin wallets' => '仅超管可执行结算,结算后系统会自动发放至管理员钱包', 'Settlement failed' => '结算失败', 'Super admin settlement completed; paid automatically by share ratios' => '超管结算完成,已按分配比例自动发放给管理员', + 'Settlement completed; commissions paid automatically by share ratios' => '结算完成,已按分配比例自动发放给管理员', 'Batch settlement completed' => '批量结算完成', 'Invalid settlement cycle' => '结算周期不合法', diff --git a/app/common/library/Auth.php b/app/common/library/Auth.php index ce13096..b09980a 100644 --- a/app/common/library/Auth.php +++ b/app/common/library/Auth.php @@ -105,7 +105,7 @@ class Auth extends \ba\Auth $this->setError(__('Email')); return false; } - $isMobileUsername = preg_match('/^1[3-9]\d{9}$/', $username) === 1; + $isMobileUsername = MalaysiaMobilePhone::isValid($username); $isNormalUsername = preg_match('/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/', $username) === 1; if ($username && !$isMobileUsername && !$isNormalUsername) { $this->setError(__('Username')); @@ -127,7 +127,7 @@ class Auth extends \ba\Auth return false; } - $nickname = preg_replace_callback('/1[3-9]\d{9}/', fn($m) => substr($m[0], 0, 3) . '****' . substr($m[0], 7), $username); + $nickname = MalaysiaMobilePhone::maskInNickname($username); $time = time(); $data = [ 'group_id' => $group, @@ -170,9 +170,13 @@ class Auth extends \ba\Auth public function login(string $username, string $password, bool $keep): bool { $accountType = false; - if (preg_match('/^1[3-9]\d{9}$/', $username)) $accountType = 'phone'; - elseif (filter_var($username, FILTER_VALIDATE_EMAIL)) $accountType = 'email'; - elseif (preg_match('/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/', $username)) $accountType = 'username'; + if (MalaysiaMobilePhone::isValid($username)) { + $accountType = 'phone'; + } elseif (filter_var($username, FILTER_VALIDATE_EMAIL)) { + $accountType = 'email'; + } elseif (preg_match('/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/', $username)) { + $accountType = 'username'; + } if (!$accountType) { $this->setError('Account not exist'); return false; diff --git a/app/common/library/MalaysiaMobilePhone.php b/app/common/library/MalaysiaMobilePhone.php new file mode 100644 index 0000000..3ffef10 --- /dev/null +++ b/app/common/library/MalaysiaMobilePhone.php @@ -0,0 +1,29 @@ + substr($matches[0], 0, 3) . '****' . substr($matches[0], -4), + $text + ); + + return is_string($masked) ? $masked : $text; + } +} diff --git a/app/common/service/AdminChannelScopeService.php b/app/common/service/AdminChannelScopeService.php new file mode 100644 index 0000000..740150d --- /dev/null +++ b/app/common/service/AdminChannelScopeService.php @@ -0,0 +1,123 @@ +isLogin()) { + return false; + } + if ($auth->isSuperAdmin()) { + return true; + } + if (self::resolveBoundGroupChannelIds($auth) === []) { + return true; + } + + return self::hasViewAllChannelsPermission($auth); + } + + /** + * 可读渠道 ID 列表;null 表示不限制(全部渠道) + * + * @return array|null + */ + public static function readableChannelIds(Auth $auth): ?array + { + if (!$auth->isLogin()) { + return [0]; + } + if (self::hasGlobalReadScope($auth)) { + return null; + } + + $ids = self::resolveBoundGroupChannelIds($auth); + if ($ids !== []) { + return $ids; + } + + $selfChannelId = (int) Db::name('admin')->where('id', (int) $auth->id)->value('channel_id'); + if ($selfChannelId > 0) { + return [$selfChannelId]; + } + + return [0]; + } + + /** + * 当前管理员所属角色组上绑定的渠道 ID(去重) + * + * @return array + */ + public static function resolveBoundGroupChannelIds(Auth $auth): array + { + $uid = (int) $auth->id; + if ($uid <= 0) { + return []; + } + $groupIds = Db::name('admin_group_access')->where('uid', $uid)->column('group_id'); + if ($groupIds === []) { + return []; + } + + $rows = Db::name('admin_group') + ->where('id', 'in', $groupIds) + ->whereNotNull('channel_id') + ->where('channel_id', '>', 0) + ->column('channel_id'); + + $ids = []; + foreach ($rows as $cid) { + $ids[] = (int) $cid; + } + + return array_values(array_unique($ids)); + } + + /** + * 是否拥有「查看所有渠道」按钮权限 + */ + public static function hasViewAllChannelsPermission(Auth $auth): bool + { + if (!$auth->isLogin()) { + return false; + } + $nodes = ['channel/viewAllChannels', 'channel/viewallchannels']; + foreach ($nodes as $node) { + if ($auth->check($node)) { + return true; + } + } + $ruleList = $auth->getRuleList(); + foreach ($nodes as $node) { + if (in_array(strtolower($node), $ruleList, true)) { + return true; + } + } + + return false; + } + + /** + * 列表按 channel_id 过滤时使用的 ID;null=不过滤 + * + * @return array|null + */ + public static function channelIdFilterForQuery(Auth $auth): ?array + { + return self::readableChannelIds($auth); + } +} diff --git a/docs/36字花-数据库与实施计划.md b/docs/36字花-数据库与实施计划.md index 5e9e8ee..2e066b0 100644 --- a/docs/36字花-数据库与实施计划.md +++ b/docs/36字花-数据库与实施计划.md @@ -138,7 +138,7 @@ ### 3.3 注意 -`channel` 不承担子代理树结构;子代理层级与邀请码归属下沉到 `admin`。除超管外,渠道管理仅可查看当前管理员归属渠道。 +`channel` 不承担子代理树结构;子代理层级与邀请码归属下沉到 `admin`。渠道列表**只读范围**:超管、所属角色组均未绑定 `channel_id`、或拥有 `channel/viewAllChannels` 时可查看全平台渠道;否则仅绑定渠道或本人 `admin.channel_id`。详见 `docs/渠道管理后台说明.md`。 --- @@ -316,9 +316,9 @@ ### 8.3.1 渠道结算新流程(2026-04-23,2026-05-29 树形拆分) -1. **仅超管可结算**:按渠道结算周期(支持自动任务与手动提前结算)执行渠道结算。 +1. **结算权限**:超管,或拥有按钮权限 `channel/manualSettle` 且目标渠道在可读范围内,可手动提前结算;**一键批量结算**仍仅超管。自动任务由 `ChannelAutoSettleTicker` 执行。 2. **结算即发放**:结算时按 **代理树**(`admin.parent_admin_id` + `admin.commission_share_rate`)拆分各管理员实得,直接发放到管理员钱包并写入 `admin_wallet_record`(`commission_income`),同时写 `agent_settlement_period` / `agent_commission_record`。 -3. **配置位置**:分红比例在 **管理员管理** 维护,不在渠道管理页维护 flat 分配表。 +3. **配置位置**:分红比例在 **管理员管理** 维护,不在渠道管理页维护 flat 分配表。渠道页支持查看直属/分红口径下注记录、已分红记录及筛选,见 `docs/渠道管理后台说明.md`。 4. **提前结算规则**:手动提前结算后,新的周期起点从本次结算结束时间开始,后续自动周期归入下个结算段。 5. **停用渠道限制**:`channel.status != 1` 时,该渠道不再允许玩家注册与登录。 @@ -407,6 +407,7 @@ | V1.16 | 2026-04-23 | 渠道结算改为单阶段口径(仅超管结算,结算即按比例发放管理员钱包并记录操作人)与管理员钱包提现流程(`admin_wallet` / `admin_wallet_record` / `admin_withdraw_order`,渠道顶级组审核) | | V1.17 | 2026-05-29 | 代理分红改为树形拆分:`admin.commission_share_rate` + `parent_admin_id`;配置迁移至管理员管理;渠道页移除 `channel_admin_share` 入口;管理员列表树形展示与下级可见范围 | | V1.18 | 2026-05-29 | 顶级角色组(`admin_group.pid=0`)可配置渠道分红比例;表单禁用上级代理并增加说明 | +| V1.19 | 2026-05-30 | 渠道管理:全平台只读范围、`viewAllChannels`、下注记录弹窗与筛选、移动端适配;`manualSettle` 按权限;`docs/渠道管理后台说明.md` | --- diff --git a/docs/36字花-移动端接口设计草案.md b/docs/36字花-移动端接口设计草案.md index d236438..3ae23bb 100644 --- a/docs/36字花-移动端接口设计草案.md +++ b/docs/36字花-移动端接口设计草案.md @@ -1002,7 +1002,7 @@ flowchart TD ## 10. 后台代理分红配置(管理端补充,2026-05-29) -> 分红比例在 **管理员管理** `/admin/auth/admin` 维护,不再使用渠道页「分配比例」弹窗。 +> 分红比例在 **管理员管理** `/admin/auth/admin` 维护,不再使用渠道页「分配比例」弹窗。渠道页交互(统计、下注记录、筛选、权限)见 `docs/渠道管理后台说明.md`。 ### 10.1 页面与展示 diff --git a/docs/en/channel-admin-guide.md b/docs/en/channel-admin-guide.md new file mode 100644 index 0000000..f4db931 --- /dev/null +++ b/docs/en/channel-admin-guide.md @@ -0,0 +1,88 @@ +# Channel Management (Admin) + +## 1. Purpose + +Documents the **Channel Management** page (`/admin/channel`): summary cards, list scope, button permissions, bet-record dialogs, and APIs. For commission calculation and agent tree split, see [commission-share-guide.md](./commission-share-guide.md). + +--- + +## 2. Summary cards + +| Card | Description | +|------|-------------| +| Total channels | Count in **readable scope** | +| Enabled | `status = 1` | +| Pending dividend (count) | `carryover_balance > 0` | +| Pending dividend (amount) | Sum of those balances | +| Paid dividend | Sum of paid `agent_commission_record` in scope; clickable dialog requires `viewDividendRecords` | + +List filters: **All / With balance / No balance / Enabled only / Disabled only** (UI search only). + +--- + +## 3. Read scope + +`AdminChannelScopeService` applies to list and stats: + +**Global read** (all channels) if any of: + +- Super admin (`*`) +- No `admin_group.channel_id` bound for the user’s groups +- Button permission `channel/viewAllChannels` + +Otherwise: bound group channel IDs, or `admin.channel_id`, or empty. + +**Write** (add/edit/delete/manual settle DB) stays on **writable** channels only; `viewAllChannels` does not expand write scope. + +--- + +## 4. Actions & permissions + +| Node | Label | Behavior | +|------|-------|----------| +| `channel/viewAllChannels` | View all channels | Global read scope | +| `channel/viewDividendRecords` | Paid dividend records | Top card + dialog | +| `channel/viewDirectBetRecords` | Direct bet records | Direct bet column click | +| `channel/viewSettlementBetRecords` | Settlement-scope bets | Row action | +| `channel/manualSettle` | Manual settle | Preview + submit (readable channel) | +| `channel/batchSettlePending` | Batch settle | **Super admin only** | + +Re-login after role changes to refresh `authNode`. + +--- + +## 5. Manual settlement + +- `GET /admin/channel/manualSettlePreview?id=` +- `POST /admin/channel/manualSettle` +- Super admin **or** `channel/manualSettle` with channel in read scope +- Same payout flow as super-admin settle (`ChannelSettlementService::settleBySuperAdmin`) + +--- + +## 6. Bet record dialogs + +| Entry | API | Data | +|-------|-----|------| +| Direct bet amount | `directBetRecordList` | All play records for channel | +| View settlement bets | `settlementBetRecordList` | `status = 2` only | + +**Filters (GET):** `period_no`, `user_keyword`, `result_number`, `pick_number`, `win_hit` (`won`/`lost`/`pending`), `page`, `limit`. + +**Columns:** period, player, channel, picks, winning number, win status, bet amount, win amount. + +**Mobile:** ~92% width, scrollable body, 3 summary cards per row, horizontal table scroll. + +--- + +## 7. APIs + +See §8 in the Chinese doc `docs/渠道管理后台说明.md` (same paths under `/admin/channel/`). + +--- + +## 8. Changelog + +| Date | Note | +|------|------| +| 2026-05-30 | Bet record columns, filters, mobile layout; `viewAllChannels`; manual settle by permission | diff --git a/docs/en/commission-share-guide.md b/docs/en/commission-share-guide.md index b589273..fe7ab2e 100644 --- a/docs/en/commission-share-guide.md +++ b/docs/en/commission-share-guide.md @@ -47,11 +47,12 @@ If a sub-agent has further downline, the same rules apply on **their received am | Capability | Entry | Notes | |------------|-------|-------| -| Channel commission params | `/admin/channel` | `agent_mode`, turnover/affiliate rates, settlement cycle, etc. | +| Channel commission params | `/admin/channel` | `agent_mode`, turnover/affiliate rates, etc.; see [channel-admin-guide.md](./channel-admin-guide.md) | | Agent tree & share rates | `/admin/auth/admin` | Tree list; parent agent, share rate, channel | | Channel filter | Admin list common search | Super admin can filter by channel | +| Channel list read scope | `/admin/channel` | Super admin / unbound groups / `viewAllChannels` → all channels read-only | | Visibility | Admin list | Non–super admin sees **self + all downline** only | -| Settlement | `/admin/channel` manual / cron | **Super admin only**; credits `admin_wallet` on settle | +| Settlement | `/admin/channel` manual / cron | Super admin **or** `channel/manualSettle`; batch still super admin only | ### 3.1 Share rate validation @@ -80,7 +81,7 @@ If a level totals 100%, the parent at that level keeps **no commission**. ## 4. Settlement Flow -1. Super admin triggers channel settlement (manual or `ChannelAutoSettleTicker`) +1. Super admin or holder of `channel/manualSettle` triggers settlement (manual or cron; batch API still super admin only) 2. `ChannelSettlementService::buildSettlePayload` aggregates bets and computes channel total commission 3. `AdminCommissionDistributionService::distributeChannelCommission` splits by agent tree 4. In one transaction: @@ -123,6 +124,8 @@ If a level totals 100%, the parent at that level keeps **no commission**. | Module | Path | |--------|------| | Channel settlement | `app/common/service/ChannelSettlementService.php` | +| Channel read scope | `app/common/service/AdminChannelScopeService.php` | +| Channel admin UI | `app/admin/controller/Channel.php` | | Tree split | `app/common/service/AdminCommissionDistributionService.php` | | Admin CRUD / validation | `app/admin/controller/auth/Admin.php` | | Admin UI | `web/src/views/backend/auth/admin/` | @@ -138,3 +141,4 @@ If a level totals 100%, the parent at that level keeps **no commission**. | 2026-04-23 | Settle-and-pay to admin wallet; `admin_wallet` system | | 2026-05-29 | **Agent tree commission** in Administrator Management; removed channel share UI; tree list & downline visibility | | 2026-05-29 | Top-level role groups (`pid=0`) require channel share rate; parent agent disabled in form | +| 2026-05-30 | Channel UI: view-all, bet/dividend dialogs, filters, mobile; manual settle by permission; `channel-admin-guide.md` | diff --git a/docs/分红说明文档.md b/docs/分红说明文档.md index a717542..425ec7f 100644 --- a/docs/分红说明文档.md +++ b/docs/分红说明文档.md @@ -47,11 +47,12 @@ | 能力 | 入口 | 说明 | |------|------|------| -| 渠道分红参数 | `/admin/channel` | `agent_mode`、返水/联营比例、结算周期等 | +| 渠道分红参数 | `/admin/channel` | `agent_mode`、返水/联营比例、结算周期等;详见 [渠道管理后台说明.md](./渠道管理后台说明.md) | | 代理树与分红比例 | `/admin/auth/admin` | 树形列表;配置上级代理、分红比例、渠道归属 | | 渠道筛选 | 管理员列表公共搜索 | 超管可按渠道筛选 | +| 渠道列表可见范围 | `/admin/channel` | 超管 / 角色组未绑渠道 / `viewAllChannels` → 全平台只读;否则仅绑定渠道 | | 数据可见范围 | 管理员列表 | 非超管仅见 **本人 + 全部下级**,不见其他代理线 | -| 结算执行 | `/admin/channel` 手动结算 / 定时任务 | **仅超管**可结算;结算即发放至 `admin_wallet` | +| 结算执行 | `/admin/channel` 手动结算 / 定时任务 | **超管**或 `channel/manualSettle`(渠道可读);批量结算仍仅超管;结算即发放至 `admin_wallet` | ### 3.1 分红比例校验 @@ -80,7 +81,7 @@ ## 4. 结算执行流程 -1. 超管触发渠道结算(手动或 `ChannelAutoSettleTicker` 周期任务) +1. 超管或具备 `channel/manualSettle` 的管理员触发渠道结算(手动或 `ChannelAutoSettleTicker` 周期任务;批量接口仍仅超管) 2. `ChannelSettlementService::buildSettlePayload` 汇总注单并计算渠道总佣金 3. `AdminCommissionDistributionService::distributeChannelCommission` 按代理树拆分各管理员实得 4. 事务内写入: @@ -123,6 +124,8 @@ | 模块 | 路径 | |------|------| | 渠道结算 | `app/common/service/ChannelSettlementService.php` | +| 渠道列表范围 | `app/common/service/AdminChannelScopeService.php` | +| 渠道后台 | `app/admin/controller/Channel.php`、`web/src/views/backend/channel/` | | 树形拆分 | `app/common/service/AdminCommissionDistributionService.php` | | 管理员 CRUD / 校验 | `app/admin/controller/auth/Admin.php` | | 管理员前端 | `web/src/views/backend/auth/admin/` | @@ -139,3 +142,4 @@ | 2026-05-29 | **改为代理树形分红**:配置迁移至管理员管理 `commission_share_rate` + `parent_admin_id`;移除渠道页分配比例入口;管理员列表树形展示与下级可见范围 | | 2026-05-29 | 新增英文文档 `docs/en/commission-share-guide.md`;后台切换 `lang=en` 时文档页自动加载英文版 | | 2026-05-29 | **顶级角色组可配置渠道分红比例**:`pid=0` 时禁用上级代理并必填 `commission_share_rate`;结算按顶级比例划入后再向下拆分 | +| 2026-05-30 | 渠道页:查看所有渠道、下注/分红记录弹窗与筛选、移动端适配;`manualSettle` 按按钮权限展示;文档 [渠道管理后台说明.md](./渠道管理后台说明.md) | diff --git a/docs/后端.md b/docs/后端.md index 1485914..4066fcb 100644 --- a/docs/后端.md +++ b/docs/后端.md @@ -78,12 +78,16 @@ ### 3.5 🏢 代理中枢与佣金结算系统 (Agent System) * **创建总代/子代账号**:在 **管理员管理**(`/admin/auth/admin`)维护代理树:`parent_admin_id`、`commission_share_rate`(顶级角色组从渠道总佣金分得 %,子代理从上级实得抽取 %)、`channel_id`、邀请码。 * **代理树状图 (Tree View)**:管理员列表以树形展示;非超管仅见本人及全部下级。 -* **渠道佣金结算**(仅超管): +* **渠道管理页**(`/admin/channel`): + * 顶部统计:渠道数、待分红、已分红(可点开记录);列表支持分红余额/启用状态筛选。 + * **数据范围**:`AdminChannelScopeService`;全平台只读条件见 `docs/渠道管理后台说明.md` §3。 + * **操作**:查看总投注金额 / 直属投注记录(弹窗 + 筛选);手动结算(超管或 `channel/manualSettle`)。 +* **渠道佣金结算**: * 按渠道 `agent_mode` 与已结算注单计算渠道总佣金(非充值口径)。 * 按代理树拆分各管理员实得,写入 `agent_commission_record` 并 **即时入账** `admin_wallet`。 - * 支持周期自动结算(`ChannelAutoSettleTicker`)与手动提前结算。 + * 支持周期自动结算(`ChannelAutoSettleTicker`)、手动提前结算;批量待结算仅超管。 * **佣金结算看板**:`agent_settlement_period`、`agent_commission_record` 列表查询与对账。 -* 详细口径见 `docs/分红说明文档.md`。 +* 详细口径见 `docs/分红说明文档.md`;渠道页交互见 `docs/渠道管理后台说明.md`。 ### 3.6 🤝 联营契约与合营代理管控大厅 (Affiliate Management) 设计为与普通流水分佣系统并行的**客损占成代理模块**方案: diff --git a/docs/渠道管理后台说明.md b/docs/渠道管理后台说明.md new file mode 100644 index 0000000..ad9e146 --- /dev/null +++ b/docs/渠道管理后台说明.md @@ -0,0 +1,158 @@ +# 渠道管理后台说明 + +## 1. 文档目的 + +说明 **渠道管理**(`/admin/channel`)页面的统计卡片、列表数据范围、操作按钮权限、下注记录弹窗与相关接口,便于运营配置权限与开发联调。 + +分红计算与代理树拆分口径见 [分红说明文档.md](./分红说明文档.md)。 + +--- + +## 2. 页面入口与统计卡片 + +| 卡片 | 说明 | +|------|------| +| 渠道总数 | 当前账号**可读范围**内渠道数量 | +| 启用渠道 | `status = 1` 的渠道数 | +| 待分红渠道 | `carryover_balance > 0` 的渠道数 | +| 待分红总额 | 上述渠道 `carryover_balance` 合计 | +| 已分红金额 | 可读范围内渠道下,已发放佣金(`agent_commission_record.status = 1`)合计;**可点击**打开已分红记录弹窗(需 `viewDividendRecords` 权限) | + +列表上方筛选:**全部 / 有分红余额 / 无分红余额 / 仅启用 / 仅停用**(前端 `search` 条件,不改变数据范围规则)。 + +--- + +## 3. 数据可见范围(只读) + +由 `app/common/service/AdminChannelScopeService.php` 统一判定,列表与统计均遵守: + +| 条件(满足任一即**全平台渠道可读**) | 说明 | +|--------------------------------------|------| +| 超管 | 权限含 `*` | +| 角色组均未绑定 `channel_id` | 该管理员所属角色组 `admin_group.channel_id` 均为空 | +| 拥有「查看所有渠道」 | 按钮权限 `channel/viewAllChannels` | + +否则仅可读: + +- 角色组绑定的 `channel_id` 集合,或 +- 本人 `admin.channel_id`(若 > 0),或 +- 无绑定且无账号渠道时返回空列表。 + +**写操作**(新增/编辑/删除渠道、手动结算写库)仍限制在**可写渠道**:角色组绑定渠道 + 账号 `channel_id`,**不**因「查看所有渠道」而扩大写范围。 + +其它菜单(用户、充值/提现订单、游玩记录、控制台等)在只读全平台时同样可不按 `admin_id` 收窄,逻辑与 `Backend::hasGlobalReadScope()` 一致。 + +--- + +## 4. 列表字段与操作 + +### 4.1 常用列 + +- 渠道标识、名称、代理模式、联营负结转、契约编号、结算周期等 +- **直属投注额**:该渠道下 `game_play_record` 投注合计;**可点击**打开直属下注记录弹窗(需 `viewDirectBetRecords`) +- 操作列:**查看总投注金额**、**手动结算**、编辑、删除(后两者受写权限约束) + +### 4.2 操作按钮权限 + +| 按钮权限节点 | 名称 | 行为 | +|--------------|------|------| +| `channel/index` | 查看 | 列表与详情 | +| `channel/viewAllChannels` | 查看所有渠道 | 扩大**只读**范围至全平台渠道 | +| `channel/viewDividendRecords` | 查看已分红记录 | 顶部「已分红金额」卡片与弹窗 | +| `channel/viewDirectBetRecords` | 查看直属投注记录 | 「直属投注额」列点击 | +| `channel/viewSettlementBetRecords` | 查看总投注金额 | 操作列;分红口径已结算注单 | +| `channel/manualSettle` | 手动结算 | 操作列;预览并提交渠道结算(见 §5) | +| `channel/batchSettlePending` | 一键批量结算 | **仅超管**可见;结算全部待结算渠道 | +| `channel/add` / `edit` / `del` | 增删改 | 须对目标渠道具备写权限 | + +角色组在 **权限管理 → 角色组** 中勾选对应按钮;保存后管理员需**重新登录**以刷新前端 `authNode`。 + +--- + +## 5. 手动结算 + +- **接口**:`GET /admin/channel/manualSettlePreview?id={channelId}`、`POST /admin/channel/manualSettle` +- **权限**:超管,或拥有 `channel/manualSettle` 且目标渠道在**可读范围**内 +- **逻辑**:与超管结算相同,调用 `ChannelSettlementService::settleBySuperAdmin`,结算即按代理树发放至 `admin_wallet` +- **周期**:上次结算结束时间 ~ 当前时间;金额来自期内 **已结算** 游玩记录(`game_play_record.status = 2`) +- **批量**:`POST /admin/channel/batchSettlePending` 仍**仅超管** + +--- + +## 6. 下注记录弹窗 + +两种入口共用同一套 UI 与筛选(接口不同): + +| 入口 | 接口 | 数据范围 | +|------|------|----------| +| 直属投注额 | `GET /admin/channel/directBetRecordList` | 该渠道全部游玩记录 | +| 查看总投注金额 | `GET /admin/channel/settlementBetRecordList` | 该渠道 **已结算** 记录(`status = 2`,参与分红口径) | + +### 6.1 顶部统计(Card) + +- 总笔数、总投注额、总中奖额(随筛选条件重算) + +### 6.2 列表列 + +游戏期号、玩家名、渠道、选号、中奖号码、中奖状态(已中奖 / 未中奖 / 待开奖)、投注金额、玩家中奖金额(含 `jackpot_extra_amount`)。 + +### 6.3 筛选参数(GET) + +| 参数 | 说明 | +|------|------| +| `channel_id` | 必填 | +| `page` / `limit` | 分页,默认 1 / 20,最大 200 | +| `period_no` | 期号模糊(`pr.period_no` 或 `game_record.period_no`) | +| `user_keyword` | 玩家名或手机号模糊 | +| `result_number` | 开奖号码精确匹配 | +| `pick_number` | 选号模糊(匹配 `pick_numbers` JSON 文本) | +| `win_hit` | `won` / `lost` / `pending`(与列表中奖状态一致) | + +### 6.4 移动端适配 + +- 弹窗宽度约 92% 视口,内容区可**纵向滚动**(勿使用 `ba-operate-dialog` 固定高度) +- 统计 Card **一行三列**;筛选项纵向铺满;表格区域可**横向滑动** + +--- + +## 7. 已分红记录弹窗 + +- **接口**:`GET /admin/channel/dividendRecordList` +- **权限**:`channel/viewDividendRecords` +- **字段**:结算单号、渠道名、代理账号、分红金额、结算周期、发放时间等 + +--- + +## 8. 相关接口一览 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/admin/channel/index` | 渠道列表 | +| GET | `/admin/channel/settleStats` | 顶部统计卡片 | +| GET | `/admin/channel/directBetRecordList` | 直属下注记录 | +| GET | `/admin/channel/settlementBetRecordList` | 分红口径下注记录 | +| GET | `/admin/channel/dividendRecordList` | 已分红记录 | +| GET | `/admin/channel/manualSettlePreview` | 手动结算预览 | +| POST | `/admin/channel/manualSettle` | 提交手动结算 | +| POST | `/admin/channel/batchSettlePending` | 超管批量结算 | + +--- + +## 9. 相关代码 + +| 模块 | 路径 | +|------|------| +| 渠道控制器 | `app/admin/controller/Channel.php` | +| 数据范围 | `app/common/service/AdminChannelScopeService.php` | +| 渠道结算 | `app/common/service/ChannelSettlementService.php` | +| 前端页面 | `web/src/views/backend/channel/index.vue` | +| 权限迁移 | `database/migrations/20260530120000_*`、`20260530130000_*` | + +--- + +## 10. 变更记录 + +| 日期 | 说明 | +|------|------| +| 2026-05-30 | 新增:查看所有渠道、下注/分红查看按钮;下注记录弹窗列与筛选;移动端弹窗适配 | +| 2026-05-30 | 手动结算:拥有 `channel/manualSettle` 且渠道可读即可操作(不再仅限超管展示按钮) | diff --git a/support/bootstrap/ValidateInit.php b/support/bootstrap/ValidateInit.php index a756a5b..ef468ec 100644 --- a/support/bootstrap/ValidateInit.php +++ b/support/bootstrap/ValidateInit.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace support\bootstrap; +use app\common\library\MalaysiaMobilePhone; use think\Validate; use support\think\Db; use Workerman\Worker; @@ -17,6 +18,9 @@ class ValidateInit implements \Webman\Bootstrap { Validate::maker(function (Validate $validate): void { $validate->setDb(Db::connect()); + $validate->extend('mobile', static function ($value): bool { + return MalaysiaMobilePhone::isValid((string) $value); + }); }); } } diff --git a/web/src/lang/backend/en/channel.ts b/web/src/lang/backend/en/channel.ts index 7397802..b5ce6c1 100644 --- a/web/src/lang/backend/en/channel.ts +++ b/web/src/lang/backend/en/channel.ts @@ -83,6 +83,33 @@ export default { settle_stats_enabled: 'Enabled channels', settle_stats_pending_dividend: 'Channels pending dividend', settle_stats_pending_amount: 'Pending dividend amount', + settle_stats_paid_dividend: 'Paid dividend amount', + direct_bet_amount: 'Direct bet amount', + view_settlement_bet: 'View settlement bets', + dividend_record_dialog_title: 'Paid dividend records', + direct_bet_record_dialog_title: 'Direct player bet records', + settlement_bet_record_dialog_title: 'Dividend-scope bet records', + bet_record_period_no: 'Period No.', + bet_record_user_username: 'Player', + bet_record_total_amount: 'Bet amount', + bet_record_win_amount: 'Win amount', + bet_record_channel_name: 'Channel', + bet_record_pick_numbers: 'Picks', + bet_record_result_number: 'Winning number', + bet_record_win_hit: 'Win status', + bet_record_win_hit_won: 'Won', + bet_record_win_hit_lost: 'Lost', + bet_record_win_hit_pending: 'Pending', + bet_record_pick_filter_placeholder: 'Number', + bet_record_summary_count: 'Total records', + bet_record_summary_bet: 'Total bet', + bet_record_summary_win: 'Total win', + dividend_settlement_no: 'Settlement No.', + dividend_channel_name: 'Channel', + dividend_admin_username: 'Agent', + dividend_commission_amount: 'Dividend amount', + dividend_settled_at: 'Paid at', + dividend_period_range: 'Settlement period', settle_filter_all: 'All', settle_filter_with_balance: 'With dividend balance', settle_filter_no_balance: 'No dividend balance', diff --git a/web/src/lang/backend/zh-cn/channel.ts b/web/src/lang/backend/zh-cn/channel.ts index 084c227..fb65244 100644 --- a/web/src/lang/backend/zh-cn/channel.ts +++ b/web/src/lang/backend/zh-cn/channel.ts @@ -83,6 +83,33 @@ export default { settle_stats_enabled: '启用渠道', settle_stats_pending_dividend: '待分红渠道', settle_stats_pending_amount: '待分红总额', + settle_stats_paid_dividend: '已分红金额', + direct_bet_amount: '直属投注额', + view_settlement_bet: '查看总投注金额', + dividend_record_dialog_title: '已分红记录', + direct_bet_record_dialog_title: '直属玩家下注记录', + settlement_bet_record_dialog_title: '分红口径下注记录', + bet_record_period_no: '游戏期号', + bet_record_user_username: '玩家名', + bet_record_total_amount: '投注金额', + bet_record_win_amount: '玩家中奖金额', + bet_record_channel_name: '渠道', + bet_record_pick_numbers: '选号', + bet_record_result_number: '中奖号码', + bet_record_win_hit: '中奖状态', + bet_record_win_hit_won: '已中奖', + bet_record_win_hit_lost: '未中奖', + bet_record_win_hit_pending: '待开奖', + bet_record_pick_filter_placeholder: '号码', + bet_record_summary_count: '总笔数', + bet_record_summary_bet: '总投注额', + bet_record_summary_win: '总中奖额', + dividend_settlement_no: '结算单号', + dividend_channel_name: '渠道', + dividend_admin_username: '代理账号', + dividend_commission_amount: '分红金额', + dividend_settled_at: '发放时间', + dividend_period_range: '结算周期', settle_filter_all: '全部', settle_filter_with_balance: '有分红余额', settle_filter_no_balance: '无分红余额', diff --git a/web/src/lang/common/en/validate.ts b/web/src/lang/common/en/validate.ts index 8a821d7..6cdf182 100644 --- a/web/src/lang/common/en/validate.ts +++ b/web/src/lang/common/en/validate.ts @@ -3,7 +3,7 @@ export default { 'The correct area is not clicked, please try again!': 'The correct area is not clicked, please try again!', 'Verification is successful!': 'Verification is successful!', 'Please click': 'Please click', - 'Please enter the correct mobile number': 'Please enter the correct mobile number', + 'Please enter the correct mobile number': 'Please enter a valid Malaysia mobile number starting with 60', 'Please enter the correct account': 'The account requires 3 to 15 characters and contains a-z A-Z 0-9 _', 'Please enter the correct password': 'The password requires 6 to 32 characters and cannot contains & < > " \'', 'Please enter the correct name': 'Please enter the correct name', diff --git a/web/src/lang/common/zh-cn/validate.ts b/web/src/lang/common/zh-cn/validate.ts index 6b8ec16..b2322d0 100644 --- a/web/src/lang/common/zh-cn/validate.ts +++ b/web/src/lang/common/zh-cn/validate.ts @@ -3,7 +3,7 @@ export default { 'The correct area is not clicked, please try again!': '未点中正确区域,请重试!', 'Verification is successful!': '验证成功!', 'Please click': '请依次点击', - 'Please enter the correct mobile number': '请输入正确的手机号', + 'Please enter the correct mobile number': '请输入正确的马来西亚手机号(60开头)', 'Please enter the correct account': '要求3到15位,字母开头且只含字母、数字、下划线', 'Please enter the correct password': '密码要求6到32位,不能包含 & < > " \'', 'Please enter the correct name': '请输入正确的名称', diff --git a/web/src/utils/validate.ts b/web/src/utils/validate.ts index 7a8fa48..d4c98db 100644 --- a/web/src/utils/validate.ts +++ b/web/src/utils/validate.ts @@ -2,6 +2,11 @@ import type { RuleType } from 'async-validator' import type { FormItemRule } from 'element-plus' import { i18n } from '../lang' +/** + * 马来西亚手机号(60 国际前缀,不含 +) + */ +export const malaysiaMobilePattern = /^60(1[0-9])\d{7,9}$/ + /** * 手机号码验证 */ @@ -10,7 +15,7 @@ export function validatorMobile(rule: any, mobile: string | number, callback: Fu if (!mobile) { return callback() } - if (!/^(1[3-9])\d{9}$/.test(mobile.toString())) { + if (!malaysiaMobilePattern.test(mobile.toString())) { return callback(new Error(i18n.global.t('validate.Please enter the correct mobile number'))) } return callback() diff --git a/web/src/views/backend/channel/index.vue b/web/src/views/backend/channel/index.vue index 0d89edf..8bf6b59 100644 --- a/web/src/views/backend/channel/index.vue +++ b/web/src/views/backend/channel/index.vue @@ -24,6 +24,15 @@
{{ t('channel.settle_stats_pending_amount') }}
{{ settleStats.carryover_positive_total }}
+ +
{{ t('channel.settle_stats_paid_dividend') }}
+
{{ settleStats.paid_dividend_total }}
+
@@ -106,11 +115,220 @@ + + +
+
+ + + + + + + + + + +
+
+ +
+
+
+ + + +
+
+ +
{{ t('channel.bet_record_summary_count') }}
+
{{ betRecordDialog.summary.record_count }}
+
+ +
{{ t('channel.bet_record_summary_bet') }}
+
{{ betRecordDialog.summary.total_bet_amount }}
+
+ +
{{ t('channel.bet_record_summary_win') }}
+
{{ betRecordDialog.summary.total_win_amount }}
+
+
+ + + + + + + + + + + + + + + + + + + + + + {{ t('Search') }} + {{ t('Reset') }} + + +
+ + + + + + + + + + + + +
+
+ +
+
+
+