// 115 开放平台只读 API:列目录、详情、下载直链、授权(二维码/换 token/刷新)。 package cloud115 import ( "context" "encoding/json" "fmt" "strings" ) // ─── 文件模型 ────────────────────────────────────────────────────────────────── type FileType string const ( TypeFile FileType = "1" TypeDir FileType = "0" ) // RemoteFile 是 115 文件列表中的一个条目。 type RemoteFile struct { FileId string `json:"fid"` // 文件 ID Pid string `json:"pid"` // 父文件夹 ID Category FileType `json:"fc"` // 0 文件夹 1 文件 FileName string `json:"fn"` // 文件名 PickCode string `json:"pc"` // 提取码 Utime int64 `json:"upt"` // 修改时间 Ptime int64 `json:"uppt"` // 上传时间 Sha1 string `json:"sha1"` // SHA1 FileSize int64 `json:"fs"` // 大小 Fta string `json:"fta"` // 0/2 未上传完成,1 已完成 } func (f RemoteFile) ModifiedAt() int64 { if f.Utime > 0 { return f.Utime } return f.Ptime } type FileListResp struct { RespBase Path []struct { Name string `json:"name"` FileId string `json:"cid"` } `json:"path"` PathStr string `json:"path_str"` } // GetFsList 列目录(cid=0 为根目录)。 func (c *OpenClient) GetFsList(ctx context.Context, cid string, offset, limit int) ([]RemoteFile, string, error) { if cid == "" { cid = "0" } params := map[string]string{"cid": cid} if limit > 0 { params["limit"] = fmt.Sprint(limit) } if offset > 0 { params["offset"] = fmt.Sprint(offset) } params["cur"] = "1" params["show_dir"] = "1" resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/ufile/files", params, 2) if err != nil { return nil, "", err } // state=false(token 过期、限流、业务失败等)绝不能当作空目录返回: // 同步流程会据此认为远端已清空并清理本地 .strm/元数据文件。 if !resp.State { return nil, "", NewOpenAPIResponseError(resp.Code, resp.Errno, resp.Message, resp.Error, "115 接口调用失败") } var list FileListResp list.RespBase = *resp if len(resp.Raw) > 0 { _ = json.Unmarshal(resp.Raw, &list) } files, err := openList[RemoteFile](resp.Data) if err != nil { return nil, "", fmt.Errorf("115: 解析文件列表失败:%w", err) } pathStr := make([]string, 0, len(list.Path)) for _, item := range list.Path { if item.FileId == "0" { continue } pathStr = append(pathStr, item.Name) } return files, strings.Join(pathStr, "/"), nil } // GetFsListFlat 递归扁平化列出 cid 下的所有文件(跨越所有子目录,不包含文件夹节点),并返回文件列表与该树下的总文件数。 // 类似于 QMediaSync 的 115 扁平化批量拉取机制,极大地降低多层级子目录下的 API 请求次数。 func (c *OpenClient) GetFsListFlat(ctx context.Context, cid string, offset, limit int) ([]RemoteFile, int64, error) { if cid == "" { cid = "0" } if limit <= 0 { limit = 1150 } params := map[string]string{ "cid": cid, "limit": fmt.Sprint(limit), "offset": fmt.Sprint(offset), "cur": "0", "show_dir": "0", } resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/ufile/files", params, 2) if err != nil { return nil, 0, err } if !resp.State { return nil, 0, NewOpenAPIResponseError(resp.Code, resp.Errno, resp.Message, resp.Error, "115 接口调用失败") } files, err := openList[RemoteFile](resp.Data) if err != nil { return nil, 0, fmt.Errorf("115: 解析文件列表失败:%w", err) } return files, resp.Count, nil } // GetFsDetailByCid 查询文件(夹)详情。 func (c *OpenClient) GetFsDetailByCid(ctx context.Context, fileId string) (*RemoteFileDetail, error) { params := map[string]string{"file_id": fileId} resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/folder/get_info", params, 2) if err != nil { return nil, err } return openFirstList[RemoteFileDetail](resp.Data) } // RemoteFileDetail 是文件详情。 type RemoteFileDetail struct { FileId string `json:"file_id"` FileName string `json:"file_name"` PickCode string `json:"pick_code"` Sha1 string `json:"sha1"` Category FileType `json:"file_category"` SizeByte int64 `json:"size_byte"` Paths []struct { FileId string `json:"file_id"` Name string `json:"file_name"` } `json:"paths"` } // RelativePath 计算该目录相对于根同步目录(rootCID)的相对路径。 func (d *RemoteFileDetail) RelativePath(rootCID string) string { if d == nil { return "" } if rootCID == "" { rootCID = "0" } if d.FileId == rootCID { return "" } rootIdx := -1 for i, p := range d.Paths { if p.FileId == rootCID { rootIdx = i break } } var segments []string start := 0 if rootIdx >= 0 { start = rootIdx + 1 } else if len(d.Paths) > 0 && (d.Paths[0].FileId == "0" || d.Paths[0].FileId == "") { start = 1 } hasSelf := false for i := start; i < len(d.Paths); i++ { if d.Paths[i].FileId == d.FileId { hasSelf = true } name := strings.TrimSpace(d.Paths[i].Name) if name != "" { segments = append(segments, name) } } // 若 115 返回的 paths 祖先链未包含当前目录自身,则将其自身目录名 FileName 补在末尾 if !hasSelf && strings.TrimSpace(d.FileName) != "" && d.FileId != rootCID { segments = append(segments, strings.TrimSpace(d.FileName)) } return strings.Join(segments, "/") } // ─── 下载直链 ────────────────────────────────────────────────────────────────── type downloadURLData struct { FileName string `json:"file_name"` PickCode string `json:"pick_code"` Sha1 string `json:"sha1"` URL struct { URL string `json:"url"` } `json:"url"` } // GetDownloadURL 获取下载直链(pickcode)。命中缓存直接返回, // 避免对同一文件反复换取直链触发 115 风控。 func (c *OpenClient) GetDownloadURL(ctx context.Context, pickCode string) (string, error) { return c.GetDownloadURLWithUA(ctx, pickCode, "") } // GetDownloadURLWithUA 支持按调用方/播放器 User-Agent 换取对应的 115 CDN 直链(用于 115 防盗链白名单校验)。 func (c *OpenClient) GetDownloadURLWithUA(ctx context.Context, pickCode, ua string) (string, error) { ua = strings.TrimSpace(ua) if cached := GetDownloadURLCache(pickCode, ua); cached != "" { return cached, nil } params := map[string]string{"pick_code": pickCode} resp, err := c.doAuthJSONWithUA(ctx, "POST", ProAPIBase+"/open/ufile/downurl", params, 1, ua) if err != nil { return "", err } var data map[string]downloadURLData if err := json.Unmarshal(resp.Data, &data); err != nil { return "", fmt.Errorf("115: 解析下载地址失败:%w", err) } first := firstOrEmpty(data) if first.URL.URL == "" { return "", fmt.Errorf("115: 下载地址为空(文件可能未上传完成或已被删除)") } SetDownloadURLCache(pickCode, first.URL.URL, ua) 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 扫码状态。 type QrCodeScanStatus int const ( QrCodeScanStatusExpired QrCodeScanStatus = 5 QrCodeScanStatusNotScanned QrCodeScanStatus = 2 QrCodeScanStatusScanned QrCodeScanStatus = 3 QrCodeScanStatusConfirmed QrCodeScanStatus = 4 ) func (s QrCodeScanStatus) String() string { switch s { case QrCodeScanStatusNotScanned: return "waiting" case QrCodeScanStatusScanned: return "scanned" case QrCodeScanStatusConfirmed: return "confirmed" default: return "expired" } } func (s QrCodeScanStatus) Tip() string { switch s { case QrCodeScanStatusNotScanned: return "等待扫码" case QrCodeScanStatusScanned: return "已扫码,请在 115 客户端确认" case QrCodeScanStatusConfirmed: return "授权成功" default: return "二维码已过期" } } // QrCodeData 是设备码二维码数据。 type QrCodeData struct { Uid string `json:"uid"` Time int64 `json:"time"` Sign string `json:"sign"` Qrcode string `json:"qrcode"` // 二维码图片 URL } // QrCodeDataReturn 含 PKCE code_verifier。 type QrCodeDataReturn struct { QrCodeData CodeVerifier string `json:"code_verifier"` } // TokenData 是 115 访问令牌。 type TokenData struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int64 `json:"expires_in"` } // GetQrCode 获取设备码登录二维码。 func (c *OpenClient) GetQrCode() (*QrCodeDataReturn, error) { codeVerifier := RandomString(64) params := map[string]string{ "client_id": c.AppID, "code_challenge": genCodeChallenge(codeVerifier), "code_challenge_method": "sha256", } resp, err := c.doJSON(context.Background(), "POST", PassportAPIBase+"/open/authDeviceCode", params, false, 1) if err != nil { return nil, err } code, err := openFirstList[QrCodeData](resp.Data) if err != nil { return nil, err } // 关键字段缺失时显式报错:空 uid/sign 会导致后续扫码轮询必然失败, // 不能把残缺响应当成功返回给界面。 if code.Uid == "" || code.Sign == "" { return nil, fmt.Errorf("115: 设备码响应缺少 uid/sign,无法发起扫码授权") } return &QrCodeDataReturn{QrCodeData: *code, CodeVerifier: codeVerifier}, nil } // QrCodeScanStatus 查询扫码状态。 func (c *OpenClient) QrCodeScanStatus(codeData *QrCodeData) (QrCodeScanStatus, error) { if codeData == nil { return QrCodeScanStatusExpired, fmt.Errorf("空二维码数据") } params := map[string]string{ "uid": codeData.Uid, "time": fmt.Sprint(codeData.Time), "sign": codeData.Sign, } resp, err := c.doJSON(context.Background(), "GET", QRCodeAPIBase+"/get/status/", params, false, 1) if err != nil { return QrCodeScanStatusExpired, err } status, err := openFirstList[struct { Status int `json:"status"` // 0 未扫码 1 已扫码 2 已确认 }](resp.Data) if err != nil { return QrCodeScanStatusExpired, err } switch status.Status { case 1: return QrCodeScanStatusScanned, nil case 2: return QrCodeScanStatusConfirmed, nil case 0: return QrCodeScanStatusNotScanned, nil default: return QrCodeScanStatusExpired, nil } } // GetToken 用设备码换访问令牌。 func (c *OpenClient) GetToken(qrCode *QrCodeDataReturn) (*TokenData, error) { if qrCode == nil || qrCode.Uid == "" { return nil, fmt.Errorf("空二维码数据") } params := map[string]string{ "uid": qrCode.Uid, "code_verifier": qrCode.CodeVerifier, } resp, err := c.doJSON(context.Background(), "POST", PassportAPIBase+"/open/deviceCodeToToken", params, false, 0) if err != nil { return nil, err } token, err := openFirstList[TokenData](resp.Data) if err != nil { return nil, err } // 空凭证绝不能 SetAuthToken 后当成功返回:界面会显示"授权成功"但账号不可用 if token.AccessToken == "" || token.RefreshToken == "" { return nil, fmt.Errorf("115: 设备码换 token 返回空凭证(access_token/refresh_token 缺失)") } c.SetAuthToken(token.AccessToken, token.RefreshToken) return token, nil } // RefreshToken 刷新访问令牌。 func (c *OpenClient) RefreshToken(refreshToken string) (*TokenData, error) { if refreshToken == "" { refreshToken = c.currentRefreshToken() } if refreshToken == "" { return nil, fmt.Errorf("没有可用的 refresh_token") } token, err := c.doRefreshToken(refreshToken) if err != nil { // refresh_token 已失效时清空内存令牌(提示需重新授权) if IsRefreshTokenDead(err) { c.SetAuthToken("", "") } return nil, err } if token.AccessToken == "" || token.RefreshToken == "" { return nil, fmt.Errorf("115: 刷新返回空凭证(access_token/refresh_token 缺失)") } c.SetAuthToken(token.AccessToken, token.RefreshToken) return token, nil } // doRefreshToken 调用 115 刷新接口换取新令牌,不修改客户端内存状态; // 拆出无状态方法供 tryRefreshTokenLocked(已持 tokenMu 写锁)复用, // 避免在持锁期间重入 SetAuthToken 造成死锁。 func (c *OpenClient) doRefreshToken(refreshToken string) (*TokenData, error) { params := map[string]string{"refresh_token": refreshToken} resp, err := c.doJSON(context.Background(), "POST", PassportAPIBase+"/open/refreshToken", params, false, 0) if err != nil && resp == nil { return nil, err } if resp == nil { return nil, err } if !resp.State { return nil, NewOpenAPIResponseError(resp.Code, resp.Errno, resp.Message, resp.Error, "115 开放平台刷新访问凭证失败") } return openFirstList[TokenData](resp.Data) } // ─── 用户信息 ────────────────────────────────────────────────────────────────── // UserInfo 是 115 用户信息。 type UserInfo struct { UserId json.Number `json:"user_id"` UserName string `json:"user_name"` } // FetchUserInfo 获取用户信息。 func (c *OpenClient) FetchUserInfo(ctx context.Context) (*UserInfo, error) { resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/user/info", nil, 1) if err != nil { return nil, err } var info UserInfo if err := json.Unmarshal(resp.Data, &info); err != nil { return nil, fmt.Errorf("115: 解析用户信息失败:%w", err) } return &info, nil }