From 9be9e2666b7295e2364b21dbaa9c02667ceeaecc Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Fri, 29 May 2026 11:18:10 +0800 Subject: [PATCH] =?UTF-8?q?1.=E6=96=B0=E5=A2=9E=E6=98=BE=E7=A4=BA=E5=88=86?= =?UTF-8?q?=E7=BA=A2=E8=AF=B4=E6=98=8E=E6=96=87=E6=A1=A3=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=202.=E6=96=87=E6=A1=A3=E6=96=B0=E5=A2=9E=E8=8B=B1=E6=96=87?= =?UTF-8?q?=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/docs/Doc36ZiHuaMobileApi.php | 39 +- .../controller/docs/DocCommissionShare.php | 82 ++ app/common/lang/en/admin_rule_title.php | 3 + app/common/lang/zh-cn/admin_rule_title.php | 4 + app/common/library/docs/MarkdownDocReader.php | 82 ++ ...60529150000_docs_commission_share_menu.php | 135 +++ docs/en/36zihua-mobile-api-design-draft.md | 905 ++++++++++++++++++ docs/en/commission-share-guide.md | 126 +++ docs/分红说明文档.md | 1 + web/src/lang/backend/en.ts | 6 + web/src/lang/backend/zh-cn.ts | 6 + .../docs/doc36ZiHuaMobileApi/index.vue | 7 + .../backend/docs/docCommissionShare/index.vue | 290 ++++++ 13 files changed, 1669 insertions(+), 17 deletions(-) create mode 100644 app/admin/controller/docs/DocCommissionShare.php create mode 100644 app/common/library/docs/MarkdownDocReader.php create mode 100644 database/migrations/20260529150000_docs_commission_share_menu.php create mode 100644 docs/en/36zihua-mobile-api-design-draft.md create mode 100644 docs/en/commission-share-guide.md create mode 100644 web/src/views/backend/docs/docCommissionShare/index.vue diff --git a/app/admin/controller/docs/Doc36ZiHuaMobileApi.php b/app/admin/controller/docs/Doc36ZiHuaMobileApi.php index a18a90f..94d83e0 100644 --- a/app/admin/controller/docs/Doc36ZiHuaMobileApi.php +++ b/app/admin/controller/docs/Doc36ZiHuaMobileApi.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace app\admin\controller\docs; use app\common\controller\Backend; +use app\common\library\docs\MarkdownDocReader; use support\Response; use Webman\Http\Request; @@ -13,7 +14,17 @@ use Webman\Http\Request; */ class Doc36ZiHuaMobileApi extends Backend { - private const DOC_RELATIVE = 'docs' . DIRECTORY_SEPARATOR . '36字花-移动端接口设计草案.md'; + private const DOC_ZH = [ + 'relative' => 'docs' . DIRECTORY_SEPARATOR . '36字花-移动端接口设计草案.md', + 'filename' => '36字花-移动端接口设计草案.md', + 'ascii_fallback' => '36zihua-mobile-api-design-draft.md', + ]; + + private const DOC_EN = [ + 'relative' => 'docs' . DIRECTORY_SEPARATOR . 'en' . DIRECTORY_SEPARATOR . '36zihua-mobile-api-design-draft.md', + 'filename' => '36zihua-mobile-api-design-draft.md', + 'ascii_fallback' => '36zihua-mobile-api-design-draft.md', + ]; public function content(Request $request): Response { @@ -22,19 +33,20 @@ class Doc36ZiHuaMobileApi extends Backend return $response; } - $path = $this->docAbsolutePath(); - if (!is_file($path)) { + $doc = MarkdownDocReader::resolve($request, self::DOC_ZH, self::DOC_EN); + if (!is_file($doc['path'])) { return $this->error(__('Document file not found')); } - $raw = file_get_contents($path); + $raw = file_get_contents($doc['path']); if ($raw === false) { return $this->error(__('Failed to read document')); } return $this->success('', [ 'markdown' => $raw, - 'filename' => '36字花-移动端接口设计草案.md', + 'filename' => $doc['filename'], + 'lang' => $doc['lang'], ]); } @@ -45,22 +57,20 @@ class Doc36ZiHuaMobileApi extends Backend return $response; } - $path = $this->docAbsolutePath(); - if (!is_file($path)) { + $doc = MarkdownDocReader::resolve($request, self::DOC_ZH, self::DOC_EN); + if (!is_file($doc['path'])) { return $this->error(__('Document file not found')); } - $body = file_get_contents($path); + $body = file_get_contents($doc['path']); if ($body === false) { return $this->error(__('Failed to read document')); } - $utf8Name = '36字花-移动端接口设计草案.md'; - $asciiFallback = '36zihua-mobile-api-design-draft.md'; $disposition = sprintf( 'attachment; filename="%s"; filename*=UTF-8\'\'%s', - $asciiFallback, - rawurlencode($utf8Name) + $doc['ascii_fallback'], + rawurlencode($doc['filename']) ); return new Response(200, [ @@ -69,9 +79,4 @@ class Doc36ZiHuaMobileApi extends Backend 'Cache-Control' => 'private, max-age=0, must-revalidate', ], $body); } - - private function docAbsolutePath(): string - { - return rtrim(base_path(), DIRECTORY_SEPARATOR . '/') . DIRECTORY_SEPARATOR . self::DOC_RELATIVE; - } } diff --git a/app/admin/controller/docs/DocCommissionShare.php b/app/admin/controller/docs/DocCommissionShare.php new file mode 100644 index 0000000..f87f740 --- /dev/null +++ b/app/admin/controller/docs/DocCommissionShare.php @@ -0,0 +1,82 @@ + 'docs' . DIRECTORY_SEPARATOR . '分红说明文档.md', + 'filename' => '分红说明文档.md', + 'ascii_fallback' => 'commission-share-guide.md', + ]; + + private const DOC_EN = [ + 'relative' => 'docs' . DIRECTORY_SEPARATOR . 'en' . DIRECTORY_SEPARATOR . 'commission-share-guide.md', + 'filename' => 'commission-share-guide.md', + 'ascii_fallback' => 'commission-share-guide.md', + ]; + + public function content(Request $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + + $doc = MarkdownDocReader::resolve($request, self::DOC_ZH, self::DOC_EN); + if (!is_file($doc['path'])) { + return $this->error(__('Document file not found')); + } + + $raw = file_get_contents($doc['path']); + if ($raw === false) { + return $this->error(__('Failed to read document')); + } + + return $this->success('', [ + 'markdown' => $raw, + 'filename' => $doc['filename'], + 'lang' => $doc['lang'], + ]); + } + + public function download(Request $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + + $doc = MarkdownDocReader::resolve($request, self::DOC_ZH, self::DOC_EN); + if (!is_file($doc['path'])) { + return $this->error(__('Document file not found')); + } + + $body = file_get_contents($doc['path']); + if ($body === false) { + return $this->error(__('Failed to read document')); + } + + $disposition = sprintf( + 'attachment; filename="%s"; filename*=UTF-8\'\'%s', + $doc['ascii_fallback'], + rawurlencode($doc['filename']) + ); + + return new Response(200, [ + 'Content-Type' => 'text/markdown; charset=UTF-8', + 'Content-Disposition' => $disposition, + 'Cache-Control' => 'private, max-age=0, must-revalidate', + ], $body); + } +} diff --git a/app/common/lang/en/admin_rule_title.php b/app/common/lang/en/admin_rule_title.php index e4e07bc..39bf3f7 100644 --- a/app/common/lang/en/admin_rule_title.php +++ b/app/common/lang/en/admin_rule_title.php @@ -132,4 +132,7 @@ return [ '36字花移动端接口文档' => '36 Zihua mobile API design draft', '拉取正文' => 'Load document body', '下载 Markdown' => 'Download Markdown', + + // 文档:分红说明 + '分红说明文档' => 'Commission share guide', ]; diff --git a/app/common/lang/zh-cn/admin_rule_title.php b/app/common/lang/zh-cn/admin_rule_title.php index bd896da..82f1bd8 100644 --- a/app/common/lang/zh-cn/admin_rule_title.php +++ b/app/common/lang/zh-cn/admin_rule_title.php @@ -54,5 +54,9 @@ return [ 'manualSettle' => '手动结算', 'batchSettlePending' => '批量结算待结算渠道', 'walletAdjust' => '钱包加减点', + 'Markdown文档' => 'Markdown文档', + '分红说明文档' => '分红说明文档', + '拉取正文' => '拉取正文', + '下载 Markdown' => '下载 Markdown', ]; diff --git a/app/common/library/docs/MarkdownDocReader.php b/app/common/library/docs/MarkdownDocReader.php new file mode 100644 index 0000000..593e3e6 --- /dev/null +++ b/app/common/library/docs/MarkdownDocReader.php @@ -0,0 +1,82 @@ + $path, + 'filename' => $filename, + 'ascii_fallback' => $asciiFallback, + 'lang' => $lang, + ]; + } + + public static function resolveLang(Request $request): string + { + $thinkRaw = $request->header('think-lang', ''); + $thinkLang = is_string($thinkRaw) ? trim($thinkRaw) : ''; + if ($thinkLang === '' && is_array($thinkRaw) && isset($thinkRaw[0]) && is_string($thinkRaw[0])) { + $thinkLang = trim($thinkRaw[0]); + } + + $queryRaw = $request->get('lang'); + if ($queryRaw === null || $queryRaw === '') { + $queryRaw = $request->post('lang'); + } + $queryLang = is_string($queryRaw) ? trim($queryRaw) : (is_scalar($queryRaw) ? trim(strval($queryRaw)) : ''); + + $headerRaw = $request->header('lang', ''); + $headerLang = is_string($headerRaw) ? trim($headerRaw) : ''; + + $normalize = static function (string $raw): string { + $s = str_replace('_', '-', strtolower(trim($raw))); + if ($s === 'zh') { + return 'zh-cn'; + } + return $s; + }; + + foreach ([$thinkLang, $headerLang, $queryLang] as $candidate) { + if ($candidate === '') { + continue; + } + $normalized = $normalize($candidate); + if ($normalized === 'en') { + return 'en'; + } + if ($normalized === 'zh-cn') { + return 'zh-cn'; + } + } + + return 'zh-cn'; + } +} diff --git a/database/migrations/20260529150000_docs_commission_share_menu.php b/database/migrations/20260529150000_docs_commission_share_menu.php new file mode 100644 index 0000000..6ff9c67 --- /dev/null +++ b/database/migrations/20260529150000_docs_commission_share_menu.php @@ -0,0 +1,135 @@ +hasTable('admin_rule') ? 'admin_rule' : ($this->hasTable('menu_rule') ? 'menu_rule' : null); + if ($ruleTable === null) { + return; + } + $ct = $this->table($ruleTable)->hasColumn('create_time') ? 'create_time' : 'createtime'; + $ut = $this->table($ruleTable)->hasColumn('update_time') ? 'update_time' : 'updatetime'; + $now = time(); + + $dirId = $this->resolveDocsDirId($ruleTable, $ct, $ut, $now); + $menuName = 'docs/docCommissionShare'; + $menuId = $this->ensureMenu($ruleTable, $dirId, $menuName, $ct, $ut, $now); + $this->ensureButtons($ruleTable, $menuId, $menuName, $ct, $ut, $now); + } + + private function resolveDocsDirId(string $table, string $ct, string $ut, int $now): int + { + $row = Db::name($table)->where('name', 'docs/mdManual')->where('type', 'menu_dir')->find(); + if ($row) { + return (int) $row['id']; + } + return (int) Db::name($table)->insertGetId([ + 'pid' => 0, + 'type' => 'menu_dir', + 'title' => 'Markdown文档', + 'name' => 'docs/mdManual', + 'path' => 'docs/mdManual', + 'icon' => 'fa fa-book', + 'menu_type' => 'tab', + 'component' => '', + 'url' => '', + 'extend' => 'none', + 'remark' => '', + 'weigh' => 53, + 'status' => 1, + 'keepalive' => 0, + $ct => $now, + $ut => $now, + ]); + } + + private function ensureMenu(string $table, int $pid, string $name, string $ct, string $ut, int $now): int + { + $row = Db::name($table)->where('name', $name)->where('type', 'menu')->find(); + if ($row) { + Db::name($table)->where('id', $row['id'])->update([ + 'pid' => $pid, + 'title' => '分红说明文档', + 'path' => $name, + 'component' => '/src/views/backend/docs/docCommissionShare/index.vue', + 'icon' => 'fa fa-percent', + 'menu_type' => 'tab', + 'weigh' => 51, + 'status' => 1, + 'keepalive' => 1, + $ut => $now, + ]); + return (int) $row['id']; + } + return (int) Db::name($table)->insertGetId([ + 'pid' => $pid, + 'type' => 'menu', + 'title' => '分红说明文档', + 'name' => $name, + 'path' => $name, + 'icon' => 'fa fa-percent', + 'menu_type' => 'tab', + 'component' => '/src/views/backend/docs/docCommissionShare/index.vue', + 'url' => '', + 'extend' => 'none', + 'remark' => 'commission share guide markdown', + 'weigh' => 51, + 'status' => 1, + 'keepalive' => 1, + $ct => $now, + $ut => $now, + ]); + } + + private function ensureButtons(string $table, int $pid, string $menuName, string $ct, string $ut, int $now): void + { + $titleMap = [ + 'content' => '拉取正文', + 'download' => '下载 Markdown', + ]; + foreach ($titleMap as $action => $title) { + $btnName = $menuName . '/' . $action; + $exists = Db::name($table)->where('name', $btnName)->where('type', 'button')->find(); + if ($exists) { + Db::name($table)->where('id', $exists['id'])->update([ + 'pid' => $pid, + 'title' => $title, + 'status' => 1, + $ut => $now, + ]); + continue; + } + Db::name($table)->insert([ + 'pid' => $pid, + 'type' => 'button', + 'title' => $title, + 'name' => $btnName, + 'path' => '', + 'icon' => '', + 'menu_type' => 'tab', + 'component' => '', + 'url' => '', + 'extend' => 'none', + 'remark' => '', + 'weigh' => 0, + 'status' => 1, + 'keepalive' => 0, + $ct => $now, + $ut => $now, + ]); + } + } + + public function down(): void + { + } +} diff --git a/docs/en/36zihua-mobile-api-design-draft.md b/docs/en/36zihua-mobile-api-design-draft.md new file mode 100644 index 0000000..c639f27 --- /dev/null +++ b/docs/en/36zihua-mobile-api-design-draft.md @@ -0,0 +1,905 @@ +# 36 Zihua Mobile API Design Draft (V1) + +This document is based on `docs/36字花-数据库与实施计划.md` and the PRD. It provides an initial mobile-facing API inventory and field definitions. + +Scope: **platform-wide single period number and single draw result**; channels are used only for attribution, commission sharing, and risk control—not for splitting game rounds. + +**Addendum (2026-04)**: §**1.5** describes server-side **Redis hot-spot caching** (`GameHotDataRedis`). It does **not** change any interface URL, parameter, or response field contracts; it is for integration testing and operations reference only. + +## 1. Design Conventions + +### 1.1 Base Conventions +- Protocol: HTTPS + JSON +- Path naming: `/api/{module}/{action}`, must match regex `^/api/[a-z]+/[a-z]+[A-Z][a-zA-Z]*$` +- **HTTP methods**: All mobile business APIs (`/api/*`, excluding `/api/v1/authToken`) use `POST`. Query-style endpoints also accept `GET` (for browser/debug tools); clients should uniformly use `POST`. + - For `POST`, header `Content-Type: application/json`; parameters in JSON body + - In `GET` compatibility mode, parameters go in the URL query string + - **Exception**: Notice module `/api/notice/noticeList`, `/api/notice/noticeConfirm` support **`GET` only**; parameters always use URL query string + - **Notice list without auth**: `GET /api/notice/noticeList` does **not** require `auth-token` or `user-token`; if `user-token` is still sent, popout items can return the user’s real `is_read` + - Auth endpoint `/api/v1/authToken` remains `GET` +- Time: UTC timestamp (seconds) + server timezone configuration +- Amounts: numeric transport (e.g. `"100.00"`); client display uses two decimal places (storage remains `decimal(18,2)`) +- Idempotency: critical write APIs require `idempotency_key` +- Required headers: + - `auth-token`: API auth token from `GET /api/v1/authToken` (signature-based API access credential) + - `user-token`: user session token; required on login-protected APIs +- Language header: + - `lang=zh`: Chinese (default) + - `lang=en`: English + +### 1.2 Common Response Shape +```json +{ + "code": 1, + "message": "ok", + "data": {} +} +``` + +- `code=1` means success; any other value is a business error +- All `/api/*` response messages support Chinese and English: default Chinese; `lang=en` returns English, `lang=zh` returns Chinese +- Suggested error code ranges (by nature): + - `1000-1099`: parameter errors (missing fields, type errors, format errors, out of range) + - `1100-1199`: auth errors (not logged in, token expired, insufficient permission) + - `2000-2999`: business errors (insufficient balance, round not found, order not found, notice not found) + - `3000-3099`: flow errors (illegal flow/state, e.g. bet after lock, duplicate confirm, illegal state transition) + - `5000-5999`: system errors (service exception, dependency timeout, unknown error) + +- Recommended base error codes (first release): +- `1`: success + - `1001`: missing parameter + - `1002`: invalid parameter format + - `1003`: illegal parameter value + - `1101`: not logged in or session expired + - `1103`: no permission + - `2001`: insufficient balance + - `2002`: round not found + - `2003`: order not found + - `2004`: notice not found + - `3001`: operation not allowed in current flow + - `3002`: betting closed, bets not allowed + - `3003`: duplicate request (idempotency conflict) + - `5000`: system busy, try again later + +### 1.3 Authentication +- **API auth (`auth-token`)**: all mobile business APIs must send header `auth-token` (issued by `/api/v1/authToken`) +- **User session (`user-token`)**: login-protected APIs send header `user-token`; on expiry, refresh or log in again + +### 1.4 Obtain API Auth Token (`auth-token`) +- **GET** `/api/v1/authToken` +- Purpose: obtain `auth-token` (required on all API request headers) + +Request example: +`/api/v1/authToken?secret=564d14asdasd113e46542asd6das1a2a×tamp=1776331077&device_id=1&signature=AD84C49880896DBC16C59C7B122D1FF7` + +Request parameters: +- `secret`: string (client secret; server validates against env `AUTH_TOKEN_SECRET`) +- `timestamp`: int (request timestamp; server allows ±300 seconds from server time) +- `device_id`: string (device id) +- `signature`: string (signature value) + +Signature algorithm: +- Parameters in signature (excluding `signature`): `device_id`, `secret`, `timestamp` +- Sort parameter names **a-z**, concatenate as `key=value&key=value...` +- Compute: `signature = strtoupper(md5(concatenated string))` + +Response parameters: +- `auth_token`: string (API auth token; put in header `auth-token`) +- `expires_in`: int (TTL in seconds) +- `server_time`: int (server timestamp for clock sync) + +Possible error codes: +- `1001` missing parameter +- `1002` invalid parameter format +- `1103` invalid secret / signature error +- `3001` invalid timestamp + +### 1.5 Server Performance & Redis Hot-Spot Cache (Implementation Notes) + +> **No client contract change**: request paths, parameters, response JSON shape, and error codes are unchanged by caching; this section only explains how the server reduces latency, read paths, and consistency notes. + +**Difference from “framework file cache”** + +| Config | Scope | +|--------|--------| +| `CACHE_DRIVER` (`config/cache.php`, e.g. `file`) | Think-ORM / `get_sys_config()` etc. **system `config` table** model cache under `runtime/cache`; **not** used on this game hot-path. | +| `GAME_HOT_CACHE_*` (`config/game_hot_cache.php`) | Game **`user` / `game_config` / `game_record`** row-level JSON cache via **`support\Redis`** (`config/redis.php`), key prefix `dfw:v1:`. | + +**Server cache coverage (read paths relevant to mobile)** + +- **User**: member auth prefers Redis `user` row snapshot; on miss, DB then backfill. After **balance, streak, bet-flow**, etc. change and commit, **`GameHotDataCoordinator::afterUserCommitted($userId)`**: **`GameHotDataRedis::userReplaceCacheFromDb`** aligns with DB, then enqueues idempotent refresh tasks (`GameHotDataWriteQueue` / `GameHotDataQueueConsumer`) for peak shaving—not a substitute for synchronous reload. +- **Game config**: `game_config` cached by `config_key`. Direct `Db` updates in admin must call **`GameHotDataCoordinator::afterGameConfigKeyCommitted($key)`** (model `GameConfig` events and standalone form controllers wired); standalone save uses **`GameHotDataLock` (`TYPE_GAME_CONFIG`)** per `config_key`. Do not only delete cache keys without reload—max inconsistency window is TTL. +- **Round**: active round, round by `id`, latest `game_record`, etc.; after write, **`GameHotDataCoordinator::afterGameRecordCommitted`** refreshes Redis keys and enqueues. Draw/lock paths may use **`GameHotDataLock` (`TYPE_GAME_RECORD`)** per record id. + +**Environment variables (see repo root `.env-example`)** + +- `GAME_HOT_CACHE_ENABLED`: enable Redis hot cache (`false` = always DB). +- `GAME_HOT_CACHE_TTL_GAME_CONFIG` / `GAME_HOT_CACHE_TTL_GAME_RECORD` / `GAME_HOT_CACHE_TTL_USER`: TTL seconds; **write-then-sync reload is primary**, TTL is fallback only. +- `GAME_HOT_CACHE_ENABLE_WRITE_QUEUE` and queue length, consumer interval, etc.: idempotent refresh after write (`config/game_hot_cache.php`). + +**Consistency notes (integration / testing)** + +- Scripts that bypass coordinator and only change DB without **`GameHotDataCoordinator`** may be briefly inconsistent with Redis; avoid in production. +- **`POST /api/game/betPlace`** debit path uses the same **per-user Redis lock** as admin wallet adjust (`GameHotDataRedis::userAdminMutationLockTry`) and **`WHERE coin = ?` conditional update**, mutually exclusive with concurrent payout/admin adjust; on failure returns Chinese messages listed in **§4.2**. +- Clients may still use **§3.2 `dictionaryList` `version`** for local cache; server dictionary also has Redis acceleration—both can coexist. + +--- + +## 2. Auth & Account Module (`user`) + +### 2.1 Register +- **POST** `/api/user/register` +- Purpose: phone-only registration with invite attribution (admin/channel) + +Request parameters: +- `username`: string, phone number (registration account; mainland China mobile only) +- `password`: string, plaintext over HTTPS (login password; server stores salted hash) +- `invite_code`: string, required (sub-agent invite code; binds `channel_id` and ownership) +- `device_id`: string, optional (device id for risk control and login logs) + +Response parameters: +- `user-token`: string (session token for login-protected APIs) +- `refresh_token`: string, optional (refresh access token) +- `expires_in`: int (seconds, token TTL) +- `user`: object (non-sensitive fields only; no `id`) + - `uuid`: string (public user id, 10 chars) + - `username`: string (nickname/display name) + - `coin`: string (current balance) + - `channel_id`: int (attribution channel id) + - `risk_flags`: int (risk status bitmask) + +### 2.2 Login +- **POST** `/api/user/login` + +Request parameters: +- `username`: string (login account; currently phone) +- `password`: string (login password) +- `device_id`: string, optional (risk assist) + +Response parameters: +- `user-token`: string (access token for login-protected APIs) +- `refresh_token`: string, optional +- `expires_in`: int (access token remaining seconds) +- `user`: object (non-sensitive fields only; no `id`) + - `uuid`: string (public user id, 10 chars) + - `username`: string (nickname/display name) + - `coin`: string (current balance) + - `channel_id`: int (attribution channel id) + - `risk_flags`: int (risk status bitmask) + +### 2.3 Current User Profile +- **POST** `/api/user/profile` + +Response parameters (amount fields as 2-decimal strings, aligned with wallet display): + +**Basic profile** +- `uuid`: string (public user id, 10 chars) +- `username`: string (nickname) +- `head_image`: string (avatar URL) +- `phone`: string (mobile) +- `email`: string (email) +- `register_invite_code`: string (invite code snapshot at registration) +- `channel_id`: int (attribution channel id) +- `risk_flags`: int (risk status bitmask) +- `current_streak`: int (current win streak count) +- `last_bet_period_no`: string (period no of last valid bet) +- `create_time`: int (registration timestamp) + +**Funds & withdraw quota** +- `coin` / `coin_balance`: string (current balance; same value) +- `frozen_balance`: string (frozen balance; fixed `0.00` when none) +- `total_deposit_coin`: string (lifetime deposits) +- `total_withdraw_coin`: string (lifetime withdraws; incremented after acceptance) +- `bet_flow_coin`: string (bet flow / turnover; after settlement, +`total_amount` per bet 1:1) +- `max_withdrawable`: string (**max single withdraw allowed now** = `min(coin_balance, max_withdraw_by_flow)`) +- `withdraw_flow`: object (bet-flow / withdraw quota diagnostic snapshot; includes `pending_withdraw`) + - `ratio`: string (bet-flow multiplier; `0` = unlimited) + - `net_deposit`: string (net deposit = max(0, total deposit − total withdraw)) + - `required_bet_flow`: string (required bet flow by threshold rule; display only) + - `remaining_bet_flow`: string (remaining bet flow by threshold; display only) + - `eligible`: bool (meets overall threshold; display only; actual gate is `max_withdrawable`) + - `max_withdraw_by_flow`: string/null (cap from bet flow only; `null` when `ratio=0`) + - `flow_unlimited`: bool (unlimited bet-flow mode) + - `pending_withdraw`: object + - `count`: int (pending-review withdraw orders) + - `max`: int (max pending-review withdraws per user, currently `3`; exceeds → `withdrawCreate` returns `code=2004`) + +### 2.4 Refresh Token (Optional) +- **POST** `/api/user/refreshToken` + +Request parameters: +- `refresh_token`: string (credential to renew access token) + +Response parameters: +- `user-token`: string (new access token) +- `expires_in`: int (new token TTL) + +--- + +## 3. Game Lobby & Dictionary Module (`game`/`lobby`) + +### 3.1 Lobby Init +- **POST** `/api/game/lobbyInit` +- Purpose: one-shot current round, config, 36 zihua dictionary, user quick display + +Response parameters: +- `server_time`: int (server time for client clock sync) +- `runtime_enabled`: bool (**game runtime switch**; `false` = admin maintenance—**no new bets**, idle won’t auto-create periods, post-payout won’t auto-create next; **current open round still draws, pays out, and settles**. Mobile should disable bet entry and show maintenance copy) +- `period`: object + - `period_no`: string (global period number) + - `status`: string (`betting`/`locked`/`settling`/`finished`/`void`; `void` = period voided) + - `countdown`: int (countdown seconds) + - `lock_at`: int (lock timestamp) + - `open_at`: int (expected draw timestamp) +- `bet_config`: object + - `pick_max_number_count`: int (max numbers per bet; from `game_config.config_key = pick_max_number_count`; default matches seed, usually 10; range 1–36) + - `chips`: object (quick chip map; fixed keys `"1"`…`"6"`, values are per-number stake strings, 2 decimals; same as admin `game_config.bet_chips`) + - `default_bet_chip_id`: int (default chip id from `game_config.default_bet_chip_id`; invalid → first valid chip) + - `min_bet_per_number`: string (min per number; ≤ selected chip and admin limits) + - `max_bet_per_number`: string (max per number) +- `dictionary`: array + - `number`: int (1-36, zihua number) + - `name`: string (zihua name) + - `category`: string (category) + - `icon`: string (icon URL) +- `user_snapshot`: object (user snapshot + **odds for current player this round**; no full 1–10 table) + - `coin`: string (balance) + - `current_streak`: int (current win streak) + - `streak_level`: int (streak tier 1–10 if win this round: `min(current_streak+1, 10)`) + - `odds_factor`: int (odds multiplier; payout = bet `total_amount` × `odds_factor`) + - `is_jackpot`: bool (jackpot tier) + +### 3.2 36 Zihua Dictionary (Cacheable) +- **POST** `/api/game/dictionaryList` + +Response parameters: +- `version`: string (dictionary version for client cache compare) +- `items`: same as `dictionary` (36 zihua entries) + +## 4. Betting & Round Module (`game`/`bet`) + +### 4.1 Current Period Detail +- **POST** `/api/game/periodCurrent` + +Response parameters: +- `runtime_enabled`: bool (same as `lobbyInit.runtime_enabled`) +- `period_id`: int (current period primary key) +- `period_no`: string (current period number) +- `status`: string (includes `void`) +- `countdown`: int (remaining seconds) +- `bet_close_in`: int (seconds until lock) +- `result_number`: int/null (null before draw) + +### 4.2 Place Bet +- **POST** `/api/game/placeBet` (legacy path `/api/game/betPlace` supported) +- Purpose: manual bet per period; player sends **picked numbers** and **chip id `bet_id` (1–6)**. Stake per number from `game_config.bet_chips`; server debits `per-number amount × count(numbers)` as `total_amount`. One winning number per round; win if that number ∈ picked set. + +Request parameters: +- `period_no`: string (target period) +- `numbers`: string (comma-separated picks, e.g. `1,8,16`; each 1–36; count ≤ `pick_max_number_count`; duplicates deduped) +- `bet_id`: int (quick chip 1–6; must exist in `lobbyInit.bet_config.chips`; **`single_bet_amount` / `bet_amount` not used**) +- `idempotency_key`: string, required + +Response parameters: +- `order_no`: string (bet order no) +- `period_no`: string (actual period) +- `status`: string (`accepted`/`rejected`) +- `bet_id`: int (chip used) +- `single_bet_amount`: string (from `bet_id`) +- `numbers_count`: int (number count) +- `locked_balance`: string, optional (frozen amount) +- `balance_after`: string (balance after order) +- `current_streak`: int (streak snapshot after order) + +**Additional possible error codes** (see header ranges; debit/cache consistency): + +- `3001`: game paused (`runtime_enabled=false`, admin live switch off or void without restart; same band as flow errors) +- `5000`: system busy; or **user Redis lock** not acquired (same serial as admin wallet/concurrent writes: 「该用户正在被其他管理员操作(钱包/并发保存),请稍后再试」); or **`coin` conditional update** miss (concurrent bet/payout/admin: 「扣款失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试」). + +### 4.3 Auto Spin +- **POST** `/api/game/autoSpin` + +Request parameters: +- `action`: string (`start`/`stop`) +- `period_no`: string (required when `action=start`) +- `numbers`: string (required when `action=start`, comma-separated) +- `bet_id`: int (required when `action=start`; same as `placeBet`, chips 1–6) +- `rounds`: int (required when `action=start`, >=1) + +Response parameters: +- `status`: string (`scheduled`/`stopped`) +- `auto_mode`: bool +- `bet_id`: int (`start` only) +- `single_bet_amount`: string (`start` only, from `bet_id`) +- `remaining_rounds`: int (`start` only) + +### 4.4 My Bet Orders (Last 1 Month) +- **POST** `/api/game/betMyOrders` + +Request parameters: +- `page`: int, optional, default 1 +- `page_size`: int, optional, default 20 + +Response parameters: +- `list`: array + - `order_no`: string + - `period_no`: string + - `numbers`: array[int] + - `bet_amount`: string (same as `total_amount`) + - `total_amount`: string + - `result_number`: int/null + - `win_amount`: string + - `status`: string + - `create_time`: int +- `pagination`: object (`page`, `page_size`, `total`) + +--- + +## 5. Wallet & Finance Module (`wallet`/`finance`) + +### 5.1 Balance Sync (Standalone Summary Removed) +- Removed `/api/wallet/balanceSummary`. +- Balance sync sources: + - `placeBet.balance_after` + - WebSocket `wallet.changed` + - Deposit/withdraw detail APIs (`depositDetail` / `withdrawDetail`) for order-level reconciliation +- Multiplier `ratio` from admin **Game Config → `withdraw_bet_flow_ratio`**; effective immediately on new requests. +- Lifetime fields (`total_deposit_coin` / `total_withdraw_coin` / `bet_flow_coin`) are cumulative; rejections reversed by admin review flow. + +### 5.2 Wallet Records +- **POST** `/api/wallet/recordList` + +Request parameters: +- `page`: int, optional, default 1 +- `page_size`: int, optional, default 20 +- `type`: string, optional (filter; omit = all): + - `deposit`: deposit credit + - `withdraw`: withdraw debit/freeze + - `bet`: bet debit + - `payout`: draw payout credit + - `adjust`: manual adjust (`biz_type=admin_credit/admin_deduct`) + - `bet_void`: period void refund (admin live void pending bets) + +Response parameters: +- `list`: array + - `record_id`: int + - `biz_type`: string + - `direction`: int (1 in, 2 out) + - `amount`: string + - `balance_before`: string + - `balance_after`: string + - `ref_type`: string + - `ref_id`: string + - `create_time`: int + +Notes: +- Amount fields display with two decimals on client. +- Admin credit/deduct creates `biz_type=admin_credit/admin_deduct` records; default remark template: `后台管理员(操作管理员)加点/扣点100(值)` (example). + +### 5.3 Deposit Tier List +- **POST** `/api/finance/depositTierList` + +Notes: +- Maintained in admin **Config → Deposit Tiers**, stored in `game_config.deposit_tier` (JSON array). +- Admin “pay currency” dropdown from `game_config.finance_cashier.currencies` (not hardcoded). +- Init/rebuild: **6 tiers per currency** from `finance_cashier` (ops may edit). +- Only `status=1` tiers, sorted by `sort`. +- Tiers describe recharge spec only; collection via third-party `pay_url`. +- **i18n**: admin stores `title`, `title_en`, `desc`, `desc_en`. API `title` / `desc` follow `lang`: + - `lang=zh` (default): `title` / `desc`, fallback to English if empty + - `lang=en`: `title_en` / `desc_en`, fallback to Chinese if empty + - Mobile sees single `title` / `desc` only + +Request parameters: none + +Response parameters: +- `list`: tier list; each item: + - `id`: string (stable tier id; pass as `tier_id`; same as `tier_key`) + - `tier_key`: string (same as `id`, legacy) + - `title`: string (localized by `lang`) + - `currency`: string (e.g. `CNY`) + - `pay_amount`: string (2 decimals, list price) + - `amount`: string (2 decimals, amount user pays) + - `bonus_amount`: string (2 decimals, bonus; `0.00` if none) + - `total_amount`: string (2 decimals, `amount + bonus_amount`) + - `desc`: string (localized; may be empty) + - `channels`: array (for `depositCreate` `channel_code`; all enabled channels compatible with all tiers) + - each: `code`, `name`, `sort` + +### 5.3A Deposit / Withdraw Config +- **POST** `/api/finance/depositWithdrawConfig` +- Legacy: `POST /api/finance/cashierConfig` (same shape; prefer `depositWithdrawConfig`) + +Purpose: +- One-shot recharge/withdraw page config: currencies, rates, deposit channels, withdraw banks, limits, copy. + +Response parameters: +- `platform_coin_label`: string (localized) +- `currencies`: array + - `code`, `label`, `deposit_coins_per_fiat`, `withdraw_coins_per_fiat` +- `rates`: array (compat) + - `currency`, `diamonds_per_fiat_unit` +- `pay_channels`: array (deposit) + - `code`, `name`, `sort`, `status` (1=enabled), `tier_ids` (compat; empty = all tiers) +- `withdraw`: object + - `pay_channels`: array (for `withdrawCreate` `channel_code`; enabled + server-integrated only) + - `banks`: array + - `min_ewallet`, `min_bank`, `rate_hint`, `processing_note`, `fee_note` + - `rate_mode`: `fixed` / `live` + - `fields`: object (**aligned with DDPay / `withdrawCreate`**; not admin cashier toggles) + - `receive_type_bank_only`: bool (fixed `true`, bank only) + - `require_channel_code`: bool (`true`, `channel_code`) + - `require_receiver_name`: bool (`true`, `receiver_name`) + - `require_receive_account`: bool (`true`, `receive_account`) + - `require_receiver_email`: bool (`true`, `receiver_email`) + - `require_receiver_mobile`: bool (`true`, `receiver_mobile`) + - `require_bank_code`: bool (`true`, `bank_code`) + - `require_bank_branch`: bool (`false`, optional; server sends `N/A` to DDPay if omitted) + +### 5.4 Create Deposit Order +- **POST** `/api/finance/depositCreate` +- `Content-Type: application/json` (recommended), `application/x-www-form-urlencoded`, or **`multipart/form-data`**; same field names; server reads unified param names. + +Notes: +- **Mock pay (integration)**: `channel_code=mock`, no DDPay merchant; returns `pay_url` (**mock cashier** `GET /api/finance/mockDepositPage`, **3 min** TTL). User confirms → **`pending_review`**; credit after admin approves in **Deposit Orders**. +- Optional **`GET/POST /api/finance/mockDepositPay`** (login + same `auth-token`): in-app confirm (either cashier or this; idempotent). +- **DDPay**: `channel_code=ddpay`; pending order then DDPay deposit API; on success `pay_url=payment_url`. +- Switch: `FINANCE_MOCK_PAY_ENABLED` (default on in dev); off → `mock` unavailable. +- Settlement: **`POST /api/finance/ddpayDepositNotify`** verified, or sync `transaction_status=completed`; **`mock`** credits only after admin approval. +- Tiers/channels from `depositTierList`. +- Max 3 pending deposit orders per user; unpaid orders fail after `DEPOSIT_PENDING_EXPIRE_SECONDS` (default **60s**, `.env`); pay link countdown matches. + +Request parameters: + +**A. Common required** +- `tier_id`: string, required (synonym `tier_key`) +- `channel_code`: string, required, `mock` or `ddpay` +- `idempotency_key`: string, required, ≤64 + +**A.1 Mock (`channel_code=mock`)** +- No `payment_type` / `payer_name` / `payer_bank_name` + +**B. DDPay required (`channel_code=ddpay`)** + +> Per DDPay official *Payment Gateway* (`docs/DDPay Payment Gateway_v1.1.3_zh.md` / PDF). + +- `payment_type`: string, required (**official enum §3.1**): **`01`** FPX, **`02`** duitnow, **`03`** ewallet; confirm other values with DDPay support. + - Alias: `paymentType` + - **Note**: values like `FPX`, `duitnow` may be rejected; send **`01` / `02` / `03`**. +- `payer_name`: string, required (account holder name) + - Alias: `payerName` +- `payer_bank_name`: string, required (**`payer_bank[name]`**; full name from DDPay bank list) + - **MYR**: English full name from deposit bank list (e.g. `Public Bank`, `Maybank2U`); **THB**: THB list English full name. Mismatches may be rejected. + - Aliases: `payer_bank[name]`, `payerBankName` + +**B.1 Server-only DDPay fields (integration reference)** + +Mobile must not send; server fills on deposit API: + +- `client_id`, `identifier` (if `DDPAY_IDENTIFIER` set), `order_id` (= `order_no`) +- `transaction_amount`: tier **`pay_amount`** (Decimal); currency per DDPay onboarding (docs often MYR; use your merchant currency). +- `callback_url`, `redirect_url`: from **`DDPAY_PUBLIC_BASE_URL`** (`.env-example`) or request `Host`; production **HTTPS**. + +> **Common `1001` (DDPay)**: missing `payment_type` / `payer_name` / `payer_bank_name`; or `payment_type` not `01/02/03`. + +Example (DDPay, MYR + FPX): +```json +{ + "tier_id": "t_xxxxxxxx", + "channel_code": "ddpay", + "idempotency_key": "dp_20260429_xxx", + "payment_type": "01", + "payer_name": "ZHANG SAN", + "payer_bank_name": "Public Bank" +} +``` + +Response parameters: +- `order_no`, `amount`, `bonus_amount`, `total_amount`, `pay_channel`, `paid`, `pay_url`, `review_required`, `reject_reason`, `expire_at`, `expire_seconds`, `status` (`pending` / `pending_review` / `paid` / `failed`), `create_time`, `pay_time` + +#### 5.4.1 DDPay Callback & Status (Current Implementation) + +- Callback: `POST /api/finance/ddpayDepositNotify` (**`callback_url`** to DDPay; **HTTPS**). +- Official webhook fields (§3.5, **verify signature first**): + +| Param | Description | +|-------|-------------| +| `client_id` | Merchant id | +| `order_id` | Reference (= deposit `order_no`) | +| `transaction_status` | `pending` / `completed` / `failed` (§4.1) | +| `timestamp` | Notify time | +| `transaction_amount` | Paid amount | +| `signature` | Signature | + +- After verify: + - `completed` → settle, `paid` + - `failed` → `failed` +- Respond HTTP **200**, body **`{"status":"ok"}`**; up to **6** retries per docs. +- Without callback, `pending` persists; server may use deposit status query (`query_time` `YYYY-MM-DD HH:MM:SS`); not exposed to mobile yet. + +Error codes: +- `1001`: missing `tier_id`/`tier_key`, `channel_code`, `idempotency_key` +- `1001` (DDPay): missing/invalid DDPay fields +- `1002`: `idempotency_key` too long or cross-user conflict +- `2000`: generic DB/settle failure; DDPay API failure → 「DDPay 充值发起失败」, see `deposit_order.remark` (`[ddpay]`) or logs +- `2003`: invalid/disabled tier +- `2004`: bad `channel_code`; mock off; ddpay off; currency/channel mismatch +- `2005`: too many pending (`data.max_pending`, `data.pending_count`, `data.expire_seconds`) + +### 5.4A Mock Pay (Browser Cashier + Optional App Confirm) + +- **GET/POST** `/api/finance/mockDepositPage`: HTML cashier (no login; `order_no` required; `pay_channel=mock`, `status=pending`, not expired) +- **GET/POST** `/api/finance/mockDepositConfirm`: cashier confirm (no login; → **`pending_review`**, no credit) +- **GET/POST** `/api/finance/mockDepositPay`: app confirm (login; idempotent with above) + +After admin approves/rejects in **Deposit Orders**, mock order succeeds or fails. + +### 5.5 Deposit Order Detail +- **POST** `/api/finance/depositDetail` + +Request parameters: +- `order_no`: string, required + +Response parameters (same as `depositCreate`): +- `order_no`, `amount`, `bonus_amount`, `total_amount`, `pay_channel`, `paid`, `pay_url`, `review_required`, `reject_reason`, `expire_at`, `expire_seconds`, `status`, `create_time`, `pay_time` + +### 5.6 Deposit Order List +- **POST** `/api/finance/depositList` + +Paged recharge history; use `depositDetail` for full fields. + +Request parameters: +- `page`, `page_size` (max `100`, over → `20`) + +Response parameters: +- `list`: `order_no`, `amount`, `bonus_amount`, `status` +- `pagination`: `page`, `page_size`, `total` + +### 5.7 Withdraw Request +- **POST** `/api/finance/withdrawCreate` + +Notes: +- **DDPay payout only**: `channel_code` must be **`ddpay`** (alias `pay_channel`); else `code=2004`. +- Channels: `depositWithdrawConfig` → `withdraw.pay_channels`. + +Request parameters: +- `channel_code`: string, required (`ddpay`; alias `pay_channel`) +- `withdraw_coin`: string (> 0) +- `receive_account`: string (DDPay **`receiver_account`**) +- `receive_type`: string (`bank` only currently) +- `idempotency_key`: string +- `receiver_name`: string (required for `bank`; DDPay **`receiver_name`**) +- `receiver_email`: string (required, valid email, max 255) +- `receiver_mobile`: string (required, 5–32, digits and `+` `-` space, ≥5 digits) +- `bank_code`: string (required for `bank`; from `withdraw_banks[].code` → DDPay **`bank[name]`**) +- `bank_branch`: string (optional; server sends **`N/A`** if omitted per DDPay) + +**DDPay Payout field map (§3.2, server assembles after approval)** + +| Official param | Description | +|----------------|-------------| +| `client_id` | Merchant id | +| `bill_number` | Unique ref (= `order_no`) | +| `amount` | Payout amount | +| `receiver_name` | Account holder | +| `receiver_account` | Account / mobile | +| `bank[name]` | Full bank name (appendix) | +| `bank_branch` | Branch; default `N/A` | +| `callback_url` | HTTPS notify URL | +| `signature` | MD5 lowercase | + +Response may include `transaction_fee`, `transaction_total`, `transaction_status`, `remark`, etc. + +Return parameters: +- `order_no`, `status` (`pending_review`/`approved`/`rejected`; paid merged into `approved`), `fee_coin`, `actual_arrival_coin`, `risk_review_required` + +Validation order (first failure wins): +1. Params & amount (`1001`) +2. Channel: not `ddpay` → `2004 Withdraw only supports DDPay`; disabled → `2004 Pay channel not available` +3. Pending limit: `status=0` withdraw orders ≤ 3 → `2004 Too many pending withdraw orders` + `max_pending`, `pending_count` +4. `coin_balance >= withdraw_coin` → `2001 Insufficient balance` +5. `withdraw_coin <= max_withdrawable` → `2002 Withdraw exceeds available bet flow` + diagnostic `data` +6. On success in one transaction: + - `withdraw_order` with `pay_channel`, amounts, `status=0`, user `channel_id` snapshot, receiver fields + - `user`: `coin -= withdraw_coin`, `total_withdraw_coin += withdraw_coin` with `WHERE coin >= withdraw_coin` + - `user_wallet_record`: `biz_type=withdraw`, `direction=2`, `idempotency_key=wd_apply_{order_no}` (freeze semantics) + +Notes (bet-flow withdraw quota): +- `max_withdrawable = min(coin_balance, max_withdraw_by_flow)`; each withdraw consumes `withdraw_coin × ratio` quota via `total_withdraw_coin`. +- `ratio = 0`: unlimited bet-flow; cap = `coin_balance` only. +- Apply-and-freeze: mobile submit debits `user.coin` immediately; admin **reject** refunds in one transaction. +- Admin **approve** triggers DDPay payout; payout **fail** auto-refund (`withdraw_refund`), `rejected` (`status=2`). +- After payout, internal `status=3`; mobile shows `approved`. +- `withdraw_bet_flow_ratio` in game config; default `1.00`. + +### 5.8 Withdraw Order Detail +- **POST** `/api/finance/withdrawDetail` + +Request parameters: +- `order_no`: string, required + +Response parameters: +- `order_no`, `status`, `withdraw_coin`, `fee_coin`, `actual_arrival_coin`, `receive_type`, `receive_account`, `pay_channel`, `receiver_email`, `receiver_mobile`, `reject_reason`, `create_time`, `review_time` + +### 5.9 Withdraw Order List +- **POST** `/api/finance/withdrawList` + +Paged list; use `withdrawDetail` for fees, actual amount, reject reason. + +Request parameters: +- `page`, `page_size` (max `100`) + +Response parameters: +- `list`: `order_no`, `amount`, `status` +- `pagination`: `page`, `page_size`, `total` + +--- + +## 6. Notice Module (`operation`/`notice`) + +### 6.1 Notice List +- **GET** `/api/notice/noticeList` +- **Auth**: no `auth-token` or `user-token` (public) + +Request parameters (query): +- `page`, `page_size` + +Response parameters: +- `list`: array + - `notice_id`, `title`, `content`, `notice_type` (`silent`/`popout`), `must_confirm`, `is_read` (**popout only**; `silent` always `false`, no read record), `publish_time` + +> **Read records**: `user_notice_read` only for popout confirm; silent mailbox never writes/reads records. + +### 6.2 Popout Confirm Read +- **GET** `/api/notice/noticeConfirm` + +Request parameters (query): +- `notice_id`: int + +Behavior: +- **Popout only**; silent returns business error +- Existing record: update `read_at`; if unconfirmed, set `confirmed=1` +- No record: create confirmed record + +Response parameters: +- `notice_id`, `confirmed`, `confirm_time` + +--- + +## 7. WebSocket (H5) & State Sync + +> This version removed webman/push channel mode; H5 uses native WebSocket; HTTP polling is weak-network fallback only. + +### 7.0 Deployment & Connection Prerequisites (Current Implementation) + +Aligned with `app/process/GameWebSocketServer.php`, `config/process.php`, `app/common/library/admin/WebSocketConfigHelper.php`: + +- **Dedicated process**: run Webman process **`gameWebSocketServer`** (`config/process.php`), default **`H5_WEBSOCKET_LISTEN`** (`websocket://0.0.0.0:3131`). Without it, clients cannot connect. +- **URL**: prefer **`H5_WEBSOCKET_URL`** (full `ws://` / `wss://`, with path per `.env-example`). **Do not use `127.0.0.1` / `localhost` in production** for browser clients. Loopback in `.env` with external HTTP Host → `WebSocketConfigHelper` ignores config and derives `ws(s)://{host}/ws/`. Admin **`GET /admin/test.GameCurrentStatus/wsConfig`**, **`GET /admin/game.Live/wsConfig`** same; local fallback **`ws://127.0.0.1:3131/ws/`**. HTTPS sites need reverse proxy **`/ws/`** → port **3131**. +- **Mobile gap**: **`POST /api/game/lobbyInit` does not return WebSocket URL**; H5 must use ops/bundle `H5_WEBSOCKET_URL` or remote config (may differ from HTTP API host). +- **Mixed content**: HTTPS pages require **`wss://`**. +- **Redis event bus**: HTTP uses **`GameWebSocketEventBus`** (Redis list); if Redis down, broadcast pushes may fail except **`admin.live.snapshot`** has per-second direct snapshot fallback. +- **Handshake auth (2026-05, mandatory)**: `GameWebSocketServer::onWebSocketConnect` via `GameWebSocketAuthHelper::authorize` on URL query: + - **mobile**: query **`auth-token`** + **`user-token`** (hyphenated; legacy `auth_token`/`user_token` parsed but **use hyphens for new clients**). Binds `user_id`; user topics only to owner. + - **admin**: query **`admin-ws-token`** (from admin `wsConfig`, Redis, TTL 7200s). `user_id=0`, full observability. + - Failure → `{"event":"ws.error","code":1101,"message":"Authentication failed: ..."}` then `close`. +- **Server filter (user topics)**: `bet.win`, `user.streak`, `wallet.changed`, `bet.accepted`, `auto.spin.progress` only if `data.user_id` equals connection `user_id`. Others broadcast by subscription. Admin mode not filtered. +- **Idle timeout**: no uplink (incl. `ping`/`subscribe`) for 60s → server `close`. +- **Log channel `ws`**: `runtime/logs/ws.log` (7 days): enqueue, dispatch, handshake, subscribe, pong, idle close, send failures. +- **Subscribe required**: after connect, only handshake + subscribed topics; no `subscribe` → no `period.tick` etc. + +### 7.1 WebSocket Connection & Messages + +- **URL**: §7.0 (`H5_WEBSOCKET_URL` or admin `ws_url`) +- **Client**: browser `WebSocket` (`ws://` / `wss://`) +- **Required query (2026-05+)**: + - **H5/mobile**: `auth-token` + `user-token`. Optional: `device_id`, `lang`. + - **Admin**: `admin-ws-token` (in `ws_url`). + - Examples: + - H5: `wss://ws.example.com/ws/?auth-token=xxx&user-token=yyy&device_id=ios_001&lang=zh` + - Admin: `wss://ws.example.com/ws/?admin-ws-token=zzz` + - Missing/invalid → `ws.error` `1101` then close. +- **First frame on connect**: + - `event`: `ws.connected` + - `message`: `WebSocket connected` + - `connection_id`, `mode` (`mobile` | `admin`; **no** `user_id`) + - `server_time` (seconds, int) + - `heartbeat_interval` (30) + - `idle_timeout` (60; send `ping` within `idle_timeout - heartbeat_interval`) +- **Error frames** (not HTTP `code` band): + - Bad JSON: `ws.error`, `Invalid JSON payload` + - Unknown `action`: `ws.error`, `Unsupported action`, maybe `received_action` + - Process error: `ws.error`, `Server internal error`, Workerman `code`/`detail` +- **Suggested messages**: + - Heartbeat: `{"action":"ping"}` + - Pong: `{"event":"pong","server_time":"YYYY-mm-dd HH:ii:ss"}` (**string** local time; business pushes often use int seconds—branch in client) + - Subscribe: `{"action":"subscribe","topics":["period.tick"]}` + - Streak/odds: `["user.streak","wallet.changed","bet.accepted"]` + - Funds: `["bet.accepted","wallet.changed"]` + - Auto spin: `["auto.spin.progress","wallet.changed"]` + - Recommended mobile set: `period.tick`, `bet.win`, `user.streak`, `wallet.changed`, `bet.accepted`, `period.opened`, `period.payout`, `jackpot.hit` + +#### 7.1.1 Message Protocol Fields + +- Client → server: + - `action`: `ping` / `subscribe` + - `topics`: required for `subscribe` (array) +- Server → client: + - `event`, `topic`, `data`, `server_time` (seconds) + +#### 7.1.2 Subscribe Behavior + +- **Connection alone does not stream all business events**; send `subscribe`. +- Success: `{"event":"ws.subscribed","topics":[...]}` (deduped, sorted). +- **`subscribe` replaces** the whole subscription set (not append). +- Without subscription: handshake + `pong` only. +- **Server filters by bound user** on mobile; outbound **`data` has no `user_id`** (and other sensitive fields). +- **Outbound redaction (2026-05)**: removes `user_id`, `uuid`, `phone`, `balance_before`, `channel_id`; `jackpot.hit` `hits[]` keeps `nickname`, `period_no`, `total_win`, `result_number`, etc. +- **No** full `streak_win_reward` table; odds via `user.streak` / `wallet.changed` / `bet.accepted` and `lobbyInit.user_snapshot`. + +#### 7.1.2A Streak Odds & Streak Count (WebSocket) + +- **`user.streak`** (after settlement; current player odds): + - `data.current_streak`, `streak_level`, `odds_factor`, `is_jackpot` +- **`wallet.changed` / `bet.accepted` / `bet.win`**: merge **`current_streak`**, **`streak_level`**, **`odds_factor`**, **`is_jackpot`** (no `user_id`). +- **`bet.accepted` vs `bet.win` `is_jackpot`**: `bet.accepted` = tier at bet time; **`bet.win` authoritative** for settlement jackpot tier (`streak_at_bet` → `streak_win_reward.is_jackpot=true`, usually tier 10). + +#### 7.1.3 Push Frequency & Triggers (Current) + +- `period.tick`: **only when `status ∈ {betting, locked}`**, once per second (no full odds table). + - **Payout silence**: no `period.tick` during `status=payouting`; instead **`period.payout.tick`** per second with `payout_remaining_seconds` / `payout_until`. Wins via `period.opened` / `period.payout` / **`bet.win`** / `jackpot.hit` / `wallet.changed(biz_type=payout)`. + - **Boundary (once per period)**: + - `status=finished`: one frame when payout grace ends. + - `status=void`: one frame on void. + - **Resume**: new period `betting` restarts ticks. + - Dedup: Redis `dfw:v1:ws:tick:boundary:{period_no}:{status}` (TTL 300s). +- `user.streak`: per user after settlement (may reset to 0). +- `admin.live.snapshot`: every second (admin live page; not affected by payout silence). +- `period.opened` / `period.payout` / `admin.live.opened`: event-driven. +- `wallet.changed`: on balance change; payout includes `amount`, `period_no`, `period_id`, `result_number`. +- **`bet.win` (wins this period, small + jackpot)**: + - After settlement, aggregated per winning user (same batch as `wallet.changed(payout)`); **personal win UI listens here**; style via `data.is_jackpot`. Jackpot users still get `bet.win`; don’t rely only on `jackpot.hit`. + - Fields: `period_id`, `period_no`, `result_number`, `total_win`, `balance_after`, `bets[]` `{ bet_id, win_amount }`, **`is_jackpot`**, **`is_win`** (`true`), **`payout_pending_review`** (jackpot admin review pending), merged streak fields, `server_time`. + - Dedup: `dfw:v1:ws:betwin:{period_id}:{user_id}` (86400s), separate from `dfw:v1:settle:notify:{period_id}`. Payload rebuilt from DB settled orders if memory aggregate empty. + - **Compensation**: `buildBetWinPayloadsFromSettledOrders` if needed. + - After **`approveJackpot`**: pushes `bet.win` again post-credit. +- **`jackpot.hit` (public jackpot broadcast)**: + - After **`bet.win`** in same batch, if jackpot-tier hits exist, one public frame for marquee; else skip. **Personal UI still `bet.win`**. + - Order: `bet.win` per user → `jackpot.hit` if applicable. + - `hits[]`: `nickname`, `period_no`, `total_win`, `result_number` (no `user_id`). + +#### 7.1.4 Settlement Push Dedup & Ops Republish (2026-05) + +| Redis Key | Purpose | +|-----------|---------| +| `dfw:v1:settle:notify:{period_id}` | Once per period: `user.streak` / settlement-batch `wallet.changed` / `jackpot.hit` | +| `dfw:v1:ws:betwin:{period_id}:{user_id}` | Once per user per period: `bet.win` | + +**Ops republish** (settled win but client missed `bet.win`): + +```bash +php scripts/republish_bet_win.php --play-record-id=1370 +php scripts/republish_bet_win.php --period-id=123 +php scripts/republish_bet_win.php --period-no=20260526-183418-c9c90ef1 +# force ignore dedup: add --force +``` + +Integration: `php scripts/verify_ws_topic_subscribe.php`. + +### 7.1A Admin Connection (Ops Integration) + +- Menus: `连接服务器websocket` (test), **Game Management → Live Game** (`/admin/game/live`) +- WS config: + - `/admin/test.GameCurrentStatus/wsConfig` + - `/admin/game.Live/wsConfig` (auto subscribe incl. `admin.live.snapshot`, `bet.win`) +- **HTTP snapshot**: `GET /admin/game.Live/snapshot` read-only (`buildSnapshot`); **no** `recoverLiveRoundState` / auto-draw on this endpoint; progression via **`gameLiveTicker`** and draw flow. +- Admin page: `ws_url`, `connect_tip`, `sample_messages`; connect/disconnect; manual subscribe/ping; live frame log. + +### 7.2 HTTP Fallback APIs + +- Removed: `/api/game/currentStatus`, `/api/game/periodHistory`, `/api/wallet/balanceSummary`. +- State/balance primarily WebSocket; HTTP for actions/details (`placeBet`, `depositDetail`, `withdrawDetail`). + +### 7.3 Consistency Rules + +- Countdown from server time, not local clock drift. +- After bet, trust `placeBet.balance_after` and `wallet.changed`. +- On disconnect, reconnect and resubscribe; no `currentStatus`/`periodHistory`/`balanceSummary` backfill. + +--- + +## 8. Mobile End-to-End Call Flows + +## 8.1 First Game Entry +1. `GET /api/v1/authToken?secret=xxx×tamp=xxx&device_id=xxx&signature=xxx` → `auth-token` +2. `POST /api/user/login` (header `auth-token`) +3. `POST /api/game/lobbyInit` (header `auth-token`) +4. WebSocket base URL (**not in lobbyInit today**: ops/bundle `H5_WEBSOCKET_URL` or custom config) → connect, **send `subscribe` immediately** (§7.0/7.1; **include `bet.win`**) +5. `POST /api/game/placeBet` +6. Balance: `placeBet.balance_after` + `wallet.changed`; after draw, **`bet.win`** (`is_win=true`), jackpot style via `data.is_jackpot` (no `user_id` in payload) +7. On disconnect/foreground, reconnect and resubscribe + +## 8.2 Deposit → Bet → Withdraw +1. `POST /api/finance/depositTierList` (pick tier + `channels[].code`) +2. `POST /api/finance/depositCreate` (`tier_id` + **`channel_code=ddpay`** + `idempotency_key` + DDPay fields; JSON / form-data / urlencoded) + - `paid=false`, `status=pending`, non-empty `pay_url`: open **`pay_url`** (DDPay `payment_url`); credit via **`ddpayDepositNotify`**; poll `depositDetail` or `wallet.changed` +3. Optional `POST /api/finance/depositDetail` +4. `POST /api/game/placeBet` +5. `wallet.changed` or order detail +6. `POST /api/wallet/recordList` +7. `POST /api/finance/withdrawCreate` (immediate freeze) → `POST /api/finance/withdrawDetail` + +## 8.3 Popout Notice Flow +1. Client listens `notice.popout` +2. `GET /api/notice/noticeList` (includes `content`, `must_confirm`) +3. User confirms `GET /api/notice/noticeConfirm?notice_id=...` +4. Frontend may block bet until confirmed + +--- + +## 9. Game Sequence (WebSocket + HTTP) + +```mermaid +flowchart TD + A[User login /api/user/login] --> B[Lobby init /api/game/lobbyInit] + B --> C[Connect WebSocket and subscribe] + C --> D{0-20s betting window?} + D -- yes --> E[Place bet /api/game/placeBet] + E --> F[Wait wallet.changed for balance] + D -- no --> G[Lock and draw phase] + G --> H[Server tally and draw] + H --> I[WebSocket: period.opened / bet.win / wallet.changed etc.] + I --> J[Reconnect and resubscribe on disconnect] + J --> C +``` + +--- + +## 10. Admin Agent Commission Config (Admin Supplement, 2026-05-29) + +> Commission rates are maintained in **Administrator Management** `/admin/auth/admin`, not via the channel page “share ratio” dialog. + +### 10.1 Page & Display + +- **Tree table** by `parent_admin_id` (role-group style); expand/collapse +- Columns: username, nickname, **channel**, **parent agent**, **commission share (%)**, role group, invite code, status, etc. +- Super-admin public search: **channel dropdown filter** +- Non-super-admin: **self and all downline agents only**; no peers under other channel roots + +### 10.2 Form Fields + +| Field | Description | +|-------|-------------| +| `channel_id` | Channel (super-admin selectable; others follow role/account) | +| `parent_admin_id` | Parent agent; empty = channel **root agent** | +| `commission_share_rate` | Share taken from parent’s period commission (0–100); required when parent set | +| `group_arr` | Role group (single select, permissions only) | + +### 10.3 Validation & Hints + +- Under same `parent_admin_id`, sum of enabled children’s `commission_share_rate` **≤ 100%** +- Form calls **GET** `/admin/auth.Admin/commissionShareRemainder?parent_admin_id=&exclude_id=` for remaining allocatable % +- At 100% total: hint that parent retains no share at this level + +### 10.4 Settlement Split (Channel Settlement Integration) + +- After channel settlement total commission, **`AdminCommissionDistributionService`** splits recursively from root agent +- Each admin’s net amount → `agent_commission_record` and **immediate credit** to `admin_wallet` +- At least one channel root agent required; else settlement fails + +### 10.5 Legacy APIs (Deprecated in UI; Do Not Integrate) + +- ~~GET `/admin/channel/channelAdminShareList`~~ +- ~~POST `/admin/channel/saveChannelAdminShare`~~ + +--- + +## 11. Open Implementation Decisions (Before API Development) + +1. **Login**: password only, or SMS/email OTP? +2. **Withdraw receive types**: bank only v1, or e-wallet/crypto too? +3. **Auto spin**: ship in v1 or hide `auto-bet` APIs? +4. **WebSocket topics**: fix topic names and payloads per this doc? +5. **Error codes**: company-wide table to align with this draft? + +After confirmation: implement controllers + validate + service + routes per this document. diff --git a/docs/en/commission-share-guide.md b/docs/en/commission-share-guide.md new file mode 100644 index 0000000..a02318c --- /dev/null +++ b/docs/en/commission-share-guide.md @@ -0,0 +1,126 @@ +# Commission Share Guide + +## 1. Purpose + +This document describes the end-to-end flow **channel commission → agent tree split → admin wallet credit**, so operations, finance, and engineering share the same configuration and settlement rules. + +--- + +## 2. Overall Structure (Two Layers + Tree) + +Commission is calculated in two steps: + +### A. Layer 1: Channel settlement (platform → channel) + +- Compute **channel total commission** from `channel.agent_mode`: + - **turnover**: base = sum of settled bet amounts; commission = total bet × `turnover_share_rate` + - **affiliate**: base = platform profit after cost deduction; commission = base × ladder share rate +- Data scope: `bet_order.status = 2` (settled), period = **last settlement end ~ current settlement time** +- Output: `agent_settlement_period` snapshot + +### B. Layer 2: Agent tree split (channel total → each admin’s net amount) + +- **Do not** configure flat channel-wide shares on the channel page (`channel_admin_share` is deprecated in UI; table may remain for history) +- Maintain the agent tree in **Administrator Management** (`/admin/auth/admin`): + - `parent_admin_id`: parent agent (empty for top-level) + - `commission_share_rate`: percentage taken from **parent’s commission for this period** (sub-agents only) +- At settlement, `AdminCommissionDistributionService` splits recursively: + 1. Channel total commission goes to **top-level agent(s)** (`parent_admin_id` is null) + 2. Each agent allocates to direct children by `commission_share_rate` from **their own received amount** + 3. **Parent keeps** = received amount − sum allocated to children + +**Example** (parent receives 3000 this period): + +| Sub-agent | Rate | Amount | +|-----------|------|--------| +| Sub-agent A | 20% | 600 | +| Sub-agent B | 40% | 1200 | +| Parent | — | 1200 (3000 − 600 − 1200) | + +If a sub-agent has further downline, the same rules apply on **their received amount**. + +--- + +## 3. Configuration & Permissions + +| Capability | Entry | Notes | +|------------|-------|-------| +| Channel commission params | `/admin/channel` | `agent_mode`, turnover/affiliate rates, settlement cycle, etc. | +| 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 | +| Visibility | Admin list | Non–super admin sees **self + all downline** only | +| Settlement | `/admin/channel` manual / cron | **Super admin only**; credits `admin_wallet` on settle | + +### 3.1 Sub-agent share validation + +- Under the same `parent_admin_id`, enabled sub-agents’ `commission_share_rate` **must not exceed 100% in total** +- Form shows **remaining allocatable rate** when creating/editing sub-agents +- If total is 100%, parent keeps **no commission** at this level +- Top-level agents (no parent) **do not** set `commission_share_rate` + +### 3.2 Role groups + +- `admin_group` is for **menu/data permissions only** +- **Not** used in amount calculation + +--- + +## 4. Settlement Flow + +1. Super admin triggers channel settlement (manual or `ChannelAutoSettleTicker`) +2. `ChannelSettlementService::buildSettlePayload` aggregates bets and computes channel total commission +3. `AdminCommissionDistributionService::distributeChannelCommission` splits by agent tree +4. In one transaction: + - Insert `agent_settlement_period` (`status = 2` completed) + - Insert `agent_commission_record` per admin with amount > 0 (`status = 1` paid) + - `AdminWalletService::creditCommission` → `admin_wallet` + `admin_wallet_record` (`biz_type = commission_income`) +5. Reset `channel.carryover_balance` to 0 (settle-and-pay; no channel pending pool) + +**Prerequisite**: at least one **top-level agent** for the channel (`channel_id` match, `parent_admin_id` empty); otherwise settlement fails. + +--- + +## 5. Recommended wording (operations / stakeholders) + +1. **Channel commission**: computed from settled bets for the period (**not** from deposit volume). +2. **Distribution**: total commission enters the channel top agent, then splits down by **parent/child and share rates** in Administrator Management; parent keeps the remainder after paying children. +3. **Role groups**: permissions only, not money. +4. **Traceability**: each `agent_commission_record` links to `agent_settlement_period`; wallet `commission_income` traces back to commission records. + +--- + +## 6. Core fields + +| Table / field | Role | +|---------------|------| +| `channel.agent_mode` / `turnover_share_rate` / `affiliate_*` | Channel commission calculation | +| `admin.parent_admin_id` | Parent agent | +| `admin.channel_id` | Channel | +| `admin.commission_share_rate` | Share from parent (%); null for top-level | +| `agent_settlement_period` | Settlement period snapshot | +| `agent_commission_record` | Paid commission per admin | +| `admin_wallet` / `admin_wallet_record` | Admin wallet & ledger | + +> **Legacy** `channel_admin_share`: flat 100% split (2026-04-18); tree split since 2026-05-29. Do not configure via this table. + +--- + +## 7. Related code + +| Module | Path | +|--------|------| +| Channel settlement | `app/common/service/ChannelSettlementService.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/` | +| Auto settlement | `app/process/ChannelAutoSettleTicker.php` | + +--- + +## 8. Changelog + +| Date | Change | +|------|--------| +| 2026-04-18 | `channel_admin_share` flat split; removed `admin`/`admin_group.commission_rate` | +| 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 | diff --git a/docs/分红说明文档.md b/docs/分红说明文档.md index 97fdf70..add87cd 100644 --- a/docs/分红说明文档.md +++ b/docs/分红说明文档.md @@ -124,3 +124,4 @@ | 2026-04-18 | 落地 `channel_admin_share` 渠道内 flat 拆分;移除 `admin`/`admin_group.commission_rate` | | 2026-04-23 | 超管结算即发放至管理员钱包;新增 `admin_wallet` 体系 | | 2026-05-29 | **改为代理树形分红**:配置迁移至管理员管理 `commission_share_rate` + `parent_admin_id`;移除渠道页分配比例入口;管理员列表树形展示与下级可见范围 | +| 2026-05-29 | 新增英文文档 `docs/en/commission-share-guide.md`;后台切换 `lang=en` 时文档页自动加载英文版 | diff --git a/web/src/lang/backend/en.ts b/web/src/lang/backend/en.ts index d70ea5c..53f03b1 100644 --- a/web/src/lang/backend/en.ts +++ b/web/src/lang/backend/en.ts @@ -91,6 +91,12 @@ export default { doc_title: '36 Zihua Mobile API Design Draft', nav_outline: 'Outline', }, + docsCommissionShare: { + download: 'Download Markdown', + load_fail: 'Failed to load document', + doc_title: 'Commission Share Guide', + nav_outline: 'Outline', + }, vite: { Later: '稍后', 'Restart hot update': '重启热更新', diff --git a/web/src/lang/backend/zh-cn.ts b/web/src/lang/backend/zh-cn.ts index c04c136..903bcf7 100644 --- a/web/src/lang/backend/zh-cn.ts +++ b/web/src/lang/backend/zh-cn.ts @@ -91,6 +91,12 @@ export default { doc_title: '36字花移动端接口设计草案', nav_outline: '目录', }, + docsCommissionShare: { + download: '下载 Markdown', + load_fail: '文档加载失败', + doc_title: '分红说明文档', + nav_outline: '目录', + }, vite: { Later: '稍后', 'Restart hot update': '重启热更新', diff --git a/web/src/views/backend/docs/doc36ZiHuaMobileApi/index.vue b/web/src/views/backend/docs/doc36ZiHuaMobileApi/index.vue index 5de2edb..063e5b8 100644 --- a/web/src/views/backend/docs/doc36ZiHuaMobileApi/index.vue +++ b/web/src/views/backend/docs/doc36ZiHuaMobileApi/index.vue @@ -204,6 +204,13 @@ async function onDownload() { onMounted(() => { void loadContent() }) + +watch( + () => config.lang.defaultLang, + () => { + void loadContent() + } +)