优化,添加115接口文档

This commit is contained in:
truewhile
2026-09-05 14:32:58 +08:00
parent d00b14df55
commit 0e9fb2c937
57 changed files with 5682 additions and 76 deletions
@@ -0,0 +1,96 @@
## 删除用户云下载任务
### 基本信息
| 属性 | 内容 |
|:-------------|:----------------------------------|
| 接口名称 | 删除用户云下载任务 |
| 接口版本 | v1.0 |
| 接口路径 | /del_task |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
删除当前授权用户的指定云下载任务,可选择同时删除对应源文件。
### 接口地址
```
https://proapi.115.com/open/offline/del_task
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------------|:-------|:-----|:-------|:---------------------------|:-------------|
| info_hash | string | 是 | - | 需删除的任务Hash | `<info_hash>` |
| del_source_file | int | 否 | 0 | 是否删除源文件,1=删除 0=不删除 | 0 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/offline/del_task' \
-H 'Authorization: Bearer <access_token>' \
--form-string 'info_hash=<info_hash>' \
--form-string 'del_source_file=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------|:--------|:----------------------|
| state | boolean | 操作结果状态 |
| message | string | 返回信息 |
| code | int | 错误码 |
| data | object[] | 返回数据,成功时为空数组 |
#### 响应的 code 字段(错误码)说明
| 错误码 | 说明 | 解决方案 |
|:-------|:---------|:-------------------------|
| 990002 | 参数错误 | 检查 `info_hash` 参数 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 业务规则
- 未传 `del_source_file` 时默认不删除源文件。
- `del_source_file=1` 会删除任务对应的源文件,操作前需确认影响范围。
### 性能与安全说明
- 用户身份由 Bearer access_token 解析,请求参数不能指定115账号。
- 接口按应用、用户和接口维度执行频率限制。
### 注意事项
- 删除结果以响应中的 `state`、`code` 和 `message` 字段为准。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,111 @@
## 添加云下载BT任务
### 基本信息
| 属性 | 内容 |
|:-------------|:----------------------------------|
| 接口名称 | 添加云下载BT任务 |
| 接口版本 | v1.0 |
| 接口路径 | /add_task_bt |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
根据已解析的BT种子信息添加云下载BT任务。
### 接口地址
```
https://proapi.115.com/open/offline/add_task_bt
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-------------|:-------|:-----|:-------|:----------------------------------|:----------------|
| info_hash | string | 是 | - | BT任务Hash | `<info_hash>` |
| wanted | string | 是 | - | 选中下载的文件索引,使用半角逗号分隔 | `<file_indexes>` |
| save_path | string | 是 | - | BT任务文件保存路径 | `A/B` |
| torrent_sha1 | string | 是 | - | BT种子SHA1 | `<torrent_sha1>` |
| pick_code | string | 是 | - | BT种子文件提取码 | `<pick_code>` |
| wp_path_id | string | 否 | 0 | 保存目标文件夹ID | 0 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/offline/add_task_bt' \
-H 'Authorization: Bearer <access_token>' \
--form-string 'info_hash=<info_hash>' \
--form-string 'wanted=<file_indexes>' \
--form-string 'save_path=A/B' \
--form-string 'torrent_sha1=<torrent_sha1>' \
--form-string 'pick_code=<pick_code>' \
--form-string 'wp_path_id=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------|:--------|:----------------------|
| state | boolean | 操作结果状态 |
| message | string | 返回信息 |
| code | int | 错误码 |
| data | object[] | 返回数据,成功时为空数组 |
#### 响应的 code 字段(错误码)说明
| 错误码 | 说明 | 解决方案 |
|:--------|:---------------------|:-------------------------------|
| 20018 | 文件不存在或已删除 | 检查提取码、文件归属和种子SHA1 |
| 91006 | 存储空间不足 | 扩充存储空间后重试 |
| 990002 | 参数错误 | 检查必填参数 |
| 1000012 | 云下载配额已用完 | 购买配额或获得更多配额后重试 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 业务规则
- `wanted` 可以为 `0`,但不能是空字符串。
- 种子文件需属于当前授权用户、位于指定存储区域,并且文件SHA1与 `torrent_sha1` 一致。
- 不传 `wp_path_id` 时默认保存到根目录;`save_path` 是相对于 `wp_path_id` 所在文件夹的路径。例如,`wp_path_id` 不传或传云下载文件夹ID且 `save_path=A/B` 时,最终路径为根目录下的 `A/B/`。
- 添加任务前会检查当前授权账号的剩余存储空间;空间不足时不扣减云下载配额。
- 云下载服务返回状态码 `10007` 或 `10010` 时,接口统一返回错误码 `1000012`。
### 性能与安全说明
- 用户身份由 Bearer access_token 解析,请求参数不能指定115账号。
- 接口按应用、用户和接口维度执行频率限制。
- 种子文件必须属于当前授权账号,并且文件SHA1与 `torrent_sha1` 一致。
### 注意事项
- 任务处理结果以响应中的 `data` 字段为准。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,115 @@
## 添加云下载链接任务
### 基本信息
| 属性 | 内容 |
|:-------------|:----------------------------------|
| 接口名称 | 添加云下载链接任务 |
| 接口版本 | v1.0 |
| 接口路径 | /add_task_urls |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
批量添加云下载链接任务。多个链接使用换行符分隔,支持HTTP(S)、FTP、磁力链和电驴链接。
### 接口地址
```
https://proapi.115.com/open/offline/add_task_urls
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-----------|:-------|:-----|:-------|:----------------------------------------|:----------------|
| urls | string | 是 | - | 云下载链接,多个链接使用换行符分隔 | `<download_url>` |
| wp_path_id | string | 否 | 0 | 保存目标文件夹ID;不传或传0时保存到根目录 | 0 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/offline/add_task_urls' \
-H 'Authorization: Bearer <access_token>' \
--form-string 'urls=<download_url>' \
--form-string 'wp_path_id=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-----------------|:---------|:-------------------------------|
| state | boolean | 操作结果状态 |
| message | string | 返回信息 |
| code | int | 错误码 |
| data | object[] | 各链接任务的添加结果 |
| data[].state | boolean | 链接任务添加状态 |
| data[].code | int | 链接任务状态码 |
| data[].message | string | 链接任务状态描述 |
| data[].info_hash | string | 链接任务SHA1,仅任务成功时返回 |
| data[].url | string | 链接任务URL |
#### 响应的 code 字段(错误码)说明
| 错误码 | 说明 | 解决方案 |
|:--------|:---------------------------|:-----------------------------|
| 91006 | 存储空间不足 | 扩充存储空间后重试 |
| 980004 | 操作失败 | 稍后重试 |
| 990002 | 参数错误 | 检查必填参数 |
| 1000011 | 链接数量超过115个 | 将链接拆分为每批不超过115个 |
| 1000012 | 云下载配额已用完 | 购买配额或获得更多配额后重试 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": [
{
"state": true,
"code": 0,
"message": "",
"info_hash": "",
"url": ""
}
]
}
```
### 业务规则
- 服务端按换行符拆分并清理链接,单次最多提交115个链接。
- 添加任务前会检查当前授权账号的剩余存储空间;空间不足时不扣减云下载配额。
- 云下载服务返回状态码 `10007` 或 `10010` 时,接口统一返回错误码 `1000012`。
### 性能与安全说明
- 用户身份由 Bearer access_token 解析,请求参数不能指定115账号。
- 接口按应用、用户和接口维度执行频率限制。
- 本接口限制单批链接数量,避免不受控的批量请求。
### 注意事项
- 任务处理结果以响应中的 `data` 字段为准。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,104 @@
## 清空云下载任务
### 基本信息
| 属性 | 内容 |
|:-------------|:----------------------------------|
| 接口名称 | 清空云下载任务 |
| 接口版本 | v1.0 |
| 接口路径 | /clear_task |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
按指定类型清空当前授权用户的云下载任务。
### 接口地址
```
https://proapi.115.com/open/offline/clear_task
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-------|:-----|:-----|:-------|:-----------------------|:----------|
| flag | int | 是 | - | 清空任务类型,见下方枚举表格 | 1 |
#### 请求参数中的 flag 参数枚举
| 值 | 说明 | 备注 |
|:---|:-----------------------------|:-----|
| 0 | 清空已完成任务 | - |
| 1 | 清空全部任务 | - |
| 2 | 清空失败任务 | - |
| 3 | 清空进行中任务 | - |
| 4 | 清空已完成任务并删除对应源文件 | - |
| 5 | 清空全部任务并删除对应源文件 | - |
### 请求示例
```shell
curl 'https://proapi.115.com/open/offline/clear_task' \
-H 'Authorization: Bearer <access_token>' \
--form-string 'flag=1'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------|:--------|:-----------------|
| state | boolean | 操作结果状态 |
| message | string | 返回信息 |
| code | int | 错误码 |
| data | object[] | 返回数据,成功时为空数组 |
#### 响应的 code 字段(错误码)说明
| 错误码 | 说明 | 解决方案 |
|:-------|:---------|:--------------------------|
| 990002 | 参数错误 | 检查 `flag` 是否为0至5的整数 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 业务规则
- `flag=4` 或 `flag=5` 会同时删除对应源文件,操作前需确认清理范围。
### 性能与安全说明
- 用户身份由 Bearer access_token 解析,请求参数不能指定115账号。
- 接口按应用、用户和接口维度执行频率限制。
### 注意事项
- 清空结果以响应中的 `state`、`code` 和 `message` 字段为准。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,101 @@
## 获取云下载配额信息
### 基本信息
| 属性 | 内容 |
|:-------------|:----------------------------------|
| 接口名称 | 获取云下载配额信息 |
| 接口版本 | v1.0 |
| 接口路径 | /get_quota_info |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
获取当前授权用户各类云下载配额的使用情况和过期明细。
### 接口地址
```
https://proapi.115.com/open/offline/get_quota_info
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
无。
### 请求示例
```shell
curl 'https://proapi.115.com/open/offline/get_quota_info' \
-H 'Authorization: Bearer <access_token>'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------------------------------------------|:---------|:---------------------|
| state | boolean | 状态,true表示成功 |
| message | string | 返回信息 |
| code | int | 错误码 |
| data | object | 云下载配额数据 |
| data.package | object[] | 配额类型列表 |
| data.package[].surplus | int | 该类型剩余配额 |
| data.package[].used | int | 该类型已用配额 |
| data.package[].count | int | 该类型总配额 |
| data.package[].name | string | 该类型配额名称 |
| data.package[].expire_info | object[] | 该类型配额过期明细 |
| data.package[].expire_info[].surplus | int | 明细项剩余配额 |
| data.package[].expire_info[].expire_time | int | 明细项过期时间 |
| data.count | int | 用户总配额数量 |
| data.surplus | int | 用户总剩余配额数量 |
| data.used | int | 用户总已用配额数量 |
#### 响应的 code 字段(错误码)说明
| 错误码 | 说明 | 解决方案 |
|:-------|:---------|:-------------|
| 990002 | 参数错误 | 检查授权信息 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"package": [],
"count": 0,
"surplus": 0,
"used": 0
}
}
```
### 性能与安全说明
- 用户身份由 Bearer access_token 解析,请求参数不能指定115账号。
- 接口按应用、用户和接口维度执行频率限制。
### 注意事项
- 云下载配额以响应中的 `data` 字段为准。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,136 @@
## 获取用户云下载任务列表
### 基本信息
| 属性 | 内容 |
|:-------------|:----------------------------------|
| 接口名称 | 获取用户云下载任务列表 |
| 接口版本 | v1.0 |
| 接口路径 | /get_task_list |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
分页获取当前授权用户的云下载任务列表。
### 接口地址
```
https://proapi.115.com/open/offline/get_task_list
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-------|:-----|:-----|:-------|:---------|:----------|
| page | int | 否 | 1 | 页码 | 1 |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/offline/get_task_list' \
-H 'Authorization: Bearer <access_token>' \
--data-urlencode 'page=1'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:----------------------------|:---------|:--------------------------------------|
| state | boolean | 状态,true表示成功 |
| message | string | 返回信息 |
| code | int | 错误码 |
| data | object | 分页任务数据 |
| data.page | int | 当前页码 |
| data.page_count | int | 总页数 |
| data.count | int | 任务总数 |
| data.tasks | object[] | 云下载任务列表 |
| data.tasks[].info_hash | string | 任务SHA1 |
| data.tasks[].add_time | int | 任务添加时间戳 |
| data.tasks[].percentDone | int | 任务下载进度 |
| data.tasks[].size | int | 任务总大小,单位为字节 |
| data.tasks[].name | string | 任务名称 |
| data.tasks[].last_update | int | 任务最后更新时间戳 |
| data.tasks[].file_id | string | 任务源文件或文件夹ID |
| data.tasks[].delete_file_id | string | 删除任务并删除源文件时需传递的文件或文件夹ID |
| data.tasks[].status | int | 任务状态,见下方枚举表格 |
| data.tasks[].url | string | 链接任务URL |
| data.tasks[].wp_path_id | string | 任务源文件所在父文件夹ID |
| data.tasks[].def2 | int | 视频清晰度,见下方枚举表格 |
| data.tasks[].play_long | int | 视频时长 |
| data.tasks[].can_appeal | int | 是否可以申诉 |
#### 响应的 data.tasks[].status 字段枚举
| 值 | 说明 | 备注 |
|:---|:---------|:-----|
| -1 | 下载失败 | - |
| 0 | 分配中 | - |
| 1 | 下载中 | - |
| 2 | 下载成功 | - |
#### 响应的 data.tasks[].def2 字段枚举
| 值 | 说明 | 备注 |
|:----|:------|:-----|
| 1 | 标清 | - |
| 2 | 高清 | - |
| 3 | 超清 | - |
| 4 | 1080P | - |
| 5 | 4K | - |
| 100 | 原画 | - |
#### 响应的 code 字段(错误码)说明
| 错误码 | 说明 | 解决方案 |
|:-------|:---------|:---------------|
| 990002 | 参数错误 | 检查授权信息 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"page": 1,
"page_count": 0,
"count": 0,
"tasks": []
}
}
```
### 业务规则
- 未传 `page` 或参数值为空时,默认查询第1页。
### 性能与安全说明
- 用户身份由 Bearer access_token 解析,请求参数不能指定115账号。
- 接口按应用、用户和接口维度执行频率限制。
- 任务列表按 `page` 分页,实际返回数量以响应为准。
### 注意事项
- 云下载任务状态和字段以响应中的 `data` 字段为准。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,118 @@
## 解析BT种子
### 基本信息
| 属性 | 内容 |
|:-------------|:----------------------------------|
| 接口名称 | 解析BT种子 |
| 接口版本 | v1.0 |
| 接口路径 | /torrent |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
解析已上传的BT种子文件,返回种子任务信息和文件列表。
### 接口地址
```
https://proapi.115.com/open/offline/torrent
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-------------|:-------|:-----|:-------|:---------------|:----------------|
| torrent_sha1 | string | 是 | - | BT种子文件SHA1 | `<torrent_sha1>` |
| pick_code | string | 是 | - | BT种子文件提取码 | `<pick_code>` |
### 请求示例
```shell
curl 'https://proapi.115.com/open/offline/torrent' \
-H 'Authorization: Bearer <access_token>' \
--form-string 'torrent_sha1=<torrent_sha1>' \
--form-string 'pick_code=<pick_code>'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-------------------------------|:---------|:---------------------|
| state | boolean | 状态,true表示成功 |
| message | string | 返回信息 |
| code | int | 错误码 |
| data | object | 种子解析结果 |
| data.file_size | int | 任务大小 |
| data.torrent_name | string | 任务名称 |
| data.file_count | int | 文件数量 |
| data.info_hash | string | 任务SHA1 |
| data.torrent_filelist | object[] | 文件列表 |
| data.torrent_filelist[].size | int | 文件大小 |
| data.torrent_filelist[].path | string | 文件路径 |
| data.torrent_filelist[].wanted | int | 文件是否默认选中 |
#### 响应的 code 字段(错误码)说明
| 错误码 | 说明 | 解决方案 |
|:-------|:---------------------|:---------------------------------|
| 20018 | 文件不存在或已删除 | 检查提取码、文件归属和种子SHA1 |
| 990002 | 参数错误 | 检查必填参数 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"file_size": 0,
"torrent_name": "",
"file_count": 0,
"info_hash": "",
"torrent_filelist": [
{
"size": 0,
"path": "",
"wanted": 0
}
]
}
}
```
### 业务规则
- 种子文件需属于当前授权用户、位于指定存储区域,并且文件SHA1与 `torrent_sha1` 一致。
- 现有开放平台文档建议先将种子文件上传至“云下载/种子文件”文件夹,但该目录不是硬性要求。
### 性能与安全说明
- 用户身份由 Bearer access_token 解析,请求参数不能指定115账号。
- 接口按应用、用户和接口维度执行频率限制。
- 种子文件必须属于当前授权账号,并且文件SHA1与 `torrent_sha1` 一致。
### 注意事项
- 种子解析结果以响应中的 `data` 字段为准。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,172 @@
## 开发者商业价值转化:推广产品得收益
### 基本信息
| 属性 | 内容 |
|:-------|:--------------------|
| 文档名称 | 开发者商业价值转化:推广产品得收益 |
| 文档版本 | v1.0 |
## 一、简介
为帮助开发者实现商业价值转化,115生活开放平台推出“推广产品得收益”计划,通过“推广有奖、收益共享”的方式,与开发者共同构建可持续发展的开放生态。
开发者接入标准化服务接口后,可以在用户需要升级使用权限、扩充长期存储空间或增加云下载配额时,引导用户购买对应的 115 增值服务,并基于用户实际购买的产品获取相应推广收益。
## 二、服务形式说明
### 1. 标准化服务接口
标准化服务接口覆盖以下核心场景。
#### 1.1. VIP 服务
适用于用户对应功能使用权限不足的场景:
- 视频播放权限升级:解决非 VIP 用户不支持在线预览视频、年费VIP以下用户不支持视频 4K 超轻转码等权限限制。
- 大文件上传权限升级:解决月费VIP以下用户不支持上传 115GB 大文件等权限限制。
开发者可以引导用户升级至更高的 VIP 类型,以获取对应使用权限。
#### 1.2. 长期存储空间扩容服务
适用于用户存储空间不足的场景,可解决因空间容量不足导致文件上传、复制及添加云下载失败等问题。
开发者可以引导用户购买 VIP 服务以获取更多长期存储空间,或单独购买长期存储空间进行扩容。
#### 1.3. 云下载配额
适用于用户云下载配额不足的场景,可解决因服务配额不足导致添加云下载失败等问题。
开发者可以引导用户升级至更高的 VIP 类型以获取更多云下载配额,或单独购买云下载配额。
### 2. 收益获取模式
用户通过开发者应用进入购买页面并成功购买以下任一服务产品后,开发者可以获得对应推广收益:
- 115生活 VIP 服务,包括月费VIP、年费VIP等。
- 长期存储空间扩容。
- 云下载配额。
VIP 服务购买页面示例:
![VIP服务购买页](图片/推广产品得收益/VIP服务购买页.png)
长期存储空间扩容购买页面示例:
![长期存储空间购买页](图片/推广产品得收益/长期存储空间购买页.png)
## 三、接入流程
### 1. 成为开发者
已成为 115 生活开发者的用户可以直接进行接口接入;未注册的开发者,请先参考[接入流程](../接入指南/接入流程.md)完成注册。
### 2. 接口接入
开发者可以调用“获取产品列表地址”接口取得购买页面地址,并在对应业务场景中引导用户访问。
#### 接口信息
| 属性 | 内容 |
|:-------|:------------|
| 接口名称 | 获取产品列表地址 |
| 接口版本 | v1.0 |
| 接口路径 | /vip/qr_url |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
该接口用于获取 115 生活开放平台增值服务产品列表地址。
### 接口地址
```
https://proapi.115.com/open/vip/qr_url
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-------------------|:-------|:---|:-----|:---------------------------|:--------------------------------------------------------|
| default_product_id | int | 否 | null | 打开产品列表时默认选中的产品 ID | 月费:`5`;年费:`1`;尝鲜 1 天:`101`;长期VIP(至尊版):`24072401` |
| open_device | string | 是 | - | 设备号 | `DEVICE_ID_PLACEHOLDER` |
| hide_title | int | 否 | 0 | 是否隐藏购买页推荐人信息:0-不隐藏;1-隐藏 | `0` |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/vip/qr_url' \
-H 'Authorization: Bearer ACCESS_TOKEN_PLACEHOLDER' \
--data-urlencode 'default_product_id=1' \
--data-urlencode 'open_device=DEVICE_ID_PLACEHOLDER' \
--data-urlencode 'hide_title=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:----------------|:--------|:--------------|
| state | boolean | 状态码,true 表示成功 |
| message | string | 响应信息 |
| code | int | 错误码 |
| data | object | 响应数据 |
| data.qrcode_url | string | 开放平台产品列表地址 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"qrcode_url": "PRODUCT_LIST_URL_PLACEHOLDER"
}
}
```
### 注意事项
- 接口根据 `access_token` 识别授权信息,调用方无需额外传入 115 账号和 AppID。
- `open_device` 不能为空;未传或传入空值时,接口返回参数错误。
- `default_product_id` 未传或为空时,服务端不指定默认产品。
- `hide_title` 大于 0 时,服务端会将该参数传递给产品列表服务。
- `access_token` 属于敏感凭证,不得写入公开仓库、客户端日志或公开沟通内容。
### 3. 场景触发与收益
完成接口接入后,开发者可以在用户使用权限不足、长期存储空间不足或云下载配额不足时触发对应引导。用户完成购买后,开发者可以获得对应推广收益。
## 四、收益管理与结算
### 1. 收益查看与提现
开发者可以登录“115生活-生活-联盟”,或直接访问[115联盟](https://union.115.com/),查看收益明细等推广收益情况并进行提现操作。
![推广收益管理页](图片/推广产品得收益/推广收益管理页.png)
### 2. 规则说明
收益计算方式、结算方式和提现流程等详情,参见[联盟规则](https://union.115.com/?ac=help&i=10)。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----------------|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
| 2026年08月05日(周三) 17:30:01 | 恢复业务说明、接入流程及示例图片 |
| 2026年08月19日(周三) 11:00:08 | 长期VIP默认产品名称改为至尊版 |
@@ -0,0 +1,79 @@
## 删除或清空回收站
### 基本信息
| 属性 | 内容 |
|:-----------|:--------------------------------|
| 接口名称 | 删除或清空回收站 |
| 接口版本 | v1.0 |
| 接口路径 | /del |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
批量彻底删除回收站中的文件(夹),或在不传 `tid` 时清空回收站。
### 接口地址
```
https://proapi.115.com/open/rb/del
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----|:-------|:---|:----|:--------------------------------|:------|
| tid | string | 否 | "" | 需要删除的回收站ID,多个ID用半角逗号分隔,最多 1150 个;不传时清空回收站 | 1,2,3,4 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/rb/del' \
-H 'Authorization: Bearer access_token' \
--form-string 'tid=1,2,3,4'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------|:---------|:--------------|
| state | boolean | 状态码,true 表示成功 |
| message | string | 错误信息 |
| code | int | 错误码 |
| data | string[] | 响应数据 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 注意事项
- `access_token` 由开放平台授权流程获取,并通过 `Authorization` 请求头传递。
- 不传 `tid` 会清空当前授权用户的整个回收站,操作后无法恢复。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,80 @@
## 删除文件
### 基本信息
| 属性 | 内容 |
|:-----------|:--------------------------------|
| 接口名称 | 删除文件 |
| 接口版本 | v1.0 |
| 接口路径 | /delete |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
批量删除文件(夹),删除操作异步执行并将目标移入回收站。
### 接口地址
```
https://proapi.115.com/open/ufile/delete
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------|:-------|:---|:----|:-----------------------|:-------------------------------------------|
| file_ids | string | 是 | - | 需要删除的文件(夹)ID,多个ID用半角逗号分隔 | 3073323042143855813,3073323042143855822 |
| parent_id | string | 否 | 0 | 待删除文件(夹)所在的父目录ID | 3073311192547189943 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/ufile/delete' \
-H 'Authorization: Bearer access_token' \
--form-string 'file_ids=3073323042143855813,3073323042143855822' \
--form-string 'parent_id=3073311192547189943'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------|:---------|:-----------------|
| state | boolean | 状态码,true 表示请求已受理 |
| message | string | 错误信息 |
| code | int | 错误码 |
| data | string[] | 响应数据 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 注意事项
- `access_token` 由开放平台授权流程获取,并通过 `Authorization` 请求头传递。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,126 @@
## 回收站列表
### 基本信息
| 属性 | 内容 |
|:-----------|:--------------------------------|
| 接口名称 | 回收站列表 |
| 接口版本 | v1.0 |
| 接口路径 | /list |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
分页获取当前授权用户的回收站文件(夹)列表。
### 接口地址
```
https://proapi.115.com/open/rb/list
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-------|:----|:---|:----|:--------|:------|
| limit | int | 否 | 30 | 单页记录数 | 最大 200 |
| offset | int | 否 | 0 | 数据显示偏移量 | 0 |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/rb/list' \
-H 'Authorization: Bearer access_token' \
--data-urlencode 'limit=30' \
--data-urlencode 'offset=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:----------------------|:--------|:-------------------------------|
| state | boolean | 状态码,true 表示成功 |
| message | string | 错误信息 |
| code | int | 错误码 |
| data | object | 响应数据 |
| data.offset | int | 数据显示偏移量 |
| data.limit | int | 单页记录数 |
| data.count | string | 回收站文件(夹)总数 |
| data.rb_pass | int | 是否设置回收站密码:1-是,0-否 |
| data.{回收站ID} | object | 以回收站ID为键的文件(夹)信息 |
| data.{回收站ID}.id | string | 回收站ID |
| data.{回收站ID}.file_name | string | 文件(夹)名称 |
| data.{回收站ID}.type | string | 类型:1-文件,2-文件夹 |
| data.{回收站ID}.file_size | string | 文件大小,单位为字节 |
| data.{回收站ID}.dtime | string | 删除时间 |
| data.{回收站ID}.thumb_url | string | 缩略图地址 |
| data.{回收站ID}.status | string | 还原状态:-1-还原中,0-正常 |
| data.{回收站ID}.cid | int | 原文件(夹)的父目录ID |
| data.{回收站ID}.parent_name | string | 原文件(夹)的父目录名称 |
| data.{回收站ID}.pick_code | string | 文件提取码 |
| data.{回收站ID}.isv | int | 是否为视频文件,按文件类型返回 |
| data.{回收站ID}.def2 | int | 视频清晰度,按文件类型返回 |
| data.{回收站ID}.ico | string | 文件扩展名,按文件类型返回 |
| data.{回收站ID}.muc | string | 音频封面地址,按文件类型返回 |
| data.{回收站ID}.d_img | string | 文档缩略图地址,按文件类型返回 |
| data.{回收站ID}.play_long | int | 音视频时长,按文件类型返回 |
| data.{回收站ID}.sha1 | string | 文件 SHA1 值,按文件类型返回 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"offset": 0,
"limit": 30,
"count": "0",
"rb_pass": 0,
"3074054555277845747": {
"id": "3074054555277845747",
"file_name": "",
"type": "1",
"file_size": "0",
"dtime": "",
"thumb_url": "",
"status": "0",
"cid": 0,
"parent_name": "",
"pick_code": "",
"isv": 0,
"def2": 0,
"ico": "",
"muc": "",
"d_img": "",
"play_long": 0,
"sha1": ""
}
}
}
```
### 注意事项
- `access_token` 由开放平台授权流程获取,并通过 `Authorization` 请求头传递。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
| 2026年08月05日(周三) 17:15:28 | 补充单页记录数上限 |
@@ -0,0 +1,78 @@
## 回收站还原
### 基本信息
| 属性 | 内容 |
|:-----------|:--------------------------------|
| 接口名称 | 回收站还原 |
| 接口版本 | v1.0 |
| 接口路径 | /revert |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
批量还原回收站中的文件(夹)。
### 接口地址
```
https://proapi.115.com/open/rb/revert
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----|:-------|:---|:----|:---------------------------|:--------------|
| tid | string | 是 | - | 需要还原的回收站ID,多个ID用半角逗号分隔,最多 1150 个 | 111,222,333,444 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/rb/revert' \
-H 'Authorization: Bearer access_token' \
--form-string 'tid=111,222,333,444'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------|:---------|:--------------|
| state | boolean | 状态码,true 表示成功 |
| message | string | 错误信息 |
| code | int | 错误码 |
| data | string[] | 响应数据 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 注意事项
- `access_token` 由开放平台授权流程获取,并通过 `Authorization` 请求头传递。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,87 @@
## 文件(夹)更新
### 基本信息
| 属性 | 内容 |
|:-----------|:--------------------------------|
| 接口名称 | 文件(夹)更新 |
| 接口版本 | v1.0 |
| 接口路径 | /update |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
更新文件(夹)名称或星标状态。`file_name` 与 `star` 至少传入一个。
### 接口地址
```
https://proapi.115.com/open/ufile/update
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:---------|:-------|:---|:----|:----------------------|:------------------|
| file_id | string | 是 | - | 需要更新的文件(夹)ID | 3073323042143855813 |
| file_name | string | 否 | null | 新的文件(夹)名称,文件夹名称限制 255 字节 | 新的名字 |
| star | int | 否 | null | 是否星标:1-星标,0-取消星标 | 1 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/ufile/update' \
-H 'Authorization: Bearer access_token' \
--form-string 'file_id=3073323042143855813' \
--form-string 'file_name=新的名字' \
--form-string 'star=1'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:---------------|:--------|:------------------|
| state | boolean | 状态码,true 表示成功 |
| message | string | 错误信息 |
| code | int | 错误码 |
| data | object | 响应数据 |
| data.file_name | string | 更新后的文件(夹)名称 |
| data.star | int | 更新后的星标状态:1-星标,0-取消星标 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"file_name": "新的名字",
"star": 1
}
}
```
### 注意事项
- `access_token` 由开放平台授权流程获取,并通过 `Authorization` 请求头传递。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,41 @@
# 上传流程
### 基本信息
| 属性 | 内容 |
|:---------|:------------------------------|
| 文档名称 | 上传流程 |
| 文档版本 | v1.0 |
| 适用场景 | 115开放平台文件上传 |
### 文档说明
本文档说明 115 开放平台的文件秒传、普通上传和断点续传流程。
## 流程概览
1. 请求「文件上传」接口初始化上传。
2. 若响应中 `status=2`,表示秒传成功,上传流程结束。
3. 若响应提示需要二次认证,按 `sign_check` 指定的字节范围计算大写 SHA1,然后携带 `sign_key` 和 `sign_val` 重新请求「文件上传」接口。
4. 若 `status=1`,携带初始化响应中的 `bucket`、`object`、`callback` 及「获取上传凭证」接口返回的凭证,向对象存储上传文件。
5. 需要续传时,携带初始化响应中的 `pick_code` 及待上传文件信息请求「断点续传」接口,获取新的对象存储上传参数。
6. 对象存储返回上传成功后,普通上传或断点续传完成。
## 相关接口
- [文件上传](文件上传.md)
- [获取上传凭证](获取上传凭证.md)
- [断点续传](断点续传.md)
- [阿里云 OSS 上传文件说明](https://help.aliyun.com/zh/oss/user-guide/upload-objects-to-oss/)
## 注意事项
- 对象存储上传不请求 `proapi.115.com`,应使用「获取上传凭证」和上传调度接口返回的域名、对象标识、临时凭证与回调参数发起请求。
- `sign_check` 的起止字节均在 SHA1 计算范围内。例如 `0-99` 表示计算共 100 字节的内容。
- 调用开放平台接口时必须携带 `Authorization: Bearer access_token`,并妥善保管临时上传凭证。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,141 @@
## 文件上传
### 基本信息
| 属性 | 内容 |
|:---------|:------------------------------|
| 接口名称 | 文件上传 |
| 接口版本 | v1.0 |
| 接口路径 | /upload/init |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
初始化断点续传调度,完成秒传判定、二次认证调度或返回对象存储上传参数。
### 接口地址
```
https://proapi.115.com/open/upload/init
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------|:-----|:---|:----|:---------------------------|:--------------------|
| file_name | string | 是 | - | 文件名 | 图片.jpg |
| file_size | int | 是 | - | 文件大小,单位为字节 | 5335 |
| target | string | 是 | - | 文件上传目标 | `U_1_0`,格式为 `U_1_<文件夹ID>` |
| fileid | string | 是 | - | 文件 SHA1 值 | `<FILE_SHA1>` |
| preid | string | 否 | "" | 文件前 128 KiB 内容的 SHA1 值 | `<PREID_SHA1>` |
| path | string | 否 | "" | 上传路径 | "" |
| pick_code | string | 否 | "" | 上传任务唯一标识,用于续传 | `<PICK_CODE>` |
| topupload | int | 否 | "" | 上传调度文件类型标记,见下方枚举表格 | 0 |
| sign_key | string | 否 | "" | 二次认证标识 | `<SIGN_KEY>` |
| sign_val | string | 否 | "" | 根据 `sign_check` 计算的大写 SHA1 值 | `<SIGN_VAL>` |
#### 请求参数中的 topupload 字段枚举
| 值 | 说明 | 备注 |
|:---|:---------------------------|:---|
| -1 | 没有上传调度文件类型标记 | - |
| 0 | 单文件上传任务,记录一条独立上传记录 | - |
| 1 | 文件夹任务的第一个子文件,记录一次文件夹上传 | - |
| 2 | 文件夹任务的其他子文件,不单独记录上传记录 | - |
### 请求示例
```shell
curl 'https://proapi.115.com/open/upload/init' \
-H 'Authorization: Bearer <ACCESS_TOKEN>' \
-F 'file_name=图片.jpg' \
-F 'file_size=5335' \
-F 'target=U_1_0' \
-F 'fileid=<FILE_SHA1>' \
-F 'topupload=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------------------------|:------|:----------------------------------------|
| state | boolean | 状态码,是表示成功,否表示异常 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object | 上传调度数据 |
| data.status | int | 上传状态:1-非秒传 2-秒传 |
| data.code | int | 上传调度状态码 |
| data.pick_code | string | 上传任务唯一标识,用于续传 |
| data.target | string | 文件上传目标 |
| data.bucket | string | 对象存储 bucket |
| data.object | string | OSS 对象标识 |
| data.callback | object | 上传完成回调数据 |
| data.callback.callback | string | 上传完成回调信息 |
| data.callback.callback_var | string | 上传完成回调参数 |
| data.sign_key | string | 本次二次认证的 SHA1 标识 |
| data.sign_check | string | 二次认证所需本地文件 SHA1 计算的字节范围 |
| data.file_id | string | 秒传成功时新增文件 ID |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"status": 1,
"code": 0,
"pick_code": "",
"target": "U_1_0",
"bucket": "",
"object": "",
"callback": {
"callback": "",
"callback_var": ""
},
"sign_key": "",
"sign_check": "",
"file_id": ""
}
}
```
### 业务规则
- `target` 必须匹配 `U_1_<数字文件夹ID>`;`U_1_0` 表示网盘根目录。
- 非 VIP 用户单文件大小不得超过 5 GiB;尝鲜VIP、体验VIP用户单文件大小不得超过 15 GiB。
- 上传前会检查用户剩余空间和盗播上传封禁状态。
- 二次认证调度结果见下表。
| code | status | 说明 | 后续处理 |
|:-----|:-------|:---------|:------|
| 700 | 6 | 签名认证后失败 | 按 `sign_check` 截取包含起止字节的文件内容计算大写 SHA1,再传入 `sign_key` 和 `sign_val` |
| 701 | 7 | 需要认证签名 | 按 `sign_check` 截取包含起止字节的文件内容计算大写 SHA1,再传入 `sign_key` 和 `sign_val` |
| 702 | 8 | 签名认证失败 | 按 `sign_check` 截取包含起止字节的文件内容计算大写 SHA1,再传入 `sign_key` 和 `sign_val` |
### 注意事项
- `sign_check` 格式为 `起始字节-结束字节`,起止字节均包含在 SHA1 计算范围内;例如 `2392148-2392298` 需计算该范围内文件内容的 SHA1。
- 省略 `topupload` 时,服务端会将其归一化为空字符串;显式传入枚举值时会归一化为整数。
- 请妥善保管 `access_token`、上传凭证与回调数据,不要在日志或客户端可见信息中输出。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,110 @@
## 断点续传
### 基本信息
| 属性 | 内容 |
|:---------|:------------------------------|
| 接口名称 | 断点续传 |
| 接口版本 | v1.0 |
| 接口路径 | /upload/resume |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
根据已有上传任务和待上传文件信息,获取断点续传所需的对象存储上传参数。
### 接口地址
```
https://proapi.115.com/open/upload/resume
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------|:-----|:---|:----|:------------------|:--------------------|
| file_size | int | 是 | - | 文件大小,单位为字节 | 5335 |
| target | string | 是 | - | 文件上传目标 | `U_1_0`,格式为 `U_1_<文件夹ID>` |
| fileid | string | 是 | - | 文件 SHA1 值 | `<FILE_SHA1>` |
| pick_code | string | 是 | - | 上传任务唯一标识 | `<PICK_CODE>` |
### 请求示例
```shell
curl 'https://proapi.115.com/open/upload/resume' \
-H 'Authorization: Bearer <ACCESS_TOKEN>' \
-F 'file_size=5335' \
-F 'target=U_1_0' \
-F 'fileid=<FILE_SHA1>' \
-F 'pick_code=<PICK_CODE>'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------------------------|:------|:----------------------|
| state | boolean | 状态码,是表示成功,否表示异常 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object | 续传调度数据 |
| data.version | string | 上传接口版本 |
| data.target | string | 文件上传目标 |
| data.pick_code | string | 上传任务唯一标识 |
| data.bucket | string | 对象存储 bucket |
| data.object | string | OSS 对象标识 |
| data.callback | object | 上传完成回调数据 |
| data.callback.callback | string | 上传完成回调信息 |
| data.callback.callback_var | string | 上传完成回调参数 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"version": "",
"target": "U_1_0",
"pick_code": "",
"bucket": "",
"object": "",
"callback": {
"callback": "",
"callback_var": ""
}
}
}
```
### 业务规则
- `target` 必须匹配 `U_1_<数字文件夹ID>`;`U_1_0` 表示网盘根目录。
- 非 VIP 用户单文件大小不得超过 5 GiB;尝鲜VIP、体验VIP用户单文件大小不得超过 15 GiB。
- 续传前会检查用户剩余空间和盗播上传封禁状态。
- 上游调度结果的 `status` 只有为 1 或 2 时才按成功响应返回续传参数。
### 注意事项
- `pick_code` 必须来自原上传初始化调度响应,并与当前文件信息匹配。
- 请妥善保管 `access_token`、上传凭证与回调数据,不要在日志或客户端可见信息中输出。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,87 @@
## 获取上传凭证
### 基本信息
| 属性 | 内容 |
|:---------|:------------------------------|
| 接口名称 | 获取上传凭证 |
| 接口版本 | v1.0 |
| 接口路径 | /upload/get_token |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
获取对象存储上传域名和临时上传凭证。
### 接口地址
```
https://proapi.115.com/open/upload/get_token
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
无
### 请求示例
```shell
curl 'https://proapi.115.com/open/upload/get_token' \
-H 'Authorization: Bearer <ACCESS_TOKEN>'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-------------------|:------|:---------------|
| state | boolean | 状态码,是表示成功,否表示异常 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object | 上传凭证数据 |
| data.endpoint | string | 上传域名 |
| data.AccessKeySecret | string | 临时上传凭证密钥 |
| data.SecurityToken | string | 临时安全令牌 |
| data.Expiration | string | 上传凭证过期时间 |
| data.AccessKeyId | string | 临时上传凭证 ID |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"endpoint": "",
"AccessKeySecret": "",
"SecurityToken": "",
"Expiration": "",
"AccessKeyId": ""
}
}
```
### 注意事项
- 网页版文档中的密钥字段名存在拼写误差,服务端实际返回字段为 `AccessKeySecret`。
- 上传凭证为敏感信息,仅用于当前上传流程;不要写入日志、持久化存储或对外暴露。
- 服务端通过当前 `access_token` 识别用户,不接收用户账号请求参数。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,84 @@
## 文件复制
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 文件复制 |
| 接口版本 | v1.0 |
| 接口路径 | /copy |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
将一个或多个文件、文件夹复制到指定目录。
### 接口地址
```
https://proapi.115.com/open/ufile/copy
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:--------|:-------|:---|:----|:------------------------------------|:--------------------|
| pid | string | 否 | "0" | 目标目录ID,根目录ID为 `0` | 1054251402869818368 |
| file_id | string | 是 | - | 待复制的文件或文件夹ID,多个ID使用半角逗号分隔 | 2323423573680609857 |
| nodupli | int | 否 | 0 | 目标目录是否不允许同名:0-允许,1-不允许 | 1 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/ufile/copy' \
-H 'Authorization: Bearer access_token' \
--form-string 'pid=1054251402869818368' \
--form-string 'file_id=2323423573680609857' \
--form-string 'nodupli=1'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------|:---------|:--------------------|
| state | boolean | 接口状态,true表示成功 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object[] | 响应数据 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 注意事项
- `pid` 不传或传 `0` 时复制到根目录。
- 接口受文件操作频率限制;触发限制时返回异常。
- `user_id` 由服务端根据 access token 获取,无需传入。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,121 @@
## 文件搜索
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 文件搜索 |
| 接口版本 | v1.0 |
| 接口路径 | /search |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
根据文件名搜索文件或文件夹,支持按目录、文件标签、时间范围和文件类型筛选。
### 接口地址
```
https://proapi.115.com/open/ufile/search
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:------------|:-------|:---|:----|:----------------------------------------------------------|:---------|
| search_value | string | 否 | "" | 搜索关键词;与 `file_label` 至少传一个,最多取前40个字符 | "文件" |
| limit | int | 否 | 20 | 单页记录数;`offset + limit` 最大不超过10000 | 20 |
| offset | int | 否 | 0 | 数据显示偏移量 | 0 |
| file_label | string | 否 | "" | 文件标签;与 `search_value` 至少传一个 | "1" |
| cid | int | 否 | 0 | 目标目录ID;`-1` 表示不返回任何列表内容 | 0 |
| gte_day | string | 否 | "" | 搜索结果匹配的开始日期 | 2020-11-19 |
| lte_day | string | 否 | "" | 搜索结果匹配的结束日期 | 2020-11-20 |
| fc | int | 否 | 0 | 显示类型:1-只显示文件夹,2-只显示文件,0-全部 | 0 |
| type | int | 否 | 0 | 一级筛选大分类,见下方枚举表格 | 1 |
| suffix | string | 否 | "" | 一级筛选选择“其他”时填写的后缀名 | "pdf" |
#### 请求参数中的 type 字段枚举
| 值 | 说明 | 备注 |
|:--|:----|:---|
| 1 | 文档 | - |
| 2 | 图片 | - |
| 3 | 音频 | - |
| 4 | 视频 | - |
| 5 | 压缩包 | - |
| 6 | 应用 | - |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/ufile/search' \
-H 'Authorization: Bearer access_token' \
--data-urlencode 'search_value=文件' \
--data-urlencode 'limit=20' \
--data-urlencode 'offset=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-------------------|:---------|:----------------------------------------|
| count | int | 符合条件的文件或文件夹总数 |
| data | object[] | 文件或文件夹列表 |
| data[].file_id | string | 文件或文件夹ID |
| data[].user_id | string | 115账号 |
| data[].sha1 | string | 文件SHA-1值 |
| data[].file_name | string | 文件或文件夹名称 |
| data[].file_size | string | 文件大小 |
| data[].user_ptime | string | 上传时间 |
| data[].user_utime | string | 更新时间 |
| data[].pick_code | string | 文件提取码 |
| data[].parent_id | string | 父目录ID |
| data[].area_id | string | 文件状态:1-正常,7-已删除(回收站),120-彻底删除 |
| data[].is_private | int | 文件是否隐藏:0-未隐藏,1-已隐藏 |
| data[].file_category | string | 文件属性:1-文件,0-文件夹 |
| data[].ico | string | 文件后缀 |
| limit | int | 分页数量 |
| offset | int | 偏移量 |
| state | boolean | 接口状态,true表示成功 |
| message | string | 异常信息 |
| code | int | 异常码 |
### 响应示例
```json
{
"count": 0,
"data": [],
"limit": 20,
"offset": 0,
"state": true,
"message": "",
"code": 0
}
```
### 注意事项
- `search_value` 与 `file_label` 至少传一个;搜索关键词最多取前40个字符。
- 当 `offset + limit` 超过10000时,服务端按 `offset=0`、`limit=115` 查询。
- 搜索结果会过滤不属于正常区域的文件;当前账号未开启隐藏文件展示时,也会过滤隐藏文件。
- `user_id` 由服务端根据 access token 获取,无需传入。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,91 @@
## 文件移动
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 文件移动 |
| 接口版本 | v1.0 |
| 接口路径 | /move |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
将一个或多个文件、文件夹移动到指定目录。
### 接口地址
```
https://proapi.115.com/open/ufile/move
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:---------|:-------|:---|:----|:------------------------------------|:----------------------------------------|
| file_ids | string | 是 | - | 待移动的文件或文件夹ID,多个ID使用半角逗号分隔 | 3073323042143855813,3073323042143855822 |
| to_cid | string | 否 | "0" | 目标目录ID;`0` 表示根目录,非 `0` 时必须指向正常可用的目录 | 3073311192547189943 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/ufile/move' \
-H 'Authorization: Bearer access_token' \
--form-string 'file_ids=3073323042143855813,3073323042143855822' \
--form-string 'to_cid=3073311192547189943'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------|:---------|:--------------------|
| state | boolean | 接口状态,true表示成功 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object[] | 响应数据 |
#### 响应的 code 字段(错误码)说明
| 错误码 | 说明 | 解决方案 |
|:-------|:-----------------------------------|:-----------------------------|
| 20009 | 目标目录不存在或目标ID不是目录 | 确认 to_cid 指向存在的目录 |
| 20018 | 目标目录不在正常区域或已经删除 | 选择正常可用的目标目录 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 注意事项
- `to_cid` 不传或传 `0` 时移动到根目录。
- `to_cid` 非 `0` 时,目标必须是正常可用的目录;目标不存在、不是目录或已经删除时,移动失败。
- 接口受文件操作频率限制;触发限制时返回异常。
- `user_id` 由服务端根据 access token 获取,无需传入。
### 修改历史
| 修改时间 | 修改说明 |
|:---------------------------------|:---------------------------------------|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
| 2026年08月14日(周五) 15:38:44 | 补充移动目标目录有效性约束及错误码 |
@@ -0,0 +1,86 @@
## 新建文件夹
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 新建文件夹 |
| 接口版本 | v1.0 |
| 接口路径 | /add |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
在指定父目录下新建文件夹。
### 接口地址
```
https://proapi.115.com/open/folder/add
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:---------|:-------|:---|:----|:---------------------------|:--------------------|
| pid | string | 否 | "0" | 父目录ID,根目录ID为 `0` | 3073323042143855813 |
| file_name | string | 是 | - | 文件夹名称,最多255个字符 | 新建文件夹名称 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/folder/add' \
-H 'Authorization: Bearer access_token' \
--form-string 'pid=3073323042143855813' \
--form-string 'file_name=新建文件夹名称'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:---------------|:--------|:----------------------|
| state | boolean | 接口状态,true表示成功 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object | 响应数据 |
| data.file_name | string | 新建的文件夹名称 |
| data.file_id | string | 新建的文件夹ID |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"file_name": "",
"file_id": ""
}
}
```
### 注意事项
- `pid` 不传或传 `0` 时在根目录下新建文件夹。
- `user_id` 由服务端根据 access token 获取,无需传入。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,114 @@
## 按ID获取
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 按ID获取 |
| 接口版本 | v1.0 |
| 接口路径 | /get_info |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
根据文件或文件夹ID获取详情。
### 接口地址
```
https://proapi.115.com/open/folder/get_info
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:---------|:-------|:---|:----|:----------|:--------------------|
| file_id | string | 是 | - | 文件或文件夹ID | 1288444975268439877 |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/folder/get_info' \
-H 'Authorization: Bearer access_token' \
--data-urlencode 'file_id=1288444975268439877'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:----------------------|:---------|:----------------------------------------|
| state | boolean | 接口状态,true表示成功 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object | 文件或文件夹详情 |
| data.count | int | 包含的文件总数 |
| data.size | string | 文件或文件夹总大小 |
| data.size_byte | int | 文件或文件夹总大小,单位为字节 |
| data.folder_count | int | 包含的文件夹总数 |
| data.play_long | int | 视频时长;`-1` 表示正在统计,其他数值单位为秒 |
| data.show_play_long | int | 是否开启展示视频时长 |
| data.ptime | string | 上传时间 |
| data.utime | string | 修改时间 |
| data.file_name | string | 文件或文件夹名称 |
| data.pick_code | string | 文件提取码 |
| data.sha1 | string | 文件SHA-1值 |
| data.file_id | string | 文件或文件夹ID |
| data.is_mark | string | 是否星标 |
| data.open_time | int | 文件或文件夹最近打开时间 |
| data.file_category | string | 文件属性:1-文件,0-文件夹 |
| data.paths | object[] | 文件或文件夹所在路径 |
| data.paths[].file_id | string | 父目录ID |
| data.paths[].file_name | string | 父目录名称 |
| data.paths[].iss | int | 父目录共享状态标识 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"count": 0,
"size": "",
"size_byte": 0,
"folder_count": 0,
"play_long": 0,
"show_play_long": 0,
"ptime": "",
"utime": "",
"file_name": "",
"pick_code": "",
"sha1": "",
"file_id": "",
"is_mark": "",
"open_time": 0,
"file_category": "",
"paths": []
}
}
```
### 注意事项
- 文件或文件夹已进入回收站或被彻底删除时,接口返回异常。
- `user_id` 由服务端根据 access token 获取,无需传入。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,115 @@
## 按路径获取
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 按路径获取 |
| 接口版本 | v1.0 |
| 接口路径 | /get_info |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
根据文件或文件夹路径获取详情。
### 接口地址
```
https://proapi.115.com/open/folder/get_info
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----|:-------|:---|:----|:----------------------------------------------------------------|:-------------------|
| path | string | 是 | - | 文件路径,支持 `/`、`>` 两种分隔符;路径需以分隔符开头,并用同一分隔符分隔目录层级 | /a/b/c.png 或 >a>b>c |
### 请求示例
```shell
curl 'https://proapi.115.com/open/folder/get_info' \
-H 'Authorization: Bearer access_token' \
--form-string 'path=/a/b/c.png'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:----------------------|:---------|:----------------------------------------|
| state | boolean | 接口状态,true表示成功 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object | 文件或文件夹详情 |
| data.count | int | 包含的文件总数 |
| data.size | string | 文件或文件夹总大小 |
| data.size_byte | int | 文件或文件夹总大小,单位为字节 |
| data.folder_count | int | 包含的文件夹总数 |
| data.play_long | int | 视频时长;`-1` 表示正在统计,其他数值单位为秒 |
| data.show_play_long | int | 是否开启展示视频时长 |
| data.ptime | string | 上传时间 |
| data.utime | string | 修改时间 |
| data.file_name | string | 文件或文件夹名称 |
| data.pick_code | string | 文件提取码 |
| data.sha1 | string | 文件SHA-1值 |
| data.file_id | string | 文件或文件夹ID |
| data.is_mark | string | 是否星标 |
| data.open_time | int | 文件或文件夹最近打开时间 |
| data.file_category | string | 文件属性:1-文件,0-文件夹 |
| data.paths | object[] | 文件或文件夹所在路径 |
| data.paths[].file_id | string | 父目录ID |
| data.paths[].file_name | string | 父目录名称 |
| data.paths[].iss | int | 父目录共享状态标识 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"count": 0,
"size": "",
"size_byte": 0,
"folder_count": 0,
"play_long": 0,
"show_play_long": 0,
"ptime": "",
"utime": "",
"file_name": "",
"pick_code": "",
"sha1": "",
"file_id": "",
"is_mark": "",
"open_time": 0,
"file_category": "",
"paths": []
}
}
```
### 注意事项
- 文件或文件夹已进入回收站或被彻底删除时,接口返回异常。
- `user_id` 由服务端根据 access token 获取,无需传入。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,98 @@
## 获取文件下载地址
### 基本信息
| 属性 | 内容 |
|:-----------|:--------------------------------|
| 接口名称 | 获取文件下载地址 |
| 接口版本 | v1.0 |
| 接口路径 | /downurl |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
根据文件提取码获取文件下载地址。
### 接口地址
```
https://proapi.115.com/open/ufile/downurl
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:---------|:-------|:---|:----|:------|:-----------------|
| pick_code | string | 是 | - | 文件提取码,多个提取码用半角逗号分隔 | dtctprlmfkl4exiok |
### 请求示例
```shell
curl 'https://proapi.115.com/open/ufile/downurl' \
-H 'Authorization: Bearer access_token' \
--form-string 'pick_code=dtctprlmfkl4exiok'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:---------------------------|:--------|:---------------------------|
| state | boolean | 状态码,true 表示成功 |
| message | string | 错误信息 |
| code | int | 错误码 |
| errno | int | 下载地址获取失败时返回的错误码 |
| data | object | 响应数据 |
| data.{文件ID} | object | 以文件ID为键的文件下载信息 |
| data.{文件ID}.file_name | string | 文件名 |
| data.{文件ID}.file_size | int | 文件大小,单位为字节 |
| data.{文件ID}.pick_code | string | 文件提取码 |
| data.{文件ID}.sha1 | string | 文件 SHA1 值 |
| data.{文件ID}.url | object | 下载地址信息 |
| data.{文件ID}.url.url | string | 文件下载地址 |
| data.can_appeal | boolean | 文件违规时是否可以申诉,按错误场景返回 |
| data.want_appeal_id | string | 文件违规时的申诉标识,按错误场景返回 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"2323423573680609857": {
"file_name": "",
"file_size": 0,
"pick_code": "",
"sha1": "",
"url": {
"url": ""
}
}
}
}
```
### 注意事项
- `access_token` 由开放平台授权流程获取,并通过 `Authorization` 请求头传递。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,204 @@
## 获取文件列表
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 获取文件列表 |
| 接口版本 | v1.0 |
| 接口路径 | /files |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
获取指定目录中的文件和文件夹列表,支持分页、排序以及按文件类型、后缀名和星标状态筛选。
### 接口地址
```
https://proapi.115.com/open/ufile/files
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-----------|:-------|:---|:----------|:-------------------------------------------------------|:----------|
| cid | string | 否 | "0" | 目录ID,对应 `parent_id`;根目录ID为 `0` | "0" |
| type | int | 否 | 0 | 文件类型,见下方枚举表格 | 1 |
| limit | int | 否 | 20 | 查询数量,最大1150 | 20 |
| offset | int | 否 | 0 | 查询起始位置 | 0 |
| suffix | string | 否 | "" | 文件后缀名 | "pdf" |
| asc | int | 否 | 0 | 排序方向:1-升序,0-降序 | 0 |
| o | string | 否 | user_ptime | 排序字段,见下方枚举表格 | file_name |
| custom_order | int | 否 | 0 | 排序模式,见下方枚举表格 | 0 |
| stdir | int | 否 | 0 | 筛选文件时是否显示文件夹:1-显示,0-不显示 | 1 |
| star | int | 否 | 0 | 星标筛选:1-仅显示星标文件,0-全部 | 0 |
| cur | int | 否 | 0 | 是否只显示当前文件夹内的文件:1-是,0-否 | 1 |
| show_dir | int | 否 | 0 | 是否显示目录:1-是,0-否 | 0 |
#### 请求参数中的 type 字段枚举
| 值 | 说明 | 备注 |
|:--|:----|:---|
| 1 | 文档 | - |
| 2 | 图片 | - |
| 3 | 音频 | - |
| 4 | 视频 | - |
| 5 | 压缩包 | - |
| 6 | 应用 | - |
| 7 | 书籍 | - |
#### 请求参数中的 o 字段枚举
| 值 | 说明 | 备注 |
|:-----------|:-------|:---|
| file_name | 文件名 | - |
| file_size | 文件大小 | - |
| user_ptime | 上传时间 | 默认值 |
| user_utime | 更新时间 | - |
| file_type | 文件类型 | - |
#### 请求参数中的 custom_order 字段枚举
| 值 | 说明 | 备注 |
|:--|:----------------------|:---|
| 0 | 使用记忆排序,自定义排序失效 | 默认值 |
| 1 | 使用自定义排序,不使用记忆排序 | - |
| 2 | 使用自定义排序,非文件夹置顶 | - |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/ufile/files' \
-H 'Authorization: Bearer access_token' \
--data-urlencode 'cid=0' \
--data-urlencode 'limit=20' \
--data-urlencode 'offset=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:----------------------|:---------|:----------------------------------------------------------|
| data | object[] | 文件和文件夹列表 |
| data[].fid | string | 文件或文件夹ID |
| data[].aid | string | 文件状态:1-正常,7-已删除(回收站),120-彻底删除 |
| data[].pid | string | 父目录ID |
| data[].fc | string | 文件分类:0-文件夹,1-文件 |
| data[].fn | string | 文件或文件夹名称 |
| data[].fco | string | 文件夹封面 |
| data[].ism | string | 是否星标,1表示星标 |
| data[].isp | int | 是否加密,1表示加密 |
| data[].pc | string | 文件提取码 |
| data[].upt | int | 修改时间 |
| data[].uet | int | 修改时间 |
| data[].uppt | int | 上传时间 |
| data[].cm | int | 特殊目录标识 |
| data[].fdesc | string | 文件备注 |
| data[].ispl | int | 是否统计文件夹下视频时长 |
| data[].fl | object[] | 文件标签 |
| data[].fl[].id | string | 文件标签ID |
| data[].fl[].name | string | 文件标签名称 |
| data[].fl[].sort | string | 文件标签排序 |
| data[].fl[].color | string | 文件标签颜色 |
| data[].fl[].is_default | int | 文件标签类型:0-最近使用,1-非最近使用,2-默认标签 |
| data[].fl[].update_time | int | 文件标签更新时间 |
| data[].fl[].create_time | int | 文件标签创建时间 |
| data[].sha1 | string | 文件SHA-1值 |
| data[].fs | int | 文件大小,单位为字节 |
| data[].fta | string | 文件状态:0或2-未上传完成,1-已上传完成 |
| data[].ico | string | 文件后缀名 |
| data[].fatr | string | 音频长度 |
| data[].isv | int | 是否为视频 |
| data[].def | int | 视频清晰度:1-标清,2-高清,3-超清,4-1080P,5-4K,100-原画 |
| data[].def2 | int | 视频清晰度:1-标清,2-高清,3-超清,4-1080P,5-4K,100-原画 |
| data[].play_long | int | 音视频时长,单位为秒 |
| data[].v_img | string | 视频缩略图地址 |
| data[].thumb | string | 图片缩略图地址 |
| data[].uo | string | 原图地址 |
| count | int | 当前目录文件数量 |
| sys_count | int | 系统文件夹数量 |
| offset | int | 偏移量 |
| limit | int | 分页数量 |
| aid | string | 文件状态:1-正常,7-已删除(回收站),120-彻底删除 |
| cid | int | 父目录ID |
| is_asc | int | 排序方向:1-升序,0-降序 |
| min_size | int | 最小文件大小筛选值 |
| max_size | int | 最大文件大小筛选值 |
| sys_dir | string | 系统目录 |
| hide_data | string | 是否返回文件数据 |
| record_open_time | string | 是否记录文件夹打开时间 |
| star | int | 是否星标:1-星标,0-未星标 |
| type | int | 一级筛选大分类,见请求参数中的 `type` 字段枚举 |
| suffix | string | 一级筛选选择“其他”时填写的后缀名 |
| path | object[] | 父目录树 |
| path[].name | string | 父目录名称 |
| path[].aid | int | 父目录文件状态 |
| path[].cid | int | 父目录ID |
| path[].pid | int | 上级父目录ID |
| path[].isp | int | 父目录是否加密 |
| path[].p_cid | string | 父目录路径标识 |
| path[].fv | string | 父目录属性 |
| cur | int | 是否只显示当前文件夹内的文件 |
| stdir | int | 筛选文件时是否显示文件夹 |
| fields | string | 指定返回字段 |
| order | string | 实际使用的排序字段 |
| state | boolean | 接口状态,true表示成功 |
| code | int | 异常码 |
| message | string | 异常信息 |
### 响应示例
```json
{
"data": [],
"count": 0,
"sys_count": 0,
"offset": 0,
"limit": 20,
"aid": "1",
"cid": 0,
"is_asc": 0,
"min_size": 0,
"max_size": 0,
"sys_dir": "",
"hide_data": "",
"record_open_time": "",
"star": 0,
"type": 0,
"suffix": "",
"path": [],
"cur": 0,
"stdir": 0,
"fields": "",
"order": "user_ptime",
"state": true,
"code": 0,
"message": ""
}
```
### 注意事项
- `limit` 最大为1150。
- 当 `cid` 指向不存在或已删除的目录时,接口返回异常;当目录为加密目录时,不返回目录内容。
- `user_id` 由服务端根据 access token 获取,无需传入。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,140 @@
## 用户信息
### 基本信息
| 属性 | 内容 |
|:---------|:------------------------------|
| 接口名称 | 用户信息 |
| 接口版本 | v1.0 |
| 接口路径 | /user/info |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
获取当前授权用户的基本信息、网盘空间信息、VIP 等级信息以及第三方畅用权益信息。
### 接口地址
```
https://proapi.115.com/open/user/info
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
无
### 请求示例
```shell
curl 'https://proapi.115.com/open/user/info' \
-H 'Authorization: Bearer <ACCESS_TOKEN>'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:----------------------------------------|:------|:----------------------------------------|
| state | boolean | 状态码,是表示成功,否表示异常 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object | 响应数据 |
| data.user_id | int | 用户115账号 |
| data.user_name | string | 用户名称 |
| data.user_face_s | string | 小尺寸用户头像 |
| data.user_face_m | string | 中尺寸用户头像 |
| data.user_face_l | string | 大尺寸用户头像 |
| data.rt_space_info | object | 用户实时空间信息 |
| data.rt_space_info.all_total | object | 用户总空间 |
| data.rt_space_info.all_total.size | int | 用户总空间大小,单位为字节 |
| data.rt_space_info.all_total.size_format | string | 用户总空间大小,格式化文本 |
| data.rt_space_info.all_remain | object | 用户剩余空间 |
| data.rt_space_info.all_remain.size | int | 用户剩余空间大小,单位为字节 |
| data.rt_space_info.all_remain.size_format | string | 用户剩余空间大小,格式化文本 |
| data.rt_space_info.all_use | object | 用户已使用空间 |
| data.rt_space_info.all_use.size | int | 用户已使用空间大小,单位为字节 |
| data.rt_space_info.all_use.size_format | string | 用户已使用空间大小,格式化文本 |
| data.vip_info | object | 用户 VIP 等级信息 |
| data.vip_info.level_name | string | VIP 等级名称,见下方枚举表格 |
| data.vip_info.expire | int | VIP 过期时间戳,无 VIP 时为 0 |
| data.vip_info.tp_rights | object | 第三方畅用权益信息 |
| data.vip_info.tp_rights.is_tp_rights | int | 是否具有当前应用的第三方畅用权益:0-否 1-是 |
| data.vip_info.tp_rights.tp_rights_time | int | 第三方畅用权益过期时间戳,无有效权益时为 0 |
#### 响应的 data.vip_info.level_name 字段枚举
| 值 | 说明 | 备注 |
|:----------|:---|:---|
| 原石会员 | 原石用户 | 无有效 VIP 等级时的服务端固定返回值 |
| 尝鲜VIP | 尝鲜VIP | - |
| 体验VIP | 体验VIP | - |
| 月费VIP | 月费VIP | - |
| 年费VIP | 年费VIP | - |
| 长期VIP(高级版) | 长期VIP(高级版) | - |
| 长期VIP(特级版) | 长期VIP(特级版) | - |
| 长期VIP(超级版) | 长期VIP(超级版) | - |
| 长期VIP(至尊版) | 长期VIP(至尊版) | - |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"user_id": 0,
"user_name": "",
"user_face_s": "",
"user_face_m": "",
"user_face_l": "",
"rt_space_info": {
"all_total": {
"size": 0,
"size_format": ""
},
"all_remain": {
"size": 0,
"size_format": ""
},
"all_use": {
"size": 0,
"size_format": ""
}
},
"vip_info": {
"expire": 0,
"level_name": "原石会员",
"tp_rights": {
"is_tp_rights": 0,
"tp_rights_time": 0
}
}
}
}
```
### 注意事项
- `access_token` 决定当前用户和开放应用,接口不接收用户账号请求参数。
- `rt_space_info` 为实时空间数据,`all_use` 由总空间减去剩余空间计算得出。
- 请妥善保管 `access_token`,不要在日志或客户端可见信息中输出。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
| 2026年08月18日(周二) 14:40:25 | 更新四种长期VIP等级名称枚举 |
@@ -0,0 +1,88 @@
## 提交视频转码
### 基本信息
| 属性 | 内容 |
|:-------------|:--------------------------------|
| 接口名称 | 提交视频转码 |
| 接口版本 | v1.0 |
| 接口路径 | /video_push |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
按 VIP 等级或消耗枫币提交视频加速转码。
### 接口地址
```
https://proapi.115.com/open/video/video_push
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------|:-------|:---|:----|:----------------------------------------|:-----------------|
| pick_code | string | 是 | - | 视频文件提取码 | b53gu6z3hvqji8wrm |
| op | string | 是 | - | 加速转码方式,`vip_push`:按 VIP 等级加速;`pay_push`:消耗枫币加速 | vip_push |
### 请求示例
```shell
curl 'https://proapi.115.com/open/video/video_push' \
-H 'Authorization: Bearer <access_token>' \
-F 'pick_code=b53gu6z3hvqji8wrm' \
-F 'op=vip_push'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:------|:---------|:----------------------|
| state | boolean | 操作结果状态,true:成功;false:失败 |
| message | string | 返回信息,成功时为空字符串 |
| code | int | 错误码 |
| data | object[] | 响应数据,成功时为空数组 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 业务规则
- 仅支持为当前授权用户所属的视频文件提交加速转码。
- 已完成转码的视频无需重复提交。
- 两种加速转码方式均要求当前用户具备 VIP 权益;`pay_push` 在枫币余额不足时提交失败。
- 提交成功后,系统通知转码服务重新计算排队信息,并在 3 小时内记录已加速状态。
### 注意事项
- `access_token` 通过 `Authorization` 请求头传递,请将请求示例中的占位符替换为实际访问令牌。
- 请勿对同一视频重复提交加速转码。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,125 @@
## 获取视频在线播放地址
### 基本信息
| 属性 | 内容 |
|:-------------|:--------------------------------|
| 接口名称 | 获取视频在线播放地址 |
| 接口版本 | v1.0 |
| 接口路径 | /play |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
获取指定视频的在线播放地址、清晰度、音轨及文件基础信息。
### 接口地址
```
https://proapi.115.com/open/video/play
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------|:-------|:---|:----|:----------|:-----------------|
| pick_code | string | 是 | - | 视频文件提取码 | b53gu6z3hvqji8wrm |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/video/play' \
-H 'Authorization: Bearer <access_token>' \
--data-urlencode 'pick_code=b53gu6z3hvqji8wrm'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:---------------------------------|:---------|:----------------------------------------|
| state | boolean | 操作结果状态,true:成功;false:失败 |
| message | string | 返回信息,成功时为空字符串 |
| code | int | 错误码 |
| data | object | 视频播放及文件数据 |
| data.file_id | string | 文件ID |
| data.parent_id | string | 文件父目录ID |
| data.file_name | string | 文件名称 |
| data.file_size | string | 文件大小,单位为字节 |
| data.file_sha1 | string | 文件哈希值 |
| data.file_type | string | 文件类型 |
| data.is_private | string | 文件是否加密隐藏,0:否;1:是 |
| data.play_long | string | 视频时长 |
| data.user_def | int | 记忆的清晰度,1:标清;2:高清;3:超清;4:1080P;5:4K;100:原画 |
| data.user_rotate | int | 记忆的视频旋转角度,取值为 0、90、180、270 |
| data.user_turn | int | 视频翻转方向,0:不翻转;1:水平翻转;2:垂直翻转 |
| data.multitrack_list | object | 多音轨列表,键为音轨序号 |
| data.multitrack_list.*.title | string | 音轨标题 |
| data.multitrack_list.*.is_selected | string | 音轨是否为上次选中,1:是 |
| data.multitrack_list.*.sync_time | string | 音轨同步时间 |
| data.definition_list | object | 清晰度列表,键为清晰度值,值为清晰度名称 |
| data.definition_list_new | object | 新版清晰度列表,键为清晰度值,值为清晰度名称 |
| data.video_url | object[] | 各清晰度的播放地址信息 |
| data.video_url[].url | string | 播放地址 |
| data.video_url[].height | int | 视频高度 |
| data.video_url[].width | int | 视频宽度 |
| data.video_url[].definition | int | 视频清晰度 |
| data.video_url[].title | string | 视频清晰度名称 |
| data.video_url[].definition_n | int | 新版视频清晰度 |
| data.video_push_state | boolean | 视频尚未完成转码时是否已提交加速转码,仅对应失败响应返回 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"file_id": "",
"parent_id": "",
"file_name": "",
"file_size": "0",
"file_sha1": "",
"file_type": "",
"is_private": "0",
"play_long": "0",
"user_def": 0,
"user_rotate": 0,
"user_turn": 0,
"multitrack_list": {},
"definition_list": {},
"definition_list_new": {},
"video_url": []
}
}
```
### 业务规则
- 切换音轨时,在返回的播放地址后增加整型参数 `audio_track`,参数值取 `multitrack_list` 对应的键;音轨下标从 `0` 开始。
- 年费VIP以下用户不支持播放 4K 视频;选择 4K 清晰度时会返回引导升级的视频地址。
- 接口不返回下载地址字段 `down_url`。
### 注意事项
- `access_token` 通过 `Authorization` 请求头传递,请将请求示例中的占位符替换为实际访问令牌。
- 播放地址具有时效性,请勿缓存或向无关方披露。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,96 @@
## 获取视频播放进度
### 基本信息
| 属性 | 内容 |
|:-------------|:--------------------------------|
| 接口名称 | 获取视频播放进度 |
| 接口版本 | v1.0 |
| 接口路径 | /history |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
获取指定视频已记录的播放进度。
### 接口地址
```
https://proapi.115.com/open/video/history
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------|:-------|:---|:----|:----------|:-----------------|
| pick_code | string | 是 | - | 视频文件提取码 | b53gu6z3hvqji8wrm |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/video/history' \
-H 'Authorization: Bearer <access_token>' \
--data-urlencode 'pick_code=b53gu6z3hvqji8wrm'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-------------|:--------|:-----------------------------|
| state | boolean | 操作结果状态,true:成功;false:失败 |
| message | string | 返回信息,成功时为空字符串 |
| code | int | 错误码 |
| data | object | 播放进度数据;没有记录时为空数组 |
| data.add_time | int | 记录添加时间,Unix 时间戳 |
| data.file_id | string | 文件ID |
| data.file_name | string | 文件名称 |
| data.hash | string | 文件哈希值 |
| data.pick_code | string | 文件提取码 |
| data.time | int | 已播放时长,单位为秒 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"add_time": 0,
"file_id": "",
"file_name": "",
"hash": "",
"pick_code": "b53gu6z3hvqji8wrm",
"time": 0
}
}
```
### 业务规则
- 接口只返回当前 `pick_code` 对应的单条播放进度记录。
- 视频是否播放完毕可通过文件列表中的 `played_end` 字段查看,`1` 表示已播放完毕。
### 注意事项
- `access_token` 通过 `Authorization` 请求头传递,请将请求示例中的占位符替换为实际访问令牌。
- `pick_code` 对应的文件不存在或不在有效文件区域时,接口返回失败。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,119 @@
## 视频字幕列表
### 基本信息
| 属性 | 内容 |
|:-------------|:--------------------------------|
| 接口名称 | 视频字幕列表 |
| 接口版本 | v1.0 |
| 接口路径 | /subtitle |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
获取指定视频的自动载入字幕和可用字幕列表。
### 接口地址
```
https://proapi.115.com/open/video/subtitle
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------|:-------|:---|:----|:----------|:-----------------|
| pick_code | string | 是 | - | 视频文件提取码 | b53gu6z3hvqji8wrm |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/video/subtitle' \
-H 'Authorization: Bearer <access_token>' \
--data-urlencode 'pick_code=b53gu6z3hvqji8wrm'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:----------------------------|:---------|:----------------------------|
| state | boolean | 操作结果状态,true:成功;false:失败 |
| message | string | 返回信息,成功时为空字符串 |
| code | int | 错误码 |
| data | object | 响应数据 |
| data.autoload | object | 默认自动载入的字幕;无可用字幕时为空数组 |
| data.autoload.sid | string | 字幕标识 |
| data.autoload.language | string | 字幕语言 |
| data.autoload.title | string | 字幕标题 |
| data.autoload.url | string | 字幕文件地址 |
| data.autoload.type | string | 字幕文件类型 |
| data.autoload.key | string | 内置字幕键,仅内置字幕返回 |
| data.autoload.sha1 | string | 字幕文件哈希值 |
| data.autoload.file_id | string | 外挂或内嵌字幕文件ID |
| data.autoload.file_name | string | 外挂或内嵌字幕文件名 |
| data.autoload.pick_code | string | 外挂或内嵌字幕文件提取码 |
| data.autoload.caption_map_id | string | 内嵌字幕映射ID |
| data.autoload.is_caption_map | int | 是否为内嵌字幕,0:否;1:是 |
| data.autoload.sync_time | float | 字幕同步时间 |
| data.autoload.from | int | 记忆字幕来源标识 |
| data.autoload.user_sub | int | 是否为用户记忆字幕,1:是 |
| data.list | object[] | 字幕列表 |
| data.list[].sid | string | 字幕标识 |
| data.list[].language | string | 字幕语言 |
| data.list[].title | string | 字幕标题 |
| data.list[].url | string | 字幕文件地址 |
| data.list[].type | string | 字幕文件类型 |
| data.list[].key | string | 内置字幕键,仅内置字幕返回 |
| data.list[].sha1 | string | 字幕文件哈希值 |
| data.list[].file_id | string | 外挂或内嵌字幕文件ID |
| data.list[].file_name | string | 外挂或内嵌字幕文件名 |
| data.list[].pick_code | string | 外挂或内嵌字幕文件提取码 |
| data.list[].caption_map_id | string | 内嵌字幕映射ID |
| data.list[].is_caption_map | int | 是否为内嵌字幕,0:否;1:是 |
| data.list[].sync_time | float | 字幕同步时间 |
| data.list[].from | int | 记忆字幕来源标识 |
| data.list[].user_sub | int | 是否为用户记忆字幕,1:是 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"autoload": [],
"list": []
}
}
```
### 业务规则
- 用户记忆字幕不在列表中时会被前置;其余字幕按同目录同名外挂字幕、内嵌字幕、内置字幕、其他外挂字幕的顺序组合。
- 存在用户记忆字幕时优先将其作为自动载入字幕;否则依次选择内嵌字幕、同名外挂字幕或内置字幕。
- 字幕项来源不同,部分来源专属字段可能不返回。
### 注意事项
- `access_token` 通过 `Authorization` 请求头传递,请将请求示例中的占位符替换为实际访问令牌。
- 字幕文件地址具有时效性,请以本次接口返回值为准。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,89 @@
## 记忆视频播放进度
### 基本信息
| 属性 | 内容 |
|:-------------|:--------------------------------|
| 接口名称 | 记忆视频播放进度 |
| 接口版本 | v1.0 |
| 接口路径 | /history |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
记录指定视频的播放进度及是否播放完毕。
### 接口地址
```
https://proapi.115.com/open/video/history
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------|:-------|:---|:----|:-------------------|:-----------------|
| pick_code | string | 是 | - | 视频文件提取码 | b53gu6z3hvqji8wrm |
| time | int | 否 | 0 | 视频播放进度,单位为秒 | 10 |
| watch_end | int | 否 | 0 | 是否播放完毕,0:否;1:是 | 0 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/video/history' \
-H 'Authorization: Bearer <access_token>' \
-F 'pick_code=b53gu6z3hvqji8wrm' \
-F 'time=10' \
-F 'watch_end=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:------|:---------|:----------------------|
| state | boolean | 操作结果状态,true:成功;false:失败 |
| message | string | 返回信息,成功时为空字符串 |
| code | int | 错误码 |
| data | object[] | 响应数据,成功时为空数组 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 业务规则
- `time` 和 `watch_end` 均不传时,两个参数均按 `0` 处理,即播放进度为 0 秒且未播放完毕。
- 非本人文件不写入播放进度,但接口按操作成功返回。
- 同一视频未播放完毕的进度在 5 秒内重复上报时不重复持久化;标记为播放完毕时会立即持久化。
### 注意事项
- `access_token` 通过 `Authorization` 请求头传递,请将请求示例中的占位符替换为实际访问令牌。
- `pick_code` 必须属于当前授权用户可记录播放进度的视频文件。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,30 @@
# 开发须知
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 文档名称 | 开发须知 |
| 文档版本 | v1.0 |
## 注意事项
为共同建设开放、共赢的合作生态并保障平台服务的可持续发展,开发者在接入平台服务前,须认真阅读并严格遵循《115生活开放平台开发者协议》的相关要求,确保合规运营、维护平台秩序。
基于平台与用户权益保护原则,平台会持续监测开发者的服务使用行为。如发现违反平台规范的行为,平台将视情节采取包括但不限于服务限流、接口冻结、资质回收等限制措施,并保留依法追责的权利。
开发者严禁实施包括但不限于以下行为:
1. **数据隐私违规行为**:侵害用户数据隐私安全,包括未经用户授权或未明确用途,违规收集、下载、存储、传播、加工用户存储数据等。开发者需要确保用户数据的获取与使用全程透明、可追溯。
2. **商业利益侵犯行为**:损害 115 科技的商业利益,包括多人共享开发者账号及会员权益、开展竞争关系业务、未经授权获取平台相关服务运营数据等。
3. **不当使用行为**:违规或未按要求使用 API 服务,包括违反国家相关政策法规、侵犯第三方合法权益、调用非公开接口、实际用途与申请信息不符等。
## 限流说明
为确保系统安全并保障服务稳定运行,115生活开放平台对所有 API 实施频率控制策略。出于安全防护需要,相关策略细则暂不公开,平台会持续动态优化该机制。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,60 @@
# 授权错误码
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 文档名称 | 授权错误码 |
| 文档版本 | v1.0 |
| 错误码 | 描述 | 建议 |
|:-------|:------------------------------------|:-------------------------------------------------------------|
| 40100000 | 参数缺失 | - |
| 40101017 | 用户验证失败 | - |
| 40110000 | 请求异常,需要重试 | - |
| 40140100 | `client_id` 错误 | - |
| 40140101 | `code_challenge` 必填 | - |
| 40140102 | `code_challenge_method` 必须是 `sha256`、`sha1`、`md5` 之一 | - |
| 40140103 | `sign` 必填 | - |
| 40140104 | `sign` 签名失败 | - |
| 40140105 | 生成二维码失败 | - |
| 40140106 | AppID 无效 | - |
| 40140107 | 应用不存在 | - |
| 40140108 | 应用未审核通过 | - |
| 40140109 | 应用已被停用 | - |
| 40140110 | 应用已过期 | - |
| 40140111 | AppSecret 错误 | - |
| 40140112 | `code_verifier` 长度要求为 43 至 128 位 | - |
| 40140113 | `code_verifier` 验证失败 | - |
| 40140114 | `refresh_token` 格式错误(防篡改) | - |
| 40140115 | `refresh_token` 签名校验失败(防篡改) | - |
| 40140116 | `refresh_token` 无效(已解除授权) | 重新授权。终态错误,重试不会成功;继续重试将被标记为永久失效,见 `40140137` |
| 40140117 | `access_token` 刷新太频繁 | - |
| 40140118 | 开发者认证已过期 | - |
| 40140119 | `refresh_token` 已过期 | 重新授权。终态错误,重试不会成功;继续重试将被标记为永久失效,见 `40140137` |
| 40140120 | `refresh_token` 检验失败(防篡改) | 调用 `/open/refreshToken` 后会重新生成 `refresh_token`,检查本地是否已更新其值。终态错误,持续用旧值重试将被标记为永久失效,见 `40140137` |
| 40140121 | `access_token` 刷新失败 | 重试 |
| 40140122 | 超出授权应用数量上限 | - |
| 40140123 | `access_token` 格式错误(防篡改) | - |
| 40140124 | `access_token` 签名校验失败(防篡改) | - |
| 40140125 | `access_token` 无效(已过期、已解除授权或授权缓存不存在) | 调用 `/open/refreshToken` 获取新凭证;若 `refresh_token` 无效或已过期,重新授权 |
| 40140126 | `access_token` 与当前授权记录不匹配 | 重新读取刷新后保存的最新凭证,禁止使用原 `access_token` 重试;没有可用新凭证时调用 `/open/refreshToken` |
| 40140127 | `response_type` 错误 | - |
| 40140128 | `redirect_uri` 缺少协议 | - |
| 40140129 | `redirect_uri` 缺少域名 | - |
| 40140130 | 没有配置重定向域名 | 到应用管理中配置域名 |
| 40140131 | `redirect_uri` 域名不合法 | 需要与应用管理中配置的应用域名一致 |
| 40140132 | `grant_type` 错误 | - |
| 40140133 | `client_secret` 验证失败 | - |
| 40140134 | 授权码 `code` 验证失败 | - |
| 40140135 | `client_id` 验证失败 | - |
| 40140136 | `redirect_uri` 验证失败(防 MITM 攻击) | - |
| 40140137 | `refresh_token` 已失效,请停止重试并重新授权(终态) | 同一 `refresh_token` 连续多次以 `40140116`/`40140119`/`40140120` 失败后被服务端标记为永久失效。收到后必须停止用该令牌重试,引导用户重新授权;重新授权产生的新令牌不受影响 |
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
| 2026年08月10日(周一) 10:23:15 | 修正 `40140125`、`40140126` 的原因及处理建议 |
| 2026年08月24日(周一) 10:16:52 | 新增终态错误码 `40140137`,补充 `40140116`/`40140119`/`40140120` 的永久失效判定说明 |
@@ -0,0 +1,92 @@
## 刷新access_token
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 刷新access_token |
| 接口版本 | v1.0 |
| 接口路径 | /refreshToken |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
该接口用于通过 `refresh_token` 获取新的 `access_token` 和 `refresh_token`。
### 接口地址
```
https://passportapi.115.com/open/refreshToken
```
### 请求方式
```
POST
Content-Type: application/x-www-form-urlencoded
```
### 认证方式
```
OAuth 2.0 刷新凭证
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-------------|:-------|:---|:----|:---------------------|:----------------------------|
| refresh_token | string | 是 | - | 用于刷新访问凭证的刷新凭证 | `REFRESH_TOKEN_PLACEHOLDER` |
### 请求示例
```shell
curl 'https://passportapi.115.com/open/refreshToken' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'refresh_token=REFRESH_TOKEN_PLACEHOLDER'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-------------------|:-------|:------------------------------------|
| state | int | 状态码 |
| code | int | 错误码 |
| message | string | 响应信息 |
| data | object | 响应数据 |
| data.access_token | string | 新的 `access_token`,同时刷新有效期 |
| data.refresh_token | string | 新的 `refresh_token`,其有效期不延长、不改变 |
| data.expires_in | int | `access_token` 有效期,单位为秒 |
### 响应示例
```json
{
"state": 1,
"code": 0,
"message": "",
"data": {
"access_token": "ACCESS_TOKEN_PLACEHOLDER",
"refresh_token": "REFRESH_TOKEN_PLACEHOLDER",
"expires_in": 7200
}
}
```
### 注意事项
- `access_token` 有效期为 7200 秒,调用方应以响应中的 `expires_in` 计算刷新时间。
- 同一授权在 60 秒内重复刷新会触发频率控制;多进程或多节点调用方应确保同一授权同一时间只有一个刷新请求。
- 调用后会同时生成新的 `access_token` 和 `refresh_token`。调用方必须将两者作为一组原子保存,并停止使用刷新前的旧凭证;刷新凭证本身的有效期不延长、不改变。
- 收到 `40140125` 或 `40140126` 时,不要使用原 `access_token` 重复重试。应先读取已保存的最新凭证;没有可用新凭证时,再调用本接口刷新。
- `40140116`、`40140119`、`40140120` 是终态错误,用同一个 `refresh_token` 重试永远不会成功。同一 `refresh_token` 连续多次以这三种原因失败会被服务端标记为永久失效,之后每次刷新都直接返回 `40140137`。客户端收到这三种错误或 `40140137` 时必须停止重试,引导用户重新授权;后台常驻程序(如 NAS 同步任务)尤其要实现该逻辑,避免长期无效轮询。
- `access_token` 和 `refresh_token` 属于敏感凭证,不得写入公开仓库、客户端日志或公开沟通内容。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
| 2026年08月10日(周一) 10:23:15 | 修正 `access_token` 有效期示例,补充凭证轮换及并发刷新说明 |
| 2026年08月24日(周一) 10:16:52 | 新增 `refresh_token` 永久失效判定规则与终态错误码 `40140137` 说明 |
@@ -0,0 +1,88 @@
## 获取access_token
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 获取access_token |
| 接口版本 | v1.0 |
| 接口路径 | /deviceCodeToToken |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
该接口用于在用户确认手机扫码授权后,使用设备码和 PKCE 原始校验值换取 `access_token`。
### 接口地址
```
https://passportapi.115.com/open/deviceCodeToToken
```
### 请求方式
```
POST
Content-Type: application/x-www-form-urlencoded
```
### 认证方式
```
OAuth 2.0 + PKCE
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-----------|:-------|:---|:----|:--------------------------------|:---------------------------------------------------------------|
| uid | string | 是 | - | 二维码 ID/设备码 | `DEVICE_CODE_PLACEHOLDER` |
| code_verifier | string | 是 | - | 计算 `code_challenge` 时使用的原始随机字符串 | `IGKN6CJanWxCDPDhHZJrhswQdlcPBGLqExkhyujysXaQ4fJKBk_6dlPJo47s` |
### 请求示例
```shell
curl 'https://passportapi.115.com/open/deviceCodeToToken' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'uid=DEVICE_CODE_PLACEHOLDER' \
--data-urlencode 'code_verifier=IGKN6CJanWxCDPDhHZJrhswQdlcPBGLqExkhyujysXaQ4fJKBk_6dlPJo47s'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-------------------|:-------|:--------------------------------|
| state | int | 状态码 |
| code | int | 错误码 |
| message | string | 响应信息 |
| data | object | 响应数据 |
| data.access_token | string | 访问资源接口的凭证 |
| data.refresh_token | string | 刷新 `access_token` 的凭证,有效期 1 年 |
| data.expires_in | int | `access_token` 有效期,单位为秒 |
### 响应示例
```json
{
"state": 1,
"code": 0,
"message": "",
"data": {
"access_token": "ACCESS_TOKEN_PLACEHOLDER",
"refresh_token": "REFRESH_TOKEN_PLACEHOLDER",
"expires_in": 7200
}
}
```
### 注意事项
- `code_verifier` 必须与生成 `code_challenge` 时使用的原始值一致。
- `access_token` 和 `refresh_token` 属于敏感凭证,不得写入公开仓库、客户端日志或公开沟通内容。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,103 @@
## 获取设备码和二维码内容
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 获取设备码和二维码内容 |
| 接口版本 | v1.0 |
| 接口路径 | /authDeviceCode |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
该接口用于 OAuth 2.0 + PKCE 手机扫码授权流程的第一步,获取设备码和二维码内容。此模式适用于无后端服务的第三方客户端,无需提供 AppSecret。
第三方客户端需要根据响应中的 `data.qrcode` 生成二维码,供 115 客户端扫码授权。
### 接口地址
```
https://passportapi.115.com/open/authDeviceCode
```
### 请求方式
```
POST
Content-Type: application/x-www-form-urlencoded
```
### 认证方式
```
OAuth 2.0 + PKCE
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:---------------------|:-------|:---|:----|:--------------------------------|:-----------------------------------------------------------|
| client_id | string | 是 | - | AppID | `YOUR_APP_ID` |
| code_challenge | string | 是 | - | PKCE 挑战码 | `THHodGWg-FZfv8XYz7QArNGIK_aVomSHPldlSOTUtkw` |
| code_challenge_method | string | 是 | - | `code_challenge` 的哈希算法,见下方枚举表格 | `sha256` |
#### 请求参数中的 code_challenge_method 字段枚举
| 值 | 说明 | 备注 |
|:-------|:---------------------------|:---|
| md5 | 使用 MD5 计算挑战码 | - |
| sha1 | 使用 SHA-1 计算挑战码 | - |
| sha256 | 使用 SHA-256 计算挑战码,推荐使用 | - |
### 请求示例
```shell
curl 'https://passportapi.115.com/open/authDeviceCode' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=YOUR_APP_ID' \
--data-urlencode 'code_challenge=THHodGWg-FZfv8XYz7QArNGIK_aVomSHPldlSOTUtkw' \
--data-urlencode 'code_challenge_method=sha256'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-----------|:-----|:------------------------------------------|
| state | int | 状态码 |
| code | int | 错误码 |
| message | string | 响应信息 |
| data | object | 响应数据 |
| data.uid | string | 设备码,轮询二维码状态时使用 |
| data.time | int | 校验时间戳,轮询二维码状态时使用 |
| data.qrcode | string | 二维码内容,第三方客户端需要据此生成设备二维码 |
| data.sign | string | 校验签名,轮询二维码状态时使用 |
### 响应示例
```json
{
"state": 1,
"code": 0,
"message": "",
"data": {
"uid": "DEVICE_CODE_PLACEHOLDER",
"time": 0,
"qrcode": "QRCODE_CONTENT_PLACEHOLDER",
"sign": "SIGN_PLACEHOLDER"
}
}
```
### 注意事项
- `code_verifier` 为长度 43 至 128 位的随机字符串。
- `code_challenge` 的计算方式为 `url_safe(base64_encode(hash(code_verifier)))`。哈希结果按二进制数据参与 Base64 编码,算法需要与 `code_challenge_method` 一致。
- AppID 等应用凭证请使用实际应用配置,不要在公开仓库或日志中记录敏感凭证。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,89 @@
## 轮询二维码状态
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 轮询二维码状态 |
| 接口版本 | v1.0 |
| 接口路径 | /get/status/ |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
该接口用于长轮询设备二维码的扫码和授权状态。当二维码状态没有变化时,接口不会立即响应,直到请求超时或状态发生变化。
### 接口地址
```
https://qrcodeapi.115.com/get/status/
```
### 请求方式
```
GET
```
### 认证方式
```
设备码参数校验
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----|:-------|:---|:----|:------------------------------------|:------------------------|
| uid | string | 是 | - | 二维码 ID/设备码,从 `/open/authDeviceCode` 的 `data.uid` 获取 | `DEVICE_CODE_PLACEHOLDER` |
| time | int | 是 | - | 校验时间戳,从 `/open/authDeviceCode` 的 `data.time` 获取 | `0` |
| sign | string | 是 | - | 校验签名,从 `/open/authDeviceCode` 的 `data.sign` 获取 | `SIGN_PLACEHOLDER` |
### 请求示例
```shell
curl -G 'https://qrcodeapi.115.com/get/status/' \
--data-urlencode 'uid=DEVICE_CODE_PLACEHOLDER' \
--data-urlencode 'time=0' \
--data-urlencode 'sign=SIGN_PLACEHOLDER'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:------------|:-------|:-------------------------------------------|
| state | int | 轮询状态:0-二维码无效,结束轮询;1-继续轮询 |
| code | int | 错误码 |
| message | string | 响应信息 |
| data | object | 响应数据;115 客户端扫码或输入设备码后才有值 |
| data.msg | string | 操作提示 |
| data.status | int | 二维码状态:1-扫码成功,等待确认;2-确认登录或授权,结束轮询 |
| data.version | string | 版本信息 |
### 响应示例
```json
{
"state": 1,
"code": 0,
"message": "",
"data": {
"msg": "OPERATION_MESSAGE_PLACEHOLDER",
"status": 1,
"version": "VERSION_PLACEHOLDER"
}
}
```
### 注意事项
- `state=0` 表示二维码无效,应结束轮询;`state=1` 表示继续轮询。
- `data.status=1` 表示扫码成功并等待用户确认;`data.status=2` 表示用户已确认登录或授权,应结束轮询并进入换取访问凭证的步骤。
- 长轮询超时不等同于授权失败,客户端可以按照自身网络策略重新发起请求。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,95 @@
## 用授权码换取access_token
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 用授权码换取access_token |
| 接口版本 | v1.0 |
| 接口路径 | /authCodeToToken |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
该接口用于通过授权码换取 `access_token`。建议在开发者服务端调用,避免泄露 AppSecret。
### 接口地址
```
https://passportapi.115.com/open/authCodeToToken
```
### 请求方式
```
POST
Content-Type: application/x-www-form-urlencoded
```
### 认证方式
```
OAuth 2.0 授权码模式
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-------------|:-------|:---|:----|:------------------------------------|:------------------------------|
| client_id | string | 是 | - | AppID | `YOUR_APP_ID` |
| client_secret | string | 是 | - | AppSecret | `YOUR_APP_SECRET` |
| code | string | 是 | - | 请求授权接口重定向返回的授权码 | `AUTHORIZATION_CODE_PLACEHOLDER` |
| redirect_uri | string | 是 | - | 与请求授权时传入的 `redirect_uri` 一致,用于防止 MITM 和 CSRF 攻击 | `https://foo.com?state=123456` |
| grant_type | string | 是 | - | 授权类型,固定为 `authorization_code` | `authorization_code` |
### 请求示例
```shell
curl 'https://passportapi.115.com/open/authCodeToToken' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=YOUR_APP_ID' \
--data-urlencode 'client_secret=YOUR_APP_SECRET' \
--data-urlencode 'code=AUTHORIZATION_CODE_PLACEHOLDER' \
--data-urlencode 'redirect_uri=https://foo.com?state=123456' \
--data-urlencode 'grant_type=authorization_code'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-------------------|:-------|:--------------------------------|
| state | int | 状态码:0-失败;1-成功 |
| code | int | 错误码 |
| message | string | 响应信息 |
| data | object | 响应数据 |
| data.access_token | string | 访问资源接口的凭证 |
| data.refresh_token | string | 刷新 `access_token` 的凭证,有效期 1 年 |
| data.expires_in | int | `access_token` 有效期,单位为秒 |
### 响应示例
```json
{
"state": 1,
"code": 0,
"message": "",
"data": {
"access_token": "ACCESS_TOKEN_PLACEHOLDER",
"refresh_token": "REFRESH_TOKEN_PLACEHOLDER",
"expires_in": 7200
}
}
```
### 注意事项
- 必须在服务端安全保存并使用 AppSecret,不得在客户端代码、公开仓库或日志中泄露。
- `redirect_uri` 必须与请求授权时传入的值一致。
- `access_token` 和 `refresh_token` 属于敏感凭证,应按照敏感数据规范存储。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,87 @@
## 请求授权
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 请求授权 |
| 接口版本 | v1.0 |
| 接口路径 | /authorize |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
该接口用于发起 OAuth 2.0 授权码模式授权,建议由开发者服务端参与授权流程。
用户未登录时,接口会重定向到登录页面;用户已登录时,接口会自动完成授权并重定向到 `redirect_uri` 指定的地址。
### 接口地址
```
https://passportapi.115.com/open/authorize
```
### 请求方式
```
GET
```
### 认证方式
```
OAuth 2.0 授权码模式
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-----------|:-------|:---|:----|:-----------------------------------------------|:---------------------|
| client_id | string | 是 | - | AppID | `YOUR_APP_ID` |
| redirect_uri | string | 是 | - | 授权完成后的重定向地址;接口会附加授权码 `code`,并原样附加请求中的 `state` | `https://foo.com/bar` |
| response_type | string | 是 | - | 授权模式,固定为 `code` | `code` |
| state | string | 否 | "" | 防止 CSRF 攻击的随机值,重定向时原样返回 | `123456` |
### 请求示例
```shell
curl -G 'https://passportapi.115.com/open/authorize' \
--data-urlencode 'client_id=YOUR_APP_ID' \
--data-urlencode 'redirect_uri=https://foo.com/bar' \
--data-urlencode 'response_type=code' \
--data-urlencode 'state=123456'
```
### 响应字段说明
接口调用成功时会重定向到 `redirect_uri`,并通过查询参数返回授权码 `code` 和请求中携带的 `state`。接口调用失败时返回以下字段:
| 字段 | 类型 | 描述 |
|:--------|:-------|:------------------|
| state | int | 状态码:0-失败;1-成功 |
| code | int | 错误码 |
| data | object | 响应数据 |
| message | string | 响应信息 |
### 响应示例
```json
{
"state": 0,
"code": 40140127,
"data": {},
"message": "response_type 错误"
}
```
### 注意事项
- `redirect_uri` 需要先在[115生活开放平台](https://open.115.com/)的应用管理中配置域名,并在请求时进行 URL 编码。
- 强烈建议传入随机 `state`,并在换取 `access_token` 前验证重定向返回的 `state` 与请求值一致,以防止 CSRF 攻击。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,57 @@
# 接入流程
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 文档名称 | 接入流程 |
| 文档版本 | v1.0 |
## 1. 注册“115生活”账号
在使用 115 开放平台服务之前,开发者需要先通过[“115生活”官网](https://115.com/)注册“115生活”账号并登录,完成实名认证。
## 2. 提交入驻申请
访问[115生活开放平台](https://open.115.com/),按照页面流程填写相关信息。
### 2.1 选择开发者身份类型
开发者可以申请成为“个人开发者”或“企业开发者”。点击页面上方的“切换申请类型”,可以切换身份类型。
### 2.2 签署协议
认真阅读并确认相关协议内容;如无异议,勾选“同意”并点击“下一步”。
### 2.3 填写入驻资料
按照页面指引填写个人信息、API 对接需求、应用场景等入驻资料,然后点击“下一步”。
### 2.4 填写认证信息
根据所选择的开发者身份类型,按照页面指引填写并上传身份认证资料:
- **个人开发者**:姓名、身份证信息(证件号码、有效期)、证件照片(身份证件正反面照片、本人手持身份证照片)。
- **企业开发者**:企业负责人或法人的姓名、联系方式、身份证信息(证件号码、有效期)、证件照片(身份证件正反面照片、加盖公章的营业执照影印件)、统一社会信用代码。
### 2.5 提交入驻申请
确认上述信息填写无误后,点击“提交验证”提交入驻申请。平台将在 7 个工作日内完成审核。
## 3. 创建应用
入驻申请通过后,进入 115 生活开放平台管理页面,选择“应用管理”,点击“创建应用”,按照页面指引填写应用信息、应用域名、应用描述、接口信息等内容。确认信息填写无误后提交应用申请,平台将在 7 个工作日内完成审核。
应用审核通过后,开发者可以获取该应用的 AppID、AppKey 和 AppSecret 等接入凭证。请妥善保存这些凭证,不要在客户端代码、公开仓库或沟通内容中泄露。
## 4. 接口调试
按照开放平台接口文档完成接入和调用后,即可正式使用开放平台能力。
开放平台 API 基础域名:`https://proapi.115.com/`
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -0,0 +1,23 @@
# 更新记录
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 文档名称 | 更新记录 |
| 文档版本 | v1.0 |
## 更新内容
| 更新模块 | 更新内容 | 更新时间 |
|:-----------|:-------------------------|:--------------|
| 增值服务产品 | 新增增值服务产品,获得推广收益 | 2025年4月17日周四 |
| 视频播放、云下载 | 新增视频播放、云下载接口 | 2025年4月3日周四 |
| 接入授权 | 支持 H5 账号密码/短信授权 | 2025年4月2日周三 |
| 基本框架 | 新增开放平台文档接口 | 2025年1月22日周三 |
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
+30
View File
@@ -0,0 +1,30 @@
# 概述
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 文档名称 | 概述 |
| 文档版本 | v1.0 |
## 115生活简介
“115生活”是一款面向个人用户的数字生活平台,提供海量数据的安全存储、多端同步与快速访问。用户不仅可以便捷地管理和使用各类数字资源,还能使用多维社交、生活服务等多元化功能。
## 115生活开放平台能力说明
115生活开放平台提供“115生活”数据存储、同步、管理等功能的 API 服务。开发者通过对接 API,可以将“115生活”的存储能力集成到自己的应用中。
目前已开放以下能力:
- **用户管理能力**:用户授权与信息查询等。
- **文件管理能力**:获取文件列表,查看文件属性,以及文件上传、下载、搜索、移动、删除等。
- **视频管理能力**:视频文件的在线转码与播放等。
- **云下载服务**:获取云下载任务列表、配额信息,以及添加、删除下载任务等。
- **商业价值转化**:开发者参与“推广产品得收益”计划,可基于用户实际购买的产品获取相应推广收益。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
+67
View File
@@ -0,0 +1,67 @@
# 115 开放平台文档(离线版)
> 来源:https://open.115.com/doc/ (115生活开放平台开发者文档)
> 抓取时间:2026-09-05,共 42 篇 Markdown 文档,目录结构与官网一致。
> 目录索引:[tree-index.json](tree-index.json)(官网原始目录树,程序化遍历可用)。
## 文档目录
- [概述](115开放平台/简介/概述.md)
- [更新记录](115开放平台/简介/更新记录.md)
- [接入流程](115开放平台/接入指南/接入流程.md)
- [开发须知](115开放平台/接入指南/开发须知.md)
- [授权错误码](115开放平台/接入指南/授权错误码.md)
- **接入授权/**
- **手机扫码授权PKCE模式/**
- [获取设备码和二维码内容](115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取设备码和二维码内容.md)
- [轮询二维码状态](115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/轮询二维码状态.md)
- [获取access_token](115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取access_token.md)
- **授权码模式/**
- [请求授权](115开放平台/接入指南/接入授权/授权码模式/请求授权.md)
- [用授权码换取access_token](115开放平台/接入指南/接入授权/授权码模式/用授权码换取access_token.md)
- [刷新access_token](115开放平台/接入指南/接入授权/刷新access_token.md)
- [开发者商业价值转化:推广产品得收益](115开放平台/API列表/开发者商业价值转化:推广产品得收益.md)
- **用户管理/**
- [用户信息](115开放平台/API列表/用户管理/用户信息.md)
- **文件管理/**
- **文件上传/**
- [上传流程](115开放平台/API列表/文件管理/文件上传/上传流程.md)
- [获取上传凭证](115开放平台/API列表/文件管理/文件上传/获取上传凭证.md)
- [文件上传](115开放平台/API列表/文件管理/文件上传/文件上传.md)
- [断点续传](115开放平台/API列表/文件管理/文件上传/断点续传.md)
- [新建文件夹](115开放平台/API列表/文件管理/新建文件夹.md)
- [获取文件列表](115开放平台/API列表/文件管理/获取文件列表.md)
- **获取文件(夹)详情/**
- [按ID获取](115开放平台/API列表/文件管理/获取文件(夹)详情/按ID获取.md)
- [按路径获取](115开放平台/API列表/文件管理/获取文件(夹)详情/按路径获取.md)
- [文件搜索](115开放平台/API列表/文件管理/文件搜索.md)
- [文件复制](115开放平台/API列表/文件管理/文件复制.md)
- [文件移动](115开放平台/API列表/文件管理/文件移动.md)
- [获取文件下载地址](115开放平台/API列表/文件管理/获取文件下载地址.md)
- [文件(夹)更新](115开放平台/API列表/文件管理/文件(夹)更新.md)
- [删除文件](115开放平台/API列表/文件管理/删除文件.md)
- [回收站列表](115开放平台/API列表/文件管理/回收站列表.md)
- [回收站还原](115开放平台/API列表/文件管理/回收站还原.md)
- [删除或清空回收站](115开放平台/API列表/文件管理/删除或清空回收站.md)
- **视频播放/**
- [记忆视频播放进度](115开放平台/API列表/视频播放/记忆视频播放进度.md)
- [视频字幕列表](115开放平台/API列表/视频播放/视频字幕列表.md)
- [获取视频播放进度](115开放平台/API列表/视频播放/获取视频播放进度.md)
- [获取视频在线播放地址](115开放平台/API列表/视频播放/获取视频在线播放地址.md)
- [提交视频转码](115开放平台/API列表/视频播放/提交视频转码.md)
- **云下载/**
- [解析BT种子](115开放平台/API列表/云下载/解析BT种子.md)
- [获取用户云下载任务列表](115开放平台/API列表/云下载/获取用户云下载任务列表.md)
- [获取云下载配额信息](115开放平台/API列表/云下载/获取云下载配额信息.md)
- [清空云下载任务](115开放平台/API列表/云下载/清空云下载任务.md)
- [添加云下载链接任务](115开放平台/API列表/云下载/添加云下载链接任务.md)
- [删除用户云下载任务](115开放平台/API列表/云下载/删除用户云下载任务.md)
- [添加云下载BT任务](115开放平台/API列表/云下载/添加云下载BT任务.md)
## API 接口速查
主要 API 域名与认证方式(详见各文档):
- 开放 API 基础地址:`https://proapi.115.com/open/...`
- 认证方式:`Authorization: Bearer access_token`
- OAuth 授权相关文档见 `115开放平台/接入指南/接入授权/`
+308
View File
@@ -0,0 +1,308 @@
{
"name": "doc",
"path": "/doc/",
"children": [
{
"name": "115开放平台",
"path": "/doc/115开放平台/",
"children": [
{
"name": "简介",
"path": "/doc/115开放平台/简介/",
"children": [
{
"name": "概述.md",
"path": "/doc/115开放平台/简介/概述.md",
"type": "file"
},
{
"name": "更新记录.md",
"path": "/doc/115开放平台/简介/更新记录.md",
"type": "file"
}
],
"type": "directory"
},
{
"name": "接入指南",
"path": "/doc/115开放平台/接入指南/",
"children": [
{
"name": "接入流程.md",
"path": "/doc/115开放平台/接入指南/接入流程.md",
"type": "file"
},
{
"name": "开发须知.md",
"path": "/doc/115开放平台/接入指南/开发须知.md",
"type": "file"
},
{
"name": "授权错误码.md",
"path": "/doc/115开放平台/接入指南/授权错误码.md",
"type": "file"
},
{
"name": "接入授权",
"path": "/doc/115开放平台/接入指南/接入授权/",
"children": [
{
"name": "手机扫码授权PKCE模式",
"path": "/doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/",
"children": [
{
"name": "获取设备码和二维码内容.md",
"path": "/doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取设备码和二维码内容.md",
"type": "file"
},
{
"name": "轮询二维码状态.md",
"path": "/doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/轮询二维码状态.md",
"type": "file"
},
{
"name": "获取access_token.md",
"path": "/doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取access_token.md",
"type": "file"
}
],
"type": "directory"
},
{
"name": "授权码模式",
"path": "/doc/115开放平台/接入指南/接入授权/授权码模式/",
"children": [
{
"name": "请求授权.md",
"path": "/doc/115开放平台/接入指南/接入授权/授权码模式/请求授权.md",
"type": "file"
},
{
"name": "用授权码换取access_token.md",
"path": "/doc/115开放平台/接入指南/接入授权/授权码模式/用授权码换取access_token.md",
"type": "file"
}
],
"type": "directory"
},
{
"name": "刷新access_token.md",
"path": "/doc/115开放平台/接入指南/接入授权/刷新access_token.md",
"type": "file"
}
],
"type": "directory"
}
],
"type": "directory"
},
{
"name": "API列表",
"path": "/doc/115开放平台/API列表/",
"children": [
{
"name": "开发者商业价值转化:推广产品得收益.md",
"path": "/doc/115开放平台/API列表/开发者商业价值转化:推广产品得收益.md",
"type": "file"
},
{
"name": "用户管理",
"path": "/doc/115开放平台/API列表/用户管理/",
"children": [
{
"name": "用户信息.md",
"path": "/doc/115开放平台/API列表/用户管理/用户信息.md",
"type": "file"
}
],
"type": "directory"
},
{
"name": "文件管理",
"path": "/doc/115开放平台/API列表/文件管理/",
"children": [
{
"name": "文件上传",
"path": "/doc/115开放平台/API列表/文件管理/文件上传/",
"children": [
{
"name": "上传流程.md",
"path": "/doc/115开放平台/API列表/文件管理/文件上传/上传流程.md",
"type": "file"
},
{
"name": "获取上传凭证.md",
"path": "/doc/115开放平台/API列表/文件管理/文件上传/获取上传凭证.md",
"type": "file"
},
{
"name": "文件上传.md",
"path": "/doc/115开放平台/API列表/文件管理/文件上传/文件上传.md",
"type": "file"
},
{
"name": "断点续传.md",
"path": "/doc/115开放平台/API列表/文件管理/文件上传/断点续传.md",
"type": "file"
}
],
"type": "directory"
},
{
"name": "新建文件夹.md",
"path": "/doc/115开放平台/API列表/文件管理/新建文件夹.md",
"type": "file"
},
{
"name": "获取文件列表.md",
"path": "/doc/115开放平台/API列表/文件管理/获取文件列表.md",
"type": "file"
},
{
"name": "获取文件(夹)详情",
"path": "/doc/115开放平台/API列表/文件管理/获取文件(夹)详情/",
"children": [
{
"name": "按ID获取.md",
"path": "/doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按ID获取.md",
"type": "file"
},
{
"name": "按路径获取.md",
"path": "/doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按路径获取.md",
"type": "file"
}
],
"type": "directory"
},
{
"name": "文件搜索.md",
"path": "/doc/115开放平台/API列表/文件管理/文件搜索.md",
"type": "file"
},
{
"name": "文件复制.md",
"path": "/doc/115开放平台/API列表/文件管理/文件复制.md",
"type": "file"
},
{
"name": "文件移动.md",
"path": "/doc/115开放平台/API列表/文件管理/文件移动.md",
"type": "file"
},
{
"name": "获取文件下载地址.md",
"path": "/doc/115开放平台/API列表/文件管理/获取文件下载地址.md",
"type": "file"
},
{
"name": "文件(夹)更新.md",
"path": "/doc/115开放平台/API列表/文件管理/文件(夹)更新.md",
"type": "file"
},
{
"name": "删除文件.md",
"path": "/doc/115开放平台/API列表/文件管理/删除文件.md",
"type": "file"
},
{
"name": "回收站列表.md",
"path": "/doc/115开放平台/API列表/文件管理/回收站列表.md",
"type": "file"
},
{
"name": "回收站还原.md",
"path": "/doc/115开放平台/API列表/文件管理/回收站还原.md",
"type": "file"
},
{
"name": "删除或清空回收站.md",
"path": "/doc/115开放平台/API列表/文件管理/删除或清空回收站.md",
"type": "file"
}
],
"type": "directory"
},
{
"name": "视频播放",
"path": "/doc/115开放平台/API列表/视频播放/",
"children": [
{
"name": "记忆视频播放进度.md",
"path": "/doc/115开放平台/API列表/视频播放/记忆视频播放进度.md",
"type": "file"
},
{
"name": "视频字幕列表.md",
"path": "/doc/115开放平台/API列表/视频播放/视频字幕列表.md",
"type": "file"
},
{
"name": "获取视频播放进度.md",
"path": "/doc/115开放平台/API列表/视频播放/获取视频播放进度.md",
"type": "file"
},
{
"name": "获取视频在线播放地址.md",
"path": "/doc/115开放平台/API列表/视频播放/获取视频在线播放地址.md",
"type": "file"
},
{
"name": "提交视频转码.md",
"path": "/doc/115开放平台/API列表/视频播放/提交视频转码.md",
"type": "file"
}
],
"type": "directory"
},
{
"name": "云下载",
"path": "/doc/115开放平台/API列表/云下载/",
"children": [
{
"name": "解析BT种子.md",
"path": "/doc/115开放平台/API列表/云下载/解析BT种子.md",
"type": "file"
},
{
"name": "获取用户云下载任务列表.md",
"path": "/doc/115开放平台/API列表/云下载/获取用户云下载任务列表.md",
"type": "file"
},
{
"name": "获取云下载配额信息.md",
"path": "/doc/115开放平台/API列表/云下载/获取云下载配额信息.md",
"type": "file"
},
{
"name": "清空云下载任务.md",
"path": "/doc/115开放平台/API列表/云下载/清空云下载任务.md",
"type": "file"
},
{
"name": "添加云下载链接任务.md",
"path": "/doc/115开放平台/API列表/云下载/添加云下载链接任务.md",
"type": "file"
},
{
"name": "删除用户云下载任务.md",
"path": "/doc/115开放平台/API列表/云下载/删除用户云下载任务.md",
"type": "file"
},
{
"name": "添加云下载BT任务.md",
"path": "/doc/115开放平台/API列表/云下载/添加云下载BT任务.md",
"type": "file"
}
],
"type": "directory"
}
],
"type": "directory"
}
],
"type": "directory"
}
],
"type": "directory"
}
+1
View File
@@ -124,6 +124,7 @@ type StrmUploadTask struct {
FileName string `gorm:"size:512" json:"file_name"` FileName string `gorm:"size:512" json:"file_name"`
LocalPath string `gorm:"size:1024" json:"local_path"` // 本地源文件 LocalPath string `gorm:"size:1024" json:"local_path"` // 本地源文件
RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目标路径 RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目标路径
RemoteRef string `gorm:"size:1024" json:"remote_ref"` // 远端同名旧文件引用(115 文件 ID;上传覆盖前先删除旧文件,WebDAV/OpenList 直接覆盖无需删除)
Size int64 `json:"size"` Size int64 `json:"size"`
Status string `gorm:"size:16;index" json:"status"` Status string `gorm:"size:16;index" json:"status"`
Error string `gorm:"size:1024" json:"error"` Error string `gorm:"size:1024" json:"error"`
+47
View File
@@ -901,6 +901,53 @@ func (r *StrmDirCacheRepository) Set(ctx context.Context, syncPathID, dirID, pat
}) })
} }
// SetBatch 批量 upsert 目录缓存(dirID → 相对路径)。单个事务内先查出已存在
// 行再分流更新/插入,替代同步流程逐目录单条 Set,避免首次全量同步上万目录时
// 的 SQLite 写锁竞争。同一 dirID 的重复项以 map 语义取最后一次写入。
func (r *StrmDirCacheRepository) SetBatch(ctx context.Context, syncPathID string, paths map[string]string) error {
if len(paths) == 0 {
return nil
}
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
ids := make([]string, 0, len(paths))
for dirID := range paths {
ids = append(ids, dirID)
}
var existing []model.StrmDirCache
if err := tx.Where("sync_path_id = ? AND dir_id IN ?", syncPathID, ids).Find(&existing).Error; err != nil {
return err
}
existingRowID := make(map[string]string, len(existing))
for _, row := range existing {
existingRowID[row.DirID] = row.ID
}
now := time.Now()
var creates []model.StrmDirCache
for dirID, path := range paths {
if rowID, ok := existingRowID[dirID]; ok {
if err := tx.Model(&model.StrmDirCache{}).Where("id = ?", rowID).Updates(map[string]any{
"path": path,
"updated_at": now,
}).Error; err != nil {
return err
}
continue
}
creates = append(creates, model.StrmDirCache{
SyncPathID: syncPathID,
DirID: dirID,
Path: path,
})
}
if len(creates) > 0 {
return tx.CreateInBatches(creates, 100).Error
}
return nil
})
})
}
func (r *StrmDirCacheRepository) DeleteBySyncPathID(ctx context.Context, syncPathID string) error { func (r *StrmDirCacheRepository) DeleteBySyncPathID(ctx context.Context, syncPathID string) error {
return withSQLiteBusyRetry(ctx, func() error { return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("sync_path_id = ?", syncPathID).Delete(&model.StrmDirCache{}).Error return r.db.WithContext(ctx).Unscoped().Where("sync_path_id = ?", syncPathID).Delete(&model.StrmDirCache{}).Error
+12
View File
@@ -45,6 +45,9 @@ type FileEntry struct {
MTime int64 `json:"mtime,omitempty"` MTime int64 `json:"mtime,omitempty"`
// PickCode is 115-specific; other providers use ID directly. // PickCode is 115-specific; other providers use ID directly.
PickCode string `json:"pick_code,omitempty"` PickCode string `json:"pick_code,omitempty"`
// Sha1 is 115-specific content hash(大写 hex,目录/未完成文件可能为空或占位符)。
// 其他网盘不提供,留空时调用方退回大小比对。用于元数据"是否同一文件"的精确判定。
Sha1 string `json:"sha1,omitempty"`
} }
// DirectLink is a resolved playback target. // DirectLink is a resolved playback target.
@@ -72,6 +75,15 @@ type Provider interface {
Resolve(ctx context.Context, fileRef string) (*DirectLink, error) Resolve(ctx context.Context, fileRef string) (*DirectLink, error)
} }
// BatchResolver is implemented by providers that can resolve several file
// references in fewer API calls(115 的 downurl 接口支持逗号分隔多个 pick_code,
// 批量换取可显著降低下载队列的换链请求量)。返回以原始引用为键的直链 map;
// 解析失败的引用不在结果中,err 汇报批量机制本身的失败,调用方应据此对缺失
// 项回退到逐个 Resolve。
type BatchResolver interface {
ResolveBatch(ctx context.Context, fileRefs []string) (map[string]*DirectLink, error)
}
// MutableProvider is implemented by cloud bridges that support safe folder // MutableProvider is implemented by cloud bridges that support safe folder
// management through their official API or standard WebDAV methods. // management through their official API or standard WebDAV methods.
type MutableProvider interface { type MutableProvider interface {
+11 -5
View File
@@ -81,7 +81,12 @@ func Test115OpenAPIListPaginates(t *testing.T) {
t.Fatalf("unexpected path %s", r.URL.Path) t.Fatalf("unexpected path %s", r.URL.Path)
} }
offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
count := 100 limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 100
}
// 首页返回满页,之后返回 1 条:驱动按 offset/limit 翻页直到短页
count := limit
if offset > 0 { if offset > 0 {
count = 1 count = 1
} }
@@ -97,11 +102,12 @@ func Test115OpenAPIListPaginates(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
if len(entries) != 101 { // List 使用文档上限 limit=1150:首页 1150 条 + 短页 1 条
t.Fatalf("entries = %d, want 101", len(entries)) if len(entries) != 1151 {
t.Fatalf("entries = %d, want 1151", len(entries))
} }
if entries[100].ID != "100" || entries[100].PickCode != "pick100" { if entries[1150].ID != "1150" || entries[1150].PickCode != "pick1150" {
t.Fatalf("last entry wrong: %#v", entries[100]) t.Fatalf("last entry wrong: %#v", entries[1150])
} }
} }
+17 -2
View File
@@ -56,8 +56,9 @@ func (p *openAPI115Provider) Ping(ctx context.Context) error {
} }
func (p *openAPI115Provider) List(ctx context.Context, dirID string) ([]FileEntry, error) { func (p *openAPI115Provider) List(ctx context.Context, dirID string) ([]FileEntry, error) {
// 115 开放平台列表接口按 offset/limit 分页,这里循环取完整个目录 // 115 开放平台列表接口按 offset/limit 分页,这里循环取完整个目录;
const pageSize = 100 // limit 上限 1150(官方文档《获取文件列表》),取上限减少大目录翻页次数
const pageSize = 1150
var out []FileEntry var out []FileEntry
for offset := 0; ; offset += pageSize { for offset := 0; ; offset += pageSize {
files, _, err := p.c.GetFsList(ctx, dirID, offset, pageSize) files, _, err := p.c.GetFsList(ctx, dirID, offset, pageSize)
@@ -72,6 +73,7 @@ func (p *openAPI115Provider) List(ctx context.Context, dirID string) ([]FileEntr
Size: f.FileSize, Size: f.FileSize,
MTime: f.ModifiedAt(), MTime: f.ModifiedAt(),
PickCode: f.PickCode, PickCode: f.PickCode,
Sha1: f.Sha1,
}) })
} }
if len(files) < pageSize { if len(files) < pageSize {
@@ -105,6 +107,19 @@ func (p *openAPI115Provider) ResolveWithUA(ctx context.Context, fileRef, ua stri
return &DirectLink{URL: url, Proxy: false, Headers: map[string]string{"User-Agent": bound}}, nil return &DirectLink{URL: url, Proxy: false, Headers: map[string]string{"User-Agent": bound}}, nil
} }
// ResolveBatch 批量换取直链(downurl 支持逗号分隔多 pick_code,一次请求覆盖
// 整批下载任务的换链)。返回 pickcode → 直链,未解析成功的引用不在结果中;
// err 非 nil 表示批量过程部分/全部失败,调用方对缺失项回退到逐个 Resolve。
// 下载队列统一使用默认 UA,与单个换取的防盗链绑定语义一致。
func (p *openAPI115Provider) ResolveBatch(ctx context.Context, fileRefs []string) (map[string]*DirectLink, error) {
urls, err := p.c.GetDownloadURLsBatch(ctx, fileRefs, "")
out := make(map[string]*DirectLink, len(urls))
for pc, u := range urls {
out[pc] = &DirectLink{URL: u, Proxy: false, Headers: map[string]string{"User-Agent": cloud115.DefaultUA}}
}
return out, err
}
// OpenClient 暴露底层客户端(token 刷新用)。 // OpenClient 暴露底层客户端(token 刷新用)。
func (p *openAPI115Provider) OpenClient() *cloud115.OpenClient { return p.c } func (p *openAPI115Provider) OpenClient() *cloud115.OpenClient { return p.c }
@@ -6,6 +6,7 @@ import (
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
) )
@@ -472,3 +473,65 @@ func TestFsListRefreshContinue(t *testing.T) {
t.Fatalf("want 1 file, got %d", len(files)) t.Fatalf("want 1 file, got %d", len(files))
} }
} }
// TestGetDownloadURLsBatch 验证批量换链:多个 pick_code 合并为一次逗号分隔
// 请求;响应按文件 ID 为键、以条目内 pick_code 映射回请求侧;已缓存的
// pick_code 不再发起请求;缺失项(空 URL)不出现在结果中。
func TestGetDownloadURLsBatch(t *testing.T) {
var mu sync.Mutex
var requests []string
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/open/ufile/downurl" {
t.Errorf("unexpected path %s", r.URL.Path)
}
pc := r.PostFormValue("pick_code")
mu.Lock()
requests = append(requests, pc)
mu.Unlock()
w.Write([]byte(`{"state":true,"data":{
"111":{"pick_code":"batch-pc-a","url":{"url":"https://cdn/a.mkv"}},
"222":{"pick_code":"batch-pc-b","url":{"url":"https://cdn/b.jpg"}},
"333":{"pick_code":"batch-pc-empty","url":{"url":""}}}}`))
})
t.Cleanup(func() {
ClearDownloadURLCache("batch-pc-a")
ClearDownloadURLCache("batch-pc-b")
ClearDownloadURLCache("batch-pc-empty")
})
c := NewOpenClient("100195125", "at1", "rt1")
urls, err := c.GetDownloadURLsBatch(context.Background(),
[]string{"batch-pc-a", "batch-pc-b", "batch-pc-empty", "", "batch-pc-a"}, "")
if err != nil {
t.Fatalf("batch downurl: %v", err)
}
if urls["batch-pc-a"] != "https://cdn/a.mkv" || urls["batch-pc-b"] != "https://cdn/b.jpg" {
t.Fatalf("bad urls: %#v", urls)
}
if _, ok := urls["batch-pc-empty"]; ok {
t.Fatalf("empty-url entry should be absent: %#v", urls)
}
if _, ok := urls[""]; ok {
t.Fatalf("empty pickcode should be absent: %#v", urls)
}
mu.Lock()
if len(requests) != 1 || requests[0] != "batch-pc-a,batch-pc-b,batch-pc-empty" {
mu.Unlock()
t.Fatalf("unexpected downurl requests: %v", requests)
}
mu.Unlock()
// 第二次调用全部命中缓存:不再发任何请求
urls2, err := c.GetDownloadURLsBatch(context.Background(), []string{"batch-pc-a", "batch-pc-b"}, "")
if err != nil {
t.Fatalf("cached batch downurl: %v", err)
}
if urls2["batch-pc-a"] != "https://cdn/a.mkv" {
t.Fatalf("cached url lost: %#v", urls2)
}
mu.Lock()
defer mu.Unlock()
if len(requests) != 1 {
t.Fatalf("cache hit should not issue requests, got %v", requests)
}
}
+35
View File
@@ -0,0 +1,35 @@
// 115 开放平台删除类 API:元数据覆盖上传前清理远端旧文件。
package cloud115
import (
"context"
"strings"
)
// DeleteFiles 批量删除 115 文件(官方接口 POST /open/ufile/delete)。
// 删除为异步执行,文件移入回收站。parentID 为待删除文件所在父目录 ID
//(可选提示,空串省略)。fileIDs 中的空项自动忽略,全为空时直接返回成功。
func (c *OpenClient) DeleteFiles(ctx context.Context, parentID string, fileIDs ...string) error {
ids := make([]string, 0, len(fileIDs))
for _, id := range fileIDs {
if id = strings.TrimSpace(id); id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return nil
}
params := map[string]string{"file_ids": strings.Join(ids, ",")}
if parentID = strings.TrimSpace(parentID); parentID != "" {
params["parent_id"] = parentID
}
resp, err := c.doAuthJSON(ctx, "POST", ProAPIBase+"/open/ufile/delete", params, 2)
if err != nil {
return err
}
// doJSON 已把 state=false 转为错误返回,这里兜底防御响应外壳异常
if !resp.State {
return NewOpenAPIResponseError(resp.Code, resp.Errno, resp.Message, resp.Error, "115 删除文件失败")
}
return nil
}
+56
View File
@@ -225,6 +225,62 @@ func (c *OpenClient) GetDownloadURLWithUA(ctx context.Context, pickCode, ua stri
return first.URL.URL, nil return first.URL.URL, nil
} }
// downurlBatchSize 单次批量换取直链的 pick_code 数上限。官方 /open/ufile/downurl
// 支持逗号分隔多个 pick_code,批量可大幅降低元数据下载的换链请求量;大小取
// 保守值,减小单个违规/异常文件导致整批失败的爆炸半径。
const downurlBatchSize = 10
// GetDownloadURLsBatch 批量获取下载直链(pickcode → URL)。先查进程内缓存,
// 仅对未命中的 pick_code 分片发起批量请求;单个分片失败时返回已解析的部分与
// 错误,调用方对缺失项回退到逐个 GetDownloadURLWithUA。UA 语义与单个换取
// 一致:直链绑定换取时的 UA,后续下载必须携带同一 UA。
func (c *OpenClient) GetDownloadURLsBatch(ctx context.Context, pickCodes []string, ua string) (map[string]string, error) {
ua = strings.TrimSpace(ua)
out := make(map[string]string, len(pickCodes))
seen := make(map[string]struct{}, len(pickCodes))
missing := make([]string, 0, len(pickCodes))
for _, pc := range pickCodes {
pc = strings.TrimSpace(pc)
if pc == "" {
continue
}
if _, dup := seen[pc]; dup {
continue
}
seen[pc] = struct{}{}
if cached := GetDownloadURLCache(pc, ua); cached != "" {
out[pc] = cached
continue
}
missing = append(missing, pc)
}
for start := 0; start < len(missing); start += downurlBatchSize {
end := start + downurlBatchSize
if end > len(missing) {
end = len(missing)
}
chunk := missing[start:end]
params := map[string]string{"pick_code": strings.Join(chunk, ",")}
resp, err := c.doAuthJSONWithUA(ctx, "POST", ProAPIBase+"/open/ufile/downurl", params, 1, ua)
if err != nil {
return out, err
}
var data map[string]downloadURLData
if err := json.Unmarshal(resp.Data, &data); err != nil {
return out, fmt.Errorf("115: 解析下载地址失败:%w", err)
}
// 响应以文件 ID 为键,条目内的 pick_code 用于映射回请求侧
for _, item := range data {
if item.PickCode == "" || item.URL.URL == "" {
continue
}
SetDownloadURLCache(item.PickCode, item.URL.URL, ua)
out[item.PickCode] = item.URL.URL
}
}
return out, nil
}
// ─── 授权(设备码扫码) ────────────────────────────────────────────────────── // ─── 授权(设备码扫码) ──────────────────────────────────────────────────────
// QrCodeScanStatus 扫码状态。 // QrCodeScanStatus 扫码状态。
+111 -14
View File
@@ -52,16 +52,28 @@ func (s *StrmService) downloadWorker(ctx context.Context) {
sleepContext(ctx, 2*time.Second) sleepContext(ctx, 2*time.Second)
continue continue
} }
var wg sync.WaitGroup // 处于 WAF 冷却的 115 任务先退回,剩余任务在派发前按账号批量换链:
// downurl 支持逗号分隔多个 pick_code,整批任务一次请求即可完成解析,
// 显著减少全局 QPS 限流下的换链请求量。
runnable := make([]*model.StrmDownloadTask, 0, len(tasks))
for i := range tasks { for i := range tasks {
task := &tasks[i]
if task.Provider == model.StrmProvider115 && s.wafCooldownLeft() > 0 {
s.requeueDownloadTask(task)
continue
}
runnable = append(runnable, task)
}
if len(runnable) == 0 {
continue
}
resolved := s.batchResolve115Links(ctx, runnable)
var wg sync.WaitGroup
for i := range runnable {
wg.Add(1) wg.Add(1)
go func(i int) { go func(i int) {
defer wg.Done() defer wg.Done()
task := &tasks[i] task := runnable[i]
if task.Provider == model.StrmProvider115 && s.wafCooldownLeft() > 0 {
s.requeueDownloadTask(task)
return
}
if !s.acquireDownloadSlot(ctx, task.Provider) { if !s.acquireDownloadSlot(ctx, task.Provider) {
s.requeueDownloadTask(task) s.requeueDownloadTask(task)
return return
@@ -76,7 +88,7 @@ func (s *StrmService) downloadWorker(ctx context.Context) {
s.downloadTaskFailWithRetry(task, "任务执行异常中断") s.downloadTaskFailWithRetry(task, "任务执行异常中断")
} }
}() }()
s.processDownloadTask(ctx, task) s.processDownloadTask(ctx, task, resolved)
completed = true completed = true
}) })
}(i) }(i)
@@ -85,6 +97,69 @@ func (s *StrmService) downloadWorker(ctx context.Context) {
} }
} }
// dlResolveKey 构造批量换链结果 map 的键(按账号隔离,避免极端情况下不同
// 账号的引用串扰)。
func dlResolveKey(accountID, fileRef string) string {
return accountID + "|" + fileRef
}
// batchResolve115Links 在派发执行前对 115 下载任务做批量换链。官方 downurl
// 接口支持逗号分隔多个 pick_code(文档《获取文件下载地址》),按账号把整批
// 任务的 pickcode 合并换取,减少 QPS 限流下的换链请求量。解析结果写入
// pickcode 直链缓存供任务执行时命中;批量失败只记日志并触发风控冷却判定,
// 未解析成功的任务在执行时回退到逐个 Resolve,不影响任务本身。
func (s *StrmService) batchResolve115Links(ctx context.Context, tasks []*model.StrmDownloadTask) map[string]*cloud.DirectLink {
byAcct := map[string][]string{}
seenRef := map[string]map[string]struct{}{}
for _, task := range tasks {
if task.Provider != model.StrmProvider115 {
continue
}
ref := strings.TrimSpace(task.RemoteRef)
if ref == "" {
continue
}
if seenRef[task.AccountID] == nil {
seenRef[task.AccountID] = map[string]struct{}{}
}
if _, dup := seenRef[task.AccountID][ref]; dup {
continue
}
seenRef[task.AccountID][ref] = struct{}{}
byAcct[task.AccountID] = append(byAcct[task.AccountID], ref)
}
resolved := map[string]*cloud.DirectLink{}
for acctID, refs := range byAcct {
acct, err := s.repo.StrmAccount.FindByID(ctx, acctID)
if err != nil || acct == nil {
continue
}
provider, err := s.providerFor(ctx, acct)
if err != nil {
continue
}
batch, ok := provider.(cloud.BatchResolver)
if !ok {
continue
}
links, err := batch.ResolveBatch(ctx, refs)
if err != nil {
if is115Blocked(err) {
s.triggerWAFCooldown()
}
s.log.Warn("batch resolve 115 download links failed; fall back to per-task resolve",
zap.String("account_id", acctID), zap.Int("refs", len(refs)), zap.Error(err))
}
for ref, link := range links {
if link == nil || link.URL == "" {
continue
}
resolved[dlResolveKey(acctID, ref)] = link
}
}
return resolved
}
// requeueDownloadTask 把已认领但未实际执行的任务退回 pending,避免长期停留在 running。 // requeueDownloadTask 把已认领但未实际执行的任务退回 pending,避免长期停留在 running。
// 退回时必须设置 NextTryAt(WAF 冷却剩余时间):claim 只过滤 next_try_at // 退回时必须设置 NextTryAt(WAF 冷却剩余时间):claim 只过滤 next_try_at
// 已过期的任务,不设会让同一批任务被立刻再认领,形成 claim/requeue // 已过期的任务,不设会让同一批任务被立刻再认领,形成 claim/requeue
@@ -104,7 +179,9 @@ func (s *StrmService) requeueDownloadTask(task *model.StrmDownloadTask) {
} }
} }
func (s *StrmService) processDownloadTask(ctx context.Context, task *model.StrmDownloadTask) { // processDownloadTask 处理单个下载任务:解析直链(优先使用批量换链预取的
// 结果,未命中时逐个 Resolve)→ 下载 → 落盘。
func (s *StrmService) processDownloadTask(ctx context.Context, task *model.StrmDownloadTask, resolved map[string]*cloud.DirectLink) {
cleanPath := sanitizeLocalPath(task.LocalPath) cleanPath := sanitizeLocalPath(task.LocalPath)
if cleanPath != "" && cleanPath != task.LocalPath { if cleanPath != "" && cleanPath != task.LocalPath {
task.LocalPath = cleanPath task.LocalPath = cleanPath
@@ -137,13 +214,16 @@ func (s *StrmService) processDownloadTask(ctx context.Context, task *model.StrmD
s.downloadTaskFailWithRetry(task, err.Error()) s.downloadTaskFailWithRetry(task, err.Error())
return return
} }
link, err := provider.Resolve(ctx, task.RemoteRef) link, ok := resolved[dlResolveKey(task.AccountID, task.RemoteRef)]
if err != nil { if !ok || link == nil || link.URL == "" {
if is115Blocked(err) { link, err = provider.Resolve(ctx, task.RemoteRef)
s.triggerWAFCooldown() if err != nil {
if is115Blocked(err) {
s.triggerWAFCooldown()
}
s.downloadTaskFailWithRetry(task, "解析下载地址失败:"+err.Error())
return
} }
s.downloadTaskFailWithRetry(task, "解析下载地址失败:"+err.Error())
return
} }
if err := downloadToFile(ctx, link, task.LocalPath, s.http); err != nil { if err := downloadToFile(ctx, link, task.LocalPath, s.http); err != nil {
// 直链失效(403/404/410 等):清掉缓存让下一轮重新换取 // 直链失效(403/404/410 等):清掉缓存让下一轮重新换取
@@ -286,6 +366,20 @@ func (s *StrmService) processUpload115(ctx context.Context, task *model.StrmUplo
finish(model.StrmTaskFailed, "该网盘不支持元数据上传") finish(model.StrmTaskFailed, "该网盘不支持元数据上传")
return return
} }
// 以本地为准:网盘端已有同名但内容不同的旧元数据时,先删除旧文件再上传。
// 115 的上传接口不保证同名覆盖,直接上传可能产生同名重复文件;删除失败则
// 任务重试(旧文件 ID 失效的场景会在下次同步后自动修复)。
if task.RemoteRef != "" {
open115, ok := provider.(cloud.OpenAPI115Provider)
if !ok {
finish(model.StrmTaskFailed, "该网盘不支持删除远端旧元数据")
return
}
if err := open115.OpenClient().DeleteFiles(ctx, task.RemotePath, task.RemoteRef); err != nil {
s.uploadTaskFailWithRetry(task, "删除网盘旧元数据失败:"+err.Error())
return
}
}
f, err := os.Open(task.LocalPath) f, err := os.Open(task.LocalPath)
if err != nil { if err != nil {
s.uploadTaskFailWithRetry(task, "打开本地文件失败:"+err.Error()) s.uploadTaskFailWithRetry(task, "打开本地文件失败:"+err.Error())
@@ -765,12 +859,15 @@ func (s *StrmService) wafCooldownLeft() time.Duration {
} }
// is115Blocked 判断错误是否来自 115 的风控/限流(WAF 405 拦截页或限流错误码)。 // is115Blocked 判断错误是否来自 115 的风控/限流(WAF 405 拦截页或限流错误码)。
// 覆盖两层文案:HTTP 层(doJSON 的"接口触发频控/安全拦截(HTTP 405)")与
// 业务错误码层(OpenAPIError 的"115 接口错误(406/770004)")。
func is115Blocked(err error) bool { func is115Blocked(err error) bool {
if err == nil { if err == nil {
return false return false
} }
msg := strings.ToLower(err.Error()) msg := strings.ToLower(err.Error())
return strings.Contains(msg, "115 接口返回 http 405") || return strings.Contains(msg, "115 接口返回 http 405") ||
strings.Contains(msg, "115 接口触发频控/安全拦截") ||
strings.Contains(msg, "访问被阻断") || strings.Contains(msg, "访问被阻断") ||
strings.Contains(msg, "request has been blocked") || strings.Contains(msg, "request has been blocked") ||
strings.Contains(msg, "115 接口错误(770004") || strings.Contains(msg, "115 接口错误(770004") ||
+194
View File
@@ -3,11 +3,18 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing" "testing"
"time" "time"
"github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository" "github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud"
"github.com/truewhile/MeBox/internal/service/cloud115"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -18,6 +25,7 @@ func TestIs115Blocked(t *testing.T) {
}{ }{
{errors.New("115 接口返回 HTTP 405:<!doctypehtml>...访问被阻断"), true}, {errors.New("115 接口返回 HTTP 405:<!doctypehtml>...访问被阻断"), true},
{errors.New("115 接口返回 HTTP 405"), true}, {errors.New("115 接口返回 HTTP 405"), true},
{errors.New("115 接口触发频控/安全拦截(HTTP 405):阿里云 WAF 拦截页"), true},
{errors.New("115 接口错误(770004):访问频率过高"), true}, {errors.New("115 接口错误(770004):访问频率过高"), true},
{errors.New("115 接口错误(406):达到访问上限"), true}, {errors.New("115 接口错误(406):达到访问上限"), true},
{errors.New("下载失败:http 403"), false}, {errors.New("下载失败:http 403"), false},
@@ -164,3 +172,189 @@ func TestRequeueDownloadTask(t *testing.T) {
t.Fatalf("task not requeued: %+v", got) t.Fatalf("task not requeued: %+v", got)
} }
} }
// TestProcessUpload115DeletesStaleRemoteMetaFirst 验证 115 覆盖上传语义(以本地为准):
// 任务携带网盘旧文件 ID 时,必须先调用 /open/ufile/delete 删除旧元数据再上传本地文件,
// 避免 115 出现同名重复文件;删除请求应携带 file_ids 与父目录 cid。
func TestProcessUpload115DeletesStaleRemoteMetaFirst(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
localFile := filepath.Join(localDir, "movie.nfo")
if err := os.WriteFile(localFile, []byte("local-nfo-data"), 0o644); err != nil {
t.Fatal(err)
}
acct := &model.StrmAccount{Name: "fake115", Provider: "cloud115", Config: "{}", Enabled: true}
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var calls []string
deleteForm := map[string]string{}
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
mu.Lock()
defer mu.Unlock()
switch r.URL.Path {
case "/open/ufile/delete":
calls = append(calls, "delete")
deleteForm["file_ids"] = r.FormValue("file_ids")
deleteForm["parent_id"] = r.FormValue("parent_id")
w.Write([]byte(`{"state":true,"data":[]}`))
case "/open/upload/init":
calls = append(calls, "upload")
// 返回秒传成功,跳过 OSS 真实上传
w.Write([]byte(`{"state":true,"data":{"status":2,"file_id":"new-1","pick_code":"new-pc-1","callback":null}}`))
default:
t.Errorf("unexpected 115 api path %s", r.URL.Path)
w.Write([]byte(`{"state":false,"message":"unexpected path"}`))
}
}))
defer api.Close()
oldPro := cloud115.ProAPIBase
cloud115.ProAPIBase = api.URL
defer func() { cloud115.ProAPIBase = oldPro }()
task := &model.StrmUploadTask{
Base: model.Base{ID: "up-del-1"},
SyncPathID: "p1",
AccountID: acct.ID,
Provider: model.StrmProvider115,
FileName: "movie.nfo",
LocalPath: localFile,
RemotePath: "777",
RemoteRef: "old-file-1",
Status: model.StrmTaskRunning,
}
svc.processUpload115(context.Background(), task)
if task.Status != model.StrmTaskDone {
t.Fatalf("upload task should succeed, status = %s, error = %s", task.Status, task.Error)
}
mu.Lock()
defer mu.Unlock()
if len(calls) != 2 || calls[0] != "delete" || calls[1] != "upload" {
t.Fatalf("expected delete before upload, got calls = %v", calls)
}
if deleteForm["file_ids"] != "old-file-1" {
t.Fatalf("delete file_ids = %q, want old-file-1", deleteForm["file_ids"])
}
if deleteForm["parent_id"] != "777" {
t.Fatalf("delete parent_id = %q, want 777", deleteForm["parent_id"])
}
}
// TestBatchResolve115Links 验证下载队列的批量换链:同账号多个 115 任务的
// pickcode 合并为一次 downurl 请求(官方接口支持逗号分隔多 pick_code),
// 重复引用去重、非 115 任务不参与、直链携带绑定 UA。
func TestBatchResolve115Links(t *testing.T) {
svc := testStrmService(t)
acct := &model.StrmAccount{Name: "fake115", Provider: model.StrmProvider115, Config: "{}", Enabled: true}
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var requests []string
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/open/ufile/downurl":
_ = r.ParseForm()
mu.Lock()
requests = append(requests, r.PostFormValue("pick_code"))
mu.Unlock()
w.Write([]byte(`{"state":true,"data":{
"111":{"pick_code":"q-pc-a","url":{"url":"http://cdn/a.mkv"}},
"222":{"pick_code":"q-pc-b","url":{"url":"http://cdn/b.jpg"}}}}`))
default:
t.Errorf("unexpected 115 api path %s", r.URL.Path)
w.Write([]byte(`{"state":false,"message":"unexpected path"}`))
}
}))
defer api.Close()
oldPro := cloud115.ProAPIBase
cloud115.ProAPIBase = api.URL
defer func() { cloud115.ProAPIBase = oldPro }()
defer cloud115.ClearDownloadURLCache("q-pc-a")
defer cloud115.ClearDownloadURLCache("q-pc-b")
tasks := []*model.StrmDownloadTask{
{SyncPathID: "p1", AccountID: acct.ID, Provider: model.StrmProvider115, RemoteRef: "q-pc-a"},
{SyncPathID: "p1", AccountID: acct.ID, Provider: model.StrmProvider115, RemoteRef: "q-pc-a"}, // 重复引用
{SyncPathID: "p1", AccountID: acct.ID, Provider: model.StrmProvider115, RemoteRef: "q-pc-b"},
{SyncPathID: "p1", AccountID: acct.ID, Provider: model.StrmProviderOpenList, RemoteRef: "ol-ref"},
{SyncPathID: "p1", AccountID: acct.ID, Provider: model.StrmProvider115, RemoteRef: " "}, // 空引用
}
resolved := svc.batchResolve115Links(context.Background(), tasks)
if got := resolved[dlResolveKey(acct.ID, "q-pc-a")]; got == nil || got.URL != "http://cdn/a.mkv" {
t.Fatalf("missing/bad link for q-pc-a: %+v", resolved)
}
if got := resolved[dlResolveKey(acct.ID, "q-pc-b")]; got == nil || got.URL != "http://cdn/b.jpg" {
t.Fatalf("missing/bad link for q-pc-b: %+v", resolved)
}
if got := resolved[dlResolveKey(acct.ID, "q-pc-a")].Headers["User-Agent"]; got != cloud115.DefaultUA {
t.Fatalf("link UA = %q, want default bound UA", got)
}
if _, ok := resolved[dlResolveKey(acct.ID, "ol-ref")]; ok {
t.Fatal("non-115 task should not be batch resolved")
}
mu.Lock()
defer mu.Unlock()
if len(requests) != 1 || requests[0] != "q-pc-a,q-pc-b" {
t.Fatalf("expected single batched downurl request, got %v", requests)
}
}
// TestProcessDownloadTaskUsesPreResolvedLink 验证任务执行时优先使用批量换链
// 预取的直链:downurl 接口保持失败,若任务仍走逐个换链则必然失败。
func TestProcessDownloadTaskUsesPreResolvedLink(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
acct := &model.StrmAccount{Name: "fake115", Provider: model.StrmProvider115, Config: "{}", Enabled: true}
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
t.Fatal(err)
}
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer api.Close()
oldPro := cloud115.ProAPIBase
cloud115.ProAPIBase = api.URL
defer func() { cloud115.ProAPIBase = oldPro }()
contentSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("User-Agent") != cloud115.DefaultUA {
t.Errorf("download UA = %q, want bound default UA", r.Header.Get("User-Agent"))
}
_, _ = w.Write([]byte("nfo-content"))
}))
defer contentSrv.Close()
task := &model.StrmDownloadTask{
SyncPathID: "p1",
AccountID: acct.ID,
Provider: model.StrmProvider115,
FileName: "movie.nfo",
RemoteRef: "q-pc-pre",
LocalPath: filepath.Join(localDir, "movie.nfo"),
Status: model.StrmTaskRunning,
}
if err := svc.repo.StrmDownload.Create(context.Background(), task); err != nil {
t.Fatal(err)
}
resolved := map[string]*cloud.DirectLink{
dlResolveKey(acct.ID, "q-pc-pre"): {URL: contentSrv.URL, Headers: map[string]string{"User-Agent": cloud115.DefaultUA}},
}
svc.processDownloadTask(context.Background(), task, resolved)
if task.Status != model.StrmTaskDone {
t.Fatalf("task should be done via pre-resolved link, status = %s, error = %s", task.Status, task.Error)
}
data, err := os.ReadFile(task.LocalPath)
if err != nil || string(data) != "nfo-content" {
t.Fatalf("downloaded file mismatch: err = %v, data = %q", err, string(data))
}
}
+1 -1
View File
@@ -67,7 +67,7 @@ var StrmSettingDefs = map[string]struct {
StrmSettingMinVideoSizeMB: {Default: "0", Label: "最小视频大小(MB)", Kind: "number", Help: "小于该大小的视频文件不生成 STRM,0 表示不限"}, StrmSettingMinVideoSizeMB: {Default: "0", Label: "最小视频大小(MB)", Kind: "number", Help: "小于该大小的视频文件不生成 STRM,0 表示不限"},
StrmSettingAddPath: {Default: "1", Label: "STRM 链接 path 参数", Kind: "choice", Choices: []string{"1", "2", "3"}, Help: "1=附带完整远端路径 2=仅文件名 3=不带 path"}, StrmSettingAddPath: {Default: "1", Label: "STRM 链接 path 参数", Kind: "choice", Choices: []string{"1", "2", "3"}, Help: "1=附带完整远端路径 2=仅文件名 3=不带 path"},
StrmSettingDownloadMeta: {Default: "true", Label: "下载元数据", Kind: "bool", Help: "同步时把远端 nfo/图片/字幕下载到本地输出目录"}, StrmSettingDownloadMeta: {Default: "true", Label: "下载元数据", Kind: "bool", Help: "同步时把远端 nfo/图片/字幕下载到本地输出目录"},
StrmSettingUploadMeta: {Default: "false", Label: "上传元数据", Kind: "bool", Help: "同步时把本地元数据上传到远端(需网盘支持写入)"}, StrmSettingUploadMeta: {Default: "false", Label: "上传元数据", Kind: "bool", Help: "同步时把本地元数据上传到远端;本地与网盘元数据不同时以本地为准覆盖(需网盘支持写入)"},
StrmSettingDeleteDir: {Default: "false", Label: "清理空目录", Kind: "bool", Help: "清理远端已删除的多余 .strm/元数据后,删除空目录"}, StrmSettingDeleteDir: {Default: "false", Label: "清理空目录", Kind: "bool", Help: "清理远端已删除的多余 .strm/元数据后,删除空目录"},
Strm115RelayKeySetting: {Default: "", Label: "115 中继授权共享密钥", Kind: "text", Help: "QMediaSync/MQFamily 中继授权的共享 AES 密钥(OAUTH_RELAY_ENCRYPTION_KEY);不配置则中继授权不可用"}, Strm115RelayKeySetting: {Default: "", Label: "115 中继授权共享密钥", Kind: "text", Help: "QMediaSync/MQFamily 中继授权的共享 AES 密钥(OAUTH_RELAY_ENCRYPTION_KEY);不配置则中继授权不可用"},
StrmSettingDownloadThreads: {Default: "6", Label: "下载队列线程数", Kind: "number", Help: "OpenList/CloudDrive2 元数据下载并发数(115 独立限速为 3)"}, StrmSettingDownloadThreads: {Default: "6", Label: "下载队列线程数", Kind: "number", Help: "OpenList/CloudDrive2 元数据下载并发数(115 独立限速为 3)"},
+189 -35
View File
@@ -41,7 +41,9 @@ type strmSyncState struct {
lastProgressFlush time.Time // 上次进度落库时间 lastProgressFlush time.Time // 上次进度落库时间
seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频 seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据 seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
remoteMeta map[string]int64 // 远端元数据大小(上传比对用) remoteMeta map[string]int64 // 远端元数据大小(上传比对用)
remoteMetaRef map[string]string // "m:"+相对路径 → 远端元数据文件引用(115 文件 ID,覆盖上传前删除旧文件用)
remoteMetaSha1 map[string]string // "m:"+相对路径 → 远端元数据内容 SHA1(115 列表返回;上传/下载精确比对用,其他网盘为空)
seenMetaTarget map[string]cloud.FileEntry seenMetaTarget map[string]cloud.FileEntry
seenVideoTarget map[string]cloud.FileEntry seenVideoTarget map[string]cloud.FileEntry
activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重) activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重)
@@ -50,6 +52,7 @@ type strmSyncState struct {
pendingUploads []*model.StrmUploadTask pendingUploads []*model.StrmUploadTask
dirCache sync.Map // dirID (string) -> relativePath (string) dirCache sync.Map // dirID (string) -> relativePath (string)
dirPathToID map[string]string // relativePath (string) -> dirID(115 上传父目录寻址用,walk 后构建) dirPathToID map[string]string // relativePath (string) -> dirID(115 上传父目录寻址用,walk 后构建)
dirCacheDirty map[string]string // 待批量落库的目录缓存(dirID → 相对路径),避免逐目录单条 upsert
scanIncomplete atomic.Bool // 远端目录树/文件列表本次扫描不完整 → 禁止增量 prune 误删本地文件 scanIncomplete atomic.Bool // 远端目录树/文件列表本次扫描不完整 → 禁止增量 prune 误删本地文件
} }
@@ -201,6 +204,8 @@ func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *m
seenVideo: map[string]bool{}, seenVideo: map[string]bool{},
seenMeta: map[string]bool{}, seenMeta: map[string]bool{},
remoteMeta: map[string]int64{}, remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
seenMetaTarget: map[string]cloud.FileEntry{}, seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{}, seenVideoTarget: map[string]cloud.FileEntry{},
} }
@@ -331,6 +336,11 @@ func (st *strmSyncState) run() error {
// 多个目录列表请求的网络往返彼此重叠,大幅缩短大目录树同步耗时。 // 多个目录列表请求的网络往返彼此重叠,大幅缩短大目录树同步耗时。
const strmScanWorkers = 8 const strmScanWorkers = 8
// strmProcessWorkers 115 平铺拉取后本地文件分类处理(生成 strm/元数据入队)
// 的 worker 数。本地磁盘 I/O 是大库同步的尾部瓶颈,输出目录在网络挂载上尤甚;
// processRemoteFile 的共享状态均由 st.mu 保护,可安全并发。
const strmProcessWorkers = 8
// walkRemote 并发广度优先遍历网盘目录树。 // walkRemote 并发广度优先遍历网盘目录树。
// 多个 worker 并行执行 List(受全局 115 令牌桶限流约束),子目录动态 // 多个 worker 并行执行 List(受全局 115 令牌桶限流约束),子目录动态
// 入队;任一目录失败则取消其余 worker 并返回错误(与旧串行版语义一致)。 // 入队;任一目录失败则取消其余 worker 并返回错误(与旧串行版语义一致)。
@@ -509,8 +519,8 @@ func (st *strmSyncState) isMetaExt(ext string) bool {
// cleanDirRel 对 115 扁平化拉取的目录相对路径逐段套用目录级文件名清洗, // cleanDirRel 对 115 扁平化拉取的目录相对路径逐段套用目录级文件名清洗,
// 确保与 walkRemote / joinLocalRel(sanitizeRelativePath)使用同一套清洗规则。 // 确保与 walkRemote / joinLocalRel(sanitizeRelativePath)使用同一套清洗规则。
// 若不清洗,目录名中的冒号等非法字符会直达 rel,而 seenVideo/seenMeta 的 key // 若不清洗,目录名中的冒号等非法字符会直达 rel,而 seenVideo/remoteMeta 的 key
// 与磁盘实际路径不一致,导致 pruneLocal 误删已下载的 strm / 元数据。 // 与磁盘实际路径不一致,导致 pruneLocal 误删已下载的 strm、上传误传或重复下载。
// 空 rel(根目录)原样返回。 // 空 rel(根目录)原样返回。
func cleanDirRel(rel string) string { func cleanDirRel(rel string) string {
if rel == "" { if rel == "" {
@@ -530,6 +540,34 @@ func cleanDirRel(rel string) string {
return strings.Join(out, "/") return strings.Join(out, "/")
} }
// deferDirCacheSave 暂存一条目录缓存写入,由 flushDirCacheSave 统一批量落库。
// 首次全量同步可能有上万个目录,逐目录单条 upsert 会造成明显的 SQLite 写锁
// 竞争;内存 dirCache(sync.Map)始终即时可用,落库仅服务于下次增量预加载。
func (st *strmSyncState) deferDirCacheSave(dirID, relPath string) {
st.mu.Lock()
if st.dirCacheDirty == nil {
st.dirCacheDirty = map[string]string{}
}
st.dirCacheDirty[dirID] = relPath
st.mu.Unlock()
}
// flushDirCacheSave 把暂存的目录缓存一次性批量落库;失败仅记日志(缓存缺失
// 只影响下次增量的目录解析提速,正确性由"重新向 115 获取"兜底)。
func (st *strmSyncState) flushDirCacheSave() {
st.mu.Lock()
dirty := st.dirCacheDirty
st.dirCacheDirty = nil
st.mu.Unlock()
if len(dirty) == 0 {
return
}
if err := st.s.repo.StrmDirCache.SetBatch(st.ctx, st.p.ID, dirty); err != nil {
st.s.log.Warn("batch save strm dir cache failed",
zap.Error(err), zap.Int("count", len(dirty)), zap.String("path_id", st.p.ID))
}
}
// walk115Flat 使用 115 开放平台扁平化分页批量拉取机制与目录拓扑缓存(参考 QMediaSync)。 // walk115Flat 使用 115 开放平台扁平化分页批量拉取机制与目录拓扑缓存(参考 QMediaSync)。
// 极大地降低 API 请求次数并支持毫秒级/秒级增量同步。 // 极大地降低 API 请求次数并支持毫秒级/秒级增量同步。
func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error { func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
@@ -721,7 +759,7 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
// 解析相对路径 // 解析相对路径
relPath := cleanDirRel(detail.RelativePath(rootCID)) relPath := cleanDirRel(detail.RelativePath(rootCID))
st.dirCache.Store(pid, relPath) st.dirCache.Store(pid, relPath)
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, pid, relPath) st.deferDirCacheSave(pid, relPath)
// 顺便解析并缓存 detail.Paths 中包含的中间各层级目录 // 顺便解析并缓存 detail.Paths 中包含的中间各层级目录
for _, ancestor := range detail.Paths { for _, ancestor := range detail.Paths {
@@ -742,7 +780,7 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
} }
ancestorRel := cleanDirRel(subDetail.RelativePath(rootCID)) ancestorRel := cleanDirRel(subDetail.RelativePath(rootCID))
st.dirCache.Store(ancestor.FileId, ancestorRel) st.dirCache.Store(ancestor.FileId, ancestorRel)
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, ancestor.FileId, ancestorRel) st.deferDirCacheSave(ancestor.FileId, ancestorRel)
} }
} }
} }
@@ -757,6 +795,9 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
}() }()
} }
pwg.Wait() pwg.Wait()
// 目录解析阶段结束即批量落库已解析的缓存:失败路径也保留部分成果,
// 下次同步可少解析一批目录。
st.flushDirCacheSave()
if firstErr != nil { if firstErr != nil {
// 目录树解析失败会导致 rel 塌缩,若继续处理会让大量本地文件 // 目录树解析失败会导致 rel 塌缩,若继续处理会让大量本地文件
// 被错误判定为"云端不存在"而重复下载/上传,并可能误删本地文件。 // 被错误判定为"云端不存在"而重复下载/上传,并可能误删本地文件。
@@ -767,10 +808,57 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
st.updateSyncMessage(fmt.Sprintf("正在生成 STRM 与同步文件 (共 %d 个)...", len(allFiles))) st.updateSyncMessage(fmt.Sprintf("正在生成 STRM 与同步文件 (共 %d 个)...", len(allFiles)))
// 5. 分类处理所有文件 // 5. 分类处理所有文件。本地磁盘 I/O(Stat/读内容比对/写盘)远慢于列表
// 拉取,串行消化是大库同步的尾部瓶颈(输出目录在网络挂载上尤甚);
// processRemoteFile 的共享状态均由 st.mu 保护(walkRemote 已并发调用),
// 这里用有界 worker 池并行处理。rel 构建依赖 dirCache 且需在父目录缺失
// 时整体中止,保留在生产者侧串行完成。
type strmFileTask struct {
file cloud115.RemoteFile
rel string
}
fileCh := make(chan strmFileTask)
var (
procWg sync.WaitGroup
procErrMu sync.Mutex
procErr error
)
for i := 0; i < strmProcessWorkers; i++ {
procWg.Add(1)
go func() {
defer procWg.Done()
if err := helper.Recover(st.s.log, "strm.sync.walk115.process", func() error {
for t := range fileCh {
// 中止(ctx 取消)后排空队列即可,不再产生任何写操作
if ctx.Err() != nil {
continue
}
entry := cloud.FileEntry{
ID: t.file.FileId,
Name: t.file.FileName,
IsDir: false,
Size: t.file.FileSize,
MTime: t.file.Utime,
PickCode: t.file.PickCode,
Sha1: t.file.Sha1,
}
st.processRemoteFile(entry, t.rel)
}
return nil
}); err != nil {
procErrMu.Lock()
if procErr == nil {
procErr = err
}
procErrMu.Unlock()
cancel()
}
}()
}
feed:
for _, f := range allFiles { for _, f := range allFiles {
if ctx.Err() != nil { if ctx.Err() != nil {
return ctx.Err() break
} }
cleanName := cleanEntryName(f.FileName, false) cleanName := cleanEntryName(f.FileName, false)
var rel string var rel string
@@ -783,21 +871,27 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
// 父目录不在目录缓存,无法还原真实相对路径。若继续用塌缩后的 // 父目录不在目录缓存,无法还原真实相对路径。若继续用塌缩后的
// 根路径处理,该文件会被错误判定,导致重复下载/上传或误删本地文件。 // 根路径处理,该文件会被错误判定,导致重复下载/上传或误删本地文件。
// 目录树不完整时宁可中止本次同步,也不带着损坏的 rel 继续执行。 // 目录树不完整时宁可中止本次同步,也不带着损坏的 rel 继续执行。
return fmt.Errorf("115: 文件 %s 的父目录未解析成功,目录树不完整,中止同步以防误删/误传", cleanName) procErrMu.Lock()
if procErr == nil {
procErr = fmt.Errorf("115: 文件 %s 的父目录未解析成功,目录树不完整,中止同步以防误删/误传", cleanName)
}
procErrMu.Unlock()
cancel()
break
} }
} }
entry := cloud.FileEntry{ select {
ID: f.FileId, case fileCh <- strmFileTask{file: f, rel: rel}:
Name: f.FileName, case <-ctx.Done():
IsDir: false, break feed
Size: f.FileSize,
MTime: f.Utime,
PickCode: f.PickCode,
} }
st.processRemoteFile(entry, rel)
} }
close(fileCh)
return nil procWg.Wait()
if procErr != nil {
return procErr
}
return ctx.Err()
} }
// handleVideo 生成/更新 .strm 文件。 // handleVideo 生成/更新 .strm 文件。
@@ -933,11 +1027,46 @@ func (st *strmSyncState) strmPathParam(rel string) string {
} }
} }
// recordRemoteMeta 记录远端存在的元数据索引及文件大小。 // usableSha1 归一化远端内容哈希:115 对目录/未完成文件可能返回空串或占位符 "-",均视为不可用。
func usableSha1(sha string) string {
sha = strings.TrimSpace(sha)
if sha == "-" {
return ""
}
return sha
}
// localSha1Matches 计算本地文件 SHA1 并与远端哈希做大小写不敏感比对
// (115 列表返回大写 hex,本地计算为小写)。读取/哈希失败按"视为同一文件"
// 处理,避免瞬时读文件错误触发大规模重复上传/下载。
func (st *strmSyncState) localSha1Matches(path, remoteSha1 string) bool {
local, err := cloud115.FileSHA1(path)
if err != nil {
st.s.log.Warn("strm 计算本地元数据 SHA1 失败,按同一文件处理",
zap.String("path", path), zap.Error(err))
return true
}
return strings.EqualFold(local, remoteSha1)
}
// recordRemoteMeta 记录远端存在的元数据索引、文件大小、文件引用及内容 SHA1。
func (st *strmSyncState) recordRemoteMeta(entry cloud.FileEntry, rel string) { func (st *strmSyncState) recordRemoteMeta(entry cloud.FileEntry, rel string) {
st.mu.Lock() st.mu.Lock()
if st.remoteMeta == nil {
st.remoteMeta = map[string]int64{}
}
if st.remoteMetaRef == nil {
st.remoteMetaRef = map[string]string{}
}
if st.remoteMetaSha1 == nil {
st.remoteMetaSha1 = map[string]string{}
}
st.seenMeta["m:"+rel] = true st.seenMeta["m:"+rel] = true
st.remoteMeta["m:"+rel] = entry.Size st.remoteMeta["m:"+rel] = entry.Size
st.remoteMetaRef["m:"+rel] = entry.ID
if sha := usableSha1(entry.Sha1); sha != "" {
st.remoteMetaSha1["m:"+rel] = sha
}
st.mu.Unlock() st.mu.Unlock()
} }
@@ -971,7 +1100,8 @@ func (st *strmSyncState) flushPendingUploads() {
} }
} }
// handleMeta 元数据入下载队列(本地已存在且大小一致则跳过)。 // handleMeta 元数据入下载队列。本地已存在时:开启上传元数据则一律跳过(以本地
// 为准);否则大小与 SHA1(115 提供)均一致视为同一文件跳过,内容不同则下载覆盖。
func (st *strmSyncState) handleMeta(entry cloud.FileEntry, rel, ext string) { func (st *strmSyncState) handleMeta(entry cloud.FileEntry, rel, ext string) {
st.recordRemoteMeta(entry, rel) st.recordRemoteMeta(entry, rel)
@@ -993,9 +1123,22 @@ func (st *strmSyncState) handleMeta(entry cloud.FileEntry, rel, ext string) {
st.seenMetaTarget[target] = entry st.seenMetaTarget[target] = entry
st.mu.Unlock() st.mu.Unlock()
if info, err := os.Stat(target); err == nil && info.Size() == entry.Size { if info, err := os.Stat(target); err == nil {
st.touchProgress() // 开启上传元数据时以本地为准:本地已存在的元数据不再用远端版本覆盖,
return // 与网盘版本的差异交给上传队列把本地文件推回网盘,避免下载/上传
// 两个队列互相覆盖形成回环。
if st.cfg.UploadMeta {
st.touchProgress()
return
}
// 同名同大小:115 提供远端 SHA1 时做内容级比对,网盘更新了同大小
// 元数据也能被下载到本地;无哈希(其他网盘/列表未返回)或哈希一致
// 视为同一文件跳过。
remoteSha := usableSha1(entry.Sha1)
if info.Size() == entry.Size && (remoteSha == "" || st.localSha1Matches(target, remoteSha)) {
st.touchProgress()
return
}
} }
st.mu.Lock() st.mu.Lock()
if st.activeDownloadPaths == nil { if st.activeDownloadPaths == nil {
@@ -1135,6 +1278,8 @@ func (st *strmSyncState) walkLocalSource() error {
} }
// scanLocalMetaForUpload 扫描本地元数据,与远端比对后入上传队列。 // scanLocalMetaForUpload 扫描本地元数据,与远端比对后入上传队列。
// 以本地为准:网盘端不存在、同名不同大小、或同名同大小但 SHA1 不同(115 提供
// 远端哈希时做内容级比对)均入队覆盖上传;同名同大小同内容视为同一文件跳过。
func (st *strmSyncState) scanLocalMetaForUpload() error { func (st *strmSyncState) scanLocalMetaForUpload() error {
defer st.flushPendingUploads() defer st.flushPendingUploads()
if st.activeUploadPaths == nil { if st.activeUploadPaths == nil {
@@ -1174,12 +1319,22 @@ func (st *strmSyncState) scanLocalMetaForUpload() error {
return nil return nil
} }
st.mu.Lock() st.mu.Lock()
_, exists := st.remoteMeta["m:"+rel] remoteSize, exists := st.remoteMeta["m:"+rel]
remoteRef := st.remoteMetaRef["m:"+rel]
remoteSha1 := st.remoteMetaSha1["m:"+rel]
st.mu.Unlock() st.mu.Unlock()
if exists { if exists && remoteSize == info.Size() {
// 网盘端已存在该元数据文件,跳过上传 // 网盘端同名同大小:候选同一文件。115 提供远端 SHA1 时做内容级比对,
return nil // 识别"同大小不同内容"(如 nfo 改一个字符长度不变)避免漏传;
// 无哈希(其他网盘/列表未返回)视为同一文件,保持大小比对兜底。
if remoteSha1 == "" || st.localSha1Matches(path, remoteSha1) {
return nil
}
} }
// 入队上传(以本地为准):网盘端不存在;或同名但大小不同(必然内容不同);
// 或同名同大小但 SHA1 不同(精确比对发现的同大小不同内容)。
// 115 的上传接口不保证同名覆盖,任务携带远端旧文件 ID(RemoteRef),
// 由上传端先删旧文件再上传;WebDAV/OpenList 的 PutFile 本身即覆盖上传。
st.mu.Lock() st.mu.Lock()
if st.activeUploadPaths != nil && st.activeUploadPaths[path] { if st.activeUploadPaths != nil && st.activeUploadPaths[path] {
st.mu.Unlock() st.mu.Unlock()
@@ -1200,6 +1355,9 @@ func (st *strmSyncState) scanLocalMetaForUpload() error {
Size: info.Size(), Size: info.Size(),
Status: model.StrmTaskPending, Status: model.StrmTaskPending,
} }
if exists && st.p.Provider == model.StrmProvider115 {
task.RemoteRef = remoteRef
}
st.mu.Lock() st.mu.Lock()
st.pendingUploads = append(st.pendingUploads, task) st.pendingUploads = append(st.pendingUploads, task)
shouldFlush := len(st.pendingUploads) >= 100 shouldFlush := len(st.pendingUploads) >= 100
@@ -1260,10 +1418,11 @@ func (st *strmSyncState) taskExists(kind, syncPathID, localPath string) bool {
return count > 0 return count > 0
} }
// pruneLocal 清理本地多余 .strm 与元数据(远端已不存在),可选删除空目录。 // pruneLocal 清理本地多余 .strm(远端已不存在的视频),可选删除空目录。
// 元数据文件(nfo/图片/字幕等)一律保留:本地刮削结果不因网盘端缺失而被删除。
func (st *strmSyncState) pruneLocal() error { func (st *strmSyncState) pruneLocal() error {
// 增量同步保护:本次远端扫描不完整(目录详情解析失败 / 文件父路径降级)时, // 增量同步保护:本次远端扫描不完整(目录详情解析失败 / 文件父路径降级)时,
// seenVideo/seenMeta 覆盖不全,按"远端不存在"清理会误删刚下载或已存在的本地文件, // seenVideo 覆盖不全,按"远端不存在"清理会误删刚下载或已存在的本地 .strm,
// 进而触发"下次增量重新下载"的循环。此时跳过清理,仅做进度落库。 // 进而触发"下次增量重新下载"的循环。此时跳过清理,仅做进度落库。
if st.syncType == model.StrmSyncTypeIncremental && st.scanIncomplete.Load() { if st.syncType == model.StrmSyncTypeIncremental && st.scanIncomplete.Load() {
st.s.log.Warn("strm 增量同步跳过清理:本次远端扫描不完整,prune 已禁用", st.s.log.Warn("strm 增量同步跳过清理:本次远端扫描不完整,prune 已禁用",
@@ -1295,16 +1454,11 @@ func (st *strmSyncState) pruneLocal() error {
rel = filepath.ToSlash(rel) rel = filepath.ToSlash(rel)
ext := strings.ToLower(filepath.Ext(rel)) ext := strings.ToLower(filepath.Ext(rel))
remove := false remove := false
switch { if ext == ".strm" {
case ext == ".strm":
relSansExt := rel[:len(rel)-len(ext)] relSansExt := rel[:len(rel)-len(ext)]
st.mu.Lock() st.mu.Lock()
remove = !st.seenVideo["v:"+relSansExt] remove = !st.seenVideo["v:"+relSansExt]
st.mu.Unlock() st.mu.Unlock()
case st.isMetaExt(ext) && st.cfg.DownloadMeta && !st.cfg.UploadMeta && st.p.Provider != model.StrmProviderLocal:
st.mu.Lock()
remove = !st.seenMeta["m:"+rel]
st.mu.Unlock()
} }
if remove { if remove {
if err := os.Remove(path); err == nil { if err := os.Remove(path); err == nil {
+464 -19
View File
@@ -344,14 +344,19 @@ func TestSanitizePathWithSpecialChars(t *testing.T) {
} }
} }
// TestScanLocalMetaForUpload 验证元数据上传比对逻辑:网盘已存在跳过,网盘不存在才入队上传。 // TestScanLocalMetaForUpload 验证元数据上传比对逻辑:网盘同名同大小(同一文件)跳过,
// 网盘不存在或同名不同大小(内容不同的元数据文件)以本地为准入队上传。
func TestScanLocalMetaForUpload(t *testing.T) { func TestScanLocalMetaForUpload(t *testing.T) {
svc := testStrmService(t) svc := testStrmService(t)
localDir := t.TempDir() localDir := t.TempDir()
// 本地有 2 个元数据:poster.jpg 和 fanart.jpg // 本地有 3 个元数据:
// poster.jpg — 网盘已有同名同大小 → 同一文件,跳过
// fanart.jpg — 网盘没有 → 上传
// tvshow.nfo — 网盘已有同名但大小不同(内容不同的元数据文件)→ 以本地为准覆盖上传
writeFile(t, filepath.Join(localDir, "动漫", "poster.jpg"), "poster-data") writeFile(t, filepath.Join(localDir, "动漫", "poster.jpg"), "poster-data")
writeFile(t, filepath.Join(localDir, "动漫", "fanart.jpg"), "fanart-data") writeFile(t, filepath.Join(localDir, "动漫", "fanart.jpg"), "fanart-data")
writeFile(t, filepath.Join(localDir, "动漫", "tvshow.nfo"), "local-nfo-data")
p := &model.StrmSyncPath{ p := &model.StrmSyncPath{
Base: model.Base{ID: "test-path-upload"}, Base: model.Base{ID: "test-path-upload"},
@@ -363,35 +368,47 @@ func TestScanLocalMetaForUpload(t *testing.T) {
} }
st := &strmSyncState{ st := &strmSyncState{
s: svc, s: svc,
ctx: context.Background(), ctx: context.Background(),
p: p, p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"jpg", "nfo"}}, cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"jpg", "nfo"}},
rec: &model.StrmSyncRecord{}, rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{}, seenMeta: map[string]bool{},
remoteMeta: map[string]int64{}, remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
} }
// 模拟远端已存在 poster.jpg // 模拟远端已存在 poster.jpg(与本地同一文件)和 tvshow.nfo(与本地不同)
st.remoteMeta["m:动漫/poster.jpg"] = 1000 st.remoteMeta["m:动漫/poster.jpg"] = int64(len("poster-data"))
st.remoteMeta["m:动漫/tvshow.nfo"] = 999
if err := st.scanLocalMetaForUpload(); err != nil { if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err) t.Fatalf("scanLocalMetaForUpload failed: %v", err)
} }
// 此时应该只有 fanart.jpg 入队上传,poster.jpg 被跳过 // fanart.jpg(网盘缺失)与 tvshow.nfo(网盘版本不同)入队,poster.jpg 跳过
tasks, _, err := svc.repo.StrmUpload.List(context.Background(), "", 1, 10) tasks, _, err := svc.repo.StrmUpload.List(context.Background(), "", 1, 10)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(tasks) != 1 { if len(tasks) != 2 {
t.Fatalf("expected 1 upload task (fanart.jpg), got %d", len(tasks)) t.Fatalf("expected 2 upload tasks (fanart.jpg, tvshow.nfo), got %d", len(tasks))
} }
if tasks[0].FileName != "fanart.jpg" { got := map[string]model.StrmUploadTask{}
t.Errorf("expected upload task for fanart.jpg, got %s", tasks[0].FileName) for _, task := range tasks {
got[task.FileName] = task
}
if _, ok := got["fanart.jpg"]; !ok {
t.Errorf("expected upload task for fanart.jpg, got %v", taskNames(tasks))
}
if _, ok := got["tvshow.nfo"]; !ok {
t.Errorf("expected upload task for tvshow.nfo (local wins), got %v", taskNames(tasks))
}
if _, ok := got["poster.jpg"]; ok {
t.Errorf("poster.jpg (same size on remote) should be skipped")
} }
// 再次扫描:fanart.jpg 已经在队列中,应自动去重,不重复入队 // 再次扫描:已入队的文件自动去重,不重复入队
if err := st.scanLocalMetaForUpload(); err != nil { if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("second scanLocalMetaForUpload failed: %v", err) t.Fatalf("second scanLocalMetaForUpload failed: %v", err)
} }
@@ -399,8 +416,313 @@ func TestScanLocalMetaForUpload(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(tasks) != 2 {
t.Fatalf("expected still 2 upload tasks after dedup, got %d", len(tasks))
}
}
// TestScanLocalMetaForUpload115CarriesRemoteRef 验证 115 覆盖上传时任务携带
// 网盘旧文件 ID(供上传前删除旧文件,避免同名重复),网盘无同名文件时不携带。
func TestScanLocalMetaForUpload115CarriesRemoteRef(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
// movie.nfo 网盘已有同名但大小不同(需要删除旧文件后覆盖上传)
writeFile(t, filepath.Join(localDir, "movie.nfo"), "local-nfo-data")
// fresh.nfo 网盘没有(普通上传,不携带 ref)
writeFile(t, filepath.Join(localDir, "fresh.nfo"), "fresh-nfo")
p := &model.StrmSyncPath{
Base: model.Base{ID: "test-path-upload-115"},
AccountID: "acct-1",
Provider: model.StrmProvider115,
RemotePath: "100",
LocalPath: localDir,
UploadMeta: true,
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"jpg", "nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
}
st.remoteMeta["m:movie.nfo"] = 1
st.remoteMetaRef["m:movie.nfo"] = "file-42"
if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err)
}
tasks, _, err := svc.repo.StrmUpload.List(context.Background(), "", 1, 10)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 2 {
t.Fatalf("expected 2 upload tasks, got %d", len(tasks))
}
for _, task := range tasks {
switch task.FileName {
case "movie.nfo":
if task.RemoteRef != "file-42" {
t.Errorf("movie.nfo upload task should carry remote ref %q, got %q", "file-42", task.RemoteRef)
}
if task.RemotePath != "100" {
t.Errorf("movie.nfo upload task remote path = %q, want parent cid %q", task.RemotePath, "100")
}
case "fresh.nfo":
if task.RemoteRef != "" {
t.Errorf("fresh.nfo (not on remote) should not carry remote ref, got %q", task.RemoteRef)
}
default:
t.Errorf("unexpected upload task %s", task.FileName)
}
}
}
func taskNames(tasks []model.StrmUploadTask) []string {
names := make([]string, 0, len(tasks))
for _, task := range tasks {
names = append(names, task.FileName)
}
return names
}
// TestPruneLocalKeepsLocalMeta 验证清理规则:远端已删除的视频 .strm 仍会被清理,
// 但本地元数据一律保留(即使开启"下载元数据"且未开启"上传元数据"、网盘端没有
// 该元数据,也不再删除本地刮削好的 nfo/图片/字幕)。
func TestPruneLocalKeepsLocalMeta(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
// 阿凡达.strm(对应视频已被网盘删除 → 应清理)+ 阿凡达.nfo(网盘没有 → 保留)
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.strm"), "http://test.local:8096/x")
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.nfo"), "<local scraped meta/>")
writeFile(t, filepath.Join(localDir, "电影", "poster.jpg"), "local-poster")
p := &model.StrmSyncPath{
Base: model.Base{ID: "prune-meta-path"},
Provider: model.StrmProvider115,
RemotePath: "0",
LocalPath: localDir,
DownloadMeta: true,
UploadMeta: false,
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{DownloadMeta: true, UploadMeta: false, MetaExt: []string{"nfo", "jpg"}},
rec: &model.StrmSyncRecord{},
syncType: model.StrmSyncTypeFull,
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
}
// 本次远端扫描既没有看到视频,也没有看到任何元数据
if err := st.pruneLocal(); err != nil {
t.Fatalf("pruneLocal failed: %v", err)
}
if _, err := os.Stat(filepath.Join(localDir, "电影", "阿凡达.strm")); !os.IsNotExist(err) {
t.Fatalf("orphan .strm should be pruned, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(localDir, "电影", "阿凡达.nfo")); err != nil {
t.Fatalf("local meta must be kept even when missing on remote: %v", err)
}
if _, err := os.Stat(filepath.Join(localDir, "电影", "poster.jpg")); err != nil {
t.Fatalf("local poster must be kept even when missing on remote: %v", err)
}
if st.rec.Pruned != 1 {
t.Fatalf("expected 1 pruned (strm only), got %d", st.rec.Pruned)
}
}
// TestHandleMetaKeepsLocalWhenUploadEnabled 验证下载侧规则:开启"上传元数据"时
// 以本地为准——本地已存在的元数据(即使与网盘大小不同)不再入下载队列被网盘版本
// 覆盖;本地不存在的元数据仍正常入队下载。
func TestHandleMetaKeepsLocalWhenUploadEnabled(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
// 本地已有 test.nfo(大小 50,与网盘版本大小 100 不同)
writeFile(t, filepath.Join(localDir, "test.nfo"), strings.Repeat("L", 50))
p := &model.StrmSyncPath{
Base: model.Base{ID: "meta-local-wins-path"},
Provider: model.StrmProvider115,
RemotePath: "0",
LocalPath: localDir,
DownloadMeta: true,
UploadMeta: true,
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{DownloadMeta: true, UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
seenMetaTarget: map[string]cloud.FileEntry{},
}
// 网盘版本与本地不同:本地存在 → 不入下载队列(以本地为准)
entry := cloud.FileEntry{ID: "r1", Name: "test.nfo", Size: 100, PickCode: "pc1"}
st.handleMeta(entry, "test.nfo", ".nfo")
// 网盘独有:本地不存在 → 正常入下载队列
missing := cloud.FileEntry{ID: "r2", Name: "absent.nfo", Size: 200, PickCode: "pc2"}
st.handleMeta(missing, "absent.nfo", ".nfo")
st.flushPendingDownloads()
tasks, _, err := svc.repo.StrmDownload.List(context.Background(), "", 1, 10)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 1 { if len(tasks) != 1 {
t.Fatalf("expected still 1 upload task after dedup, got %d", len(tasks)) t.Fatalf("expected only 1 download task (absent.nfo), got %d", len(tasks))
}
if tasks[0].FileName != "absent.nfo" {
t.Fatalf("expected download task for absent.nfo, got %s", tasks[0].FileName)
}
if st.rec.NewMeta != 1 {
t.Fatalf("expected NewMeta = 1, got %d", st.rec.NewMeta)
}
}
// TestScanLocalMetaForUploadSha1Identity 验证 115 远端 SHA1 可用时按内容精确比对:
// 同名同大小同内容(大小写不敏感)跳过;同名同大小不同内容以本地为准覆盖上传,
// 且任务携带网盘旧文件 ID。纯大小比对无法识别"同大小不同内容"(如 nfo 改一个字符)。
func TestScanLocalMetaForUploadSha1Identity(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
// same.nfo / diff.nfo 本地与网盘大小均相同:
// same.nfo 网盘内容与本地一致(SHA1 相同)→ 同一文件,跳过;
// diff.nfo 网盘上是另一个同大小文件(SHA1 不同)→ 以本地为准覆盖上传。
writeFile(t, filepath.Join(localDir, "same.nfo"), "same-content")
writeFile(t, filepath.Join(localDir, "diff.nfo"), "diff-content")
otherFile := filepath.Join(localDir, "other.tmp")
writeFile(t, otherFile, "other-content!")
sameSha, err := cloud115.FileSHA1(filepath.Join(localDir, "same.nfo"))
if err != nil {
t.Fatal(err)
}
otherSha, err := cloud115.FileSHA1(otherFile)
if err != nil {
t.Fatal(err)
}
p := &model.StrmSyncPath{
Base: model.Base{ID: "test-path-upload-sha1"},
AccountID: "acct-1",
Provider: model.StrmProvider115,
RemotePath: "100",
LocalPath: localDir,
UploadMeta: true,
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
}
// 115 返回大写 SHA1,本地计算为小写:同时验证大小写不敏感比对
st.remoteMeta["m:same.nfo"] = int64(len("same-content"))
st.remoteMetaSha1["m:same.nfo"] = strings.ToUpper(sameSha)
st.remoteMeta["m:diff.nfo"] = int64(len("diff-content"))
st.remoteMetaSha1["m:diff.nfo"] = strings.ToUpper(otherSha)
st.remoteMetaRef["m:diff.nfo"] = "old-diff-1"
if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err)
}
tasks, _, err := svc.repo.StrmUpload.List(context.Background(), "", 1, 10)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 1 {
t.Fatalf("expected 1 upload task (diff.nfo), got %d: %v", len(tasks), taskNames(tasks))
}
if tasks[0].FileName != "diff.nfo" {
t.Fatalf("expected upload task for diff.nfo, got %s", tasks[0].FileName)
}
if tasks[0].RemoteRef != "old-diff-1" {
t.Fatalf("diff.nfo task should carry remote ref %q, got %q", "old-diff-1", tasks[0].RemoteRef)
}
}
// TestHandleMetaSha1Identity 验证下载侧(未开启上传时镜像网盘元数据):
// 同名同大小同 SHA1 跳过;网盘更新了同大小元数据(SHA1 不同)仍会下载;
// 网盘未返回哈希时退回大小比对。
func TestHandleMetaSha1Identity(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
writeFile(t, filepath.Join(localDir, "same.nfo"), "identical-data") // 与网盘内容一致
writeFile(t, filepath.Join(localDir, "stale.nfo"), "stale-content!!") // 网盘已更新为同大小新内容
writeFile(t, filepath.Join(localDir, "nohash.nfo"), "nohash-content") // 网盘未返回 SHA1
otherFile := filepath.Join(localDir, "other.tmp")
writeFile(t, otherFile, "totally-newdata")
sameSha, err := cloud115.FileSHA1(filepath.Join(localDir, "same.nfo"))
if err != nil {
t.Fatal(err)
}
otherSha, err := cloud115.FileSHA1(otherFile)
if err != nil {
t.Fatal(err)
}
p := &model.StrmSyncPath{
Base: model.Base{ID: "meta-sha1-path"},
Provider: model.StrmProvider115,
RemotePath: "0",
LocalPath: localDir,
DownloadMeta: true,
UploadMeta: false,
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{DownloadMeta: true, UploadMeta: false, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
seenMetaTarget: map[string]cloud.FileEntry{},
}
st.handleMeta(cloud.FileEntry{ID: "r1", Name: "same.nfo", Size: int64(len("identical-data")), Sha1: strings.ToUpper(sameSha), PickCode: "pc1"}, "same.nfo", ".nfo")
st.handleMeta(cloud.FileEntry{ID: "r2", Name: "stale.nfo", Size: int64(len("stale-content!!")), Sha1: strings.ToUpper(otherSha), PickCode: "pc2"}, "stale.nfo", ".nfo")
st.handleMeta(cloud.FileEntry{ID: "r3", Name: "nohash.nfo", Size: int64(len("nohash-content")), PickCode: "pc3"}, "nohash.nfo", ".nfo")
st.flushPendingDownloads()
tasks, _, err := svc.repo.StrmDownload.List(context.Background(), "", 1, 10)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 1 {
t.Fatalf("expected only 1 download task (stale.nfo), got %d", len(tasks))
}
if tasks[0].FileName != "stale.nfo" {
t.Fatalf("expected download task for stale.nfo, got %s", tasks[0].FileName)
} }
} }
@@ -672,7 +994,7 @@ func TestWalk115FlatAbortsOnDirResolveFailure(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case "/open/ufile/files": case "/open/ufile/files":
w.Write([]byte(`{"state":true,"count":1,"data":[{"fid":"100","pid":"999","fc":1,"fn":"movie.mkv","pc":"pc1","upt":1700000000,"fs":1024}]}`)) w.Write([]byte(`{"state":true,"count":1,"data":[{"fid":"100","pid":"999","fc":"1","fn":"movie.mkv","pc":"pc1","upt":1700000000,"fs":1024}]}`))
case "/open/folder/get_info": case "/open/folder/get_info":
w.Write([]byte(`{"state":false,"code":40140123,"message":"access_token 格式错误"}`)) w.Write([]byte(`{"state":false,"code":40140123,"message":"access_token 格式错误"}`))
default: default:
@@ -716,3 +1038,126 @@ func TestWalk115FlatAbortsOnDirResolveFailure(t *testing.T) {
t.Fatalf("expected no .strm written after abort, got %d", strmCount) t.Fatalf("expected no .strm written after abort, got %d", strmCount)
} }
} }
// TestWalk115FlatConcurrentProcessing 验证 115 平铺拉取后文件分类处理走并发
// worker 池:同一父目录下多个视频的 strm 全部生成,且解析出的目录拓扑缓存
// 通过 SetBatch 批量落库,供下次增量同步预加载(省掉重复的 get_info 调用)。
func TestWalk115FlatConcurrentProcessing(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
acct := &model.StrmAccount{Name: "fake115", Provider: "cloud115", Config: "{}", Enabled: true}
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
t.Fatal(err)
}
p := &model.StrmSyncPath{
Base: model.Base{ID: "flat-path"},
AccountID: acct.ID,
Provider: model.StrmProvider115,
RemotePath: "0",
LocalPath: localDir,
}
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/open/ufile/files":
w.Write([]byte(`{"state":true,"count":3,"data":[
{"fid":"101","pid":"999","fc":"1","fn":"m1.mkv","pc":"pc1","upt":1700000001,"fs":1024},
{"fid":"102","pid":"999","fc":"1","fn":"m2.mkv","pc":"pc2","upt":1700000002,"fs":2048},
{"fid":"103","pid":"999","fc":"1","fn":"m3.mkv","pc":"pc3","upt":1700000003,"fs":4096}]}`))
case "/open/folder/get_info":
w.Write([]byte(`{"state":true,"data":{"file_id":"999","file_name":"Movies","file_category":"0",
"paths":[{"file_id":"0","file_name":"根目录"},{"file_id":"999","file_name":"Movies"}]}}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer api.Close()
oldPro := cloud115.ProAPIBase
cloud115.ProAPIBase = api.URL
defer func() { cloud115.ProAPIBase = oldPro }()
oc := cloud115.NewOpenClient("app", "at", "rt")
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
provider: cloud.NewOpenAPI115("app", "at", "rt"),
cfg: &strmPathConfig{VideoExt: []string{"mkv"}, MetaExt: []string{"nfo"}, AddPath: 1},
rec: &model.StrmSyncRecord{},
syncType: model.StrmSyncTypeFull,
dirCache: sync.Map{},
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{},
}
if err := st.walk115Flat(oc); err != nil {
t.Fatalf("walk115Flat: %v", err)
}
// 3 个视频的 strm 全部生成
if st.rec.NewStrm != 3 {
t.Fatalf("expected 3 strm created, got %d", st.rec.NewStrm)
}
for _, name := range []string{"m1.strm", "m2.strm", "m3.strm"} {
if _, err := os.Stat(filepath.Join(localDir, "Movies", name)); err != nil {
t.Fatalf("strm %s missing: %v", name, err)
}
}
// 目录拓扑缓存已批量落库
rows, err := svc.repo.StrmDirCache.ListBySyncPathID(context.Background(), p.ID)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].DirID != "999" || rows[0].Path != "Movies" {
t.Fatalf("dir cache rows = %#v, want one row for dir 999 -> Movies", rows)
}
// 增量同步复用目录缓存:不再发起 get_info 调用
var infoCalls int
api.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/open/ufile/files":
w.Write([]byte(`{"state":true,"count":3,"data":[
{"fid":"101","pid":"999","fc":"1","fn":"m1.mkv","pc":"pc1","upt":1700000001,"fs":1024},
{"fid":"102","pid":"999","fc":"1","fn":"m2.mkv","pc":"pc2","upt":1700000002,"fs":2048},
{"fid":"103","pid":"999","fc":"1","fn":"m3.mkv","pc":"pc3","upt":1700000003,"fs":4096}]}`))
case "/open/folder/get_info":
infoCalls++
w.Write([]byte(`{"state":true,"data":{"file_id":"999","file_name":"Movies","file_category":"0","paths":[]}}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
})
st2 := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
provider: cloud.NewOpenAPI115("app", "at", "rt"),
cfg: st.cfg,
rec: &model.StrmSyncRecord{},
syncType: model.StrmSyncTypeIncremental,
dirCache: sync.Map{},
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{},
}
if err := st2.walk115Flat(oc); err != nil {
t.Fatalf("incremental walk115Flat: %v", err)
}
if infoCalls != 0 {
t.Fatalf("incremental sync should reuse dir cache, got %d get_info calls", infoCalls)
}
if st2.rec.NewStrm != 0 || st2.rec.Skipped != 3 {
t.Fatalf("incremental sync should skip all, new = %d skipped = %d", st2.rec.NewStrm, st2.rec.Skipped)
}
}