feat(seedance): 接入真人资产管理与引用

This commit is contained in:
2026-07-22 00:25:22 +08:00
parent 5a71643099
commit ddd68cfebd
9 changed files with 1627 additions and 1 deletions
+194
View File
@@ -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)
}
}