feat(admin): 添加网络代理配置和钱包交易功能

- 在管理面板中集成网络代理配置显示和平台代理设置
- 添加钱包摘要和交易列表API接口及数据管理
- 实现SSE流式响应中的错误处理机制
- 添加全局HTTP代理环境变量配置支持
- 更新平台表单以支持代理模式选择和自定义代理地址
- 集成钱包交易查询过滤和分页功能
- 优化API错误详情解析和显示格式
This commit is contained in:
2026-05-11 23:02:10 +08:00
parent c992f1de60
commit f550c0acd5
30 changed files with 1455 additions and 76 deletions
@@ -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
}
+28 -2
View File
@@ -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)
+12 -7
View File
@@ -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) {
+6
View File
@@ -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,
})
}