feat!: 账号池改渠道网关,对外协议换成 vivid /v1

- 新增 channels/channel_models:上游 URL + AES-GCM 加密 key +
  并发/权重/本地统计,模型经渠道 /v1/models 导入绑定
- internal/upstream:vivid 协议客户端(NDJSON 流 + jobId 断流重连 +
  uploads 预签名 + models + balance + 错误分类)
- service/v1 重写:权重分组调度、Redis 并发槽 conc:c:、故障转移,
  站内/测试/对外统一走渠道执行器
- 对外端点换成 vivid 四件套:POST /v1/generate(NDJSON+jobId 重连)、
  POST /v1/uploads(SigV4 预签名 PUT)、GET /v1/models、GET /v1/balance,
  错误包 {"error":{"code","message"}}
- API key 新发改 vk_+48hex;删除全部内置 provider 与 token 池
- 管理端:渠道 CRUD/拉模型/导入/余额端点,前端账号页换渠道页,
  概览/文档/测试弹窗适配

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-16 21:14:18 +08:00
co-authored by Devin
parent ecdbc0b281
commit b4efe6b192
75 changed files with 3598 additions and 23848 deletions
+83
View File
@@ -0,0 +1,83 @@
package storage
import (
"encoding/hex"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// PresignGet issues a query-string SigV4 URL that lets anyone holding it GET
// the object for ttl. Used for /v1/generate result URLs and reference
// read-back.
func (c *Client) PresignGet(key string, ttl time.Duration) (string, error) {
return c.presign(http.MethodGet, key, "", ttl)
}
// PresignPut issues a query-string SigV4 URL bound to PUT with the exact
// contentType signed in — the uploader must send that Content-Type verbatim
// (same contract vivid upstreams expect on /v1/uploads sessions).
func (c *Client) PresignPut(key, contentType string, ttl time.Duration) (string, error) {
if strings.TrimSpace(contentType) == "" {
return "", fmt.Errorf("presign put requires contentType")
}
return c.presign(http.MethodPut, key, contentType, ttl)
}
// presign builds an AWS SigV4 presigned URL (UNSIGNED-PAYLOAD). Signed headers
// are host (+content-type for PUT). Validity is capped at 7 days by SigV4.
func (c *Client) presign(method, key, contentType string, ttl time.Duration) (string, error) {
if !c.Configured() {
return "", fmt.Errorf("rustfs not configured")
}
if ttl <= 0 {
ttl = 30 * time.Minute
}
if ttl > 7*24*time.Hour {
ttl = 7 * 24 * time.Hour
}
now := time.Now().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
scope := dateStamp + "/" + region + "/" + service + "/aws4_request"
signedHeaders := "host"
if contentType != "" {
signedHeaders = "content-type;host"
}
q := map[string]string{
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
"X-Amz-Credential": c.ak + "/" + scope,
"X-Amz-Date": amzDate,
"X-Amz-Expires": strconv.FormatInt(int64(ttl.Seconds()), 10),
"X-Amz-SignedHeaders": signedHeaders,
}
canonicalURI := "/" + uriEncode(c.bucket+"/"+strings.TrimPrefix(key, "/"), true)
canonicalQuery := canonicalQueryString(q)
var canonHeaders strings.Builder
if contentType != "" {
canonHeaders.WriteString("content-type:" + contentType + "\n")
}
canonHeaders.WriteString("host:" + c.host + "\n")
canonicalRequest := strings.Join([]string{
method, canonicalURI, canonicalQuery, canonHeaders.String(), signedHeaders, "UNSIGNED-PAYLOAD",
}, "\n")
stringToSign := strings.Join([]string{
"AWS4-HMAC-SHA256", amzDate, scope, hexSHA256([]byte(canonicalRequest)),
}, "\n")
sig := hex.EncodeToString(hmacSHA256(signingKey(c.sk, dateStamp), []byte(stringToSign)))
// Rebuild the query with url.Values so the signature chars get escaped
// correctly, then append X-Amz-Signature verbatim (hex, safe).
vals := url.Values{}
for k, v := range q {
vals.Set(k, v)
}
return c.endpoint + canonicalURI + "?" + vals.Encode() + "&X-Amz-Signature=" + sig, nil
}