From 0f28c0fd2a6324a5e30e0bc0b83dbaca35679ef4 Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Tue, 21 Apr 2026 18:31:43 +0800 Subject: [PATCH] =?UTF-8?q?1.=E6=96=B0=E5=A2=9E=E5=85=85=E5=80=BC=E6=A1=A3?= =?UTF-8?q?=E4=BD=8D=E9=85=8D=E7=BD=AE=202.=E6=96=B0=E5=A2=9E=E5=85=85?= =?UTF-8?q?=E5=80=BC/=E6=8F=90=E7=8E=B0=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env-example | 4 + .../controller/config/DepositChannel.php | 421 ++++++++++++++ app/admin/controller/config/DepositTier.php | 286 +++++++++- .../config/FinanceCashierConfig.php | 225 ++++++++ app/admin/controller/config/GameConfig.php | 4 + .../controller/order/DepositChannelOrder.php | 29 + app/admin/controller/order/DepositOrder.php | 10 + app/api/controller/Finance.php | 257 ++++++++- app/common/lang/en/admin_rule_title.php | 4 + app/common/library/game/DepositChannel.php | 438 +++++++++++++++ app/common/library/game/DepositTier.php | 56 +- .../library/game/FinanceCashierConfig.php | 392 +++++++++++++ config/route.php | 1 + docs/36字花-数据库与实施计划.md | 10 + docs/36字花-移动端接口设计草案.md | 8 +- .../lang/backend/en/config/depositChannel.ts | 16 + web/src/lang/backend/en/config/depositTier.ts | 38 +- .../backend/en/config/financeCashierConfig.ts | 50 ++ .../backend/en/order/depositChannelOrder.ts | 3 + .../backend/zh-cn/config/depositChannel.ts | 16 + .../lang/backend/zh-cn/config/depositTier.ts | 34 +- .../zh-cn/config/financeCashierConfig.ts | 51 ++ .../zh-cn/order/depositChannelOrder.ts | 3 + .../backend/config/depositChannel/index.vue | 155 ++++++ .../config/depositChannel/popupForm.vue | 109 ++++ .../backend/config/depositTier/index.vue | 421 ++++++-------- .../backend/config/depositTier/popupForm.vue | 152 +++++ .../config/financeCashierConfig/index.vue | 518 ++++++++++++++++++ .../order/depositChannelOrder/index.vue | 214 ++++++++ 29 files changed, 3647 insertions(+), 278 deletions(-) create mode 100644 app/admin/controller/config/DepositChannel.php create mode 100644 app/admin/controller/config/FinanceCashierConfig.php create mode 100644 app/admin/controller/order/DepositChannelOrder.php create mode 100644 app/common/library/game/DepositChannel.php create mode 100644 app/common/library/game/FinanceCashierConfig.php create mode 100644 web/src/lang/backend/en/config/depositChannel.ts create mode 100644 web/src/lang/backend/en/config/financeCashierConfig.ts create mode 100644 web/src/lang/backend/en/order/depositChannelOrder.ts create mode 100644 web/src/lang/backend/zh-cn/config/depositChannel.ts create mode 100644 web/src/lang/backend/zh-cn/config/financeCashierConfig.ts create mode 100644 web/src/lang/backend/zh-cn/order/depositChannelOrder.ts create mode 100644 web/src/views/backend/config/depositChannel/index.vue create mode 100644 web/src/views/backend/config/depositChannel/popupForm.vue create mode 100644 web/src/views/backend/config/depositTier/popupForm.vue create mode 100644 web/src/views/backend/config/financeCashierConfig/index.vue create mode 100644 web/src/views/backend/order/depositChannelOrder/index.vue diff --git a/.env-example b/.env-example index 300874d..34d6099 100644 --- a/.env-example +++ b/.env-example @@ -43,3 +43,7 @@ AUTH_TOKEN_SECRET = 564d14asdasd113e46542asd6das1a2a # Webman Push:浏览器实际连接的 WS 基址(不含 /app/)。HTTPS 后台须用 wss://,与 Nginx 反代 3131 一致 # 示例:PUSH_WEBSOCKET_CLIENT_URL = wss://zihua-api.yuliao666.top PUSH_WEBSOCKET_CLIENT_URL = + +# 充值支付渠道:在代码注册表之外追加渠道(JSON 数组,每项含 code / name / name_en / sort) +# 示例:DEPOSIT_CHANNELS_REGISTRY_JSON = [{"code":"bank_a","name":"银行转账A","name_en":"Bank A","sort":20}] +DEPOSIT_CHANNELS_REGISTRY_JSON = diff --git a/app/admin/controller/config/DepositChannel.php b/app/admin/controller/config/DepositChannel.php new file mode 100644 index 0000000..d1edf51 --- /dev/null +++ b/app/admin/controller/config/DepositChannel.php @@ -0,0 +1,421 @@ +auth) { + return false; + } + $controllerPath = get_controller_path($request); + if (!$controllerPath) { + return false; + } + $paths = []; + $paths[] = $controllerPath . '/' . $action; + $parts = explode('/', $controllerPath); + foreach ($parts as &$part) { + if (str_contains($part, '_')) { + $part = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $part)))); + } + } + $paths[] = implode('/', $parts) . '/' . $action; + foreach (array_values(array_unique($paths)) as $path) { + if ($this->auth->check($path)) { + return true; + } + } + + return false; + } + + protected function initController(WebmanRequest $request): ?Response + { + return null; + } + + /** + * 列表(baTable:list / total / remark)+ registry + tier_options(弹窗用) + */ + public function index(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if (!$this->hasNodePermission($request, 'index')) { + return $this->error(__('You have no permission'), [], 401); + } + if ($request->method() !== 'GET') { + return $this->error(__('Parameter error')); + } + + $tierOptions = $this->buildTierOptions(); + $registryOut = $this->buildRegistryOut(); + + $parsed = DepositChannelLib::parseStoredOverridesFromDb(); + $items = DepositChannelLib::expandRowsForAdmin($parsed); + + $quickSearch = $request->get('quickSearch', ''); + if (is_string($quickSearch) && trim($quickSearch) !== '') { + $q = mb_strtolower(trim($quickSearch)); + $items = array_values(array_filter($items, static function (array $it) use ($q, $registryOut): bool { + $code = isset($it['code']) && is_string($it['code']) ? mb_strtolower($it['code']) : ''; + $name = ''; + if (isset($it['code']) && is_string($it['code']) && isset($registryOut[$it['code']])) { + $meta = $registryOut[$it['code']]; + $name = isset($meta['name']) && is_string($meta['name']) ? mb_strtolower($meta['name']) : ''; + } + + return $q === '' || str_contains($code, $q) || str_contains($name, $q); + })); + } + + $orderRaw = $request->get('order', ''); + if (is_string($orderRaw) && str_contains($orderRaw, ',')) { + $parts = explode(',', $orderRaw, 2); + $field = trim($parts[0]); + $dir = strtolower(trim($parts[1] ?? 'asc')); + if ($field === 'sort') { + usort($items, static function (array $a, array $b) use ($dir): int { + $sa = isset($a['sort']) && is_numeric($a['sort']) ? intval($a['sort']) : 0; + $sb = isset($b['sort']) && is_numeric($b['sort']) ? intval($b['sort']) : 0; + if ($sa !== $sb) { + return $dir === 'desc' ? ($sb <=> $sa) : ($sa <=> $sb); + } + $ca = isset($a['code']) && is_string($a['code']) ? $a['code'] : ''; + $cb = isset($b['code']) && is_string($b['code']) ? $b['code'] : ''; + + return strcmp($ca, $cb); + }); + } + } + + $total = count($items); + $pageRaw = $request->get('page', '1'); + $limitRaw = $request->get('limit', '20'); + $page = is_numeric($pageRaw) ? max(1, intval($pageRaw)) : 1; + $limit = is_numeric($limitRaw) ? max(1, min(500, intval($limitRaw))) : 20; + $offset = ($page - 1) * $limit; + $pageRows = array_slice($items, $offset, $limit); + + foreach ($pageRows as &$pr) { + if (!is_array($pr)) { + continue; + } + $code = isset($pr['code']) && is_string($pr['code']) ? $pr['code'] : ''; + $meta = $code !== '' && isset($registryOut[$code]) ? $registryOut[$code] : null; + $pr['display_name'] = is_array($meta) && isset($meta['name']) && is_string($meta['name']) ? $meta['name'] : $code; + $pr['name_en'] = is_array($meta) && isset($meta['name_en']) && is_string($meta['name_en']) ? $meta['name_en'] : ''; + } + unset($pr); + + return $this->success('', [ + 'list' => $pageRows, + 'total' => $total, + 'remark' => '', + 'registry' => $registryOut, + 'tier_options' => $tierOptions, + 'items' => $pageRows, + ]); + } + + /** + * 单条读取 / 单条更新(弹窗) + */ + public function edit(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if (!$this->hasNodePermission($request, 'edit')) { + return $this->error(__('You have no permission'), [], 401); + } + if ($request->method() === 'GET') { + $code = $request->get('code', ''); + if (!is_string($code) || trim($code) === '') { + $code = $request->get('id', ''); + } + if (!is_string($code) || trim($code) === '') { + return $this->error(__('Parameter error')); + } + $code = strtolower(trim($code)); + $parsed = DepositChannelLib::parseStoredOverridesFromDb(); + $items = DepositChannelLib::expandRowsForAdmin($parsed); + $found = null; + foreach ($items as $it) { + if (!is_array($it)) { + continue; + } + $c = isset($it['code']) && is_string($it['code']) ? strtolower(trim($it['code'])) : ''; + if ($c === $code) { + $found = $it; + break; + } + } + if ($found === null) { + return $this->error(__('Record not found')); + } + + return $this->success('', ['row' => $found]); + } + if ($request->method() === 'POST') { + $payload = $request->post(); + if (!is_array($payload)) { + return $this->error(__('Parameter %s can not be empty', [''])); + } + $code = isset($payload['code']) && is_string($payload['code']) ? strtolower(trim($payload['code'])) : ''; + if ($code === '') { + return $this->error(__('Parameter error')); + } + $parsed = DepositChannelLib::parseStoredOverridesFromDb(); + $items = DepositChannelLib::expandRowsForAdmin($parsed); + $foundIdx = -1; + $foundRow = null; + foreach ($items as $k => $it) { + if (!is_array($it)) { + continue; + } + $c = isset($it['code']) && is_string($it['code']) ? strtolower(trim($it['code'])) : ''; + if ($c === $code) { + $foundIdx = $k; + $foundRow = $it; + break; + } + } + if ($foundIdx < 0 || $foundRow === null) { + return $this->error(__('Record not found')); + } + $items[$foundIdx] = $this->mergeDepositChannelEditPayload($foundRow, $payload, $code); + + return $this->persistChannelList($items); + } + + return $this->error(__('Parameter error')); + } + + public function save(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if (!$this->hasNodePermission($request, 'save')) { + return $this->error(__('You have no permission'), [], 401); + } + if ($request->method() !== 'POST') { + return $this->error(__('Parameter error')); + } + $payload = $request->post(); + if (!is_array($payload)) { + return $this->error(__('Parameter %s can not be empty', [''])); + } + $items = $payload['items'] ?? null; + if (!is_array($items)) { + return $this->error('items 必须为数组'); + } + + return $this->persistChannelList($items); + } + + /** + * @param list> $items + */ + private function persistChannelList(array $items): Response + { + try { + $registry = DepositChannelLib::codeRegistry(); + $clean = DepositChannelLib::prepareOverridesForSave(array_values($items)); + $expectedCodes = array_keys($registry); + sort($expectedCodes); + $got = array_column($clean, 'code'); + sort($got); + if ($expectedCodes !== $got) { + return $this->error('请保存全部已注册渠道行(不可缺行)'); + } + $json = DepositChannelLib::encodeForDb($clean); + } catch (InvalidArgumentException $e) { + return $this->error($e->getMessage()); + } + + $now = time(); + $resourceKey = GameHotDataLock::safeResourceKeyForConfig(DepositChannelLib::CONFIG_KEY); + $lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey); + if (!$lock['acquired']) { + return $this->error('该配置正在被其他操作占用,请稍后再试'); + } + try { + try { + $exists = Db::name('game_config')->where('config_key', DepositChannelLib::CONFIG_KEY)->find(); + if ($exists) { + Db::name('game_config')->where('config_key', DepositChannelLib::CONFIG_KEY)->update([ + 'config_value' => $json, + 'value_type' => 'json', + 'update_time' => $now, + ]); + } else { + Db::name('game_config')->insert([ + 'config_key' => DepositChannelLib::CONFIG_KEY, + 'config_value' => $json, + 'value_type' => 'json', + 'remark' => '充值支付渠道 JSON(与 finance_cashier.channels 同步)', + 'create_time' => $now, + 'update_time' => $now, + ]); + } + $fcExists = Db::name('game_config')->where('config_key', FinanceCashierConfigLib::CONFIG_KEY)->find(); + if ($fcExists) { + $fcPayload = FinanceCashierConfigLib::parseFromConfigValue($fcExists['config_value'] ?? null); + $fcPayload['channels'] = $clean; + $fcJson = FinanceCashierConfigLib::encodeForDb($fcPayload); + Db::name('game_config')->where('config_key', FinanceCashierConfigLib::CONFIG_KEY)->update([ + 'config_value' => $fcJson, + 'value_type' => 'json', + 'update_time' => $now, + ]); + GameHotDataCoordinator::afterGameConfigKeyCommitted(FinanceCashierConfigLib::CONFIG_KEY); + } + } catch (Throwable $e) { + return $this->error($e->getMessage()); + } + GameHotDataCoordinator::afterGameConfigKeyCommitted(DepositChannelLib::CONFIG_KEY); + + return $this->success(__('Saved successfully')); + } finally { + GameHotDataLock::release(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey, $lock['token'], $lock['redis_lock']); + } + } + + /** + * @return list + */ + private function buildTierOptions(): array + { + $tierRow = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find(); + $allTiers = DepositTierLib::parseFromConfigValue($tierRow['config_value'] ?? null); + $tierOptions = []; + foreach ($allTiers as $t) { + if (!is_array($t)) { + continue; + } + $tid = isset($t['id']) && is_string($t['id']) ? $t['id'] : ''; + if ($tid === '') { + continue; + } + $title = isset($t['title']) && is_string($t['title']) ? trim($t['title']) : ''; + $tierOptions[] = [ + 'id' => $tid, + 'label' => $title !== '' ? $title . ' (' . $tid . ')' : $tid, + ]; + } + + return $tierOptions; + } + + /** + * @return array + */ + private function buildRegistryOut(): array + { + $registry = DepositChannelLib::codeRegistry(); + $registryOut = []; + foreach ($registry as $code => $meta) { + if (!is_array($meta)) { + continue; + } + $registryOut[$code] = [ + 'name' => isset($meta['name']) && is_string($meta['name']) ? $meta['name'] : '', + 'name_en' => isset($meta['name_en']) && is_string($meta['name_en']) ? $meta['name_en'] : '', + 'sort' => isset($meta['sort']) && is_numeric($meta['sort']) ? intval($meta['sort']) : 10, + ]; + } + + return $registryOut; + } + + /** + * 弹窗整表提交与列表内开关均走此合并:未出现在 payload 中的字段沿用 $current。 + * + * @param array $current + * @param array $payload + * + * @return array{code: string, sort: int, status: int, tier_ids: list} + */ + private function mergeDepositChannelEditPayload(array $current, array $payload, string $code): array + { + $form = []; + if (array_key_exists('sort', $payload)) { + $form['sort'] = $payload['sort']; + } else { + $form['sort'] = $current['sort'] ?? 0; + } + if (array_key_exists('status', $payload)) { + $form['status'] = $payload['status']; + } else { + $form['status'] = $current['status'] ?? 0; + } + if (array_key_exists('tier_ids', $payload) && is_array($payload['tier_ids'])) { + $form['tier_ids'] = $payload['tier_ids']; + } else { + $form['tier_ids'] = isset($current['tier_ids']) && is_array($current['tier_ids']) ? $current['tier_ids'] : []; + } + + return $this->normalizeChannelFormRow($form, $code); + } + + /** + * @param array $payload + * + * @return array{code: string, sort: int, status: int, tier_ids: list} + */ + private function normalizeChannelFormRow(array $payload, string $code): array + { + $sort = isset($payload['sort']) && is_numeric($payload['sort']) ? intval($payload['sort']) : 0; + $status = 0; + $st = $payload['status'] ?? 1; + if ($st === true || $st === 1 || $st === '1') { + $status = 1; + } + $tierIds = []; + if (isset($payload['tier_ids']) && is_array($payload['tier_ids'])) { + foreach ($payload['tier_ids'] as $tid) { + if (is_string($tid)) { + $t = trim($tid); + if ($t !== '' && preg_match('/^[a-zA-Z0-9_\-]{1,32}$/', $t)) { + $tierIds[] = $t; + } + } + } + $tierIds = array_values(array_unique($tierIds)); + } + + return [ + 'code' => $code, + 'sort' => $sort, + 'status' => $status, + 'tier_ids' => $tierIds, + ]; + } +} diff --git a/app/admin/controller/config/DepositTier.php b/app/admin/controller/config/DepositTier.php index b142016..18e4976 100644 --- a/app/admin/controller/config/DepositTier.php +++ b/app/admin/controller/config/DepositTier.php @@ -44,6 +44,7 @@ class DepositTier extends Backend return true; } } + return false; } @@ -53,7 +54,7 @@ class DepositTier extends Backend } /** - * 读取 game_config.deposit_tier 的档位列表 + * 列表(baTable:list / total / remark),支持 quickSearch、分页 */ public function index(WebmanRequest $request): Response { @@ -69,13 +70,219 @@ class DepositTier extends Backend } $row = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find(); $items = DepositTierLib::parseFromConfigValue($row['config_value'] ?? null); + + $quickSearch = $request->get('quickSearch', ''); + if (is_string($quickSearch) && trim($quickSearch) !== '') { + $q = mb_strtolower(trim($quickSearch)); + $items = array_values(array_filter($items, static function (array $it) use ($q): bool { + $id = isset($it['id']) && is_string($it['id']) ? mb_strtolower($it['id']) : ''; + $t = isset($it['title']) && is_string($it['title']) ? mb_strtolower($it['title']) : ''; + $te = isset($it['title_en']) && is_string($it['title_en']) ? mb_strtolower($it['title_en']) : ''; + + return $q === '' || str_contains($id, $q) || str_contains($t, $q) || str_contains($te, $q); + })); + } + + $orderRaw = $request->get('order', ''); + if (is_string($orderRaw) && str_contains($orderRaw, ',')) { + $parts = explode(',', $orderRaw, 2); + $field = trim($parts[0]); + $dir = strtolower(trim($parts[1] ?? 'asc')); + if ($field === 'sort') { + usort($items, static function (array $a, array $b) use ($dir): int { + $sa = isset($a['sort']) && is_numeric($a['sort']) ? intval($a['sort']) : 0; + $sb = isset($b['sort']) && is_numeric($b['sort']) ? intval($b['sort']) : 0; + if ($sa !== $sb) { + return $dir === 'desc' ? ($sb <=> $sa) : ($sa <=> $sb); + } + $ida = isset($a['id']) && is_string($a['id']) ? $a['id'] : ''; + $idb = isset($b['id']) && is_string($b['id']) ? $b['id'] : ''; + + return strcmp($ida, $idb); + }); + } + } + + $total = count($items); + $pageRaw = $request->get('page', '1'); + $limitRaw = $request->get('limit', '20'); + $page = is_numeric($pageRaw) ? max(1, intval($pageRaw)) : 1; + $limit = is_numeric($limitRaw) ? max(1, min(500, intval($limitRaw))) : 20; + $offset = ($page - 1) * $limit; + $pageRows = array_slice($items, $offset, $limit); + return $this->success('', [ - 'items' => $items, + 'list' => $pageRows, + 'total' => $total, + 'remark' => '', + 'items' => $pageRows, ]); } /** - * 保存 JSON 数组(value_type=json) + * 单条读取(弹窗编辑) + */ + public function edit(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if (!$this->hasNodePermission($request, 'edit')) { + return $this->error(__('You have no permission'), [], 401); + } + if ($request->method() === 'GET') { + $id = $request->get('id', ''); + if (!is_string($id) || trim($id) === '') { + return $this->error(__('Parameter error')); + } + $row = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find(); + $items = DepositTierLib::parseFromConfigValue($row['config_value'] ?? null); + $found = null; + foreach ($items as $it) { + if (!is_array($it)) { + continue; + } + $rid = $it['id'] ?? ''; + if (is_string($rid) && $rid === $id) { + $found = $it; + break; + } + } + if ($found === null) { + return $this->error(__('Record not found')); + } + + return $this->success('', ['row' => $found]); + } + if ($request->method() === 'POST') { + if (!$this->hasNodePermission($request, 'edit')) { + return $this->error(__('You have no permission'), [], 401); + } + $payload = $request->post(); + if (!is_array($payload)) { + return $this->error(__('Parameter %s can not be empty', [''])); + } + $id = isset($payload['id']) && is_string($payload['id']) ? trim($payload['id']) : ''; + if ($id === '') { + return $this->error(__('Parameter error')); + } + $cfgRow = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find(); + $items = DepositTierLib::parseFromConfigValue($cfgRow['config_value'] ?? null); + $foundIdx = -1; + $foundRow = null; + foreach ($items as $k => $it) { + if (!is_array($it)) { + continue; + } + $rid = $it['id'] ?? ''; + if (is_string($rid) && $rid === $id) { + $foundIdx = $k; + $foundRow = $it; + break; + } + } + if ($foundIdx < 0 || $foundRow === null) { + return $this->error(__('Record not found')); + } + $items[$foundIdx] = $this->mergeDepositTierEditPayload($foundRow, $payload, $id); + + return $this->persistTierList($items); + } + + return $this->error(__('Parameter error')); + } + + /** + * 新增一条档位 + */ + public function add(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if (!$this->hasNodePermission($request, 'add')) { + return $this->error(__('You have no permission'), [], 401); + } + if ($request->method() !== 'POST') { + return $this->error(__('Parameter error')); + } + $payload = $request->post(); + if (!is_array($payload)) { + return $this->error(__('Parameter %s can not be empty', [''])); + } + $cfgRow = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find(); + $items = DepositTierLib::parseFromConfigValue($cfgRow['config_value'] ?? null); + $newId = isset($payload['id']) && is_string($payload['id']) ? trim($payload['id']) : ''; + if ($newId === '') { + $newId = DepositTierLib::generateId(); + } + foreach ($items as $it) { + if (!is_array($it)) { + continue; + } + $rid = $it['id'] ?? ''; + if (is_string($rid) && $rid === $newId) { + return $this->error('档位 ID 已存在'); + } + } + $items[] = $this->normalizeFormRow($payload, $newId); + + return $this->persistTierList($items); + } + + /** + * 删除(支持批量 ids) + */ + public function del(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if (!$this->hasNodePermission($request, 'del')) { + return $this->error(__('You have no permission'), [], 401); + } + if ($request->method() !== 'DELETE') { + return $this->error(__('Parameter error')); + } + $ids = $request->get('ids', []); + if (!is_array($ids)) { + if (is_string($ids) && $ids !== '') { + $ids = [$ids]; + } else { + $ids = []; + } + } + $idSet = []; + foreach ($ids as $id) { + if (is_string($id) && trim($id) !== '') { + $idSet[trim($id)] = true; + } + } + if ($idSet === []) { + return $this->error(__('Parameter error')); + } + $cfgRow = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find(); + $items = DepositTierLib::parseFromConfigValue($cfgRow['config_value'] ?? null); + $filtered = []; + foreach ($items as $it) { + if (!is_array($it)) { + continue; + } + $rid = isset($it['id']) && is_string($it['id']) ? $it['id'] : ''; + if ($rid !== '' && isset($idSet[$rid])) { + continue; + } + $filtered[] = $it; + } + + return $this->persistTierList($filtered); + } + + /** + * 整表保存 JSON 数组(兼容旧版批量提交) */ public function save(WebmanRequest $request): Response { @@ -97,6 +304,15 @@ class DepositTier extends Backend if (!is_array($items)) { return $this->error('items 必须为数组'); } + + return $this->persistTierList($items); + } + + /** + * @param list> $items + */ + private function persistTierList(array $items): Response + { try { $clean = DepositTierLib::prepareItemsForSave(array_values($items)); $json = DepositTierLib::encodeForDb($clean); @@ -132,7 +348,6 @@ class DepositTier extends Backend } catch (Throwable $e) { return $this->error($e->getMessage()); } - GameHotDataCoordinator::afterGameConfigKeyCommitted(DepositTierLib::CONFIG_KEY); return $this->success(__('Saved successfully')); @@ -140,4 +355,67 @@ class DepositTier extends Backend GameHotDataLock::release(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey, $lock['token'], $lock['redis_lock']); } } + + /** + * 弹窗整表提交与列表内开关:未出现在 payload 中的字段沿用 $current。 + * + * @param array $current + * @param array $payload + * + * @return array + */ + private function mergeDepositTierEditPayload(array $current, array $payload, string $id): array + { + $merged = []; + $merged['id'] = $id; + if (array_key_exists('sort', $payload)) { + $merged['sort'] = $payload['sort']; + } else { + $merged['sort'] = $current['sort'] ?? 0; + } + if (array_key_exists('status', $payload)) { + $merged['status'] = $payload['status']; + } else { + $merged['status'] = $current['status'] ?? 0; + } + $stringKeys = ['title', 'title_en', 'currency', 'pay_amount', 'amount', 'bonus_amount', 'desc', 'desc_en']; + foreach ($stringKeys as $key) { + if (array_key_exists($key, $payload)) { + $merged[$key] = $payload[$key]; + } else { + $merged[$key] = $current[$key] ?? ($key === 'bonus_amount' ? '0' : ''); + } + } + + return $this->normalizeFormRow($merged, $id); + } + + /** + * @param array $payload + * + * @return array + */ + private function normalizeFormRow(array $payload, string $id): array + { + $sort = isset($payload['sort']) && is_numeric($payload['sort']) ? intval($payload['sort']) : 0; + $status = 0; + $st = $payload['status'] ?? 1; + if ($st === true || $st === 1 || $st === '1') { + $status = 1; + } + + return [ + 'id' => $id, + 'title' => isset($payload['title']) && is_string($payload['title']) ? $payload['title'] : '', + 'title_en' => isset($payload['title_en']) && is_string($payload['title_en']) ? $payload['title_en'] : '', + 'currency' => isset($payload['currency']) && is_string($payload['currency']) ? $payload['currency'] : '', + 'pay_amount' => $payload['pay_amount'] ?? '', + 'amount' => $payload['amount'] ?? '', + 'bonus_amount' => $payload['bonus_amount'] ?? '0', + 'desc' => isset($payload['desc']) && is_string($payload['desc']) ? $payload['desc'] : '', + 'desc_en' => isset($payload['desc_en']) && is_string($payload['desc_en']) ? $payload['desc_en'] : '', + 'sort' => $sort, + 'status' => $status, + ]; + } } diff --git a/app/admin/controller/config/FinanceCashierConfig.php b/app/admin/controller/config/FinanceCashierConfig.php new file mode 100644 index 0000000..53f2455 --- /dev/null +++ b/app/admin/controller/config/FinanceCashierConfig.php @@ -0,0 +1,225 @@ +auth) { + return false; + } + $controllerPath = get_controller_path($request); + if (!$controllerPath) { + return false; + } + $paths = []; + $paths[] = $controllerPath . '/' . $action; + $parts = explode('/', $controllerPath); + foreach ($parts as &$part) { + if (str_contains($part, '_')) { + $part = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $part)))); + } + } + $paths[] = implode('/', $parts) . '/' . $action; + foreach (array_values(array_unique($paths)) as $path) { + if ($this->auth->check($path)) { + return true; + } + } + + return false; + } + + protected function initController(WebmanRequest $request): ?Response + { + return null; + } + + public function index(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if (!$this->hasNodePermission($request, 'index')) { + return $this->error(__('You have no permission'), [], 401); + } + if ($request->method() !== 'GET') { + return $this->error(__('Parameter error')); + } + $row = Db::name('game_config')->where('config_key', FinanceCashierConfigLib::CONFIG_KEY)->find(); + $form = FinanceCashierConfigLib::parseFromConfigValue($row['config_value'] ?? null); + + $decoded = null; + if (is_array($row) && isset($row['config_value']) && is_string($row['config_value']) && trim($row['config_value']) !== '') { + $tmp = json_decode($row['config_value'], true); + if (is_array($tmp)) { + $decoded = $tmp; + } + } + $channelsKeyPresent = is_array($decoded) && array_key_exists('channels', $decoded); + if (!$channelsKeyPresent) { + $depRow = Db::name('game_config')->where('config_key', DepositChannelLib::CONFIG_KEY)->find(); + $legacy = DepositChannelLib::parseOverridesFromConfigValue(is_array($depRow) ? ($depRow['config_value'] ?? null) : null); + $form['channels'] = DepositChannelLib::expandRowsForAdmin($legacy); + } else { + $form['channels'] = DepositChannelLib::expandRowsForAdmin($form['channels'] ?? []); + } + + return $this->success('', [ + 'form' => $form, + 'registry' => $this->buildRegistryOut(), + 'tier_options' => $this->buildTierOptions(), + ]); + } + + public function save(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if (!$this->hasNodePermission($request, 'save')) { + return $this->error(__('You have no permission'), [], 401); + } + if ($request->method() !== 'POST') { + return $this->error(__('Parameter error')); + } + $payload = $request->post(); + if (!is_array($payload)) { + return $this->error(__('Parameter %s can not be empty', [''])); + } + try { + $json = FinanceCashierConfigLib::encodeForDb($payload); + } catch (InvalidArgumentException $e) { + return $this->error($e->getMessage()); + } + + $now = time(); + $resourceKey = GameHotDataLock::safeResourceKeyForConfig(FinanceCashierConfigLib::CONFIG_KEY); + $lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey); + if (!$lock['acquired']) { + return $this->error('该配置正在被其他操作占用,请稍后再试'); + } + try { + try { + $exists = Db::name('game_config')->where('config_key', FinanceCashierConfigLib::CONFIG_KEY)->find(); + if ($exists) { + Db::name('game_config')->where('config_key', FinanceCashierConfigLib::CONFIG_KEY)->update([ + 'config_value' => $json, + 'value_type' => 'json', + 'update_time' => $now, + ]); + } else { + Db::name('game_config')->insert([ + 'config_key' => FinanceCashierConfigLib::CONFIG_KEY, + 'config_value' => $json, + 'value_type' => 'json', + 'remark' => '支付/收款配置(表单维护,含充值渠道)', + 'create_time' => $now, + 'update_time' => $now, + ]); + } + $decodedSave = json_decode($json, true); + $chList = is_array($decodedSave) && isset($decodedSave['channels']) && is_array($decodedSave['channels']) + ? $decodedSave['channels'] + : []; + $mirrorJson = DepositChannelLib::encodeForDb($chList); + $depExists = Db::name('game_config')->where('config_key', DepositChannelLib::CONFIG_KEY)->find(); + if ($depExists) { + Db::name('game_config')->where('config_key', DepositChannelLib::CONFIG_KEY)->update([ + 'config_value' => $mirrorJson, + 'value_type' => 'json', + 'update_time' => $now, + ]); + } else { + Db::name('game_config')->insert([ + 'config_key' => DepositChannelLib::CONFIG_KEY, + 'config_value' => $mirrorJson, + 'value_type' => 'json', + 'remark' => '充值渠道(与 finance_cashier.channels 同步)', + 'create_time' => $now, + 'update_time' => $now, + ]); + } + } catch (Throwable $e) { + return $this->error($e->getMessage()); + } + GameHotDataCoordinator::afterGameConfigKeyCommitted(FinanceCashierConfigLib::CONFIG_KEY); + GameHotDataCoordinator::afterGameConfigKeyCommitted(DepositChannelLib::CONFIG_KEY); + + return $this->success(__('Saved successfully')); + } finally { + GameHotDataLock::release(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey, $lock['token'], $lock['redis_lock']); + } + } + + /** + * @return list + */ + private function buildTierOptions(): array + { + $tierRow = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find(); + $allTiers = DepositTierLib::parseFromConfigValue(is_array($tierRow) ? ($tierRow['config_value'] ?? null) : null); + $tierOptions = []; + foreach ($allTiers as $t) { + if (!is_array($t)) { + continue; + } + $tid = isset($t['id']) && is_string($t['id']) ? $t['id'] : ''; + if ($tid === '') { + continue; + } + $title = isset($t['title']) && is_string($t['title']) ? trim($t['title']) : ''; + $tierOptions[] = [ + 'id' => $tid, + 'label' => $title !== '' ? $title . ' (' . $tid . ')' : $tid, + ]; + } + + return $tierOptions; + } + + /** + * @return array + */ + private function buildRegistryOut(): array + { + $registry = DepositChannelLib::codeRegistry(); + $registryOut = []; + foreach ($registry as $code => $meta) { + if (!is_array($meta)) { + continue; + } + $registryOut[$code] = [ + 'name' => isset($meta['name']) && is_string($meta['name']) ? $meta['name'] : '', + 'name_en' => isset($meta['name_en']) && is_string($meta['name_en']) ? $meta['name_en'] : '', + 'sort' => isset($meta['sort']) && is_numeric($meta['sort']) ? intval($meta['sort']) : 10, + ]; + } + + return $registryOut; + } +} diff --git a/app/admin/controller/config/GameConfig.php b/app/admin/controller/config/GameConfig.php index 850987a..559e893 100644 --- a/app/admin/controller/config/GameConfig.php +++ b/app/admin/controller/config/GameConfig.php @@ -3,7 +3,9 @@ namespace app\admin\controller\config; use app\common\controller\Backend; +use app\common\library\game\DepositChannel; use app\common\library\game\DepositTier; +use app\common\library\game\FinanceCashierConfig; use app\common\library\game\StreakWinReward; use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib; use support\Response; @@ -42,6 +44,8 @@ class GameConfig extends Backend return [ ZiHuaDictionaryLib::CONFIG_KEY, DepositTier::CONFIG_KEY, + DepositChannel::CONFIG_KEY, + FinanceCashierConfig::CONFIG_KEY, StreakWinReward::CONFIG_KEY, ]; } diff --git a/app/admin/controller/order/DepositChannelOrder.php b/app/admin/controller/order/DepositChannelOrder.php new file mode 100644 index 0000000..05608e2 --- /dev/null +++ b/app/admin/controller/order/DepositChannelOrder.php @@ -0,0 +1,29 @@ +> $where + */ + protected function appendDepositOrderIndexWhere(array &$where, string $mainShort): void + { + if ($mainShort === '') { + return; + } + $effective = DepositChannel::effectiveRowsFromDb(); + $codes = DepositChannel::enabledPayChannelCodes($effective); + if ($codes === []) { + $where[] = [$mainShort . '.pay_channel', '=', '__no_pay_channel__']; + + return; + } + $where[] = [$mainShort . '.pay_channel', 'in', $codes]; + } +} diff --git a/app/admin/controller/order/DepositOrder.php b/app/admin/controller/order/DepositOrder.php index b2fe358..b7058cf 100644 --- a/app/admin/controller/order/DepositOrder.php +++ b/app/admin/controller/order/DepositOrder.php @@ -52,6 +52,7 @@ class DepositOrder extends Backend $channelIds = $this->getScopedChannelIdsForFilter(); $where[] = [$mainShort . '.channel_id', 'in', $channelIds !== [] ? $channelIds : [0]]; } + $this->appendDepositOrderIndexWhere($where, $mainShort); $res = $this->model ->withJoin($this->withJoinTable, $this->withJoinType) @@ -72,6 +73,15 @@ class DepositOrder extends Backend ]); } + /** + * 子类可追加列表过滤条件(例如仅展示已注册充值渠道的订单) + * + * @param list> $where + */ + protected function appendDepositOrderIndexWhere(array &$where, string $mainShort): void + { + } + /** * GET 时返回关联信息,便于前端详情弹窗直接渲染 user.username / channel.name; * POST 一律拒绝,保证充值订单的金额/状态只能由结算服务变更。 diff --git a/app/api/controller/Finance.php b/app/api/controller/Finance.php index 9f3bed3..a673be2 100644 --- a/app/api/controller/Finance.php +++ b/app/api/controller/Finance.php @@ -6,7 +6,9 @@ namespace app\api\controller; use app\common\library\finance\DepositSettlement; use app\common\library\finance\WithdrawFlow; +use app\common\library\game\DepositChannel as DepositChannelLib; use app\common\library\game\DepositTier as DepositTierLib; +use app\common\library\game\FinanceCashierConfig as FinanceCashierConfigLib; use app\common\model\DepositOrder; use app\common\model\GameConfig; use app\common\model\WithdrawOrder; @@ -29,19 +31,30 @@ class Finance extends MobileBase $lang = $this->currentLang(); $tiers = $this->loadEnabledTiers(); + $effectiveChannels = $this->loadDepositChannelEffective(); $list = []; foreach ($tiers as $tier) { $amount = $this->amountString($tier['amount'] ?? '0'); $bonus = $this->amountString($tier['bonus_amount'] ?? '0'); $total = bcadd($amount, $bonus, 4); + $payAmount = $this->amountString($tier['pay_amount'] ?? '0'); + $currency = isset($tier['currency']) && is_string($tier['currency']) ? strtoupper(trim($tier['currency'])) : 'CNY'; + if ($currency === '') { + $currency = 'CNY'; + } $localized = DepositTierLib::localize($tier, $lang); + $tierId = isset($tier['id']) && is_string($tier['id']) ? $tier['id'] : ''; $list[] = [ - 'id' => $tier['id'], + 'id' => $tierId, + 'tier_key' => $tierId, 'title' => $localized['title'], + 'currency' => $currency, + 'pay_amount' => $payAmount, 'amount' => $amount, 'bonus_amount' => $bonus, 'total_amount' => $total, 'desc' => $localized['desc'], + 'channels' => DepositChannelLib::channelsForTier($tierId, $effectiveChannels, $lang), ]; } return $this->mobileSuccess([ @@ -69,7 +82,8 @@ class Finance extends MobileBase * 并把入账动作放到网关回调里完成(回调中调用 DepositSettlement::settle)。 * * 请求:application/json 或 x-www-form-urlencoded - * - tier_id: 必填,档位 ID(需在 game_config.deposit_tier 启用档位内) + * - tier_id / tier_key: 必填,档位唯一标识(与 depositTierList 中 id、tier_key 一致) + * - channel_code: 必填,支付渠道代码(与 depositTierList 各档位 channels[].code 一致) * - idempotency_key: 必填,客户端幂等键,短时间内重复提交只生成一次订单 * * 响应(统一结构,未来接入第三方也保持此形状): @@ -83,8 +97,12 @@ class Finance extends MobileBase } $tierId = $this->stringParam($request->input('tier_id')); + if ($tierId === '') { + $tierId = $this->stringParam($request->input('tier_key')); + } + $channelCode = strtolower($this->stringParam($request->input('channel_code'))); $idempotencyKey = $this->stringParam($request->input('idempotency_key')); - if ($tierId === '' || $idempotencyKey === '') { + if ($tierId === '' || $channelCode === '' || $idempotencyKey === '') { return $this->mobileError(1001, 'Missing parameters'); } if (mb_strlen($idempotencyKey) > 64) { @@ -96,6 +114,10 @@ class Finance extends MobileBase if (!$tier) { return $this->mobileError(2003, 'Deposit tier not available'); } + $effectiveChannels = $this->loadDepositChannelEffective(); + if (!DepositChannelLib::assertChannelAllowsTier($channelCode, $tierId, $effectiveChannels)) { + return $this->mobileError(2004, 'Pay channel not available'); + } // 幂等命中:直接返回已有订单 try { @@ -112,14 +134,21 @@ class Finance extends MobileBase $user = $this->auth->getUser(); $orderNo = 'DP' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6); + $curSnap = isset($tier['currency']) && is_string($tier['currency']) ? strtoupper(trim($tier['currency'])) : 'CNY'; + if ($curSnap === '') { + $curSnap = 'CNY'; + } $tierSnapshot = [ 'id' => $tier['id'], 'title' => is_string($tier['title'] ?? null) ? $tier['title'] : '', 'title_en' => is_string($tier['title_en'] ?? null) ? $tier['title_en'] : '', + 'currency' => $curSnap, + 'pay_amount' => $this->amountString($tier['pay_amount'] ?? '0'), 'amount' => $this->amountString($tier['amount'] ?? '0'), 'bonus_amount' => $this->amountString($tier['bonus_amount'] ?? '0'), 'desc' => is_string($tier['desc'] ?? null) ? $tier['desc'] : '', 'desc_en' => is_string($tier['desc_en'] ?? null) ? $tier['desc_en'] : '', + 'channel_code' => $channelCode, ]; $now = time(); @@ -138,7 +167,7 @@ class Finance extends MobileBase 'amount' => $tierSnapshot['amount'], 'bonus_amount' => $tierSnapshot['bonus_amount'], 'status' => 0, - 'pay_channel' => 'mock_gateway', + 'pay_channel' => $channelCode, 'deposit_tier_id' => $tier['id'], 'proof_image' => '', 'pay_account_snapshot' => json_encode($tierSnapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), @@ -163,7 +192,9 @@ class Finance extends MobileBase DepositSettlement::settle( $orderId, DepositSettlement::SOURCE_MOCK_GATEWAY, - 'mock gateway auto settled' + 'mock gateway auto settled', + null, + 'channel_code=' . $channelCode ); } catch (Throwable $e) { return $this->mobileError(2000, $e->getMessage()); @@ -483,6 +514,214 @@ class Finance extends MobileBase ]); } + /** + * 收银台配置:货币列表(含充值/提现汇率)、支付渠道(pay_channels)、提现银行与文案(供充值/提现页展示) + */ + public function cashierConfig(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + $lang = $this->currentLang(); + $isZh = str_starts_with($lang, 'zh'); + $row = GameConfig::where('config_key', FinanceCashierConfigLib::CONFIG_KEY)->find(); + $cfg = FinanceCashierConfigLib::parseFromConfigValue($row?->config_value ?? null); + + $pc = $cfg['platform_coin'] ?? []; + $platformLabel = ''; + if (is_array($pc)) { + $platformLabel = $isZh + ? (is_string($pc['label_zh'] ?? null) ? $pc['label_zh'] : '') + : (is_string($pc['label_en'] ?? null) ? $pc['label_en'] : ''); + } + + $currencies = []; + if (isset($cfg['currencies']) && is_array($cfg['currencies'])) { + $list = $cfg['currencies']; + usort($list, function (array $a, array $b): int { + return $this->sortBySortKeyThenCode($a, $b); + }); + foreach ($list as $c) { + if (!is_array($c)) { + continue; + } + $code = isset($c['code']) && is_string($c['code']) ? $c['code'] : ''; + if ($code === '') { + continue; + } + $dep = isset($c['deposit_coins_per_fiat']) && is_string($c['deposit_coins_per_fiat']) ? $c['deposit_coins_per_fiat'] : ''; + $wdr = isset($c['withdraw_coins_per_fiat']) && is_string($c['withdraw_coins_per_fiat']) ? $c['withdraw_coins_per_fiat'] : ''; + $currencies[] = [ + 'code' => $code, + 'label' => $isZh + ? (is_string($c['label_zh'] ?? null) ? $c['label_zh'] : '') + : (is_string($c['label_en'] ?? null) ? $c['label_en'] : ''), + 'deposit_coins_per_fiat' => $dep, + 'withdraw_coins_per_fiat' => $wdr, + ]; + } + } + + $rates = []; + foreach ($currencies as $c) { + if (!is_array($c)) { + continue; + } + $cur = isset($c['code']) && is_string($c['code']) ? $c['code'] : ''; + $ratio = isset($c['withdraw_coins_per_fiat']) && is_string($c['withdraw_coins_per_fiat']) ? $c['withdraw_coins_per_fiat'] : ''; + if ($cur === '' || $ratio === '') { + continue; + } + $rates[] = [ + 'currency' => $cur, + 'diamonds_per_fiat_unit' => $ratio, + ]; + } + + $banks = []; + if (isset($cfg['withdraw_banks']) && is_array($cfg['withdraw_banks'])) { + $list = $cfg['withdraw_banks']; + usort($list, function (array $a, array $b): int { + $cmp = $this->sortBySortKeyOnly($a, $b); + if ($cmp !== 0) { + return $cmp; + } + $ca = isset($a['code']) && is_string($a['code']) ? $a['code'] : ''; + $cb = isset($b['code']) && is_string($b['code']) ? $b['code'] : ''; + + return strcmp($ca, $cb); + }); + foreach ($list as $b) { + if (!is_array($b)) { + continue; + } + $code = isset($b['code']) && is_string($b['code']) ? $b['code'] : ''; + if ($code === '') { + continue; + } + $banks[] = [ + 'code' => $code, + 'name' => $isZh + ? (is_string($b['name_zh'] ?? null) ? $b['name_zh'] : '') + : (is_string($b['name_en'] ?? null) ? $b['name_en'] : ''), + ]; + } + } + + $wl = $cfg['withdraw_limits'] ?? []; + $minEw = is_array($wl) && isset($wl['min_ewallet']) && is_string($wl['min_ewallet']) ? $wl['min_ewallet'] : '0'; + $minBk = is_array($wl) && isset($wl['min_bank']) && is_string($wl['min_bank']) ? $wl['min_bank'] : '0'; + + $wc = $cfg['withdraw_copy'] ?? []; + $rateMode = is_array($wc) && isset($wc['rate_mode']) && is_string($wc['rate_mode']) ? $wc['rate_mode'] : 'fixed'; + + $wf = $cfg['withdraw_fields'] ?? []; + $reqCard = is_array($wf) && !empty($wf['require_cardholder']); + $reqAcct = is_array($wf) && !empty($wf['require_bank_account']); + $reqMail = is_array($wf) && !empty($wf['require_email']); + $reqMob = is_array($wf) && !empty($wf['require_mobile']); + + $payChannels = []; + $effectiveCh = DepositChannelLib::effectiveRowsFromDb(); + $regCh = DepositChannelLib::codeRegistry(); + foreach ($effectiveCh as $row) { + if (!is_array($row)) { + continue; + } + $code = isset($row['code']) && is_string($row['code']) ? $row['code'] : ''; + if ($code === '' || !isset($regCh[$code]) || !is_array($regCh[$code])) { + continue; + } + $meta = $regCh[$code]; + $nameZh = isset($meta['name']) && is_string($meta['name']) ? $meta['name'] : ''; + $nameEn = isset($meta['name_en']) && is_string($meta['name_en']) ? $meta['name_en'] : ''; + $sortNum = filter_var($row['sort'] ?? 0, FILTER_VALIDATE_INT); + $statusNum = filter_var($row['status'] ?? 0, FILTER_VALIDATE_INT); + $payChannels[] = [ + 'code' => $code, + 'name' => $isZh ? $nameZh : ($nameEn !== '' ? $nameEn : $nameZh), + 'sort' => $sortNum !== false ? $sortNum : 0, + 'status' => $statusNum !== false ? $statusNum : 0, + 'tier_ids' => isset($row['tier_ids']) && is_array($row['tier_ids']) ? $row['tier_ids'] : [], + ]; + } + usort($payChannels, function (array $a, array $b): int { + $sa = isset($a['sort']) ? filter_var($a['sort'], FILTER_VALIDATE_INT) : false; + $sb = isset($b['sort']) ? filter_var($b['sort'], FILTER_VALIDATE_INT) : false; + $ia = $sa === false ? 0 : $sa; + $ib = $sb === false ? 0 : $sb; + if ($ia !== $ib) { + return $ia <=> $ib; + } + $ca = isset($a['code']) && is_string($a['code']) ? $a['code'] : ''; + $cb = isset($b['code']) && is_string($b['code']) ? $b['code'] : ''; + + return strcmp($ca, $cb); + }); + + return $this->mobileSuccess([ + 'platform_coin_label' => $platformLabel, + 'currencies' => $currencies, + 'rates' => $rates, + 'pay_channels' => $payChannels, + 'withdraw' => [ + 'banks' => $banks, + 'min_ewallet' => $minEw, + 'min_bank' => $minBk, + 'rate_hint' => $isZh + ? (is_array($wc) && is_string($wc['rate_hint_zh'] ?? null) ? $wc['rate_hint_zh'] : '') + : (is_array($wc) && is_string($wc['rate_hint_en'] ?? null) ? $wc['rate_hint_en'] : ''), + 'processing_note' => $isZh + ? (is_array($wc) && is_string($wc['processing_zh'] ?? null) ? $wc['processing_zh'] : '') + : (is_array($wc) && is_string($wc['processing_en'] ?? null) ? $wc['processing_en'] : ''), + 'fee_note' => $isZh + ? (is_array($wc) && is_string($wc['fee_note_zh'] ?? null) ? $wc['fee_note_zh'] : '') + : (is_array($wc) && is_string($wc['fee_note_en'] ?? null) ? $wc['fee_note_en'] : ''), + 'rate_mode' => $rateMode, + 'fields' => [ + 'require_cardholder' => $reqCard, + 'require_bank_account' => $reqAcct, + 'require_email' => $reqMail, + 'require_mobile' => $reqMob, + ], + ], + ]); + } + + /** + * @param array $a + * @param array $b + */ + private function sortBySortKeyThenCode(array $a, array $b): int + { + $sa = isset($a['sort']) ? filter_var($a['sort'], FILTER_VALIDATE_INT) : false; + $sb = isset($b['sort']) ? filter_var($b['sort'], FILTER_VALIDATE_INT) : false; + $ia = $sa === false ? 0 : $sa; + $ib = $sb === false ? 0 : $sb; + if ($ia !== $ib) { + return $ia <=> $ib; + } + $ca = isset($a['code']) && is_string($a['code']) ? $a['code'] : ''; + $cb = isset($b['code']) && is_string($b['code']) ? $b['code'] : ''; + + return strcmp($ca, $cb); + } + + /** + * @param array $a + * @param array $b + */ + private function sortBySortKeyOnly(array $a, array $b): int + { + $sa = isset($a['sort']) ? filter_var($a['sort'], FILTER_VALIDATE_INT) : false; + $sb = isset($b['sort']) ? filter_var($b['sort'], FILTER_VALIDATE_INT) : false; + $ia = $sa === false ? 0 : $sa; + $ib = $sb === false ? 0 : $sb; + + return $ia <=> $ib; + } + private function stringParam($raw): string { if ($raw === null) { @@ -501,6 +740,14 @@ class Finance extends MobileBase return DepositTierLib::publicList($all); } + /** + * @return list}> + */ + private function loadDepositChannelEffective(): array + { + return DepositChannelLib::effectiveRowsFromDb(); + } + private function mapDepositStatus($status): string { if ($this->intValue($status) === 1) { diff --git a/app/common/lang/en/admin_rule_title.php b/app/common/lang/en/admin_rule_title.php index 8a6ce27..a279263 100644 --- a/app/common/lang/en/admin_rule_title.php +++ b/app/common/lang/en/admin_rule_title.php @@ -74,6 +74,10 @@ return [ '运营公告' => 'Operation notices', '用户阅读记录' => 'User read log', '充值档位' => 'Deposit tiers', + '充值提现收银配置' => 'Pay / receipt settings', + '支付/收款配置' => 'Pay / receipt settings', + '充值渠道' => 'Deposit channels', + '渠道充值订单' => 'Channel deposit orders', '连胜奖励' => 'Win streak rewards', '连胜降低档位' => 'Streak reduction tiers', '钱包加减点' => 'Wallet adjust', diff --git a/app/common/library/game/DepositChannel.php b/app/common/library/game/DepositChannel.php new file mode 100644 index 0000000..e24bc63 --- /dev/null +++ b/app/common/library/game/DepositChannel.php @@ -0,0 +1,438 @@ + + */ + public static function codeRegistry(): array + { + $base = [ + 'directpay' => ['name' => 'DirectPay', 'name_en' => 'DirectPay', 'sort' => 10], + ]; + $extra = self::registryFromEnv(); + foreach ($extra as $code => $meta) { + if (!is_string($code) || trim($code) === '' || !is_array($meta)) { + continue; + } + $normCode = strtolower(trim($code)); + if (!preg_match('/^[a-z0-9_\-]{1,24}$/', $normCode)) { + continue; + } + $name = isset($meta['name']) && is_string($meta['name']) ? trim($meta['name']) : ''; + $nameEn = isset($meta['name_en']) && is_string($meta['name_en']) ? trim($meta['name_en']) : ''; + $sort = isset($meta['sort']) && is_numeric($meta['sort']) ? intval($meta['sort']) : 50; + if ($name === '') { + continue; + } + $base[$normCode] = [ + 'name' => $name, + 'name_en' => $nameEn !== '' ? $nameEn : $name, + 'sort' => $sort, + ]; + } + return $base; + } + + /** + * @return array + */ + private static function registryFromEnv(): array + { + $raw = getenv('DEPOSIT_CHANNELS_REGISTRY_JSON'); + if (!is_string($raw) || trim($raw) === '') { + return []; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return []; + } + $out = []; + foreach ($decoded as $item) { + if (!is_array($item)) { + continue; + } + $code = isset($item['code']) && is_string($item['code']) ? strtolower(trim($item['code'])) : ''; + if ($code === '') { + continue; + } + $out[$code] = $item; + } + return $out; + } + + public static function parseOverridesFromConfigValue($raw): array + { + if (!is_string($raw) || trim($raw) === '') { + return []; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return []; + } + $list = isset($decoded['channels']) && is_array($decoded['channels']) ? $decoded['channels'] : $decoded; + + return self::normalizeOverrides($list); + } + + /** + * 库中「运营覆盖」原始列表(未与注册表合并):finance_cashier 含 channels 键时用其值;否则读 deposit_channel + * + * @return list}> + */ + public static function parseStoredOverridesFromDb(): array + { + $fcRow = Db::name('game_config')->where('config_key', FinanceCashierConfig::CONFIG_KEY)->find(); + $rawFc = is_array($fcRow) ? ($fcRow['config_value'] ?? null) : null; + $decoded = null; + if (is_string($rawFc) && trim($rawFc) !== '') { + $tmp = json_decode($rawFc, true); + if (is_array($tmp)) { + $decoded = $tmp; + } + } + $channelsKeyPresent = is_array($decoded) && array_key_exists('channels', $decoded); + if ($channelsKeyPresent) { + $list = isset($decoded['channels']) && is_array($decoded['channels']) ? $decoded['channels'] : []; + + return self::normalizeOverrides($list); + } + $depRow = Db::name('game_config')->where('config_key', self::CONFIG_KEY)->find(); + $depRaw = is_array($depRow) ? ($depRow['config_value'] ?? null) : null; + + return self::parseOverridesFromConfigValue($depRaw); + } + + /** + * 与注册表合并并排序后的有效渠道行(业务侧统一入口) + * + * @return list}> + */ + public static function effectiveRowsFromDb(): array + { + $stored = self::parseStoredOverridesFromDb(); + + return self::effectiveOverrides(self::expandRowsForAdmin($stored)); + } + + /** + * @param list $items + * + * @return list}> + */ + public static function normalizeOverrides(array $items): array + { + $out = []; + foreach ($items as $row) { + if (!is_array($row)) { + continue; + } + $code = isset($row['code']) && is_string($row['code']) ? strtolower(trim($row['code'])) : ''; + if ($code === '') { + continue; + } + $sort = isset($row['sort']) && is_numeric($row['sort']) ? intval($row['sort']) : 0; + $status = isset($row['status']) && is_numeric($row['status']) ? intval($row['status']) : 1; + $status = $status === 1 ? 1 : 0; + $tierIds = []; + if (isset($row['tier_ids']) && is_array($row['tier_ids'])) { + foreach ($row['tier_ids'] as $tid) { + if (is_string($tid)) { + $t = trim($tid); + if ($t !== '' && preg_match('/^[a-zA-Z0-9_\-]{1,32}$/', $t)) { + $tierIds[] = $t; + } + } + } + $tierIds = array_values(array_unique($tierIds)); + } + $out[] = [ + 'code' => $code, + 'sort' => $sort, + 'status' => $status, + 'tier_ids' => $tierIds, + ]; + } + + return $out; + } + + /** + * 合并注册表与运营覆盖;若库中无覆盖则对注册表内全部渠道启用默认行 + * + * @param list}> $overrides + * + * @return list}> + */ + public static function effectiveOverrides(array $overrides): array + { + $registry = self::codeRegistry(); + $byCode = []; + foreach ($overrides as $row) { + if (!isset($registry[$row['code']])) { + continue; + } + $byCode[$row['code']] = $row; + } + if ($byCode === []) { + foreach ($registry as $code => $meta) { + $sortMeta = $meta['sort'] ?? 10; + $sortVal = is_numeric($sortMeta) ? intval($sortMeta) : 10; + $byCode[$code] = [ + 'code' => $code, + 'sort' => $sortVal, + 'status' => 1, + 'tier_ids' => [], + ]; + } + } + $list = array_values($byCode); + usort($list, static function (array $a, array $b): int { + if ($a['sort'] !== $b['sort']) { + return $a['sort'] <=> $b['sort']; + } + + return strcmp($a['code'], $b['code']); + }); + + return $list; + } + + /** + * @param array{code: string, sort: int, status: int, tier_ids: list} $overrideRow + */ + public static function isTierAllowed(array $overrideRow, string $tierId): bool + { + $ids = $overrideRow['tier_ids'] ?? []; + if (!is_array($ids) || $ids === []) { + return true; + } + foreach ($ids as $id) { + if (is_string($id) && $id === $tierId) { + return true; + } + } + + return false; + } + + /** + * @param list> $effectiveRows + */ + public static function findMergedByCode(array $effectiveRows, string $code): ?array + { + $norm = strtolower(trim($code)); + foreach ($effectiveRows as $row) { + if (!is_array($row)) { + continue; + } + $c = isset($row['code']) && is_string($row['code']) ? strtolower(trim($row['code'])) : ''; + if ($c === $norm) { + return $row; + } + } + + return null; + } + + /** + * @param list}> $overrideRows + * + * @return list + */ + public static function channelsForTier(string $tierId, array $overrideRows, string $lang): array + { + $registry = self::codeRegistry(); + $out = []; + foreach ($overrideRows as $row) { + if (($row['status'] ?? 0) !== 1) { + continue; + } + $code = $row['code']; + if (!isset($registry[$code])) { + continue; + } + if (!self::isTierAllowed($row, $tierId)) { + continue; + } + $meta = $registry[$code]; + $name = self::pickLangName($meta, $lang); + $sortRaw = $row['sort'] ?? 0; + $sortVal = is_numeric($sortRaw) ? intval($sortRaw) : 0; + $out[] = [ + 'code' => $code, + 'name' => $name, + 'sort' => $sortVal, + ]; + } + usort($out, static function (array $a, array $b): int { + if ($a['sort'] !== $b['sort']) { + return $a['sort'] <=> $b['sort']; + } + + return strcmp($a['code'], $b['code']); + }); + + return $out; + } + + /** + * @param array $meta + */ + private static function pickLangName(array $meta, string $lang): string + { + $name = isset($meta['name']) && is_string($meta['name']) ? $meta['name'] : ''; + $nameEn = isset($meta['name_en']) && is_string($meta['name_en']) ? $meta['name_en'] : ''; + $normalized = strtolower(str_replace('_', '-', trim($lang))); + $isEn = $normalized === 'en' || str_starts_with($normalized, 'en-'); + if ($isEn) { + return $nameEn !== '' ? $nameEn : $name; + } + + return $name !== '' ? $name : $nameEn; + } + + /** + * @param list}> $effectiveRows + */ + public static function assertChannelAllowsTier(string $channelCode, string $tierId, array $effectiveRows): bool + { + $row = self::findMergedByCode($effectiveRows, $channelCode); + if ($row === null) { + return false; + } + if (($row['status'] ?? 0) !== 1) { + return false; + } + + return self::isTierAllowed($row, $tierId); + } + + /** + * @param list}> $effectiveRows + * + * @return list + */ + public static function enabledPayChannelCodes(array $effectiveRows): array + { + $registry = self::codeRegistry(); + $codes = []; + foreach ($effectiveRows as $row) { + if (($row['status'] ?? 0) !== 1) { + continue; + } + $c = isset($row['code']) && is_string($row['code']) ? $row['code'] : ''; + if ($c !== '' && isset($registry[$c])) { + $codes[] = $c; + } + } + + return array_values(array_unique($codes)); + } + + /** + * @param list> $items + * + * @return list}> + */ + public static function prepareOverridesForSave(array $items): array + { + $registry = self::codeRegistry(); + $seen = []; + $out = []; + foreach ($items as $idx => $row) { + if (!is_array($row)) { + throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行格式错误'); + } + $code = isset($row['code']) && is_string($row['code']) ? strtolower(trim($row['code'])) : ''; + if ($code === '' || !isset($registry[$code])) { + throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行渠道 code 未注册'); + } + if (isset($seen[$code])) { + throw new InvalidArgumentException('渠道 code 重复:' . $code); + } + $seen[$code] = true; + $norm = self::normalizeOverrides([array_merge($row, ['code' => $code])]); + if ($norm === []) { + throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行渠道数据无效'); + } + $out[] = $norm[0]; + } + usort($out, static function (array $a, array $b): int { + if ($a['sort'] !== $b['sort']) { + return $a['sort'] <=> $b['sort']; + } + + return strcmp($a['code'], $b['code']); + }); + + return $out; + } + + /** + * @param list}> $items + */ + public static function encodeForDb(array $items): string + { + $wrapped = ['channels' => $items]; + $encoded = json_encode($wrapped, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($encoded === false) { + throw new InvalidArgumentException('JSON 编码失败'); + } + + return $encoded; + } + + /** + * 后台编辑用:为注册表内每个 code 补齐一行(合并库内覆盖) + * + * @param list}> $storedOverrides + * + * @return list}> + */ + public static function expandRowsForAdmin(array $storedOverrides): array + { + $registry = self::codeRegistry(); + $effective = self::effectiveOverrides($storedOverrides); + $byCode = []; + foreach ($effective as $r) { + $byCode[$r['code']] = $r; + } + $items = []; + foreach ($registry as $code => $meta) { + $sortMeta = $meta['sort'] ?? 10; + $sortDefault = is_numeric($sortMeta) ? intval($sortMeta) : 10; + $items[] = $byCode[$code] ?? [ + 'code' => $code, + 'sort' => $sortDefault, + 'status' => 1, + 'tier_ids' => [], + ]; + } + usort($items, static function (array $a, array $b): int { + if ($a['sort'] !== $b['sort']) { + return $a['sort'] <=> $b['sort']; + } + + return strcmp($a['code'], $b['code']); + }); + + return $items; + } +} diff --git a/app/common/library/game/DepositTier.php b/app/common/library/game/DepositTier.php index 5940d45..dd29899 100644 --- a/app/common/library/game/DepositTier.php +++ b/app/common/library/game/DepositTier.php @@ -10,11 +10,13 @@ use InvalidArgumentException; * 充值档位(game_config.deposit_tier):仅存 JSON 数组 * * 每一项字段(mock/第三方支付模式,已不再保存收款账户信息;支持中英文双语): - * - id : string,档位稳定 ID(如 t_xxxxxxxx) + * - id : string,档位稳定唯一标识(如 t_xxxxxxxx),移动端下单 tier_id / tier_key * - title : string,档位中文名称(必填,前端中文环境展示) * - title_en : string,档位英文名称(可选,前端英文环境展示;为空时回退到 title) - * - amount : string,充值金额(4 位小数) - * - bonus_amount : string,赠送金额(4 位小数,可为 0) + * - currency : string,支付货币代码(3~8 位大写字母,如 MYR、CNY) + * - pay_amount : string,玩家支付的法币/支付货币额度(4 位小数) + * - amount : string,到账基础平台币(4 位小数) + * - bonus_amount : string,赠送平台币(4 位小数,可为 0) * - desc : string,档位中文描述(可空,<=255) * - desc_en : string,档位英文描述(可空,<=255,为空时回退到 desc) * - sort : int,排序权重(小值在前) @@ -33,6 +35,8 @@ final class DepositTier * id: string, * title: string, * title_en: string, + * currency: string, + * pay_amount: string, * amount: string, * bonus_amount: string, * desc: string, @@ -83,8 +87,18 @@ final class DepositTier } $titleEn = self::stringField($row, 'title_en'); + $currency = self::normalizeCurrency($row['currency'] ?? ''); + if ($currency === '') { + $currency = 'CNY'; + } + + $payAmount = self::normalizeAmount($row['pay_amount'] ?? ''); $amount = self::normalizeAmount($row['amount'] ?? ''); $bonus = self::normalizeAmount($row['bonus_amount'] ?? '0'); + if (bccomp($payAmount, '0', 4) <= 0 && bccomp($amount, '0', 4) > 0) { + // 历史数据仅有 amount(平台币)时,用占位同步 pay_amount,运营应在后台改为真实支付额度 + $payAmount = $amount; + } $desc = self::stringField($row, 'desc'); if ($desc === '') { @@ -100,6 +114,8 @@ final class DepositTier 'id' => $id, 'title' => $title, 'title_en' => $titleEn, + 'currency' => $currency, + 'pay_amount' => $payAmount, 'amount' => $amount, 'bonus_amount' => $bonus, 'desc' => $desc, @@ -165,9 +181,19 @@ final class DepositTier throw new InvalidArgumentException('第 ' . $no . ' 行英文充值名称过长'); } + $currency = self::normalizeCurrency($row['currency'] ?? ''); + if ($currency === '') { + throw new InvalidArgumentException('第 ' . $no . ' 行支付货币不能为空'); + } + + $payAmount = self::normalizeAmount($row['pay_amount'] ?? ''); + if (bccomp($payAmount, '0', 4) <= 0) { + throw new InvalidArgumentException('第 ' . $no . ' 行支付货币额度必须大于 0'); + } + $amount = self::normalizeAmount($row['amount'] ?? ''); if (bccomp($amount, '0', 4) <= 0) { - throw new InvalidArgumentException('第 ' . $no . ' 行充值金额必须大于 0'); + throw new InvalidArgumentException('第 ' . $no . ' 行基础平台币到账必须大于 0'); } $bonus = self::normalizeAmount($row['bonus_amount'] ?? '0'); @@ -193,6 +219,8 @@ final class DepositTier 'id' => $id, 'title' => $title, 'title_en' => $titleEn, + 'currency' => $currency, + 'pay_amount' => $payAmount, 'amount' => $amount, 'bonus_amount' => $bonus, 'desc' => $desc, @@ -340,6 +368,26 @@ final class DepositTier return is_string($v) ? trim($v) : ''; } + /** + * 支付货币代码:3~8 位字母,统一大写 + * + * @param mixed $raw + */ + private static function normalizeCurrency($raw): string + { + if (!is_string($raw)) { + return ''; + } + $s = strtoupper(trim($raw)); + if ($s === '') { + return ''; + } + if (!preg_match('/^[A-Z]{3,8}$/', $s)) { + return ''; + } + return $s; + } + private static function isEnglishLang(string $lang): bool { $normalized = strtolower(str_replace('_', '-', trim($lang))); diff --git a/app/common/library/game/FinanceCashierConfig.php b/app/common/library/game/FinanceCashierConfig.php new file mode 100644 index 0000000..df0e2cc --- /dev/null +++ b/app/common/library/game/FinanceCashierConfig.php @@ -0,0 +1,392 @@ + + */ + public static function defaultPayload(): array + { + return [ + 'platform_coin' => [ + 'label_zh' => '钻石', + 'label_en' => 'Diamonds', + ], + 'currencies' => [ + [ + 'code' => 'MYR', + 'label_zh' => '马来西亚林吉特', + 'label_en' => 'Malaysian Ringgit', + 'sort' => 10, + 'deposit_coins_per_fiat' => '100', + 'withdraw_coins_per_fiat' => '100', + ], + [ + 'code' => 'VND', + 'label_zh' => '越南盾', + 'label_en' => 'Vietnamese Dong', + 'sort' => 20, + 'deposit_coins_per_fiat' => '10', + 'withdraw_coins_per_fiat' => '10', + ], + [ + 'code' => 'USDT', + 'label_zh' => 'USDT', + 'label_en' => 'USDT', + 'sort' => 30, + 'deposit_coins_per_fiat' => '1', + 'withdraw_coins_per_fiat' => '1', + ], + ], + 'withdraw_banks' => [ + ['code' => 'agrobank', 'name_zh' => 'Agrobank', 'name_en' => 'Agrobank', 'sort' => 10], + ], + 'withdraw_limits' => [ + 'min_ewallet' => '10', + 'min_bank' => '10', + ], + 'withdraw_copy' => [ + 'rate_hint_zh' => '汇率为参考价格,实际以提现时为准。', + 'rate_hint_en' => 'Exchange rates are for reference only; the actual rate at withdrawal time prevails.', + 'processing_zh' => '9秒即可到账', + 'processing_en' => 'Arrives in seconds', + 'fee_note_zh' => '注意:RM 10 — RM 99.99 之间的交易将收取最低 RM 1 的提现手续费。', + 'fee_note_en' => 'A minimum RM 1 handling fee may apply for withdrawals between RM 10 and RM 99.99.', + 'rate_mode' => 'fixed', + ], + 'withdraw_fields' => [ + 'require_cardholder' => true, + 'require_bank_account' => true, + 'require_email' => true, + 'require_mobile' => true, + ], + 'channels' => [], + ]; + } + + /** + * @return array + */ + public static function parseFromConfigValue(mixed $raw): array + { + $out = self::defaultPayload(); + if (!is_string($raw) || trim($raw) === '') { + return self::normalizePayload($out, []); + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return self::normalizePayload($out, []); + } + if (isset($decoded['platform_coin']) && is_array($decoded['platform_coin'])) { + $out['platform_coin'] = array_replace($out['platform_coin'], array_intersect_key($decoded['platform_coin'], $out['platform_coin'])); + } + $legacyRates = []; + if (isset($decoded['rates']) && is_array($decoded['rates'])) { + $legacyRates = array_values($decoded['rates']); + } + foreach (['currencies', 'withdraw_banks', 'channels'] as $listKey) { + if (isset($decoded[$listKey]) && is_array($decoded[$listKey])) { + $out[$listKey] = array_values($decoded[$listKey]); + } + } + if (isset($decoded['withdraw_limits']) && is_array($decoded['withdraw_limits'])) { + $out['withdraw_limits'] = array_replace($out['withdraw_limits'], array_intersect_key($decoded['withdraw_limits'], $out['withdraw_limits'])); + } + if (isset($decoded['withdraw_copy']) && is_array($decoded['withdraw_copy'])) { + $out['withdraw_copy'] = array_replace($out['withdraw_copy'], array_intersect_key($decoded['withdraw_copy'], $out['withdraw_copy'])); + } + if (isset($decoded['withdraw_fields']) && is_array($decoded['withdraw_fields'])) { + $out['withdraw_fields'] = array_replace($out['withdraw_fields'], array_intersect_key($decoded['withdraw_fields'], $out['withdraw_fields'])); + } + + return self::normalizePayload($out, $legacyRates); + } + + public static function encodeForDb(array $payload): string + { + $legacyRates = []; + if (isset($payload['rates']) && is_array($payload['rates'])) { + $legacyRates = array_values($payload['rates']); + } + $normalized = self::normalizePayload($payload, $legacyRates); + $normalized['channels'] = DepositChannel::prepareOverridesForSave( + DepositChannel::expandRowsForAdmin($normalized['channels'] ?? []) + ); + self::validate($normalized); + + return json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + /** + * @param array $p + * @param list> $legacyRates + * + * @return array + */ + private static function normalizePayload(array $p, array $legacyRates): array + { + $defaults = self::defaultPayload(); + $out = array_replace($defaults, $p); + unset($out['rates'], $out['fx_pairs']); + + $withdrawFromLegacyRates = []; + foreach ($legacyRates as $r) { + if (!is_array($r)) { + continue; + } + $cur = isset($r['currency']) && is_string($r['currency']) ? strtoupper(trim($r['currency'])) : ''; + $ratio = self::ratioStringFromRow($r, 'diamonds_per_fiat_unit'); + if ($cur !== '' && $ratio !== '') { + $withdrawFromLegacyRates[$cur] = $ratio; + } + } + + if (isset($out['platform_coin']) && is_array($out['platform_coin'])) { + $out['platform_coin'] = array_replace($defaults['platform_coin'], $out['platform_coin']); + } + if (isset($out['currencies']) && is_array($out['currencies'])) { + foreach ($out['currencies'] as $i => $row) { + if (!is_array($row)) { + unset($out['currencies'][$i]); + continue; + } + $code = isset($row['code']) && is_string($row['code']) ? strtoupper(trim($row['code'])) : ''; + $dep = self::ratioStringFromRow($row, 'deposit_coins_per_fiat'); + $wdr = self::ratioStringFromRow($row, 'withdraw_coins_per_fiat'); + if ($wdr === '') { + $wdr = self::ratioStringFromRow($row, 'diamonds_per_fiat_unit'); + } + if ($wdr === '' && $code !== '' && isset($withdrawFromLegacyRates[$code])) { + $wdr = $withdrawFromLegacyRates[$code]; + } + if ($dep === '' && $wdr !== '') { + $dep = $wdr; + } + if ($wdr === '' && $dep !== '') { + $wdr = $dep; + } + if ($code !== '' && $dep === '' && $wdr === '') { + $dep = '100'; + $wdr = '100'; + } + $out['currencies'][$i] = [ + 'code' => $code, + 'label_zh' => isset($row['label_zh']) && is_string($row['label_zh']) ? trim($row['label_zh']) : '', + 'label_en' => isset($row['label_en']) && is_string($row['label_en']) ? trim($row['label_en']) : '', + 'sort' => self::normalizeSort($row['sort'] ?? 0), + 'deposit_coins_per_fiat' => $dep, + 'withdraw_coins_per_fiat' => $wdr, + ]; + } + $out['currencies'] = array_values(array_filter($out['currencies'], static fn ($r) => is_array($r) && $r['code'] !== '')); + } + + usort($out['currencies'], static function (array $a, array $b): int { + $sa = $a['sort'] ?? 0; + $sb = $b['sort'] ?? 0; + if ($sa !== $sb) { + return $sa <=> $sb; + } + $ca = $a['code'] ?? ''; + $cb = $b['code'] ?? ''; + + return strcmp($ca, $cb); + }); + + if (isset($out['withdraw_banks']) && is_array($out['withdraw_banks'])) { + foreach ($out['withdraw_banks'] as $i => $row) { + if (!is_array($row)) { + unset($out['withdraw_banks'][$i]); + continue; + } + $code = isset($row['code']) && is_string($row['code']) ? strtolower(trim($row['code'])) : ''; + $out['withdraw_banks'][$i] = [ + 'code' => $code, + 'name_zh' => isset($row['name_zh']) && is_string($row['name_zh']) ? trim($row['name_zh']) : '', + 'name_en' => isset($row['name_en']) && is_string($row['name_en']) ? trim($row['name_en']) : '', + 'sort' => self::normalizeSort($row['sort'] ?? 0), + ]; + } + $out['withdraw_banks'] = array_values(array_filter($out['withdraw_banks'], static fn ($r) => is_array($r) && $r['code'] !== '')); + usort($out['withdraw_banks'], static function (array $a, array $b): int { + $sa = $a['sort'] ?? 0; + $sb = $b['sort'] ?? 0; + if ($sa !== $sb) { + return $sa <=> $sb; + } + $ca = $a['code'] ?? ''; + $cb = $b['code'] ?? ''; + + return strcmp($ca, $cb); + }); + } + if (isset($out['withdraw_limits']) && is_array($out['withdraw_limits'])) { + $wl = array_replace($defaults['withdraw_limits'], $out['withdraw_limits']); + foreach (['min_ewallet', 'min_bank'] as $k) { + if (isset($wl[$k]) && is_numeric($wl[$k]) && !is_string($wl[$k])) { + $wl[$k] = strval($wl[$k]); + } + if (!isset($wl[$k]) || !is_string($wl[$k])) { + $wl[$k] = $defaults['withdraw_limits'][$k]; + } + } + $out['withdraw_limits'] = $wl; + } + if (isset($out['withdraw_copy']) && is_array($out['withdraw_copy'])) { + $out['withdraw_copy'] = array_replace($defaults['withdraw_copy'], array_intersect_key($out['withdraw_copy'], $defaults['withdraw_copy'])); + $mode = isset($out['withdraw_copy']['rate_mode']) && is_string($out['withdraw_copy']['rate_mode']) + ? strtolower(trim($out['withdraw_copy']['rate_mode'])) + : 'fixed'; + if (!in_array($mode, ['fixed', 'live'], true)) { + $mode = 'fixed'; + } + $out['withdraw_copy']['rate_mode'] = $mode; + } + if (isset($out['withdraw_fields']) && is_array($out['withdraw_fields'])) { + $wf = array_replace($defaults['withdraw_fields'], array_intersect_key($out['withdraw_fields'], $defaults['withdraw_fields'])); + foreach (array_keys($defaults['withdraw_fields']) as $fk) { + $wf[$fk] = !empty($wf[$fk]); + } + $out['withdraw_fields'] = $wf; + } + if (isset($out['channels']) && is_array($out['channels'])) { + $out['channels'] = DepositChannel::normalizeOverrides(array_values($out['channels'])); + } else { + $out['channels'] = []; + } + usort($out['channels'], static function (array $a, array $b): int { + $sa = $a['sort'] ?? 0; + $sb = $b['sort'] ?? 0; + if ($sa !== $sb) { + return $sa <=> $sb; + } + + return strcmp($a['code'] ?? '', $b['code'] ?? ''); + }); + + unset($out['fx_pairs']); + + return $out; + } + + /** + * @param array $row + */ + private static function ratioStringFromRow(array $row, string $key): string + { + if (!isset($row[$key])) { + return ''; + } + if (is_string($row[$key])) { + return trim($row[$key]); + } + if (is_numeric($row[$key])) { + return strval($row[$key]); + } + + return ''; + } + + private static function normalizeSort(mixed $v): int + { + $opt = filter_var($v, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 99999]]); + if ($opt !== false) { + return $opt; + } + if (is_numeric($v)) { + $f = filter_var($v, FILTER_VALIDATE_FLOAT); + if ($f !== false) { + $rounded = round($f); + $opt2 = filter_var($rounded, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 99999]]); + if ($opt2 !== false) { + return $opt2; + } + } + } + + return 0; + } + + /** + * @param array $p + */ + private static function validate(array $p): void + { + if (!isset($p['platform_coin']) || !is_array($p['platform_coin'])) { + throw new InvalidArgumentException('platform_coin 格式错误'); + } + $lz = $p['platform_coin']['label_zh'] ?? ''; + $le = $p['platform_coin']['label_en'] ?? ''; + if (!is_string($lz) || trim($lz) === '' || !is_string($le) || trim($le) === '') { + throw new InvalidArgumentException('请填写平台币中英文名称'); + } + if (!isset($p['currencies']) || !is_array($p['currencies']) || $p['currencies'] === []) { + throw new InvalidArgumentException('至少保留一条货币'); + } + $seenCodes = []; + foreach ($p['currencies'] as $idx => $row) { + if (!is_array($row)) { + throw new InvalidArgumentException('货币列表第 ' . ($idx + 1) . ' 行格式错误'); + } + $code = $row['code'] ?? ''; + if (!is_string($code) || !preg_match('/^[A-Z0-9]{2,12}$/', $code)) { + throw new InvalidArgumentException('货币代码非法:' . (is_string($code) ? $code : '')); + } + if (isset($seenCodes[$code])) { + throw new InvalidArgumentException('货币代码不能重复:' . $code); + } + $seenCodes[$code] = true; + $dep = $row['deposit_coins_per_fiat'] ?? ''; + $wdr = $row['withdraw_coins_per_fiat'] ?? ''; + if (!is_string($dep) || $dep === '' || !is_numeric($dep) || bccomp($dep, '0', 8) <= 0) { + throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行:充值汇率须为大于 0 的数字'); + } + if (!is_string($wdr) || $wdr === '' || !is_numeric($wdr) || bccomp($wdr, '0', 8) <= 0) { + throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行:提现汇率须为大于 0 的数字'); + } + } + if (isset($p['withdraw_banks']) && is_array($p['withdraw_banks'])) { + $seen = []; + foreach ($p['withdraw_banks'] as $idx => $row) { + if (!is_array($row)) { + throw new InvalidArgumentException('银行第 ' . ($idx + 1) . ' 行格式错误'); + } + $code = $row['code'] ?? ''; + if (!is_string($code) || !preg_match('/^[a-z0-9][a-z0-9_\-]{0,31}$/', $code)) { + throw new InvalidArgumentException('银行代码非法'); + } + if (isset($seen[$code])) { + throw new InvalidArgumentException('银行代码重复:' . $code); + } + $seen[$code] = true; + } + } + if (isset($p['withdraw_limits']) && is_array($p['withdraw_limits'])) { + foreach (['min_ewallet', 'min_bank'] as $k) { + $v = $p['withdraw_limits'][$k] ?? '0'; + if (!is_string($v) || !is_numeric($v) || bccomp($v, '0', 4) < 0) { + throw new InvalidArgumentException('提现最低限额须为不小于 0 的数字'); + } + } + } + $reg = DepositChannel::codeRegistry(); + if ($reg !== [] && (!isset($p['channels']) || !is_array($p['channels']) || $p['channels'] === [])) { + throw new InvalidArgumentException('请配置充值渠道'); + } + } +} diff --git a/config/route.php b/config/route.php index 0d7c715..2f6b276 100644 --- a/config/route.php +++ b/config/route.php @@ -139,6 +139,7 @@ Route::add(['GET', 'POST'], '/api/finance/depositTierList', [\app\api\controller Route::post('/api/finance/depositCreate', [\app\api\controller\Finance::class, 'depositCreate']); Route::add(['GET', 'POST'], '/api/finance/depositDetail', [\app\api\controller\Finance::class, 'depositDetail']); Route::add(['GET', 'POST'], '/api/finance/depositList', [\app\api\controller\Finance::class, 'depositList']); +Route::add(['GET', 'POST'], '/api/finance/cashierConfig', [\app\api\controller\Finance::class, 'cashierConfig']); Route::post('/api/finance/withdrawCreate', [\app\api\controller\Finance::class, 'withdrawCreate']); Route::add(['GET', 'POST'], '/api/finance/withdrawDetail', [\app\api\controller\Finance::class, 'withdrawDetail']); Route::add(['GET', 'POST'], '/api/finance/withdrawList', [\app\api\controller\Finance::class, 'withdrawList']); diff --git a/docs/36字花-数据库与实施计划.md b/docs/36字花-数据库与实施计划.md index 12e4cc9..3a02066 100644 --- a/docs/36字花-数据库与实施计划.md +++ b/docs/36字花-数据库与实施计划.md @@ -269,6 +269,16 @@ | 提现 | 有效投注 ≥ 总充值×配置倍数;最低提现与 0.5% 手续费;大额 / Jackpot 进审核 | | 灾难恢复 | 模拟期卡在「计算中」,启动退本流程:期作废、本金退回账本 | +#### 8.2.1 后台「游戏实时对局」:运行开关与作废 + +| 项 | 说明 | +|----|------| +| 配置键 | `game_config.config_key = game_live_runtime_enabled`:`1` = 运行(默认),`0` = 维护/关闭 | +| 关闭时服务端行为 | **禁止下注**(移动端 **`betPlace`** 返回 `3001`);**无进行中局时**定时 **`GamePeriodAutoTicker`** 不创建新期;派彩宽限期结束后的 **`createNextRecordAfterDraw`** 不插入下一期。**当局仍继续**:**`tickAutoDraw` / `drawResult` / 结算**照常执行,直至本期结束。后台 **`POST /admin/game.Live/runtime` 且 `enabled=1`** 时,若 **`hasActiveRecord()` 为假**,则 **`bootstrapPeriodWhenRuntimeEnabled`** 立即插入新一期 | +| 后台快照 `maintenance_ui` | `!game_live_runtime_enabled && !hasActiveRecord()` 时为 `true`:表示**当局已完全结束**(含派彩),此时后台页展示「维护中」并锁定操作区(仅顶部「游戏运行」开关可用);当局尚在 0–3 状态时为 `false`,倒计时与操作区保持可用直至收尾完成 | +| 作废本局(后台按钮) | 仅当本期为 **下注开放 / 已封盘**(`game_record.status` 为 `0` 或 `1`):将所有 **待开奖** 注单(`bet_order.status = 1`)按 `total_amount` 退回余额并置 `bet_order.status = 3`,写钱包流水 `biz_type = bet_void`;本期 **`game_record.status = 5`**,写入 **`void_reason`**;并 **强制将 `game_live_runtime_enabled` 置为 `0`**。需管理员在后台再次打开「游戏运行」开关后,才恢复下注与后续期次创建 | +| 后台接口 | `POST /admin/game.Live/runtime`(body:`enabled` 0/1);`POST /admin/game.Live/voidPeriod`(body:`record_id` 可选、`void_reason` 必填) | + ### 8.3 代理与结算(逻辑就绪后) 1. 构造多代理树与多用户下注,跑一期结算脚本或单元测试纯算法。 diff --git a/docs/36字花-移动端接口设计草案.md b/docs/36字花-移动端接口设计草案.md index ad2c978..abae8fb 100644 --- a/docs/36字花-移动端接口设计草案.md +++ b/docs/36字花-移动端接口设计草案.md @@ -222,9 +222,10 @@ 返回参数: - `server_time`:int(含义:服务端当前时间,用于客户端校时) +- `runtime_enabled`:bool(含义:**游戏运行开关**;`false` 时表示后台维护——**禁止下注**,且 idle 时不会自动创建新期、派彩结束后也不会自动创建下一期;**当前已开盘的局仍会开奖、派彩并结算**。移动端应禁用下注入口并提示「维护」类文案) - `period`:object - `period_no`:string(含义:当前全局期号) - - `status`:string(`betting`/`locked`/`settling`/`finished`,含义:当前期状态) + - `status`:string(`betting`/`locked`/`settling`/`finished`/`void`,含义:当前期状态;`void` 表示该期已作废) - `countdown`:int(含义:当前期倒计时秒数) - `lock_at`:int(含义:封盘时间戳) - `open_at`:int(含义:预计开奖时间戳) @@ -266,9 +267,10 @@ - **POST** `/api/game/periodCurrent` 返回参数: +- `runtime_enabled`:bool(含义:同 `lobbyInit.runtime_enabled`) - `period_id`:int(含义:当前期主键 ID) - `period_no`:string(含义:当前期号) -- `status`:string(含义:当前期状态) +- `status`:string(含义:当前期状态,含 `void` 已作废) - `countdown`:int(含义:当前期剩余秒数) - `bet_close_in`:int(含义:距离封盘剩余秒数) - `result_number`:int/null(未开奖为 null,含义:开奖号码) @@ -293,6 +295,7 @@ **可能错误码(补充)**(其余见文档头部错误码分段;扣款与缓存一致性强相关): +- `3001`:游戏已暂停(`runtime_enabled=false`,后台「游戏实时对局」关闭运行开关或作废本局后未重新开启;与非法流程类错误同段) - `5000`:系统繁忙;或 **用户 Redis 互斥锁**未获取(与后台钱包/并发写同一用户串行,文案与后台一致:「该用户正在被其他管理员操作(钱包/并发保存),请稍后再试」);或 **`coin` 条件更新**未命中(并发下注/派彩/后台已改余额:「扣款失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试」)。 > 说明:一键重复上一注、自动托管开启/停止均由前端控制,客户端在相应时机调用 `/api/game/betPlace` 即可完成,不再提供独立接口。 @@ -359,6 +362,7 @@ - `bet`:下注扣款(提交下注时从玩家余额扣除的投注金额) - `payout`:开奖派彩(中奖后系统将奖金入账到玩家余额) - `adjust`:人工调整(后台管理员加/扣点,对应 `biz_type=admin_credit/admin_deduct`) + - `bet_void`:期次作废退款(后台「游戏实时对局」作废本局时,退回待开奖注单本金) 返回参数: - `list`:array diff --git a/web/src/lang/backend/en/config/depositChannel.ts b/web/src/lang/backend/en/config/depositChannel.ts new file mode 100644 index 0000000..ccc3d5f --- /dev/null +++ b/web/src/lang/backend/en/config/depositChannel.ts @@ -0,0 +1,16 @@ +export default { + desc: 'Pay channels are registered in code and environment variables (display name, code). Here you only toggle availability, sort order, and which deposit tiers each channel accepts. Leave tiers empty to allow all enabled tiers. Changes apply immediately.', + btn_save: 'Save', + sort: 'Sort', + status: 'Enabled', + code: 'Channel code', + display_name: 'Display name (read-only)', + tier_ids: 'Allowed tier ids', + tier_ids_ph: 'Empty = all tiers', + 'quick Search Fields': 'channel code / display name', + form_tip: 'Display names come from the code/env registry and cannot be edited here; adjust sort, status, and allowed tiers.', + status_on: 'Enabled', + status_off: 'Disabled', + tier_all: 'All tiers', + rule_sort: 'Sort value is required', +} diff --git a/web/src/lang/backend/en/config/depositTier.ts b/web/src/lang/backend/en/config/depositTier.ts index 889917d..2be8f91 100644 --- a/web/src/lang/backend/en/config/depositTier.ts +++ b/web/src/lang/backend/en/config/depositTier.ts @@ -1,29 +1,47 @@ export default { title: 'Deposit Tiers', - desc: 'Configure the deposit tiers players can pick when creating a deposit order. In the third-party payment mode, only tier specs (name, amount, bonus, description) are maintained; receiving accounts are no longer stored here. Maintain both Chinese and English text for the title and description: the mobile API returns the language matching the request `lang` header, falling back to Chinese if English is blank. Changes take effect immediately.', + desc: 'Configure mobile deposit tiers: titles (ZH/EN), pay currency & pay amount, credited platform coins (base + bonus), and stable tier id (tier_key) for orders and channel binding. Disabled tiers are hidden from players. Changes apply immediately.', + 'quick Search Fields': 'tier id / title (ZH) / title (EN)', + form_tip: 'The table is read-only; use toolbar Add / Edit in the dialog. Changes apply immediately.', + tier_id_optional: 'Tier id (optional)', + tier_id_optional_ph: 'Leave empty to auto-generate', + status_on: 'Enabled', + status_off: 'Disabled', + total_platform: 'Total credit (base + bonus)', + rule_title: 'Title (ZH) is required', + rule_currency: 'Pay currency is required', + rule_pay_amount: 'Pay amount must be a number greater than 0', + rule_platform_base: 'Base platform coin must be a number greater than 0', + rule_bonus: 'Bonus must be a number no less than 0', btn_add: 'Add Tier', btn_save: 'Save', btn_remove: 'Delete', confirm_remove: 'Delete this deposit tier?', - tier_id: 'Tier ID', + tier_id: 'Tier key', auto_id: '(generated on save)', sort: 'Sort', status: 'Enabled', title_col: 'Title (ZH)', - title_ph: 'e.g. 新手首充、VIP 高额充值', + title_ph: 'e.g. First recharge, VIP top-up bundle', title_en_col: 'Title (EN)', title_en_ph: 'e.g. Starter Pack, VIP Recharge', - amount: 'Amount', - amount_ph: 'e.g. 100.00', - bonus_amount: 'Bonus', + pay_currency: 'Pay currency', + pay_currency_ph: 'Select or type', + pay_amount: 'Pay amount (fiat)', + pay_amount_ph: 'e.g. 3.00', + platform_base: 'Base platform coin', + platform_base_ph: 'e.g. 210.00', + bonus_amount: 'Bonus platform coin', bonus_ph: 'e.g. 20.00, use 0 if none', + platform_coin_suffix: 'coin', desc_col: 'Description (ZH)', - desc_ph: 'Optional Chinese description, up to 255 chars', + desc_ph: 'Optional description for Chinese locale, up to 255 characters', desc_en_col: 'Description (EN)', desc_en_ph: 'Optional English description, up to 255 chars', - currency: '', operate: 'Action', - err_title: 'Row {no}: Chinese title is required', - err_amount: 'Row {no}: amount must be a number greater than 0', + err_title: 'Row {no}: Title (ZH) is required', + err_currency: 'Row {no}: pay currency is required', + err_pay_amount: 'Row {no}: pay amount must be a number greater than 0', + err_platform_base: 'Row {no}: base platform coin must be a number greater than 0', err_bonus: 'Row {no}: bonus must be a number no less than 0', } diff --git a/web/src/lang/backend/en/config/financeCashierConfig.ts b/web/src/lang/backend/en/config/financeCashierConfig.ts new file mode 100644 index 0000000..3d4f5f4 --- /dev/null +++ b/web/src/lang/backend/en/config/financeCashierConfig.ts @@ -0,0 +1,50 @@ +export default { + desc: 'Mobile pay & receipt settings: platform coin labels, currencies and rates, deposit pay channels (on/off, sort, tier scope), withdraw banks, limits, copy, and required withdraw fields. Deposit tiers are configured separately.', + btn_save: 'Save', + btn_add_row: 'Add row', + sec_platform: 'Platform coin labels', + platform_label_zh: 'Label (Chinese)', + platform_label_en: 'Label (English)', + sec_currencies: 'Currencies (deposit/withdraw selectors)', + sec_deposit_channels: 'Deposit pay channels', + deposit_channels_hint: 'Display names come from the registry; here you only set enabled state, sort order, and applicable deposit tiers. Leave tiers empty to allow all tiers.', + currency_rates_hint: 'Deposit rate: platform coins credited per 1 fiat paid. Withdraw rate: platform coins needed per 1 fiat redeemed (e.g. 100 ⇒ 100 coins = 1 fiat unit).', + err_dup_code: 'Duplicate currency codes are not allowed.', + sec_banks: 'Withdraw bank codes', + sec_limits: 'Minimum withdraw (fiat amount; match your copy currency)', + min_ewallet: 'E-wallet minimum', + min_bank: 'Bank minimum', + sec_copy: 'Withdraw page copy', + rate_mode: 'Rate display', + rate_mode_fixed: 'Fixed rate', + rate_mode_live: 'Live rate (display)', + rate_hint_zh: 'Rate hint (ZH)', + rate_hint_en: 'Rate hint (EN)', + processing_zh: 'Processing note (ZH)', + processing_en: 'Processing note (EN)', + fee_note_zh: 'Fee note (ZH)', + fee_note_en: 'Fee note (EN)', + sec_fields: 'Withdraw form (required)', + field_cardholder: 'Cardholder name', + field_bank_account: 'Bank account', + field_email: 'Payee email', + field_mobile: 'Payee mobile', + col_code: 'Code', + col_label_zh: 'Name (ZH)', + col_label_en: 'Name (EN)', + col_sort: 'Sort', + col_deposit_rate: 'Deposit rate', + col_withdraw_rate: 'Withdraw rate', + col_bank_code: 'Bank code', + col_name_zh: 'Bank (ZH)', + col_name_en: 'Bank (EN)', + ph_ratio: 'e.g. 100', + ph_currency_code: 'e.g. MYR', + ph_bank_code: 'e.g. agrobank', + ch_code: 'Channel code', + ch_display_name: 'Display name', + ch_sort: 'Sort', + ch_status: 'Enabled', + ch_tier_ids: 'Allowed deposit tiers', + ch_tier_ids_ph: 'Empty = all tiers', +} diff --git a/web/src/lang/backend/en/order/depositChannelOrder.ts b/web/src/lang/backend/en/order/depositChannelOrder.ts new file mode 100644 index 0000000..fb628bf --- /dev/null +++ b/web/src/lang/backend/en/order/depositChannelOrder.ts @@ -0,0 +1,3 @@ +export default { + 'quick Search Fields': 'order no / pay channel / tier id / idempotency key', +} diff --git a/web/src/lang/backend/zh-cn/config/depositChannel.ts b/web/src/lang/backend/zh-cn/config/depositChannel.ts new file mode 100644 index 0000000..10ea807 --- /dev/null +++ b/web/src/lang/backend/zh-cn/config/depositChannel.ts @@ -0,0 +1,16 @@ +export default { + desc: '支付渠道在代码与环境变量中注册(展示名、code);此处仅配置开关、排序,以及各渠道允许的充值档位(唯一标识 tier id)。留空「适用档位」表示不限制。修改后立即生效。', + btn_save: '保存', + sort: '排序', + status: '启用', + code: '渠道代码', + display_name: '展示名称(只读)', + tier_ids: '适用充值档位', + tier_ids_ph: '空=全部档位', + 'quick Search Fields': '渠道代码 / 展示名称', + form_tip: '展示名由代码与环境注册表决定,此处不可改;可调整排序、开关与适用档位。', + status_on: '启用', + status_off: '停用', + tier_all: '全部档位', + rule_sort: '请填写排序值', +} diff --git a/web/src/lang/backend/zh-cn/config/depositTier.ts b/web/src/lang/backend/zh-cn/config/depositTier.ts index 2b63bf6..add9694 100644 --- a/web/src/lang/backend/zh-cn/config/depositTier.ts +++ b/web/src/lang/backend/zh-cn/config/depositTier.ts @@ -1,11 +1,23 @@ export default { title: '充值档位', - desc: '配置玩家创建充值订单时可选的充值档位。第三方支付模式下仅需维护档位规格:名称、充值金额、赠送金额、描述等;不再保存收款账户信息。充值名称/描述需分别维护中英文两套:移动端接口会根据请求头 `lang` 返回对应语言,英文缺省时回退到中文。修改后立即生效。', + desc: '配置移动端充值档位:充值名称(中英文)、支付货币与支付额度、到账平台币(基础+赠送)、唯一档位标识(id/tier_key)用于下单与渠道绑定。关闭「启用」后玩家不可选该档。修改后立即生效。', + 'quick Search Fields': '唯一标识 / 中文名 / 英文名', + form_tip: '列表为只读展示;请使用工具栏「添加 / 编辑」在弹窗中维护。保存后立即生效。', + tier_id_optional: '唯一标识(可选)', + tier_id_optional_ph: '留空则由系统自动生成', + status_on: '启用', + status_off: '停用', + total_platform: '到账合计(基础+赠送)', + rule_title: '请填写中文充值名称', + rule_currency: '请选择或填写支付货币', + rule_pay_amount: '支付货币额度须为大于 0 的数字', + rule_platform_base: '基础平台币须为大于 0 的数字', + rule_bonus: '赠送平台币须为不小于 0 的数字', btn_add: '新增档位', btn_save: '保存', btn_remove: '删除', confirm_remove: '确定删除该充值档位?', - tier_id: '档位 ID', + tier_id: '唯一标识', auto_id: '(保存时生成)', sort: '排序', status: '启用', @@ -13,17 +25,23 @@ export default { title_ph: '例如:新手首充、VIP 高额充值', title_en_col: '充值名称(英文)', title_en_ph: 'e.g. Starter Pack, VIP Recharge', - amount: '充值金额', - amount_ph: '例如:100.00', - bonus_amount: '赠送金额', + pay_currency: '支付货币', + pay_currency_ph: '选择或输入', + pay_amount: '支付货币额度', + pay_amount_ph: '例如:3.00', + platform_base: '到账平台币(基础)', + platform_base_ph: '例如:210.00', + bonus_amount: '赠送平台币', bonus_ph: '例如:20.00,无赠送填 0', + platform_coin_suffix: '币', desc_col: '描述(中文)', desc_ph: '可选,展示给中文玩家的档位说明,最长 255 字', desc_en_col: '描述(英文)', desc_en_ph: 'Optional English description for EN players, up to 255 chars', - currency: '币', operate: '操作', err_title: '第 {no} 行:中文充值名称不能为空', - err_amount: '第 {no} 行:充值金额必须为大于 0 的数字', - err_bonus: '第 {no} 行:赠送金额必须为不小于 0 的数字', + err_currency: '第 {no} 行:请选择支付货币', + err_pay_amount: '第 {no} 行:支付货币额度必须为大于 0 的数字', + err_platform_base: '第 {no} 行:基础平台币到账必须为大于 0 的数字', + err_bonus: '第 {no} 行:赠送平台币必须为不小于 0 的数字', } diff --git a/web/src/lang/backend/zh-cn/config/financeCashierConfig.ts b/web/src/lang/backend/zh-cn/config/financeCashierConfig.ts new file mode 100644 index 0000000..e90c5c4 --- /dev/null +++ b/web/src/lang/backend/zh-cn/config/financeCashierConfig.ts @@ -0,0 +1,51 @@ +export default { + desc: '配置移动端支付与收款展示:平台币名称、货币与汇率、充值支付渠道(开关/排序/适用档位)、提现银行、最低限额、文案与提现表单字段。充值档位在「充值档位」中维护。', + btn_save: '保存', + btn_add_row: '新增一行', + sec_platform: '平台币展示名', + platform_label_zh: '名称(中文)', + platform_label_en: '名称(英文)', + sec_currencies: '货币列表(充值/提现货币下拉)', + sec_deposit_channels: '充值支付渠道', + deposit_channels_hint: '展示名由环境注册表决定,此处仅维护启用状态、排序与适用充值档位;不选档位表示全部档位可用。', + currency_rates_hint: '充值汇率:每支付 1 单位该货币到账的平台币;提现汇率:每兑换 1 单位该货币所需平台币(例 100 表示 100 平台币 = 1 单位)。', + err_dup_code: '货币代码不能重复,请检查后再保存。', + sec_banks: '提现支持银行代码', + sec_limits: '提现最低限额(法币金额,与文案中币种一致)', + min_ewallet: '电子钱包最低', + min_bank: '银行最低', + sec_copy: '提现页文案', + rate_mode: '汇率展示', + rate_mode_fixed: '固定汇率', + rate_mode_live: '实时汇率(展示用)', + rate_hint_zh: '汇率提示(中文)', + rate_hint_en: '汇率提示(英文)', + processing_zh: '到账说明(中文)', + processing_en: '到账说明(英文)', + fee_note_zh: '手续费说明(中文)', + fee_note_en: '手续费说明(英文)', + sec_fields: '提现表单字段(必填)', + field_cardholder: '持卡人姓名', + field_bank_account: '银行账号', + field_email: '收款邮箱', + field_mobile: '收款手机', + col_code: '代码', + col_label_zh: '中文名', + col_label_en: '英文名', + col_sort: '排序', + col_deposit_rate: '充值汇率', + col_withdraw_rate: '提现汇率', + col_bank_code: '银行代码', + col_name_zh: '银行名(中文)', + col_name_en: '银行名(英文)', + ph_ratio: '如 100', + ph_currency_code: '如 MYR', + ph_bank_code: '如 agrobank', + /** 本页「充值支付渠道」表格(不依赖 depositChannel 路由语言包) */ + ch_code: '渠道代码', + ch_display_name: '展示名称', + ch_sort: '排序', + ch_status: '启用', + ch_tier_ids: '适用充值档位', + ch_tier_ids_ph: '不选表示全部档位', +} diff --git a/web/src/lang/backend/zh-cn/order/depositChannelOrder.ts b/web/src/lang/backend/zh-cn/order/depositChannelOrder.ts new file mode 100644 index 0000000..63176f1 --- /dev/null +++ b/web/src/lang/backend/zh-cn/order/depositChannelOrder.ts @@ -0,0 +1,3 @@ +export default { + 'quick Search Fields': '订单号/支付通道/档位ID/幂等键', +} diff --git a/web/src/views/backend/config/depositChannel/index.vue b/web/src/views/backend/config/depositChannel/index.vue new file mode 100644 index 0000000..c9fdfe3 --- /dev/null +++ b/web/src/views/backend/config/depositChannel/index.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/web/src/views/backend/config/depositChannel/popupForm.vue b/web/src/views/backend/config/depositChannel/popupForm.vue new file mode 100644 index 0000000..ca983e5 --- /dev/null +++ b/web/src/views/backend/config/depositChannel/popupForm.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/web/src/views/backend/config/depositTier/index.vue b/web/src/views/backend/config/depositTier/index.vue index 0ed6eb0..6fe5467 100644 --- a/web/src/views/backend/config/depositTier/index.vue +++ b/web/src/views/backend/config/depositTier/index.vue @@ -1,278 +1,209 @@ - + diff --git a/web/src/views/backend/config/depositTier/popupForm.vue b/web/src/views/backend/config/depositTier/popupForm.vue new file mode 100644 index 0000000..0bd918b --- /dev/null +++ b/web/src/views/backend/config/depositTier/popupForm.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/web/src/views/backend/config/financeCashierConfig/index.vue b/web/src/views/backend/config/financeCashierConfig/index.vue new file mode 100644 index 0000000..c212e62 --- /dev/null +++ b/web/src/views/backend/config/financeCashierConfig/index.vue @@ -0,0 +1,518 @@ + + + + + diff --git a/web/src/views/backend/order/depositChannelOrder/index.vue b/web/src/views/backend/order/depositChannelOrder/index.vue new file mode 100644 index 0000000..de739bf --- /dev/null +++ b/web/src/views/backend/order/depositChannelOrder/index.vue @@ -0,0 +1,214 @@ + + + + +