# PlayX 接口文档（按调用方向拆分）

说明：本文档严格依据当前代码 `app/api/controller/v1/Playx.php`、`app/api/controller/v1/Auth.php`（临时登录）、`config/playx.php` 与定时任务 `app/process/PlayxJobs.php` 整理。

三类接口分别为：
- `积分商城 -> PlayX`（PlayX 调用商城）
- `PlayX -> 积分商城`（商城调用 PlayX）
- `积分商城 -> H5`（H5 调用商城）

---

## 1. 积分商城 -> PlayX（PlayX 调用商城）

### 1.1 Daily Push API
* 方法：`POST`
* 路径：`/api/v1/playx/daily-push`

#### Header（签名校验：可选）
当 `playx.daily_push_secret` 配置非空时，需要携带：
- `X-Request-Id`：请求 ID
- `X-Timestamp`：时间戳
- `X-Signature`：签名（HMAC_SHA256）

服务端签名计算：
- `canonical = X-Timestamp + "\n" + X-Request-Id + "\nPOST\n/api/v1/playx/daily-push\n" + sha256(json_body)`
- `expected = hash_hmac('sha256', canonical, daily_push_secret)`
- 校验：`hash_equals(expected, X-Signature)`

#### Body
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `request_id` | string | 是 | 外部推送请求号（原样返回） |
| `date` | string(YYYY-MM-DD) | 是 | 业务日期（入库到 `mall_playx_daily_push.date`） |
| `user_id` | string | 是 | PlayX 用户 ID（用于幂等；入库 `mall_playx_daily_push.user_id` 等；服务端会映射/创建 `mall_user` 与 `mall_playx_user_asset`） |
| `username` | string | 否 | 展示冗余（同步到商城用户侧逻辑时使用） |
| `yesterday_win_loss_net` | number | 否 | 昨日净输赢（仅当 `< 0` 时计算新增保障金） |
| `yesterday_total_deposit` | number | 否 | 昨日总充值（用于计算今日可领取上限） |
| `lifetime_total_deposit` | number | 否 | 历史总充值 |
| `lifetime_total_withdraw` | number | 否 | 历史总提现 |

#### 幂等规则
* 幂等键：`user_id + date`
* 重复推送：不会重复入账，返回 `data.deduped=true`

#### 返回（Response）
外层通用返回结构：`{ code, msg, time, data }`

成功（首次入库）：
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.request_id` | string | 原样返回 |
| `data.accepted` | boolean | `true` |
| `data.deduped` | boolean | `false` |
| `data.message` | string | `ok` |

成功（重复推送）：
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.request_id` | string | 原样返回 |
| `data.accepted` | boolean | `true` |
| `data.deduped` | boolean | `true` |
| `data.message` | string | `duplicate input` |

失败：
* 当缺少必填字段：code=0，msg 为缺少字段错误
* 当签名不正确：HTTP 401，code=0，msg 为 `INVALID_SIGNATURE`

#### 示例（未开启签名校验）
请求：
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/daily-push' \
  -H 'Content-Type: application/json' \
  -d '{
    "request_id":"req_1001",
    "date":"2026-03-18",
    "user_id":"U123",
    "username":"demo_user",
    "yesterday_win_loss_net":-120.5,
    "yesterday_total_deposit":50,
    "lifetime_total_deposit":5000,
    "lifetime_total_withdraw":2000
  }'
```

响应（首次）：
```json
{
  "code": 1,
  "msg": "",
  "data": {
    "request_id": "req_1001",
    "accepted": true,
    "deduped": false,
    "message": "ok"
  },
  "time": 0
}
```

---

## 2. PlayX -> 积分商城（商城调用 PlayX）

> 下面这些接口由 PlayX 提供。商城侧仅按“请求参数 + 期望返回判定条件”发起调用与处理结果。  
> **说明**：H5 调商城的 **`/api/v1/playx/verify-token`** 在配置 **`playx.verify_token_local_only=true`**（默认）时**不会请求**本节接口，而是在商城内校验 `muser` token；远程对接 PlayX 时见 **3.3** 与下文 **2.1**。

### 2.1 Token Verification API（PlayX 侧实现，远程验证时使用）
* 方法：`POST`
* URL：`${playx.api.base_url}${playx.api.token_verify_url}`
  * 默认：`/api/v1/auth/verify-token`

#### 请求 Body（商城侧发送）
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `request_id` | string | 是 | 形如 `mall_{uniqid}` |
| `token` | string | 是 | 前端传入的 PlayX token |

#### 返回（期望）
商城侧校验：
* HTTP 状态码必须为 `200`
* 且响应体中必须包含 `user_id`

期望字段（示例）：
| 字段 | 类型 | 说明 |
|------|------|------|
| `user_id` | string | 必选 |
| `username` | string | 可选 |
| `token_expire_at` | string | 可选（能被 `strtotime` 解析） |

示例（成功）：
```json
{
  "user_id": "U123",
  "username": "demo_user",
  "token_expire_at": "2026-04-01T12:00:00Z"
}
```

示例（失败）：
```json
{
  "message": "invalid token"
}
```

---

### 2.2 Bonus Grant API
* 方法：`POST`
* URL：`${playx.api.base_url}${playx.api.bonus_grant_url}`
  * 默认：`/api/v1/bonus/grant`

#### 请求 Body（商城侧发送）
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `request_id` | string | 是 | 形如 `mall_bonus_{uniqid}` |
| `externalTransactionId` | string | 是 | `MallPlayxOrder.external_transaction_id` |
| `user_id` | string | 是 | PlayX 用户 ID |
| `amount` | number | 是 | `MallPlayxOrder.amount` |
| `rewardName` | string | 是 | `mall_item.title` |
| `category` | string | 是 | `mall_item.category`（默认 `daily`） |
| `categoryTitle` | string | 是 | `mall_item.category_title` |
| `multiplier` | int | 是 | `MallPlayxOrder.multiplier` |

#### 返回（期望）
商城侧判定：
* HTTP 状态码 `200`
* 且 `data.status === "accepted"`

成功时读取：
* `data.playx_transaction_id`

失败时读取：
* `data.message` 写入订单 `fail_reason`

示例（accepted）：
```json
{
  "status": "accepted",
  "playx_transaction_id": "PX_TX_001"
}
```

示例（rejected）：
```json
{
  "status": "rejected",
  "message": "insufficient balance"
}
```

---

### 2.3 Balance Credit API
* 方法：`POST`
* URL：`${playx.api.base_url}${playx.api.balance_credit_url}`
  * 默认：`/api/v1/balance/credit`

#### 请求 Body（商城侧发送）
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `request_id` | string | 是 | 形如 `mall_withdraw_{uniqid}` |
| `externalTransactionId` | string | 是 | `MallPlayxOrder.external_transaction_id` |
| `user_id` | string | 是 | PlayX 用户 ID |
| `amount` | number | 是 | `MallPlayxOrder.amount` |
| `multiplier` | int | 是 | `MallPlayxOrder.multiplier` |

#### 返回（期望）
与 Bonus Grant 一致：
* `data.status === "accepted"` -> 读取 `playx_transaction_id`
* 否则 -> 读取 `message` 写入 `fail_reason`

示例（accepted）：
```json
{
  "status": "accepted",
  "playx_transaction_id": "PX_TX_002"
}
```

示例（rejected）：
```json
{
  "status": "rejected",
  "message": "insufficient balance"
}
```

---

### 2.4 Transaction Status Query API（交易终态查询）
* 方法：`GET`
* URL：`${playx.api.base_url}${playx.api.transaction_status_url}`
  * 默认：`/api/v1/transaction/status`

#### Query 参数
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `externalTransactionId` | string | 是 | 订单幂等键 `external_transaction_id` |

#### 返回（期望）
定时任务读取 `data.status`：
* `COMPLETED`：商城将订单 `status` 更新为 `COMPLETED`
* `FAILED` 或 `REJECTED`：商城将订单 `status=REJECTED`、`grant_status=FAILED_FINAL`，并退回积分
* 失败信息取 `data.message` 写入订单 `fail_reason`

示例（completed）：
```json
{ "status": "COMPLETED" }
```

示例（failed）：
```json
{ "status": "FAILED", "message": "grant rejected by PlayX" }
```

---

## 3. 积分商城 -> H5（服务端提供给 H5 的接口）

### 3.0 数据模型说明（与代码一致）

* **商城用户**：表 `mall_user`（主键 `id`）。
* **PlayX 资产扩展**：表 `mall_playx_user_asset`，与 `mall_user` **一对一**（`mall_user_id` 唯一，`playx_user_id` 唯一）。
* **对外业务 ID**：接口里返回或订单里使用的 `user_id` 字符串多为 **PlayX 侧用户 ID**（`playx_user_id`）；H5 临时登录场景若尚无真实 PlayX ID，会生成形如 **`mall_{mall_user.id}`** 的占位 ID（见 `temLogin`）。
* **服务端内部**：`Playx` 控制器内部用 **`mall_user.id`**（整型）解析资产；`session_id` / `token` / `user_id` 会映射到该 `mall_user`。

### 3.1 鉴权解析规则（`resolveMallUserIdFromRequest`）

以下接口在服务端最终都会解析出 **商城用户 `mall_user.id`**，再按该用户查询 `mall_playx_user_asset` 等。

优先级（由高到低）：

1. **`session_id`**（`post` 优先，`get` 兼容）  
   * 在 `mall_playx_session` 中存在且未过期：用会话里的 `user_id`（即 `playx_user_id`）在 `mall_playx_user_asset` 反查 `mall_user_id`。  
   * 若会话无效：兼容把 `session_id` 参数误当作 **商城 token** 再试一次（UUID 形态 token）。
2. **`token`**（`post` / `get` 或请求头 **`ba-token`** / **`token`**）  
   * 校验 `token` 表：类型为会员 `user` 或商城临时 **`muser`**（`mall_user` 登录），未过期则 `user_id` 字段即为 **`mall_user.id`**。
3. **`user_id`**（`post` / `get` 兼容）  
   * **纯数字**：视为 **`mall_user.id`**。  
   * **非纯数字**：视为 **`playx_user_id`**，在 `mall_playx_user_asset` 查找对应 `mall_user_id`。

> 注意：请求参数的取值方式是 `post()` 优先，`get()` 兼容（即同字段既可传 post 也可传 get）。

---

### 3.2 临时登录（获取商城 token）

* 方法：`GET`（推荐）或 `POST`
* 路径：`/api/v1/temLogin`
* 开关：`config/buildadmin.php` → `agent_auth.temp_login_enable` 为 `true`；有效期 `agent_auth.temp_login_expire`（秒）。

#### 请求参数

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `username` | string | 是 | 商城用户名（唯一）；不存在则自动创建 `mall_user` |

#### 行为说明

* 若 `mall_user` 不存在：创建用户（随机占位手机号、随机密码等，与后台「商城用户」一致）。
* **无论是否新用户**：保证存在 **`mall_playx_user_asset`** 一条记录（`MallPlayxUserAsset::ensureForMallUser`），`playx_user_id` 默认 **`mall_{mall_user.id}`**（与 PlayX 真实 ID 冲突概率低）。
* 签发 **商城 token**（类型 **`muser`**，非会员表 `user`），并签发 `muser-refresh` 刷新令牌。

#### 返回（成功 data.userInfo）

| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | int | `mall_user.id` |
| `username` | string | 用户名 |
| `nickname` | string | 同 `username` |
| `playx_user_id` | string | 资产表中的 `playx_user_id`（如 `mall_12`） |
| `token` | string | 访问 H5 接口时携带 |
| `refresh_token` | string | 调用 `/api/common/refreshToken` 时使用（类型 `muser-refresh`） |
| `expires_in` | int | token 有效秒数 |

#### 示例

```bash
curl -G 'http://localhost:1818/api/v1/temLogin' --data-urlencode 'username=demo_h5'
```

用户名含 `+` 等号时需 URL 编码（如 `%2B60123456789`）。

---

### 3.3 Token 验证（换 session）

* 方法：`POST`（推荐 `GET` 传 `token` 亦可）
* 路径：`/api/v1/playx/verify-token`

#### 配置：本地验证 vs 远程 PlayX

* 配置项：`config/playx.php` → **`verify_token_local_only`**（环境变量 **`PLAYX_VERIFY_TOKEN_LOCAL_ONLY`**，未设置时默认为 **`1` / 开启本地验证）。
* **`verify_token_local_only = true`（默认）**  
  * **不请求** PlayX HTTP。  
  * 仅接受商城临时登录 token（类型 **`muser`**），校验 `token` 表后，根据 `mall_user` 与 `mall_playx_user_asset` 写入 `mall_playx_session`。  
  * 返回的 `data.user_id` 为 **`playx_user_id`**（无资产记录时回退为 `mall_user.id` 字符串，一般 temLogin 后已有资产）。
* **`verify_token_local_only = false`**（生产对接 PlayX）  
  * 需配置 **`playx.api.base_url`**，由商城向 PlayX 发起 `POST` 校验（见下文「远程模式」）。  
  * 若未配置 `base_url`，返回 `PlayX API not configured`。

#### 请求参数

必填其一：

* `token`（Body 优先；`session` 兼容字段；Query 也可传 `token`）

#### 返回（成功 data）

| 字段 | 类型 | 说明 |
|------|------|------|
| `session_id` | string | 写入 `mall_playx_session` |
| `user_id` | string | PlayX 用户 ID（即 `playx_user_id`，会话内与订单/推送一致） |
| `username` | string | 用户名 |
| `token_expire_at` | string | ISO 字符串（服务端 `date('c', expireAt)`） |

失败：

* token 为空：HTTP 401，msg=`INVALID_TOKEN`
* 远程模式且 PlayX 未配置：`msg=PlayX API not configured`

#### 示例（本地验证）

```bash
curl -X POST 'http://localhost:1818/api/v1/playx/verify-token' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'token=上一步TemLogin返回的token'
```

#### 远程模式（`verify_token_local_only=false` + 已配置 `base_url`）

商城侧请求 URL：`${playx.api.base_url}${playx.api.token_verify_url}`（默认路径 `/api/v1/auth/verify-token`）。

#### 请求 Body（商城侧发送）——仅远程模式

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `request_id` | string | 是 | 形如 `mall_{uniqid}` |
| `token` | string | 是 | 前端传入的 PlayX token |

#### 返回（期望）——仅远程模式

* HTTP 状态码必须为 `200`
* 且响应体中必须包含 `user_id`

响应（成功示例）：

```json
{
  "code": 1,
  "msg": "",
  "data": {
    "session_id": "7b1c....",
    "user_id": "U123",
    "username": "demo_user",
    "token_expire_at": "2026-04-01T12:00:00+00:00"
  }
}
```

---

### 3.4 用户资产（Assets）
* 方法：`GET`
* 路径：`/api/v1/playx/assets`

#### 请求参数（鉴权）

以下任选其一即可（与 **3.1 鉴权解析规则** 一致）：

* `session_id`
* `token`（或请求头 `ba-token` / `token`）
* `user_id`（纯数字为 `mall_user.id`，否则为 `playx_user_id`）

#### 返回（成功 data）
若未找到资产：返回 0。
| 字段 | 类型 | 说明 |
|------|------|------|
| `locked_points` | int | 待领取积分 |
| `available_points` | int | 可用积分 |
| `today_limit` | int | 今日可领取上限 |
| `today_claimed` | int | 今日已领取 |
| `withdrawable_cash` | number(2) | `available_points * points_to_cash_ratio`（保留 2 位） |

#### 示例
```bash
curl -G 'http://localhost:1818/api/v1/playx/assets' --data-urlencode 'token=上一步temLogin返回的token'
```

响应（示例）：
```json
{
  "code": 1,
  "msg": "",
  "time": 1712345678,
  "data": {
    "locked_points": 100,
    "available_points": 50,
    "today_limit": 200,
    "today_claimed": 80,
    "withdrawable_cash": 5.2
  }
}
```

---

### 3.5 领取（Claim）
* 方法：`POST`
* 路径：`/api/v1/playx/claim`

#### 请求 Body
必填：

* `claim_request_id`：幂等键（string，唯一）

鉴权：同 **3.1**（`session_id` / `token` / `user_id`）

#### 返回（成功 data）
与 `用户资产` 返回字段一致（资产快照）。

幂等：
* `claim_request_id` 已存在：不会重复入账，直接返回当前资产快照

#### 示例
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/claim' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'claim_request_id=claim_001' \
  --data-urlencode 'token=上一步temLogin返回的token'
```

响应（首次领取，示例）：
```json
{
  "code": 1,
  "msg": "Claim success",
  "time": 1712345679,
  "data": {
    "locked_points": 60,
    "available_points": 90,
    "today_limit": 200,
    "today_claimed": 120,
    "withdrawable_cash": 9.0
  }
}
```

响应（幂等重复，示例：可能 msg 为空）：
```json
{
  "code": 1,
  "msg": "",
  "time": 1712345680,
  "data": {
    "locked_points": 60,
    "available_points": 90,
    "today_limit": 200,
    "today_claimed": 120,
    "withdrawable_cash": 9.0
  }
}
```

---

### 3.6 商品列表
* 方法：`GET`
* 路径：`/api/v1/playx/items`

#### 请求参数
* `type`（可选）：`BONUS` | `PHYSICAL` | `WITHDRAW`

不传或空：返回 `mall_item.status=1` 且不过滤 type 的商品列表。

#### 返回（成功 data）
* `list`：商品列表（直接返回 `MallItem` 的字段数组；包含扩展字段：`amount/multiplier/category/category_title` 等）

#### 示例
请求：
```bash
curl -G 'http://localhost:1818/api/v1/playx/items' --data-urlencode 'type=WITHDRAW'
```

响应（示例）：
```json
{
  "code": 1,
  "msg": "",
  "time": 1712345685,
  "data": {
    "list": [
      {
        "id": 321,
        "title": "提现档位A",
        "type": 3,
        "score": 1000,
        "amount": 100.0,
        "multiplier": 1,
        "category": "withdraw",
        "category_title": "提现"
      }
    ]
  }
}
```

---

### 3.7 红利兑换（Bonus Redeem）
* 方法：`POST`
* 路径：`/api/v1/playx/bonus/redeem`

#### 请求 Body
必填：
* `item_id`：商品 ID（要求 `mall_item.type=BONUS` 且 `status=1`）
鉴权：同 **3.1**（`session_id` / `token` / `user_id`）

#### 返回（成功）
* `msg`：`Redeem submitted, please wait about 10 minutes`
* `data.order_id`：订单 ID
* `data.status`：`PENDING`

#### 示例
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/bonus/redeem' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'item_id=123' \
  --data-urlencode 'session_id=7b1c....'
```

响应（示例）：
```json
{
  "code": 1,
  "msg": "Redeem submitted, please wait about 10 minutes",
  "time": 1712345686,
  "data": {
    "order_id": 456,
    "status": "PENDING"
  }
}
```

---

### 3.8 实物兑换（Physical Redeem）
* 方法：`POST`
* 路径：`/api/v1/playx/physical/redeem`

#### 请求 Body
必填：
* `item_id`：商品 ID（要求 `mall_item.type=PHYSICAL` 且 `status=1`）
* `receiver_name`：收货人
* `receiver_phone`：收货电话
* `receiver_address`：收货地址
鉴权：同 **3.1**（`session_id` / `token` / `user_id`）

#### 返回（成功）
* `msg`：`Redeem success`
* `data`：`null`

#### 示例
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/physical/redeem' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'item_id=200' \
  --data-urlencode 'receiver_name=张三' \
  --data-urlencode 'receiver_phone=18800001111' \
  --data-urlencode 'receiver_address=北京市朝阳区XX路XX号' \
  --data-urlencode 'session_id=7b1c....'
```

响应（示例）：
```json
{
  "code": 1,
  "msg": "Redeem success",
  "time": 1712345687,
  "data": null
}
```

---

### 3.9 提现申请（Withdraw Apply）
* 方法：`POST`
* 路径：`/api/v1/playx/withdraw/apply`

#### 请求 Body
必填：
* `item_id`：商品 ID（要求 `mall_item.type=WITHDRAW` 且 `status=1`）
鉴权：同 **3.1**（`session_id` / `token` / `user_id`）

#### 返回（成功）
* `msg`：`Withdraw submitted, please wait about 10 minutes`
* `data.order_id`：订单 ID
* `data.status`：`PENDING`

#### 示例
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/withdraw/apply' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'item_id=321' \
  --data-urlencode 'session_id=7b1c....'
```

响应（示例）：
```json
{
  "code": 1,
  "msg": "Withdraw submitted, please wait about 10 minutes",
  "time": 1712345688,
  "data": {
    "order_id": 789,
    "status": "PENDING"
  }
}
```

---

### 3.10 订单列表
* 方法：`GET`
* 路径：`/api/v1/playx/orders`

#### 请求参数（鉴权）

同 **3.1**（`session_id` / `token` / `user_id`）。

#### 返回（成功 data）

* `list`：订单列表（最多 100 条），并包含关联的 `mallItem`（关系对象）
* 列表项中的 `user_id` 为 **PlayX 侧 `playx_user_id`**（字符串），与 `mall_playx_order.user_id` 一致

#### 示例
请求：
```bash
curl -G 'http://localhost:1818/api/v1/playx/orders' --data-urlencode 'token=上一步temLogin返回的token'
```

响应（示例，简化）：
```json
{
  "code": 1,
  "msg": "",
  "time": 1712345689,
  "data": {
    "list": [
      {
        "id": 456,
        "user_id": "U123",
        "type": "BONUS",
        "status": "PENDING",
        "mall_item_id": 123,
        "points_cost": 100,
        "amount": 10.0,
        "external_transaction_id": "BONUS_ORD2026....",
        "grant_status": "NOT_SENT",
        "mallItem": {
          "id": 123,
          "title": "每日红利",
          "type": 1
        }
      }
    ]
  }
}
```

---

### 3.11 同步额度（可选）
当前代码未实现并未注册路由：`/api/v1/playx/sync-limit`。

