Compare commits
9
Commits
19232567f2
..
team
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca4dc4b3b7 | ||
|
|
0628d5050f | ||
|
|
a376ac0722 | ||
|
|
9733b3c20b | ||
|
|
d28fca8ee6 | ||
|
|
a2cef00908 | ||
|
|
d41bcdc371 | ||
|
|
8d949eff18 | ||
|
|
c65d497551 |
+1
-1
@@ -9,7 +9,7 @@ web
|
||||
|
||||
# Go 构建产物
|
||||
bin
|
||||
cmd/openteam/dist
|
||||
backend/cmd/openteam/dist
|
||||
|
||||
# 文档与 CI
|
||||
doc
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# ===========================================
|
||||
# OpenCatd-Open 配置文件
|
||||
# 复制此文件为 .env 并修改相应配置
|
||||
# ===========================================
|
||||
|
||||
# --- 服务器配置 ---
|
||||
PORT=80
|
||||
READ_TIMEOUT=10
|
||||
WRITE_TIMEOUT=10
|
||||
|
||||
# --- Passkey (WebAuthn) 配置 ---
|
||||
# 应用名称(显示给用户)
|
||||
APP_NAME=OpenTeam
|
||||
# 依赖方 ID(通常为域名,生产环境需改为实际域名)
|
||||
RPID=localhost
|
||||
# 依赖方来源(前端 URL,逗号分隔)
|
||||
RPORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# --- 数据库配置 ---
|
||||
# 支持: sqlite, mysql, postgres
|
||||
DB_TYPE=sqlite
|
||||
# DSN 连接字符串(SQLite 可留空)
|
||||
DB_DSN=
|
||||
DB_MAX_OPEN_CONNS=10
|
||||
DB_MAX_IDLE_CONNS=5
|
||||
|
||||
# --- Redis 配置(可选,用于分布式 passkey session)---
|
||||
# REDIS_HOST=localhost
|
||||
# REDIS_PORT=6379
|
||||
# REDIS_PASSWORD=
|
||||
# REDIS_DB=0
|
||||
|
||||
# --- 日志配置 ---
|
||||
LOG_LEVEL=info
|
||||
LOG_PATH=./logs/
|
||||
|
||||
# --- 功能开关 ---
|
||||
# 允许注册(false=关闭注册)
|
||||
ALLOW_REGISTER=false
|
||||
# 无限制配额(true=不限制)
|
||||
UNLIMITED_QUOTA=true
|
||||
# 新用户默认激活
|
||||
DEFAULT_ACTIVE=true
|
||||
|
||||
# --- 用量统计 ---
|
||||
USAGE_WORKER=1
|
||||
USAGE_CHAN_SIZE=1000
|
||||
TASK_TIME_INTERVAL=60
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: build run test clean fmt lint frontend dev dev-backend dev-frontend
|
||||
.PHONY: build run test clean fmt lint frontend dev dev-backend dev-frontend docker docker-cn docker-multi
|
||||
|
||||
BINARY_NAME=openteam
|
||||
BUILD_DIR=bin
|
||||
@@ -8,6 +8,10 @@ BACKEND_DIR=backend
|
||||
build: frontend
|
||||
cd $(BACKEND_DIR) && CGO_ENABLED=0 go build -o ../$(BUILD_DIR)/$(BINARY_NAME) ./cmd/openteam
|
||||
|
||||
# Build backend only (frontend dist must exist)
|
||||
build-backend:
|
||||
cd $(BACKEND_DIR) && CGO_ENABLED=0 go build -o ../$(BUILD_DIR)/$(BINARY_NAME) ./cmd/openteam
|
||||
|
||||
# Build frontend and copy dist
|
||||
frontend:
|
||||
cd frontend && pnpm install && pnpm build
|
||||
@@ -74,3 +78,18 @@ migrate:
|
||||
# Seed data (will be implemented)
|
||||
seed:
|
||||
@echo "Seeding will be implemented in future"
|
||||
|
||||
# Docker build (default platform)
|
||||
docker:
|
||||
docker build -f deploy/docker/Dockerfile -t $(BINARY_NAME):latest .
|
||||
|
||||
# Docker build (China mirror accelerated)
|
||||
docker-cn:
|
||||
docker build -f deploy/docker/Dockerfile.cn -t $(BINARY_NAME):latest .
|
||||
|
||||
# Docker build multi-platform (requires: docker buildx)
|
||||
docker-multi:
|
||||
docker buildx build -f deploy/docker/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t $(BINARY_NAME):latest --push .
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
|
||||
@@ -59,10 +60,12 @@ require (
|
||||
github.com/ncruces/go-sqlite3-wasm/v2 v2.1.35300 // indirect
|
||||
github.com/ncruces/julianday v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/redis/go-redis/v9 v9.22.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/arch v0.16.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
|
||||
@@ -7,6 +7,8 @@ github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
@@ -112,6 +114,8 @@ github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQ
|
||||
github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
|
||||
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -141,6 +145,8 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
|
||||
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"net/http"
|
||||
"opencatd-open/internal/dao"
|
||||
"opencatd-open/internal/passkey"
|
||||
"opencatd-open/internal/store"
|
||||
"opencatd-open/internal/pkg/apikey"
|
||||
"opencatd-open/internal/pkg/crypto"
|
||||
@@ -23,9 +24,10 @@ type Handler struct {
|
||||
modelDAO *dao.ModelDAO
|
||||
usageDAO *dao.UsageDAO
|
||||
dailyDAO *dao.DailyUsageDAO
|
||||
passkeys *passkey.Service
|
||||
}
|
||||
|
||||
func NewHandler(db *gorm.DB) *Handler {
|
||||
func NewHandler(db *gorm.DB, passkeys *passkey.Service) *Handler {
|
||||
return &Handler{
|
||||
db: db,
|
||||
userDAO: dao.NewUserDAO(db),
|
||||
@@ -34,6 +36,7 @@ func NewHandler(db *gorm.DB) *Handler {
|
||||
modelDAO: dao.NewModelDAO(db),
|
||||
usageDAO: dao.NewUsageDAO(db),
|
||||
dailyDAO: dao.NewDailyUsageDAO(db),
|
||||
passkeys: passkeys,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"opencatd-open/internal/auth"
|
||||
"opencatd-open/internal/pkg/jwt"
|
||||
"opencatd-open/internal/store"
|
||||
)
|
||||
|
||||
// PasskeyRegisterBegin POST /api/webauthn/register/begin — 生成注册选项。
|
||||
func (h *Handler) PasskeyRegisterBegin(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
u, err := h.passkeys.GetUserByID(userID.(uint64))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
creation, err := h.passkeys.BeginRegistration(u)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin registration: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"creation": creation, "challenge": creation.Response.Challenge}})
|
||||
}
|
||||
|
||||
// PasskeyRegisterComplete POST /api/webauthn/register/complete — 校验并保存凭据。
|
||||
func (h *Handler) PasskeyRegisterComplete(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
u, err := h.passkeys.GetUserByID(userID.(uint64))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
|
||||
return
|
||||
}
|
||||
if err := h.passkeys.FinishRegistration(u, req.Challenge, req.Credential, req.Name); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "passkey 注册失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
|
||||
}
|
||||
|
||||
// PasskeyLoginBegin POST /api/auth/passkey/begin — 生成断言选项。
|
||||
// 传 username 用指定用户;不传则用可发现凭据(平台 passkey)。
|
||||
func (h *Handler) PasskeyLoginBegin(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if req.Username != "" {
|
||||
u, err := h.passkeys.GetUserByUsername(req.Username)
|
||||
if err != nil || u.Status != store.UserStatusActive {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
assertion, err := h.passkeys.BeginLogin(u)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin login: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge, "user_id": u.ID}})
|
||||
return
|
||||
}
|
||||
assertion, err := h.passkeys.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin login: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge}})
|
||||
}
|
||||
|
||||
// PasskeyLoginComplete POST /api/auth/passkey/finish — 校验断言并发放令牌。
|
||||
func (h *Handler) PasskeyLoginComplete(c *gin.Context) {
|
||||
var req struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
|
||||
return
|
||||
}
|
||||
var u *store.User
|
||||
if req.UserID > 0 {
|
||||
var err error
|
||||
u, err = h.passkeys.GetUserByID(req.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if err := h.passkeys.FinishLogin(u, req.Challenge, req.Credential); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "passkey 校验失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
u, err = h.passkeys.FinishDiscoverableLogin(req.Challenge, req.Credential)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "passkey 校验失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if u.Status != store.UserStatusActive {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "user account disabled"})
|
||||
return
|
||||
}
|
||||
secret := auth.GetSecretKey()
|
||||
accessToken, refreshToken, err := jwt.GenerateTokenPair(u.ID, u.Username, u.Role, secret, 24*time.Hour, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to issue token"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"token": accessToken,
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// PasskeyList GET /api/profile/passkeys — 当前用户的 passkey 列表。
|
||||
func (h *Handler) PasskeyList(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
pks, err := h.passkeys.List(userID.(uint64))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load passkeys"})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(pks))
|
||||
for _, pk := range pks {
|
||||
out = append(out, gin.H{"id": pk.ID, "name": pk.Name, "created_at": pk.CreatedAt})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||
}
|
||||
|
||||
// PasskeyDelete DELETE /api/profile/passkeys/:id — 解除绑定。
|
||||
func (h *Handler) PasskeyDelete(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid passkey id"})
|
||||
return
|
||||
}
|
||||
if err := h.passkeys.Delete(userID.(uint64), id); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "passkey not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -77,6 +79,130 @@ func (h *Handler) MyUsageStats(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// MyUsageMonthly GET /api/usage/monthly?year=2026 — 当前用户年度按自然月聚合,
|
||||
// 每月含按模型分解(供月度堆叠柱状图使用)。
|
||||
func (h *Handler) MyUsageMonthly(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
uid, _ := userID.(uint64)
|
||||
|
||||
year := time.Now().Year()
|
||||
if y := c.Query("year"); y != "" {
|
||||
if n, err := strconv.Atoi(y); err == nil && n >= 2000 && n <= 2100 {
|
||||
year = n
|
||||
}
|
||||
}
|
||||
start := time.Date(year, 1, 1, 0, 0, 0, 0, time.Local)
|
||||
end := start.AddDate(1, 0, -1)
|
||||
|
||||
dailies, err := h.dailyDAO.ListByDateRange(c.Request.Context(), uid, start, end)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage"})
|
||||
return
|
||||
}
|
||||
|
||||
// 补齐模型名(模型可能已被删除,回退为 模型#id)
|
||||
modelIDs := make([]uint64, 0, len(dailies))
|
||||
seen := map[uint64]bool{}
|
||||
for _, d := range dailies {
|
||||
if !seen[d.ModelID] {
|
||||
seen[d.ModelID] = true
|
||||
modelIDs = append(modelIDs, d.ModelID)
|
||||
}
|
||||
}
|
||||
modelNames := map[uint64]string{}
|
||||
if len(modelIDs) > 0 {
|
||||
var models []store.Model
|
||||
if err := h.db.Where("id IN ?", modelIDs).Find(&models).Error; err == nil {
|
||||
for _, m := range models {
|
||||
modelNames[m.ID] = m.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type modelAgg struct {
|
||||
ModelID uint64 `json:"model_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
Requests int64 `json:"requests"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
Cost float64 `json:"cost"`
|
||||
}
|
||||
type monthAgg struct {
|
||||
Month string `json:"month"`
|
||||
Requests int64 `json:"requests"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
Cost float64 `json:"cost"`
|
||||
Models map[uint64]*modelAgg `json:"-"`
|
||||
}
|
||||
|
||||
months := make([]*monthAgg, 12)
|
||||
for i := range months {
|
||||
months[i] = &monthAgg{
|
||||
Month: fmt.Sprintf("%d-%02d", year, i+1),
|
||||
Models: map[uint64]*modelAgg{},
|
||||
}
|
||||
}
|
||||
for _, d := range dailies {
|
||||
mm, err := strconv.Atoi(d.Date[5:7])
|
||||
if err != nil || mm < 1 || mm > 12 {
|
||||
continue
|
||||
}
|
||||
m := months[mm-1]
|
||||
m.Requests += d.Requests
|
||||
m.InputTokens += d.InputTokens
|
||||
m.OutputTokens += d.OutputTokens
|
||||
m.CacheReadTokens += d.CacheReadTokens
|
||||
m.Cost += d.Cost
|
||||
ma, ok := m.Models[d.ModelID]
|
||||
if !ok {
|
||||
name := modelNames[d.ModelID]
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("模型#%d", d.ModelID)
|
||||
}
|
||||
ma = &modelAgg{ModelID: d.ModelID, ModelName: name}
|
||||
m.Models[d.ModelID] = ma
|
||||
}
|
||||
ma.Requests += d.Requests
|
||||
ma.InputTokens += d.InputTokens
|
||||
ma.OutputTokens += d.OutputTokens
|
||||
ma.CacheReadTokens += d.CacheReadTokens
|
||||
ma.Cost += d.Cost
|
||||
}
|
||||
|
||||
out := make([]gin.H, 12)
|
||||
for i, m := range months {
|
||||
modelList := make([]*modelAgg, 0, len(m.Models))
|
||||
for _, ma := range m.Models {
|
||||
modelList = append(modelList, ma)
|
||||
}
|
||||
// 模型按 token 总量降序,柱状图图例顺序与之一致
|
||||
sort.Slice(modelList, func(a, b int) bool {
|
||||
ta := modelList[a].InputTokens + modelList[a].OutputTokens + modelList[a].CacheReadTokens
|
||||
tb := modelList[b].InputTokens + modelList[b].OutputTokens + modelList[b].CacheReadTokens
|
||||
return ta > tb
|
||||
})
|
||||
out[i] = gin.H{
|
||||
"month": m.Month,
|
||||
"requests": m.Requests,
|
||||
"input_tokens": m.InputTokens,
|
||||
"output_tokens": m.OutputTokens,
|
||||
"cache_read_tokens": m.CacheReadTokens,
|
||||
"cost": m.Cost,
|
||||
"models": modelList,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"year": year,
|
||||
"months": out,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// MyUsageLogs GET /api/usage/logs?page=1&pageSize=20 — 当前用户的用量明细(分页)。
|
||||
func (h *Handler) MyUsageLogs(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
|
||||
@@ -8,23 +8,21 @@ import (
|
||||
)
|
||||
|
||||
type Api struct {
|
||||
cfg *config.Config
|
||||
db *gorm.DB
|
||||
userService *service.UserServiceImpl
|
||||
tokenService *service.TokenServiceImpl
|
||||
keyService *service.ApiKeyServiceImpl
|
||||
webAuthService *service.WebAuthnService
|
||||
usageService *service.UsageService
|
||||
cfg *config.Config
|
||||
db *gorm.DB
|
||||
userService *service.UserServiceImpl
|
||||
tokenService *service.TokenServiceImpl
|
||||
keyService *service.ApiKeyServiceImpl
|
||||
usageService *service.UsageService
|
||||
}
|
||||
|
||||
func NewApi(cfg *config.Config, db *gorm.DB, userService *service.UserServiceImpl, tokenService *service.TokenServiceImpl, keyService *service.ApiKeyServiceImpl, webAuthService *service.WebAuthnService, usageService *service.UsageService) *Api {
|
||||
func NewApi(cfg *config.Config, db *gorm.DB, userService *service.UserServiceImpl, tokenService *service.TokenServiceImpl, keyService *service.ApiKeyServiceImpl, usageService *service.UsageService) *Api {
|
||||
return &Api{
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
userService: userService,
|
||||
tokenService: tokenService,
|
||||
keyService: keyService,
|
||||
webAuthService: webAuthService,
|
||||
usageService: usageService,
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
userService: userService,
|
||||
tokenService: tokenService,
|
||||
keyService: keyService,
|
||||
usageService: usageService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package dto
|
||||
|
||||
type Passkey struct {
|
||||
ID int64 `json:"id" gorm:"column:id;primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"column:name"` // 凭证名称,用于用户识别不同的设备
|
||||
SignCount uint32 `json:"sign_count" gorm:"column:sign_count"` // 签名计数器,用于防止重放攻击
|
||||
DeviceType string `json:"device_type" gorm:"column:device_type"` // 设备类型,如"platform"或"cross-platform"
|
||||
LastUsedAt int64 `json:"last_used_at" gorm:"column:last_used_at"` // 最后使用时间
|
||||
CreatedAt int64 `json:"created_at,omitempty" gorm:"autoCreateTime"`
|
||||
UpdatedAt int64 `json:"updated_at,omitempty" gorm:"autoUpdateTime"`
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Package passkey 封装 WebAuthn(passkey)注册与登录。
|
||||
package passkey
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"opencatd-open/internal/store"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionPrefix = "passkey:session:"
|
||||
sessionTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
RPID string
|
||||
Origins []string
|
||||
Name string
|
||||
Redis *redis.Client // 可选,nil 时使用内存存储
|
||||
}
|
||||
|
||||
// SessionStore challenge 会话存储接口
|
||||
type SessionStore interface {
|
||||
Set(ctx context.Context, session *webauthn.SessionData) error
|
||||
Get(ctx context.Context, challenge string) (*webauthn.SessionData, bool, error)
|
||||
Delete(ctx context.Context, challenge string) error
|
||||
}
|
||||
|
||||
// memoryStore 内存存储(单实例)
|
||||
type memoryStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]webauthn.SessionData
|
||||
}
|
||||
|
||||
func newMemoryStore() *memoryStore {
|
||||
return &memoryStore{sessions: make(map[string]webauthn.SessionData)}
|
||||
}
|
||||
|
||||
func (m *memoryStore) Set(_ context.Context, session *webauthn.SessionData) error {
|
||||
m.mu.Lock()
|
||||
m.sessions[session.Challenge] = *session
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Get(_ context.Context, challenge string) (*webauthn.SessionData, bool, error) {
|
||||
m.mu.Lock()
|
||||
sess, ok := m.sessions[challenge]
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
// 检查过期
|
||||
if !sess.Expires.IsZero() && time.Now().After(sess.Expires) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return &sess, true, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Delete(_ context.Context, challenge string) error {
|
||||
m.mu.Lock()
|
||||
delete(m.sessions, challenge)
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// redisStore Redis 存储(分布式)
|
||||
type redisStore struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func newRedisStore(rdb *redis.Client) *redisStore {
|
||||
return &redisStore{rdb: rdb}
|
||||
}
|
||||
|
||||
func (r *redisStore) Set(ctx context.Context, session *webauthn.SessionData) error {
|
||||
data, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal session: %w", err)
|
||||
}
|
||||
key := sessionPrefix + session.Challenge
|
||||
return r.rdb.Set(ctx, key, data, sessionTTL).Err()
|
||||
}
|
||||
|
||||
func (r *redisStore) Get(ctx context.Context, challenge string) (*webauthn.SessionData, bool, error) {
|
||||
key := sessionPrefix + challenge
|
||||
data, err := r.rdb.Get(ctx, key).Bytes()
|
||||
if err == redis.Nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("redis get: %w", err)
|
||||
}
|
||||
var sess webauthn.SessionData
|
||||
if err := json.Unmarshal(data, &sess); err != nil {
|
||||
return nil, false, fmt.Errorf("unmarshal session: %w", err)
|
||||
}
|
||||
return &sess, true, nil
|
||||
}
|
||||
|
||||
func (r *redisStore) Delete(ctx context.Context, challenge string) error {
|
||||
key := sessionPrefix + challenge
|
||||
return r.rdb.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// Service WebAuthn 服务:凭据存储 + challenge 会话。
|
||||
type Service struct {
|
||||
wa *webauthn.WebAuthn
|
||||
db *gorm.DB
|
||||
sessions SessionStore
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, cfg Config) (*Service, error) {
|
||||
wa, err := webauthn.New(&webauthn.Config{
|
||||
RPDisplayName: cfg.Name,
|
||||
RPID: cfg.RPID,
|
||||
RPOrigins: cfg.Origins,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 根据配置选择存储后端
|
||||
var store SessionStore
|
||||
if cfg.Redis != nil {
|
||||
store = newRedisStore(cfg.Redis)
|
||||
} else {
|
||||
store = newMemoryStore()
|
||||
}
|
||||
|
||||
return &Service{wa: wa, db: db, sessions: store}, nil
|
||||
}
|
||||
|
||||
// webUser 实现 go-webauthn 的 User 接口。
|
||||
type webUser struct {
|
||||
id uint64
|
||||
name string
|
||||
displayName string
|
||||
credentials []webauthn.Credential
|
||||
}
|
||||
|
||||
func (u *webUser) WebAuthnID() []byte { return []byte(strconv.FormatUint(u.id, 10)) }
|
||||
func (u *webUser) WebAuthnName() string { return u.name }
|
||||
func (u *webUser) WebAuthnDisplayName() string { return u.displayName }
|
||||
func (u *webUser) WebAuthnIcon() string { return "" }
|
||||
func (u *webUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
|
||||
|
||||
func (s *Service) loadWebUser(u *store.User) (*webUser, error) {
|
||||
var pks []store.Passkey
|
||||
s.db.Where("user_id = ?", u.ID).Find(&pks)
|
||||
creds := make([]webauthn.Credential, 0, len(pks))
|
||||
for _, pk := range pks {
|
||||
var c webauthn.Credential
|
||||
if err := json.Unmarshal(pk.Credential, &c); err == nil {
|
||||
creds = append(creds, c)
|
||||
}
|
||||
}
|
||||
return &webUser{id: u.ID, name: u.Username, displayName: u.Username, credentials: creds}, nil
|
||||
}
|
||||
|
||||
// GetUserByUsername 通过用户名或邮箱查找用户
|
||||
func (s *Service) GetUserByUsername(username string) (*store.User, error) {
|
||||
var u store.User
|
||||
if err := s.db.Where("username = ? OR email = ?", username, username).First(&u).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// GetUserByID 通过 ID 查找用户
|
||||
func (s *Service) GetUserByID(id uint64) (*store.User, error) {
|
||||
var u store.User
|
||||
if err := s.db.First(&u, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 注册
|
||||
|
||||
// BeginRegistration 生成注册选项并暂存 challenge。
|
||||
func (s *Service) BeginRegistration(u *store.User) (*protocol.CredentialCreation, error) {
|
||||
wu, err := s.loadWebUser(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
creation, session, err := s.wa.BeginRegistration(wu)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.sessions.Set(context.Background(), session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return creation, nil
|
||||
}
|
||||
|
||||
// FinishRegistration 校验浏览器返回的凭据并落库。
|
||||
func (s *Service) FinishRegistration(u *store.User, challenge string, body []byte, name string) error {
|
||||
session, ok, err := s.sessions.Get(context.Background(), challenge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("challenge 已过期或不存在")
|
||||
}
|
||||
// 删除已使用的 challenge
|
||||
_ = s.sessions.Delete(context.Background(), challenge)
|
||||
|
||||
wu, err := s.loadWebUser(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||
cred, err := s.wa.FinishRegistration(wu, *session, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, _ := json.Marshal(cred)
|
||||
nm := name
|
||||
if nm == "" {
|
||||
nm = "passkey"
|
||||
}
|
||||
return s.db.Create(&store.Passkey{
|
||||
UserID: u.ID, Name: nm, CredentialID: cred.ID, Credential: raw,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 登录
|
||||
|
||||
// BeginLogin 已知用户(按用户名)发起断言。
|
||||
func (s *Service) BeginLogin(u *store.User) (*protocol.CredentialAssertion, error) {
|
||||
wu, err := s.loadWebUser(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assertion, session, err := s.wa.BeginLogin(wu)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.sessions.Set(context.Background(), session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return assertion, nil
|
||||
}
|
||||
|
||||
// BeginDiscoverableLogin 无用户名(使用平台/漫游器上的可发现凭据)。
|
||||
func (s *Service) BeginDiscoverableLogin() (*protocol.CredentialAssertion, error) {
|
||||
assertion, session, err := s.wa.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.sessions.Set(context.Background(), session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return assertion, nil
|
||||
}
|
||||
|
||||
// FinishLogin 校验断言并更新签名计数。
|
||||
func (s *Service) FinishLogin(u *store.User, challenge string, body []byte) error {
|
||||
session, ok, err := s.sessions.Get(context.Background(), challenge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("challenge 已过期或不存在")
|
||||
}
|
||||
_ = s.sessions.Delete(context.Background(), challenge)
|
||||
|
||||
wu, err := s.loadWebUser(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||
cred, err := s.wa.FinishLogin(wu, *session, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.updateCredential(u.ID, cred)
|
||||
}
|
||||
|
||||
// FinishDiscoverableLogin 通过凭据定位用户并校验断言。
|
||||
func (s *Service) FinishDiscoverableLogin(challenge string, body []byte) (*store.User, error) {
|
||||
session, ok, err := s.sessions.Get(context.Background(), challenge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, errors.New("challenge 已过期或不存在")
|
||||
}
|
||||
_ = s.sessions.Delete(context.Background(), challenge)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||
|
||||
// 先通过 credential_id 反查用户
|
||||
var pks []store.Passkey
|
||||
if err := s.db.Find(&pks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 遍历所有 passkey 找到匹配的
|
||||
for _, pk := range pks {
|
||||
var c webauthn.Credential
|
||||
if err := json.Unmarshal(pk.Credential, &c); err != nil {
|
||||
continue
|
||||
}
|
||||
// 尝试用这个用户的凭据进行登录
|
||||
var u store.User
|
||||
if err := s.db.First(&u, pk.UserID).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
wu, err := s.loadWebUser(&u)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cred, err := s.wa.FinishLogin(wu, *session, req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = s.updateCredential(u.ID, cred)
|
||||
return &u, nil
|
||||
}
|
||||
return nil, errors.New("no matching passkey found")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 管理
|
||||
|
||||
// List 列出用户的 passkey。
|
||||
func (s *Service) List(userID uint64) ([]store.Passkey, error) {
|
||||
var pks []store.Passkey
|
||||
err := s.db.Where("user_id = ?", userID).Order("id DESC").Find(&pks).Error
|
||||
return pks, err
|
||||
}
|
||||
|
||||
// Delete 删除用户的 passkey。
|
||||
func (s *Service) Delete(userID, id uint64) error {
|
||||
res := s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&store.Passkey{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) updateCredential(userID uint64, cred *webauthn.Credential) error {
|
||||
raw, _ := json.Marshal(cred)
|
||||
return s.db.Model(&store.Passkey{}).
|
||||
Where("user_id = ? AND credential_id = ?", userID, cred.ID).
|
||||
Update("credential", raw).Error
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"opencatd-open/internal/proxy/convert"
|
||||
)
|
||||
|
||||
// cacheWriteInputMultiplier 缓存写(cache creation)相对输入价的倍数。
|
||||
// Anthropic 官方口径:缓存写按基础输入价的 1.25 倍计费(5m TTL);OpenAI 系无缓存写概念。
|
||||
const cacheWriteInputMultiplier = 1.25
|
||||
|
||||
// ComputeCost 按上游协议的 token 语义计算一次请求的费用(USD)。
|
||||
// 价格均为每百万 token 的 USD 单价。tok 的 token 语义由解析它的上游协议决定:
|
||||
// - chat / responses(OpenAI 系):prompt_tokens 包含缓存读,
|
||||
// 非缓存输入 = input − cacheRead;该协议没有缓存写,cacheCreation 恒为 0。
|
||||
// - messages(Anthropic):input_tokens 不含缓存读/写(三个字段相互独立),
|
||||
// 非缓存输入 = input 原值,不得再扣减;缓存写按输入价 ×1.25。
|
||||
func ComputeCost(upstreamProto string, input, output, cacheRead, cacheCreation int, inputPrice, outputPrice, cacheReadPrice float64) float64 {
|
||||
uncached := input
|
||||
if upstreamProto != convert.ProtoMessages {
|
||||
uncached -= cacheRead
|
||||
if uncached < 0 {
|
||||
uncached = 0
|
||||
}
|
||||
}
|
||||
return (float64(uncached)*inputPrice +
|
||||
float64(cacheRead)*cacheReadPrice +
|
||||
float64(cacheCreation)*inputPrice*cacheWriteInputMultiplier +
|
||||
float64(output)*outputPrice) / 1e6
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package proxy
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestComputeCost(t *testing.T) {
|
||||
// 标准三价:输入 0.5 / 输出 1.5 / 缓存读 0.05($/M)
|
||||
const (
|
||||
inPrice = 0.5
|
||||
outPrice = 1.5
|
||||
cchPrice = 0.05
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
upstreamProto string
|
||||
input int
|
||||
output int
|
||||
cacheRead int
|
||||
cacheCreation int
|
||||
inP float64
|
||||
outP float64
|
||||
cchP float64
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
// OpenAI:prompt 含缓存读,需扣减:(11000−10000)×0.5 + 10000×0.05 + 500×1.5
|
||||
name: "openai prompt includes cache read",
|
||||
upstreamProto: "chat",
|
||||
input: 11000, output: 500, cacheRead: 10000,
|
||||
inP: inPrice, outP: outPrice, cchP: cchPrice,
|
||||
want: (1000*inPrice + 10000*cchPrice + 500*outPrice) / 1e6,
|
||||
},
|
||||
{
|
||||
// Anthropic:input 不含缓存;缓存写按输入价 ×1.25
|
||||
name: "anthropic cache write at 1.25x input price",
|
||||
upstreamProto: "messages",
|
||||
input: 1000, output: 500, cacheRead: 10000, cacheCreation: 2000,
|
||||
inP: inPrice, outP: outPrice, cchP: cchPrice,
|
||||
want: (1000*inPrice + 10000*cchPrice + 2000*inPrice*1.25 + 500*outPrice) / 1e6,
|
||||
},
|
||||
{
|
||||
// Anthropic 口径不得扣减缓存读(否则这里非缓存输入会算成负数)
|
||||
name: "anthropic does not subtract cache read",
|
||||
upstreamProto: "messages",
|
||||
input: 100, output: 0, cacheRead: 1000,
|
||||
inP: inPrice, outP: outPrice, cchP: cchPrice,
|
||||
want: (100*inPrice + 1000*cchPrice) / 1e6,
|
||||
},
|
||||
{
|
||||
// OpenAI 异常数据:cached > prompt 时非缓存输入钳制为 0,不出现负费用
|
||||
name: "openai clamps negative uncached input",
|
||||
upstreamProto: "responses",
|
||||
input: 100, output: 0, cacheRead: 5000,
|
||||
inP: inPrice, outP: outPrice, cchP: cchPrice,
|
||||
want: (5000 * cchPrice) / 1e6,
|
||||
},
|
||||
{
|
||||
// 未配置价格时费用为 0
|
||||
name: "no prices no cost",
|
||||
upstreamProto: "messages",
|
||||
input: 1000, output: 1000, cacheRead: 1000, cacheCreation: 1000,
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ComputeCost(tt.upstreamProto, tt.input, tt.output, tt.cacheRead, tt.cacheCreation, tt.inP, tt.outP, tt.cchP)
|
||||
if diff := got - tt.want; diff > 1e-12 || diff < -1e-12 {
|
||||
t.Fatalf("ComputeCost(%q, in=%d, out=%d, cr=%d, cw=%d) = %v, want %v",
|
||||
tt.upstreamProto, tt.input, tt.output, tt.cacheRead, tt.cacheCreation, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -204,7 +204,7 @@ func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
|
||||
g.writeError(c, http.StatusServiceUnavailable, "no enabled channels for model: "+req.Model)
|
||||
g.recordUsage(req, nil, nil, usage.Event{
|
||||
IsError: true, ErrorCode: "no_channel",
|
||||
}, convert.TokenUsage{})
|
||||
}, convert.TokenUsage{}, "")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
|
||||
IsError: true,
|
||||
ErrorCode: "upstream_error",
|
||||
LatencyMS: int(time.Since(start).Milliseconds()),
|
||||
}, convert.TokenUsage{})
|
||||
}, convert.TokenUsage{}, targetFormat)
|
||||
continue // 可重试:换下一个渠道
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
|
||||
IsError: true,
|
||||
ErrorCode: fmt.Sprintf("upstream_%d", resp.StatusCode),
|
||||
LatencyMS: int(time.Since(start).Milliseconds()),
|
||||
}, convert.TokenUsage{})
|
||||
}, convert.TokenUsage{}, targetFormat)
|
||||
// 429/5xx 可换渠道重试;4xx 直接透传
|
||||
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
|
||||
lastErrStatus, lastErrBody = resp.StatusCode, string(body)
|
||||
@@ -318,13 +318,13 @@ func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
|
||||
IsError: true,
|
||||
ErrorCode: errCode,
|
||||
LatencyMS: int(time.Since(start).Milliseconds()),
|
||||
}, tok)
|
||||
}, tok, targetFormat)
|
||||
return
|
||||
}
|
||||
// 成功记录:用量 + 定价计费。
|
||||
g.recordUsage(req, cand, ch, usage.Event{
|
||||
LatencyMS: int(time.Since(start).Milliseconds()),
|
||||
}, tok)
|
||||
}, tok, targetFormat)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -349,9 +349,10 @@ func rewriteModel(body []byte, upstreamModel string) []byte {
|
||||
return out
|
||||
}
|
||||
|
||||
// recordUsage 汇总一次请求的用量事件并异步落库。tok 为从上游响应提取的用量。
|
||||
// recordUsage 汇总一次请求的用量事件并异步落库。tok 为从上游响应提取的用量,
|
||||
// 其 token 语义由 upstreamProto(渠道实际使用的上游协议)决定。
|
||||
// cand/ch 可为 nil(无可用渠道的失败场景)。
|
||||
func (g *Gateway) recordUsage(req *Request, cand *channel.Candidate, ch *store.Channel, ev usage.Event, tok convert.TokenUsage) {
|
||||
func (g *Gateway) recordUsage(req *Request, cand *channel.Candidate, ch *store.Channel, ev usage.Event, tok convert.TokenUsage, upstreamProto string) {
|
||||
if g.usageRec == nil {
|
||||
return
|
||||
}
|
||||
@@ -380,7 +381,8 @@ func (g *Gateway) recordUsage(req *Request, cand *channel.Candidate, ch *store.C
|
||||
}
|
||||
}
|
||||
// 定价与成本(价格按每百万 token 的 USD 单价)。
|
||||
// 成本口径:非缓存输入 × 输入价 + 缓存读 × 缓存价 + 缓存写与输出 × 输出价。
|
||||
// 成本口径按上游协议区分(详见 ComputeCost):OpenAI 系 prompt 含缓存读需扣减;
|
||||
// Anthropic 的 input_tokens 不含缓存,缓存写按输入价 ×1.25。
|
||||
if ev.ModelID != 0 {
|
||||
if m, err := g.modelDAO.GetByID(ev.ModelID); err == nil {
|
||||
ev.InputPrice = m.InputPrice
|
||||
@@ -389,10 +391,8 @@ func (g *Gateway) recordUsage(req *Request, cand *channel.Candidate, ch *store.C
|
||||
}
|
||||
}
|
||||
if !ev.IsError {
|
||||
ev.Cost = (float64(ev.PromptTokens-ev.CacheReadTokens)*ev.InputPrice +
|
||||
float64(ev.CacheReadTokens)*ev.CacheReadPrice +
|
||||
float64(ev.CacheCreationTokens)*ev.OutputPrice +
|
||||
float64(ev.CompletionTokens)*ev.OutputPrice) / 1e6
|
||||
ev.Cost = ComputeCost(upstreamProto, tok.InputTokens, tok.OutputTokens, tok.CacheReadTokens, tok.CacheCreationTokens,
|
||||
ev.InputPrice, ev.OutputPrice, ev.CacheReadPrice)
|
||||
}
|
||||
g.usageRec.Record(ev)
|
||||
}
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"opencatd-open/internal/store"
|
||||
"opencatd-open/pkg/config"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WebAuthnUser struct {
|
||||
User *store.User
|
||||
Credentials []webauthn.Credential
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) WebAuthnID() []byte {
|
||||
return []byte(strconv.FormatUint(u.User.ID, 10))
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) WebAuthnName() string {
|
||||
return u.User.Username
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) WebAuthnDisplayName() string {
|
||||
return u.User.Username
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
|
||||
return u.Credentials
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) WebAuthnCredentialDescriptors() (descriptors []protocol.CredentialDescriptor) {
|
||||
credentials := u.WebAuthnCredentials()
|
||||
descriptors = make([]protocol.CredentialDescriptor, len(credentials))
|
||||
for i, credential := range credentials {
|
||||
descriptors[i] = credential.Descriptor()
|
||||
}
|
||||
return descriptors
|
||||
}
|
||||
|
||||
type WebAuthnService struct {
|
||||
cfg *config.Config
|
||||
DB *gorm.DB
|
||||
WebAuthn *webauthn.WebAuthn
|
||||
}
|
||||
|
||||
func NewWebAuthnService(cfg *config.Config, db *gorm.DB) (*WebAuthnService, error) {
|
||||
wconfig := &webauthn.Config{
|
||||
RPDisplayName: cfg.AppName,
|
||||
RPID: cfg.RPID,
|
||||
RPOrigins: cfg.RPOrigins,
|
||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||
RequireResidentKey: protocol.ResidentKeyRequired(),
|
||||
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
||||
UserVerification: protocol.VerificationPreferred,
|
||||
},
|
||||
}
|
||||
|
||||
wa, err := webauthn.New(wconfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &WebAuthnService{
|
||||
cfg: cfg,
|
||||
DB: db,
|
||||
WebAuthn: wa,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) GetUserWithCredentials(userID uint64) (*WebAuthnUser, error) {
|
||||
var user store.User
|
||||
if err := s.DB.First(&user, userID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var passkeys []store.Passkey
|
||||
if err := s.DB.Where("user_id = ?", userID).Find(&passkeys).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
credentials := make([]webauthn.Credential, len(passkeys))
|
||||
for i, pk := range passkeys {
|
||||
credentialIDBytes, err := base64.StdEncoding.DecodeString(pk.CredentialID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode CredentialID: %w", err)
|
||||
}
|
||||
publicKeyBytes, err := base64.StdEncoding.DecodeString(pk.PublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode PublicKey: %w", err)
|
||||
}
|
||||
aaguidBytes, err := base64.StdEncoding.DecodeString(pk.AAGUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode AAGUID: %w", err)
|
||||
}
|
||||
|
||||
var transport []protocol.AuthenticatorTransport
|
||||
if pk.Transport != "" {
|
||||
transport = []protocol.AuthenticatorTransport{protocol.AuthenticatorTransport(pk.Transport)}
|
||||
}
|
||||
|
||||
credentials[i] = webauthn.Credential{
|
||||
ID: credentialIDBytes,
|
||||
PublicKey: publicKeyBytes,
|
||||
AttestationType: pk.AttestationType,
|
||||
Transport: transport,
|
||||
Flags: webauthn.CredentialFlags{
|
||||
UserPresent: true,
|
||||
UserVerified: true,
|
||||
BackupEligible: pk.BackupEligible,
|
||||
BackupState: pk.BackupState,
|
||||
},
|
||||
Authenticator: webauthn.Authenticator{
|
||||
AAGUID: aaguidBytes,
|
||||
SignCount: uint32(pk.SignCount),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &WebAuthnUser{
|
||||
User: &user,
|
||||
Credentials: credentials,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) BeginRegistration(userID uint64) (*protocol.CredentialCreation, error) {
|
||||
user, err := s.GetUserWithCredentials(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
options, _, err := s.WebAuthn.BeginRegistration(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) FinishRegistration(userID uint64, response *http.Request, deviceName string) (*store.Passkey, error) {
|
||||
user, err := s.GetUserWithCredentials(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
credential, err := s.WebAuthn.FinishRegistration(user, webauthn.SessionData{}, response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var transport string
|
||||
if len(credential.Transport) > 0 {
|
||||
transport = string(credential.Transport[0])
|
||||
}
|
||||
|
||||
passkey := &store.Passkey{
|
||||
UserID: userID,
|
||||
CredentialID: base64.StdEncoding.EncodeToString(credential.ID),
|
||||
PublicKey: base64.StdEncoding.EncodeToString(credential.PublicKey),
|
||||
AttestationType: string(credential.AttestationType),
|
||||
AAGUID: base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID),
|
||||
SignCount: uint64(credential.Authenticator.SignCount),
|
||||
Name: deviceName,
|
||||
DeviceType: strings.TrimSpace(fmt.Sprintf("%s", deviceName)),
|
||||
LastUsedAt: time.Now().Unix(),
|
||||
BackupEligible: credential.Flags.BackupEligible,
|
||||
BackupState: credential.Flags.BackupState,
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
if err := s.DB.Create(passkey).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return passkey, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) BeginLogin() (*protocol.CredentialAssertion, error) {
|
||||
options, _, err := s.WebAuthn.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) ListPasskeys(userID uint64) ([]store.Passkey, error) {
|
||||
var passkeys []store.Passkey
|
||||
if err := s.DB.Where("user_id = ?", userID).Find(&passkeys).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return passkeys, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) DeletePasskey(userID uint64, passkeyID uint64) error {
|
||||
return s.DB.Where("id = ? AND user_id = ?", passkeyID, userID).Delete(&store.Passkey{}).Error
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package store
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"opencatd-open/pkg/config"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
@@ -15,11 +17,17 @@ import (
|
||||
var DB *gorm.DB
|
||||
|
||||
func InitDB(cfg *config.Config) (*gorm.DB, error) {
|
||||
var dialector gorm.Dialector
|
||||
var (
|
||||
dialector gorm.Dialector
|
||||
err error
|
||||
)
|
||||
|
||||
switch cfg.DB_Type {
|
||||
case "sqlite":
|
||||
dialector = sqliteDialector(cfg.DSN)
|
||||
dialector, err = sqliteDialector(cfg.DSN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case "postgres":
|
||||
dialector = postgresDialector(cfg.DSN)
|
||||
case "mysql":
|
||||
@@ -48,11 +56,17 @@ func InitDB(cfg *config.Config) (*gorm.DB, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func sqliteDialector(dsn string) gorm.Dialector {
|
||||
func sqliteDialector(dsn string) (gorm.Dialector, error) {
|
||||
if dsn == "" {
|
||||
dsn = "opencatd.db"
|
||||
dsn = "db/openteam.db"
|
||||
}
|
||||
return gormlite.Open(dsn)
|
||||
// sqlite 不会自动创建上级目录,先确保它存在(与 docker-compose 挂载的 /app/db 对应)
|
||||
if dir := filepath.Dir(dsn); dir != "." && dir != string(filepath.Separator) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
return gormlite.Open(dsn), nil
|
||||
}
|
||||
|
||||
func postgresDialector(dsn string) gorm.Dialector {
|
||||
|
||||
@@ -197,16 +197,8 @@ type Passkey struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint64 `gorm:"index;not null" json:"user_id"`
|
||||
Name string `gorm:"size:64" json:"name"`
|
||||
CredentialID string `gorm:"size:255;not null" json:"-"`
|
||||
PublicKey string `gorm:"size:512;not null" json:"-"`
|
||||
AttestationType string `gorm:"size:64" json:"-"`
|
||||
AAGUID string `gorm:"size:64" json:"-"`
|
||||
SignCount uint64 `json:"-"`
|
||||
DeviceType string `gorm:"size:255" json:"device_type,omitempty"`
|
||||
LastUsedAt int64 `json:"last_used_at,omitempty"`
|
||||
BackupEligible bool `json:"-"`
|
||||
BackupState bool `json:"-"`
|
||||
Transport string `gorm:"size:32" json:"-"`
|
||||
CredentialID []byte `gorm:"size:255;not null" json:"-"`
|
||||
Credential []byte `gorm:"type:blob;not null" json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"opencatd-open/internal/api"
|
||||
"opencatd-open/internal/channel"
|
||||
"opencatd-open/internal/dao"
|
||||
"opencatd-open/internal/passkey"
|
||||
"opencatd-open/internal/proxy"
|
||||
"opencatd-open/internal/usage"
|
||||
"opencatd-open/middleware"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -61,8 +63,28 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
|
||||
gateway := proxy.NewGateway(ctx, cfg, db, &wg, userDAO, apiKeyDAO, usageDAO, dailyDAO)
|
||||
gateway.SetChannelService(channelSvc)
|
||||
gateway.SetUsageRecorder(usageRecorder)
|
||||
|
||||
// Initialize passkey service
|
||||
var rdb *redis.Client
|
||||
if cfg.RedisHost != "" {
|
||||
rdb = redis.NewClient(&redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.RedisHost, cfg.RedisPort),
|
||||
Password: cfg.RedisPassword,
|
||||
DB: cfg.RedisDB,
|
||||
})
|
||||
}
|
||||
passkeySvc, err := passkey.New(db, passkey.Config{
|
||||
RPID: cfg.RPID,
|
||||
Origins: cfg.RPOrigins,
|
||||
Name: cfg.AppName,
|
||||
Redis: rdb,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize passkey service: %v", err)
|
||||
}
|
||||
|
||||
// Initialize API handler
|
||||
apiHandler := api.NewHandler(db)
|
||||
apiHandler := api.NewHandler(db, passkeySvc)
|
||||
|
||||
r := gin.Default()
|
||||
r.Use(middleware.CORS())
|
||||
@@ -72,6 +94,8 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
|
||||
{
|
||||
public.POST("/register", apiHandler.Register)
|
||||
public.POST("/login", apiHandler.Login)
|
||||
public.POST("/passkey/begin", apiHandler.PasskeyLoginBegin)
|
||||
public.POST("/passkey/finish", apiHandler.PasskeyLoginComplete)
|
||||
}
|
||||
|
||||
// API routes (authenticated)
|
||||
@@ -83,6 +107,12 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
|
||||
apiGroup.POST("/profile/update", apiHandler.UpdateProfile)
|
||||
apiGroup.POST("/profile/update/password", apiHandler.UpdatePassword)
|
||||
|
||||
// Passkey management
|
||||
apiGroup.POST("/webauthn/register/begin", apiHandler.PasskeyRegisterBegin)
|
||||
apiGroup.POST("/webauthn/register/complete", apiHandler.PasskeyRegisterComplete)
|
||||
apiGroup.GET("/webauthn/passkeys", apiHandler.PasskeyList)
|
||||
apiGroup.DELETE("/webauthn/passkeys/:id", apiHandler.PasskeyDelete)
|
||||
|
||||
// User management (admin)
|
||||
apiGroup.GET("/users", apiHandler.ListUsers)
|
||||
apiGroup.GET("/users/:id", apiHandler.GetUser)
|
||||
@@ -115,6 +145,7 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
|
||||
|
||||
// 用户自身用量统计
|
||||
apiGroup.GET("/usage/stats", apiHandler.MyUsageStats)
|
||||
apiGroup.GET("/usage/monthly", apiHandler.MyUsageMonthly)
|
||||
apiGroup.GET("/usage/logs", apiHandler.MyUsageLogs)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,12 +18,12 @@ ARG TARGETARCH
|
||||
RUN apk --no-cache add make upx
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
COPY --from=frontend /frontend-build/dist /build/cmd/openteam/dist
|
||||
COPY --from=frontend /frontend-build/dist /build/backend/cmd/openteam/dist
|
||||
ENV GO111MODULE=on \
|
||||
CGO_ENABLED=0 \
|
||||
GOOS=$TARGETOS \
|
||||
GOARCH=$TARGETARCH
|
||||
RUN make build
|
||||
RUN make build-backend
|
||||
|
||||
FROM alpine:latest AS runner
|
||||
# 设置alpine 时间为上海时间
|
||||
|
||||
@@ -21,13 +21,13 @@ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
&& apk --no-cache add make upx
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
COPY --from=frontend /frontend-build/dist /build/cmd/openteam/dist
|
||||
COPY --from=frontend /frontend-build/dist /build/backend/cmd/openteam/dist
|
||||
ENV GO111MODULE=on \
|
||||
GOPROXY=https://goproxy.cn,direct \
|
||||
CGO_ENABLED=0 \
|
||||
GOOS=$TARGETOS \
|
||||
GOARCH=$TARGETARCH
|
||||
RUN make build
|
||||
RUN make build-backend
|
||||
|
||||
FROM alpine:latest AS runner
|
||||
# 设置alpine 时间为上海时间
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import router from '@/router'
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
if (import.meta.env.DEV) { // Vite 的方式判断开发环境
|
||||
@@ -49,7 +50,12 @@ service.interceptors.response.use(
|
||||
if (error.response && error.response.status === 401) {
|
||||
const authStore = useAuthStore();
|
||||
authStore.clear();
|
||||
window.location.href = '/login';
|
||||
// 守卫校验期间(尚未进入受保护路由)由守卫负责跳登录;
|
||||
// 这里只处理已登录状态下 token 失效的情况,且不再用 location.href 硬刷新
|
||||
const current = router.currentRoute.value;
|
||||
if (current.matched.some(record => record.meta.requiresAuth)) {
|
||||
router.push({ path: '/login', query: { redirect: current.fullPath } });
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
@@ -141,8 +141,8 @@ const rightIcons = [
|
||||
{ id: 'zhipu', label: 'Zhipu', img: LobeIcon('zhipu'), color: '#4268fa' },
|
||||
{ id: 'qwen', label: 'Qwen', img: LobeIcon('qwen'), color: '#615ced' },
|
||||
{ id: 'deepseek', label: 'DeepSeek', img: LobeIcon('deepseek'), color: '#4d6bfe' },
|
||||
{ id: 'moonshot', label: 'Moonshot', img: LobeIcon('moonshot'), color: '#000' },
|
||||
{ id: 'minimax', label: 'MiniMax', img: LobeIcon('minimax'), color: '#000' },
|
||||
{ id: 'moonshot', label: 'Moonshot', img: LobeIcon('moonshot'), color: '#666' },
|
||||
{ id: 'minimax', label: 'MiniMax', img: LobeIcon('minimax'), color: '#F23F5D' },
|
||||
{ id: 'bedrock', label: 'Bedrock', img: LobeIcon('bedrock'), color: '#ff9900' },
|
||||
{ id: 'azure', label: 'Azure', img: LobeIcon('azure'), color: '#0078d4' },
|
||||
{ id: 'volcengine', label: 'Volcengine', img: LobeIcon('volcengine'), color: '#325ab4' },
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<!-- src/layouts/DashboardLayout.vue -->
|
||||
<template>
|
||||
<div class="min-h-screen bg-base-200">
|
||||
<!-- 用户信息就绪前不渲染后台内容,避免未授权内容闪现 -->
|
||||
<div v-if="!authStore.user" class="flex min-h-screen items-center justify-center bg-base-200">
|
||||
<span class="loading loading-spinner loading-lg text-base-content/30"></span>
|
||||
</div>
|
||||
<div v-else class="min-h-screen bg-base-200">
|
||||
<div class="drawer" :class="{ 'lg:drawer-open': isLargeSidebarOpen }">
|
||||
<input id="ot-drawer" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
|
||||
@@ -10,6 +10,6 @@ const pinia = createPinia()
|
||||
const app = createApp(App)
|
||||
|
||||
app.provide('request', request)
|
||||
app.use(pinia) // 必须先于 router:路由守卫里会用到 auth store
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { routes } from '@/utils/router_menu'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const isAuthenticated = localStorage.getItem('token')
|
||||
// 受保护页面必须先通过服务端校验才渲染:
|
||||
// 本地 token 存在不代表有效(可能已过期/被重置),若只查 localStorage,
|
||||
// 页面会先渲染约 1 秒、等 /profile 返回 401 后才被踢回登录页。
|
||||
router.beforeEach(async (to) => {
|
||||
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
|
||||
if (requiresAuth && !isAuthenticated) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
if (!requiresAuth) return true
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (!authStore.token) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
// 有 token 但还没加载用户信息时,先向服务端确认身份,失败则不得进入
|
||||
if (!authStore.user) {
|
||||
try {
|
||||
await authStore.getProfile()
|
||||
} catch {
|
||||
authStore.clear()
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
}
|
||||
|
||||
// 管理后台仅对 role >= 10 开放
|
||||
if (to.matched.some(record => record.meta.requiresAdmin) && (authStore.user?.role ?? 0) < 10) {
|
||||
return '/dashboard/overview'
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import request from '@/api/client'
|
||||
import type { UsageStatsData, UsageLogItem, AdminUsageSummary } from '@/types'
|
||||
import type { UsageStatsData, UsageLogItem, AdminUsageSummary, MonthlyUsageData } from '@/types'
|
||||
|
||||
export const useUsageStore = defineStore('usage', () => {
|
||||
const loading = ref(false)
|
||||
@@ -11,6 +11,9 @@ export const useUsageStore = defineStore('usage', () => {
|
||||
// 普通用户:每日统计
|
||||
const stats = ref<UsageStatsData | null>(null)
|
||||
|
||||
// 普通用户:年度按月统计(含按模型分解)
|
||||
const monthly = ref<MonthlyUsageData | null>(null)
|
||||
|
||||
// 普通用户:自身明细
|
||||
const myLogs = ref<UsageLogItem[]>([])
|
||||
const myLogsTotal = ref(0)
|
||||
@@ -34,6 +37,20 @@ export const useUsageStore = defineStore('usage', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMonthly(year?: number) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await request.get('/usage/monthly', { params: year ? { year } : {} })
|
||||
monthly.value = res.data?.data ?? null
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || '获取月度统计失败'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMyLogs(pageSize = 20, page = 1) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
@@ -82,12 +99,14 @@ export const useUsageStore = defineStore('usage', () => {
|
||||
loading,
|
||||
error,
|
||||
stats,
|
||||
monthly,
|
||||
myLogs,
|
||||
myLogsTotal,
|
||||
adminLogs,
|
||||
adminLogsTotal,
|
||||
adminSummary,
|
||||
fetchStats,
|
||||
fetchMonthly,
|
||||
fetchMyLogs,
|
||||
fetchAdminLogs,
|
||||
fetchAdminSummary,
|
||||
|
||||
@@ -16,26 +16,21 @@ export const useWebAuthStore = defineStore("webauth", () => {
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const addPasskey = async () => {
|
||||
const addPasskey = async (name?: string) => {
|
||||
error.value = "";
|
||||
loading.value = true;
|
||||
try {
|
||||
// 1. 从后端获取注册选项 (Creation Options)
|
||||
const res = await request.get("/profile/passkey");
|
||||
// console.log("begin:", res.data.data.publicKey);
|
||||
const options = res.data.data.publicKey;
|
||||
const res = await request.post("/webauthn/register/begin", {});
|
||||
const { creation, challenge } = res.data.data;
|
||||
|
||||
// 调用 Web Authentication API 进行注册
|
||||
// const credential = await navigator.credentials.create(options);
|
||||
// console.log("credential:", credential);
|
||||
let attestation;
|
||||
try {
|
||||
// Pass 'undefined' as the second argument if you are not using an AbortSignal
|
||||
attestation = await startRegistration({ optionsJSON: options });
|
||||
// console.log("WebAuthn 注册结果 (Attestation):", JSON.stringify(attestation));
|
||||
attestation = await startRegistration({ optionsJSON: creation });
|
||||
error.value = null;
|
||||
} catch (regError: any) {
|
||||
// console.log("WebAuthn 注册失败或取消:", regError);
|
||||
if (regError.name === "NotAllowedError") {
|
||||
error.value = "Passkey 操作被取消或不允许。";
|
||||
} else {
|
||||
@@ -45,8 +40,11 @@ export const useWebAuthStore = defineStore("webauth", () => {
|
||||
}
|
||||
|
||||
// 3. 将注册结果 (Attestation) 发送到后端进行验证和保存
|
||||
const res2: AxiosResponse = await request.post("/profile/passkey", attestation);
|
||||
// console.log("end:", res2);
|
||||
const res2: AxiosResponse = await request.post("/webauthn/register/complete", {
|
||||
challenge,
|
||||
name: name || "passkey",
|
||||
credential: attestation,
|
||||
});
|
||||
return res2;
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || "添加 Passkey 失败,请稍后重试。";
|
||||
@@ -56,20 +54,18 @@ export const useWebAuthStore = defineStore("webauth", () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loginPasskey = async () => {
|
||||
const loginPasskey = async (username?: string) => {
|
||||
error.value = null;
|
||||
loading.value = true;
|
||||
try {
|
||||
// 1. 从后端获取登录选项 (Assertion Options)
|
||||
const res = await request.get("/auth/passkey/begin");
|
||||
// console.log("login begin:", res.data);
|
||||
const options = res.data.data.publicKey;
|
||||
const res = await request.post("/auth/passkey/begin", { username });
|
||||
const { assertion, challenge, user_id } = res.data.data;
|
||||
|
||||
// 2. 调用 Web Authentication API 进行认证
|
||||
let assertion;
|
||||
let credential;
|
||||
try {
|
||||
assertion = await startAuthentication({ optionsJSON: options });
|
||||
// console.log("WebAuthn 认证结果 (Assertion):", JSON.stringify(assertion));
|
||||
credential = await startAuthentication({ optionsJSON: assertion });
|
||||
} catch (loginError: any) {
|
||||
if (loginError.name === "NotAllowedError") {
|
||||
error.value = "Passkey 登录被取消或不允许。";
|
||||
@@ -80,8 +76,11 @@ export const useWebAuthStore = defineStore("webauth", () => {
|
||||
}
|
||||
|
||||
// 3. 将认证结果 (Assertion) 发送到后端进行验证并获取 Token
|
||||
const challenge = options.challenge; // 从 begin 接口返回的 options 中获取 challenge
|
||||
const res2: AxiosResponse = await request.post(`/auth/passkey/finish?challenge=${challenge}`, assertion);
|
||||
const res2: AxiosResponse = await request.post("/auth/passkey/finish", {
|
||||
challenge,
|
||||
credential,
|
||||
user_id,
|
||||
});
|
||||
|
||||
// 4. 处理登录成功的响应,通常包含 Token
|
||||
if (res2.status === 200 && !!res2.data.data?.token) {
|
||||
@@ -103,8 +102,7 @@ export const useWebAuthStore = defineStore("webauth", () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await request.get('/profile/passkeys')
|
||||
// console.log('getPasskeys',response.data.data)
|
||||
const response = await request.get('/webauthn/passkeys')
|
||||
passkeys.value = response.data.data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || '获取token列表失败';
|
||||
@@ -118,7 +116,7 @@ export const useWebAuthStore = defineStore("webauth", () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response: AxiosResponse = await request.delete(`/profile/passkeys/${id}`)
|
||||
const response: AxiosResponse = await request.delete(`/webauthn/passkeys/${id}`)
|
||||
return response
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || `删除passkey ${id} 失败`;
|
||||
|
||||
@@ -200,6 +200,33 @@ export interface UsageStatsData {
|
||||
totals: UsageTotals
|
||||
}
|
||||
|
||||
// 月度按模型用量分解(柱状图分色堆叠用)
|
||||
export interface MonthlyModelUsage {
|
||||
model_id: number
|
||||
model_name: string
|
||||
requests: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_read_tokens: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
// 单个自然月的聚合(models 已按 token 总量降序)
|
||||
export interface MonthlyUsage {
|
||||
month: string // "2026-09"
|
||||
requests: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_read_tokens: number
|
||||
cost: number
|
||||
models: MonthlyModelUsage[]
|
||||
}
|
||||
|
||||
export interface MonthlyUsageData {
|
||||
year: number
|
||||
months: MonthlyUsage[]
|
||||
}
|
||||
|
||||
export interface UsageLogItem {
|
||||
id: number
|
||||
request_id?: string
|
||||
|
||||
@@ -20,6 +20,7 @@ declare module 'vue-router' {
|
||||
icon?: Component
|
||||
showInSidebar?: boolean
|
||||
requiresAuth?: boolean
|
||||
requiresAdmin?: boolean
|
||||
open?: boolean
|
||||
badge?: string
|
||||
}
|
||||
@@ -46,7 +47,7 @@ export const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'manager',
|
||||
name: 'Manager',
|
||||
meta: { title: '管理后台' },
|
||||
meta: { title: '管理后台', requiresAdmin: true },
|
||||
redirect: '/dashboard/manager/users',
|
||||
children: [
|
||||
{ path: 'users', name: 'User', component: () => import('@/views/dashboard/User.vue'), meta: { title: '用户管理' } },
|
||||
|
||||
@@ -62,17 +62,21 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { CircleAlert } from '@lucide/vue'
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useWebAuthStore } from '@/stores/webauth';
|
||||
import { useToast } from '@/composables/toast';
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore();
|
||||
const webauthStore = useWebAuthStore();
|
||||
const { setToast } = useToast();
|
||||
|
||||
// 被守卫拦下时带上原始目标,登录成功后回跳
|
||||
const redirectPath = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const loggingIn = ref(false)
|
||||
const user = reactive({
|
||||
@@ -113,7 +117,7 @@ const handleLogin = async () => {
|
||||
localStorage.removeItem('rember');
|
||||
}
|
||||
setToast('Logged in successfully.', 'success');
|
||||
router.push('/dashboard');
|
||||
router.push(redirectPath);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Login error:', err);
|
||||
@@ -130,7 +134,7 @@ const handlePasskeyLogin = async () => {
|
||||
const res = await webauthStore.loginPasskey();
|
||||
if (!!res?.code && res.code === 200) {
|
||||
setToast('Logged in successfully.', 'success');
|
||||
router.push('/dashboard');
|
||||
router.push(redirectPath);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Passkey login error:', err);
|
||||
|
||||
@@ -170,8 +170,6 @@
|
||||
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||
<th class="pl-4">Name</th>
|
||||
<th>Create Time</th>
|
||||
<th>Sign Count</th>
|
||||
<th>Device</th>
|
||||
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -179,8 +177,6 @@
|
||||
<tr v-for="passkey in passkeys" :key="passkey.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||
<td class="pl-4 font-medium">{{ passkey.name }}</td>
|
||||
<td class="tabular-nums text-base-content/70">{{ formatDateTime(passkey.created_at) }}</td>
|
||||
<td class="tabular-nums">{{ passkey.sign_count }}</td>
|
||||
<td class="text-base-content/70">{{ passkey.device_type }}</td>
|
||||
<td class="pr-4 text-right">
|
||||
<button class="btn btn-ghost btn-xs btn-square text-error"
|
||||
@click="confirmRmovePasskey(passkey)" aria-label="Delete passkey">
|
||||
|
||||
@@ -2,55 +2,97 @@
|
||||
<div class="space-y-5">
|
||||
<BreadcrumbHeader />
|
||||
|
||||
<div v-if="store.loading && !store.stats" class="py-16 text-center text-sm text-base-content/50">
|
||||
<div v-if="store.loading && !store.monthly" class="py-16 text-center text-sm text-base-content/50">
|
||||
加载中…
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-5">
|
||||
<!-- 年份切换 + 选中月份概览卡片 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1">
|
||||
<button class="btn btn-ghost btn-square btn-sm" aria-label="上一年" :disabled="year <= 2000" @click="switchYear(-1)">
|
||||
<ChevronLeft class="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
<span class="min-w-16 text-center text-lg font-semibold tabular-nums">{{ year }}</span>
|
||||
<button class="btn btn-ghost btn-square btn-sm" aria-label="下一年" :disabled="year >= currentYear" @click="switchYear(1)">
|
||||
<ChevronRight class="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-xs text-base-content/50">{{ selectedMonthLabel }}用量概览</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||
<p class="text-xs text-base-content/50">请求次数</p>
|
||||
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.requests) }}</p>
|
||||
<p class="text-xs text-base-content/50">消费金额 (USD)</p>
|
||||
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtCost(selectedMonth?.cost) }}</p>
|
||||
</div>
|
||||
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||
<p class="text-xs text-base-content/50">输入 Tokens</p>
|
||||
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.input_tokens) }}</p>
|
||||
<p class="text-xs text-base-content/50">调用次数</p>
|
||||
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(selectedMonth?.requests) }}</p>
|
||||
</div>
|
||||
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||
<p class="text-xs text-base-content/50">输出 Tokens</p>
|
||||
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.output_tokens) }}</p>
|
||||
</div>
|
||||
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||
<p class="text-xs text-base-content/50">缓存 Tokens</p>
|
||||
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.cache_read_tokens) }}</p>
|
||||
</div>
|
||||
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
|
||||
<p class="text-xs text-base-content/50">费用 (USD)</p>
|
||||
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtCost(totals.cost) }}</p>
|
||||
<p class="text-xs text-base-content/50">Token 消耗</p>
|
||||
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(monthTokens(selectedMonth)) }}</p>
|
||||
<p class="mt-0.5 text-xs tabular-nums text-base-content/50">
|
||||
输入 {{ fmtCompact(selectedMonth?.input_tokens) }} · 输出 {{ fmtCompact(selectedMonth?.output_tokens) }} · 缓存 {{
|
||||
fmtCompact(selectedMonth?.cache_read_tokens) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 每日请求量条形图(纯 CSS) -->
|
||||
<!-- 月度汇总图表:三种指标均按模型分色堆叠 -->
|
||||
<div class="card border border-base-300/60 bg-base-100 p-5 shadow-sm">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">每日请求量</h3>
|
||||
<select v-model="days" class="select select-sm border-base-300 bg-base-100" @change="loadStats">
|
||||
<option :value="7">近 7 天</option>
|
||||
<option :value="30">近 30 天</option>
|
||||
<option :value="90">近 90 天</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="barItems.length" class="flex h-40 items-end gap-1">
|
||||
<div v-for="b in barItems" :key="b.date" class="flex flex-1 flex-col items-center gap-1" :title="`${b.date}: ${b.requests} 次`">
|
||||
<div class="w-full rounded-t bg-primary/70 transition-all" :style="{ height: b.height + 'px' }"></div>
|
||||
<span class="text-[9px] leading-none text-base-content/40">{{ b.label }}</span>
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="text-sm font-semibold">月度汇总</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="join">
|
||||
<button v-for="opt in METRICS" :key="opt.key" class="btn btn-xs join-item"
|
||||
:class="metric === opt.key ? 'btn-primary' : 'btn-ghost border-base-300/60'" @click="metric = opt.key">
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="maxMetricValue > 0" class="text-xs text-base-content/40">峰值 {{ fmtMetricValue(maxMetricValue) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="py-10 text-center text-sm text-base-content/50">暂无用量数据</div>
|
||||
|
||||
<div v-if="maxMetricValue > 0" class="flex h-44 items-end gap-1.5 sm:gap-3">
|
||||
<div v-for="(m, i) in months" :key="m.month"
|
||||
class="group flex h-full min-w-0 flex-1 cursor-pointer flex-col items-center justify-end gap-1"
|
||||
:title="barTitle(m)" @click="selectedMonthIndex = i">
|
||||
<!-- 柱顶总量 -->
|
||||
<span class="text-[9px] leading-none tabular-nums text-base-content/40"
|
||||
:class="{ 'font-semibold text-base-content/70': i === selectedMonthIndex }">
|
||||
{{ metricValue(m) > 0 ? fmtMetricValue(metricValue(m)) : '' }}
|
||||
</span>
|
||||
<!-- 堆叠柱体:图例顺序堆叠,用量最大的模型在底部 -->
|
||||
<div class="flex w-full max-w-10 flex-col-reverse overflow-hidden rounded-t transition-opacity"
|
||||
:class="i === selectedMonthIndex ? 'opacity-100 ring-2 ring-primary/60' : 'opacity-80 group-hover:opacity-100'"
|
||||
:style="{ height: barHeightPct(m) }">
|
||||
<div v-for="seg in barSegments(m)" :key="seg.name" class="w-full"
|
||||
:style="{ height: seg.pct + '%', backgroundColor: seg.color }"
|
||||
:title="`${seg.name}: ${fmtMetricValue(seg.value)}(${seg.share}%)`">
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] leading-none tabular-nums"
|
||||
:class="i === selectedMonthIndex ? 'font-semibold text-primary' : 'text-base-content/50'">
|
||||
{{ i + 1 }}月
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="py-10 text-center text-sm text-base-content/50">{{ year }} 年暂无用量数据</div>
|
||||
|
||||
<!-- 图例 -->
|
||||
<div v-if="legend.length" class="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
||||
<span v-for="item in legend" :key="item.name" class="flex items-center gap-1.5 text-xs text-base-content/70"
|
||||
:title="`${item.name}:全年 ${fmtMetricValue(item.value)}`">
|
||||
<span class="size-2.5 rounded-sm" :style="{ backgroundColor: item.color }" aria-hidden="true"></span>
|
||||
<span class="max-w-40 truncate">{{ item.name }}</span>
|
||||
<span class="tabular-nums text-base-content/40">{{ fmtMetricValue(item.value) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 明细表格 -->
|
||||
<!-- 请求明细 -->
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="flex items-center justify-between px-5 pt-4">
|
||||
<h3 class="text-sm font-semibold">请求明细</h3>
|
||||
@@ -99,35 +141,151 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import { useUsageStore } from '@/stores/usage'
|
||||
import type { MonthlyUsage, MonthlyModelUsage } from '@/types'
|
||||
|
||||
const store = useUsageStore()
|
||||
|
||||
const days = ref(30)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const myLogsTotal = computed(() => store.myLogsTotal)
|
||||
const totals = computed(() => store.stats?.totals ?? {
|
||||
requests: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cost: 0,
|
||||
})
|
||||
const currentYear = new Date().getFullYear()
|
||||
const year = ref(currentYear)
|
||||
const selectedMonthIndex = ref(new Date().getMonth())
|
||||
|
||||
const barItems = computed(() => {
|
||||
const stats = store.stats
|
||||
if (!stats) return []
|
||||
const max = Math.max(1, ...stats.dates.map(d => stats.daily[d]?.requests ?? 0))
|
||||
return stats.dates.slice(-14).map(d => ({
|
||||
date: d,
|
||||
requests: stats.daily[d]?.requests ?? 0,
|
||||
height: Math.round(((stats.daily[d]?.requests ?? 0) / max) * 120),
|
||||
label: d.slice(5).replace('-', '/'),
|
||||
const months = computed<MonthlyUsage[]>(() => {
|
||||
const data = store.monthly
|
||||
if (data && data.year === year.value) return data.months
|
||||
// 数据未就绪/年份不匹配时给出 12 个月空骨架,保持布局稳定
|
||||
return Array.from({ length: 12 }, (_, i) => ({
|
||||
month: `${year.value}-${String(i + 1).padStart(2, '0')}`,
|
||||
requests: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cost: 0, models: [],
|
||||
}))
|
||||
})
|
||||
|
||||
async function loadStats() {
|
||||
const selectedMonth = computed(() => months.value[selectedMonthIndex.value])
|
||||
const selectedMonthLabel = computed(() => `${year.value} 年 ${selectedMonthIndex.value + 1} 月`)
|
||||
|
||||
function monthTokens(m?: MonthlyUsage): number {
|
||||
if (!m) return 0
|
||||
return m.input_tokens + m.output_tokens + m.cache_read_tokens
|
||||
}
|
||||
|
||||
// --- 月度汇总图表:指标切换 + 按模型分色堆叠 ---
|
||||
|
||||
type MetricKey = 'tokens' | 'cost' | 'requests'
|
||||
const METRICS: { key: MetricKey; label: string }[] = [
|
||||
{ key: 'tokens', label: 'Token' },
|
||||
{ key: 'cost', label: '消费金额' },
|
||||
{ key: 'requests', label: '调用次数' },
|
||||
]
|
||||
const metric = ref<MetricKey>('tokens')
|
||||
|
||||
const PALETTE = [
|
||||
'#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||
'#14b8a6', '#f97316', '#3b82f6', '#ec4899', '#84cc16', '#eab308',
|
||||
]
|
||||
const OTHER_COLOR = '#94a3b8'
|
||||
const MAX_LEGEND = 8 // 图例最多展示 8 个模型,其余归入「其他」
|
||||
const OTHER_NAME = '其他'
|
||||
|
||||
function monthTokensOf(mm: MonthlyModelUsage): number {
|
||||
return mm.input_tokens + mm.output_tokens + mm.cache_read_tokens
|
||||
}
|
||||
|
||||
// 当前指标下的数值(柱高、图例、峰值共用)
|
||||
function metricValue(m: MonthlyUsage): number {
|
||||
switch (metric.value) {
|
||||
case 'cost': return m.cost
|
||||
case 'requests': return m.requests
|
||||
default: return monthTokens(m)
|
||||
}
|
||||
}
|
||||
function metricValueOf(mm: MonthlyModelUsage): number {
|
||||
switch (metric.value) {
|
||||
case 'cost': return mm.cost
|
||||
case 'requests': return mm.requests
|
||||
default: return monthTokensOf(mm)
|
||||
}
|
||||
}
|
||||
const maxMetricValue = computed(() => Math.max(0, ...months.value.map(metricValue)))
|
||||
|
||||
// 全年维度统计每个模型在当前指标下的总量,取前 MAX_LEGEND 个进入图例
|
||||
const legend = computed(() => {
|
||||
const totals = new Map<string, number>()
|
||||
for (const m of months.value) {
|
||||
for (const mm of m.models) {
|
||||
totals.set(mm.model_name, (totals.get(mm.model_name) ?? 0) + metricValueOf(mm))
|
||||
}
|
||||
}
|
||||
const sorted = [...totals.entries()].sort((a, b) => b[1] - a[1])
|
||||
const top = sorted.slice(0, MAX_LEGEND).map(([name, value], i) => ({
|
||||
name, value, color: PALETTE[i % PALETTE.length],
|
||||
}))
|
||||
const restValue = sorted.slice(MAX_LEGEND).reduce((s, [, v]) => s + v, 0)
|
||||
if (restValue > 0) top.push({ name: OTHER_NAME, value: restValue, color: OTHER_COLOR })
|
||||
return top
|
||||
})
|
||||
|
||||
const legendIndex = computed(() => {
|
||||
const idx = new Map<string, number>()
|
||||
legend.value.forEach((item, i) => idx.set(item.name, i))
|
||||
return idx
|
||||
})
|
||||
|
||||
// 单月柱体:按图例顺序堆叠(保持各月颜色顺序一致),未进图例的模型归入「其他」
|
||||
function barSegments(m: MonthlyUsage) {
|
||||
const total = metricValue(m)
|
||||
if (total === 0) return []
|
||||
const byName = new Map<string, number>()
|
||||
for (const mm of m.models) byName.set(mm.model_name, metricValueOf(mm))
|
||||
|
||||
const segs: { name: string; value: number; pct: number; share: number; color: string }[] = []
|
||||
let other = 0
|
||||
for (const [name, value] of byName) {
|
||||
if (legendIndex.value.has(name)) continue
|
||||
other += value
|
||||
}
|
||||
for (const item of legend.value) {
|
||||
const value = item.name === OTHER_NAME ? other : (byName.get(item.name) ?? 0)
|
||||
if (value <= 0) continue
|
||||
const pct = (value / total) * 100
|
||||
segs.push({ name: item.name, value, pct, share: Math.round(pct), color: item.color })
|
||||
}
|
||||
return segs
|
||||
}
|
||||
|
||||
function barHeightPct(m: MonthlyUsage): string {
|
||||
if (maxMetricValue.value === 0) return '0%'
|
||||
return `${(metricValue(m) / maxMetricValue.value) * 100}%`
|
||||
}
|
||||
|
||||
function barTitle(m: MonthlyUsage): string {
|
||||
if (monthTokens(m) === 0 && m.requests === 0) return `${m.month}:无用量`
|
||||
const parts = m.models
|
||||
.slice()
|
||||
.sort((a, b) => metricValueOf(b) - metricValueOf(a))
|
||||
.map(mm => `${mm.model_name} ${fmtMetricValue(metricValueOf(mm))}`)
|
||||
return `${m.month}:${fmtInt(m.requests)} 次调用,${fmtInt(monthTokens(m))} tokens,${fmtCost(m.cost)}\n${parts.join('\n')}`
|
||||
}
|
||||
|
||||
const switchYear = (delta: number) => {
|
||||
const next = year.value + delta
|
||||
if (next < 2000 || next > currentYear) return
|
||||
year.value = next
|
||||
selectedMonthIndex.value = next === currentYear ? new Date().getMonth() : 11
|
||||
loadMonthly()
|
||||
}
|
||||
|
||||
// --- 请求明细(保留原有功能) ---
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const myLogsTotal = computed(() => store.myLogsTotal)
|
||||
|
||||
async function loadMonthly() {
|
||||
try {
|
||||
await store.fetchStats(days.value)
|
||||
await store.fetchMonthly(year.value)
|
||||
} catch { /* toast 由 store 抛错,页面保持静默 */ }
|
||||
}
|
||||
|
||||
@@ -147,7 +305,30 @@ function fmtInt(n?: number): string {
|
||||
return (n ?? 0).toLocaleString()
|
||||
}
|
||||
function fmtCost(n?: number): string {
|
||||
return `$${(n ?? 0).toFixed(6)}`
|
||||
return `$${(n ?? 0).toFixed(4)}`
|
||||
}
|
||||
// 金额紧凑格式(图表柱顶/图例使用)
|
||||
function fmtMoney(v: number): string {
|
||||
if (v >= 1e6) return '$' + (v / 1e6).toFixed(2) + 'M'
|
||||
if (v >= 1e3) return '$' + (v / 1e3).toFixed(2) + 'k'
|
||||
if (v >= 1) return '$' + v.toFixed(2)
|
||||
return '$' + v.toFixed(4)
|
||||
}
|
||||
// 当前指标数值格式化
|
||||
function fmtMetricValue(v: number): string {
|
||||
switch (metric.value) {
|
||||
case 'cost': return fmtMoney(v)
|
||||
case 'requests': return fmtCompact(v)
|
||||
default: return fmtCompact(v)
|
||||
}
|
||||
}
|
||||
// 紧凑数字:柱顶/图例等小空间使用
|
||||
function fmtCompact(n?: number): string {
|
||||
const v = n ?? 0
|
||||
if (v >= 1e9) return (v / 1e9).toFixed(1) + 'B'
|
||||
if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M'
|
||||
if (v >= 1e3) return (v / 1e3).toFixed(1) + 'k'
|
||||
return String(v)
|
||||
}
|
||||
function fmtTime(t?: string): string {
|
||||
if (!t) return '—'
|
||||
@@ -174,7 +355,7 @@ function statusClass(s: string): string {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadMonthly()
|
||||
loadLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user