mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
360 lines
11 KiB
Go
360 lines
11 KiB
Go
// 115 网盘元数据上传能力:115 开放平台调度 + 阿里云 OSS 直传。
|
||
// 参考 QMediaSync 的上传流程实现:
|
||
//
|
||
// POST /open/upload/init 上传初始化/秒传调度(含二次签名)
|
||
// GET /open/upload/get_token 获取 OSS 临时上传凭证(STS)
|
||
// OSS multipart 分片直传 + callback 完成
|
||
//
|
||
// 上传目标父目录为 115 目录 ID(cid),而非路径字符串。
|
||
package cloud115
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// 115 上传状态码。
|
||
const (
|
||
UploadInitStatusNeedUpload = 1 // 需要真实上传
|
||
UploadInitStatusRapidUploaded = 2 // 秒传成功
|
||
UploadInitStatusSignFailed = 6 // 签名验证失败
|
||
UploadInitStatusNeedSign = 7 // 需要二次签名
|
||
UploadInitStatusSignRejected = 8 // 签名认证失败
|
||
)
|
||
|
||
// UploadInitRequest 是 /open/upload/init 的结构化请求。
|
||
type UploadInitRequest struct {
|
||
FileName string
|
||
FileSize int64
|
||
ParentFileId string
|
||
FileSha1 string
|
||
Preid string
|
||
PickCode string
|
||
TopUpload string
|
||
SignKey string
|
||
SignVal string
|
||
}
|
||
|
||
// UploadInitResult 是 /open/upload/init 的调度结果。
|
||
type UploadInitResult struct {
|
||
PickCode string
|
||
Status int
|
||
FileId string
|
||
Target string
|
||
Bucket string
|
||
Object string
|
||
SignKey string
|
||
SignCheck string
|
||
Callback UploadResultCallBack
|
||
}
|
||
|
||
type uploadScheduleAPIResult struct {
|
||
PickCode string `json:"pick_code"`
|
||
Status int `json:"status"`
|
||
FileId string `json:"file_id"`
|
||
Target string `json:"target"`
|
||
Version string `json:"version"`
|
||
Bucket string `json:"bucket"`
|
||
Object string `json:"object"`
|
||
SignKey string `json:"sign_key"`
|
||
SignCheck string `json:"sign_check"`
|
||
Callback json.RawMessage `json:"callback"`
|
||
}
|
||
|
||
// UploadResultCallBack 是 init 返回给 OSS complete 使用的 callback 内容。
|
||
type UploadResultCallBack struct {
|
||
Callback string `json:"callback"`
|
||
CallbackVar string `json:"callback_var"`
|
||
}
|
||
|
||
// UploadToken 是 /open/upload/get_token 返回的 OSS STS 临时凭证。
|
||
type UploadToken struct {
|
||
Endpoint string `json:"endpoint"`
|
||
AccessKeySecret string `json:"AccessKeySecret"`
|
||
AccessKeySecrett string `json:"AccessKeySecrett"`
|
||
SecurityToken string `json:"SecurityToken"`
|
||
Expiration string `json:"Expiration"`
|
||
AccessKeyId string `json:"AccessKeyId"`
|
||
}
|
||
|
||
func (token *UploadToken) normalize() {
|
||
if token == nil {
|
||
return
|
||
}
|
||
if token.AccessKeySecret == "" {
|
||
token.AccessKeySecret = token.AccessKeySecrett
|
||
}
|
||
}
|
||
|
||
// UploadCompleteResult 是 OSS complete callback 成功后的远端文件定位结果。
|
||
type UploadCompleteResult struct {
|
||
FileId string
|
||
PickCode string
|
||
ParentId string
|
||
Sha1 string
|
||
Size int64
|
||
Mtime int64
|
||
}
|
||
|
||
// SignCheckRange 是 115 二次认证要求的闭区间 [start,end]。
|
||
type SignCheckRange struct {
|
||
Start int64
|
||
End int64
|
||
}
|
||
|
||
// UploadInit 调用 115 上传初始化/秒传调度接口。
|
||
func (c *OpenClient) UploadInit(ctx context.Context, input UploadInitRequest) (*UploadInitResult, error) {
|
||
params := buildUploadInitForm(input)
|
||
resp, err := c.doAuthJSON(ctx, "POST", ProAPIBase+"/open/upload/init", params, 2)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var raw uploadScheduleAPIResult
|
||
if err := json.Unmarshal(resp.Data, &raw); err != nil {
|
||
return nil, fmt.Errorf("115: 解析 upload/init 结果失败:%w", err)
|
||
}
|
||
callback, err := decodeUploadCallback(raw.Callback)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &UploadInitResult{
|
||
PickCode: raw.PickCode,
|
||
Status: raw.Status,
|
||
FileId: raw.FileId,
|
||
Target: raw.Target,
|
||
Bucket: raw.Bucket,
|
||
Object: raw.Object,
|
||
SignKey: raw.SignKey,
|
||
SignCheck: raw.SignCheck,
|
||
Callback: callback,
|
||
}, nil
|
||
}
|
||
|
||
func buildUploadInitForm(input UploadInitRequest) map[string]string {
|
||
topUpload := input.TopUpload
|
||
if topUpload == "" {
|
||
topUpload = "0"
|
||
}
|
||
params := map[string]string{
|
||
"file_name": input.FileName,
|
||
"file_size": strconv.FormatInt(input.FileSize, 10),
|
||
"target": fmt.Sprintf("U_1_%s", input.ParentFileId),
|
||
"fileid": input.FileSha1,
|
||
"preid": input.Preid,
|
||
"topupload": topUpload,
|
||
}
|
||
if input.PickCode != "" {
|
||
params["pick_code"] = input.PickCode
|
||
}
|
||
if input.SignKey != "" && input.SignVal != "" {
|
||
params["sign_key"] = input.SignKey
|
||
params["sign_val"] = input.SignVal
|
||
}
|
||
return params
|
||
}
|
||
|
||
func decodeUploadCallback(raw json.RawMessage) (UploadResultCallBack, error) {
|
||
if len(raw) == 0 || string(raw) == "null" {
|
||
return UploadResultCallBack{}, nil
|
||
}
|
||
if raw[0] == '[' {
|
||
var callbacks []UploadResultCallBack
|
||
if err := json.Unmarshal(raw, &callbacks); err != nil {
|
||
return UploadResultCallBack{}, err
|
||
}
|
||
if len(callbacks) == 0 {
|
||
return UploadResultCallBack{}, nil
|
||
}
|
||
return callbacks[0], nil
|
||
}
|
||
var callback UploadResultCallBack
|
||
if err := json.Unmarshal(raw, &callback); err != nil {
|
||
return UploadResultCallBack{}, err
|
||
}
|
||
return callback, nil
|
||
}
|
||
|
||
func parseSignCheckRange(value string) (SignCheckRange, error) {
|
||
parts := strings.Split(value, "-")
|
||
if len(parts) != 2 {
|
||
return SignCheckRange{}, fmt.Errorf("sign_check 格式错误:%s", value)
|
||
}
|
||
start, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
|
||
if err != nil {
|
||
return SignCheckRange{}, err
|
||
}
|
||
end, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
|
||
if err != nil {
|
||
return SignCheckRange{}, err
|
||
}
|
||
if start < 0 || end < start {
|
||
return SignCheckRange{}, fmt.Errorf("sign_check 范围非法:%s", value)
|
||
}
|
||
return SignCheckRange{Start: start, End: end}, nil
|
||
}
|
||
|
||
// GetUploadToken 获取 115 下发的 OSS 临时上传凭证。
|
||
func (c *OpenClient) GetUploadToken(ctx context.Context) (*UploadToken, error) {
|
||
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/upload/get_token", nil, 2)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var token UploadToken
|
||
if err := json.Unmarshal(resp.Data, &token); err != nil {
|
||
return nil, fmt.Errorf("115: 解析 get_token 结果失败:%w", err)
|
||
}
|
||
token.normalize()
|
||
return &token, nil
|
||
}
|
||
|
||
// Upload 上传单个本地文件到 115 指定父目录(cid),返回成功后的远端文件信息。
|
||
// filePath 必须是落到磁盘的真实文件路径(调用方负责把 io.Reader 落盘为临时文件)。
|
||
func (c *OpenClient) Upload(ctx context.Context, filePath, parentCID, signKey, signVal string) (*UploadCompleteResult, error) {
|
||
fileSize := fileSizeOf(filePath)
|
||
if fileSize < 0 {
|
||
return nil, fmt.Errorf("115: 无法获取文件大小:%s", filePath)
|
||
}
|
||
fileSha1, err := FileSHA1(filePath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("115: 计算文件 SHA1 失败:%w", err)
|
||
}
|
||
preSha1, err := FileSHA1Partial(filePath, 0, 128*1024-1)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("115: 计算文件前 128 KiB SHA1 失败:%w", err)
|
||
}
|
||
request := UploadInitRequest{
|
||
FileName: baseNameOf(filePath),
|
||
FileSize: fileSize,
|
||
ParentFileId: parentCID,
|
||
FileSha1: fileSha1,
|
||
Preid: preSha1,
|
||
TopUpload: "0",
|
||
SignKey: signKey,
|
||
SignVal: signVal,
|
||
}
|
||
initResult, err := c.UploadInit(ctx, request)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("115: 上传初始化失败:%w", err)
|
||
}
|
||
status := initResult.Status
|
||
if status == UploadInitStatusNeedSign {
|
||
// 二次签名:按 sign_check 指定区间重算 sha1
|
||
rng, err := parseSignCheckRange(initResult.SignCheck)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
signValue, err := FileSHA1Partial(filePath, rng.Start, rng.End)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
request.SignKey = initResult.SignKey
|
||
request.SignVal = signValue
|
||
initResult, err = c.UploadInit(ctx, request)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("115: 上传二次签名失败:%w", err)
|
||
}
|
||
status = initResult.Status
|
||
}
|
||
switch status {
|
||
case UploadInitStatusRapidUploaded:
|
||
// 秒传成功:必须带远端文件定位信息,否则视为异常响应
|
||
if initResult.FileId == "" || initResult.PickCode == "" {
|
||
return nil, fmt.Errorf("115: 秒传成功但缺少 file_id/pick_code(status=%d)", status)
|
||
}
|
||
return &UploadCompleteResult{FileId: initResult.FileId, PickCode: initResult.PickCode}, nil
|
||
case UploadInitStatusSignFailed:
|
||
return nil, fmt.Errorf("115: 签名验证后失败")
|
||
case UploadInitStatusSignRejected:
|
||
return nil, fmt.Errorf("115: 签名认证失败")
|
||
case UploadInitStatusNeedUpload:
|
||
// 真实上传:OSS multipart
|
||
default:
|
||
// 未知状态不能当成功返回(会静默丢文件),显式报错便于排查
|
||
return nil, fmt.Errorf("115: 未知的 upload/init 状态 %d", status)
|
||
}
|
||
|
||
if initResult.Bucket == "" || initResult.Object == "" {
|
||
return nil, fmt.Errorf("115: upload/init 缺少 bucket/object 信息")
|
||
}
|
||
token, err := c.GetUploadToken(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("115: 获取上传凭证失败:%w", err)
|
||
}
|
||
if token == nil || token.Endpoint == "" || token.AccessKeyId == "" || token.AccessKeySecret == "" {
|
||
return nil, fmt.Errorf("115: 上传凭证不完整")
|
||
}
|
||
uploader := NewOSSMultipartUploader(token.Endpoint, token.AccessKeyId, token.AccessKeySecret, token.SecurityToken)
|
||
result, err := uploader.UploadFile(ctx, OSSMultipartUploadInput{
|
||
Bucket: initResult.Bucket,
|
||
Object: initResult.Object,
|
||
Callback: initResult.Callback.Callback,
|
||
CallbackVar: initResult.Callback.CallbackVar,
|
||
FilePath: filePath,
|
||
FileSize: fileSize,
|
||
refreshClient: func(ctx context.Context) (ossMultipartClient, error) {
|
||
refreshed, rerr := c.GetUploadToken(ctx)
|
||
if rerr != nil || refreshed == nil {
|
||
return nil, rerr
|
||
}
|
||
return newOSSMultipartClient(refreshed.Endpoint, refreshed.AccessKeyId, refreshed.AccessKeySecret, refreshed.SecurityToken), nil
|
||
},
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("115: OSS 上传失败:%w", err)
|
||
}
|
||
complete, err := ParseCompleteCallbackResult(result)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &complete, nil
|
||
}
|
||
|
||
// MkDir 在 115 的 parentCid 下创建目录,返回新目录 cid。
|
||
func (c *OpenClient) MkDir(ctx context.Context, parentCID, name string) (string, error) {
|
||
params := map[string]string{
|
||
"cname": name,
|
||
"pid": parentCID,
|
||
}
|
||
resp, err := c.doAuthJSON(ctx, "POST", ProAPIBase+"/open/folder/add", params, 2)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
// /open/folder/add 结构:{ aid, cid, fid, name, pid, ... },单一对象
|
||
var r struct {
|
||
Cid string `json:"cid"`
|
||
}
|
||
if err := json.Unmarshal(resp.Data, &r); err != nil {
|
||
return "", fmt.Errorf("115: 解析 folder/add 结果失败:%w", err)
|
||
}
|
||
if r.Cid == "" {
|
||
return "", errors.New("115: folder/add 未返回 cid")
|
||
}
|
||
return r.Cid, nil
|
||
}
|
||
|
||
func fileSizeOf(path string) int64 {
|
||
info, err := os.Stat(path)
|
||
if err != nil {
|
||
return -1
|
||
}
|
||
if info.IsDir() {
|
||
return -1
|
||
}
|
||
return info.Size()
|
||
}
|
||
|
||
func baseNameOf(path string) string {
|
||
s := path
|
||
for i := len(s) - 1; i >= 0; i-- {
|
||
if s[i] == '/' || s[i] == '\\' {
|
||
return s[i+1:]
|
||
}
|
||
}
|
||
return s
|
||
}
|