58535fda7b
安全: - 渠道 webhook 入站强制令牌鉴权(恒定时间比较+统一文案),企微签名官方算法; - 报表/概览/systemInfo 端点按 usage:read/audit:read/system:manage 授权; - sso_error 固定错误码;个人渠道令牌仅请求头;工具出站 Dialer.Control 消除 DNS rebinding TOCTOU;新增 channel:read/manage 权限;限流倍数上限 10。 并发/一致性: - 任务上报单条条件 UPDATE 防重放双提交;认领回收过期 claimed 任务; - 审批改先开通后落记录(幂等,无嵌套事务);聊天消息单事务落库; - 会话列表校验 AuthVersion;吊销先 Del 后 SRem;删工具保护调用历史; - rejected 冷却 24h;限流被拒补偿;maintenance 清理限流窗口。 前端/菜单: - 修复 gatewayChildren late-append 导致 reports/tenants/channels 菜单不可见; - 聊天改名 PUT 对齐;渠道编辑清空凭据防串写+启用开关; - 聊天响应防串扰;报表本地时区日期。
956 lines
28 KiB
Go
956 lines
28 KiB
Go
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/resource-requests", h.resourceRequests)
|
|
h.mux.HandleFunc("POST /api/v1/portal/resource-requests", h.createResourceRequest)
|
|
h.mux.HandleFunc("DELETE /api/v1/portal/resource-requests/{id}", h.cancelResourceRequest)
|
|
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("PUT /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)
|
|
// 个人渠道:webhook 入站(公开,令牌鉴权) + 个人管理。
|
|
h.mux.HandleFunc("POST /v1/personal-channels/{code}/inbound", h.personalChannelInbound)
|
|
h.mux.HandleFunc("GET /api/v1/portal/personal-channels", h.personalChannels)
|
|
h.mux.HandleFunc("POST /api/v1/portal/personal-channels", h.createPersonalChannel)
|
|
h.mux.HandleFunc("POST /api/v1/portal/personal-channels/{id}/token", h.regeneratePersonalToken)
|
|
h.mux.HandleFunc("DELETE /api/v1/portal/personal-channels/{id}", h.deletePersonalChannel)
|
|
// 我的渠道:部门可见或已授权。
|
|
h.mux.HandleFunc("GET /api/v1/portal/channels", h.myChannels)
|
|
// 数字员工:会话入口 + 调用记录。
|
|
h.mux.HandleFunc("GET /api/v1/portal/digital-employees", h.digitalEmployees)
|
|
h.mux.HandleFunc("POST /api/v1/portal/digital-employees/{code}/chat", h.runDigitalEmployee)
|
|
h.mux.HandleFunc("GET /api/v1/portal/digital-employees/runs", h.myEmployeeRuns)
|
|
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 (h *HTTPHandler) resourceRequests(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := h.account(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := h.service.ResourceRequests(r.Context(), a)
|
|
if err != nil {
|
|
portalError(w, err)
|
|
return
|
|
}
|
|
apiresponse.OK(w, items)
|
|
}
|
|
|
|
func (h *HTTPHandler) createResourceRequest(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := h.account(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input struct {
|
|
ResourceType string `json:"resource_type"`
|
|
ResourceCode string `json:"resource_code"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if !decode(w, r, &input) {
|
|
return
|
|
}
|
|
item, err := h.service.CreateResourceRequest(r.Context(), a, input.ResourceType, input.ResourceCode, input.Reason)
|
|
if err != nil {
|
|
portalError(w, err)
|
|
return
|
|
}
|
|
apiresponse.OK(w, item)
|
|
}
|
|
|
|
func (h *HTTPHandler) cancelResourceRequest(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := h.account(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.service.CancelResourceRequest(r.Context(), a, r.PathValue("id")); err != nil {
|
|
portalError(w, err)
|
|
return
|
|
}
|
|
apiresponse.OK(w, map[string]bool{"cancelled": true})
|
|
}
|
|
|
|
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 <API_KEY>", "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)
|
|
}
|
|
|
|
// --- 个人渠道 ---
|
|
|
|
func (h *HTTPHandler) personalChannels(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := h.account(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := h.service.PersonalChannels(r.Context(), a)
|
|
if err != nil {
|
|
portalError(w, err)
|
|
return
|
|
}
|
|
apiresponse.OK(w, items)
|
|
}
|
|
|
|
func (h *HTTPHandler) createPersonalChannel(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := h.account(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
ProviderCode string `json:"provider_code"`
|
|
Model string `json:"model"`
|
|
}
|
|
if !decode(w, r, &input) {
|
|
return
|
|
}
|
|
item, token, err := h.service.CreatePersonalChannel(r.Context(), a, input.Code, input.Name, input.ProviderCode, input.Model)
|
|
if err != nil {
|
|
portalError(w, err)
|
|
return
|
|
}
|
|
apiresponse.OK(w, map[string]any{"channel": item, "inbound_token": token, "inbound_url": "/v1/personal-channels/" + item.Code + "/inbound"})
|
|
}
|
|
|
|
func (h *HTTPHandler) regeneratePersonalToken(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := h.account(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
token, err := h.service.RegenerateToken(r.Context(), a, r.PathValue("id"))
|
|
if err != nil {
|
|
portalError(w, err)
|
|
return
|
|
}
|
|
apiresponse.OK(w, map[string]string{"inbound_token": token})
|
|
}
|
|
|
|
func (h *HTTPHandler) deletePersonalChannel(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := h.account(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.service.DeletePersonalChannel(r.Context(), a, r.PathValue("id")); err != nil {
|
|
portalError(w, err)
|
|
return
|
|
}
|
|
apiresponse.OK(w, map[string]bool{"deleted": true})
|
|
}
|
|
|
|
// personalChannelInbound 个人渠道入站(公开端点,令牌鉴权,同步返回文本)。
|
|
func (h *HTTPHandler) personalChannelInbound(w http.ResponseWriter, r *http.Request) {
|
|
// 令牌只经请求头传递:query 传参会进访问日志/浏览器历史/Referer。
|
|
token := strings.TrimSpace(r.Header.Get("X-Inbound-Token"))
|
|
var input struct {
|
|
Message string `json:"message"`
|
|
Content string `json:"content"`
|
|
}
|
|
if !decode(w, r, &input) {
|
|
return
|
|
}
|
|
message := strings.TrimSpace(input.Message)
|
|
if message == "" {
|
|
message = strings.TrimSpace(input.Content)
|
|
}
|
|
reply, err := h.service.HandlePersonalInbound(r.Context(), r.PathValue("code"), token, message)
|
|
if err != nil {
|
|
apiresponse.Error(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
apiresponse.OK(w, map[string]string{"reply": reply})
|
|
}
|
|
|
|
// --- 数字员工 ---
|
|
|
|
func (h *HTTPHandler) digitalEmployees(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := h.account(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := h.service.DigitalEmployees(r.Context(), a)
|
|
if err != nil {
|
|
portalError(w, err)
|
|
return
|
|
}
|
|
apiresponse.OK(w, items)
|
|
}
|
|
|
|
func (h *HTTPHandler) runDigitalEmployee(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.RunDigitalEmployee(r.Context(), a, r.PathValue("code"), input.Message)
|
|
if err != nil {
|
|
portalError(w, err)
|
|
return
|
|
}
|
|
writeApplicationResponse(w, response)
|
|
}
|
|
|
|
func (h *HTTPHandler) myEmployeeRuns(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.MyEmployeeRuns(r.Context(), a, limit)
|
|
if err != nil {
|
|
portalError(w, err)
|
|
return
|
|
}
|
|
apiresponse.OK(w, items)
|
|
}
|
|
|
|
// --- 我的渠道:部门可见或已授权 ---
|
|
|
|
func (h *HTTPHandler) myChannels(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := h.account(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if h.service.channels == nil {
|
|
apiresponse.OK(w, map[string]any{"channels": []any{}, "granted_codes": []string{}})
|
|
return
|
|
}
|
|
visible, err := h.service.channels.VisibleChannelsForUser(r.Context(), a.ID, a.DepartmentID)
|
|
if err != nil {
|
|
portalError(w, err)
|
|
return
|
|
}
|
|
granted, err := h.service.channels.GrantsForUser(r.Context(), a.ID)
|
|
if err != nil {
|
|
portalError(w, err)
|
|
return
|
|
}
|
|
grantedCodes := make([]string, 0, len(granted))
|
|
for _, item := range granted {
|
|
grantedCodes = append(grantedCodes, item.Code)
|
|
}
|
|
apiresponse.OK(w, map[string]any{"channels": visible, "granted_codes": grantedCodes})
|
|
}
|