feat(seedance): 接入真人资产管理与引用
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
volcesAssetDefaultEndpoint = "https://ark.cn-beijing.volcengineapi.com"
|
||||
volcesAssetRegion = "cn-beijing"
|
||||
volcesAssetService = "ark"
|
||||
volcesAssetVersion = "2024-01-01"
|
||||
)
|
||||
|
||||
type VolcesAssetClient struct {
|
||||
HTTPClient *http.Client
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type VolcesAssetCredentials struct {
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
type VolcesAssetResult struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name,omitempty"`
|
||||
URL string `json:"URL,omitempty"`
|
||||
AssetType string `json:"AssetType,omitempty"`
|
||||
GroupID string `json:"GroupId,omitempty"`
|
||||
Status string `json:"Status,omitempty"`
|
||||
Error map[string]any `json:"Error,omitempty"`
|
||||
ProjectName string `json:"ProjectName,omitempty"`
|
||||
CreateTime string `json:"CreateTime,omitempty"`
|
||||
UpdateTime string `json:"UpdateTime,omitempty"`
|
||||
}
|
||||
|
||||
func (c VolcesAssetClient) CreateAsset(ctx context.Context, credentials VolcesAssetCredentials, body map[string]any) (VolcesAssetResult, string, error) {
|
||||
var result struct {
|
||||
ID string `json:"Id"`
|
||||
}
|
||||
requestID, err := c.call(ctx, credentials, "CreateAsset", body, &result)
|
||||
return VolcesAssetResult{ID: result.ID}, requestID, err
|
||||
}
|
||||
|
||||
func (c VolcesAssetClient) GetAsset(ctx context.Context, credentials VolcesAssetCredentials, body map[string]any) (VolcesAssetResult, string, error) {
|
||||
var result VolcesAssetResult
|
||||
requestID, err := c.call(ctx, credentials, "GetAsset", body, &result)
|
||||
return result, requestID, err
|
||||
}
|
||||
|
||||
func (c VolcesAssetClient) call(ctx context.Context, credentials VolcesAssetCredentials, action string, body map[string]any, target any) (string, error) {
|
||||
accessKey := strings.TrimSpace(credentials.AccessKey)
|
||||
secretKey := strings.TrimSpace(credentials.SecretKey)
|
||||
if accessKey == "" || secretKey == "" {
|
||||
return "", &ClientError{Code: "missing_credentials", Message: "volces portrait asset accessKey and secretKey are required", Retryable: false}
|
||||
}
|
||||
endpoint := strings.TrimRight(strings.TrimSpace(credentials.Endpoint), "/")
|
||||
if endpoint == "" {
|
||||
endpoint = volcesAssetDefaultEndpoint
|
||||
}
|
||||
baseURL, err := url.Parse(endpoint)
|
||||
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
|
||||
return "", &ClientError{Code: "invalid_configuration", Message: "invalid volces portrait asset endpoint", Retryable: false}
|
||||
}
|
||||
bodyJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal volces asset request: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if c.Now != nil {
|
||||
now = c.Now().UTC()
|
||||
}
|
||||
xDate := now.Format("20060102T150405Z")
|
||||
contentSHA := sha256HexBytes(bodyJSON)
|
||||
requestURL := *baseURL
|
||||
requestURL.Path = "/"
|
||||
requestURL.RawPath = ""
|
||||
requestURL.RawQuery = canonicalVolcesAssetQuery(map[string]string{"Action": action, "Version": volcesAssetVersion})
|
||||
headers := map[string]string{
|
||||
"content-type": "application/json",
|
||||
"host": baseURL.Host,
|
||||
"x-content-sha256": contentSHA,
|
||||
"x-date": xDate,
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(bodyJSON))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Host = baseURL.Host
|
||||
req.Header.Set("Content-Type", headers["content-type"])
|
||||
req.Header.Set("X-Content-Sha256", headers["x-content-sha256"])
|
||||
req.Header.Set("X-Date", headers["x-date"])
|
||||
req.Header.Set("Authorization", volcesAssetAuthorization(accessKey, secretKey, http.MethodPost, "/", requestURL.RawQuery, headers, contentSHA, xDate))
|
||||
|
||||
response, err := httpClient(nil, c.HTTPClient).Do(req)
|
||||
if err != nil {
|
||||
return "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var envelope struct {
|
||||
ResponseMetadata struct {
|
||||
RequestID string `json:"RequestId"`
|
||||
Error struct {
|
||||
Code string `json:"Code"`
|
||||
Message string `json:"Message"`
|
||||
} `json:"Error"`
|
||||
} `json:"ResponseMetadata"`
|
||||
Result json.RawMessage `json:"Result"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil {
|
||||
return requestIDFromHTTPResponse(response), &ClientError{Code: "invalid_response", Message: "decode volces portrait asset response: " + err.Error(), Retryable: HTTPRetryable(response.StatusCode), StatusCode: response.StatusCode}
|
||||
}
|
||||
requestID := firstNonEmpty(requestIDFromHTTPResponse(response), envelope.ResponseMetadata.RequestID)
|
||||
if envelope.ResponseMetadata.Error.Code != "" || response.StatusCode >= http.StatusBadRequest {
|
||||
message := strings.TrimSpace(envelope.ResponseMetadata.Error.Message)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(envelope.ResponseMetadata.Error.Code)
|
||||
}
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("volces %s failed with status %d", action, response.StatusCode)
|
||||
}
|
||||
return requestID, &ClientError{Code: firstNonEmpty(envelope.ResponseMetadata.Error.Code, "volces_asset_error"), Message: message, RequestID: requestID, StatusCode: response.StatusCode, Retryable: HTTPRetryable(response.StatusCode)}
|
||||
}
|
||||
if len(envelope.Result) == 0 || string(envelope.Result) == "null" {
|
||||
return requestID, &ClientError{Code: "invalid_response", Message: "volces " + action + " returned empty result", RequestID: requestID, Retryable: false}
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Result, target); err != nil {
|
||||
return requestID, &ClientError{Code: "invalid_response", Message: "decode volces " + action + " result: " + err.Error(), RequestID: requestID, Retryable: false}
|
||||
}
|
||||
return requestID, nil
|
||||
}
|
||||
|
||||
func volcesAssetAuthorization(accessKey string, secretKey string, method string, path string, canonicalQuery string, headers map[string]string, bodySHA string, xDate string) string {
|
||||
signedHeaders := []string{"content-type", "host", "x-content-sha256", "x-date"}
|
||||
canonicalHeaderLines := make([]string, 0, len(signedHeaders))
|
||||
for _, key := range signedHeaders {
|
||||
canonicalHeaderLines = append(canonicalHeaderLines, key+":"+strings.TrimSpace(headers[key]))
|
||||
}
|
||||
canonicalRequest := strings.Join([]string{
|
||||
strings.ToUpper(method), path, canonicalQuery,
|
||||
strings.Join(canonicalHeaderLines, "\n") + "\n",
|
||||
strings.Join(signedHeaders, ";"), bodySHA,
|
||||
}, "\n")
|
||||
date := xDate
|
||||
if len(date) >= 8 {
|
||||
date = date[:8]
|
||||
}
|
||||
scope := strings.Join([]string{date, volcesAssetRegion, volcesAssetService, "request"}, "/")
|
||||
stringToSign := strings.Join([]string{"HMAC-SHA256", xDate, scope, sha256HexString(canonicalRequest)}, "\n")
|
||||
kDate := hmacSHA256([]byte(secretKey), date)
|
||||
kRegion := hmacSHA256(kDate, volcesAssetRegion)
|
||||
kService := hmacSHA256(kRegion, volcesAssetService)
|
||||
kSigning := hmacSHA256(kService, "request")
|
||||
signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
|
||||
return "HMAC-SHA256 Credential=" + accessKey + "/" + scope + ", SignedHeaders=" + strings.Join(signedHeaders, ";") + ", Signature=" + signature
|
||||
}
|
||||
|
||||
func canonicalVolcesAssetQuery(values map[string]string) string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(values[key]))
|
||||
}
|
||||
return strings.ReplaceAll(strings.Join(parts, "&"), "+", "%20")
|
||||
}
|
||||
|
||||
func sha256HexBytes(value []byte) string {
|
||||
digest := sha256.Sum256(value)
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func sha256HexString(value string) string { return sha256HexBytes([]byte(value)) }
|
||||
|
||||
func hmacSHA256(key []byte, value string) []byte {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
_, _ = mac.Write([]byte(value))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVolcesAssetClientSignsCreateAndReadsAsset(t *testing.T) {
|
||||
var calls []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.URL.Query().Get("Action"))
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/" || r.URL.Query().Get("Version") != "2024-01-01" {
|
||||
t.Fatalf("unexpected asset request %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery)
|
||||
}
|
||||
if r.Header.Get("X-Date") != "20260718T010203Z" {
|
||||
t.Fatalf("unexpected x-date: %q", r.Header.Get("X-Date"))
|
||||
}
|
||||
if !strings.HasPrefix(r.Header.Get("Authorization"), "HMAC-SHA256 Credential=asset-ak/") {
|
||||
t.Fatalf("missing Volces authorization: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
digest := sha256.Sum256(raw)
|
||||
if got := r.Header.Get("X-Content-Sha256"); got != hex.EncodeToString(digest[:]) {
|
||||
t.Fatalf("content hash mismatch got=%q", got)
|
||||
}
|
||||
switch r.URL.Query().Get("Action") {
|
||||
case "CreateAsset":
|
||||
if body["GroupId"] != "group-1" || body["AssetType"] != "Image" {
|
||||
t.Fatalf("unexpected CreateAsset body: %+v", body)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ResponseMetadata": map[string]any{"RequestId": "create-rid"}, "Result": map[string]any{"Id": "asset-1"}})
|
||||
case "GetAsset":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ResponseMetadata": map[string]any{"RequestId": "get-rid"}, "Result": map[string]any{"Id": "asset-1", "Status": "Active", "AssetType": "Image"}})
|
||||
default:
|
||||
t.Fatalf("unexpected Action: %q", r.URL.Query().Get("Action"))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := VolcesAssetClient{HTTPClient: server.Client(), Now: func() time.Time {
|
||||
return time.Date(2026, 7, 18, 1, 2, 3, 0, time.UTC)
|
||||
}}
|
||||
credentials := VolcesAssetCredentials{AccessKey: "asset-ak", SecretKey: "asset-sk", Endpoint: server.URL}
|
||||
created, requestID, err := client.CreateAsset(context.Background(), credentials, map[string]any{"GroupId": "group-1", "URL": "https://example.com/person.png", "AssetType": "Image", "ProjectName": "default"})
|
||||
if err != nil || created.ID != "asset-1" || requestID != "create-rid" {
|
||||
t.Fatalf("unexpected CreateAsset result=%+v requestID=%s err=%v", created, requestID, err)
|
||||
}
|
||||
asset, requestID, err := client.GetAsset(context.Background(), credentials, map[string]any{"Id": "asset-1", "ProjectName": "default"})
|
||||
if err != nil || asset.Status != "Active" || requestID != "get-rid" {
|
||||
t.Fatalf("unexpected GetAsset result=%+v requestID=%s err=%v", asset, requestID, err)
|
||||
}
|
||||
if strings.Join(calls, ",") != "CreateAsset,GetAsset" {
|
||||
t.Fatalf("unexpected actions: %+v", calls)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user