feat(server): API relay gateway backend M0-M4
Gin + GORM + pure-Go SQLite. Users/auth (JWT), API key management with quotas, proxy gateway with weighted channel failover and health checks, usage/billing ledger, cross-protocol conversion (Anthropic Messages / OpenAI Chat Completions / OpenAI Responses), and channel/model admin API. Channels declare native API formats and auto-convert the rest. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b0c7439c01
commit
d0e31b198f
@@ -0,0 +1,329 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"openteam/server/internal/channel"
|
||||
"openteam/server/internal/pkg/httpx"
|
||||
"openteam/server/internal/store"
|
||||
)
|
||||
|
||||
type channelHandler struct {
|
||||
db *gorm.DB
|
||||
ch *channel.Service
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
type channelInput struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Provider string `json:"provider" binding:"omitempty,oneof=openai anthropic compatible"`
|
||||
BaseURL string `json:"baseUrl" binding:"required"`
|
||||
APIKey string `json:"apiKey"`
|
||||
Weight int `json:"weight"`
|
||||
Priority int `json:"priority"`
|
||||
TimeoutMs int `json:"timeoutMs"`
|
||||
MaxConcurrency int `json:"maxConcurrency"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Formats []string `json:"formats"`
|
||||
}
|
||||
|
||||
// ListChannels handles GET /api/v1/admin/channels.
|
||||
func (h *channelHandler) List(c *gin.Context) {
|
||||
var channels []store.Channel
|
||||
if err := h.db.Order("id ASC").Find(&channels).Error; err != nil {
|
||||
h.log.Warn("list channels failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "list channels failed")
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(channels))
|
||||
for i := range channels {
|
||||
out = append(out, channelDTO(&channels[i]))
|
||||
}
|
||||
httpx.OK(c, out)
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/admin/channels.
|
||||
func (h *channelHandler) Create(c *gin.Context) {
|
||||
var in channelInput
|
||||
if !httpx.Bind(c, &in) {
|
||||
return
|
||||
}
|
||||
provider := in.Provider
|
||||
if len(in.Formats) > 0 {
|
||||
if err := validateFormats(in.Formats); err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
provider = deriveProvider(in.Formats)
|
||||
}
|
||||
if provider == "" {
|
||||
provider = "openai"
|
||||
}
|
||||
enc, err := h.ch.EncryptKey(in.APIKey)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusInternalServerError, "encrypt key failed")
|
||||
return
|
||||
}
|
||||
ch := &store.Channel{
|
||||
Name: in.Name, Provider: provider, BaseURL: in.BaseURL, Formats: in.Formats,
|
||||
APIKeyEnc: enc, Weight: defaultIf(in.Weight, 1), Priority: in.Priority,
|
||||
TimeoutMs: defaultIf(in.TimeoutMs, 300000), MaxConcurrency: defaultIf(in.MaxConcurrency, 100),
|
||||
Enabled: true,
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
ch.Enabled = *in.Enabled
|
||||
}
|
||||
if err := h.db.Create(ch).Error; err != nil {
|
||||
h.log.Warn("create channel failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "create channel failed")
|
||||
return
|
||||
}
|
||||
httpx.Created(c, channelDTO(ch))
|
||||
}
|
||||
|
||||
// Update handles PUT /api/v1/admin/channels/:id.
|
||||
func (h *channelHandler) Update(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
var ch store.Channel
|
||||
if err := h.db.First(&ch, id).Error; err != nil {
|
||||
httpx.Fail(c, http.StatusNotFound, "channel not found")
|
||||
return
|
||||
}
|
||||
var in channelInput
|
||||
if !httpx.Bind(c, &in) {
|
||||
return
|
||||
}
|
||||
updates := map[string]any{
|
||||
"name": in.Name, "base_url": in.BaseURL,
|
||||
"weight": defaultIf(in.Weight, ch.Weight), "priority": in.Priority,
|
||||
"timeout_ms": defaultIf(in.TimeoutMs, ch.TimeoutMs),
|
||||
"max_concurrency": defaultIf(in.MaxConcurrency, ch.MaxConcurrency),
|
||||
}
|
||||
if len(in.Formats) > 0 {
|
||||
if err := validateFormats(in.Formats); err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
updates["provider"] = deriveProvider(in.Formats)
|
||||
// GORM map updates skip the json serializer, so encode explicitly;
|
||||
// a raw []string would be emitted as a SQL row-value list.
|
||||
formatsJSON, err := json.Marshal(in.Formats)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusInternalServerError, "serialize formats failed")
|
||||
return
|
||||
}
|
||||
updates["formats"] = string(formatsJSON)
|
||||
} else if in.Provider != "" {
|
||||
updates["provider"] = in.Provider
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
updates["enabled"] = *in.Enabled
|
||||
}
|
||||
if in.APIKey != "" {
|
||||
enc, err := h.ch.EncryptKey(in.APIKey)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusInternalServerError, "encrypt key failed")
|
||||
return
|
||||
}
|
||||
updates["api_key_enc"] = enc
|
||||
}
|
||||
if err := h.db.Model(&ch).Updates(updates).Error; err != nil {
|
||||
h.log.Warn("update channel failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "update channel failed")
|
||||
return
|
||||
}
|
||||
h.db.First(&ch, id)
|
||||
httpx.OK(c, channelDTO(&ch))
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/admin/channels/:id.
|
||||
func (h *channelHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
if err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("channel_id = ?", id).Delete(&store.ChannelModelBinding{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&store.Channel{}, id).Error
|
||||
}); err != nil {
|
||||
h.log.Warn("delete channel failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "delete channel failed")
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Test handles POST /api/v1/admin/channels/:id/test.
|
||||
func (h *channelHandler) Test(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
var ch store.Channel
|
||||
if err := h.db.First(&ch, id).Error; err != nil {
|
||||
httpx.Fail(c, http.StatusNotFound, "channel not found")
|
||||
return
|
||||
}
|
||||
ok, latency, model, err := h.ch.Test(&ch)
|
||||
if err != nil || !ok {
|
||||
msg := "test failed"
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
httpx.Fail(c, http.StatusBadGateway, msg)
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"ok": true, "latencyMs": latency, "model": model})
|
||||
}
|
||||
|
||||
// ImportModels handles POST /api/v1/admin/channels/:id/import-models.
|
||||
func (h *channelHandler) ImportModels(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
var ch store.Channel
|
||||
if err := h.db.First(&ch, id).Error; err != nil {
|
||||
httpx.Fail(c, http.StatusNotFound, "channel not found")
|
||||
return
|
||||
}
|
||||
names, err := h.ch.ImportModels(&ch)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadGateway, "import failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"imported": names, "count": len(names)})
|
||||
}
|
||||
|
||||
// ListBindings handles GET /api/v1/admin/channels/:id/bindings.
|
||||
func (h *channelHandler) ListBindings(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
var bindings []store.ChannelModelBinding
|
||||
if err := h.db.Preload("Model").Where("channel_id = ?", id).Find(&bindings).Error; err != nil {
|
||||
h.log.Warn("list bindings failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "list bindings failed")
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(bindings))
|
||||
for _, b := range bindings {
|
||||
out = append(out, gin.H{
|
||||
"id": b.ID, "modelId": b.ModelID, "modelName": b.Model.Name,
|
||||
"upstreamModel": b.UpstreamModel, "weight": b.Weight,
|
||||
})
|
||||
}
|
||||
httpx.OK(c, out)
|
||||
}
|
||||
|
||||
// SaveBindings handles PUT /api/v1/admin/channels/:id/bindings.
|
||||
func (h *channelHandler) SaveBindings(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Items []struct {
|
||||
ModelID int64 `json:"modelId"`
|
||||
UpstreamModel string `json:"upstreamModel"`
|
||||
Weight int `json:"weight"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if !httpx.Bind(c, &in) {
|
||||
return
|
||||
}
|
||||
if err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("channel_id = ?", id).Delete(&store.ChannelModelBinding{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range in.Items {
|
||||
if item.ModelID == 0 {
|
||||
continue
|
||||
}
|
||||
b := store.ChannelModelBinding{
|
||||
ChannelID: id, ModelID: item.ModelID,
|
||||
UpstreamModel: item.UpstreamModel, Weight: defaultIf(item.Weight, 1),
|
||||
}
|
||||
if err := tx.Create(&b).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
h.log.Warn("save bindings failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "save bindings failed")
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func channelDTO(ch *store.Channel) gin.H {
|
||||
return gin.H{
|
||||
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "baseUrl": ch.BaseURL,
|
||||
"formats": ch.FormatsResolved(),
|
||||
"weight": ch.Weight, "priority": ch.Priority, "timeoutMs": ch.TimeoutMs,
|
||||
"maxConcurrency": ch.MaxConcurrency, "healthStatus": ch.HealthStatus,
|
||||
"healthFailures": ch.HealthFailures, "enabled": ch.Enabled,
|
||||
"createdAt": ch.CreatedAt, "updatedAt": ch.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
var validFormats = map[string]bool{
|
||||
store.FormatOpenAIChat: true,
|
||||
store.FormatOpenAIResponses: true,
|
||||
store.FormatAnthropic: true,
|
||||
}
|
||||
|
||||
func validateFormats(fs []string) error {
|
||||
for _, f := range fs {
|
||||
if !validFormats[f] {
|
||||
return fmt.Errorf("invalid api format: %s", f)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deriveProvider computes the conversion-target provider from the declared
|
||||
// formats. Anthropic-only channels convert to Claude; anything else targets the
|
||||
// OpenAI chat-completions family.
|
||||
func deriveProvider(formats []string) string {
|
||||
hasAnthropic, hasOpenAI := false, false
|
||||
for _, f := range formats {
|
||||
switch f {
|
||||
case store.FormatAnthropic:
|
||||
hasAnthropic = true
|
||||
case store.FormatOpenAIChat, store.FormatOpenAIResponses:
|
||||
hasOpenAI = true
|
||||
}
|
||||
}
|
||||
if hasAnthropic && !hasOpenAI {
|
||||
return "anthropic"
|
||||
}
|
||||
return "openai"
|
||||
}
|
||||
|
||||
func defaultIf(v, def int) int {
|
||||
if v == 0 {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"openteam/server/internal/pkg/httpx"
|
||||
"openteam/server/internal/store"
|
||||
)
|
||||
|
||||
type configHandler struct {
|
||||
db *gorm.DB
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/admin/config — returns the full config map.
|
||||
func (h *configHandler) Get(c *gin.Context) {
|
||||
var confs []store.SystemConfig
|
||||
if err := h.db.Find(&confs).Error; err != nil {
|
||||
h.log.Warn("load config failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "load config failed")
|
||||
return
|
||||
}
|
||||
out := map[string]any{}
|
||||
for _, conf := range confs {
|
||||
var v any
|
||||
if err := json.Unmarshal(conf.Value, &v); err == nil {
|
||||
out[conf.Key] = v
|
||||
}
|
||||
}
|
||||
httpx.OK(c, out)
|
||||
}
|
||||
|
||||
// Put handles PUT /api/v1/admin/config — upserts key/value pairs.
|
||||
func (h *configHandler) Put(c *gin.Context) {
|
||||
var in map[string]any
|
||||
if !httpx.Bind(c, &in) {
|
||||
return
|
||||
}
|
||||
for k, v := range in {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
conf := store.SystemConfig{Key: k, Value: raw}
|
||||
if err := h.db.Save(&conf).Error; err != nil {
|
||||
h.log.Warn("save config failed", zap.Error(err), zap.String("key", k))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "save config failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
httpx.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"openteam/server/internal/billing"
|
||||
"openteam/server/internal/channel"
|
||||
"openteam/server/internal/user"
|
||||
)
|
||||
|
||||
// Register wires the admin routes onto a gin group.
|
||||
func Register(g *gin.RouterGroup, db *gorm.DB, ch *channel.Service, bill *billing.Service, log *zap.Logger) {
|
||||
// Handler instances.
|
||||
chHandler := &channelHandler{db: db, ch: ch, log: log}
|
||||
modelHandler := &modelHandler{db: db, log: log}
|
||||
userHandler := &userHandler{db: db, bill: bill, log: log}
|
||||
usageHandler := &usageHandler{db: db, bill: bill, log: log}
|
||||
configHandler := &configHandler{db: db, log: log}
|
||||
|
||||
g = g.Group("", user.RequireAdmin())
|
||||
|
||||
// Channels.
|
||||
g.GET("/channels", chHandler.List)
|
||||
g.POST("/channels", chHandler.Create)
|
||||
g.PUT("/channels/:id", chHandler.Update)
|
||||
g.DELETE("/channels/:id", chHandler.Delete)
|
||||
g.POST("/channels/:id/test", chHandler.Test)
|
||||
g.POST("/channels/:id/import-models", chHandler.ImportModels)
|
||||
g.GET("/channels/:id/bindings", chHandler.ListBindings)
|
||||
g.PUT("/channels/:id/bindings", chHandler.SaveBindings)
|
||||
|
||||
// Models.
|
||||
g.GET("/models", modelHandler.List)
|
||||
g.POST("/models", modelHandler.Create)
|
||||
g.PUT("/models/:id", modelHandler.Update)
|
||||
g.PUT("/models/:id/price", modelHandler.UpdatePrice)
|
||||
|
||||
// Users.
|
||||
g.GET("/users", userHandler.List)
|
||||
g.PATCH("/users/:id", userHandler.Update)
|
||||
g.POST("/users/:id/balance", userHandler.AdjustBalance)
|
||||
|
||||
// Recharges (reserved; approve/reject wire the paused state machine).
|
||||
g.GET("/recharges", usageHandler.Recharges)
|
||||
g.POST("/recharges/:id/approve", usageHandler.ApproveRecharge)
|
||||
g.POST("/recharges/:id/reject", usageHandler.RejectRecharge)
|
||||
|
||||
// Usage & stats.
|
||||
g.GET("/usage", usageHandler.Usage)
|
||||
g.GET("/stats/overview", usageHandler.Overview)
|
||||
|
||||
// System config.
|
||||
g.GET("/config", configHandler.Get)
|
||||
g.PUT("/config", configHandler.Put)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"openteam/server/internal/pkg/httpx"
|
||||
"openteam/server/internal/store"
|
||||
)
|
||||
|
||||
type modelHandler struct {
|
||||
db *gorm.DB
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
type modelInput struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
DisplayName string `json:"displayName"`
|
||||
InputPrice string `json:"inputPrice"`
|
||||
OutputPrice string `json:"outputPrice"`
|
||||
CacheReadPrice string `json:"cacheReadPrice"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Sort *int `json:"sort"`
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/admin/models.
|
||||
func (h *modelHandler) List(c *gin.Context) {
|
||||
var models []store.Model
|
||||
if err := h.db.Order("sort ASC, id ASC").Find(&models).Error; err != nil {
|
||||
h.log.Warn("list models failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "list models failed")
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(models))
|
||||
for i := range models {
|
||||
out = append(out, modelDTO(&models[i]))
|
||||
}
|
||||
httpx.OK(c, out)
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/admin/models.
|
||||
func (h *modelHandler) Create(c *gin.Context) {
|
||||
var in modelInput
|
||||
if !httpx.Bind(c, &in) {
|
||||
return
|
||||
}
|
||||
m := &store.Model{
|
||||
Name: in.Name,
|
||||
DisplayName: in.DisplayName,
|
||||
InputPrice: parsePrice(in.InputPrice),
|
||||
OutputPrice: parsePrice(in.OutputPrice),
|
||||
CacheReadPrice: parsePrice(in.CacheReadPrice),
|
||||
Enabled: true,
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
m.Enabled = *in.Enabled
|
||||
}
|
||||
if in.Sort != nil {
|
||||
m.Sort = *in.Sort
|
||||
}
|
||||
if err := h.db.Create(m).Error; err != nil {
|
||||
h.log.Warn("create model failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "create model failed")
|
||||
return
|
||||
}
|
||||
httpx.Created(c, modelDTO(m))
|
||||
}
|
||||
|
||||
// Update handles PUT /api/v1/admin/models/:id.
|
||||
func (h *modelHandler) Update(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid model id")
|
||||
return
|
||||
}
|
||||
var m store.Model
|
||||
if err := h.db.First(&m, id).Error; err != nil {
|
||||
httpx.Fail(c, http.StatusNotFound, "model not found")
|
||||
return
|
||||
}
|
||||
var in modelInput
|
||||
if !httpx.Bind(c, &in) {
|
||||
return
|
||||
}
|
||||
updates := map[string]any{
|
||||
"name": in.Name,
|
||||
}
|
||||
if in.DisplayName != "" {
|
||||
updates["display_name"] = in.DisplayName
|
||||
}
|
||||
if in.InputPrice != "" {
|
||||
updates["input_price"] = parsePrice(in.InputPrice)
|
||||
}
|
||||
if in.OutputPrice != "" {
|
||||
updates["output_price"] = parsePrice(in.OutputPrice)
|
||||
}
|
||||
if in.CacheReadPrice != "" {
|
||||
updates["cache_read_price"] = parsePrice(in.CacheReadPrice)
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
updates["enabled"] = *in.Enabled
|
||||
}
|
||||
if in.Sort != nil {
|
||||
updates["sort"] = *in.Sort
|
||||
}
|
||||
if err := h.db.Model(&m).Updates(updates).Error; err != nil {
|
||||
h.log.Warn("update model failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "update model failed")
|
||||
return
|
||||
}
|
||||
h.db.First(&m, id)
|
||||
httpx.OK(c, modelDTO(&m))
|
||||
}
|
||||
|
||||
// UpdatePrice handles PUT /api/v1/admin/models/:id/price.
|
||||
func (h *modelHandler) UpdatePrice(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid model id")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
InputPrice string `json:"inputPrice"`
|
||||
OutputPrice string `json:"outputPrice"`
|
||||
CacheReadPrice string `json:"cacheReadPrice"`
|
||||
}
|
||||
if !httpx.Bind(c, &in) {
|
||||
return
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if in.InputPrice != "" {
|
||||
updates["input_price"] = parsePrice(in.InputPrice)
|
||||
}
|
||||
if in.OutputPrice != "" {
|
||||
updates["output_price"] = parsePrice(in.OutputPrice)
|
||||
}
|
||||
if in.CacheReadPrice != "" {
|
||||
updates["cache_read_price"] = parsePrice(in.CacheReadPrice)
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
httpx.Fail(c, http.StatusBadRequest, "no price fields provided")
|
||||
return
|
||||
}
|
||||
res := h.db.Model(&store.Model{}).Where("id = ?", id).Updates(updates)
|
||||
if res.RowsAffected == 0 {
|
||||
httpx.Fail(c, http.StatusNotFound, "model not found")
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func parsePrice(s string) decimal.Decimal {
|
||||
d, err := decimal.NewFromString(s)
|
||||
if err != nil {
|
||||
return decimal.Zero
|
||||
}
|
||||
return d.Round(8)
|
||||
}
|
||||
|
||||
func modelDTO(m *store.Model) gin.H {
|
||||
return gin.H{
|
||||
"id": m.ID, "name": m.Name, "displayName": m.DisplayName,
|
||||
"inputPrice": m.InputPrice.String(), "outputPrice": m.OutputPrice.String(),
|
||||
"cacheReadPrice": m.CacheReadPrice.String(), "enabled": m.Enabled, "sort": m.Sort,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"openteam/server/internal/billing"
|
||||
"openteam/server/internal/pkg/httpx"
|
||||
"openteam/server/internal/store"
|
||||
"openteam/server/internal/user"
|
||||
)
|
||||
|
||||
type usageHandler struct {
|
||||
db *gorm.DB
|
||||
bill *billing.Service
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// Overview handles GET /api/v1/admin/stats/overview.
|
||||
func (h *usageHandler) Overview(c *gin.Context) {
|
||||
now := time.Now()
|
||||
today := now.Format("2006-01-02")
|
||||
month := now.Format("2006-01-02")
|
||||
|
||||
var totals struct {
|
||||
Requests int
|
||||
Cost decimal.Decimal
|
||||
}
|
||||
h.db.Model(&store.UsageDaily{}).
|
||||
Select("COALESCE(SUM(requests),0) as requests, COALESCE(SUM(cost),0) as cost").
|
||||
Scan(&totals)
|
||||
|
||||
var todayAgg struct {
|
||||
Requests int
|
||||
Cost decimal.Decimal
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
}
|
||||
h.db.Model(&store.UsageDaily{}).Where("date = ?", today).
|
||||
Select("COALESCE(SUM(requests),0) as requests, COALESCE(SUM(cost),0) as cost, COALESCE(SUM(input_tokens),0) as input_tokens, COALESCE(SUM(output_tokens),0) as output_tokens").
|
||||
Scan(&todayAgg)
|
||||
|
||||
var monthAgg struct {
|
||||
Requests int
|
||||
Cost decimal.Decimal
|
||||
}
|
||||
h.db.Model(&store.UsageDaily{}).Where("date >= ? AND date <= ?", month[:7]+"-01", today).
|
||||
Select("COALESCE(SUM(requests),0) as requests, COALESCE(SUM(cost),0) as cost").
|
||||
Scan(&monthAgg)
|
||||
|
||||
var userCount, channelCount, modelCount int64
|
||||
h.db.Model(&store.User{}).Count(&userCount)
|
||||
h.db.Model(&store.Channel{}).Count(&channelCount)
|
||||
h.db.Model(&store.Model{}).Count(&modelCount)
|
||||
|
||||
httpx.OK(c, gin.H{
|
||||
"total": gin.H{
|
||||
"requests": totals.Requests,
|
||||
"cost": totals.Cost.String(),
|
||||
"users": userCount,
|
||||
"channels": channelCount,
|
||||
"models": modelCount,
|
||||
},
|
||||
"today": gin.H{
|
||||
"requests": todayAgg.Requests,
|
||||
"cost": todayAgg.Cost.String(),
|
||||
"inputTokens": todayAgg.InputTokens,
|
||||
"outputTokens": todayAgg.OutputTokens,
|
||||
},
|
||||
"month": gin.H{
|
||||
"requests": monthAgg.Requests,
|
||||
"cost": monthAgg.Cost.String(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Usage handles GET /api/v1/admin/usage?from&to&userId&model&group=day|model|user.
|
||||
func (h *usageHandler) Usage(c *gin.Context) {
|
||||
from := c.Query("from")
|
||||
to := c.Query("to")
|
||||
group := c.DefaultQuery("group", "day")
|
||||
|
||||
q := h.db.Model(&store.UsageDaily{})
|
||||
if from != "" {
|
||||
q = q.Where("date >= ?", from)
|
||||
}
|
||||
if to != "" {
|
||||
q = q.Where("date <= ?", to)
|
||||
}
|
||||
if uid := c.Query("userId"); uid != "" {
|
||||
q = q.Where("user_id = ?", uid)
|
||||
}
|
||||
if m := c.Query("model"); m != "" {
|
||||
q = q.Where("model_id = ?", m)
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
Key string `gorm:"column:g"`
|
||||
Requests int
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
Cost decimal.Decimal
|
||||
}
|
||||
switch group {
|
||||
case "model":
|
||||
q = q.Joins("JOIN models ON models.id = usage_dailies.model_id").
|
||||
Select("models.name as g, COALESCE(SUM(requests),0) as requests, COALESCE(SUM(input_tokens),0) as input_tokens, COALESCE(SUM(output_tokens),0) as output_tokens, COALESCE(SUM(cost),0) as cost").
|
||||
Group("models.name")
|
||||
case "user":
|
||||
q = q.Joins("JOIN users ON users.id = usage_dailies.user_id").
|
||||
Select("users.username as g, COALESCE(SUM(requests),0) as requests, COALESCE(SUM(input_tokens),0) as input_tokens, COALESCE(SUM(output_tokens),0) as output_tokens, COALESCE(SUM(cost),0) as cost").
|
||||
Group("users.username")
|
||||
default:
|
||||
q = q.Select("date as g, COALESCE(SUM(requests),0) as requests, COALESCE(SUM(input_tokens),0) as input_tokens, COALESCE(SUM(output_tokens),0) as output_tokens, COALESCE(SUM(cost),0) as cost").
|
||||
Group("date").Order("date ASC")
|
||||
}
|
||||
if err := q.Scan(&rows).Error; err != nil {
|
||||
h.log.Warn("admin usage query failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "usage query failed")
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, gin.H{
|
||||
"key": r.Key, "requests": r.Requests,
|
||||
"inputTokens": r.InputTokens, "outputTokens": r.OutputTokens,
|
||||
"cost": r.Cost.String(),
|
||||
})
|
||||
}
|
||||
httpx.OK(c, out)
|
||||
}
|
||||
|
||||
// Recharges handles GET /api/v1/admin/recharges.
|
||||
func (h *usageHandler) Recharges(c *gin.Context) {
|
||||
status := c.Query("status")
|
||||
q := h.db.Preload("User").Model(&store.RechargeOrder{})
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var orders []store.RechargeOrder
|
||||
if err := q.Order("id DESC").Limit(100).Find(&orders).Error; err != nil {
|
||||
h.log.Warn("list recharges failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "list recharges failed")
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(orders))
|
||||
for i := range orders {
|
||||
o := &orders[i]
|
||||
out = append(out, gin.H{
|
||||
"id": o.ID, "userId": o.UserID, "username": o.User.Username,
|
||||
"amount": o.Amount.String(), "status": o.Status, "method": o.Method,
|
||||
"remark": o.Remark, "createdAt": o.CreatedAt,
|
||||
})
|
||||
}
|
||||
httpx.OK(c, out)
|
||||
}
|
||||
|
||||
// ApproveRecharge handles POST /api/v1/admin/recharges/:id/approve.
|
||||
func (h *usageHandler) ApproveRecharge(c *gin.Context) {
|
||||
h.decideRecharge(c, "approve")
|
||||
}
|
||||
|
||||
// RejectRecharge handles POST /api/v1/admin/recharges/:id/reject.
|
||||
func (h *usageHandler) RejectRecharge(c *gin.Context) {
|
||||
h.decideRecharge(c, "reject")
|
||||
}
|
||||
|
||||
func (h *usageHandler) decideRecharge(c *gin.Context, action string) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid order id")
|
||||
return
|
||||
}
|
||||
admin := user.Current(c)
|
||||
|
||||
var order store.RechargeOrder
|
||||
if err := h.db.First(&order, id).Error; err != nil {
|
||||
httpx.Fail(c, http.StatusNotFound, "order not found")
|
||||
return
|
||||
}
|
||||
if order.Status != "pending" {
|
||||
httpx.Fail(c, http.StatusBadRequest, "order already processed")
|
||||
return
|
||||
}
|
||||
|
||||
var in struct {
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&in)
|
||||
|
||||
now := time.Now()
|
||||
if action == "approve" {
|
||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if _, cerr := h.bill.Credit(order.UserID, order.Amount, "recharge", orderIDStr(order.ID)); cerr != nil {
|
||||
return cerr
|
||||
}
|
||||
return tx.Model(&order).Updates(map[string]any{
|
||||
"status": "credited", "reviewed_by": admin.ID, "reviewed_at": now, "remark": in.Remark,
|
||||
}).Error
|
||||
})
|
||||
} else {
|
||||
err = h.db.Model(&order).Updates(map[string]any{
|
||||
"status": "rejected", "reviewed_by": admin.ID, "reviewed_at": now, "remark": in.Remark,
|
||||
}).Error
|
||||
}
|
||||
if err != nil {
|
||||
h.log.Warn("recharge decision failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "recharge decision failed")
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"ok": true, "status": order.Status})
|
||||
}
|
||||
|
||||
func orderIDStr(id int64) string {
|
||||
return strconv.FormatInt(id, 10)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"openteam/server/internal/billing"
|
||||
"openteam/server/internal/pkg/httpx"
|
||||
"openteam/server/internal/store"
|
||||
)
|
||||
|
||||
type userHandler struct {
|
||||
db *gorm.DB
|
||||
bill *billing.Service
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/admin/users?page&search.
|
||||
func (h *userHandler) List(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := 20
|
||||
search := c.Query("search")
|
||||
|
||||
q := h.db.Model(&store.User{})
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
q = q.Where("username LIKE ? OR email LIKE ?", like, like)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
|
||||
var users []store.User
|
||||
if err := q.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&users).Error; err != nil {
|
||||
h.log.Warn("list users failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "list users failed")
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(users))
|
||||
for i := range users {
|
||||
out = append(out, userDTO(&users[i]))
|
||||
}
|
||||
httpx.OK(c, gin.H{"total": total, "page": page, "items": out})
|
||||
}
|
||||
|
||||
// Update handles PATCH /api/v1/admin/users/:id.
|
||||
func (h *userHandler) Update(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
var u store.User
|
||||
if err := h.db.First(&u, id).Error; err != nil {
|
||||
httpx.Fail(c, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Role *string `json:"role"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
if !httpx.Bind(c, &in) {
|
||||
return
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if in.Role != nil {
|
||||
if *in.Role != "admin" && *in.Role != "user" {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid role")
|
||||
return
|
||||
}
|
||||
updates["role"] = *in.Role
|
||||
}
|
||||
if in.Status != nil {
|
||||
if *in.Status != "active" && *in.Status != "disabled" {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid status")
|
||||
return
|
||||
}
|
||||
updates["status"] = *in.Status
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := h.db.Model(&u).Updates(updates).Error; err != nil {
|
||||
h.log.Warn("update user failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "update user failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
h.db.First(&u, id)
|
||||
httpx.OK(c, userDTO(&u))
|
||||
}
|
||||
|
||||
// AdjustBalance handles POST /api/v1/admin/users/:id/balance.
|
||||
func (h *userHandler) AdjustBalance(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Amount string `json:"amount" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if !httpx.Bind(c, &in) {
|
||||
return
|
||||
}
|
||||
amount, err := decimal.NewFromString(in.Amount)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid amount")
|
||||
return
|
||||
}
|
||||
if amount.IsZero() {
|
||||
httpx.Fail(c, http.StatusBadRequest, "amount must be non-zero")
|
||||
return
|
||||
}
|
||||
after, err := h.bill.AdminAdjust(id, amount.Round(8), in.Remark)
|
||||
if err != nil {
|
||||
if err == billing.ErrInsufficientBalance {
|
||||
httpx.Fail(c, http.StatusBadRequest, "amount would make balance negative")
|
||||
return
|
||||
}
|
||||
h.log.Warn("adjust balance failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "adjust balance failed")
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"balanceAfter": after.String()})
|
||||
}
|
||||
|
||||
func userDTO(u *store.User) gin.H {
|
||||
return gin.H{
|
||||
"id": u.ID, "username": u.Username, "email": u.Email,
|
||||
"role": u.Role, "balance": u.Balance.String(), "status": u.Status,
|
||||
"lastLoginAt": u.LastLoginAt, "createdAt": u.CreatedAt,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user