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>
223 lines
6.5 KiB
Go
223 lines
6.5 KiB
Go
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)
|
|
}
|