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:
Sakurasan
2026-08-15 21:05:02 +08:00
co-authored by Claude Sonnet 5
parent b0c7439c01
commit d0e31b198f
45 changed files with 6222 additions and 0 deletions
+329
View File
@@ -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
}
+57
View File
@@ -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})
}
+57
View File
@@ -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)
}
+171
View File
@@ -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,
}
}
+222
View File
@@ -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)
}
+140
View File
@@ -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,
}
}