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,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)
|
||||
}
|
||||
Reference in New Issue
Block a user