# 通用接口

> 本节收纳玩家端与具体业务无关的通用能力，例如客户端直传对象存储的预签名 URL。

---

## 获取上传预签名 URL {#get_upload_url}

由服务端返回一个 S3 兼容对象存储的**预签名 PUT URL**，客户端拿到后自行把文件 `PUT` 到该 URL，实现两步式直传（不经过后端中转）。上传完成后使用返回的 `url` 作为公开访问地址（例如绑定为会员头像）。

采用**内容寻址**：服务端以客户端预先计算的内容哈希拼 S3 对象 key，同一份内容重复上传会命中已有对象、直接返回 `exists=true` 而不再签发新 `upload_url`。需登录。

**URL:** `POST` /common/get_upload_url

**输入:**

字段 | 类型 | 必填 | 说明
---|---|---|---
name | string | 是 | 文件名，长度 1-1000；服务端只取扩展名（`.png` / `.jpg` / `.webp` ...）用于拼 S3 对象 key，其它部分被丢弃
hash | string | 是 | 由客户端预先计算的文件内容哈希（推荐 sha256 hex），长度 4-64；服务端据此做内容寻址与幂等判重，**不同内容必须给不同 hash**，否则会命中他人已上传的对象

**输出:**

字段 | 类型 | 说明
---|---|---
exists | bool | 目标对象是否已在 S3 存在（同 `hash` + 同扩展名的对象）。`true` 表示不需要再上传，直接使用 `url` 即可；`false` 表示需要客户端把文件 PUT 到 `upload_url`
upload_url | string | 预签名 PUT URL；**仅当 `exists=false` 时返回**。签名有效期由 SDK 默认策略决定，建议客户端拿到后立即上传
url | string | 上传完成后的**公开访问 URL**；无论 `exists` 是 `true` 还是 `false` 都会返回。格式为 `<MEDIA_BASE_URL>/<hash 前 2 位>/<hash><ext>`

> **S3 对象 key 规则**：`{hash[:2]}/{hash}{ext}`，其中 `ext` 取自请求参数 `name` 的扩展名（不含扩展名时为空串）。同一个 `hash` 使用不同扩展名视为不同对象。

**业务错误:**

message | HTTP 状态码 | 触发条件
---|---|---
请先登录 | 401 | 未携带有效 JWT / token 过期 / 匿名会话
Key: 'GetUploadURL.Name' Error:... | 400 | `name` 缺失或超过 1000 字符（binding tag `required,max=1000`，实际错误消息由框架按语言渲染）
Key: 'GetUploadURL.Hash' Error:... | 400 | `hash` 缺失或长度不在 `4-64` 之间（binding tag `required,min=4,max=64`）
获取上传URL失败 | 400 | 服务端 S3 未配置（缺少 `S3_URL` / `AWS_S3_*` 环境变量）或签名生成失败

---

### 客户端使用流程

**Step 1. 计算内容哈希 → 换取预签名 URL**

前端计算文件内容的 sha256（浏览器可用 `crypto.subtle.digest`，或使用 `crypto-js` / `spark-md5` 等库），然后调用本接口：

```bash
curl -X POST 'https://<your-domain>/api/game/common/get_upload_url' \
  -H 'Authorization: Bearer <jwt>' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "avatar.png",
    "hash": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
  }'
```

**响应示例 —— 首次上传（`exists=false`）：**

```json
{
  "data": {
    "exists": false,
    "upload_url": "https://s3.example.com/mybucket/ab/abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Date=...&X-Amz-Expires=900&X-Amz-Signature=...&X-Amz-SignedHeaders=host",
    "url": "https://cdn.example.com/ab/abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789.png"
  }
}
```

**响应示例 —— 内容已存在（`exists=true`，无 `upload_url`）：**

```json
{
  "data": {
    "exists": true,
    "url": "https://cdn.example.com/ab/abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789.png"
  }
}
```

**Step 2. `PUT` 文件二进制到 `upload_url`**（仅当 `exists=false`）

对返回的 `upload_url` 直接发起 `PUT` 请求，请求体为**原始文件二进制**（不是 base64）。签名信息已在 URL 的 query 里，不需要再手动加签名头。`Content-Type` 应与文件真实 MIME 类型一致：

```javascript
// 浏览器端伪代码
const file = document.querySelector('#fileInput').files[0];
const buf = await file.arrayBuffer();

// 1) 计算 sha256
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
const hash = Array.from(new Uint8Array(hashBuf))
  .map(b => b.toString(16).padStart(2, '0')).join('');

// 2) 换 upload_url
const { data } = await fetch('/api/game/common/get_upload_url', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: file.name, hash }),
}).then(r => r.json());

// 3) 若已存在则跳过 PUT，直接用 data.url
if (!data.exists) {
  await fetch(data.upload_url, {
    method: 'PUT',
    headers: { 'Content-Type': file.type }, // 如 "image/png"
    body: file, // 原始 File / Blob；勿包装 FormData，勿转 base64
  });
}

// 4) 使用 data.url 作为最终访问地址
console.log('public url:', data.url);
```

**Step 3. 使用返回的 `url` 作为公开访问地址**

后续把 `url` 用于图片展示或提交给业务接口。例如更新会员头像：

```bash
curl -X POST 'https://<your-domain>/api/game/auth/update' \
  -H 'Authorization: Bearer <jwt>' \
  -H 'Content-Type: application/json' \
  -d '{ "head_portrait": "https://cdn.example.com/ab/abcdef0...png" }'
```

参见 [修改用户资料](100_auth.md#update)。

---

### 注意事项

- **必须使用 `PUT` 方法上传**，不要用 `POST` / `multipart/form-data`；`upload_url` 是 S3 `PresignPutObject` 的产物，只匹配 `PUT`。
- **请求体是原始二进制**，前端不要做 base64 编码，也不要包 FormData。
- **`hash` 建议用 sha256**（十六进制小写 64 字符）。同一份文件必须产生相同 `hash`，否则会导致对象重复占用存储；不同文件必须给不同 `hash`，否则会误命中他人对象（返回 `exists=true` 但 `url` 指向的不是本次要上传的内容）。
- **签名有效期有限**：拿到 `upload_url` 后应立即 `PUT`，长时间闲置后签名过期需重新调用本接口。
- **扩展名影响 key**：`name` 里的扩展名会拼进 S3 key，同一 `hash` 用不同扩展名会产生两个不同对象；建议客户端使用文件真实扩展名，避免不必要的重复上传。
