Files
webman-buildadmin/docs/en/36zihua-mobile-api-design-draft.md
2026-05-29 12:01:00 +08:00

916 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 users 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&timestamp=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 wont auto-create periods, post-payout wont 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 136)
- `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<object>
- `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 110 table)
- `coin`: string (balance)
- `current_streak`: int (current win streak)
- `streak_level`: int (streak tier 110 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` (16)**. 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 136; count ≤ `pick_max_number_count`; duplicates deduped)
- `bet_id`: int (quick chip 16; 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 16)
- `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<object>
- `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<object>
- `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, 532, 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<object>
- `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`; dont 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&timestamp=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 (0100), required; **sub-agent**: share from parents 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 childrens `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 admins 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.