M0-M4: 推倒重来基线(基建+用户/密钥/核心代理+前端+管理后台+三协议互转)
- 后端 Go+Gin+GORM: 配置(OT_ env)/SQLite/Postgres 双驱动、用户体系(argon2id+JWT access/refresh)、 API Key(sk- 48位, 仅存 SHA-256 哈希) - 代理网关: /v1/chat/completions、/v1/responses、/v1/messages、/v1/models;错误按客户端协议返回 - 三协议互转(convert 包): Chat↔Messages↔Responses 请求/响应 + 流式 SSE 逐事件转换(直通优先) - 用量计费: 异步批量记账、余额扣减、balance_logs、usage_daily 日聚合 - 管理 API: 用户/渠道 CRUD+测试+模型导入/模型定价+绑定/统计/系统配置 - 前端 Vue3+TS+Tailwind(taste-skill 设计 tokens): Landing/登录注册/控制台/管理后台, 自建组件+Phosphor 图标+自建 SVG 趋势图, 已过 web-design-guidelines 复查 - mock 上游: OpenAI+Anthropic 双协议模拟(含流式) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -11,6 +10,7 @@ import (
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// AdminUsers GET /api/v1/admin/users — 用户列表(搜索、分页)。
|
||||
@@ -38,7 +38,7 @@ func (h *Handler) AdminUsers(c *gin.Context) {
|
||||
resp.OK(c, gin.H{"items": out, "total": total, "page": page, "page_size": size})
|
||||
}
|
||||
|
||||
// AdminPatchUser PATCH /api/v1/admin/users/:id — 角色/状态/余额。
|
||||
// AdminPatchUser PATCH /api/v1/admin/users/:id — 角色/状态。
|
||||
func (h *Handler) AdminPatchUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -53,11 +53,6 @@ func (h *Handler) AdminPatchUser(c *gin.Context) {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||
return
|
||||
}
|
||||
var u store.User
|
||||
if err := h.a.DB.First(&u, id).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if req.Role != nil {
|
||||
if *req.Role != store.RoleUser && *req.Role != store.RoleAdmin {
|
||||
@@ -73,42 +68,57 @@ func (h *Handler) AdminPatchUser(c *gin.Context) {
|
||||
}
|
||||
updates["status"] = *req.Status
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
h.a.DB.Model(&u).Updates(updates)
|
||||
if len(updates) == 0 {
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
res := h.a.DB.Model(&store.User{}).Where("id = ?", id).Updates(updates)
|
||||
if res.Error != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
resp.Fail(c, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminAdjustBalance POST /api/v1/admin/users/:id/balance — 调整余额并写流水。
|
||||
// AdminAdjustBalance POST /api/v1/admin/users/:id/balance — 手动调余额(写流水)。
|
||||
func (h *Handler) AdminAdjustBalance(c *gin.Context) {
|
||||
admin, _ := userFromContext(c)
|
||||
admin := sessionUser(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Change float64 `json:"change" binding:"required"`
|
||||
Amount float64 `json:"amount" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input: change is required")
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input: amount required")
|
||||
return
|
||||
}
|
||||
var u store.User
|
||||
if err := h.a.DB.First(&u, id).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "user not found")
|
||||
if req.Amount == 0 {
|
||||
resp.Fail(c, http.StatusBadRequest, "amount must not be zero")
|
||||
return
|
||||
}
|
||||
newBalance := u.Balance + req.Change
|
||||
ref := fmt.Sprintf("admin:%d:%d", admin.ID, time.Now().UnixNano())
|
||||
|
||||
err = h.a.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&store.User{}).Where("id = ?", u.ID).Update("balance", newBalance).Error; err != nil {
|
||||
var u store.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&u, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
newBalance := u.Balance + req.Amount
|
||||
if err := tx.Model(&store.User{}).Where("id = ?", id).Update("balance", newBalance).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
ref := "admin:" + strconv.FormatUint(u.ID, 10) + ":" + time.Now().Format("20060102150405")
|
||||
_ = admin.ID // 流水里不冗余管理员 ID;需要时再加
|
||||
return tx.Create(&store.BalanceLog{
|
||||
UserID: u.ID,
|
||||
Change: req.Change,
|
||||
Change: req.Amount,
|
||||
BalanceAfter: newBalance,
|
||||
Type: store.BalanceTypeAdminAdjust,
|
||||
RefID: ref,
|
||||
@@ -116,45 +126,45 @@ func (h *Handler) AdminAdjustBalance(c *gin.Context) {
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to adjust balance")
|
||||
resp.Fail(c, http.StatusNotFound, "user not found or failed to adjust")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"ok": true, "balance": newBalance})
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminConfig GET /api/v1/admin/config
|
||||
// AdminConfig GET /api/v1/admin/config — 全部系统配置。
|
||||
func (h *Handler) AdminConfig(c *gin.Context) {
|
||||
var cfgs []store.SystemConfig
|
||||
h.a.DB.Find(&cfgs)
|
||||
m := map[string]any{}
|
||||
for _, cfg := range cfgs {
|
||||
var v any
|
||||
_ = json.Unmarshal([]byte(cfg.Value), &v)
|
||||
m[cfg.Key] = v
|
||||
if err := h.a.DB.Find(&cfgs).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to load config")
|
||||
return
|
||||
}
|
||||
m["registration.mode"] = h.a.Cfg.Auth.RegistrationMode
|
||||
resp.OK(c, gin.H{"config": m})
|
||||
out := gin.H{}
|
||||
for _, cfg := range cfgs {
|
||||
out[cfg.Key] = json.RawMessage(cfg.Value)
|
||||
}
|
||||
resp.OK(c, gin.H{"config": out})
|
||||
}
|
||||
|
||||
// AdminPutConfig PUT /api/v1/admin/config
|
||||
// AdminPutConfig PUT /api/v1/admin/config — 整表覆盖(upsert)。
|
||||
func (h *Handler) AdminPutConfig(c *gin.Context) {
|
||||
var req struct {
|
||||
Config map[string]any `json:"config"`
|
||||
}
|
||||
var req map[string]json.RawMessage
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||
return
|
||||
}
|
||||
for k, v := range req.Config {
|
||||
if k == "registration.mode" {
|
||||
if v == "open" || v == "invite" {
|
||||
h.a.Cfg.Auth.RegistrationMode = v.(string)
|
||||
err := h.a.DB.Transaction(func(tx *gorm.DB) error {
|
||||
for k, v := range req {
|
||||
cfg := store.SystemConfig{Key: k, Value: string(v)}
|
||||
if err := tx.Save(&cfg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
b, _ := json.Marshal(v)
|
||||
cfg := store.SystemConfig{Key: k, Value: string(b)}
|
||||
h.a.DB.Save(&cfg)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to save config")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// AdminChannels GET /api/v1/admin/channels — 渠道列表(不返回加密 key,返回掩码)。
|
||||
func (h *Handler) AdminChannels(c *gin.Context) {
|
||||
var chs []store.Channel
|
||||
if err := h.a.DB.Order("id ASC").Find(&chs).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to load channels")
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(chs))
|
||||
for _, ch := range chs {
|
||||
masked := ""
|
||||
if key, err := h.a.Enc.Decrypt(ch.APIKeyEnc); err == nil && len(key) > 8 {
|
||||
masked = strings.Repeat("*", len(key)-4) + key[len(key)-4:]
|
||||
} else if err == nil {
|
||||
masked = "****"
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "base_url": ch.BaseURL,
|
||||
"api_key_masked": masked, "weight": ch.Weight, "priority": ch.Priority,
|
||||
"timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency,
|
||||
"health_status": ch.HealthStatus, "enabled": ch.Enabled,
|
||||
"created_at": ch.CreatedAt,
|
||||
})
|
||||
}
|
||||
resp.OK(c, gin.H{"items": out})
|
||||
}
|
||||
|
||||
type channelBody struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=64"`
|
||||
Provider string `json:"provider" binding:"required"`
|
||||
BaseURL string `json:"base_url" binding:"required"`
|
||||
APIKey string `json:"api_key"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
TimeoutMS *int `json:"timeout_ms"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func validateProvider(p string) bool {
|
||||
return p == store.ChannelProviderOpenAI || p == store.ChannelProviderAnthropic || p == store.ChannelProviderCompatible
|
||||
}
|
||||
|
||||
// AdminCreateChannel POST /api/v1/admin/channels
|
||||
func (h *Handler) AdminCreateChannel(c *gin.Context) {
|
||||
var req channelBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !validateProvider(req.Provider) {
|
||||
resp.Fail(c, http.StatusBadRequest, "provider must be openai, anthropic or compatible")
|
||||
return
|
||||
}
|
||||
if req.APIKey == "" {
|
||||
resp.Fail(c, http.StatusBadRequest, "api_key required")
|
||||
return
|
||||
}
|
||||
enc, err := h.a.Enc.Encrypt(req.APIKey)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to encrypt api key")
|
||||
return
|
||||
}
|
||||
ch := store.Channel{
|
||||
Name: req.Name, Provider: req.Provider, BaseURL: strings.TrimRight(req.BaseURL, "/"),
|
||||
APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0),
|
||||
TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
|
||||
HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true),
|
||||
}
|
||||
if err := h.a.DB.Create(&ch).Error; err != nil {
|
||||
resp.Fail(c, http.StatusConflict, "failed to create channel (name may already exist)")
|
||||
return
|
||||
}
|
||||
resp.Created(c, gin.H{"id": ch.ID, "name": ch.Name})
|
||||
}
|
||||
|
||||
// AdminUpdateChannel PUT /api/v1/admin/channels/:id
|
||||
func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Provider *string `json:"provider"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
APIKey *string `json:"api_key"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
TimeoutMS *int `json:"timeout_ms"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
HealthStatus *string `json:"health_status"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||
return
|
||||
}
|
||||
var ch store.Channel
|
||||
if err := h.a.DB.First(&ch, id).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "channel not found")
|
||||
return
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if body.Name != nil {
|
||||
updates["name"] = *body.Name
|
||||
}
|
||||
if body.Provider != nil {
|
||||
if !validateProvider(*body.Provider) {
|
||||
resp.Fail(c, http.StatusBadRequest, "provider must be openai, anthropic or compatible")
|
||||
return
|
||||
}
|
||||
updates["provider"] = *body.Provider
|
||||
}
|
||||
if body.BaseURL != nil {
|
||||
updates["base_url"] = strings.TrimRight(*body.BaseURL, "/")
|
||||
}
|
||||
if body.APIKey != nil && *body.APIKey != "" {
|
||||
enc, err := h.a.Enc.Encrypt(*body.APIKey)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to encrypt api key")
|
||||
return
|
||||
}
|
||||
updates["api_key_enc"] = enc
|
||||
}
|
||||
if body.Weight != nil {
|
||||
updates["weight"] = *body.Weight
|
||||
}
|
||||
if body.Priority != nil {
|
||||
updates["priority"] = *body.Priority
|
||||
}
|
||||
if body.TimeoutMS != nil {
|
||||
updates["timeout_ms"] = *body.TimeoutMS
|
||||
}
|
||||
if body.MaxConcurrency != nil {
|
||||
updates["max_concurrency"] = *body.MaxConcurrency
|
||||
}
|
||||
if body.HealthStatus != nil {
|
||||
updates["health_status"] = *body.HealthStatus
|
||||
}
|
||||
if body.Enabled != nil {
|
||||
updates["enabled"] = *body.Enabled
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := h.a.DB.Model(&ch).Updates(updates).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to update channel")
|
||||
return
|
||||
}
|
||||
}
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminDeleteChannel DELETE /api/v1/admin/channels/:id
|
||||
func (h *Handler) AdminDeleteChannel(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
res := h.a.DB.Delete(&store.Channel{}, id)
|
||||
if res.Error != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to delete channel")
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
resp.Fail(c, http.StatusNotFound, "channel not found")
|
||||
return
|
||||
}
|
||||
// 清理模型绑定
|
||||
h.a.DB.Where("channel_id = ?", id).Delete(&store.ChannelModelBinding{})
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminTestChannel POST /api/v1/admin/channels/:id/test — 请求渠道 /v1/models 测连通性。
|
||||
func (h *Handler) AdminTestChannel(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
var ch store.Channel
|
||||
if err := h.a.DB.First(&ch, id).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "channel not found")
|
||||
return
|
||||
}
|
||||
key, err := h.a.Enc.Decrypt(ch.APIKeyEnc)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
|
||||
return
|
||||
}
|
||||
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models"
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
start := time.Now()
|
||||
resp2, err := client.Do(req)
|
||||
status := store.ChannelHealthHealthy
|
||||
msg := "ok"
|
||||
latency := 0
|
||||
if err != nil {
|
||||
status = store.ChannelHealthCooldown
|
||||
msg = err.Error()
|
||||
} else {
|
||||
latency = int(time.Since(start).Milliseconds())
|
||||
if resp2.StatusCode < 200 || resp2.StatusCode >= 300 {
|
||||
status = store.ChannelHealthCooldown
|
||||
b, _ := io.ReadAll(io.LimitReader(resp2.Body, 1024))
|
||||
msg = fmt.Sprintf("http %d: %s", resp2.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
resp2.Body.Close()
|
||||
}
|
||||
h.a.DB.Model(&store.Channel{}).Where("id = ?", ch.ID).Update("health_status", status)
|
||||
if status != store.ChannelHealthHealthy {
|
||||
resp.Fail(c, http.StatusBadGateway, msg)
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"ok": true, "latency_ms": latency, "message": msg})
|
||||
}
|
||||
|
||||
// AdminImportChannelModels POST /api/v1/admin/channels/:id/models/import
|
||||
// 拉取渠道 GET /v1/models,导入模型库并绑定。
|
||||
func (h *Handler) AdminImportChannelModels(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
var ch store.Channel
|
||||
if err := h.a.DB.First(&ch, id).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "channel not found")
|
||||
return
|
||||
}
|
||||
key, err := h.a.Enc.Decrypt(ch.APIKeyEnc)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
|
||||
return
|
||||
}
|
||||
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models"
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
resp2, err := client.Do(req)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadGateway, "failed to reach channel: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
resp.Fail(c, http.StatusBadGateway, "channel returned http "+strconv.Itoa(resp2.StatusCode))
|
||||
return
|
||||
}
|
||||
var list struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&list); err != nil {
|
||||
resp.Fail(c, http.StatusBadGateway, "failed to parse model list")
|
||||
return
|
||||
}
|
||||
if len(list.Data) == 0 {
|
||||
resp.Fail(c, http.StatusNotFound, "channel returned no models")
|
||||
return
|
||||
}
|
||||
|
||||
imported := 0
|
||||
err = h.a.DB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, item := range list.Data {
|
||||
name := strings.TrimSpace(item.ID)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
var m store.Model
|
||||
if err := tx.Where("name = ?", name).FirstOrCreate(&m, store.Model{
|
||||
Name: name, DisplayName: name, Enabled: true,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// upsert 绑定(upstream_model 默认同名)
|
||||
var binding store.ChannelModelBinding
|
||||
err := tx.Where("channel_id = ? AND model_id = ?", ch.ID, m.ID).First(&binding).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
binding = store.ChannelModelBinding{ChannelID: ch.ID, ModelID: m.ID, UpstreamModel: name, Weight: 1}
|
||||
if err := tx.Create(&binding).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
imported++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to import models")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"imported": imported})
|
||||
}
|
||||
|
||||
var _ = clause.Assignments // 保留 gorm/clause 引用(后续定价批处理用)
|
||||
|
||||
func intOr(p *int, def int) int {
|
||||
if p == nil {
|
||||
return def
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func boolOr(p *bool, def bool) bool {
|
||||
if p == nil {
|
||||
return def
|
||||
}
|
||||
return *p
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AdminModels GET /api/v1/admin/models — 模型列表(含价格与渠道绑定)。
|
||||
func (h *Handler) AdminModels(c *gin.Context) {
|
||||
var ms []store.Model
|
||||
if err := h.a.DB.Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to load models")
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
var bindings []store.ChannelModelBinding
|
||||
h.a.DB.Preload("Channel").Where("model_id = ?", m.ID).Find(&bindings)
|
||||
chs := make([]gin.H, 0, len(bindings))
|
||||
for _, b := range bindings {
|
||||
chs = append(chs, gin.H{
|
||||
"id": b.ID, "channel_id": b.ChannelID, "channel_name": b.Channel.Name,
|
||||
"upstream_model": b.UpstreamModel, "weight": b.Weight,
|
||||
})
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": m.ID, "name": m.Name, "display_name": m.DisplayName,
|
||||
"input_price": m.InputPrice, "output_price": m.OutputPrice, "cache_read_price": m.CacheReadPrice,
|
||||
"enabled": m.Enabled, "sort": m.Sort, "channels": chs,
|
||||
})
|
||||
}
|
||||
resp.OK(c, gin.H{"items": out})
|
||||
}
|
||||
|
||||
// AdminCreateModel POST /api/v1/admin/models
|
||||
func (h *Handler) AdminCreateModel(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=128"`
|
||||
DisplayName string `json:"display_name"`
|
||||
InputPrice float64 `json:"input_price"`
|
||||
OutputPrice float64 `json:"output_price"`
|
||||
CacheReadPrice float64 `json:"cache_read_price"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
|
||||
return
|
||||
}
|
||||
m := store.Model{
|
||||
Name: req.Name, DisplayName: req.DisplayName,
|
||||
InputPrice: req.InputPrice, OutputPrice: req.OutputPrice, CacheReadPrice: req.CacheReadPrice,
|
||||
Enabled: boolOr(req.Enabled, true),
|
||||
}
|
||||
if m.DisplayName == "" {
|
||||
m.DisplayName = m.Name
|
||||
}
|
||||
if err := h.a.DB.Create(&m).Error; err != nil {
|
||||
resp.Fail(c, http.StatusConflict, "failed to create model (name may already exist)")
|
||||
return
|
||||
}
|
||||
resp.Created(c, gin.H{"id": m.ID, "name": m.Name})
|
||||
}
|
||||
|
||||
// AdminUpdateModel PUT /api/v1/admin/models/:id — 价格/展示名/启停/排序。
|
||||
func (h *Handler) AdminUpdateModel(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid model id")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Sort *int `json:"sort"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||
return
|
||||
}
|
||||
var m store.Model
|
||||
if err := h.a.DB.First(&m, id).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "model not found")
|
||||
return
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if req.DisplayName != nil {
|
||||
updates["display_name"] = *req.DisplayName
|
||||
}
|
||||
if req.InputPrice != nil {
|
||||
updates["input_price"] = *req.InputPrice
|
||||
}
|
||||
if req.OutputPrice != nil {
|
||||
updates["output_price"] = *req.OutputPrice
|
||||
}
|
||||
if req.CacheReadPrice != nil {
|
||||
updates["cache_read_price"] = *req.CacheReadPrice
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
updates["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.Sort != nil {
|
||||
updates["sort"] = *req.Sort
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := h.a.DB.Model(&m).Updates(updates).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to update model")
|
||||
return
|
||||
}
|
||||
}
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminDeleteModel DELETE /api/v1/admin/models/:id
|
||||
func (h *Handler) AdminDeleteModel(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid model id")
|
||||
return
|
||||
}
|
||||
res := h.a.DB.Delete(&store.Model{}, id)
|
||||
if res.Error != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to delete model")
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
resp.Fail(c, http.StatusNotFound, "model not found")
|
||||
return
|
||||
}
|
||||
h.a.DB.Where("model_id = ?", id).Delete(&store.ChannelModelBinding{})
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminCreateModelBinding POST /api/v1/admin/models/:id/bindings
|
||||
func (h *Handler) AdminCreateModelBinding(c *gin.Context) {
|
||||
modelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid model id")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ChannelID uint64 `json:"channel_id" binding:"required"`
|
||||
UpstreamModel string `json:"upstream_model" binding:"required"`
|
||||
Weight *int `json:"weight"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input: channel_id and upstream_model required")
|
||||
return
|
||||
}
|
||||
var m store.Model
|
||||
if err := h.a.DB.First(&m, modelID).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "model not found")
|
||||
return
|
||||
}
|
||||
var ch store.Channel
|
||||
if err := h.a.DB.First(&ch, req.ChannelID).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "channel not found")
|
||||
return
|
||||
}
|
||||
b := store.ChannelModelBinding{
|
||||
ChannelID: req.ChannelID, ModelID: modelID,
|
||||
UpstreamModel: req.UpstreamModel, Weight: intOr(req.Weight, 1),
|
||||
}
|
||||
if err := h.a.DB.Create(&b).Error; err != nil {
|
||||
resp.Fail(c, http.StatusConflict, "binding may already exist")
|
||||
return
|
||||
}
|
||||
resp.Created(c, gin.H{"id": b.ID})
|
||||
}
|
||||
|
||||
// AdminDeleteModelBinding DELETE /api/v1/admin/models/:id/bindings/:bid
|
||||
func (h *Handler) AdminDeleteModelBinding(c *gin.Context) {
|
||||
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid binding id")
|
||||
return
|
||||
}
|
||||
res := h.a.DB.Delete(&store.ChannelModelBinding{}, bid)
|
||||
if res.Error != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to delete binding")
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
resp.Fail(c, http.StatusNotFound, "binding not found")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
var _ = errors.Is
|
||||
var _ = gorm.ErrRecordNotFound
|
||||
@@ -0,0 +1,102 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/store"
|
||||
)
|
||||
|
||||
// AdminStatsOverview GET /api/v1/admin/stats/overview — 运营总览。
|
||||
func (h *Handler) AdminStatsOverview(c *gin.Context) {
|
||||
now := time.Now().UTC()
|
||||
today := now.Format("2006-01-02")
|
||||
month := now.Format("2006-01")
|
||||
|
||||
var totalUsers int64
|
||||
h.a.DB.Model(&store.User{}).Count(&totalUsers)
|
||||
var totalKeys int64
|
||||
h.a.DB.Model(&store.APIKey{}).Count(&totalKeys)
|
||||
var totalChannels int64
|
||||
h.a.DB.Model(&store.Channel{}).Count(&totalChannels)
|
||||
var totalModels int64
|
||||
h.a.DB.Model(&store.Model{}).Count(&totalModels)
|
||||
|
||||
// 全局今日/本月汇总(跨用户)
|
||||
var todayReq, monthReq int64
|
||||
var todayCost, monthCost float64
|
||||
var todayTokens, monthTokens int64
|
||||
h.a.DB.Model(&store.UsageDaily{}).Where("date = ?", today).
|
||||
Select("COALESCE(SUM(requests),0), COALESCE(SUM(cost),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0)").
|
||||
Row().Scan(&todayReq, &todayCost, &todayTokens)
|
||||
h.a.DB.Model(&store.UsageDaily{}).Where("date LIKE ?", month+"%").
|
||||
Select("COALESCE(SUM(requests),0), COALESCE(SUM(cost),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0)").
|
||||
Row().Scan(&monthReq, &monthCost, &monthTokens)
|
||||
|
||||
// 近 14 天趋势(日粒度)
|
||||
var days []struct {
|
||||
Date string
|
||||
Req int64
|
||||
Cost float64
|
||||
}
|
||||
h.a.DB.Model(&store.UsageDaily{}).
|
||||
Select("date, SUM(requests) req, SUM(cost) cost").
|
||||
Where("date >= ?", now.AddDate(0, 0, -13).Format("2006-01-02")).
|
||||
Group("date").Order("date").Scan(&days)
|
||||
trend := make([]gin.H, 0, len(days))
|
||||
for _, d := range days {
|
||||
trend = append(trend, gin.H{"date": d.Date, "requests": d.Req, "cost": d.Cost})
|
||||
}
|
||||
|
||||
resp.OK(c, gin.H{
|
||||
"total_users": totalUsers, "total_keys": totalKeys,
|
||||
"total_channels": totalChannels, "total_models": totalModels,
|
||||
"today": gin.H{"requests": todayReq, "cost": todayCost, "tokens": todayTokens},
|
||||
"month": gin.H{"requests": monthReq, "cost": monthCost, "tokens": monthTokens},
|
||||
"trend_14d": trend,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminUsage GET /api/v1/admin/usage — 全局用量日志(分页 + 过滤)。
|
||||
func (h *Handler) AdminUsage(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
q := h.a.DB.Model(&store.UsageLog{})
|
||||
if from := c.Query("from"); from != "" {
|
||||
q = q.Where("created_at >= ?", from+" 00:00:00")
|
||||
}
|
||||
if to := c.Query("to"); to != "" {
|
||||
q = q.Where("created_at <= ?", to+" 23:59:59")
|
||||
}
|
||||
if model := c.Query("model"); model != "" {
|
||||
q = q.Where("model_name = ?", model)
|
||||
}
|
||||
if user := c.Query("user_id"); user != "" {
|
||||
q = q.Where("user_id = ?", user)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var logs []store.UsageLog
|
||||
q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&logs)
|
||||
out := make([]gin.H, 0, len(logs))
|
||||
for _, l := range logs {
|
||||
var uname string
|
||||
h.a.DB.Model(&store.User{}).Where("id = ?", l.UserID).Pluck("username", &uname)
|
||||
out = append(out, gin.H{
|
||||
"id": l.ID, "user": uname, "user_id": l.UserID, "model": l.ModelName, "protocol": l.Protocol,
|
||||
"input_tokens": l.InputTokens, "output_tokens": l.OutputTokens,
|
||||
"cache_read_tokens": l.CacheReadTokens, "cost": l.Cost,
|
||||
"latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode,
|
||||
"created_at": l.CreatedAt,
|
||||
})
|
||||
}
|
||||
resp.OK(c, gin.H{"items": out, "total": total, "page": page, "page_size": size})
|
||||
}
|
||||
@@ -2,15 +2,16 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/app"
|
||||
"github.com/openteam/server/internal/api/middleware"
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Handler 聚合所有管理 API。
|
||||
@@ -37,10 +38,21 @@ func (h *Handler) Register(c *gin.Context) {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
|
||||
return
|
||||
}
|
||||
if h.a.Cfg.Auth.RegistrationMode == "invite" {
|
||||
// 注册模式优先读系统配置(管理后台可改),缺省用环境配置
|
||||
mode := h.a.Cfg.Auth.RegistrationMode
|
||||
var modeCfgRaw string
|
||||
h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "registration_mode").Pluck("value", &modeCfgRaw)
|
||||
var modeCfg string
|
||||
_ = json.Unmarshal([]byte(modeCfgRaw), &modeCfg)
|
||||
if modeCfg == "open" || modeCfg == "invite" {
|
||||
mode = modeCfg
|
||||
}
|
||||
if mode == "invite" {
|
||||
var icRaw string
|
||||
h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "invite_codes").Pluck("value", &icRaw)
|
||||
var ic string
|
||||
h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "invite_codes").Pluck("value", &ic)
|
||||
if !strings.Contains(ic, req.InviteCode) {
|
||||
_ = json.Unmarshal([]byte(icRaw), &ic)
|
||||
if req.InviteCode == "" || !strings.Contains(ic, req.InviteCode) {
|
||||
resp.Fail(c, http.StatusForbidden, "valid invite code required")
|
||||
return
|
||||
}
|
||||
@@ -182,16 +194,14 @@ func (h *Handler) publicUser(u *store.User) gin.H {
|
||||
}
|
||||
|
||||
func sessionUser(c *gin.Context) *store.User {
|
||||
u, _ := c.Get("session_user")
|
||||
u, _ := c.Get(middleware.CtxSessionUser)
|
||||
return u.(*store.User)
|
||||
}
|
||||
|
||||
func userFromContext(c *gin.Context) (*store.User, bool) {
|
||||
u, ok := c.Get("session_user")
|
||||
u, ok := c.Get(middleware.CtxSessionUser)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return u.(*store.User), true
|
||||
}
|
||||
|
||||
var _ = gorm.ErrRecordNotFound
|
||||
|
||||
+16
-16
@@ -12,11 +12,11 @@ import (
|
||||
)
|
||||
|
||||
type createKeyReq struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=64"`
|
||||
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
|
||||
Name *string `json:"name" binding:"required,min=1,max=64"`
|
||||
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
|
||||
QuotaRequestsPerDay *int `json:"quota_requests_per_day"`
|
||||
AllowedModels []string `json:"allowed_models"`
|
||||
ExpiresAt *string `json:"expires_at"` // RFC3339
|
||||
AllowedModels []string `json:"allowed_models"`
|
||||
ExpiresAt *string `json:"expires_at"` // RFC3339
|
||||
}
|
||||
|
||||
// CreateKey POST /api/v1/keys — 创建密钥,明文仅此一次返回。
|
||||
@@ -37,14 +37,14 @@ func (h *Handler) CreateKey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
k := store.APIKey{
|
||||
UserID: u.ID,
|
||||
Name: req.Name,
|
||||
KeyHash: hash,
|
||||
KeyPrefix: prefix,
|
||||
QuotaTokensPerDay: req.QuotaTokensPerDay,
|
||||
UserID: u.ID,
|
||||
Name: *req.Name,
|
||||
KeyHash: hash,
|
||||
KeyPrefix: prefix,
|
||||
QuotaTokensPerDay: req.QuotaTokensPerDay,
|
||||
QuotaRequestsPerDay: req.QuotaRequestsPerDay,
|
||||
AllowedModels: req.AllowedModels,
|
||||
Status: store.KeyStatusActive,
|
||||
AllowedModels: req.AllowedModels,
|
||||
Status: store.KeyStatusActive,
|
||||
}
|
||||
if req.ExpiresAt != nil {
|
||||
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
|
||||
@@ -110,11 +110,11 @@ func (h *Handler) PatchKey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
|
||||
QuotaRequestsPerDay *int `json:"quota_requests_per_day"`
|
||||
AllowedModels *[]string `json:"allowed_models"`
|
||||
Status *string `json:"status"`
|
||||
Name *string `json:"name"`
|
||||
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
|
||||
QuotaRequestsPerDay *int `json:"quota_requests_per_day"`
|
||||
AllowedModels *[]string `json:"allowed_models"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||
|
||||
@@ -26,6 +26,7 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
||||
{
|
||||
proxyGroup.Any("/chat/completions", gw.Auth, gw.Handle)
|
||||
proxyGroup.Any("/responses", gw.Auth, gw.Handle)
|
||||
proxyGroup.Any("/messages", gw.Auth, gw.Handle)
|
||||
proxyGroup.Any("/models", gw.Auth, gw.Handle)
|
||||
}
|
||||
// 未匹配的 /v1/* 返回 OpenAI 风格 404(需先认证)
|
||||
@@ -68,12 +69,31 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
||||
|
||||
admin := api.Group("/admin", middleware.SessionAuth(a), middleware.AdminOnly)
|
||||
{
|
||||
// 用户
|
||||
admin.GET("/users", h.AdminUsers)
|
||||
admin.PATCH("/users/:id", h.AdminPatchUser)
|
||||
admin.POST("/users/:id/balance", h.AdminAdjustBalance)
|
||||
// 渠道
|
||||
admin.GET("/channels", h.AdminChannels)
|
||||
admin.POST("/channels", h.AdminCreateChannel)
|
||||
admin.PUT("/channels/:id", h.AdminUpdateChannel)
|
||||
admin.DELETE("/channels/:id", h.AdminDeleteChannel)
|
||||
admin.POST("/channels/:id/test", h.AdminTestChannel)
|
||||
admin.POST("/channels/:id/models/import", h.AdminImportChannelModels)
|
||||
// 模型与定价
|
||||
admin.GET("/models", h.AdminModels)
|
||||
admin.POST("/models", h.AdminCreateModel)
|
||||
admin.PUT("/models/:id", h.AdminUpdateModel)
|
||||
admin.DELETE("/models/:id", h.AdminDeleteModel)
|
||||
admin.POST("/models/:id/bindings", h.AdminCreateModelBinding)
|
||||
admin.DELETE("/models/:id/bindings/:bid", h.AdminDeleteModelBinding)
|
||||
// 统计与用量
|
||||
admin.GET("/stats/overview", h.AdminStatsOverview)
|
||||
admin.GET("/usage", h.AdminUsage)
|
||||
// 配置
|
||||
admin.GET("/config", h.AdminConfig)
|
||||
admin.PUT("/config", h.AdminPutConfig)
|
||||
// 渠道/模型/用量管理(M4);充值审核(M5 预留)
|
||||
// 充值审核(M6 预留)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/store"
|
||||
)
|
||||
|
||||
// UsageSummary GET /api/v1/usage/summary — 今日/本月汇总。
|
||||
func (h *Handler) UsageSummary(c *gin.Context) {
|
||||
u, _ := userFromContext(c)
|
||||
now := time.Now().UTC()
|
||||
today := now.Format("2006-01-02")
|
||||
month := now.Format("2006-01")
|
||||
|
||||
var todayReq, monthReq int64
|
||||
var todayTok, monthTok int64
|
||||
var todayCost, monthCost float64
|
||||
h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date = ?", u.ID, today).
|
||||
Select("COALESCE(SUM(requests),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0), COALESCE(SUM(cost),0)").
|
||||
Row().Scan(&todayReq, &todayTok, &todayCost)
|
||||
h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date LIKE ?", u.ID, month+"%").
|
||||
Select("COALESCE(SUM(requests),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0), COALESCE(SUM(cost),0)").
|
||||
Row().Scan(&monthReq, &monthTok, &monthCost)
|
||||
resp.OK(c, gin.H{
|
||||
"today": gin.H{"requests": todayReq, "tokens": todayTok, "cost": todayCost},
|
||||
"month": gin.H{"requests": monthReq, "tokens": monthTok, "cost": monthCost},
|
||||
})
|
||||
}
|
||||
|
||||
// UsageStats GET /api/v1/usage/stats?from&to&group=day|model
|
||||
func (h *Handler) UsageStats(c *gin.Context) {
|
||||
u, _ := userFromContext(c)
|
||||
from := c.DefaultQuery("from", time.Now().Add(-30*24*time.Hour).Format("2006-01-02"))
|
||||
to := c.DefaultQuery("to", time.Now().Format("2006-01-02"))
|
||||
group := c.DefaultQuery("group", "day")
|
||||
|
||||
q := h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date BETWEEN ? AND ?", u.ID, from, to)
|
||||
out := make([]gin.H, 0, 64)
|
||||
if group == "model" {
|
||||
var rows []struct {
|
||||
ModelID uint64
|
||||
Requests int64
|
||||
Tokens int64
|
||||
Cost float64
|
||||
}
|
||||
q.Select("model_id, SUM(requests) requests, SUM(input_tokens+output_tokens+cache_read_tokens) tokens, SUM(cost) cost").
|
||||
Group("model_id").Scan(&rows)
|
||||
for _, r := range rows {
|
||||
var m store.Model
|
||||
name := strconv.FormatUint(r.ModelID, 10)
|
||||
if h.a.DB.First(&m, r.ModelID).Error == nil {
|
||||
name = m.Name
|
||||
}
|
||||
out = append(out, gin.H{"model": name, "requests": r.Requests, "tokens": r.Tokens, "cost": r.Cost})
|
||||
}
|
||||
} else {
|
||||
var rows []struct {
|
||||
Date string
|
||||
Requests int64
|
||||
Tokens int64
|
||||
Cost float64
|
||||
}
|
||||
q.Select("date, SUM(requests) requests, SUM(input_tokens+output_tokens+cache_read_tokens) tokens, SUM(cost) cost").
|
||||
Group("date").Order("date").Scan(&rows)
|
||||
for _, r := range rows {
|
||||
out = append(out, gin.H{"date": r.Date, "requests": r.Requests, "tokens": r.Tokens, "cost": r.Cost})
|
||||
}
|
||||
}
|
||||
resp.OK(c, gin.H{"items": out})
|
||||
}
|
||||
|
||||
// UsageLogs GET /api/v1/usage/logs?from&to&page&page_size&model
|
||||
func (h *Handler) UsageLogs(c *gin.Context) {
|
||||
u, _ := userFromContext(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
q := h.a.DB.Model(&store.UsageLog{}).Where("user_id = ?", u.ID)
|
||||
if from := c.Query("from"); from != "" {
|
||||
q = q.Where("created_at >= ?", from+" 00:00:00")
|
||||
}
|
||||
if to := c.Query("to"); to != "" {
|
||||
q = q.Where("created_at <= ?", to+" 23:59:59")
|
||||
}
|
||||
if model := c.Query("model"); model != "" {
|
||||
q = q.Where("model_name = ?", model)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var logs []store.UsageLog
|
||||
q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&logs)
|
||||
out := make([]gin.H, 0, len(logs))
|
||||
for _, l := range logs {
|
||||
out = append(out, gin.H{
|
||||
"id": l.ID, "request_id": l.RequestID, "model": l.ModelName, "protocol": l.Protocol,
|
||||
"input_tokens": l.InputTokens, "output_tokens": l.OutputTokens,
|
||||
"cache_read_tokens": l.CacheReadTokens, "cost": l.Cost,
|
||||
"latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode,
|
||||
"created_at": l.CreatedAt,
|
||||
})
|
||||
}
|
||||
resp.OK(c, gin.H{"items": out, "total": total, "page": page, "page_size": size})
|
||||
}
|
||||
+4
-106
@@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -24,10 +23,10 @@ func (h *Handler) UserBalance(c *gin.Context) {
|
||||
Where("user_id = ? AND status = ? AND created_at >= ?", u.ID, store.UsageStatusSuccess, time.Now().Add(-30*24*time.Hour)).
|
||||
Select("COALESCE(SUM(cost),0)").Scan(&spent)
|
||||
resp.OK(c, gin.H{
|
||||
"balance": u.Balance,
|
||||
"spent_last_30d": spent,
|
||||
"today": h.todayUsage(c, u.ID),
|
||||
"models_available": h.availableModelCount(),
|
||||
"balance": u.Balance,
|
||||
"spent_last_30d": spent,
|
||||
"today": h.todayUsage(c, u.ID),
|
||||
"models_available": h.availableModelCount(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -62,104 +61,3 @@ func (h *Handler) UserModels(c *gin.Context) {
|
||||
}
|
||||
resp.OK(c, gin.H{"items": out})
|
||||
}
|
||||
|
||||
// UsageSummary GET /api/v1/usage/summary — 今日/本月汇总。
|
||||
func (h *Handler) UsageSummary(c *gin.Context) {
|
||||
u, _ := userFromContext(c)
|
||||
now := time.Now().UTC()
|
||||
today := now.Format("2006-01-02")
|
||||
month := now.Format("2006-01")
|
||||
var todayReq, monthReq int64
|
||||
var todayTok, monthTok int64
|
||||
var todayCost, monthCost float64
|
||||
h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date = ?", u.ID, today).
|
||||
Select("COALESCE(SUM(requests),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0), COALESCE(SUM(cost),0)").
|
||||
Row().Scan(&todayReq, &todayTok, &todayCost)
|
||||
h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date LIKE ?", u.ID, month+"%").
|
||||
Select("COALESCE(SUM(requests),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0), COALESCE(SUM(cost),0)").
|
||||
Row().Scan(&monthReq, &monthTok, &monthCost)
|
||||
resp.OK(c, gin.H{
|
||||
"today": gin.H{"requests": todayReq, "tokens": todayTok, "cost": todayCost},
|
||||
"month": gin.H{"requests": monthReq, "tokens": monthTok, "cost": monthCost},
|
||||
})
|
||||
}
|
||||
|
||||
// UsageStats GET /api/v1/usage/stats?from&to&group=day|model
|
||||
func (h *Handler) UsageStats(c *gin.Context) {
|
||||
u, _ := userFromContext(c)
|
||||
from := c.DefaultQuery("from", time.Now().Add(-30*24*time.Hour).Format("2006-01-02"))
|
||||
to := c.DefaultQuery("to", time.Now().Format("2006-01-02"))
|
||||
group := c.DefaultQuery("group", "day")
|
||||
|
||||
q := h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date BETWEEN ? AND ?", u.ID, from, to)
|
||||
out := make([]gin.H, 0, 64)
|
||||
if group == "model" {
|
||||
var rows []struct {
|
||||
ModelID uint64
|
||||
Requests int64
|
||||
Tokens int64
|
||||
Cost float64
|
||||
}
|
||||
q.Select("model_id, SUM(requests) requests, SUM(input_tokens+output_tokens+cache_read_tokens) tokens, SUM(cost) cost").
|
||||
Group("model_id").Scan(&rows)
|
||||
for _, r := range rows {
|
||||
var m store.Model
|
||||
name := strconv.FormatUint(r.ModelID, 10)
|
||||
if h.a.DB.First(&m, r.ModelID).Error == nil {
|
||||
name = m.Name
|
||||
}
|
||||
out = append(out, gin.H{"model": name, "requests": r.Requests, "tokens": r.Tokens, "cost": r.Cost})
|
||||
}
|
||||
} else {
|
||||
var rows []struct {
|
||||
Date string
|
||||
Requests int64
|
||||
Tokens int64
|
||||
Cost float64
|
||||
}
|
||||
q.Select("date, SUM(requests) requests, SUM(input_tokens+output_tokens+cache_read_tokens) tokens, SUM(cost) cost").
|
||||
Group("date").Order("date").Scan(&rows)
|
||||
for _, r := range rows {
|
||||
out = append(out, gin.H{"date": r.Date, "requests": r.Requests, "tokens": r.Tokens, "cost": r.Cost})
|
||||
}
|
||||
}
|
||||
resp.OK(c, gin.H{"items": out})
|
||||
}
|
||||
|
||||
// UsageLogs GET /api/v1/usage/logs?from&to&page&page_size&model
|
||||
func (h *Handler) UsageLogs(c *gin.Context) {
|
||||
u, _ := userFromContext(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
q := h.a.DB.Model(&store.UsageLog{}).Where("user_id = ?", u.ID)
|
||||
if from := c.Query("from"); from != "" {
|
||||
q = q.Where("created_at >= ?", from+" 00:00:00")
|
||||
}
|
||||
if to := c.Query("to"); to != "" {
|
||||
q = q.Where("created_at <= ?", to+" 23:59:59")
|
||||
}
|
||||
if model := c.Query("model"); model != "" {
|
||||
q = q.Where("model_name = ?", model)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var logs []store.UsageLog
|
||||
q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&logs)
|
||||
out := make([]gin.H, 0, len(logs))
|
||||
for _, l := range logs {
|
||||
out = append(out, gin.H{
|
||||
"id": l.ID, "request_id": l.RequestID, "model": l.ModelName, "protocol": l.Protocol,
|
||||
"input_tokens": l.InputTokens, "output_tokens": l.OutputTokens,
|
||||
"cache_read_tokens": l.CacheReadTokens, "cost": l.Cost,
|
||||
"latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode,
|
||||
"created_at": l.CreatedAt,
|
||||
})
|
||||
}
|
||||
resp.OK(c, gin.H{"items": out, "total": total, "page": page, "page_size": size})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user