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
+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)
}