# 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; **top-level role group** (`admin_group.pid=0`): empty and disabled | | `commission_share_rate` | **Top-level role group**: share of channel period total (0–100), required; **sub-agent**: share from parent’s net amount, required when parent set | | `group_arr` | Role group (single select, permissions; top-level when `pid=0`) | ### 10.3 Validation & Hints **Top-level role group:** - Parent agent field disabled with hint “no parent required” - Share rate required; amount = **channel period total × this rate** - Under same `channel_id`, top-level agents’ rates **≤ 100% total** - **GET** `/admin/auth.Admin/commissionShareRemainder?is_top_level=1&channel_id=&exclude_id=` - Role group linkage: **GET** `/admin/auth.Admin/groupMeta?group_id=` **Sub-agent:** - Under same `parent_admin_id`, sum of enabled children’s `commission_share_rate` **≤ 100%** - **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`** allocates to top-level agents by `commission_share_rate`, then splits recursively downline - 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.