# 积分商城 playX 对接实施方案

> 基于《积分商城-内部对接与流程说明.md》和《playX-对接文档（积分商城）.md》整理，结合当前项目结构给出具体落地方案。

---

## 一、接口创建

### 1.1 商城需对外提供的接口（playX 调用商城）
#### Daily Push API
接收 playX 每日 T+1 数据推送。

* 方法：`POST`
* 路径：`/api/v1/mall/dailyPush`

##### 请求（Header）
当配置了 `playX.daily_push_secret`（Daily Push 签名校验）时，需要携带：
* `X-Request-Id`：请求 ID
* `X-Timestamp`：时间戳
* `X-Signature`：签名（HMAC_SHA256）

签名计算逻辑（服务端）：
* canonical：`{X-Timestamp}\n{X-Request-Id}\nPOST\n/api/v1/mall/dailyPush\n{sha256(json_body)}`
* expected：`hash_hmac('sha256', canonical, daily_push_secret)`

##### 请求（Body）
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `request_id` | string | 是 | 外部推送请求号（原样返回） |
| `date` | string(YYYY-MM-DD) | 是 | 业务日期（入库到 `mall_playx_daily_push.date`） |
| `user_id` | string | 是 | playX 用户 ID（幂等键组成部分） |
| `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）：
* `data.request_id`：原样返回
* `data.accepted`：`true`
* `data.deduped`：是否幂等命中（`false`=首次入库，`true`=重复推送）
* `data.message`：首次为 `ok`，重复为 `duplicate input`

##### 示例
无签名校验（`PLAYX_DAILY_PUSH_SECRET` 为空）：
```bash
curl -X POST 'http://localhost:1818/api/v1/mall/dailyPush' \
  -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"
  }
}
```

---

### 1.2 商城需调用的 playX 接口（外部，由 playX 提供）
以下为商城侧调用（由 playX 提供）。

#### Token Verification API
* 方法：`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`（必选）
* `username`（可选）
* `token_expire_at`（可选，能被 `strtotime` 解析）

##### 示例
```json
{
  "request_id": "mall_abc123",
  "token": "PLAYX_TOKEN_XXX"
}
```

```json
{
  "user_id": "U123",
  "username": "demo_user",
  "token_expire_at": "2026-04-01T12:00:00Z"
}
```

#### 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 | 是 | 订单幂等键：`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` |

##### 示例（请求）
```json
{
  "request_id": "mall_bonus_abc123",
  "externalTransactionId": "BONUS_ORD2026....",
  "user_id": "U123",
  "amount": 10.0,
  "rewardName": "每日红利",
  "category": "daily",
  "categoryTitle": "每日",
  "multiplier": 1
}
```

##### 返回（期望）
商城侧以 `data.status` 判断：
* `status = "accepted"`：读取 `playx_transaction_id`
* 否则：读取 `message` 写入 `fail_reason`

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

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

#### 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 | 是 | 订单幂等键：`external_transaction_id` |
| `user_id` | string | 是 | playX 用户 ID |
| `amount` | number | 是 | 订单金额：`MallPlayxOrder.amount` |
| `multiplier` | int | 是 | `MallPlayxOrder.multiplier` |

##### 返回（期望）
与 Bonus Grant 一致：
* 成功：`status="accepted"` + `playx_transaction_id`
* 失败：`message` 写入 `fail_reason`

##### 示例
请求（示意，由商城侧发起，实际 URL 以配置为准）：
```json
{
  "request_id": "mall_withdraw_abc123",
  "externalTransactionId": "WITHDRAW_ORD2026....",
  "user_id": "U123",
  "amount": 100.0,
  "multiplier": 1
}
```

响应（accepted）：
```json
{
  "status": "accepted",
  "playx_transaction_id": "PX_TX_002"
}
```

响应（rejected）：
```json
{
  "status": "rejected",
  "message": "insufficient balance"
}
```

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

##### Query
* `externalTransactionId`：订单幂等键

##### 示例（请求）
```bash
curl -G '${playX.api.base_url}/api/v1/transaction/status' \
  --data-urlencode 'externalTransactionId=BONUS_ORD2026....'
```

##### 返回（期望）
商城读取 `data.status`：
* `COMPLETED`：设置订单 `status=COMPLETED`
* `FAILED` 或 `REJECTED`：设置订单 `status=REJECTED`、`grant_status=FAILED_FINAL`，并退回积分；失败信息取 `data.message`

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

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

---

### 1.3 商城内部 API（供 H5 前端调用）
#### Token 验证
* 方法：`POST`
* 路径：`/api/v1/mall/verifyToken`

请求（Body）：
* `token`（必填，优先读取）
* 兼容：`session`（当 `token` 为空时当作 token 使用）

成功返回（data）：
* `session_id`
* `user_id`
* `username`
* `token_expire_at`（ISO 时间字符串）

示例：
```bash
curl -X POST 'http://localhost:1818/api/v1/mall/verifyToken' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'token=PLAYX_TOKEN_XXX'
```

#### 用户资产
* 方法：`GET`
* 路径：`/api/v1/mall/assets`

请求参数（二选一）：
* `session_id`（优先）：从 `mall_playx_session` 查 user_id（并校验过期）
* `user_id`：直接使用（兼容）

成功返回（data）：
* `locked_points`
* `available_points`
* `today_limit`
* `today_claimed`
* `withdrawable_cash`（`available_points * points_to_cash_ratio`，保留 2 位）

##### 示例
```bash
curl -G 'http://localhost:1818/api/v1/mall/assets' --data-urlencode 'session_id=7b1c....'
```

```json
{
  "code": 1,
  "msg": "",
  "data": {
    "locked_points": 100,
    "available_points": 50,
    "today_limit": 200,
    "today_claimed": 80,
    "withdrawable_cash": 5.2
  }
}
```

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

请求：
* `claim_request_id`：幂等键（string，必填且唯一）
* 鉴权：`session_id` 或 `user_id`

成功返回（data）：与资产接口一致（`locked_points/available_points/today_limit/today_claimed/withdrawable_cash`）

##### 示例
（首次领取成功，可能返回 `msg=Claim success`；若幂等重复，`msg` 可能为空）
```bash
curl -X POST 'http://localhost:1818/api/v1/mall/claim' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'claim_request_id=claim_001' \
  --data-urlencode 'session_id=7b1c....'
```

```json
{
  "code": 1,
  "msg": "Claim success",
  "data": {
    "locked_points": 60,
    "available_points": 90,
    "today_limit": 200,
    "today_claimed": 120,
    "withdrawable_cash": 9.0
  }
}
```

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

请求（可选）：
* `type=BONUS|PHYSICAL|WITHDRAW`

成功返回（data）：
* `list`：`mall_item` 列表（包含 `amount/multiplier/category/category_title` 等字段）

##### 示例
```bash
curl -G 'http://localhost:1818/api/v1/mall/items' --data-urlencode 'type=BONUS'
```

```json
{
  "code": 1,
  "msg": "",
  "data": {
    "list": [
      {
        "id": 123,
        "title": "每日红利",
        "type": 1,
        "score": 100,
        "amount": 10.0,
        "multiplier": 1,
        "category": "daily",
        "category_title": "每日"
      }
    ]
  }
}
```

#### 红利兑换（Bonus Redeem）
* 方法：`POST`
* 路径：`/api/v1/mall/bonusRedeem`

请求：
* `item_id`：商品 ID（BONUS）
* 鉴权：`session_id` 或 `user_id`

成功返回（data）：
* `order_id`
* `status`：`PENDING`

##### 示例
```bash
curl -X POST 'http://localhost:1818/api/v1/mall/bonusRedeem' \
  -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",
  "data": {
    "order_id": 456,
    "status": "PENDING"
  }
}
```

#### 实物兑换（Physical Redeem）
* 方法：`POST`
* 路径：`/api/v1/mall/physicalRedeem`

请求：
* `item_id`：商品 ID（PHYSICAL）
* `address_id`：`mall_address.id`（当前用户资产下地址；订单写入 `mall_address_id` 与收货快照）
* 鉴权：`session_id` 或 `user_id`

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

##### 示例
```bash
curl -X POST 'http://localhost:1818/api/v1/mall/physicalRedeem' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'item_id=200' \
  --data-urlencode 'address_id=10' \
  --data-urlencode 'session_id=7b1c....'
```

```json
{
  "code": 1,
  "msg": "Redeem success",
  "data": null
}
```

#### 提现申请（Withdraw Apply）
* 方法：`POST`
* 路径：`/api/v1/mall/withdrawApply`

请求：
* `item_id`：商品 ID（WITHDRAW）
* 鉴权：`session_id` 或 `user_id`

成功返回（data）：
* `order_id`
* `status`：`PENDING`

##### 示例
```bash
curl -X POST 'http://localhost:1818/api/v1/mall/withdrawApply' \
  -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",
  "data": {
    "order_id": 789,
    "status": "PENDING"
  }
}
```

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

请求：
* `session_id` 或 `user_id`

成功返回（data）：
* `list`：订单列表（最多 100 条，包含关联的 `mallItem`）

##### 示例
```bash
curl -G 'http://localhost:1818/api/v1/mall/orders' --data-urlencode 'session_id=7b1c....'
```

```json
{
  "code": 1,
  "msg": "",
  "data": {
    "list": [
      {
        "id": 456,
        "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 }
      }
    ]
  }
}
```

#### 同步额度
当前代码未实现并未注册路由：`/api/v1/mall/syncLimit`。
如需补齐，请在接口设计阶段新增对应实现与 playX API 对接。

---

## 二、后台修改

### 2.1 商品管理（mall_item）

- **类型**：需与文档一致
  - `1` = BONUS（红利）
  - `2` = PHYSICAL（实物）
  - `3` = WITHDRAW（提现档位）
- **新增字段**（红利/提现档位）：
  - `amount`：现金面值（元）
  - `multiplier`：流水倍数
  - `category`：红利业务类别（如 daily）
  - `category_title`：类别展示名
- **库存**：实物需 `stock`；红利/提现可不限制或按业务配置

### 2.2 订单管理（统一订单表）

- **订单类型**：BONUS / PHYSICAL / WITHDRAW
- **订单状态**：PENDING / COMPLETED / SHIPPED / REJECTED
- **实物订单**：
  - 发货：录入物流公司、单号 → `SHIPPED`
  - 驳回：录入驳回原因 → `REJECTED`，自动退回积分
- **红利/提现订单**：
  - 展示 `external_transaction_id`、推送 playX 状态
  - 手动重试：仅对 `FAILED_RETRYABLE` 状态，记录 `retry_request_id`、操作者、原因

### 2.3 用户资产与人工调账

- **用户资产**：按 `user_id` 展示 `locked_points`、`available_points`、`today_limit`、`today_claimed`
- **人工调账**：针对 T+1 推送异常或客诉，支持对 `locked_points`、`available_points` 手动加减，并记录审计日志

### 2.4 每日推送数据

- 后台可查看 `mall_playx_daily_push` 表数据，按 `user_id`、`date` 查询，便于排查异常

---

## 三、数据库修改

### 3.1 用户资产表（改造 mall_player 或新建）

**方案 A：改造 mall_player**

- 将 `user_id` 作为主键（playX 的 user_id）或与现有 id 并存
- 新增字段：
  - `locked_points`（待领取积分）
  - `available_points`（可用积分）
  - `today_limit`（今日可领取上限）
  - `today_claimed`（今日已领取）
  - `today_limit_date`（今日上限所属日期，用于每日重置）

**方案 B：新建 mall_playx_user_asset**

- `user_id`（playX 用户 ID，主键或唯一）
- `username`（冗余展示）
- `locked_points`、`available_points`、`today_limit`、`today_claimed`、`today_limit_date`
- `create_time`、`update_time`

---

### 3.2 每日推送数据表（新建）

**表名**：`mall_playx_daily_push`

| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 主键 |
| user_id | varchar(64) | 玩家 ID |
| date | date | 业务日期 |
| username | varchar(100) | 展示名 |
| yesterday_win_loss_net | decimal(15,2) | 昨日净输赢 |
| yesterday_total_deposit | decimal(15,2) | 昨日总充值 |
| lifetime_total_deposit | decimal(15,2) | 历史总充值 |
| lifetime_total_withdraw | decimal(15,2) | 历史总提现 |
| create_time | bigint | 创建时间 |

**唯一索引**：`(user_id, date)` 幂等去重

---

### 3.3 领取记录表（幂等）

**表名**：`mall_playx_claim_log`

| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 主键 |
| claim_request_id | varchar(64) | 幂等键，唯一 |
| user_id | varchar(64) | 用户 ID |
| claimed_amount | int | 领取积分 |
| create_time | bigint | 创建时间 |

**唯一索引**：`claim_request_id`

---

### 3.4 统一订单表（改造或新建）

**表名**：`mall_playx_order`（或统一改造 mall_pints_order / mall_redemption_order）

| 字段 | 类型 | 说明                                                                   |
|------|------|----------------------------------------------------------------------|
| id | int | 主键                                                                   |
| user_id | varchar(64) | 用户 ID                                                                |
| type | enum | BONUS / PHYSICAL / WITHDRAW                                          |
| status | enum | PENDING / COMPLETED / SHIPPED / REJECTED                             |
| mall_item_id | int | 商品 ID                                                                |
| points_cost | int | 消耗积分                                                                 |
| amount | decimal(15,2) | 现金面值（红利/提现）                                                          |
| multiplier | int | 流水倍数                                                                 |
| external_transaction_id | varchar(64) | 订单号（商城侧幂等/对账主键；Angpow 流程不单独落库 playX 内部流水号） |
| grant_status | enum | NOT_SENT / SENT_PENDING / ACCEPTED / FAILED_RETRYABLE / FAILED_FINAL |
| fail_reason | text | 失败原因                                                                 |
| retry_count | int | 重试次数                                                                 |
| reject_reason | varchar(255) | 驳回原因（实物）                                                             |
| shipping_company | varchar(50) | 物流公司                                                                 |
| shipping_no | varchar(64) | 物流单号                                                                 |
| mall_address_id | int unsigned, NULL | 实物兑换所选 `mall_address.id`（快照仍见 `receiver_*`）                          |
| receiver_name | varchar(50) | 收货人                                                                  |
| receiver_phone | varchar(20) | 收货电话                                                                 |
| receiver_address | text | 收货地址                                                                 |
| create_time | bigint | 创建时间                                                                 |
| update_time | bigint | 更新时间                                                                 |

**索引**：`user_id`、`external_transaction_id`、`type`、`status`

---

### 3.5 商品表（mall_item）扩展

新增字段（若不存在）：

| 字段 | 类型 | 说明 |
|------|------|------|
| amount | decimal(15,2) | 现金面值（红利/提现档位） |
| multiplier | int | 流水倍数 |
| category | varchar(32) | 红利业务类别 |
| category_title | varchar(64) | 类别展示名 |

---

## 四、业务规则

### 4.1 计算规则（需配置）

- **返还比例**：`新增保障金 = ABS(yesterday_win_loss_net) * 返还比例`（仅 `yesterday_win_loss_net < 0` 时）
- **解锁比例**：`今日可领取上限 = yesterday_total_deposit * 解锁比例`
- **提现折算**：积分 → 现金（如 10 分 = 1 元），用于前端展示

### 4.2 每日重置

- `today_claimed` 与 `today_limit` 按业务日重置（`today_limit_date` 变化时）

### 4.3 发放重试

- 自动重试：1min / 5min / 15min，最多 3 次，使用同一 `externalTransactionId`
- 仅对 `NOT_SENT`、`FAILED_RETRYABLE` 重试
- 收到 `accepted` 后不再重试，改轮询交易终态查询 API

---

## 五、实施顺序建议

1. **数据库**：新增迁移（`mall_playx_daily_push`、`mall_playx_claim_log`、`mall_playx_order`），扩展 `mall_item`、`mall_player`（或新建资产表）
2. **模型**：`MallPlayxDailyPush`、`MallPlayxClaimLog`、`MallPlayxOrder`、扩展 `MallItem`、`MallPlayer`
3. **接口**：Daily Push API（含签名校验）→ Token 验证 → 资产/领取 → 商品列表 → 红利/实物/提现 → 订单列表
4. **后台**：商品扩展、订单管理（含发货/驳回/重试）、人工调账、每日推送数据查看
5. **定时任务**：轮询交易终态、自动重试失败发放

---

## 六、待确认事项

- playX 提供的 **Token Verification API**、**Bonus Grant API**、**Balance Credit API**、**交易终态查询 API** 的 URL、鉴权方式、字段最终表
- `date` 的时区定义（如 UTC+8）
- 返还比例、解锁比例、提现折算的具体数值
- 是否启用「同步额度」功能（需 playX 提供对应 API）
