# 36字花移动端接口设计草案（V1）

本文基于 `docs/36字花-数据库与实施计划.md` 与 PRD，先给出移动端可对接的接口清单与字段初设。  
口径遵循：**全平台单期号、单开奖结果**；渠道仅用于归属、分润与风控，不拆分对局。

**补充（2026-04）**：§**1.5** 描述服务端 **Redis 热点缓存**（`GameHotDataRedis`），**不改变**各接口 URL、参数与响应字段约定，仅供联调与运维对照。

## 1. 设计约定

### 1.1 基础约定
- 协议：HTTPS + JSON
- 接口命名规范：`/api/{module}/{action}`，且必须满足正则 `^/api/[a-z]+/[a-z]+[A-Z][a-zA-Z]*$`
- **请求方法**：所有移动端业务接口（`/api/*`，不含 `/api/v1/authToken`）一律使用 `POST` 调用；查询类接口同时兼容 `GET`（便于浏览器/调试工具直接访问），客户端统一走 `POST`
  - `POST` 时请求头 `Content-Type: application/json`，参数放在 JSON body
  - `GET` 兼容模式下，参数走 URL query string
  - **例外**：公告模块 `/api/notice/noticeList`、`/api/notice/noticeConfirm` **仅支持 `GET`**，参数一律走 URL query string
  - **公告列表免鉴权**：`GET /api/notice/noticeList` **无需** `auth-token`、`user-token`；若仍携带 `user-token` 则强弹窗项可返回该用户真实的 `is_read`
  - 鉴权类接口 `/api/v1/authToken` 仍为 `GET`
- 时间：UTC 时间戳（秒） + 服务端时区配置
- 金额：数字传输（如 `"100.00"`），客户端展示统一保留两位小数（存储仍为 `decimal(18,2)`）
- 幂等：关键写接口要求 `idempotency_key`
- 请求头（必带）：
  - `auth-token`：通过 `GET /api/v1/authToken` 获取的接口鉴权令牌（含义：接口访问的签名鉴权凭证）
  - `user-token`：用户登录态令牌；需要登录的接口必带
- 语言请求头：
  - `lang=zh`：返回中文（默认）
  - `lang=en`：返回英文

### 1.2 通用响应结构
```json
{
  "code": 1,
  "message": "ok",
  "data": {}
}
```

- `code=1` 表示成功，非 1 为业务错误
- `/api/*` 所有接口返回文案支持中英双语：默认中文；请求头 `lang=en` 返回英文，`lang=zh` 返回中文
- 建议错误码段（按错误性质）：
  - `1000-1099`：参数错误（字段缺失、类型错误、格式错误、超范围）
  - `1100-1199`：鉴权错误（未登录、token 失效、权限不足）
  - `2000-2999`：业务错误（余额不足、对局不存在、订单不存在、公告不存在）
  - `3000-3099`：流程错误（非法流程/状态不允许，如封盘后下注、重复确认、状态跃迁非法）
  - `5000-5999`：系统错误（服务异常、依赖超时、未知错误）

- 推荐基础错误码（首版）：
- `1`：成功
  - `1001`：参数缺失
  - `1002`：参数格式错误
  - `1003`：参数取值非法
  - `1101`：未登录或登录已过期
  - `1103`：无权限操作
  - `2001`：余额不足
  - `2002`：对局不存在
  - `2003`：订单不存在
  - `2004`：公告不存在
  - `3001`：当前流程不允许该操作
  - `3002`：已封盘，禁止下注
  - `3003`：重复请求（幂等冲突）
  - `5000`：系统繁忙，请稍后重试

### 1.3 鉴权方式
- **接口鉴权（auth-token）**：所有移动端业务接口请求时必须携带请求头 `auth-token`（由 `/api/v1/authToken` 签发）
- **用户登录鉴权（user-token）**：需要登录的接口携带请求头 `user-token`；token 失效后调用刷新或重新登录

### 1.4 获取接口鉴权 Token（auth-token）
- **GET** `/api/v1/authToken`
- 用途：获取 `auth-token`（所有接口请求头必带）

请求示例：
`/api/v1/authToken?secret=564d14asdasd113e46542asd6das1a2a&timestamp=1776331077&device_id=1&signature=AD84C49880896DBC16C59C7B122D1FF7`

请求参数：
- `secret`：string（含义：客户端密钥；服务端从环境变量 `AUTH_TOKEN_SECRET` 校验）
- `timestamp`：int（含义：请求时间戳；服务端允许与服务器时间误差 ±300 秒）
- `device_id`：string（含义：设备码）
- `signature`：string（含义：签名值）

签名算法：
- 取参与签名的参数（不含 `signature`）：`device_id`、`secret`、`timestamp`
- 按参数名 **a-z** 排序后拼接为字符串：`key=value&key=value...`
- 计算：`signature = strtoupper(md5(拼接字符串))`

返回参数：
- `auth_token`：string（含义：接口鉴权 token；放到请求头 `auth-token`）
- `expires_in`：int（含义：有效期秒数）
- `server_time`：int（含义：服务器时间戳，用于校时）

可能错误码：
- `1001` 参数缺失
- `1002` 参数格式错误
- `1103` 密钥无效/签名错误
- `3001` 时间戳无效

### 1.5 服务端性能与 Redis 热点缓存（实现说明）

> **对客户端无契约变更**：请求路径、参数、响应 JSON 形状与错误码均不因缓存而改变；本节仅说明服务端如何降延迟、读路径与一致性注意点。

**与「框架文件缓存」的区别**

| 配置 | 作用域 |
|------|--------|
| `CACHE_DRIVER`（`config/cache.php`，如 `file`） | Think-ORM / `get_sys_config()` 等**系统参数表 `config`** 的模型缓存，落盘在 `runtime/cache`，**不参与**本游戏业务热点路径。 |
| `GAME_HOT_CACHE_*`（`config/game_hot_cache.php`） | 游戏侧 **`user` / `game_config` / `game_record`** 行级 JSON 缓存，走 **`support\Redis`**（`config/redis.php` 连接），键前缀 `dfw:v1:`。 |

**服务端缓存覆盖（与移动端直接相关的读路径）**

- **用户**：会员鉴权优先读 Redis 中的 `user` 行快照，未命中再查库并回填。**余额、连胜、打码量等变更**落库后，统一经 **`GameHotDataCoordinator::afterUserCommitted($userId)`**：先 **`GameHotDataRedis::userReplaceCacheFromDb`** 与 DB 对齐，再向 Redis 写队列投递幂等刷新任务（见 `GameHotDataWriteQueue` / `GameHotDataQueueConsumer`），用于削峰而非替代同步回源。
- **游戏配置**：`game_config` 按 `config_key` 缓存。后台直连 `Db` 更新时须 **`GameHotDataCoordinator::afterGameConfigKeyCommitted($key)`**（模型 `GameConfig` 事件与独立表单控制器已接入）；独立保存接口在写入前对同一 `config_key` 使用 **`GameHotDataLock`（`TYPE_GAME_CONFIG`）** 互斥。勿仅删除缓存键而不回源，否则最长不一致窗口为 TTL。
- **对局**：当前活跃局、按 `id` 的局、最新一条 `game_record` 等；写库后经 **`GameHotDataCoordinator::afterGameRecordCommitted`** 同步刷新相关 Redis 键并入队。开奖/封盘等路径另可按记录 id 使用 **`GameHotDataLock`（`TYPE_GAME_RECORD`）** 串行化。

**环境变量（示例见仓库根目录 `.env-example`）**

- `GAME_HOT_CACHE_ENABLED`：是否启用上述 Redis 热点缓存（`false` 时全程回退数据库）。
- `GAME_HOT_CACHE_TTL_GAME_CONFIG` / `GAME_HOT_CACHE_TTL_GAME_RECORD` / `GAME_HOT_CACHE_TTL_USER`：各类缓存 TTL（秒）；**以写后同步回源为主**，TTL 仅作兜底。
- `GAME_HOT_CACHE_ENABLE_WRITE_QUEUE` 及队列长度、消费进程间隔等：控制写库后的**幂等刷新任务**是否入队及背压策略（见 `config/game_hot_cache.php`）。

**一致性提示（联调/测试）**

- 任何绕过协调入口、只改 DB 不调用 **`GameHotDataCoordinator`** 的手工脚本，都可能与 Redis 短期不一致；生产环境应避免。
- **`POST /api/game/betPlace`** 扣款路径使用与后台钱包加减点相同的 **用户维度 Redis 锁**（`GameHotDataRedis::userAdminMutationLockTry`）及 **`WHERE coin = ?` 条件更新**，与并发派彩/后台调账互斥；失败时返回 **§4.2** 所列中文说明。
- 客户端仍可按 **§3.2 `dictionaryList` 的 `version`** 做本地缓存；服务端字典另有 Redis 加速，二者可同时存在。

---

## 2. 认证与账户模块（user）

### 2.1 注册
- **POST** `/api/user/register`
- 用途：仅手机号注册并绑定邀请归属（admin/channel）

请求参数：
- `username`：string，手机号（含义：注册账号，仅支持大陆手机号）
- `password`：string，明文经 HTTPS 传输（含义：登录密码，服务端需加盐哈希存储）
- `invite_code`：string，必填（含义：子代理邀请码，用于绑定渠道 `channel_id` 与归属）
- `device_id`：string，可选（含义：设备标识，用于风控与登录记录）

返回参数：
- `user-token`：string（含义：后续接口登录态令牌；用于需要登录的接口请求头）
- `refresh_token`：string，可选（含义：用于刷新访问令牌）
- `expires_in`：int（秒，含义：令牌有效期）
- `user`：object（仅返回非私密信息，不返回 `id`）
  - `uuid`：string（含义：用户对外唯一标识，10 位）
  - `username`：string（含义：用户昵称/展示名）
  - `coin`：string（含义：当前余额）
  - `channel_id`：int（含义：归属渠道 ID）
  - `risk_flags`：int（含义：风控状态位）

### 2.2 登录
- **POST** `/api/user/login`

请求参数：
- `username`：string（含义：登录账号，当前支持手机号）
- `password`：string（含义：登录密码）
- `device_id`：string，可选（含义：设备标识，辅助风控）

返回参数：
- `user-token`：string（含义：访问令牌；用于需要登录的接口请求头）
- `refresh_token`：string，可选（含义：用于刷新访问令牌）
- `expires_in`：int（含义：访问令牌剩余有效秒数）
- `user`：object（仅返回非私密信息，不返回 `id`）
  - `uuid`：string（含义：用户对外唯一标识，10 位）
  - `username`：string（含义：用户昵称/展示名）
  - `coin`：string（含义：当前余额）
  - `channel_id`：int（含义：归属渠道 ID）
  - `risk_flags`：int（含义：风控状态位）

### 2.3 获取当前用户信息
- **POST** `/api/user/profile`

返回参数（金额类字段统一 2 位小数字符串，与钱包展示口径一致）：

**基础档案**
- `uuid`：string（含义：用户对外唯一标识，10 位）
- `username`：string（含义：昵称）
- `head_image`：string（含义：头像地址）
- `phone`：string（含义：手机号）
- `email`：string（含义：邮箱）
- `register_invite_code`：string（含义：注册邀请码快照）
- `channel_id`：int（含义：归属渠道 ID）
- `risk_flags`：int（含义：风控状态位）
- `current_streak`：int（含义：当前连胜次数）
- `last_bet_period_no`：string（含义：最近一笔有效下注所在期号）
- `create_time`：int（含义：注册时间戳）

**资金与提现配额**
- `coin` / `coin_balance`：string（含义：当前余额；两字段同值）
- `frozen_balance`：string（含义：冻结余额；无冻结场景，固定 `0.00`）
- `total_deposit_coin`：string（含义：累计充值）
- `total_withdraw_coin`：string（含义：累计提现；受理后累加）
- `bet_flow_coin`：string（含义：打码量/流水；开奖结算后按每注 `total_amount` 1:1 累加）
- `max_withdrawable`：string（含义：**当前允许发起的单笔最大提现金额** = `min(coin_balance, max_withdraw_by_flow)`）
- `withdraw_flow`：object（含义：打码量 / 提现配额诊断快照，此处额外附 `pending_withdraw`）
  - `ratio`：string（打码量倍数；`0` 表示不限打码）
  - `net_deposit`：string（净充值 = max(0, 累计充值 − 累计提现)）
  - `required_bet_flow`：string（按门槛口径所需打码量，纯展示）
  - `remaining_bet_flow`：string（按门槛口径还差多少打码量，纯展示）
  - `eligible`：bool（是否满足整体门槛，纯展示；真正放行以 `max_withdrawable` 为准）
  - `max_withdraw_by_flow`：string/null（仅按打码量折算的上限；`ratio=0` 时为 `null`）
  - `flow_unlimited`：bool（是否处于"不限打码"状态）
  - `pending_withdraw`：object
    - `count`：int（当前待审核提现订单数）
    - `max`：int（单用户最多允许的待审核提现数，当前为 `3`；超过 `withdrawCreate` 返回 `code=2004`）

### 2.4 刷新令牌（可选）
- **POST** `/api/user/refreshToken`

请求参数：
- `refresh_token`：string（含义：续签访问令牌的凭证）

返回参数：
- `user-token`：string（含义：新访问令牌）
- `expires_in`：int（含义：新令牌有效期）

---

## 3. 游戏大厅与字典模块（game/lobby）

### 3.1 获取首页初始化数据
- **POST** `/api/game/lobbyInit`
- 用途：一次返回本局、配置、36字花字典、用户快捷展示

返回参数：
- `server_time`：int（含义：服务端当前时间，用于客户端校时）
- `runtime_enabled`：bool（含义：**游戏运行开关**；`false` 时表示后台维护——**禁止下注**，且 idle 时不会自动创建新期、派彩结束后也不会自动创建下一期；**当前已开盘的局仍会开奖、派彩并结算**。移动端应禁用下注入口并提示「维护」类文案）
- `period`：object
  - `period_no`：string（含义：当前全局期号）
  - `status`：string（`betting`/`locked`/`settling`/`finished`/`void`，含义：当前期状态；`void` 表示该期已作废）
  - `countdown`：int（含义：当前期倒计时秒数）
  - `lock_at`：int（含义：封盘时间戳）
  - `open_at`：int（含义：预计开奖时间戳）
- `bet_config`：object
  - `pick_max_number_count`：int（含义：单注最多可选号码数，来自 `game_config.config_key = pick_max_number_count`，缺省与库内种子一致，通常为 10，合法范围 1–36）
  - `chips`：object（含义：快捷筹码字典，固定 6 个键 `"1"`…`"6"`，值为该档单注面额字符串，两位小数；与后台 `game_config.bet_chips` 语义一致）
  - `default_bet_chip_id`：int（含义：默认选中的筹码标识，来自 `game_config.default_bet_chip_id`，非法或指向无效档位时服务端回退为首个有效档）
  - `min_bet_per_number`：string（含义：单号码最小下注额，须 ≤ 所选筹码面额且受后台配置约束）
  - `max_bet_per_number`：string（含义：单号码最大下注额）
- `dictionary`：array<object>
  - `number`：int（1-36，含义：字花编号）
  - `name`：string（含义：字花名称）
  - `category`：string（含义：字花分类）
  - `icon`：string（含义：图标资源地址）
- `user_snapshot`：object（含义：用户状态快照 + **当前玩家本局适用赔率**，不下发 1～10 全表）
  - `coin`：string（余额）
  - `current_streak`：int（当前连胜场数）
  - `streak_level`：int（若本局中奖将使用的连胜档位 1～10，由 `min(current_streak+1, 10)` 推导）
  - `odds_factor`：int（赔率乘数；中奖派彩 = 本笔 `total_amount` × `odds_factor`）
  - `is_jackpot`：bool（是否大奖档）

### 3.2 获取36字花字典（可缓存）
- **POST** `/api/game/dictionaryList`

返回参数：
- `version`：string（含义：字典版本号，前端可用于缓存比对）
- `items`：同 `dictionary`（含义：36字花字典明细）

## 4. 下注与对局模块（game/bet）

### 4.1 获取当前期详情
- **POST** `/api/game/periodCurrent`

返回参数：
- `runtime_enabled`：bool（含义：同 `lobbyInit.runtime_enabled`）
- `period_id`：int（含义：当前期主键 ID）
- `period_no`：string（含义：当前期号）
- `status`：string（含义：当前期状态，含 `void` 已作废）
- `countdown`：int（含义：当前期剩余秒数）
- `bet_close_in`：int（含义：距离封盘剩余秒数）
- `result_number`：int/null（未开奖为 null，含义：开奖号码）

### 4.2 提交下注
- **POST** `/api/game/placeBet`（兼容旧路径 `/api/game/betPlace`）
- 用途：单期手动下注；玩家传入**压注号码**与**筹码标识 `bet_id`（1–6）**。单注金额由后台 `game_config.bet_chips` 解析，服务端按 `单注金额 × numbers数量` 计算本笔总扣款（落库 `total_amount`），开奖只出一个号码，若该号码 ∈ 所选号码集合即视为中奖。

请求参数：
- `period_no`：string（含义：下注目标期号）
- `numbers`：string（含义：本次压注号码集合，**英文逗号分隔**，如 `1,8,16`；每个号码为 1–36 的整数，数量不超过 `pick_max_number_count`（同 `lobbyInit.bet_config`），重复号码会去重）
- `bet_id`：int（含义：**快捷筹码标识**，取值 1–6，须为 `lobbyInit.bet_config.chips` 中存在的键；不再使用 `single_bet_amount` / `bet_amount` 传参）
- `idempotency_key`：string（必填，含义：防止重复下单）

返回参数：
- `order_no`：string（含义：下注订单号）
- `period_no`：string（含义：实际落单期号）
- `status`：string（`accepted`/`rejected`，含义：受理结果）
- `bet_id`：int（含义：本次使用的筹码标识）
- `single_bet_amount`：string（含义：本次单注金额，由 `bet_id` 对应档位解析得到）
- `numbers_count`：int（含义：本次号码数量）
- `locked_balance`：string（可选，含义：冻结金额）
- `balance_after`：string（含义：下单后余额）
- `current_streak`：int（含义：下单后连胜快照）

**可能错误码（补充）**（其余见文档头部错误码分段；扣款与缓存一致性强相关）：

- `3001`：游戏已暂停（`runtime_enabled=false`，后台「游戏实时对局」关闭运行开关或作废本局后未重新开启；与非法流程类错误同段）
- `5000`：系统繁忙；或 **用户 Redis 互斥锁**未获取（与后台钱包/并发写同一用户串行，文案与后台一致：「该用户正在被其他管理员操作（钱包/并发保存），请稍后再试」）；或 **`coin` 条件更新**未命中（并发下注/派彩/后台已改余额：「扣款失败：该用户余额已被其他请求修改（如下注、派彩或其他管理员已保存），请刷新后重试」）。

### 4.3 自动托管
- **POST** `/api/game/autoSpin`

请求参数：
- `action`：string（`start`/`stop`）
- `period_no`：string（`action=start` 时必填）
- `numbers`：string（`action=start` 时必填，英文逗号分隔）
- `bet_id`：int（`action=start` 时必填，含义同 `placeBet`，快捷筹码 1–6）
- `rounds`：int（`action=start` 时必填，>=1）

返回参数：
- `status`：string（`scheduled`/`stopped`）
- `auto_mode`：bool
- `bet_id`：int（仅 `start` 返回，本次托管使用的筹码标识）
- `single_bet_amount`：string（仅 `start` 返回，由 `bet_id` 解析得到的单注面额）
- `remaining_rounds`：int（仅 `start` 返回）

### 4.4 查询我的下注记录（最近1个月）
- **POST** `/api/game/betMyOrders`

请求参数：
- `page`：int（可选，默认 1）
- `page_size`：int（可选，默认 20）

返回参数：
- `list`：array<object>
  - `order_no`：string（含义：下注订单号）
  - `period_no`：string（含义：所属期号）
  - `numbers`：array[int]（含义：下注号码）
  - `bet_amount`：string（含义：本笔整笔压注金额，与 `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）

### 5.1 余额同步口径（已移除独立摘要接口）
- 已移除 `/api/wallet/balanceSummary`。
- 余额同步来源调整为：
  - 下注返回 `placeBet.balance_after`
  - WebSocket 推送 `wallet.changed`
  - 充值/提现详情接口（如 `depositDetail` / `withdrawDetail`）作为业务单据维度核对
- 倍数 `ratio` 由后台「游戏配置 → `withdraw_bet_flow_ratio`」维护，修改后对新请求立即生效。
- 历史累计类字段（`total_deposit_coin` / `total_withdraw_coin` / `bet_flow_coin`）均为累加语义；若后续审核驳回，回冲逻辑由后台审核流程负责。

### 5.2 钱包流水
- **POST** `/api/wallet/recordList`

请求参数：
- `page`：int（可选，默认 1）
- `page_size`：int（可选，默认 20）
- `type`：string，可选（含义：流水类型筛选，可选值如下；不传表示查询全部）
  - `deposit`：充值入账（充值订单成功后，金额入账到玩家余额）
  - `withdraw`：提现出账（提现订单受理/打款后，金额从玩家余额扣除或冻结）
  - `bet`：下注扣款（提交下注时从玩家余额扣除的投注金额）
  - `payout`：开奖派彩（中奖后系统将奖金入账到玩家余额）
  - `adjust`：人工调整（后台管理员加/扣点，对应 `biz_type=admin_credit/admin_deduct`）
  - `bet_void`：期次作废退款（后台「游戏实时对局」作废本局时，退回待开奖注单本金）

返回参数：
- `list`：array<object>
  - `record_id`：int（含义：钱包流水 ID）
  - `biz_type`：string（含义：业务类型）
  - `direction`：int（1入2出，含义：资金方向）
  - `amount`：string（含义：本次变动金额）
  - `balance_before`：string（含义：变动前余额）
  - `balance_after`：string（含义：变动后余额）
  - `ref_type`：string（含义：关联业务单类型）
  - `ref_id`：string（含义：关联业务单标识）
  - `create_time`：int（含义：流水时间）

补充约定：
- 金额字段（`amount`、`balance_before`、`balance_after` 等）客户端显示统一两位小数。
- 后台管理员加减点会生成 `biz_type=admin_credit/admin_deduct` 的流水记录，备注默认模板：`后台管理员（操作管理员）加点/扣点100（值）`（示例）。

### 5.3 充值档位列表
- **POST** `/api/finance/depositTierList`

说明：
- 由后台「配置管理 → 充值档位」维护，存放在 `game_config.deposit_tier`（JSON 数组）。
- 后台表单中的「支付货币」下拉来源于 `game_config.finance_cashier.currencies`（不再前端硬编码）。
- 初始化/重建档位时按当前 `finance_cashier` 货币集合生成：**每种货币 6 条档位**（运营可再编辑）。
- 仅返回启用状态（`status=1`）的档位，按 `sort` 升序；玩家仅能从中选择。
- 档位仅描述"充值规格"，不再包含收款账户；具体收款由第三方支付网关返回的 `pay_url` 引导。
- **多语言**：后台保存 `title`（中文名）、`title_en`（英文名）、`desc`（中文描述）、`desc_en`（英文描述）。接口返回的 `title` / `desc` 会根据请求头 `lang` 自动适配：
  - `lang=zh`（默认）：返回 `title` / `desc`，若为空则回退到英文
  - `lang=en`：返回 `title_en` / `desc_en`，若为空则回退到中文
  - 移动端客户端仅看到单一 `title` / `desc`，无需自行判断语言

请求参数：无（无需 body 与 query）

返回参数：
- `list`：list，档位列表；每一项结构：
  - `id`：string（含义：档位稳定 ID，创建订单时作为 `tier_id` 原样回传；与 `tier_key` 同值）
  - `tier_key`：string（含义：与 `id` 相同，兼容旧字段名）
  - `title`：string（含义：档位名称，已按 `lang` 头切换；例如 `lang=en` 下返回 `"Starter Pack"`，`lang=zh` 下返回 `"新手首充礼包"`）
  - `currency`：string（含义：标价币种，如 `CNY`）
  - `pay_amount`：string（2 位小数，含义：对外标价金额，与业务配置一致；展示用）
  - `amount`：string（2 位小数，含义：玩家本次需支付的充值金额）
  - `bonus_amount`：string（2 位小数，含义：该档位赠送金额，无赠送为 `0.00`）
  - `total_amount`：string（2 位小数，含义：到账总额 = amount + bonus_amount，方便前端直接展示"到账 120"）
  - `desc`：string（含义：档位描述/活动文案，已按 `lang` 头切换；可空）
  - `channels`：array（含义：可用支付渠道列表，用于 `depositCreate` 的 `channel_code`；渠道与档位不再做单独绑定，所有启用渠道自动兼容全部档位）
    - 每项：`code`（string，渠道代码，小写，与创建订单时传入的 `channel_code` 一致）、`name`（展示名）、`sort`（排序）

### 5.3A 获取充值/提现配置
- **POST** `/api/finance/depositWithdrawConfig`
- 兼容旧接口：`POST /api/finance/cashierConfig`（返回结构一致，建议客户端统一切到 `depositWithdrawConfig`）

用途：
- 一次性返回充值与提现页面所需配置：货币列表、汇率、可用充值渠道、提现银行、提现限额与文案配置。

返回参数：
- `platform_coin_label`：string（平台币名称，按 `lang` 适配）
- `currencies`：array
  - `code`：string（货币代码）
  - `label`：string（货币展示名，按 `lang` 适配）
  - `deposit_coins_per_fiat`：string（充值汇率）
  - `withdraw_coins_per_fiat`：string（提现汇率）
- `rates`：array（兼容字段）
  - `currency`：string
  - `diamonds_per_fiat_unit`：string
- `pay_channels`：array（充值渠道）
  - `code`：string（渠道代码）
  - `name`：string（展示名）
  - `sort`：int（排序）
  - `status`：int（启用状态，1=启用）
  - `tier_ids`：array（兼容字段；当前固定空数组，表示自动兼容全部充值档位）
- `withdraw`：object
  - `pay_channels`：array（提现可选支付渠道，用于 `withdrawCreate` 的 `channel_code`；仅返回后台已启用且服务端已对接出金的渠道）
    - 每项：`code`（string，渠道代码，小写）、`name`（展示名）、`sort`（排序）
  - `banks`：array（提现银行）
  - `min_ewallet`：string（电子钱包最低提现）
  - `min_bank`：string（银行卡最低提现）
  - `rate_hint`：string（汇率提示文案）
  - `processing_note`：string（到账提示文案）
  - `fee_note`：string（手续费提示文案）
  - `rate_mode`：string（`fixed` / `live`）
  - `fields`：object（**与 DDPay / `withdrawCreate` 一致**，不由后台「支付/收款配置」开关维护；用于客户端展示必填项）
    - `receive_type_bank_only`：bool（当前固定 `true`，仅支持银行卡出金）
    - `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`，`bank_branch` 选填，不传时服务端按 `N/A` 提交 DDPay）

### 5.4 创建充值订单
- **POST** `/api/finance/depositCreate`
- `Content-Type: application/json`（推荐）、`application/x-www-form-urlencoded` 或 **`multipart/form-data`**（如 Apifox 的 form-data）；字段名与下表一致即可，服务端通过统一参数名读取，**不限制**为某一种 body 类型。

说明：
- **模拟支付（推荐联调）**：`channel_code=mock`，无需 DDPay 商户配置；创建后返回 `pay_url`（**模拟收银台页面** `GET /api/finance/mockDepositPage`，浏览器打开；**链接有效期 3 分钟**，过期未支付则订单自动失败）。用户在收银台点击「确认支付」后，订单进入 **`pending_review`（待后台审核）**，不会立即入账；管理员在后台「充值订单」审核通过后才入账。
- 可选 **`GET/POST /api/finance/mockDepositPay`**（需登录，与创建订单同一 `auth-token`）：用于 App 内二次确认（与收银台确认二选一，幂等）。
- **DDPay**：`channel_code=ddpay`；先创建待支付单，再调用 DDPay「入金发起」；若成功返回 `payment_url`，则 `pay_url=payment_url`。
- 开关：`FINANCE_MOCK_PAY_ENABLED`（默认开发环境开启）；关闭后 `mock` 渠道不可用。
- 入账由 **`POST /api/finance/ddpayDepositNotify`** 验签后结算，或网关同步返回 `transaction_status=completed` 时结算；**`mock` 渠道**在用户确认支付后需 **后台审核通过** 才调用入账结算。
- 档位与渠道取自 `depositTierList`：`channels` 中会出现后台已启用的渠道（包含 `mock` / `ddpay` 等）。
- 同一用户最多 3 笔待支付充值单；**待支付**订单创建后，超过 `DEPOSIT_PENDING_EXPIRE_SECONDS`（默认 **60 秒**，见 `.env`）未支付会自动失败；支付链接倒计时与此一致。

请求参数：

**A. 通用必填参数**
- `tier_id`：string，必填（充值档位 ID；同义字段：`tier_key`）
- `channel_code`：string，必填，`mock`（模拟）或 `ddpay`（真实网关）
- `idempotency_key`：string，必填，≤64（客户端幂等键，建议 UUID）

**A.1 模拟支付（`channel_code=mock`）**

- 无需 `payment_type` / `payer_name` / `payer_bank_name`

**B. DDPay 渠道必填参数（`channel_code=ddpay`）**

> **依据**：DDPay 官方《Payment Gateway》接口说明（与仓库内 `docs/DDPay Payment Gateway_v1.1.3_zh.md` / `docs/DDPay Payment Gateway_v1.1.3.pdf` 一致；以下「官方」均指该文档）。

- `payment_type`：string，必填（DDPay 字段 **`payment_type`，支付方式选择**）
  - **取值须为官方枚举字符串**（文档 §3.1）：**`01`** = FPX，**`02`** = duitnow，**`03`** = ewallet；**其他取值须向 DDPay 商户支持另行确认**，勿自行臆造。
  - 兼容字段：`paymentType`
  - **注意**：若传入 `FPX`、`duitnow` 等**非官方编码**，网关可能直接拒绝；移动端应传 **`01` / `02` / `03`**。
- `payer_name`：string，必填（**付款账户持有人姓名**，须与银行登记信息一致）
  - 兼容字段：`payerName`
- `payer_bank_name`：string，必填（对应官方字段 **`payer_bank[name]`，付款银行名称**）
  - 须使用 **DDPay 银行列表中的银行全称**（与官方附录一致）：**MYR** 参考官方附录「入金银行列表（MYR）」中 **英文全称**（如 `Public Bank`、`Maybank2U`）；**THB** 参考「入金银行列表（THB）」中 **英文全称**（如 `BANGKOK BANK PUBLIC COMPANY LTD.`）。勿使用简称或与列表不一致的拼写，否则可能被网关拒单。
  - 兼容字段：`payer_bank[name]`、`payerBankName`

**B.1 服务端代传、客户端无需传的 DDPay 字段（供联调对照）**

以下由服务端在调用 DDPay「入金发起」时自动组装（来自环境/配置），移动端**不要**也无法通过本接口覆盖：

- `client_id`、`identifier`（若项目环境已配置 `DDPAY_IDENTIFIER`）、`order_id`（即本系统 `order_no`）
- `transaction_amount`：取自所选档位的 **`pay_amount`**（法币应付额，类型为 Decimal），币种以商户在 DDPay **onboarding 时约定币种**为准；官方示例表写 **MYR**，若贵司为 THB 等，以 DDPay 后台为准。
- `callback_url`、`redirect_url`：由服务端拼接，根地址优先读取环境变量 **`DDPAY_PUBLIC_BASE_URL`**（见 `.env-example`），未配置时按请求 `Host` 推导；官方要求 **HTTPS**（生产务必配置公网 HTTPS 根地址）。

> **常见 1001 原因（DDPay）**：只传了通用参数，漏传 `payment_type / payer_name / payer_bank_name` 之一；或 `payment_type` 未使用官方 `01/02/03`。

推荐请求示例（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"
}
```

返回参数：
- `order_no`：string（含义：充值订单号）
- `amount`：string（2 位小数，含义：玩家本次支付的充值金额，与所选档位 `amount` 一致）
- `bonus_amount`：string（2 位小数，含义：本次赠送金额，与所选档位 `bonus_amount` 一致，无赠送为 `0.00`）
- `total_amount`：string（2 位小数，含义：实际入账总额 = amount + bonus_amount）
- `pay_channel`：string（含义：支付通道标识，与请求中选择的 `channel_code` 一致，落库在订单上）
- `paid`：bool（含义：当前单据是否已到账；`true` 表示钱包已入账、`status=paid`；`false` 表示尚未入账；**`mock` 待审核**也为 `false`）
- `pay_url`：string（含义：收银台地址；**`pending`（待支付）** 时：`ddpay` 为三方 **`payment_url`（完整 URL）**；**`mock` 为模拟收银台**（`GET /api/finance/mockDepositPage`）；**`paid=true` 或已进入待审核**时可能为空串）
- `review_required`：bool（含义：是否需要后台审核入账；**仅 `mock` 在用户确认支付后**为 `true`）
- `reject_reason`：string 或 null（含义：审核驳回原因；仅失败且存在驳回原因时有值）
- `expire_at`：int 或 null（含义：`pay_url` 过期时间戳（秒）；主要用于 `mock` 待支付）
- `expire_seconds`：int 或 null（含义：与 `expire_at` 配套，待支付时返回，值同 `DEPOSIT_PENDING_EXPIRE_SECONDS`）
- `status`：string（`pending` / `pending_review` / `paid` / `failed`，含义：`pending`=待支付；`pending_review`（仅 mock）=用户已确认，等待后台审核；`paid`=已入账；`failed`=失败/超时/驳回）
- `create_time`：int（含义：订单创建时间，秒级时间戳）
- `pay_time`：int（含义：订单到账时间，未到账为 0）

#### 5.4.1 DDPay 回调与状态说明（当前实现）

- 回调地址：`POST /api/finance/ddpayDepositNotify`（由服务端在 DDPay 发起请求时作为 **`callback_url`** 传给三方；须 **HTTPS**）。
- 官方 Webhook 负载字段（文档 §3.5，处理前**必须先验签**）：

| 参数 | 说明 |
|---|---|
| `client_id` | 商户标识 |
| `order_id` | 交易引用号（与本系统充值 `order_no` 对应） |
| `transaction_status` | 最终状态：`pending` / `completed` / `failed`（含义见官方 §4.1） |
| `timestamp` | 通知时间 |
| `transaction_amount` | 支付金额（配置币种下） |
| `signature` | 通知校验签名 |

- 我方验签通过后：
  - `transaction_status=completed`：执行入账结算，订单转 `paid`
  - `transaction_status=failed`：订单转 `failed`
- 官方要求：收到通知后应尽快返回 HTTP **200**，响应体为纯文本 **`{"status":"ok"}`**；未收到确认时平台最多重试 **6** 次（以官方文档为准）。
- 若三方暂未提供/未打通回调，`pending` 订单将保持待处理；官方另提供「**入金状态查询**」接口（请求需含 `query_time`，格式 **`YYYY-MM-DD HH:MM:SS`**），当前移动端未单独暴露，由服务端按需扩展。

错误码约定：
- `1001`：缺少必填参数（`tier_id`（或 `tier_key`）、`channel_code`、`idempotency_key` 任一未传或为空字符串）
- `1001`（DDPay）：缺少 `payment_type` / `payer_name` / `payer_bank_name`，或 `payment_type` 非官方允许取值
- `1002`：`idempotency_key` 过长，或与其他玩家的订单冲突
- `2000`：**通用**：订单落库或入账失败（事务回滚等，可能带原始错误描述）；**DDPay**：调用「入金发起」失败（网络/HTTP/JSON/验签/`status_code≠0` 等），对外文案为「DDPay 充值发起失败」，**具体原因可查看该笔 `deposit_order.remark`（前缀 `[ddpay]`）或服务端日志**
- `2003`：所选 `tier_id` 不存在、已停用或不在启用列表中
- `2004`：不支持的 `channel_code`；`mock` 已关闭；或 `ddpay` 未启用；或当前档位币种与该渠道不允许的组合
- `2005`：待支付充值单超过上限（`data.max_pending`、`data.pending_count`、`data.expire_seconds`）

### 5.4A 模拟支付（浏览器收银台 + 可选 App 确认）

- **GET/POST** `/api/finance/mockDepositPage`：模拟收银台 HTML 页面（**无需登录**；`order_no` 必填；仅 `pay_channel=mock` 且 `status=pending` 且未过期可展示支付按钮）
- **GET/POST** `/api/finance/mockDepositConfirm`：收银台内确认支付（**无需登录**；将订单标记为 **`pending_review`**，不入账）
- **GET/POST** `/api/finance/mockDepositPay`：App 内确认（**需登录**；与上一接口二选一、幂等）

说明：后台管理员在 **「充值订单」** 对待审核单执行审核通过/驳回后，`mock` 订单才会最终成功入账或失败（未接入真实商户前的模拟流程）。

### 5.5 查看充值订单详情
- **POST** `/api/finance/depositDetail`

请求参数：
- `order_no`：string，必填（含义：充值订单号）

返回参数（与 `depositCreate` 统一结构）：
- `order_no`：string（含义：充值订单号）
- `amount`：string（2 位小数，含义：本单充值金额）
- `bonus_amount`：string（2 位小数，含义：本单赠送金额，无赠送为 `0.00`）
- `total_amount`：string（2 位小数，含义：入账总额）
- `pay_channel`：string（含义：支付通道标识）
- `paid`：bool（含义：是否已到账）
- `pay_url`：string（含义：收银台地址；已到账或无需再支付时可能为空串）
- `review_required`：bool（含义：是否等待后台审核入账）
- `reject_reason`：string 或 null（含义：驳回原因）
- `expire_at`：int 或 null（含义：`pay_url` 过期时间戳（秒））
- `expire_seconds`：int 或 null（含义：与 `expire_at` 配套）
- `status`：string（`pending` / `pending_review` / `paid` / `failed`）
- `create_time`：int（含义：订单创建时间）
- `pay_time`：int（含义：订单到账时间，未到账为 0）

### 5.6 查询充值订单列表
- **POST** `/api/finance/depositList`

用于我的充值记录页的分页列表；列表含订单状态，到账时间/支付通道等完整字段请再调用 `/api/finance/depositDetail` 获取。

请求参数：
- `page`：int，选填，默认 `1`（含义：页码，从 1 开始）
- `page_size`：int，选填，默认 `20`，最大 `100`（含义：每页数量，超出范围回退为 `20`）

返回参数：
- `list`：array（含义：充值订单列表，按 `id desc` 排序）
  - `order_no`：string（含义：充值订单号）
  - `amount`：string（2 位小数，含义：本单充值金额）
  - `bonus_amount`：string（2 位小数，含义：本单赠送金额，无赠送为 `0.00`）
  - `status`：string（含义：订单状态，与 `depositDetail` 一致：`pending` / `pending_review` / `paid` / `failed`）
- `pagination`：object（含义：分页信息）
  - `page`：int（含义：当前页码）
  - `page_size`：int（含义：每页数量）
  - `total`：int（含义：总记录数）

### 5.7 提现申请
- **POST** `/api/finance/withdrawCreate`

说明：
- **仅支持 DDPay 出金**：`channel_code` 必须为 **`ddpay`**（兼容同义字段 `pay_channel`），且该渠道在收银台配置中为启用状态，否则返回 `code=2004`。
- 可选渠道列表见 `depositWithdrawConfig` → `withdraw.pay_channels`。

请求参数：
- `channel_code`：string，必填（支付渠道代码；当前仅支持 `ddpay`；兼容字段 `pay_channel`）
- `withdraw_coin`：string（含义：申请提现金额，必须 > 0）
- `receive_account`：string（含义：收款账号；对接 DDPay 出金时对应官方字段 **`receiver_account`**，为收款账户号/手机号，须与银行登记一致）
- `receive_type`：string（含义：收款类型；当前版本仅支持 `bank`）
- `idempotency_key`：string（含义：防重复提交提现）
- `receiver_name`：string（含义：收款账户持有人姓名；`receive_type=bank` 必填，对应官方 **`receiver_name`**，须与银行登记一致）
- `receiver_email`：string（含义：收款人邮箱，必填，须为合法邮箱格式，最长 255）
- `receiver_mobile`：string（含义：收款人手机号，必填，5–32 位，仅允许数字与 `+` `-` 空格，至少含 5 位数字）
- `bank_code`：string（含义：银行代码；`receive_type=bank` 必填。取值来自收银台配置 `withdraw_banks[].code`；服务端会映射为 DDPay 所需的 **`bank[name]`** 银行全称发起出金）
- `bank_branch`：string（含义：银行支行名称；`receive_type=bank` 可选。官方 **`bank_branch`** 为必填项，若客户端不传则服务端按 **`N/A`** 提交，与 DDPay 文档「若缺失则默认值为 `N/A`」一致）

**DDPay 出金（Payout）官方请求字段对照（文档 §3.2，由服务端在审核通过后组装）**

| 官方参数 | 说明 |
|---|---|
| `client_id` | 商户账号标识 |
| `bill_number` | 出金唯一引用号（与本系统提现 `order_no` 对应） |
| `amount` | 出金金额（Decimal，配置币种下） |
| `receiver_name` | 收款账户持有人姓名 |
| `receiver_account` | 收款账户号/手机号 |
| `bank[name]` | 银行全称（须参考官方附录「出金银行列表」） |
| `bank_branch` | 支行名称；缺失为 `N/A` |
| `callback_url` | 异步通知地址，须 **HTTPS** |
| `signature` | MD5 签名（小写） |

出金成功响应中，官方还可能返回 `transaction_fee`、`transaction_total`、`transaction_status`、`remark` 等；最终以 DDPay 文档为准。

返回参数：
- `order_no`：string（含义：提现订单号）
- `status`：string（`pending_review`/`approved`/`rejected`，含义：提现状态；已打款合并到 `approved`）
- `fee_coin`：string（含义：手续费）
- `actual_arrival_coin`：string（含义：实到账金额）
- `risk_review_required`：bool（含义：是否命中人工审核）

校验顺序（任一失败即返回对应错误码，不再创建订单）：
1. 参数完整性与金额合法性（`code=1001`；含 `channel_code`、`withdraw_coin` 等必填项；金额必须为数值且 > 0）
2. **支付渠道**：`channel_code` 非 `ddpay` 返回 `code=2004 Withdraw only supports DDPay`；渠道未启用返回 `code=2004 Pay channel not available`
3. **待审核订单数限制**：同一用户 `status=0`（待审核）的 `withdraw_order` 不得超过 3 笔，否则 `code=2004 Too many pending withdraw orders`，`data` 中回传：
   - `max_pending`：上限值（当前为 `3`）
   - `pending_count`：当前待审核订单数
4. `coin_balance >= withdraw_coin`，否则 `code=2001 Insufficient balance`
5. **单笔上限校验**：`withdraw_coin <= max_withdrawable`，否则 `code=2002 Withdraw exceeds available bet flow`，`data` 中回传：
   - `max_withdrawable`：**当前允许的单笔最大提现金额**（= `min(coin_balance, max_withdraw_by_flow)`，前端据此提示"最大可提现金额为 XXX"）
   - `coin_balance`、`bet_flow_coin`、`total_withdraw_coin`、`ratio`
   - `max_withdraw_by_flow`：仅按打码量折算的上限（= `max(0, bet_flow_coin / ratio - total_withdraw_coin)`）；`ratio=0` 时为 `null`
6. 以上全通过后在同一事务内：
   - `withdraw_order` 写入：`pay_channel`（= `channel_code`）/ `amount` / `fee`（默认 0.5%） / `actual_amount = amount - fee` / `status=0`（待审核） / `channel_id` 取自用户归属渠道快照；同时写入收款字段（`receive_type/receive_account/receiver_email/receiver_mobile/receiver_name/bank_code/bank_branch`）。
   - `user` 表原子更新：`coin -= withdraw_coin` 且 `total_withdraw_coin += withdraw_coin`（WHERE `coin >= withdraw_coin` 防止并发超额扣减）。
   - `user_wallet_record` 写入 `biz_type=withdraw`、`direction=2`、`amount=withdraw_coin`、`ref_type=withdraw_order`、`idempotency_key=wd_apply_{order_no}`，代表"冻结"动作。

说明（打码量即提现配额模型）：
- 单笔最大可提现 `max_withdrawable = min(coin_balance, max_withdraw_by_flow)`；每笔提现按 `withdraw_coin × ratio` 消耗打码配额，已消耗部分累积在 `total_withdraw_coin`。
- `ratio = 0` 时视为"不限打码"，单笔上限仅受 `coin_balance` 约束。
- 采用"申请即冻结"语义：提现在移动端提交后立即从 `user.coin` 中扣减并写出金流水；后台审核 **拒绝** 时由管理端在同一事务中回冲余额、`total_withdraw_coin` 与流水，不出现"等待审核期间用户还能把这笔钱再下注"的漏洞。
- 后台审核 **通过** 时会触发三方出金（DDPay Payout）；出金 **失败** 会自动回冲余额、`total_withdraw_coin` 与流水（`withdraw_refund`），并将订单标记为 `rejected`（内部 `status=2`）。
- 后台审核 **通过** 且出金完成后，订单内部 `status=3`（已打款），移动端对外仍合并展示为 `approved`。
- `withdraw_bet_flow_ratio` 由后台「游戏配置」维护，默认 `1.00`，修改后对新请求立即生效。

### 5.8 查看提现订单详情
- **POST** `/api/finance/withdrawDetail`

请求参数：
- `order_no`：string，必填（含义：提现订单号）

返回参数：
- `order_no`：string（含义：提现订单号）
- `status`：string（`pending_review`/`approved`/`rejected`，含义：审核状态；`status=3 已打款` 暂未对外暴露，合并到 `approved`）
- `withdraw_coin`：string（含义：申请提现金额，与后台 `withdraw_order.amount` 对齐）
- `fee_coin`：string（含义：手续费，与后台 `withdraw_order.fee` 对齐）
- `actual_arrival_coin`：string（含义：实际到账金额 = 申请金额 - 手续费；后台审核调整后会同步刷新）
- `receive_type`：string（含义：收款类型）
- `receive_account`：string（含义：收款账号）
- `pay_channel`：string（含义：支付渠道代码，与 `channel_code` 一致）
- `receiver_email`：string（含义：收款人邮箱）
- `receiver_mobile`：string（含义：收款人手机号）
- `reject_reason`：string/null（含义：拒绝原因，`status=rejected` 时取自 `withdraw_order.remark`，否则为 `null`）
- `create_time`：int（含义：申请时间）
- `review_time`：int/null（含义：审核时间戳，未审核为 `null`）

### 5.9 查询提现订单列表
- **POST** `/api/finance/withdrawList`

用于我的提现记录页的分页列表；列表含审核/打款状态摘要，手续费、实到账、拒绝原因等请再调用 `/api/finance/withdrawDetail` 获取。

请求参数：
- `page`：int，选填，默认 `1`（含义：页码，从 1 开始）
- `page_size`：int，选填，默认 `20`，最大 `100`（含义：每页数量，超出范围回退为 `20`）

返回参数：
- `list`：array（含义：提现订单列表，按 `id desc` 排序）
  - `order_no`：string（含义：提现订单号）
  - `amount`：string（2 位小数，含义：申请提现金额，与后台 `withdraw_order.amount` 对齐）
  - `status`：string（含义：订单状态，与 `withdrawDetail` 一致：`pending_review`/`approved`/`rejected`；后台已打款 `status=3` 合并为 `approved`）
- `pagination`：object（含义：分页信息）
  - `page`：int（含义：当前页码）
  - `page_size`：int（含义：每页数量）
  - `total`：int（含义：总记录数）

---

## 6. 公告与消息模块（operation/notice）

### 6.1 拉取公告列表
- **GET** `/api/notice/noticeList`
- **鉴权**：无需 `auth-token`、`user-token`（公开接口）

请求参数（query string）：
- `page`：int（可选，默认 1）
- `page_size`：int（可选，默认 20）

返回参数：
- `list`：array<object>
  - `notice_id`：int（含义：公告 ID）
  - `title`：string（含义：公告标题）
  - `content`：string（含义：公告正文，原详情接口字段）
  - `notice_type`：string（`silent`/`popout`，含义：公告类型）
  - `must_confirm`：bool（含义：是否必须手动确认；强弹窗为 `true`）
  - `is_read`：bool（含义：当前用户是否已确认；**仅 `popout` 强弹窗有效**，`silent` 恒为 `false`，不写入阅读记录）
  - `publish_time`：int（含义：发布时间）

> **阅读记录口径**：`user_notice_read` 仅用于强弹窗（`notice_type=popout`）的确认已读；静默信箱（`silent`）不生成、不查询阅读记录。

### 6.2 强弹窗确认已读
- **GET** `/api/notice/noticeConfirm`

请求参数（query string）：
- `notice_id`：int（含义：待确认公告 ID）

行为说明：
- **仅强弹窗**（`notice_type=popout`）可调用；静默公告调用返回业务错误
- 若当前用户对该公告已有阅读记录：仅更新 `read_at`；若尚未确认则同时将 `confirmed` 置为 `1`
- 若无阅读记录：创建一条已确认记录

返回参数：
- `notice_id`：int（含义：已确认公告 ID）
- `confirmed`：bool（含义：确认结果）
- `confirm_time`：int（含义：确认时间）

---

## 7. WebSocket（H5）与状态同步

> 本版本已移除 webman/push 频道模式；H5 前端使用原生 WebSocket 直连，HTTP 轮询仅作为弱网兜底。

### 7.0 部署与连接前置条件（当前实现）

以下与代码 `app/process/GameWebSocketServer.php`、`config/process.php`、`app/common/library/admin/WebSocketConfigHelper.php` 一致，**文档此前未写明的条件**在此补齐：

- **独立进程**：需在运行环境中启动 Webman 自定义进程 **`gameWebSocketServer`**（见 `config/process.php`），默认监听 **`H5_WEBSOCKET_LISTEN`**（缺省为 `websocket://0.0.0.0:3131`）。未启动进程则客户端无法建连或立刻断线。
- **连接 URL**：优先使用环境变量 **`H5_WEBSOCKET_URL`**（完整 `ws://` / `wss://` 地址，建议带路径，如 `.env-example` 所示）。未配置时，后台 **`GET /admin/test.GameCurrentStatus/wsConfig`**（及游戏实时页同类接口）会通过 `WebSocketConfigHelper` 按当前请求的 `Host` / `X-Forwarded-*` 推导 `ws(s)://{host}/ws/`，本地兜底 **`ws://127.0.0.1:3131/ws/`**。
- **移动端配置缺口**：**`POST /api/game/lobbyInit` 当前不下发 WebSocket 地址**；H5 需与运维约定同一套 `H5_WEBSOCKET_URL`（打包进前端配置、远程配置中心等），与 HTTP API 基址可不同域。
- **混合内容**：若 H5 页面为 **HTTPS**，浏览器要求 WebSocket 使用 **`wss://`**，否则会被拦截。
- **事件投递依赖 Redis**：HTTP 侧业务通过 **`GameWebSocketEventBus`**（Redis 列表）将事件投递到 WebSocket 进程；Redis 不可用或队列异常时，**除 `admin.live.snapshot` 外**的广播类推送可能收不到。后台若订阅了 `admin.live.snapshot`，服务端有**每秒直连构建快照**的兜底，不依赖队列。
- **鉴权（重要）**：**当前 `GameWebSocketServer` 在握手阶段不校验** URL Query 中的 `token` / `auth_token` / `user-token` 等；**任何人拿到地址即可建立连接**（与 §1 HTTP 接口必须 `auth-token` + `user-token` 不同）。Query 中上述参数为**预留/习惯写法**，便于后续若要在 `onWebSocketConnect` 中实现鉴权再与 HTTP 对齐；**文档中若写「未登录返回 1101」属规划口径，非现网行为**。
- **订阅才有业务推送**：建连后仅会收到握手首帧（见下）及本连接已订阅主题的消息；不发送 `subscribe` 则收不到 `period.tick` 等（`admin.live.snapshot` 同上，需显式订阅）。

### 7.1 WebSocket 连接与消息

- **连接地址**：见 **§7.0**（环境变量 `H5_WEBSOCKET_URL` 或后台 `wsConfig` 返回的 `ws_url`）
- **客户端**：浏览器原生 `WebSocket`（`ws://` / `wss://`）
- **连接时携带参数（可选 / 预留）**：
  - URL Query 可带 `token`（与 HTTP 头 `user-token` 同义习惯）、`auth_token`（与 HTTP 头 `auth-token` 同义习惯）、`device_id`、`lang` 等，**当前服务端不解析、不校验**；若后续版本实现握手鉴权，以发布说明为准。
  - 示例（习惯写法）：`wss://ws.example.com/ws?token=xxx&auth_token=xxx&device_id=ios_001&lang=zh`
- **连接成功首帧（当前实现）**：
  - `event`：`ws.connected`
  - `message`：固定文案 `WebSocket connected`（便于联调日志）
  - `connection_id`：连接唯一标识（进程内）
  - `server_time`：服务器时间戳（**秒**，int）
  - `heartbeat_interval`：建议心跳间隔（**秒**，当前实现固定为 `30`）
- **连接后错误帧（当前实现，非 HTTP 业务码）**：
  - JSON 无法解析：`event`=`ws.error`，`message`=`Invalid JSON payload`（无 `code` 或与 HTTP `code` 不同体系）
  - 未知 `action`：`event`=`ws.error`，`message`=`Unsupported action`，并可能带 `received_action`
  - 进程级异常：`onError` 回包含 `event`=`ws.error`、`message`=`Server internal error` 及 Workerman 侧 `code`/`detail`（**非** §1.2 的 HTTP API `code` 段）
- **建议消息**：
  - 心跳：`{"action":"ping"}`
  - 服务端对心跳的当前实现回包：`{"event":"pong","server_time":"YYYY-mm-dd HH:ii:ss"}`（**注意**：此处 `server_time` 为**本地时间字符串**，与业务推送帧里 `server_time` 常用**秒级 int** 不一致，客户端解析时请分支处理）
  - 订阅状态流：`{"action":"subscribe","topics":["period.tick"]}`
  - 订阅连胜/赔率（仅当前玩家）：`{"action":"subscribe","topics":["user.streak","wallet.changed","bet.accepted"]}`
  - 订阅资金流：`{"action":"subscribe","topics":["bet.accepted","wallet.changed"]}`
  - 订阅托管流：`{"action":"subscribe","topics":["auto.spin.progress","wallet.changed"]}`
  - 移动端推荐合并订阅：`period.tick`、`bet.win`、`user.streak`、`wallet.changed`、`bet.accepted`、`period.opened`、`period.payout`、`jackpot.hit`

#### 7.1.1 消息协议字段定义（联调口径）

- 客户端 -> 服务端：
  - `action`：动作名（当前约定 `ping` / `subscribe`）
  - `topics`：仅 `subscribe` 时必填，表示要订阅的主题列表（数组）
- 服务端 -> 客户端：
  - `event`：事件名（如 `period.tick`、`wallet.changed`、`jackpot.hit`）
  - `topic`：所属主题（通常与 `event` 一致；用于前端按主题路由）
  - `data`：业务载荷（对象）
  - `server_time`：服务端时间戳（秒，倒计时与对时基准）

#### 7.1.2 订阅行为说明

- **仅建立连接不会自动下发全部业务消息**；客户端需要发送 `subscribe` 明确订阅主题。
- 成功订阅后服务端返回：`{"event":"ws.subscribed","topics":[...]}`。
- 若未订阅主题，通常只能收到握手首帧（`ws.connected`）和心跳回包（`pong`）。
- **不下发** `streak_win_reward` 全表（1～10 档）；赔率仅通过 `user.streak` / `wallet.changed` / `bet.accepted` 及 `lobbyInit.user_snapshot` 推送**当前登录玩家**本局适用字段。

#### 7.1.2A 连胜赔率与连胜场次（WebSocket）

- **`user.streak`**（开奖结算后推送；载荷为当前玩家本局适用赔率）
  - `data.user_id`：int
  - `data.current_streak`：int
  - `data.streak_level`：int
  - `data.odds_factor`：int
  - `data.is_jackpot`：bool
- **`wallet.changed` / `bet.accepted`**：在原有字段上合并同上 **`current_streak`**、**`streak_level`**、**`odds_factor`**、**`is_jackpot`**；客户端按 `user_id` 过滤，仅处理本用户

#### 7.1.3 推送频率与触发规则（当前实现）

- `period.tick`：**仅在 `status ∈ {betting, locked}` 时每秒推送**（用于倒计时、状态同步；**不含**赔率全表）。
  - **派彩静默期**：`status=payouting` 期间**不推** `period.tick`（避免彩池/倒计时干扰中奖动画）。改为每秒推送 **`period.payout.tick`**，仅含 `payout_remaining_seconds` / `payout_until` 等派彩倒计时字段。中奖展示仍靠 `period.opened` / `period.payout` / `jackpot.hit` / `wallet.changed(biz_type=payout)`。
  - **边界帧（每期仅一次）**：
    - `status=finished`：派彩宽限期结束、本期进入收尾时推送一帧，告知前端可以清理本期 UI。
    - `status=void`：本期被作废时推送一帧作为通知。
  - **恢复推送**：下一期创建并进入 `betting` 后，按新 `period_no` 重新开始每秒推送。
  - 服务端去重：边界帧通过 Redis Key `dfw:v1:ws:tick:boundary:{period_no}:{status}`（TTL 300s）保证同一期号同一状态只推一次。
- `user.streak`：每期结算更新用户连胜后按用户推送（未中奖也会推送，`current_streak` 可能归零）。
- `admin.live.snapshot`：**每秒一次**（后台实时对局页全量快照；不受派彩静默期影响）。
- `period.opened` / `period.payout` / `admin.live.opened`：按开奖流程阶段触发（事件触发型，非固定频率）。
- `wallet.changed`：仅在余额发生变更时推送（如下注扣款、充值入账、派彩入账）。派彩时 `biz_type=payout`，并带 `amount`（本次派彩金额）、`period_no`、`period_id`、`result_number`（若有）。
- **`bet.win`（小奖）**：本期中奖且**非大奖档**（`streak_win_reward` 中 `is_jackpot=false`）时，按用户聚合推送一帧；用于弹窗/横幅，**比 `wallet.changed` 更适合做「你中了小奖」展示**。
  - `data.user_id` / `data.period_id` / `data.period_no` / `data.result_number`
  - `data.total_win`：本期该用户小奖派彩合计
  - `data.balance_after`：派彩后余额
  - `data.bets[]`：`{ bet_id, win_amount }` 明细
  - `data.is_jackpot`：固定 `false`
- `jackpot.hit`：**仅在本期存在中大奖命中用户时推送**；无命中不推送。
  - **载荷字段**：`period_id` / `period_no` / `result_number` / `hits[]` / `server_time`。
  - `hits[]` 数组每项字段：
    - `user_id`：int（中奖用户 ID）
    - `nickname`：string（用户昵称，**优先取 `user.nickname`，为空时 fallback 到 `user.username`，再为空则使用 `用户{user_id}`**，供前端弹窗/横幅通知直接展示）
    - `period_no`：string
    - `total_win`：string（本期该用户的命中大奖派彩合计，金额字符串）
    - `result_number`：int

### 7.1A 后台连接方式（管理端联调）

- 后台菜单：仅保留一个菜单 `连接服务器websocket`，用于统一联调 WebSocket
- 后台连接入口：
  - `/admin/test.GameCurrentStatus/wsConfig`
- 后台页面能力：
  - 读取 `ws_url`、`connect_tip`、`sample_messages`
  - 手动连接/断开 WebSocket
  - 手动发送订阅与心跳报文
  - 实时查看服务端返回帧内容（用于联调事件格式）

### 7.2 HTTP 兜底接口

- 本版本已移除以下兜底接口：`/api/game/currentStatus`、`/api/game/periodHistory`、`/api/wallet/balanceSummary`。
- 状态与余额统一以 WebSocket 推送为主，HTTP 仅保留业务动作/详情查询接口（如 `placeBet`、`depositDetail`、`withdrawDetail`）。

### 7.3 一致性规则

- 倒计时以服务端下发时间为准，不信任本地时钟累计。
- 下注成功后以 `placeBet` 返回的 `balance_after` 为准，并等待 `wallet.changed` 同步。
- WebSocket 断线后立即重连并重新订阅主题，不再依赖 `currentStatus/periodHistory/balanceSummary` 回补。

---

## 8. 移动端完整调用流程

## 8.1 首次进入游戏
1. `GET /api/v1/authToken?secret=xxx&timestamp=xxx&device_id=xxx&signature=xxx` 获取 `auth-token`
2. `POST /api/user/login` 登录（请求头带 `auth-token`）
3. `POST /api/game/lobbyInit` 拉首页初始化（请求头带 `auth-token`）
4. 取得 WebSocket 基址（**当前非 lobbyInit 下发**：与运维/打包配置中的 `H5_WEBSOCKET_URL` 或自建配置接口一致）后建立 WebSocket 连接，**立即发送 `subscribe`** 监听状态流（见 §7.0 / §7.1）
5. 用户下注调用 `POST /api/game/placeBet`
6. 下单后以 `placeBet.balance_after` 与 `wallet.changed` 同步余额
7. 断线或页面回前台时，重连 WebSocket 并重新订阅主题回补实时状态

## 8.2 充值到下注到提现闭环
1. 拉取档位：`POST /api/finance/depositTierList`（玩家选择一档，并记下该档 `channels[].code`）
2. 创建订单：`POST /api/finance/depositCreate`（`tier_id` + **`channel_code=ddpay`** + `idempotency_key` + DDPay 入金必填字段；可用 JSON / form-data / x-www-form-urlencoded）
   - 返回 `paid=false`、`status=pending`、**非空 `pay_url`**：客户端在 WebView/浏览器中打开 **`pay_url`（DDPay `payment_url`）** 完成支付；入账由 **`ddpayDepositNotify`** Webhook 驱动，可轮询 `depositDetail` 或等待 `wallet.changed` 刷新余额
3. 客户端可选轮询 `POST /api/finance/depositDetail` 兜底确认状态；入账成功后会收到 `wallet.changed`
4. 下注：`POST /api/game/placeBet`
5. 监听余额：`wallet.changed`（或按订单详情接口核对）
6. 查询流水：`POST /api/wallet/recordList`
7. 提现：`POST /api/finance/withdrawCreate`（即时冻结 `user.coin` 与写出 `withdraw` 流水） -> `POST /api/finance/withdrawDetail`

## 8.3 公告强触达流程
1. 客户端监听 `notice.popout` 
2. 拉取列表 `GET /api/notice/noticeList`（列表项已含 `content`、`must_confirm`）
3. 用户勾选确认 `GET /api/notice/noticeConfirm?notice_id=...`（若已有阅读记录则仅更新 `read_at`）
4. 未确认前可由前端阻断下注入口

---

## 9. 游戏时序流程图（WebSocket + HTTP兜底）

```mermaid
flowchart TD
  A[用户登录 /api/user/login] --> B[拉初始化 /api/game/lobbyInit]
  B --> C[连接 WebSocket 并订阅主题]
  C --> D{0-20秒下注期?}
  D -- 是 --> E[提交下注 /api/game/placeBet]
  E --> F[等待 wallet.changed 同步余额]
  D -- 否 --> G[进入封盘与开奖阶段]
  G --> H[服务端算票与开奖]
  H --> I[WebSocket 推送状态变化]
  I --> J[断线重连并重新订阅]
  J --> C
```

---

## 10. 后台渠道分红比例配置（管理端补充）

> 本节为管理后台 `/admin/channel`「分配比例」弹窗补充口径，用于便于管理员按角色层级设置二次分红比例。

### 10.1 角色组展示规则

- 表格列顺序调整为：`角色组层级` -> `负责人` -> `状态` -> `分配比例(%)`
- `角色组层级` 在 `负责人` 前展示，降低识别与分配成本
- 层级路径使用 `/` 拼接，如：`顶级组 / 运营组 / 一组`
- 同一负责人若存在多个角色组，按多标签展示多条路径
- 无角色组时显示 `-`

### 10.2 接口：读取渠道管理员分配配置

- **GET** `/admin/channel/channelAdminShareList?id={channel_id}`

返回参数（`data.list[]`）新增：
- `group_paths`：array<string>（负责人所属角色组层级路径列表）
- `group_paths_text`：string（层级路径拼接文本，`|` 分隔，用于兼容纯文本场景）

返回示例（节选）：
```json
{
  "code": 1,
  "message": "ok",
  "data": {
    "channel_id": 1,
    "channel_name": "渠道A",
    "list": [
      {
        "admin_id": 12,
        "username": "zhuguan1",
        "group_paths": ["顶级组 / 运营组 / A组"],
        "group_paths_text": "顶级组 / 运营组 / A组",
        "status": 1,
        "share_rate": "30.00"
      }
    ]
  }
}
```

### 10.3 保存约束（沿用现有）

- **POST** `/admin/channel/saveChannelAdminShare`
- 仅 `status=1` 的行参与占比汇总
- 启用项分配比例总和必须严格等于 `100.00`

---

## 11. 需要你确认的实现口径（进入接口开发前）

1. **登录方式**：仅账号密码，还是要短信/邮箱验证码？
2. **提现收款类型**：首版只做银行卡，还是同时支持电子钱包/加密地址？
3. **自动托管**：是否首期上线；若不上线可先隐藏 `auto-bet` 接口。
4. **WebSocket 主题定义**：状态流、资金流、托管流的 topic 与消息体是否按本文固定。
5. **错误码规范**：是否已有公司统一错误码表；若有需对齐替换本草案码段。

确认后可进入下一步：按该文档落地 controller + validate + service + 路由。
