feat(admin): 添加网络代理配置和钱包交易功能
- 在管理面板中集成网络代理配置显示和平台代理设置 - 添加钱包摘要和交易列表API接口及数据管理 - 实现SSE流式响应中的错误处理机制 - 添加全局HTTP代理环境变量配置支持 - 更新平台表单以支持代理模式选择和自定义代理地址 - 集成钱包交易查询过滤和分页功能 - 优化API错误详情解析和显示格式
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Server) getNetworkProxyConfig(w http.ResponseWriter, r *http.Request) {
|
||||
globalHTTPProxy := strings.TrimSpace(s.cfg.GlobalHTTPProxy)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"globalHttpProxy": globalHTTPProxy,
|
||||
"globalHttpProxySet": globalHTTPProxy != "",
|
||||
"globalHttpProxySource": strings.TrimSpace(s.cfg.GlobalHTTPProxySource),
|
||||
})
|
||||
}
|
||||
@@ -233,6 +233,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
PlatformKey string `json:"platformKey"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
|
||||
@@ -593,6 +594,51 @@ WHERE reference_type = 'gateway_task'
|
||||
if !floatNear(walletTransactionAmount, pricingTask.Task.FinalChargeAmount) {
|
||||
t.Fatalf("task billing transaction amount=%f want=%f", walletTransactionAmount, pricingTask.Task.FinalChargeAmount)
|
||||
}
|
||||
var walletSummary struct {
|
||||
Accounts []struct {
|
||||
Currency string `json:"currency"`
|
||||
Balance float64 `json:"balance"`
|
||||
TotalSpent float64 `json:"totalSpent"`
|
||||
} `json:"accounts"`
|
||||
PrimaryAccount struct {
|
||||
Currency string `json:"currency"`
|
||||
Balance float64 `json:"balance"`
|
||||
} `json:"primaryAccount"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/workspace/wallet", loginResponse.AccessToken, nil, http.StatusOK, &walletSummary)
|
||||
if walletSummary.PrimaryAccount.Currency != "resource" || !floatNear(walletSummary.PrimaryAccount.Balance, walletBalanceAfter) || len(walletSummary.Accounts) == 0 {
|
||||
t.Fatalf("workspace wallet should expose current resource balance, got %+v want balance=%f", walletSummary, walletBalanceAfter)
|
||||
}
|
||||
var walletTransactions struct {
|
||||
Items []struct {
|
||||
TransactionType string `json:"transactionType"`
|
||||
Direction string `json:"direction"`
|
||||
ReferenceID string `json:"referenceId"`
|
||||
Currency string `json:"currency"`
|
||||
Amount float64 `json:"amount"`
|
||||
} `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/workspace/wallet/transactions?direction=debit&pageSize=20", loginResponse.AccessToken, nil, http.StatusOK, &walletTransactions)
|
||||
if walletTransactions.Total == 0 || !walletTransactionListContains(walletTransactions.Items, pricingTask.Task.ID) {
|
||||
t.Fatalf("workspace wallet transactions should include task billing debit, got %+v", walletTransactions)
|
||||
}
|
||||
var filteredWalletTransactions struct {
|
||||
Items []struct {
|
||||
TransactionType string `json:"transactionType"`
|
||||
Direction string `json:"direction"`
|
||||
ReferenceID string `json:"referenceId"`
|
||||
Currency string `json:"currency"`
|
||||
Amount float64 `json:"amount"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
} `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
createdFrom := time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/workspace/wallet/transactions?direction=debit&pageSize=1&q="+pricingModel+"&createdFrom="+createdFrom, loginResponse.AccessToken, nil, http.StatusOK, &filteredWalletTransactions)
|
||||
if filteredWalletTransactions.Total == 0 || !walletTransactionListContainsWithMetadata(filteredWalletTransactions.Items, pricingTask.Task.ID, pricingModel, platform.Name, apiKeyResponse.APIKey.Name, pricingTask.Task.FinalChargeAmount) {
|
||||
t.Fatalf("workspace wallet transaction filters should match model and expose task metadata, got %+v", filteredWalletTransactions)
|
||||
}
|
||||
|
||||
rateLimitedModel := "rate-limit-smoke-" + suffixText
|
||||
var rateLimitPolicySet struct {
|
||||
@@ -941,6 +987,21 @@ WHERE reference_type = 'gateway_task'
|
||||
if !taskListContains(taskList.Items, taskResponse.Task.ID) || !taskListContains(taskList.Items, pricingTask.Task.ID) {
|
||||
t.Fatalf("task list should include persisted task records, got %+v", taskList.Items)
|
||||
}
|
||||
var workspaceTaskList struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
APIKeyName string `json:"apiKeyName"`
|
||||
ModelType string `json:"modelType"`
|
||||
FinalCharge float64 `json:"finalChargeAmount"`
|
||||
ErrorCode string `json:"errorCode"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
} `json:"items"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/workspace/tasks?limit=20", loginResponse.AccessToken, nil, http.StatusOK, &workspaceTaskList)
|
||||
if !taskListContains(workspaceTaskList.Items, taskResponse.Task.ID) || !taskListContains(workspaceTaskList.Items, pricingTask.Task.ID) {
|
||||
t.Fatalf("workspace task list should include persisted task records, got %+v", workspaceTaskList.Items)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/tasks/"+taskResponse.Task.ID+"/events", nil)
|
||||
if err != nil {
|
||||
@@ -1098,6 +1159,50 @@ func taskListContains(values []struct {
|
||||
return false
|
||||
}
|
||||
|
||||
func walletTransactionListContains(values []struct {
|
||||
TransactionType string `json:"transactionType"`
|
||||
Direction string `json:"direction"`
|
||||
ReferenceID string `json:"referenceId"`
|
||||
Currency string `json:"currency"`
|
||||
Amount float64 `json:"amount"`
|
||||
}, target string) bool {
|
||||
for _, value := range values {
|
||||
if value.ReferenceID == target && value.TransactionType == "task_billing" && value.Direction == "debit" && value.Currency == "resource" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func walletTransactionListContainsWithMetadata(values []struct {
|
||||
TransactionType string `json:"transactionType"`
|
||||
Direction string `json:"direction"`
|
||||
ReferenceID string `json:"referenceId"`
|
||||
Currency string `json:"currency"`
|
||||
Amount float64 `json:"amount"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}, target string, model string, platformName string, apiKeyName string, finalChargeAmount float64) bool {
|
||||
for _, value := range values {
|
||||
usage := objectValue(value.Metadata["usage"])
|
||||
billingSummary := objectValue(value.Metadata["billingSummary"])
|
||||
finalCharge, hasFinalCharge := numberValue(value.Metadata["finalChargeAmount"])
|
||||
billingTotal, hasBillingTotal := numberValue(billingSummary["totalAmount"])
|
||||
if value.ReferenceID == target &&
|
||||
value.Metadata["model"] == model &&
|
||||
value.Metadata["modelType"] == "text_generate" &&
|
||||
value.Metadata["platformName"] == platformName &&
|
||||
value.Metadata["apiKeyName"] == apiKeyName &&
|
||||
usage["totalTokens"] != nil &&
|
||||
hasFinalCharge &&
|
||||
hasBillingTotal &&
|
||||
floatNear(finalCharge, finalChargeAmount) &&
|
||||
floatNear(billingTotal, finalChargeAmount) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func floatNear(value float64, expected float64) bool {
|
||||
return math.Abs(value-expected) < 0.000001
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/netproxy"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
@@ -186,6 +188,12 @@ func (s *Server) createPlatform(w http.ResponseWriter, r *http.Request) {
|
||||
if input.AuthType == "" {
|
||||
input.AuthType = "bearer"
|
||||
}
|
||||
config, err := netproxy.NormalizePlatformConfig(input.Config)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
input.Config = config
|
||||
platform, err := s.store.CreatePlatform(r.Context(), input)
|
||||
if err != nil {
|
||||
s.logger.Error("create platform failed", "error", err)
|
||||
@@ -211,6 +219,12 @@ func (s *Server) updatePlatform(w http.ResponseWriter, r *http.Request) {
|
||||
if input.AuthType == "" {
|
||||
input.AuthType = "bearer"
|
||||
}
|
||||
config, err := netproxy.NormalizePlatformConfig(input.Config)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
input.Config = config
|
||||
platform, err := s.store.UpdatePlatform(r.Context(), r.PathValue("platformID"), input)
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
@@ -541,7 +555,19 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
return nil
|
||||
})
|
||||
if runErr != nil {
|
||||
sendSSE(w, "error", map[string]any{"error": map[string]any{"message": runErr.Error(), "status": statusFromRunError(runErr)}})
|
||||
status := statusFromRunError(runErr)
|
||||
errorPayload := map[string]any{
|
||||
"code": clients.ErrorCode(runErr),
|
||||
"message": runErr.Error(),
|
||||
"status": status,
|
||||
}
|
||||
if result.Task.ID != "" {
|
||||
errorPayload["taskId"] = result.Task.ID
|
||||
}
|
||||
if result.Task.RequestID != "" {
|
||||
errorPayload["requestId"] = result.Task.RequestID
|
||||
}
|
||||
sendSSE(w, "error", map[string]any{"error": errorPayload})
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
@@ -555,7 +581,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
}
|
||||
result, runErr := s.runner.Execute(r.Context(), task, user)
|
||||
if runErr != nil {
|
||||
writeError(w, statusFromRunError(runErr), runErr.Error())
|
||||
writeError(w, statusFromRunError(runErr), runErr.Error(), clients.ErrorCode(runErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result.Output)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
@@ -12,13 +13,17 @@ func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]any{
|
||||
"error": map[string]any{
|
||||
"message": message,
|
||||
"status": status,
|
||||
},
|
||||
})
|
||||
func writeError(w http.ResponseWriter, status int, message string, codes ...string) {
|
||||
errorPayload := map[string]any{
|
||||
"message": message,
|
||||
"status": status,
|
||||
}
|
||||
if len(codes) > 0 {
|
||||
if code := strings.TrimSpace(codes[0]); code != "" {
|
||||
errorPayload["code"] = code
|
||||
}
|
||||
}
|
||||
writeJSON(w, status, map[string]any{"error": errorPayload})
|
||||
}
|
||||
|
||||
func sendSSE(w http.ResponseWriter, event string, payload any) {
|
||||
|
||||
@@ -75,6 +75,11 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han
|
||||
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/disable", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.disableAPIKey)))
|
||||
mux.Handle("DELETE /api/v1/api-keys/{apiKeyID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.deleteAPIKey)))
|
||||
mux.Handle("GET /api/playground/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableAPIKeys)))
|
||||
mux.Handle("GET /api/workspace/wallet", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getWallet)))
|
||||
mux.Handle("GET /api/workspace/wallet/transactions", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listWalletTransactions)))
|
||||
mux.Handle("GET /api/workspace/tasks", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
|
||||
mux.Handle("GET /api/workspace/tasks/{taskID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
|
||||
mux.Handle("GET /api/workspace/tasks/{taskID}/events", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
|
||||
mux.Handle("GET /api/admin/pricing/rules", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPricingRules)))
|
||||
mux.Handle("GET /api/admin/pricing/rule-sets", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPricingRuleSets)))
|
||||
mux.Handle("POST /api/admin/pricing/rule-sets", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPricingRuleSet)))
|
||||
@@ -85,6 +90,7 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han
|
||||
mux.Handle("POST /api/admin/runtime/policy-sets", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createRuntimePolicySet)))
|
||||
mux.Handle("PATCH /api/admin/runtime/policy-sets/{policySetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateRuntimePolicySet)))
|
||||
mux.Handle("DELETE /api/admin/runtime/policy-sets/{policySetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteRuntimePolicySet)))
|
||||
mux.Handle("GET /api/admin/config/network-proxy", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getNetworkProxyConfig)))
|
||||
mux.Handle("GET /api/admin/platforms", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPlatforms)))
|
||||
mux.Handle("POST /api/admin/platforms", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPlatform)))
|
||||
mux.Handle("PATCH /api/admin/platforms/{platformID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updatePlatform)))
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) getWallet(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
summary, err := s.store.GetWalletSummary(r.Context(), user, r.URL.Query().Get("currency"))
|
||||
if err != nil {
|
||||
s.logger.Error("get wallet failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "get wallet failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
func (s *Server) listWalletTransactions(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
page, err := positiveQueryInt(query.Get("page"), 1)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid page")
|
||||
return
|
||||
}
|
||||
pageSizeRaw := query.Get("pageSize")
|
||||
if pageSizeRaw == "" {
|
||||
pageSizeRaw = query.Get("limit")
|
||||
}
|
||||
pageSize, err := positiveQueryInt(pageSizeRaw, 50)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid pageSize")
|
||||
return
|
||||
}
|
||||
createdFrom, err := parseTaskListTime(query.Get("createdFrom"), query.Get("from"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid createdFrom")
|
||||
return
|
||||
}
|
||||
createdTo, err := parseTaskListTime(query.Get("createdTo"), query.Get("to"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid createdTo")
|
||||
return
|
||||
}
|
||||
result, err := s.store.ListWalletTransactions(r.Context(), user, store.WalletTransactionListFilter{
|
||||
Query: firstNonEmpty(query.Get("q"), query.Get("query")),
|
||||
Direction: query.Get("direction"),
|
||||
TransactionType: query.Get("transactionType"),
|
||||
CreatedFrom: createdFrom,
|
||||
CreatedTo: createdTo,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("list wallet transactions failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list wallet transactions failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": result.Items,
|
||||
"total": result.Total,
|
||||
"page": result.Page,
|
||||
"pageSize": result.PageSize,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user