# 虾壳开放平台 API 接入文档

> **版本**: v1.0
> **更新日期**: 2026-06-25

## 目录

- [1. 概述](#1-概述)
- [2. 接入准备](#2-接入准备)
- [3. 认证流程](#3-认证流程)
- [4. 签名机制](#4-签名机制)
- [5. 接口列表](#5-接口列表)
  - [5.1 换取令牌](#51-换取令牌)
  - [5.2 刷新令牌](#52-刷新令牌)
  - [5.3 获取用户信息](#53-获取用户信息)
  - [5.4 消费扣款](#54-消费扣款)
- [6. 错误码](#6-错误码)
- [7. 签名示例代码](#7-签名示例代码)
- [8. 最佳实践](#8-最佳实践)

---

## 1. 概述

虾壳开放平台（OpenAPI）提供了一组标准化接口，供第三方应用后端与虾壳平台进行安全对接。通过 OpenAPI，第三方应用可以：

- **获取用户信息** — 在用户授权后获取其基本资料和钱包余额
- **消费扣款** — 以幂等方式扣除用户余额，用于应用内消费

所有 OpenAPI 接口均通过 HMAC-SHA256 签名机制保证请求的完整性和防重放，确保通信安全。

---

## 2. 接入准备

### 2.1 获取凭证

接入前，第三方应用需要在虾壳管理后台完成注册，获取以下凭证：

| 参数 | 说明 | 示例 |
|------|------|------|
| `appKey` | 应用标识，全局唯一 | `my-app-2024` |
| `appSecret` | 应用密钥，用于签名计算 | `abc123def456...` |

> ⚠️ **安全提醒**：`appSecret` 必须妥善保管，仅限服务端使用，禁止泄露到前端或客户端。

### 2.2 基础 URL

```
https://www.sharkai.club/api
```

---

## 3. 认证流程

### 3.1 完整时序

```
  用户(虾壳客户端)              第三方应用后端                  虾壳平台
       │                            │                            │
       │ POST /app/open/{appId}     │                            │
       │───────────────────────────>│                            │
       │   返回 session_key          │                            │
       │<───────────────────────────│                            │
       │                            │                            │
       │  将 session_key 传递给      │                            │
       │  第三方应用（URL参数）       │                            │
       │───────────────────────────>│                            │
       │                            │ POST /openapi/auth/token   │
       │                            │ { sessionKey }             │
       │                            │──────────────────────────>│
       │                            │   HMAC签名验证 ✓            │
       │                            │   返回 accessToken         │
       │                            │   + refreshToken           │
       │                            │<──────────────────────────│
       │                            │                            │
       │                            │ GET /openapi/user/info     │
       │                            │ X-Access-Token: xxx        │
       │                            │──────────────────────────>│
       │                            │   返回用户信息+余额         │
       │                            │<──────────────────────────│
       │                            │                            │
       │                            │ POST /openapi/consumption/ │
       │                            │ deduct { amount, ... }     │
       │                            │──────────────────────────>│
       │                            │   返回扣款结果              │
       │                            │<──────────────────────────│
```

### 3.2 流程说明

1. **用户打开应用**：用户在虾壳客户端点击打开第三方应用，虾壳生成一次性 `session_key`（5分钟有效），通过 URL 参数传递给第三方应用
2. **换取令牌**：第三方应用后端用 `session_key` 调用换取令牌接口，获取 `accessToken`（1小时）和 `refreshToken`（2天）
3. **访问业务接口**：使用 `accessToken` 调用获取用户信息、消费扣款等接口
4. **刷新令牌**：`accessToken` 过期前，使用 `refreshToken` 轮换获取新令牌

### 3.3 获取 session_key

`session_key` 由虾壳用户端发起，第三方开发者需了解其产生过程以正确接收：

**虾壳用户端调用**（由虾壳前端自动完成，第三方无需实现）：

```
POST /app/open/{appId}
Authorization: Bearer {用户JWT}
```

**响应**：

```json
{
  "code": 200,
  "message": "success",
  "data": {
    "sessionKey": "550e8400-e29b-41d4-a716-446655440000",
    "openUrl": "https://your-app.com/entry?session_key=550e8400-e29b-41d4-a716-446655440000"
  }
}
```

| 字段 | 类型 | 说明 |
|------|------|------|
| `sessionKey` | string | 一次性会话密钥（5分钟有效） |
| `openUrl` | string | 应用打开链接（将 `session_key` 拼接到应用配置的链接后） |

**第三方接收方式**：

虾壳前端会跳转到 `openUrl`，第三方应用的前端页面从 URL 中提取 `session_key` 参数，然后传递给后端，由后端调用 `POST /openapi/auth/token` 完成换令牌。

```
https://your-app.com/entry?session_key=550e8400-e29b-41d4-a716-446655440000
                            ↑
                   第三方前端从此处提取 session_key
```

> ⚠️ `session_key` 有效期仅 5 分钟，且一次性使用。第三方前端获取后应立即传给后端换令牌，避免超时失效。

### 3.4 令牌有效期

| 令牌 | 有效期 | 特性 |
|------|--------|------|
| `session_key` | **5 分钟** | 一次性使用，换取后立即失效 |
| `accessToken` | **1 小时**（3600秒） | 可多次使用，过期后需刷新 |
| `refreshToken` | **2 天**（172800秒） | 一次性使用，轮换后旧令牌立即失效 |

---

## 4. 签名机制

所有 `/openapi/**` 请求均需进行 HMAC-SHA256 签名验证。

### 4.1 必需 Header

| Header | 说明 | 示例 |
|--------|------|------|
| `X-App-Key` | 应用标识 | `my-app-2024` |
| `X-Timestamp` | 毫秒时间戳 | `1718083200000` |
| `X-Nonce` | 随机字符串（防重放） | `a1b2c3d4e5f6` |
| `X-Signature` | HMAC-SHA256 签名 | `e8f9a0b1c2d3...` |

### 4.2 签名算法

**第一步：计算请求体 SHA256**

```
bodySha256 = SHA256(requestBody)
```

- GET 请求无 body，使用空字符串的 SHA256：`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`
- POST 请求使用请求体 JSON 的 UTF-8 字节计算

**第二步：构建规范化字符串**

```
canonicalString = "{method}\n{path}\n{timestamp}\n{nonce}\n{bodySha256}"
```

各字段说明：

| 字段 | 取值 | 示例 |
|------|------|------|
| `method` | HTTP 方法（大写） | `POST` |
| `path` | 请求路径（不含域名） | `/openapi/auth/token` |
| `timestamp` | 与 Header 中一致的毫秒时间戳 | `1718083200000` |
| `nonce` | 与 Header 中一致的随机串 | `a1b2c3d4e5f6` |
| `bodySha256` | 第一步计算的 SHA256 | `e3b0c44...` |

**第三步：计算签名**

```
signature = HMAC-SHA256(canonicalString, appSecret)
```

返回十六进制小写字符串。

### 4.3 签名示例

假设：
- `appSecret` = `test-app-secret-123456`
- `method` = `POST`
- `path` = `/openapi/auth/token`
- `timestamp` = `1718083200000`
- `nonce` = `a1b2c3d4e5f6`
- 请求体为 `{"sessionKey":"550e8400-e29b-41d4-a716-446655440000"}`

```
bodySha256 = SHA256('{"sessionKey":"550e8400-e29b-41d4-a716-446655440000"}')

canonicalString = "POST\n/openapi/auth/token\n1718083200000\na1b2c3d4e5f6\n{bodySha256}"

signature = HMAC-SHA256(canonicalString, "test-app-secret-123456")
```

### 4.4 空 Body 处理规则

GET 请求无请求体时，`bodySha256` 使用空字符串的 SHA256 常量值：

```
bodySha256 = SHA256("") = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
```

### 4.5 响应签名验证

所有 OpenAPI 接口的响应均包含 `X-Response-Signature` Header，第三方应用可验证响应体完整性。

**签名规则**：

```
X-Response-Signature = HMAC-SHA256(SHA256(responseBody), appSecret)
```

**验证步骤**：

1. 取响应体原始 JSON 字符串，计算 `bodySha256 = SHA256(responseBody)`
2. 用本地保存的 `appSecret` 计算 `expectedSignature = HMAC-SHA256(bodySha256, appSecret)`
3. 对比响应 Header `X-Response-Signature` 与 `expectedSignature` 是否一致

> **注意**：验证时需使用响应体的原始 JSON 字节（UTF-8），不要格式化或添加空白字符。

---

## 5. 接口列表

### 5.1 换取令牌

用一次性 `session_key` 换取 `accessToken` 和 `refreshToken`。

**请求**

```
POST /openapi/auth/token
```

**Header**

| 参数 | 必填 | 说明 |
|------|------|------|
| `X-App-Key` | 是 | 应用标识 |
| `X-Timestamp` | 是 | 毫秒时间戳 |
| `X-Nonce` | 是 | 随机字符串 |
| `X-Signature` | 是 | HMAC-SHA256 签名 |
| `Content-Type` | 是 | `application/json` |

**请求体**

```json
{
  "sessionKey": "550e8400-e29b-41d4-a716-446655440000"
}
```

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `sessionKey` | string | 是 | 临时会话密钥（用户打开应用时获取） |

**响应**

```json
{
  "code": 200,
  "message": "success",
  "data": {
    "accessToken": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "expiresIn": 3600,
    "refreshToken": "f9e8d7c6-b5a4-3210-fedc-ba0987654321",
    "refreshExpiresIn": 172800
  }
}
```

| 字段 | 类型 | 说明 |
|------|------|------|
| `data.accessToken` | string | 访问令牌，用于后续业务接口 |
| `data.expiresIn` | int | accessToken 有效期（秒） |
| `data.refreshToken` | string | 刷新令牌（一次性，用于获取新 accessToken） |
| `data.refreshExpiresIn` | int | refreshToken 有效期（秒） |

---

### 5.2 刷新令牌

用 `refreshToken` 轮换获取新的双令牌。旧 `refreshToken` 一次性使用，轮换后立即失效。

**请求**

```
POST /openapi/auth/refresh
```

**Header**

| 参数 | 必填 | 说明 |
|------|------|------|
| `X-App-Key` | 是 | 应用标识 |
| `X-Timestamp` | 是 | 毫秒时间戳 |
| `X-Nonce` | 是 | 随机字符串 |
| `X-Signature` | 是 | HMAC-SHA256 签名 |
| `Content-Type` | 是 | `application/json` |

**请求体**

```json
{
  "refreshToken": "f9e8d7c6-b5a4-3210-fedc-ba0987654321"
}
```

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `refreshToken` | string | 是 | 刷新令牌 |

**响应**

```json
{
  "code": 200,
  "message": "success",
  "data": {
    "accessToken": "new-access-token-uuid",
    "expiresIn": 3600,
    "refreshToken": "new-refresh-token-uuid",
    "refreshExpiresIn": 172800
  }
}
```

响应结构与 [5.1 换取令牌](#51-换取令牌) 相同。

---

### 5.3 获取用户信息

用 `accessToken` 获取用户基本资料和钱包余额。

**请求**

```
GET /openapi/user/info
```

**Header**

| 参数 | 必填 | 说明 |
|------|------|------|
| `X-App-Key` | 是 | 应用标识 |
| `X-Timestamp` | 是 | 毫秒时间戳 |
| `X-Nonce` | 是 | 随机字符串 |
| `X-Signature` | 是 | HMAC-SHA256 签名 |
| `X-Access-Token` | 是 | 访问令牌 |

**请求体**

无（GET 请求）

**响应**

```json
{
  "code": 200,
  "message": "success",
  "data": {
    "userId": 100,
    "nickname": "小明",
    "avatar": "https://example.com/avatar.jpg",
    "balance": 100.0000
  }
}
```

| 字段 | 类型 | 说明 |
|------|------|------|
| `data.userId` | long | 用户ID |
| `data.nickname` | string | 用户昵称 |
| `data.avatar` | string | 头像 URL |
| `data.balance` | decimal | 钱包余额（当地货币） |

---

### 5.4 消费扣款

用 `accessToken` 扣除用户余额。支持幂等：传入相同的 `appOrderNo` 不会重复扣款。

**请求**

```
POST /openapi/consumption/deduct
```

**Header**

| 参数 | 必填 | 说明 |
|------|------|------|
| `X-App-Key` | 是 | 应用标识 |
| `X-Timestamp` | 是 | 毫秒时间戳 |
| `X-Nonce` | 是 | 随机字符串 |
| `X-Signature` | 是 | HMAC-SHA256 签名 |
| `X-Access-Token` | 是 | 访问令牌 |
| `Content-Type` | 是 | `application/json` |

**请求体**

```json
{
  "amount": 10.00,
  "appOrderNo": "order-20240601-001",
  "remark": "GPT-4对话"
}
```

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `amount` | decimal | 是 | 消费金额（当地货币），必须大于 0 |
| `appOrderNo` | string | 否 | 应用侧订单号（幂等键），建议传入 |
| `remark` | string | 否 | 备注 |

**响应**

```json
{
  "code": 200,
  "message": "success",
  "data": {
    "orderNo": "CON20240601000001",
    "appOrderNo": "order-20240601-001",
    "balanceBefore": 100.0000,
    "amount": 10.0000,
    "balanceAfter": 90.0000
  }
}
```

| 字段 | 类型 | 说明 |
|------|------|------|
| `data.orderNo` | string | 平台消费记录号 |
| `data.appOrderNo` | string | 应用侧订单号（与请求中传入的 appOrderNo 一致） |
| `data.balanceBefore` | decimal | 扣款前余额 |
| `data.amount` | decimal | 本次消费金额 |
| `data.balanceAfter` | decimal | 扣款后余额 |

---

## 6. 错误码

### 6.1 响应格式

```json
{
  "code": 40509,
  "message": "app.signature.invalid",
  "data": null
}
```

| 字段 | 说明 |
|------|------|
| `code` | 错误码（200 表示成功） |
| `message` | 错误描述 |
| `data` | 错误时为 null |

### 6.2 OpenAPI 错误码表

| 错误码 | message | 说明 | 处理建议 |
|--------|---------|------|----------|
| 40501 | `app.not_found` | 应用不存在 | 检查 appKey 是否正确 |
| 40502 | `app.offline` | 应用已下线 | 联系平台管理员 |
| 40503 | `app.session.invalid` | session_key 无效 | 重新获取 session_key |
| 40504 | `app.session.expired` | session_key 已过期 | session_key 有效期 5 分钟，请尽快使用 |
| 40505 | `app.session.used` | session_key 已使用 | session_key 为一次性令牌，不可重复使用 |
| 40506 | `app.access_token.invalid` | accessToken 无效或过期 | 使用 refreshToken 刷新令牌 |
| 40507 | `app.balance.insufficient` | 用户余额不足 | 提示用户充值 |
| 40508 | `app.consumption.amount_invalid` | 消费金额无效 | 检查金额是否大于 0 |
| 40509 | `app.signature.invalid` | 签名验证失败 | 检查签名算法和 appSecret |
| 40510 | `app.request.expired` | 请求已过期 | 检查时间戳，确保在 5 分钟窗口内 |
| 40511 | `app.request.replay` | 请求重放（nonce 重复） | 每次请求使用不同的 nonce |

### 6.3 通用错误码

| 错误码 | message | 说明 |
|--------|---------|------|
| 200 | `success` | 成功 |
| 40106 | `user.disabled` | 用户已禁用 |
| 500 | — | 服务器内部错误 |

---

## 7. 签名示例代码

### 7.1 Java

```java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.UUID;

public class OpenApiSigner {

    /**
     * 计算请求签名
     *
     * @param method     HTTP方法（GET/POST）
     * @param path       请求路径
     * @param appSecret  应用密钥
     * @param body       请求体（GET传null）
     * @return SignResult 包含签名所需的所有Header
     */
    public static SignResult sign(String method, String path, String appSecret, String body) {
        String timestamp = String.valueOf(System.currentTimeMillis());
        String nonce = UUID.randomUUID().toString().replace("-", "");

        // 1. 计算请求体SHA256
        String bodySha256 = sha256Hex(body != null ? body : "");

        // 2. 构建规范化字符串
        String canonicalString = method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + bodySha256;

        // 3. HMAC-SHA256签名
        String signature = hmacSha256(canonicalString, appSecret);

        return new SignResult(timestamp, nonce, signature);
    }

    private static String hmacSha256(String data, String key) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
            mac.init(keySpec);
            byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
            return bytesToHex(hash);
        } catch (Exception e) {
            throw new RuntimeException("签名失败", e);
        }
    }

    private static String sha256Hex(String data) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(data.getBytes(StandardCharsets.UTF_8));
            return bytesToHex(hash);
        } catch (Exception e) {
            throw new RuntimeException("SHA256计算失败", e);
        }
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder(bytes.length * 2);
        for (byte b : bytes) {
            sb.append(String.format("%02x", b & 0xff));
        }
        return sb.toString();
    }

    /** 签名结果 */
    public record SignResult(String timestamp, String nonce, String signature) {}
}
```

**使用示例**：

```java
String appSecret = "your-app-secret";
String body = "{\"sessionKey\":\"550e8400-e29b-41d4-a716-446655440000\"}";

OpenApiSigner.SignResult result = OpenApiSigner.sign("POST", "/openapi/auth/token", appSecret, body);

// 设置请求Header
// X-Timestamp: result.timestamp()
// X-Nonce: result.nonce()
// X-Signature: result.signature()
```

### 7.2 Python

```python
import hmac
import hashlib
import time
import uuid

def sign_request(method: str, path: str, app_secret: str, body: str | None = None) -> dict:
    """计算 OpenAPI 请求签名

    Args:
        method: HTTP方法（GET/POST）
        path: 请求路径
        app_secret: 应用密钥
        body: 请求体（GET请求传None）

    Returns:
        包含 timestamp, nonce, signature 的字典
    """
    timestamp = str(int(time.time() * 1000))
    nonce = uuid.uuid4().hex

    # 1. 计算请求体SHA256
    body_bytes = (body or "").encode("utf-8")
    body_sha256 = hashlib.sha256(body_bytes).hexdigest()

    # 2. 构建规范化字符串
    canonical_string = f"{method}\n{path}\n{timestamp}\n{nonce}\n{body_sha256}"

    # 3. HMAC-SHA256签名
    signature = hmac.new(
        app_secret.encode("utf-8"),
        canonical_string.encode("utf-8"),
        hashlib.sha256
    ).hexdigest()

    return {
        "timestamp": timestamp,
        "nonce": nonce,
        "signature": signature
    }
```

**使用示例**：

```python
import requests

app_key = "your-app-key"
app_secret = "your-app-secret"
body = '{"sessionKey":"550e8400-e29b-41d4-a716-446655440000"}'

sign_result = sign_request("POST", "/openapi/auth/token", app_secret, body)

headers = {
    "X-App-Key": app_key,
    "X-Timestamp": sign_result["timestamp"],
    "X-Nonce": sign_result["nonce"],
    "X-Signature": sign_result["signature"],
    "Content-Type": "application/json"
}

response = requests.post(
    "https://your-domain.com/openapi/auth/token",
    headers=headers,
    data=body
)
print(response.json())
```

### 7.3 Node.js

```javascript
const crypto = require('crypto');

/**
 * 计算请求签名
 * @param {string} method - HTTP方法（GET/POST）
 * @param {string} path - 请求路径
 * @param {string} appSecret - 应用密钥
 * @param {string|null} body - 请求体（GET请求传null）
 * @returns {{timestamp: string, nonce: string, signature: string}}
 */
function signRequest(method, path, appSecret, body = null) {
  const timestamp = Date.now().toString();
  const nonce = crypto.randomUUID().replace(/-/g, '');

  // 1. 计算请求体SHA256
  const bodySha256 = crypto
    .createHash('sha256')
    .update(body || '')
    .digest('hex');

  // 2. 构建规范化字符串
  const canonicalString = [method, path, timestamp, nonce, bodySha256].join('\n');

  // 3. HMAC-SHA256签名
  const signature = crypto
    .createHmac('sha256', appSecret)
    .update(canonicalString)
    .digest('hex');

  return { timestamp, nonce, signature };
}
```

**使用示例**：

```javascript
const https = require('https');

const appKey = 'your-app-key';
const appSecret = 'your-app-secret';
const body = JSON.stringify({ sessionKey: '550e8400-e29b-41d4-a716-446655440000' });

const { timestamp, nonce, signature } = signRequest('POST', '/openapi/auth/token', appSecret, body);

const options = {
  hostname: 'your-domain.com',
  path: '/openapi/auth/token',
  method: 'POST',
  headers: {
    'X-App-Key': appKey,
    'X-Timestamp': timestamp,
    'X-Nonce': nonce,
    'X-Signature': signature,
    'Content-Type': 'application/json'
  }
};

const req = https.request(options, (res) => {
  let data = '';
  res.on('data', (chunk) => data += chunk);
  res.on('end', () => console.log(JSON.parse(data)));
});
req.write(body);
req.end();
```

---

## 8. 最佳实践

### 8.1 令牌管理

- **提前刷新**：在 `accessToken` 过期前（建议剩余 5 分钟时）主动使用 `refreshToken` 刷新，避免接口调用中断
- **安全存储**：令牌应存储在服务端，禁止传递到前端或嵌入客户端代码
- **并发控制**：`refreshToken` 为一次性使用，并发刷新可能导致其中一个失败，建议加分布式锁或由单一服务负责刷新

### 8.2 幂等处理

- 消费扣款接口支持幂等：传入相同的 `appOrderNo` 不会重复扣款
- **强烈建议**每次扣款都传入 `appOrderNo`，避免网络超时重试导致的重复扣款
- `appOrderNo` 由第三方应用生成，需保证全局唯一（建议格式：`{应用前缀}-{日期}-{序号}`）

### 8.3 错误重试

| 场景 | 建议 |
|------|------|
| 签名失败（40509） | 检查签名算法，不重试 |
| 请求过期（40510） | 校准服务器时间，使用新时间戳重试 |
| 请求重放（40511） | 更换新的 nonce 重试 |
| 令牌无效（40506） | 使用 refreshToken 刷新后重试 |
| 余额不足（40507） | 提示用户充值，不重试 |
| 网络超时 | 检查扣款结果后再决定是否重试（结合 appOrderNo 幂等） |

### 8.4 安全建议

- `appSecret` 仅限服务端存储，禁止泄露到前端
- 使用 HTTPS 协议通信
- 每次请求使用不同的 `nonce`，推荐使用 UUID
- `session_key` 有效期仅 5 分钟，获取后应立即使用
