1.新增显示分红说明文档菜单

2.文档新增英文版
This commit is contained in:
2026-05-29 11:18:10 +08:00
parent f3677eb0e3
commit 9be9e2666b
13 changed files with 1669 additions and 17 deletions

View File

@@ -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 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; empty = channel **root agent** |
| `commission_share_rate` | Share taken from parents period commission (0100); required when parent set |
| `group_arr` | Role group (single select, permissions only) |
### 10.3 Validation & Hints
- Under same `parent_admin_id`, sum of enabled childrens `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 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.

View File

@@ -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 admins 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 **parents 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 | Nonsuper 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 |

View File

@@ -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` 时文档页自动加载英文版 |