package portal import ( "encoding/json" "errors" "net/http" "strconv" "strings" "time" "aigateway.local/core/internal/identity" "aigateway.local/core/internal/platform/apiresponse" ) type HTTPHandler struct { service *Service identity *identity.Service mux *http.ServeMux } func NewHTTPHandler(service *Service, identityService *identity.Service) *HTTPHandler { h := &HTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()} h.mux.HandleFunc("POST /api/v1/portal/password", h.changePassword) h.mux.HandleFunc("GET /api/v1/portal/login-logs", h.loginLogs) h.mux.HandleFunc("GET /api/v1/portal/applications", h.applications) h.mux.HandleFunc("GET /api/v1/portal/catalog", h.catalog) h.mux.HandleFunc("GET /api/v1/portal/knowledge", h.knowledge) h.mux.HandleFunc("GET /api/v1/portal/tools", h.tools) h.mux.HandleFunc("GET /api/v1/portal/prompts", h.prompts) h.mux.HandleFunc("GET /api/v1/portal/prompts/{id}", h.prompt) h.mux.HandleFunc("POST /api/v1/portal/prompts/{id}/favorite", h.favorite(true)) h.mux.HandleFunc("DELETE /api/v1/portal/prompts/{id}/favorite", h.favorite(false)) h.mux.HandleFunc("GET /api/v1/portal/model-requests/available", h.models) h.mux.HandleFunc("GET /api/v1/portal/model-requests", h.modelRequests) h.mux.HandleFunc("POST /api/v1/portal/model-requests", h.createModelRequest) h.mux.HandleFunc("GET /api/v1/portal/logs", h.logs) h.mux.HandleFunc("GET /api/v1/portal/logs/{id}", h.logDetail) h.mux.HandleFunc("GET /api/v1/portal/stats", h.stats) h.mux.HandleFunc("GET /api/v1/portal/cost", h.cost) h.mux.HandleFunc("GET /api/v1/portal/docs-info", h.docsInfo) h.mux.HandleFunc("POST /api/v1/portal/apps/{code}/chat", h.chat) h.mux.HandleFunc("GET /api/v1/portal/apps/{code}/conversations", h.listConversations) h.mux.HandleFunc("POST /api/v1/portal/apps/{code}/conversations", h.createConversation) h.mux.HandleFunc("PATCH /api/v1/portal/apps/{code}/conversations/{id}", h.renameConversation) h.mux.HandleFunc("DELETE /api/v1/portal/apps/{code}/conversations/{id}", h.deleteConversation) h.mux.HandleFunc("GET /api/v1/portal/apps/{code}/conversations/{id}", h.getConversation) h.mux.HandleFunc("POST /api/v1/portal/apps/{code}/conversations/{id}/messages", h.appendConversationMessage) // 通用聊天:选择已批准模型直接对话(复用用户运行时凭据)。 h.mux.HandleFunc("GET /api/v1/portal/chat/models", h.chatModels) h.mux.HandleFunc("POST /api/v1/portal/chat/completions", h.chatOnce) h.mux.HandleFunc("GET /api/v1/portal/chat/sessions", h.listChatSessions) h.mux.HandleFunc("POST /api/v1/portal/chat/sessions", h.createChatSession) h.mux.HandleFunc("PATCH /api/v1/portal/chat/sessions/{id}", h.renameChatSession) h.mux.HandleFunc("DELETE /api/v1/portal/chat/sessions/{id}", h.deleteChatSession) h.mux.HandleFunc("GET /api/v1/portal/chat/sessions/{id}", h.getChatSession) h.mux.HandleFunc("POST /api/v1/portal/chat/sessions/{id}/messages", h.appendChatMessage) h.mux.HandleFunc("GET /api/v1/portal/marketplace", h.marketplace) h.mux.HandleFunc("GET /api/v1/portal/marketplace/categories", h.marketplaceCategories) h.mux.HandleFunc("GET /api/v1/portal/marketplace/installed", h.marketplaceInstalled) h.mux.HandleFunc("GET /api/v1/portal/marketplace/{type}/{code}", h.marketplaceDetail) h.mux.HandleFunc("POST /api/v1/portal/marketplace/{type}/{code}/install", h.marketplaceInstall) h.mux.HandleFunc("DELETE /api/v1/portal/marketplace/{type}/{code}/install", h.marketplaceUninstall) return h } func (h *HTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } func (h *HTTPHandler) account(w http.ResponseWriter, r *http.Request) (identity.Account, bool) { account, err := h.identity.Authenticate(r.Context(), identity.KindPortal, r.Header.Get("Authorization")) if err != nil { apiresponse.Error(w, http.StatusUnauthorized, "登录状态无效或已过期") return identity.Account{}, false } return account, true } func decode(w http.ResponseWriter, r *http.Request, target any) bool { decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { apiresponse.Error(w, http.StatusBadRequest, "请求格式无效") return false } return true } func portalError(w http.ResponseWriter, err error) { if errors.Is(err, ErrNotFound) { apiresponse.Error(w, http.StatusNotFound, "资源不存在或无权访问") return } apiresponse.Error(w, http.StatusBadRequest, err.Error()) } func (h *HTTPHandler) loginLogs(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } logs, err := h.identity.ListLoginLogs(r.Context(), identity.KindPortal, a.Login, 50) if err != nil { portalError(w, err) return } apiresponse.OK(w, logs) } func (h *HTTPHandler) changePassword(w http.ResponseWriter, r *http.Request) { account, ok := h.account(w, r) if !ok { return } var input struct { OldPassword string `json:"old_password"` NewPassword string `json:"new_password"` } if !decode(w, r, &input) { return } if err := h.identity.ChangePassword(r.Context(), account, input.OldPassword, input.NewPassword); err != nil { if errors.Is(err, identity.ErrInvalidCredentials) { apiresponse.Error(w, http.StatusUnauthorized, "当前口令错误") return } portalError(w, err) return } apiresponse.OK(w, map[string]bool{"changed": true}) } func (h *HTTPHandler) applications(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } items, err := h.service.Applications(r.Context(), a) if err != nil { portalError(w, err) return } apiresponse.OK(w, items) } func (h *HTTPHandler) knowledge(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } items, err := h.service.Knowledge(r.Context(), a) if err != nil { portalError(w, err) return } apiresponse.OK(w, items) } func (h *HTTPHandler) tools(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } items, err := h.service.Tools(r.Context(), a) if err != nil { portalError(w, err) return } apiresponse.OK(w, items) } func (h *HTTPHandler) prompts(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } items, err := h.service.Prompts(r.Context(), a) if err != nil { portalError(w, err) return } query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) if query != "" { filtered := items[:0] for _, item := range items { if strings.Contains(strings.ToLower(item.Name+" "+item.Description+" "+strings.Join(item.Tags, " ")), query) { filtered = append(filtered, item) } } items = filtered } apiresponse.OK(w, items) } func (h *HTTPHandler) prompt(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } item, err := h.service.Prompt(r.Context(), a, r.PathValue("id")) if err != nil { portalError(w, err) return } apiresponse.OK(w, item) } func (h *HTTPHandler) favorite(value bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } if err := h.service.SetFavorite(r.Context(), a, r.PathValue("id"), value); err != nil { portalError(w, err) return } apiresponse.OK(w, map[string]bool{"favorite": value}) } } func (h *HTTPHandler) catalog(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } apps, err := h.service.Applications(r.Context(), a) if err != nil { portalError(w, err) return } knowledge, err := h.service.Knowledge(r.Context(), a) if err != nil { portalError(w, err) return } tools, err := h.service.Tools(r.Context(), a) if err != nil { portalError(w, err) return } prompts, err := h.service.Prompts(r.Context(), a) if err != nil { portalError(w, err) return } apiresponse.OK(w, map[string]any{"applications": apps, "knowledge": knowledge, "tools": tools, "prompts": prompts}) } // --- 资源市场 --- func (h *HTTPHandler) marketplace(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } q := r.URL.Query() items, err := h.service.MarketplaceCatalog(r.Context(), a, q.Get("type"), q.Get("category_id"), q.Get("tag"), q.Get("q")) if err != nil { portalError(w, err) return } apiresponse.OK(w, items) } func (h *HTTPHandler) marketplaceCategories(w http.ResponseWriter, r *http.Request) { if _, ok := h.account(w, r); !ok { return } items, err := h.service.MarketplaceCategories(r.Context()) if err != nil { portalError(w, err) return } apiresponse.OK(w, items) } func (h *HTTPHandler) marketplaceInstalled(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } items, err := h.service.MarketplaceInstalled(r.Context(), a) if err != nil { portalError(w, err) return } apiresponse.OK(w, items) } func (h *HTTPHandler) marketplaceDetail(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } item, raw, err := h.service.MarketplaceDetail(r.Context(), a, r.PathValue("type"), r.PathValue("code")) if err != nil { portalError(w, err) return } apiresponse.OK(w, map[string]any{"item": item, "detail": raw}) } func (h *HTTPHandler) marketplaceInstall(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } level := strings.TrimSpace(r.URL.Query().Get("permission_level")) created, err := h.service.MarketplaceInstall(r.Context(), a, r.PathValue("type"), r.PathValue("code"), level) if err != nil { portalError(w, err) return } apiresponse.OK(w, map[string]any{"installed": true, "created": created}) } func (h *HTTPHandler) marketplaceUninstall(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } if err := h.service.MarketplaceUninstall(r.Context(), a, r.PathValue("type"), r.PathValue("code")); err != nil { portalError(w, err) return } apiresponse.OK(w, map[string]any{"installed": false}) } func (h *HTTPHandler) models(w http.ResponseWriter, r *http.Request) { if _, ok := h.account(w, r); !ok { return } items, err := h.service.Models(r.Context()) if err != nil { portalError(w, err) return } apiresponse.OK(w, items) } func (h *HTTPHandler) modelRequests(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } items, err := h.service.ModelRequests(r.Context(), a.ID, "") if err != nil { portalError(w, err) return } apiresponse.OK(w, items) } func (h *HTTPHandler) createModelRequest(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } var input ModelRequest if !decode(w, r, &input) { return } item, err := h.service.CreateModelRequest(r.Context(), a, input) if err != nil { portalError(w, err) return } apiresponse.OK(w, item) } func limitParam(r *http.Request) int { value, _ := strconv.Atoi(r.URL.Query().Get("limit")) if value < 1 { value = 50 } if value > 200 { value = 200 } return value } func daysParam(r *http.Request) int { value, _ := strconv.Atoi(r.URL.Query().Get("days")) if value != 1 && value != 7 && value != 30 { value = 7 } return value } func (h *HTTPHandler) logs(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } days, limit := daysParam(r), limitParam(r) rows, err := h.service.pool.Query(r.Context(), `SELECT e.id::text,e.request_id,e.provider_code,e.model,e.protocol,e.status_code,e.prompt_tokens,e.completion_tokens,e.cost_microunits,e.latency_ms,e.labels,e.recorded_at FROM gateway.audit_events e JOIN gateway.api_keys k ON k.id=e.api_key_id WHERE k.portal_user_id=$1 AND e.recorded_at >= clock_timestamp()-make_interval(days=>$2) ORDER BY e.recorded_at DESC LIMIT $3`, a.ID, days, limit) if err != nil { portalError(w, err) return } defer rows.Close() items := []map[string]any{} for rows.Next() { var id, requestID, protocol string var provider, model *string var status, latency *int var pt, ct, cost *int64 var labels json.RawMessage var at time.Time if err = rows.Scan(&id, &requestID, &provider, &model, &protocol, &status, &pt, &ct, &cost, &latency, &labels, &at); err != nil { portalError(w, err) return } items = append(items, map[string]any{"id": id, "request_id": requestID, "provider_code": provider, "model": model, "protocol": protocol, "status_code": status, "prompt_tokens": pt, "completion_tokens": ct, "cost_microunits": cost, "latency_ms": latency, "labels": labels, "recorded_at": at}) } apiresponse.OK(w, map[string]any{"items": items}) } func (h *HTTPHandler) logDetail(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } var id, requestID, protocol string var provider, model, requestPreview, responsePreview *string var status, latency *int var pt, ct, cost *int64 var labels json.RawMessage var at time.Time err := h.service.pool.QueryRow(r.Context(), `SELECT e.id::text,e.request_id,e.provider_code,e.model,e.protocol,e.status_code,e.prompt_tokens,e.completion_tokens,e.cost_microunits,e.latency_ms,e.labels,e.request_preview,e.response_preview,e.recorded_at FROM gateway.audit_events e JOIN gateway.api_keys k ON k.id=e.api_key_id WHERE k.portal_user_id=$1 AND e.id=$2 ORDER BY e.recorded_at DESC LIMIT 1`, a.ID, r.PathValue("id")).Scan(&id, &requestID, &provider, &model, &protocol, &status, &pt, &ct, &cost, &latency, &labels, &requestPreview, &responsePreview, &at) if err != nil { if errors.Is(err, ErrNotFound) { portalError(w, err) } else { apiresponse.Error(w, http.StatusNotFound, "日志不存在或无权访问") } return } apiresponse.OK(w, map[string]any{"id": id, "request_id": requestID, "provider_code": provider, "model": model, "protocol": protocol, "status_code": status, "prompt_tokens": pt, "completion_tokens": ct, "cost_microunits": cost, "latency_ms": latency, "labels": labels, "request_preview": requestPreview, "response_preview": responsePreview, "recorded_at": at}) } func (h *HTTPHandler) stats(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } days := daysParam(r) var requests, failed, pt, ct, cost int64 var latency *float64 err := h.service.pool.QueryRow(r.Context(), `SELECT count(*),count(*) FILTER (WHERE coalesce(e.status_code,500)>=400),coalesce(sum(e.prompt_tokens),0),coalesce(sum(e.completion_tokens),0),coalesce(sum(e.cost_microunits),0),avg(e.latency_ms) FROM gateway.audit_events e JOIN gateway.api_keys k ON k.id=e.api_key_id WHERE k.portal_user_id=$1 AND e.recorded_at>=clock_timestamp()-make_interval(days=>$2)`, a.ID, days).Scan(&requests, &failed, &pt, &ct, &cost, &latency) if err != nil { portalError(w, err) return } apiresponse.OK(w, map[string]any{"days": days, "requests": requests, "failed_requests": failed, "prompt_tokens": pt, "completion_tokens": ct, "total_tokens": pt + ct, "cost_microunits": cost, "avg_latency_ms": latency}) } func (h *HTTPHandler) cost(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } var cost int64 err := h.service.pool.QueryRow(r.Context(), `SELECT coalesce(sum(e.cost_microunits),0) FROM gateway.audit_events e JOIN gateway.api_keys k ON k.id=e.api_key_id WHERE k.portal_user_id=$1 AND e.recorded_at>=date_trunc('month',clock_timestamp())`, a.ID).Scan(&cost) if err != nil { portalError(w, err) return } apiresponse.OK(w, map[string]any{"cost_microunits_this_month": cost, "period": time.Now().UTC().Format("2006-01")}) } func (h *HTTPHandler) docsInfo(w http.ResponseWriter, r *http.Request) { if _, ok := h.account(w, r); !ok { return } apiresponse.OK(w, map[string]any{"base_url": "/v1", "protocols": []string{"OpenAI chat/completions", "OpenAI responses", "Anthropic messages", "embeddings"}, "authentication": "Authorization: Bearer ", "note": "密钥只在创建时显示一次,请由管理员审批后通过安全渠道交付。"}) } type chatInput struct { Message string `json:"message"` Messages []map[string]any `json:"messages"` Variables map[string]any `json:"variables"` } func messageFromInput(input chatInput) (string, bool) { if strings.TrimSpace(input.Message) != "" { return input.Message, true } if len(input.Messages) != 1 { return "", false } role, _ := input.Messages[0]["role"].(string) content, _ := input.Messages[0]["content"].(string) return content, role == "user" && strings.TrimSpace(content) != "" } func writeApplicationResponse(w http.ResponseWriter, response map[string]any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(response) } func (h *HTTPHandler) chat(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } var input chatInput if !decode(w, r, &input) { return } message, ok := messageFromInput(input) if !ok { apiresponse.Error(w, 400, "兼容入口只接受一条 user 文本消息") return } response, err := h.service.Chat(r.Context(), a, r.PathValue("code"), message, input.Variables) if err != nil { portalError(w, err) return } writeApplicationResponse(w, response) } func (h *HTTPHandler) listConversations(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } items, err := h.service.ListConversations(r.Context(), a, r.PathValue("code"), 100) if err != nil { portalError(w, err) return } apiresponse.OK(w, items) } func (h *HTTPHandler) renameConversation(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } var input struct { Title string `json:"title"` } decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) decoder.DisallowUnknownFields() if decoder.Decode(&input) != nil { apiresponse.Error(w, http.StatusBadRequest, "请求格式无效") return } item, err := h.service.RenameConversation(r.Context(), a, r.PathValue("code"), r.PathValue("id"), input.Title) if err != nil { portalError(w, err) return } apiresponse.OK(w, item) } func (h *HTTPHandler) deleteConversation(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } if err := h.service.DeleteConversation(r.Context(), a, r.PathValue("code"), r.PathValue("id")); err != nil { portalError(w, err) return } apiresponse.OK(w, map[string]bool{"deleted": true}) } func (h *HTTPHandler) createConversation(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } item, err := h.service.CreateConversation(r.Context(), a, r.PathValue("code")) if err != nil { portalError(w, err) return } apiresponse.OK(w, item) } func (h *HTTPHandler) getConversation(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } item, err := h.service.Conversation(r.Context(), a, r.PathValue("code"), r.PathValue("id")) if err != nil { portalError(w, err) return } apiresponse.OK(w, item) } func (h *HTTPHandler) appendConversationMessage(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } var input chatInput if !decode(w, r, &input) { return } message, ok := messageFromInput(input) if !ok { apiresponse.Error(w, 400, "消息不能为空") return } response, err := h.service.AppendConversationMessage(r.Context(), a, r.PathValue("code"), r.PathValue("id"), message, input.Variables) if err != nil { portalError(w, err) return } writeApplicationResponse(w, response) } // --- 通用聊天 --- type chatCompletionsInput struct { ProviderCode string `json:"provider_code"` Model string `json:"model"` Message string `json:"message"` } func (h *HTTPHandler) chatModels(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } items, err := h.service.ChatModels(r.Context(), a) if err != nil { portalError(w, err) return } apiresponse.OK(w, items) } func (h *HTTPHandler) chatOnce(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } var input chatCompletionsInput if !decode(w, r, &input) { return } response, err := h.service.ChatOnce(r.Context(), a, input.ProviderCode, input.Model, input.Message) if err != nil { portalError(w, err) return } writeApplicationResponse(w, response) } func (h *HTTPHandler) listChatSessions(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) items, err := h.service.ListChatSessions(r.Context(), a, limit) if err != nil { portalError(w, err) return } apiresponse.OK(w, items) } func (h *HTTPHandler) createChatSession(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } var input chatCompletionsInput if !decode(w, r, &input) { return } item, err := h.service.CreateChatSession(r.Context(), a, input.ProviderCode, input.Model) if err != nil { portalError(w, err) return } apiresponse.OK(w, item) } func (h *HTTPHandler) renameChatSession(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } var input struct { Title string `json:"title"` } if !decode(w, r, &input) { return } item, err := h.service.RenameChatSession(r.Context(), a, r.PathValue("id"), input.Title) if err != nil { portalError(w, err) return } apiresponse.OK(w, item) } func (h *HTTPHandler) deleteChatSession(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } if err := h.service.DeleteChatSession(r.Context(), a, r.PathValue("id")); err != nil { portalError(w, err) return } apiresponse.OK(w, map[string]bool{"deleted": true}) } func (h *HTTPHandler) getChatSession(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } item, err := h.service.ChatSession(r.Context(), a, r.PathValue("id")) if err != nil { portalError(w, err) return } apiresponse.OK(w, item) } func (h *HTTPHandler) appendChatMessage(w http.ResponseWriter, r *http.Request) { a, ok := h.account(w, r) if !ok { return } var input struct { Message string `json:"message"` } if !decode(w, r, &input) { return } response, err := h.service.AppendChatMessage(r.Context(), a, r.PathValue("id"), input.Message) if err != nil { portalError(w, err) return } writeApplicationResponse(w, response) }