From 0e9fb2c937af34ccdaa6954265fe2a21878f5cd9 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:32:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=8C=E6=B7=BB=E5=8A=A011?= =?UTF-8?q?5=E6=8E=A5=E5=8F=A3=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../API列表/云下载/删除用户云下载任务.md | 96 ++++ .../API列表/云下载/添加云下载BT任务.md | 111 ++++ .../API列表/云下载/添加云下载链接任务.md | 115 +++++ .../API列表/云下载/清空云下载任务.md | 104 ++++ .../API列表/云下载/获取云下载配额信息.md | 101 ++++ .../API列表/云下载/获取用户云下载任务列表.md | 136 +++++ .../115开放平台/API列表/云下载/解析BT种子.md | 118 +++++ .../API列表/开发者商业价值转化:推广产品得收益.md | 172 +++++++ .../API列表/文件管理/删除或清空回收站.md | 79 +++ .../115开放平台/API列表/文件管理/删除文件.md | 80 +++ .../API列表/文件管理/回收站列表.md | 126 +++++ .../API列表/文件管理/回收站还原.md | 78 +++ .../API列表/文件管理/文件(夹)更新.md | 87 ++++ .../API列表/文件管理/文件上传/上传流程.md | 41 ++ .../API列表/文件管理/文件上传/文件上传.md | 141 +++++ .../API列表/文件管理/文件上传/断点续传.md | 110 ++++ .../API列表/文件管理/文件上传/获取上传凭证.md | 87 ++++ .../115开放平台/API列表/文件管理/文件复制.md | 84 +++ .../115开放平台/API列表/文件管理/文件搜索.md | 121 +++++ .../115开放平台/API列表/文件管理/文件移动.md | 91 ++++ .../API列表/文件管理/新建文件夹.md | 86 ++++ .../API列表/文件管理/获取文件(夹)详情/按ID获取.md | 114 +++++ .../文件管理/获取文件(夹)详情/按路径获取.md | 115 +++++ .../API列表/文件管理/获取文件下载地址.md | 98 ++++ .../API列表/文件管理/获取文件列表.md | 204 ++++++++ .../115开放平台/API列表/用户管理/用户信息.md | 140 +++++ .../API列表/视频播放/提交视频转码.md | 88 ++++ .../API列表/视频播放/获取视频在线播放地址.md | 125 +++++ .../API列表/视频播放/获取视频播放进度.md | 96 ++++ .../API列表/视频播放/视频字幕列表.md | 119 +++++ .../API列表/视频播放/记忆视频播放进度.md | 89 ++++ 115doc/115开放平台/接入指南/开发须知.md | 30 ++ 115doc/115开放平台/接入指南/授权错误码.md | 60 +++ .../接入指南/接入授权/刷新access_token.md | 92 ++++ .../接入授权/手机扫码授权PKCE模式/获取access_token.md | 88 ++++ .../手机扫码授权PKCE模式/获取设备码和二维码内容.md | 103 ++++ .../接入授权/手机扫码授权PKCE模式/轮询二维码状态.md | 89 ++++ .../接入授权/授权码模式/用授权码换取access_token.md | 95 ++++ .../接入指南/接入授权/授权码模式/请求授权.md | 87 ++++ 115doc/115开放平台/接入指南/接入流程.md | 57 +++ 115doc/115开放平台/简介/更新记录.md | 23 + 115doc/115开放平台/简介/概述.md | 30 ++ 115doc/README.md | 67 +++ 115doc/tree-index.json | 308 +++++++++++ internal/model/strm.go | 1 + internal/repository/strm_repository.go | 47 ++ internal/service/cloud/cloud.go | 12 + internal/service/cloud/cloud_115_test.go | 16 +- internal/service/cloud/pan115_openapi.go | 19 +- internal/service/cloud115/cloud115_test.go | 63 +++ internal/service/cloud115/delete.go | 35 ++ internal/service/cloud115/open.go | 56 ++ internal/service/strm_queue.go | 125 ++++- internal/service/strm_queue_test.go | 194 +++++++ internal/service/strm_service.go | 2 +- internal/service/strm_sync.go | 224 ++++++-- internal/service/strm_sync_test.go | 483 +++++++++++++++++- 57 files changed, 5682 insertions(+), 76 deletions(-) create mode 100644 115doc/115开放平台/API列表/云下载/删除用户云下载任务.md create mode 100644 115doc/115开放平台/API列表/云下载/添加云下载BT任务.md create mode 100644 115doc/115开放平台/API列表/云下载/添加云下载链接任务.md create mode 100644 115doc/115开放平台/API列表/云下载/清空云下载任务.md create mode 100644 115doc/115开放平台/API列表/云下载/获取云下载配额信息.md create mode 100644 115doc/115开放平台/API列表/云下载/获取用户云下载任务列表.md create mode 100644 115doc/115开放平台/API列表/云下载/解析BT种子.md create mode 100644 115doc/115开放平台/API列表/开发者商业价值转化:推广产品得收益.md create mode 100644 115doc/115开放平台/API列表/文件管理/删除或清空回收站.md create mode 100644 115doc/115开放平台/API列表/文件管理/删除文件.md create mode 100644 115doc/115开放平台/API列表/文件管理/回收站列表.md create mode 100644 115doc/115开放平台/API列表/文件管理/回收站还原.md create mode 100644 115doc/115开放平台/API列表/文件管理/文件(夹)更新.md create mode 100644 115doc/115开放平台/API列表/文件管理/文件上传/上传流程.md create mode 100644 115doc/115开放平台/API列表/文件管理/文件上传/文件上传.md create mode 100644 115doc/115开放平台/API列表/文件管理/文件上传/断点续传.md create mode 100644 115doc/115开放平台/API列表/文件管理/文件上传/获取上传凭证.md create mode 100644 115doc/115开放平台/API列表/文件管理/文件复制.md create mode 100644 115doc/115开放平台/API列表/文件管理/文件搜索.md create mode 100644 115doc/115开放平台/API列表/文件管理/文件移动.md create mode 100644 115doc/115开放平台/API列表/文件管理/新建文件夹.md create mode 100644 115doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按ID获取.md create mode 100644 115doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按路径获取.md create mode 100644 115doc/115开放平台/API列表/文件管理/获取文件下载地址.md create mode 100644 115doc/115开放平台/API列表/文件管理/获取文件列表.md create mode 100644 115doc/115开放平台/API列表/用户管理/用户信息.md create mode 100644 115doc/115开放平台/API列表/视频播放/提交视频转码.md create mode 100644 115doc/115开放平台/API列表/视频播放/获取视频在线播放地址.md create mode 100644 115doc/115开放平台/API列表/视频播放/获取视频播放进度.md create mode 100644 115doc/115开放平台/API列表/视频播放/视频字幕列表.md create mode 100644 115doc/115开放平台/API列表/视频播放/记忆视频播放进度.md create mode 100644 115doc/115开放平台/接入指南/开发须知.md create mode 100644 115doc/115开放平台/接入指南/授权错误码.md create mode 100644 115doc/115开放平台/接入指南/接入授权/刷新access_token.md create mode 100644 115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取access_token.md create mode 100644 115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取设备码和二维码内容.md create mode 100644 115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/轮询二维码状态.md create mode 100644 115doc/115开放平台/接入指南/接入授权/授权码模式/用授权码换取access_token.md create mode 100644 115doc/115开放平台/接入指南/接入授权/授权码模式/请求授权.md create mode 100644 115doc/115开放平台/接入指南/接入流程.md create mode 100644 115doc/115开放平台/简介/更新记录.md create mode 100644 115doc/115开放平台/简介/概述.md create mode 100644 115doc/README.md create mode 100644 115doc/tree-index.json create mode 100644 internal/service/cloud115/delete.go diff --git a/115doc/115开放平台/API列表/云下载/删除用户云下载任务.md b/115doc/115开放平台/API列表/云下载/删除用户云下载任务.md new file mode 100644 index 0000000..e5bc5cd --- /dev/null +++ b/115doc/115开放平台/API列表/云下载/删除用户云下载任务.md @@ -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 | `` | +| del_source_file | int | 否 | 0 | 是否删除源文件,1=删除 0=不删除 | 0 | + +### 请求示例 + +```shell +curl 'https://proapi.115.com/open/offline/del_task' \ + -H 'Authorization: Bearer ' \ + --form-string '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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/云下载/添加云下载BT任务.md b/115doc/115开放平台/API列表/云下载/添加云下载BT任务.md new file mode 100644 index 0000000..1847b86 --- /dev/null +++ b/115doc/115开放平台/API列表/云下载/添加云下载BT任务.md @@ -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 | `` | +| wanted | string | 是 | - | 选中下载的文件索引,使用半角逗号分隔 | `` | +| save_path | string | 是 | - | BT任务文件保存路径 | `A/B` | +| torrent_sha1 | string | 是 | - | BT种子SHA1 | `` | +| pick_code | string | 是 | - | BT种子文件提取码 | `` | +| wp_path_id | string | 否 | 0 | 保存目标文件夹ID | 0 | + +### 请求示例 + +```shell +curl 'https://proapi.115.com/open/offline/add_task_bt' \ + -H 'Authorization: Bearer ' \ + --form-string 'info_hash=' \ + --form-string 'wanted=' \ + --form-string 'save_path=A/B' \ + --form-string 'torrent_sha1=' \ + --form-string '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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/云下载/添加云下载链接任务.md b/115doc/115开放平台/API列表/云下载/添加云下载链接任务.md new file mode 100644 index 0000000..417dfe4 --- /dev/null +++ b/115doc/115开放平台/API列表/云下载/添加云下载链接任务.md @@ -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 | 是 | - | 云下载链接,多个链接使用换行符分隔 | `` | +| wp_path_id | string | 否 | 0 | 保存目标文件夹ID;不传或传0时保存到根目录 | 0 | + +### 请求示例 + +```shell +curl 'https://proapi.115.com/open/offline/add_task_urls' \ + -H 'Authorization: Bearer ' \ + --form-string 'urls=' \ + --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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/云下载/清空云下载任务.md b/115doc/115开放平台/API列表/云下载/清空云下载任务.md new file mode 100644 index 0000000..c4bc41c --- /dev/null +++ b/115doc/115开放平台/API列表/云下载/清空云下载任务.md @@ -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 ' \ + --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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/云下载/获取云下载配额信息.md b/115doc/115开放平台/API列表/云下载/获取云下载配额信息.md new file mode 100644 index 0000000..b8cc491 --- /dev/null +++ b/115doc/115开放平台/API列表/云下载/获取云下载配额信息.md @@ -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 ' +``` + +### 响应字段说明 + +| 字段 | 类型 | 描述 | +|:--------------------------------------------|:---------|:---------------------| +| 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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/云下载/获取用户云下载任务列表.md b/115doc/115开放平台/API列表/云下载/获取用户云下载任务列表.md new file mode 100644 index 0000000..5c41589 --- /dev/null +++ b/115doc/115开放平台/API列表/云下载/获取用户云下载任务列表.md @@ -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 ' \ + --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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/云下载/解析BT种子.md b/115doc/115开放平台/API列表/云下载/解析BT种子.md new file mode 100644 index 0000000..f37cc7e --- /dev/null +++ b/115doc/115开放平台/API列表/云下载/解析BT种子.md @@ -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 | `` | +| pick_code | string | 是 | - | BT种子文件提取码 | `` | + +### 请求示例 + +```shell +curl 'https://proapi.115.com/open/offline/torrent' \ + -H 'Authorization: Bearer ' \ + --form-string 'torrent_sha1=' \ + --form-string '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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/开发者商业价值转化:推广产品得收益.md b/115doc/115开放平台/API列表/开发者商业价值转化:推广产品得收益.md new file mode 100644 index 0000000..aec9042 --- /dev/null +++ b/115doc/115开放平台/API列表/开发者商业价值转化:推广产品得收益.md @@ -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默认产品名称改为至尊版 | diff --git a/115doc/115开放平台/API列表/文件管理/删除或清空回收站.md b/115doc/115开放平台/API列表/文件管理/删除或清空回收站.md new file mode 100644 index 0000000..1e192c6 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/删除或清空回收站.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/删除文件.md b/115doc/115开放平台/API列表/文件管理/删除文件.md new file mode 100644 index 0000000..85e82e2 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/删除文件.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/回收站列表.md b/115doc/115开放平台/API列表/文件管理/回收站列表.md new file mode 100644 index 0000000..5468bf9 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/回收站列表.md @@ -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 | 补充单页记录数上限 | diff --git a/115doc/115开放平台/API列表/文件管理/回收站还原.md b/115doc/115开放平台/API列表/文件管理/回收站还原.md new file mode 100644 index 0000000..f1fc3ce --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/回收站还原.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/文件(夹)更新.md b/115doc/115开放平台/API列表/文件管理/文件(夹)更新.md new file mode 100644 index 0000000..628ab47 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/文件(夹)更新.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/文件上传/上传流程.md b/115doc/115开放平台/API列表/文件管理/文件上传/上传流程.md new file mode 100644 index 0000000..7a5eddc --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/文件上传/上传流程.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/文件上传/文件上传.md b/115doc/115开放平台/API列表/文件管理/文件上传/文件上传.md new file mode 100644 index 0000000..329e42f --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/文件上传/文件上传.md @@ -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 值 | `` | +| preid | string | 否 | "" | 文件前 128 KiB 内容的 SHA1 值 | `` | +| path | string | 否 | "" | 上传路径 | "" | +| pick_code | string | 否 | "" | 上传任务唯一标识,用于续传 | `` | +| topupload | int | 否 | "" | 上传调度文件类型标记,见下方枚举表格 | 0 | +| sign_key | string | 否 | "" | 二次认证标识 | `` | +| sign_val | string | 否 | "" | 根据 `sign_check` 计算的大写 SHA1 值 | `` | + +#### 请求参数中的 topupload 字段枚举 + +| 值 | 说明 | 备注 | +|:---|:---------------------------|:---| +| -1 | 没有上传调度文件类型标记 | - | +| 0 | 单文件上传任务,记录一条独立上传记录 | - | +| 1 | 文件夹任务的第一个子文件,记录一次文件夹上传 | - | +| 2 | 文件夹任务的其他子文件,不单独记录上传记录 | - | + +### 请求示例 + +```shell +curl 'https://proapi.115.com/open/upload/init' \ + -H 'Authorization: Bearer ' \ + -F 'file_name=图片.jpg' \ + -F 'file_size=5335' \ + -F 'target=U_1_0' \ + -F 'fileid=' \ + -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/文件上传/断点续传.md b/115doc/115开放平台/API列表/文件管理/文件上传/断点续传.md new file mode 100644 index 0000000..55ac97c --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/文件上传/断点续传.md @@ -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 值 | `` | +| pick_code | string | 是 | - | 上传任务唯一标识 | `` | + +### 请求示例 + +```shell +curl 'https://proapi.115.com/open/upload/resume' \ + -H 'Authorization: Bearer ' \ + -F 'file_size=5335' \ + -F 'target=U_1_0' \ + -F 'fileid=' \ + -F '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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/文件上传/获取上传凭证.md b/115doc/115开放平台/API列表/文件管理/文件上传/获取上传凭证.md new file mode 100644 index 0000000..605678d --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/文件上传/获取上传凭证.md @@ -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 ' +``` + +### 响应字段说明 + +| 字段 | 类型 | 描述 | +|:-------------------|:------|:---------------| +| 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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/文件复制.md b/115doc/115开放平台/API列表/文件管理/文件复制.md new file mode 100644 index 0000000..6057e76 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/文件复制.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/文件搜索.md b/115doc/115开放平台/API列表/文件管理/文件搜索.md new file mode 100644 index 0000000..7d359f5 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/文件搜索.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/文件移动.md b/115doc/115开放平台/API列表/文件管理/文件移动.md new file mode 100644 index 0000000..51d0311 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/文件移动.md @@ -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 | 补充移动目标目录有效性约束及错误码 | diff --git a/115doc/115开放平台/API列表/文件管理/新建文件夹.md b/115doc/115开放平台/API列表/文件管理/新建文件夹.md new file mode 100644 index 0000000..d4b7626 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/新建文件夹.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按ID获取.md b/115doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按ID获取.md new file mode 100644 index 0000000..f93bbf1 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按ID获取.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按路径获取.md b/115doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按路径获取.md new file mode 100644 index 0000000..28aa58b --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按路径获取.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/获取文件下载地址.md b/115doc/115开放平台/API列表/文件管理/获取文件下载地址.md new file mode 100644 index 0000000..dab785b --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/获取文件下载地址.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/获取文件列表.md b/115doc/115开放平台/API列表/文件管理/获取文件列表.md new file mode 100644 index 0000000..df77cae --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/获取文件列表.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/用户管理/用户信息.md b/115doc/115开放平台/API列表/用户管理/用户信息.md new file mode 100644 index 0000000..9954a72 --- /dev/null +++ b/115doc/115开放平台/API列表/用户管理/用户信息.md @@ -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 ' +``` + +### 响应字段说明 + +| 字段 | 类型 | 描述 | +|:----------------------------------------|:------|:----------------------------------------| +| 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等级名称枚举 | diff --git a/115doc/115开放平台/API列表/视频播放/提交视频转码.md b/115doc/115开放平台/API列表/视频播放/提交视频转码.md new file mode 100644 index 0000000..e068c1d --- /dev/null +++ b/115doc/115开放平台/API列表/视频播放/提交视频转码.md @@ -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 ' \ + -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/视频播放/获取视频在线播放地址.md b/115doc/115开放平台/API列表/视频播放/获取视频在线播放地址.md new file mode 100644 index 0000000..db45c46 --- /dev/null +++ b/115doc/115开放平台/API列表/视频播放/获取视频在线播放地址.md @@ -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 ' \ + --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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/视频播放/获取视频播放进度.md b/115doc/115开放平台/API列表/视频播放/获取视频播放进度.md new file mode 100644 index 0000000..226cce3 --- /dev/null +++ b/115doc/115开放平台/API列表/视频播放/获取视频播放进度.md @@ -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 ' \ + --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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/视频播放/视频字幕列表.md b/115doc/115开放平台/API列表/视频播放/视频字幕列表.md new file mode 100644 index 0000000..8f705d5 --- /dev/null +++ b/115doc/115开放平台/API列表/视频播放/视频字幕列表.md @@ -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 ' \ + --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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/视频播放/记忆视频播放进度.md b/115doc/115开放平台/API列表/视频播放/记忆视频播放进度.md new file mode 100644 index 0000000..726d7d7 --- /dev/null +++ b/115doc/115开放平台/API列表/视频播放/记忆视频播放进度.md @@ -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 ' \ + -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 | 创建文档 | diff --git a/115doc/115开放平台/接入指南/开发须知.md b/115doc/115开放平台/接入指南/开发须知.md new file mode 100644 index 0000000..f934920 --- /dev/null +++ b/115doc/115开放平台/接入指南/开发须知.md @@ -0,0 +1,30 @@ +# 开发须知 + +### 基本信息 + +| 属性 | 内容 | +|:-----------|:---------------------------------| +| 文档名称 | 开发须知 | +| 文档版本 | v1.0 | + +## 注意事项 + +为共同建设开放、共赢的合作生态并保障平台服务的可持续发展,开发者在接入平台服务前,须认真阅读并严格遵循《115生活开放平台开发者协议》的相关要求,确保合规运营、维护平台秩序。 + +基于平台与用户权益保护原则,平台会持续监测开发者的服务使用行为。如发现违反平台规范的行为,平台将视情节采取包括但不限于服务限流、接口冻结、资质回收等限制措施,并保留依法追责的权利。 + +开发者严禁实施包括但不限于以下行为: + +1. **数据隐私违规行为**:侵害用户数据隐私安全,包括未经用户授权或未明确用途,违规收集、下载、存储、传播、加工用户存储数据等。开发者需要确保用户数据的获取与使用全程透明、可追溯。 +2. **商业利益侵犯行为**:损害 115 科技的商业利益,包括多人共享开发者账号及会员权益、开展竞争关系业务、未经授权获取平台相关服务运营数据等。 +3. **不当使用行为**:违规或未按要求使用 API 服务,包括违反国家相关政策法规、侵犯第三方合法权益、调用非公开接口、实际用途与申请信息不符等。 + +## 限流说明 + +为确保系统安全并保障服务稳定运行,115生活开放平台对所有 API 实施频率控制策略。出于安全防护需要,相关策略细则暂不公开,平台会持续动态优化该机制。 + +### 修改历史 + +| 修改时间 | 修改说明 | +|:-----------------------------|:-----| +| 2025年04月01日(周二) 00:00:00 | 创建文档 | diff --git a/115doc/115开放平台/接入指南/授权错误码.md b/115doc/115开放平台/接入指南/授权错误码.md new file mode 100644 index 0000000..25218c8 --- /dev/null +++ b/115doc/115开放平台/接入指南/授权错误码.md @@ -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` 的永久失效判定说明 | diff --git a/115doc/115开放平台/接入指南/接入授权/刷新access_token.md b/115doc/115开放平台/接入指南/接入授权/刷新access_token.md new file mode 100644 index 0000000..c82a1ea --- /dev/null +++ b/115doc/115开放平台/接入指南/接入授权/刷新access_token.md @@ -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` 说明 | diff --git a/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取access_token.md b/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取access_token.md new file mode 100644 index 0000000..50fccef --- /dev/null +++ b/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取access_token.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取设备码和二维码内容.md b/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取设备码和二维码内容.md new file mode 100644 index 0000000..c9f3b2c --- /dev/null +++ b/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取设备码和二维码内容.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/轮询二维码状态.md b/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/轮询二维码状态.md new file mode 100644 index 0000000..0d1e821 --- /dev/null +++ b/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/轮询二维码状态.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/接入指南/接入授权/授权码模式/用授权码换取access_token.md b/115doc/115开放平台/接入指南/接入授权/授权码模式/用授权码换取access_token.md new file mode 100644 index 0000000..cedc6ca --- /dev/null +++ b/115doc/115开放平台/接入指南/接入授权/授权码模式/用授权码换取access_token.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/接入指南/接入授权/授权码模式/请求授权.md b/115doc/115开放平台/接入指南/接入授权/授权码模式/请求授权.md new file mode 100644 index 0000000..f152562 --- /dev/null +++ b/115doc/115开放平台/接入指南/接入授权/授权码模式/请求授权.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/接入指南/接入流程.md b/115doc/115开放平台/接入指南/接入流程.md new file mode 100644 index 0000000..493417d --- /dev/null +++ b/115doc/115开放平台/接入指南/接入流程.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/简介/更新记录.md b/115doc/115开放平台/简介/更新记录.md new file mode 100644 index 0000000..b575ec6 --- /dev/null +++ b/115doc/115开放平台/简介/更新记录.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/简介/概述.md b/115doc/115开放平台/简介/概述.md new file mode 100644 index 0000000..b51058f --- /dev/null +++ b/115doc/115开放平台/简介/概述.md @@ -0,0 +1,30 @@ +# 概述 + +### 基本信息 + +| 属性 | 内容 | +|:-----------|:---------------------------------| +| 文档名称 | 概述 | +| 文档版本 | v1.0 | + +## 115生活简介 + +“115生活”是一款面向个人用户的数字生活平台,提供海量数据的安全存储、多端同步与快速访问。用户不仅可以便捷地管理和使用各类数字资源,还能使用多维社交、生活服务等多元化功能。 + +## 115生活开放平台能力说明 + +115生活开放平台提供“115生活”数据存储、同步、管理等功能的 API 服务。开发者通过对接 API,可以将“115生活”的存储能力集成到自己的应用中。 + +目前已开放以下能力: + +- **用户管理能力**:用户授权与信息查询等。 +- **文件管理能力**:获取文件列表,查看文件属性,以及文件上传、下载、搜索、移动、删除等。 +- **视频管理能力**:视频文件的在线转码与播放等。 +- **云下载服务**:获取云下载任务列表、配额信息,以及添加、删除下载任务等。 +- **商业价值转化**:开发者参与“推广产品得收益”计划,可基于用户实际购买的产品获取相应推广收益。 + +### 修改历史 + +| 修改时间 | 修改说明 | +|:-----------------------------|:-----| +| 2025年04月01日(周二) 00:00:00 | 创建文档 | diff --git a/115doc/README.md b/115doc/README.md new file mode 100644 index 0000000..aedbbe3 --- /dev/null +++ b/115doc/README.md @@ -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开放平台/接入指南/接入授权/` diff --git a/115doc/tree-index.json b/115doc/tree-index.json new file mode 100644 index 0000000..a796404 --- /dev/null +++ b/115doc/tree-index.json @@ -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" +} \ No newline at end of file diff --git a/internal/model/strm.go b/internal/model/strm.go index 67e90ad..9acc4c2 100644 --- a/internal/model/strm.go +++ b/internal/model/strm.go @@ -124,6 +124,7 @@ type StrmUploadTask struct { FileName string `gorm:"size:512" json:"file_name"` LocalPath string `gorm:"size:1024" json:"local_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"` Status string `gorm:"size:16;index" json:"status"` Error string `gorm:"size:1024" json:"error"` diff --git a/internal/repository/strm_repository.go b/internal/repository/strm_repository.go index 3b4117b..0ad8e0a 100644 --- a/internal/repository/strm_repository.go +++ b/internal/repository/strm_repository.go @@ -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 { return withSQLiteBusyRetry(ctx, func() error { return r.db.WithContext(ctx).Unscoped().Where("sync_path_id = ?", syncPathID).Delete(&model.StrmDirCache{}).Error diff --git a/internal/service/cloud/cloud.go b/internal/service/cloud/cloud.go index 8c30b5b..dc545a7 100644 --- a/internal/service/cloud/cloud.go +++ b/internal/service/cloud/cloud.go @@ -45,6 +45,9 @@ type FileEntry struct { MTime int64 `json:"mtime,omitempty"` // PickCode is 115-specific; other providers use ID directly. PickCode string `json:"pick_code,omitempty"` + // Sha1 is 115-specific content hash(大写 hex,目录/未完成文件可能为空或占位符)。 + // 其他网盘不提供,留空时调用方退回大小比对。用于元数据"是否同一文件"的精确判定。 + Sha1 string `json:"sha1,omitempty"` } // DirectLink is a resolved playback target. @@ -72,6 +75,15 @@ type Provider interface { 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 // management through their official API or standard WebDAV methods. type MutableProvider interface { diff --git a/internal/service/cloud/cloud_115_test.go b/internal/service/cloud/cloud_115_test.go index d05b22f..93d7694 100644 --- a/internal/service/cloud/cloud_115_test.go +++ b/internal/service/cloud/cloud_115_test.go @@ -81,7 +81,12 @@ func Test115OpenAPIListPaginates(t *testing.T) { t.Fatalf("unexpected path %s", r.URL.Path) } 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 { count = 1 } @@ -97,11 +102,12 @@ func Test115OpenAPIListPaginates(t *testing.T) { if err != nil { t.Fatalf("list: %v", err) } - if len(entries) != 101 { - t.Fatalf("entries = %d, want 101", len(entries)) + // List 使用文档上限 limit=1150:首页 1150 条 + 短页 1 条 + if len(entries) != 1151 { + t.Fatalf("entries = %d, want 1151", len(entries)) } - if entries[100].ID != "100" || entries[100].PickCode != "pick100" { - t.Fatalf("last entry wrong: %#v", entries[100]) + if entries[1150].ID != "1150" || entries[1150].PickCode != "pick1150" { + t.Fatalf("last entry wrong: %#v", entries[1150]) } } diff --git a/internal/service/cloud/pan115_openapi.go b/internal/service/cloud/pan115_openapi.go index a404b34..749cfb7 100644 --- a/internal/service/cloud/pan115_openapi.go +++ b/internal/service/cloud/pan115_openapi.go @@ -56,8 +56,9 @@ func (p *openAPI115Provider) Ping(ctx context.Context) error { } func (p *openAPI115Provider) List(ctx context.Context, dirID string) ([]FileEntry, error) { - // 115 开放平台列表接口按 offset/limit 分页,这里循环取完整个目录 - const pageSize = 100 + // 115 开放平台列表接口按 offset/limit 分页,这里循环取完整个目录; + // limit 上限 1150(官方文档《获取文件列表》),取上限减少大目录翻页次数 + const pageSize = 1150 var out []FileEntry for offset := 0; ; 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, MTime: f.ModifiedAt(), PickCode: f.PickCode, + Sha1: f.Sha1, }) } 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 } +// 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 刷新用)。 func (p *openAPI115Provider) OpenClient() *cloud115.OpenClient { return p.c } diff --git a/internal/service/cloud115/cloud115_test.go b/internal/service/cloud115/cloud115_test.go index a0bb271..a9b8019 100644 --- a/internal/service/cloud115/cloud115_test.go +++ b/internal/service/cloud115/cloud115_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "net/url" "strings" + "sync" "testing" "time" ) @@ -472,3 +473,65 @@ func TestFsListRefreshContinue(t *testing.T) { 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) + } +} diff --git a/internal/service/cloud115/delete.go b/internal/service/cloud115/delete.go new file mode 100644 index 0000000..0814283 --- /dev/null +++ b/internal/service/cloud115/delete.go @@ -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 +} diff --git a/internal/service/cloud115/open.go b/internal/service/cloud115/open.go index 27ff4ee..f0887c5 100644 --- a/internal/service/cloud115/open.go +++ b/internal/service/cloud115/open.go @@ -225,6 +225,62 @@ func (c *OpenClient) GetDownloadURLWithUA(ctx context.Context, pickCode, ua stri 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 扫码状态。 diff --git a/internal/service/strm_queue.go b/internal/service/strm_queue.go index 9d8b30d..4a98486 100644 --- a/internal/service/strm_queue.go +++ b/internal/service/strm_queue.go @@ -52,16 +52,28 @@ func (s *StrmService) downloadWorker(ctx context.Context) { sleepContext(ctx, 2*time.Second) continue } - var wg sync.WaitGroup + // 处于 WAF 冷却的 115 任务先退回,剩余任务在派发前按账号批量换链: + // downurl 支持逗号分隔多个 pick_code,整批任务一次请求即可完成解析, + // 显著减少全局 QPS 限流下的换链请求量。 + runnable := make([]*model.StrmDownloadTask, 0, len(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) go func(i int) { defer wg.Done() - task := &tasks[i] - if task.Provider == model.StrmProvider115 && s.wafCooldownLeft() > 0 { - s.requeueDownloadTask(task) - return - } + task := runnable[i] if !s.acquireDownloadSlot(ctx, task.Provider) { s.requeueDownloadTask(task) return @@ -76,7 +88,7 @@ func (s *StrmService) downloadWorker(ctx context.Context) { s.downloadTaskFailWithRetry(task, "任务执行异常中断") } }() - s.processDownloadTask(ctx, task) + s.processDownloadTask(ctx, task, resolved) completed = true }) }(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。 // 退回时必须设置 NextTryAt(WAF 冷却剩余时间):claim 只过滤 next_try_at // 已过期的任务,不设会让同一批任务被立刻再认领,形成 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) if cleanPath != "" && cleanPath != task.LocalPath { task.LocalPath = cleanPath @@ -137,13 +214,16 @@ func (s *StrmService) processDownloadTask(ctx context.Context, task *model.StrmD s.downloadTaskFailWithRetry(task, err.Error()) return } - link, err := provider.Resolve(ctx, task.RemoteRef) - if err != nil { - if is115Blocked(err) { - s.triggerWAFCooldown() + link, ok := resolved[dlResolveKey(task.AccountID, task.RemoteRef)] + if !ok || link == nil || link.URL == "" { + link, err = provider.Resolve(ctx, task.RemoteRef) + 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 { // 直链失效(403/404/410 等):清掉缓存让下一轮重新换取 @@ -286,6 +366,20 @@ func (s *StrmService) processUpload115(ctx context.Context, task *model.StrmUplo finish(model.StrmTaskFailed, "该网盘不支持元数据上传") 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) if err != nil { s.uploadTaskFailWithRetry(task, "打开本地文件失败:"+err.Error()) @@ -765,12 +859,15 @@ func (s *StrmService) wafCooldownLeft() time.Duration { } // is115Blocked 判断错误是否来自 115 的风控/限流(WAF 405 拦截页或限流错误码)。 +// 覆盖两层文案:HTTP 层(doJSON 的"接口触发频控/安全拦截(HTTP 405)")与 +// 业务错误码层(OpenAPIError 的"115 接口错误(406/770004)")。 func is115Blocked(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "115 接口返回 http 405") || + strings.Contains(msg, "115 接口触发频控/安全拦截") || strings.Contains(msg, "访问被阻断") || strings.Contains(msg, "request has been blocked") || strings.Contains(msg, "115 接口错误(770004") || diff --git a/internal/service/strm_queue_test.go b/internal/service/strm_queue_test.go index 9e17620..2a3a1b8 100644 --- a/internal/service/strm_queue_test.go +++ b/internal/service/strm_queue_test.go @@ -3,11 +3,18 @@ package service import ( "context" "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" "testing" "time" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service/cloud" + "github.com/truewhile/MeBox/internal/service/cloud115" "go.uber.org/zap" ) @@ -18,6 +25,7 @@ func TestIs115Blocked(t *testing.T) { }{ {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 接口错误(406):达到访问上限"), true}, {errors.New("下载失败:http 403"), false}, @@ -164,3 +172,189 @@ func TestRequeueDownloadTask(t *testing.T) { 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)) + } +} diff --git a/internal/service/strm_service.go b/internal/service/strm_service.go index a1b456f..e74937c 100644 --- a/internal/service/strm_service.go +++ b/internal/service/strm_service.go @@ -67,7 +67,7 @@ var StrmSettingDefs = map[string]struct { 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"}, 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/元数据后,删除空目录"}, 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)"}, diff --git a/internal/service/strm_sync.go b/internal/service/strm_sync.go index 73ac60a..aed303b 100644 --- a/internal/service/strm_sync.go +++ b/internal/service/strm_sync.go @@ -41,7 +41,9 @@ type strmSyncState struct { lastProgressFlush time.Time // 上次进度落库时间 seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频 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 seenVideoTarget map[string]cloud.FileEntry activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重) @@ -50,6 +52,7 @@ type strmSyncState struct { pendingUploads []*model.StrmUploadTask dirCache sync.Map // dirID (string) -> relativePath (string) dirPathToID map[string]string // relativePath (string) -> dirID(115 上传父目录寻址用,walk 后构建) + dirCacheDirty map[string]string // 待批量落库的目录缓存(dirID → 相对路径),避免逐目录单条 upsert scanIncomplete atomic.Bool // 远端目录树/文件列表本次扫描不完整 → 禁止增量 prune 误删本地文件 } @@ -201,6 +204,8 @@ func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *m 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{}, } @@ -331,6 +336,11 @@ func (st *strmSyncState) run() error { // 多个目录列表请求的网络往返彼此重叠,大幅缩短大目录树同步耗时。 const strmScanWorkers = 8 +// strmProcessWorkers 115 平铺拉取后本地文件分类处理(生成 strm/元数据入队) +// 的 worker 数。本地磁盘 I/O 是大库同步的尾部瓶颈,输出目录在网络挂载上尤甚; +// processRemoteFile 的共享状态均由 st.mu 保护,可安全并发。 +const strmProcessWorkers = 8 + // walkRemote 并发广度优先遍历网盘目录树。 // 多个 worker 并行执行 List(受全局 115 令牌桶限流约束),子目录动态 // 入队;任一目录失败则取消其余 worker 并返回错误(与旧串行版语义一致)。 @@ -509,8 +519,8 @@ func (st *strmSyncState) isMetaExt(ext string) bool { // cleanDirRel 对 115 扁平化拉取的目录相对路径逐段套用目录级文件名清洗, // 确保与 walkRemote / joinLocalRel(sanitizeRelativePath)使用同一套清洗规则。 -// 若不清洗,目录名中的冒号等非法字符会直达 rel,而 seenVideo/seenMeta 的 key -// 与磁盘实际路径不一致,导致 pruneLocal 误删已下载的 strm / 元数据。 +// 若不清洗,目录名中的冒号等非法字符会直达 rel,而 seenVideo/remoteMeta 的 key +// 与磁盘实际路径不一致,导致 pruneLocal 误删已下载的 strm、上传误传或重复下载。 // 空 rel(根目录)原样返回。 func cleanDirRel(rel string) string { if rel == "" { @@ -530,6 +540,34 @@ func cleanDirRel(rel string) string { 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)。 // 极大地降低 API 请求次数并支持毫秒级/秒级增量同步。 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)) st.dirCache.Store(pid, relPath) - _ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, pid, relPath) + st.deferDirCacheSave(pid, relPath) // 顺便解析并缓存 detail.Paths 中包含的中间各层级目录 for _, ancestor := range detail.Paths { @@ -742,7 +780,7 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error { } ancestorRel := cleanDirRel(subDetail.RelativePath(rootCID)) 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() + // 目录解析阶段结束即批量落库已解析的缓存:失败路径也保留部分成果, + // 下次同步可少解析一批目录。 + st.flushDirCacheSave() if firstErr != nil { // 目录树解析失败会导致 rel 塌缩,若继续处理会让大量本地文件 // 被错误判定为"云端不存在"而重复下载/上传,并可能误删本地文件。 @@ -767,10 +808,57 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error { 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 { if ctx.Err() != nil { - return ctx.Err() + break } cleanName := cleanEntryName(f.FileName, false) var rel string @@ -783,21 +871,27 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error { // 父目录不在目录缓存,无法还原真实相对路径。若继续用塌缩后的 // 根路径处理,该文件会被错误判定,导致重复下载/上传或误删本地文件。 // 目录树不完整时宁可中止本次同步,也不带着损坏的 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{ - ID: f.FileId, - Name: f.FileName, - IsDir: false, - Size: f.FileSize, - MTime: f.Utime, - PickCode: f.PickCode, + select { + case fileCh <- strmFileTask{file: f, rel: rel}: + case <-ctx.Done(): + break feed } - st.processRemoteFile(entry, rel) } - - return nil + close(fileCh) + procWg.Wait() + if procErr != nil { + return procErr + } + return ctx.Err() } // 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) { 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.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() } @@ -971,7 +1100,8 @@ func (st *strmSyncState) flushPendingUploads() { } } -// handleMeta 元数据入下载队列(本地已存在且大小一致则跳过)。 +// handleMeta 元数据入下载队列。本地已存在时:开启上传元数据则一律跳过(以本地 +// 为准);否则大小与 SHA1(115 提供)均一致视为同一文件跳过,内容不同则下载覆盖。 func (st *strmSyncState) handleMeta(entry cloud.FileEntry, rel, ext string) { st.recordRemoteMeta(entry, rel) @@ -993,9 +1123,22 @@ func (st *strmSyncState) handleMeta(entry cloud.FileEntry, rel, ext string) { st.seenMetaTarget[target] = entry st.mu.Unlock() - if info, err := os.Stat(target); err == nil && info.Size() == entry.Size { - st.touchProgress() - return + if info, err := os.Stat(target); err == nil { + // 开启上传元数据时以本地为准:本地已存在的元数据不再用远端版本覆盖, + // 与网盘版本的差异交给上传队列把本地文件推回网盘,避免下载/上传 + // 两个队列互相覆盖形成回环。 + 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() if st.activeDownloadPaths == nil { @@ -1135,6 +1278,8 @@ func (st *strmSyncState) walkLocalSource() error { } // scanLocalMetaForUpload 扫描本地元数据,与远端比对后入上传队列。 +// 以本地为准:网盘端不存在、同名不同大小、或同名同大小但 SHA1 不同(115 提供 +// 远端哈希时做内容级比对)均入队覆盖上传;同名同大小同内容视为同一文件跳过。 func (st *strmSyncState) scanLocalMetaForUpload() error { defer st.flushPendingUploads() if st.activeUploadPaths == nil { @@ -1174,12 +1319,22 @@ func (st *strmSyncState) scanLocalMetaForUpload() error { return nil } 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() - if exists { - // 网盘端已存在该元数据文件,跳过上传 - return nil + if exists && remoteSize == info.Size() { + // 网盘端同名同大小:候选同一文件。115 提供远端 SHA1 时做内容级比对, + // 识别"同大小不同内容"(如 nfo 改一个字符长度不变)避免漏传; + // 无哈希(其他网盘/列表未返回)视为同一文件,保持大小比对兜底。 + if remoteSha1 == "" || st.localSha1Matches(path, remoteSha1) { + return nil + } } + // 入队上传(以本地为准):网盘端不存在;或同名但大小不同(必然内容不同); + // 或同名同大小但 SHA1 不同(精确比对发现的同大小不同内容)。 + // 115 的上传接口不保证同名覆盖,任务携带远端旧文件 ID(RemoteRef), + // 由上传端先删旧文件再上传;WebDAV/OpenList 的 PutFile 本身即覆盖上传。 st.mu.Lock() if st.activeUploadPaths != nil && st.activeUploadPaths[path] { st.mu.Unlock() @@ -1200,6 +1355,9 @@ func (st *strmSyncState) scanLocalMetaForUpload() error { Size: info.Size(), Status: model.StrmTaskPending, } + if exists && st.p.Provider == model.StrmProvider115 { + task.RemoteRef = remoteRef + } st.mu.Lock() st.pendingUploads = append(st.pendingUploads, task) shouldFlush := len(st.pendingUploads) >= 100 @@ -1260,10 +1418,11 @@ func (st *strmSyncState) taskExists(kind, syncPathID, localPath string) bool { return count > 0 } -// pruneLocal 清理本地多余 .strm 与元数据(远端已不存在),可选删除空目录。 +// pruneLocal 清理本地多余 .strm(远端已不存在的视频),可选删除空目录。 +// 元数据文件(nfo/图片/字幕等)一律保留:本地刮削结果不因网盘端缺失而被删除。 func (st *strmSyncState) pruneLocal() error { // 增量同步保护:本次远端扫描不完整(目录详情解析失败 / 文件父路径降级)时, - // seenVideo/seenMeta 覆盖不全,按"远端不存在"清理会误删刚下载或已存在的本地文件, + // seenVideo 覆盖不全,按"远端不存在"清理会误删刚下载或已存在的本地 .strm, // 进而触发"下次增量重新下载"的循环。此时跳过清理,仅做进度落库。 if st.syncType == model.StrmSyncTypeIncremental && st.scanIncomplete.Load() { st.s.log.Warn("strm 增量同步跳过清理:本次远端扫描不完整,prune 已禁用", @@ -1295,16 +1454,11 @@ func (st *strmSyncState) pruneLocal() error { rel = filepath.ToSlash(rel) ext := strings.ToLower(filepath.Ext(rel)) remove := false - switch { - case ext == ".strm": + if ext == ".strm" { relSansExt := rel[:len(rel)-len(ext)] st.mu.Lock() remove = !st.seenVideo["v:"+relSansExt] 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 err := os.Remove(path); err == nil { diff --git a/internal/service/strm_sync_test.go b/internal/service/strm_sync_test.go index 0d62a1d..8ca41e8 100644 --- a/internal/service/strm_sync_test.go +++ b/internal/service/strm_sync_test.go @@ -344,14 +344,19 @@ func TestSanitizePathWithSpecialChars(t *testing.T) { } } -// TestScanLocalMetaForUpload 验证元数据上传比对逻辑:网盘已存在跳过,网盘不存在才入队上传。 +// TestScanLocalMetaForUpload 验证元数据上传比对逻辑:网盘同名同大小(同一文件)跳过, +// 网盘不存在或同名不同大小(内容不同的元数据文件)以本地为准入队上传。 func TestScanLocalMetaForUpload(t *testing.T) { svc := testStrmService(t) 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, "动漫", "fanart.jpg"), "fanart-data") + writeFile(t, filepath.Join(localDir, "动漫", "tvshow.nfo"), "local-nfo-data") p := &model.StrmSyncPath{ Base: model.Base{ID: "test-path-upload"}, @@ -363,35 +368,47 @@ func TestScanLocalMetaForUpload(t *testing.T) { } 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{}, + 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{}, } - // 模拟远端已存在 poster.jpg - st.remoteMeta["m:动漫/poster.jpg"] = 1000 + // 模拟远端已存在 poster.jpg(与本地同一文件)和 tvshow.nfo(与本地不同) + st.remoteMeta["m:动漫/poster.jpg"] = int64(len("poster-data")) + st.remoteMeta["m:动漫/tvshow.nfo"] = 999 if err := st.scanLocalMetaForUpload(); err != nil { 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) if err != nil { t.Fatal(err) } - if len(tasks) != 1 { - t.Fatalf("expected 1 upload task (fanart.jpg), got %d", len(tasks)) + if len(tasks) != 2 { + t.Fatalf("expected 2 upload tasks (fanart.jpg, tvshow.nfo), got %d", len(tasks)) } - if tasks[0].FileName != "fanart.jpg" { - t.Errorf("expected upload task for fanart.jpg, got %s", tasks[0].FileName) + got := map[string]model.StrmUploadTask{} + 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 { t.Fatalf("second scanLocalMetaForUpload failed: %v", err) } @@ -399,8 +416,313 @@ func TestScanLocalMetaForUpload(t *testing.T) { if err != nil { 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"), "") + 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 { - 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) { switch r.URL.Path { 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": w.Write([]byte(`{"state":false,"code":40140123,"message":"access_token 格式错误"}`)) default: @@ -716,3 +1038,126 @@ func TestWalk115FlatAbortsOnDirResolveFailure(t *testing.T) { 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) + } +}