9 Commits
Author SHA1 Message Date
Sakurasan ca4dc4b3b7 fix: 缓存 token 计费按上游协议区分口径
- 新增 ComputeCost:OpenAI 系 prompt 含缓存读需扣减(异常数据钳制为 0);
  Anthropic input_tokens 不含缓存,按原值计费,缓存写按输入价 ×1.25(原实现误用输出价)
- recordUsage 传入上游协议(用量语义跟随解析它的上游响应,而非客户端协议)
- 补充单元测试覆盖两种协议口径与边界情况
2026-09-02 02:36:28 +08:00
Sakurasan 0628d5050f feat: 用量统计页改版,月度汇总支持多指标图表
- 新增 GET /api/usage/monthly?year= 年度按自然月聚合,每月含按模型分解(token 降序)
- 月度汇总改为堆叠柱状图:Token/消费金额/调用次数三指标切换,按模型分色(图例取前 8,其余归入「其他」)
- 选中月份概览卡片(金额/次数/token 分解),点击柱体或图例联动切换
- 年份切换、悬停明细 tooltip、请求明细保留
2026-09-02 02:36:28 +08:00
Sakurasan a376ac0722 feat: add Redis support for distributed passkey sessions
- Implement SessionStore interface for pluggable storage backends
- Add redisStore for distributed environment
- Add memoryStore as fallback for single instance
- Update router to initialize Redis client when configured
- Update .env.example with Redis documentation
2026-09-01 23:03:39 +08:00
Sakurasan 9733b3c20b refactor: restructure passkey with dedicated package
- Create internal/passkey/ package with challenge session management
- Add HTTP handler layer in internal/api/passkey.go
- Simplify Passkey model to use blob credential storage
- Register passkey routes in router
- Update frontend to match new API endpoints
- Add .env.example template
2026-09-01 22:34:44 +08:00
Sakurasan d28fca8ee6 chore: sqlite 默认数据库文件调整为 db/openteam.db,运行时自动创建目录 2026-09-01 21:36:15 +08:00
Sakurasan a2cef00908 fix: 未登录访问管理后台不再闪现页面,路由守卫改为服务端校验
- 守卫先经 /profile 校验 token 有效后才放行受保护路由,无效 token 直接跳登录并带 redirect 回跳
- DashboardLayout 用户信息就绪前只渲染 spinner,不渲染后台内容
- manager 路由增加 requiresAdmin 校验(role >= 10),非管理员重定向回仪表盘
- 401 拦截器改用 router.push 软跳转,避免整页刷新与双重跳转
- pinia 先于 router 安装;登录页支持 redirect 参数回跳原目标
2026-09-01 20:20:05 +08:00
Sakurasan d41bcdc371 修复backend dist文件路径 2026-09-01 17:22:26 +08:00
Sakurasan 8d949eff18 修改图标配色 2026-09-01 15:38:32 +08:00
Sakurasan c65d497551 feat: add docker build commands to Makefile 2026-09-01 15:22:37 +08:00
32 changed files with 1276 additions and 359 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ web
# Go 构建产物 # Go 构建产物
bin bin
cmd/openteam/dist backend/cmd/openteam/dist
# 文档与 CI # 文档与 CI
doc doc
+48
View File
@@ -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
+20 -1
View File
@@ -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 BINARY_NAME=openteam
BUILD_DIR=bin BUILD_DIR=bin
@@ -8,6 +8,10 @@ BACKEND_DIR=backend
build: frontend build: frontend
cd $(BACKEND_DIR) && CGO_ENABLED=0 go build -o ../$(BUILD_DIR)/$(BINARY_NAME) ./cmd/openteam 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 # Build frontend and copy dist
frontend: frontend:
cd frontend && pnpm install && pnpm build cd frontend && pnpm install && pnpm build
@@ -74,3 +78,18 @@ migrate:
# Seed data (will be implemented) # Seed data (will be implemented)
seed: seed:
@echo "Seeding will be implemented in future" @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 .
+3
View File
@@ -27,6 +27,7 @@ require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.13.2 // indirect github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // 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/cloudwego/base64x v0.1.5 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // 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/go-sqlite3-wasm/v2 v2.1.35300 // indirect
github.com/ncruces/julianday v1.0.0 // indirect github.com/ncruces/julianday v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // 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/spf13/pflag v1.0.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
github.com/x448/float16 v0.8.4 // 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/arch v0.16.0 // indirect
golang.org/x/net v0.52.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
+6
View File
@@ -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.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 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 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 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 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= 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= 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= 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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 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= 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 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= 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= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+4 -1
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"net/http" "net/http"
"opencatd-open/internal/dao" "opencatd-open/internal/dao"
"opencatd-open/internal/passkey"
"opencatd-open/internal/store" "opencatd-open/internal/store"
"opencatd-open/internal/pkg/apikey" "opencatd-open/internal/pkg/apikey"
"opencatd-open/internal/pkg/crypto" "opencatd-open/internal/pkg/crypto"
@@ -23,9 +24,10 @@ type Handler struct {
modelDAO *dao.ModelDAO modelDAO *dao.ModelDAO
usageDAO *dao.UsageDAO usageDAO *dao.UsageDAO
dailyDAO *dao.DailyUsageDAO dailyDAO *dao.DailyUsageDAO
passkeys *passkey.Service
} }
func NewHandler(db *gorm.DB) *Handler { func NewHandler(db *gorm.DB, passkeys *passkey.Service) *Handler {
return &Handler{ return &Handler{
db: db, db: db,
userDAO: dao.NewUserDAO(db), userDAO: dao.NewUserDAO(db),
@@ -34,6 +36,7 @@ func NewHandler(db *gorm.DB) *Handler {
modelDAO: dao.NewModelDAO(db), modelDAO: dao.NewModelDAO(db),
usageDAO: dao.NewUsageDAO(db), usageDAO: dao.NewUsageDAO(db),
dailyDAO: dao.NewDailyUsageDAO(db), dailyDAO: dao.NewDailyUsageDAO(db),
passkeys: passkeys,
} }
} }
+162
View File
@@ -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}})
}
+126
View File
@@ -1,7 +1,9 @@
package api package api
import ( import (
"fmt"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"time" "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 — 当前用户的用量明细(分页)。 // MyUsageLogs GET /api/usage/logs?page=1&pageSize=20 — 当前用户的用量明细(分页)。
func (h *Handler) MyUsageLogs(c *gin.Context) { func (h *Handler) MyUsageLogs(c *gin.Context) {
userID, _ := c.Get("user_id") userID, _ := c.Get("user_id")
+1 -3
View File
@@ -13,18 +13,16 @@ type Api struct {
userService *service.UserServiceImpl userService *service.UserServiceImpl
tokenService *service.TokenServiceImpl tokenService *service.TokenServiceImpl
keyService *service.ApiKeyServiceImpl keyService *service.ApiKeyServiceImpl
webAuthService *service.WebAuthnService
usageService *service.UsageService 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{ return &Api{
cfg: cfg, cfg: cfg,
db: db, db: db,
userService: userService, userService: userService,
tokenService: tokenService, tokenService: tokenService,
keyService: keyService, keyService: keyService,
webAuthService: webAuthService,
usageService: usageService, usageService: usageService,
} }
} }
-11
View File
@@ -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"`
}
+367
View File
@@ -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
}
+29
View File
@@ -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
}
+75
View File
@@ -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)
}
})
}
}
+12 -12
View File
@@ -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.writeError(c, http.StatusServiceUnavailable, "no enabled channels for model: "+req.Model)
g.recordUsage(req, nil, nil, usage.Event{ g.recordUsage(req, nil, nil, usage.Event{
IsError: true, ErrorCode: "no_channel", IsError: true, ErrorCode: "no_channel",
}, convert.TokenUsage{}) }, convert.TokenUsage{}, "")
return return
} }
@@ -273,7 +273,7 @@ func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
IsError: true, IsError: true,
ErrorCode: "upstream_error", ErrorCode: "upstream_error",
LatencyMS: int(time.Since(start).Milliseconds()), LatencyMS: int(time.Since(start).Milliseconds()),
}, convert.TokenUsage{}) }, convert.TokenUsage{}, targetFormat)
continue // 可重试:换下一个渠道 continue // 可重试:换下一个渠道
} }
@@ -289,7 +289,7 @@ func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
IsError: true, IsError: true,
ErrorCode: fmt.Sprintf("upstream_%d", resp.StatusCode), ErrorCode: fmt.Sprintf("upstream_%d", resp.StatusCode),
LatencyMS: int(time.Since(start).Milliseconds()), LatencyMS: int(time.Since(start).Milliseconds()),
}, convert.TokenUsage{}) }, convert.TokenUsage{}, targetFormat)
// 429/5xx 可换渠道重试;4xx 直接透传 // 429/5xx 可换渠道重试;4xx 直接透传
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
lastErrStatus, lastErrBody = resp.StatusCode, string(body) lastErrStatus, lastErrBody = resp.StatusCode, string(body)
@@ -318,13 +318,13 @@ func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
IsError: true, IsError: true,
ErrorCode: errCode, ErrorCode: errCode,
LatencyMS: int(time.Since(start).Milliseconds()), LatencyMS: int(time.Since(start).Milliseconds()),
}, tok) }, tok, targetFormat)
return return
} }
// 成功记录:用量 + 定价计费。 // 成功记录:用量 + 定价计费。
g.recordUsage(req, cand, ch, usage.Event{ g.recordUsage(req, cand, ch, usage.Event{
LatencyMS: int(time.Since(start).Milliseconds()), LatencyMS: int(time.Since(start).Milliseconds()),
}, tok) }, tok, targetFormat)
return return
} }
@@ -349,9 +349,10 @@ func rewriteModel(body []byte, upstreamModel string) []byte {
return out return out
} }
// recordUsage 汇总一次请求的用量事件并异步落库。tok 为从上游响应提取的用量。 // recordUsage 汇总一次请求的用量事件并异步落库。tok 为从上游响应提取的用量,
// 其 token 语义由 upstreamProto(渠道实际使用的上游协议)决定。
// cand/ch 可为 nil(无可用渠道的失败场景)。 // 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 { if g.usageRec == nil {
return return
} }
@@ -380,7 +381,8 @@ func (g *Gateway) recordUsage(req *Request, cand *channel.Candidate, ch *store.C
} }
} }
// 定价与成本(价格按每百万 token 的 USD 单价)。 // 定价与成本(价格按每百万 token 的 USD 单价)。
// 成本口径:非缓存输入 × 输入价 + 缓存读 × 缓存价 + 缓存写与输出 × 输出价。 // 成本口径按上游协议区分(详见 ComputeCost):OpenAI 系 prompt 含缓存读需扣减;
// Anthropic 的 input_tokens 不含缓存,缓存写按输入价 ×1.25。
if ev.ModelID != 0 { if ev.ModelID != 0 {
if m, err := g.modelDAO.GetByID(ev.ModelID); err == nil { if m, err := g.modelDAO.GetByID(ev.ModelID); err == nil {
ev.InputPrice = m.InputPrice ev.InputPrice = m.InputPrice
@@ -389,10 +391,8 @@ func (g *Gateway) recordUsage(req *Request, cand *channel.Candidate, ch *store.C
} }
} }
if !ev.IsError { if !ev.IsError {
ev.Cost = (float64(ev.PromptTokens-ev.CacheReadTokens)*ev.InputPrice + ev.Cost = ComputeCost(upstreamProto, tok.InputTokens, tok.OutputTokens, tok.CacheReadTokens, tok.CacheCreationTokens,
float64(ev.CacheReadTokens)*ev.CacheReadPrice + ev.InputPrice, ev.OutputPrice, ev.CacheReadPrice)
float64(ev.CacheCreationTokens)*ev.OutputPrice +
float64(ev.CompletionTokens)*ev.OutputPrice) / 1e6
} }
g.usageRec.Record(ev) g.usageRec.Record(ev)
} }
-203
View File
@@ -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
}
+19 -5
View File
@@ -3,6 +3,8 @@ package store
import ( import (
"fmt" "fmt"
"log" "log"
"os"
"path/filepath"
"opencatd-open/pkg/config" "opencatd-open/pkg/config"
_ "github.com/lib/pq" _ "github.com/lib/pq"
@@ -15,11 +17,17 @@ import (
var DB *gorm.DB var DB *gorm.DB
func InitDB(cfg *config.Config) (*gorm.DB, error) { func InitDB(cfg *config.Config) (*gorm.DB, error) {
var dialector gorm.Dialector var (
dialector gorm.Dialector
err error
)
switch cfg.DB_Type { switch cfg.DB_Type {
case "sqlite": case "sqlite":
dialector = sqliteDialector(cfg.DSN) dialector, err = sqliteDialector(cfg.DSN)
if err != nil {
return nil, err
}
case "postgres": case "postgres":
dialector = postgresDialector(cfg.DSN) dialector = postgresDialector(cfg.DSN)
case "mysql": case "mysql":
@@ -48,11 +56,17 @@ func InitDB(cfg *config.Config) (*gorm.DB, error) {
return db, nil return db, nil
} }
func sqliteDialector(dsn string) gorm.Dialector { func sqliteDialector(dsn string) (gorm.Dialector, error) {
if dsn == "" { 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 { func postgresDialector(dsn string) gorm.Dialector {
+2 -10
View File
@@ -197,16 +197,8 @@ type Passkey struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint64 `gorm:"index;not null" json:"user_id"` UserID uint64 `gorm:"index;not null" json:"user_id"`
Name string `gorm:"size:64" json:"name"` Name string `gorm:"size:64" json:"name"`
CredentialID string `gorm:"size:255;not null" json:"-"` CredentialID []byte `gorm:"size:255;not null" json:"-"`
PublicKey string `gorm:"size:512;not null" json:"-"` Credential []byte `gorm:"type:blob;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:"-"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
+32 -1
View File
@@ -10,6 +10,7 @@ import (
"opencatd-open/internal/api" "opencatd-open/internal/api"
"opencatd-open/internal/channel" "opencatd-open/internal/channel"
"opencatd-open/internal/dao" "opencatd-open/internal/dao"
"opencatd-open/internal/passkey"
"opencatd-open/internal/proxy" "opencatd-open/internal/proxy"
"opencatd-open/internal/usage" "opencatd-open/internal/usage"
"opencatd-open/middleware" "opencatd-open/middleware"
@@ -21,6 +22,7 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/gorm" "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 := proxy.NewGateway(ctx, cfg, db, &wg, userDAO, apiKeyDAO, usageDAO, dailyDAO)
gateway.SetChannelService(channelSvc) gateway.SetChannelService(channelSvc)
gateway.SetUsageRecorder(usageRecorder) 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 // Initialize API handler
apiHandler := api.NewHandler(db) apiHandler := api.NewHandler(db, passkeySvc)
r := gin.Default() r := gin.Default()
r.Use(middleware.CORS()) 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("/register", apiHandler.Register)
public.POST("/login", apiHandler.Login) public.POST("/login", apiHandler.Login)
public.POST("/passkey/begin", apiHandler.PasskeyLoginBegin)
public.POST("/passkey/finish", apiHandler.PasskeyLoginComplete)
} }
// API routes (authenticated) // 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", apiHandler.UpdateProfile)
apiGroup.POST("/profile/update/password", apiHandler.UpdatePassword) 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) // User management (admin)
apiGroup.GET("/users", apiHandler.ListUsers) apiGroup.GET("/users", apiHandler.ListUsers)
apiGroup.GET("/users/:id", apiHandler.GetUser) 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/stats", apiHandler.MyUsageStats)
apiGroup.GET("/usage/monthly", apiHandler.MyUsageMonthly)
apiGroup.GET("/usage/logs", apiHandler.MyUsageLogs) apiGroup.GET("/usage/logs", apiHandler.MyUsageLogs)
} }
+2 -2
View File
@@ -18,12 +18,12 @@ ARG TARGETARCH
RUN apk --no-cache add make upx RUN apk --no-cache add make upx
WORKDIR /build WORKDIR /build
COPY . . 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 \ ENV GO111MODULE=on \
CGO_ENABLED=0 \ CGO_ENABLED=0 \
GOOS=$TARGETOS \ GOOS=$TARGETOS \
GOARCH=$TARGETARCH GOARCH=$TARGETARCH
RUN make build RUN make build-backend
FROM alpine:latest AS runner FROM alpine:latest AS runner
# 设置alpine 时间为上海时间 # 设置alpine 时间为上海时间
+2 -2
View File
@@ -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 && apk --no-cache add make upx
WORKDIR /build WORKDIR /build
COPY . . 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 \ ENV GO111MODULE=on \
GOPROXY=https://goproxy.cn,direct \ GOPROXY=https://goproxy.cn,direct \
CGO_ENABLED=0 \ CGO_ENABLED=0 \
GOOS=$TARGETOS \ GOOS=$TARGETOS \
GOARCH=$TARGETARCH GOARCH=$TARGETARCH
RUN make build RUN make build-backend
FROM alpine:latest AS runner FROM alpine:latest AS runner
# 设置alpine 时间为上海时间 # 设置alpine 时间为上海时间
+7 -1
View File
@@ -2,6 +2,7 @@
import axios from 'axios' import axios from 'axios'
import type { AxiosError, InternalAxiosRequestConfig } from 'axios' import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import router from '@/router'
const baseURL = import.meta.env.VITE_API_BASE_URL || '/api' const baseURL = import.meta.env.VITE_API_BASE_URL || '/api'
if (import.meta.env.DEV) { // Vite 的方式判断开发环境 if (import.meta.env.DEV) { // Vite 的方式判断开发环境
@@ -49,7 +50,12 @@ service.interceptors.response.use(
if (error.response && error.response.status === 401) { if (error.response && error.response.status === 401) {
const authStore = useAuthStore(); const authStore = useAuthStore();
authStore.clear(); 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); return Promise.reject(error);
} }
@@ -141,8 +141,8 @@ const rightIcons = [
{ id: 'zhipu', label: 'Zhipu', img: LobeIcon('zhipu'), color: '#4268fa' }, { id: 'zhipu', label: 'Zhipu', img: LobeIcon('zhipu'), color: '#4268fa' },
{ id: 'qwen', label: 'Qwen', img: LobeIcon('qwen'), color: '#615ced' }, { id: 'qwen', label: 'Qwen', img: LobeIcon('qwen'), color: '#615ced' },
{ id: 'deepseek', label: 'DeepSeek', img: LobeIcon('deepseek'), color: '#4d6bfe' }, { id: 'deepseek', label: 'DeepSeek', img: LobeIcon('deepseek'), color: '#4d6bfe' },
{ id: 'moonshot', label: 'Moonshot', img: LobeIcon('moonshot'), color: '#000' }, { id: 'moonshot', label: 'Moonshot', img: LobeIcon('moonshot'), color: '#666' },
{ id: 'minimax', label: 'MiniMax', img: LobeIcon('minimax'), color: '#000' }, { id: 'minimax', label: 'MiniMax', img: LobeIcon('minimax'), color: '#F23F5D' },
{ id: 'bedrock', label: 'Bedrock', img: LobeIcon('bedrock'), color: '#ff9900' }, { id: 'bedrock', label: 'Bedrock', img: LobeIcon('bedrock'), color: '#ff9900' },
{ id: 'azure', label: 'Azure', img: LobeIcon('azure'), color: '#0078d4' }, { id: 'azure', label: 'Azure', img: LobeIcon('azure'), color: '#0078d4' },
{ id: 'volcengine', label: 'Volcengine', img: LobeIcon('volcengine'), color: '#325ab4' }, { id: 'volcengine', label: 'Volcengine', img: LobeIcon('volcengine'), color: '#325ab4' },
+5 -1
View File
@@ -1,6 +1,10 @@
<!-- src/layouts/DashboardLayout.vue --> <!-- src/layouts/DashboardLayout.vue -->
<template> <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 }"> <div class="drawer" :class="{ 'lg:drawer-open': isLargeSidebarOpen }">
<input id="ot-drawer" type="checkbox" class="drawer-toggle" /> <input id="ot-drawer" type="checkbox" class="drawer-toggle" />
+1 -1
View File
@@ -10,6 +10,6 @@ const pinia = createPinia()
const app = createApp(App) const app = createApp(App)
app.provide('request', request) app.provide('request', request)
app.use(pinia) // 必须先于 router:路由守卫里会用到 auth store
app.use(router) app.use(router)
app.use(pinia)
app.mount('#app') app.mount('#app')
+28 -6
View File
@@ -1,19 +1,41 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { routes } from '@/utils/router_menu' import { routes } from '@/utils/router_menu'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes, 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) const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
if (requiresAuth && !isAuthenticated) { if (!requiresAuth) return true
next('/login')
} else { const authStore = useAuthStore()
next()
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 export default router
+20 -1
View File
@@ -2,7 +2,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import request from '@/api/client' 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', () => { export const useUsageStore = defineStore('usage', () => {
const loading = ref(false) const loading = ref(false)
@@ -11,6 +11,9 @@ export const useUsageStore = defineStore('usage', () => {
// 普通用户:每日统计 // 普通用户:每日统计
const stats = ref<UsageStatsData | null>(null) const stats = ref<UsageStatsData | null>(null)
// 普通用户:年度按月统计(含按模型分解)
const monthly = ref<MonthlyUsageData | null>(null)
// 普通用户:自身明细 // 普通用户:自身明细
const myLogs = ref<UsageLogItem[]>([]) const myLogs = ref<UsageLogItem[]>([])
const myLogsTotal = ref(0) 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) { async function fetchMyLogs(pageSize = 20, page = 1) {
loading.value = true loading.value = true
error.value = null error.value = null
@@ -82,12 +99,14 @@ export const useUsageStore = defineStore('usage', () => {
loading, loading,
error, error,
stats, stats,
monthly,
myLogs, myLogs,
myLogsTotal, myLogsTotal,
adminLogs, adminLogs,
adminLogsTotal, adminLogsTotal,
adminSummary, adminSummary,
fetchStats, fetchStats,
fetchMonthly,
fetchMyLogs, fetchMyLogs,
fetchAdminLogs, fetchAdminLogs,
fetchAdminSummary, fetchAdminSummary,
+21 -23
View File
@@ -16,26 +16,21 @@ export const useWebAuthStore = defineStore("webauth", () => {
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const addPasskey = async () => { const addPasskey = async (name?: string) => {
error.value = ""; error.value = "";
loading.value = true; loading.value = true;
try { try {
// 1. 从后端获取注册选项 (Creation Options) // 1. 从后端获取注册选项 (Creation Options)
const res = await request.get("/profile/passkey"); const res = await request.post("/webauthn/register/begin", {});
// console.log("begin:", res.data.data.publicKey); const { creation, challenge } = res.data.data;
const options = res.data.data.publicKey;
// 调用 Web Authentication API 进行注册 // 调用 Web Authentication API 进行注册
// const credential = await navigator.credentials.create(options);
// console.log("credential:", credential);
let attestation; let attestation;
try { try {
// Pass 'undefined' as the second argument if you are not using an AbortSignal // Pass 'undefined' as the second argument if you are not using an AbortSignal
attestation = await startRegistration({ optionsJSON: options }); attestation = await startRegistration({ optionsJSON: creation });
// console.log("WebAuthn 注册结果 (Attestation):", JSON.stringify(attestation));
error.value = null; error.value = null;
} catch (regError: any) { } catch (regError: any) {
// console.log("WebAuthn 注册失败或取消:", regError);
if (regError.name === "NotAllowedError") { if (regError.name === "NotAllowedError") {
error.value = "Passkey 操作被取消或不允许。"; error.value = "Passkey 操作被取消或不允许。";
} else { } else {
@@ -45,8 +40,11 @@ export const useWebAuthStore = defineStore("webauth", () => {
} }
// 3. 将注册结果 (Attestation) 发送到后端进行验证和保存 // 3. 将注册结果 (Attestation) 发送到后端进行验证和保存
const res2: AxiosResponse = await request.post("/profile/passkey", attestation); const res2: AxiosResponse = await request.post("/webauthn/register/complete", {
// console.log("end:", res2); challenge,
name: name || "passkey",
credential: attestation,
});
return res2; return res2;
} catch (err: any) { } catch (err: any) {
error.value = err.response?.data?.error || "添加 Passkey 失败,请稍后重试。"; 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; error.value = null;
loading.value = true; loading.value = true;
try { try {
// 1. 从后端获取登录选项 (Assertion Options) // 1. 从后端获取登录选项 (Assertion Options)
const res = await request.get("/auth/passkey/begin"); const res = await request.post("/auth/passkey/begin", { username });
// console.log("login begin:", res.data); const { assertion, challenge, user_id } = res.data.data;
const options = res.data.data.publicKey;
// 2. 调用 Web Authentication API 进行认证 // 2. 调用 Web Authentication API 进行认证
let assertion; let credential;
try { try {
assertion = await startAuthentication({ optionsJSON: options }); credential = await startAuthentication({ optionsJSON: assertion });
// console.log("WebAuthn 认证结果 (Assertion):", JSON.stringify(assertion));
} catch (loginError: any) { } catch (loginError: any) {
if (loginError.name === "NotAllowedError") { if (loginError.name === "NotAllowedError") {
error.value = "Passkey 登录被取消或不允许。"; error.value = "Passkey 登录被取消或不允许。";
@@ -80,8 +76,11 @@ export const useWebAuthStore = defineStore("webauth", () => {
} }
// 3. 将认证结果 (Assertion) 发送到后端进行验证并获取 Token // 3. 将认证结果 (Assertion) 发送到后端进行验证并获取 Token
const challenge = options.challenge; // 从 begin 接口返回的 options 中获取 challenge const res2: AxiosResponse = await request.post("/auth/passkey/finish", {
const res2: AxiosResponse = await request.post(`/auth/passkey/finish?challenge=${challenge}`, assertion); challenge,
credential,
user_id,
});
// 4. 处理登录成功的响应,通常包含 Token // 4. 处理登录成功的响应,通常包含 Token
if (res2.status === 200 && !!res2.data.data?.token) { if (res2.status === 200 && !!res2.data.data?.token) {
@@ -103,8 +102,7 @@ export const useWebAuthStore = defineStore("webauth", () => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await request.get('/profile/passkeys') const response = await request.get('/webauthn/passkeys')
// console.log('getPasskeys',response.data.data)
passkeys.value = response.data.data passkeys.value = response.data.data
} catch (err: any) { } catch (err: any) {
error.value = err.response?.data?.error || '获取token列表失败'; error.value = err.response?.data?.error || '获取token列表失败';
@@ -118,7 +116,7 @@ export const useWebAuthStore = defineStore("webauth", () => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response: AxiosResponse = await request.delete(`/profile/passkeys/${id}`) const response: AxiosResponse = await request.delete(`/webauthn/passkeys/${id}`)
return response return response
} catch (err: any) { } catch (err: any) {
error.value = err.response?.data?.error || `删除passkey ${id} 失败`; error.value = err.response?.data?.error || `删除passkey ${id} 失败`;
+27
View File
@@ -200,6 +200,33 @@ export interface UsageStatsData {
totals: UsageTotals 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 { export interface UsageLogItem {
id: number id: number
request_id?: string request_id?: string
+2 -1
View File
@@ -20,6 +20,7 @@ declare module 'vue-router' {
icon?: Component icon?: Component
showInSidebar?: boolean showInSidebar?: boolean
requiresAuth?: boolean requiresAuth?: boolean
requiresAdmin?: boolean
open?: boolean open?: boolean
badge?: string badge?: string
} }
@@ -46,7 +47,7 @@ export const routes: RouteRecordRaw[] = [
{ {
path: 'manager', path: 'manager',
name: 'Manager', name: 'Manager',
meta: { title: '管理后台' }, meta: { title: '管理后台', requiresAdmin: true },
redirect: '/dashboard/manager/users', redirect: '/dashboard/manager/users',
children: [ children: [
{ path: 'users', name: 'User', component: () => import('@/views/dashboard/User.vue'), meta: { title: '用户管理' } }, { path: 'users', name: 'User', component: () => import('@/views/dashboard/User.vue'), meta: { title: '用户管理' } },
+7 -3
View File
@@ -62,17 +62,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { CircleAlert } from '@lucide/vue' import { CircleAlert } from '@lucide/vue'
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useWebAuthStore } from '@/stores/webauth'; import { useWebAuthStore } from '@/stores/webauth';
import { useToast } from '@/composables/toast'; import { useToast } from '@/composables/toast';
const router = useRouter() const router = useRouter()
const route = useRoute()
const authStore = useAuthStore(); const authStore = useAuthStore();
const webauthStore = useWebAuthStore(); const webauthStore = useWebAuthStore();
const { setToast } = useToast(); const { setToast } = useToast();
// 被守卫拦下时带上原始目标,登录成功后回跳
const redirectPath = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
const error = ref<string | null>(null) const error = ref<string | null>(null)
const loggingIn = ref(false) const loggingIn = ref(false)
const user = reactive({ const user = reactive({
@@ -113,7 +117,7 @@ const handleLogin = async () => {
localStorage.removeItem('rember'); localStorage.removeItem('rember');
} }
setToast('Logged in successfully.', 'success'); setToast('Logged in successfully.', 'success');
router.push('/dashboard'); router.push(redirectPath);
} }
} catch (err: any) { } catch (err: any) {
console.error('Login error:', err); console.error('Login error:', err);
@@ -130,7 +134,7 @@ const handlePasskeyLogin = async () => {
const res = await webauthStore.loginPasskey(); const res = await webauthStore.loginPasskey();
if (!!res?.code && res.code === 200) { if (!!res?.code && res.code === 200) {
setToast('Logged in successfully.', 'success'); setToast('Logged in successfully.', 'success');
router.push('/dashboard'); router.push(redirectPath);
} }
} catch (err: any) { } catch (err: any) {
console.error('Passkey login error:', err); console.error('Passkey login error:', err);
-4
View File
@@ -170,8 +170,6 @@
<tr class="text-xs uppercase tracking-wider text-base-content/50"> <tr class="text-xs uppercase tracking-wider text-base-content/50">
<th class="pl-4">Name</th> <th class="pl-4">Name</th>
<th>Create Time</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> <th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
</tr> </tr>
</thead> </thead>
@@ -179,8 +177,6 @@
<tr v-for="passkey in passkeys" :key="passkey.id" class="border-base-300/40 hover:bg-base-200/50"> <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="pl-4 font-medium">{{ passkey.name }}</td>
<td class="tabular-nums text-base-content/70">{{ formatDateTime(passkey.created_at) }}</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"> <td class="pr-4 text-right">
<button class="btn btn-ghost btn-xs btn-square text-error" <button class="btn btn-ghost btn-xs btn-square text-error"
@click="confirmRmovePasskey(passkey)" aria-label="Delete passkey"> @click="confirmRmovePasskey(passkey)" aria-label="Delete passkey">
+233 -52
View File
@@ -2,55 +2,97 @@
<div class="space-y-5"> <div class="space-y-5">
<BreadcrumbHeader /> <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> </div>
<template v-else> <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"> <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="text-xs text-base-content/50">消费金额 (USD)</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.requests) }}</p> <p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtCost(selectedMonth?.cost) }}</p>
</div> </div>
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm"> <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="text-xs text-base-content/50">调用次数</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.input_tokens) }}</p> <p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(selectedMonth?.requests) }}</p>
</div> </div>
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm"> <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="text-xs text-base-content/50">Token 消耗</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.output_tokens) }}</p> <p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(monthTokens(selectedMonth)) }}</p>
</div> <p class="mt-0.5 text-xs tabular-nums text-base-content/50">
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm"> 输入 {{ fmtCompact(selectedMonth?.input_tokens) }} · 输出 {{ fmtCompact(selectedMonth?.output_tokens) }} · 缓存 {{
<p class="text-xs text-base-content/50">缓存 Tokens</p> fmtCompact(selectedMonth?.cache_read_tokens) }}
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.cache_read_tokens) }}</p> </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>
</div> </div>
</div> </div>
<!-- 每日请求量条形图(纯 CSS) --> <!-- 月度汇总图表:三种指标均按模型分色堆叠 -->
<div class="card border border-base-300/60 bg-base-100 p-5 shadow-sm"> <div class="card border border-base-300/60 bg-base-100 p-5 shadow-sm">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex flex-wrap items-center justify-between gap-2">
<h3 class="text-sm font-semibold">每日请求量</h3> <h3 class="text-sm font-semibold">月度汇总</h3>
<select v-model="days" class="select select-sm border-base-300 bg-base-100" @change="loadStats"> <div class="flex items-center gap-3">
<option :value="7">近 7 天</option> <div class="join">
<option :value="30">近 30 天</option> <button v-for="opt in METRICS" :key="opt.key" class="btn btn-xs join-item"
<option :value="90">近 90 天</option> :class="metric === opt.key ? 'btn-primary' : 'btn-ghost border-base-300/60'" @click="metric = opt.key">
</select> {{ opt.label }}
</button>
</div> </div>
<div v-if="barItems.length" class="flex h-40 items-end gap-1"> <span v-if="maxMetricValue > 0" class="text-xs text-base-content/40">峰值 {{ fmtMetricValue(maxMetricValue) }}</span>
<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> </div>
</div> </div>
<div v-else class="py-10 text-center text-sm text-base-content/50">暂无用量数据</div>
</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="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="flex items-center justify-between px-5 pt-4"> <div class="flex items-center justify-between px-5 pt-4">
<h3 class="text-sm font-semibold">请求明细</h3> <h3 class="text-sm font-semibold">请求明细</h3>
@@ -99,35 +141,151 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue' import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
import { useUsageStore } from '@/stores/usage' import { useUsageStore } from '@/stores/usage'
import type { MonthlyUsage, MonthlyModelUsage } from '@/types'
const store = useUsageStore() const store = useUsageStore()
const days = ref(30) const currentYear = new Date().getFullYear()
const page = ref(1) const year = ref(currentYear)
const pageSize = ref(20) const selectedMonthIndex = ref(new Date().getMonth())
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 barItems = computed(() => { const months = computed<MonthlyUsage[]>(() => {
const stats = store.stats const data = store.monthly
if (!stats) return [] if (data && data.year === year.value) return data.months
const max = Math.max(1, ...stats.dates.map(d => stats.daily[d]?.requests ?? 0)) // 数据未就绪/年份不匹配时给出 12 个月空骨架,保持布局稳定
return stats.dates.slice(-14).map(d => ({ return Array.from({ length: 12 }, (_, i) => ({
date: d, month: `${year.value}-${String(i + 1).padStart(2, '0')}`,
requests: stats.daily[d]?.requests ?? 0, requests: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cost: 0, models: [],
height: Math.round(((stats.daily[d]?.requests ?? 0) / max) * 120),
label: d.slice(5).replace('-', '/'),
})) }))
}) })
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 { try {
await store.fetchStats(days.value) await store.fetchMonthly(year.value)
} catch { /* toast 由 store 抛错,页面保持静默 */ } } catch { /* toast 由 store 抛错,页面保持静默 */ }
} }
@@ -147,7 +305,30 @@ function fmtInt(n?: number): string {
return (n ?? 0).toLocaleString() return (n ?? 0).toLocaleString()
} }
function fmtCost(n?: number): string { 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 { function fmtTime(t?: string): string {
if (!t) return '—' if (!t) return '—'
@@ -174,7 +355,7 @@ function statusClass(s: string): string {
} }
onMounted(() => { onMounted(() => {
loadStats() loadMonthly()
loadLogs() loadLogs()
}) })
</script> </script>