Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
283 changes: 283 additions & 0 deletions controller/log.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package controller

import (
"fmt"
"net/http"
"net/url"
"strconv"
"time"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/xuri/excelize/v2"

"github.com/gin-gonic/gin"
)
Expand Down Expand Up @@ -122,6 +126,149 @@ func GetLogsStat(c *gin.Context) {
return
}

func checkStatisticsUsername(c *gin.Context, username string) bool {
role := c.GetInt("role")
if role >= common.RoleAdminUser {
return true
}
currentUsername := c.GetString("username")
if username != currentUsername {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "you can only query your own statistics",
})
return false
}
return true
}

func GetLogStatistics(c *gin.Context) {
username := c.Query("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "username is required",
})
return
}
if !checkStatisticsUsername(c, username) {
return
}
tokenName := c.Query("token_name")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
modelName := c.Query("model_name")

models, err := model.GetLogStatistics(username, tokenName, startTimestamp, endTimestamp, modelName)
if err != nil {
common.ApiError(c, err)
return
}
trend, err := model.GetLogStatisticsTrend(username, tokenName, startTimestamp, endTimestamp, modelName)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"models": models,
"trend": trend,
},
})
}

func ExportLogStatistics(c *gin.Context) {
username := c.Query("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "username is required",
})
return
}
if !checkStatisticsUsername(c, username) {
return
}
tokenName := c.Query("token_name")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
modelName := c.Query("model_name")

models, err := model.GetLogStatistics(username, tokenName, startTimestamp, endTimestamp, modelName)
if err != nil {
common.ApiError(c, err)
return
}

f := excelize.NewFile()
defer func() { _ = f.Close() }()
sheet := "Sheet1"

// Build title: username-token_name-timeRange
timeRange := ""
if startTimestamp > 0 && endTimestamp > 0 {
timeRange = fmt.Sprintf("%s ~ %s", time.Unix(startTimestamp, 0).Format("2006-01-02"), time.Unix(endTimestamp, 0).Format("2006-01-02"))
}
title := username
if tokenName != "" {
title += "-" + tokenName
}
if timeRange != "" {
title += "-" + timeRange
}
_ = f.SetCellValue(sheet, "A1", title)

headers := []string{"模型名称", "调用次数", "消耗额度($)", "Prompt Tokens(M)", "Completion Tokens(M)", "总 Tokens(M)"}
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 2)
_ = f.SetCellValue(sheet, cell, h)
}

var totalQuota, totalPrompt, totalCompletion, totalCount int64
for i, m := range models {
row := i + 3
promptM := float64(m.PromptTokens) / 1_000_000
completionM := float64(m.CompletionTokens) / 1_000_000
totalM := float64(m.PromptTokens+m.CompletionTokens) / 1_000_000
quotaUSD := float64(m.Quota) / float64(common.QuotaPerUnit)
_ = f.SetCellValue(sheet, fmt.Sprintf("A%d", row), m.ModelName)
_ = f.SetCellValue(sheet, fmt.Sprintf("B%d", row), m.RequestCount)
_ = f.SetCellValue(sheet, fmt.Sprintf("C%d", row), quotaUSD)
_ = f.SetCellValue(sheet, fmt.Sprintf("D%d", row), promptM)
_ = f.SetCellValue(sheet, fmt.Sprintf("E%d", row), completionM)
_ = f.SetCellValue(sheet, fmt.Sprintf("F%d", row), totalM)
totalQuota += m.Quota
totalPrompt += m.PromptTokens
totalCompletion += m.CompletionTokens
totalCount += m.RequestCount
}
summaryRow := len(models) + 3
totalQuotaUSD := float64(totalQuota) / float64(common.QuotaPerUnit)
totalPromptM := float64(totalPrompt) / 1_000_000
totalCompletionM := float64(totalCompletion) / 1_000_000
totalTM := float64(totalPrompt+totalCompletion) / 1_000_000
_ = f.SetCellValue(sheet, fmt.Sprintf("A%d", summaryRow), "合计")
_ = f.SetCellValue(sheet, fmt.Sprintf("B%d", summaryRow), totalCount)
_ = f.SetCellValue(sheet, fmt.Sprintf("C%d", summaryRow), totalQuotaUSD)
_ = f.SetCellValue(sheet, fmt.Sprintf("D%d", summaryRow), totalPromptM)
_ = f.SetCellValue(sheet, fmt.Sprintf("E%d", summaryRow), totalCompletionM)
_ = f.SetCellValue(sheet, fmt.Sprintf("F%d", summaryRow), totalTM)

buf, err := f.WriteToBuffer()
if err != nil {
common.ApiError(c, err)
return
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

filename := title + ".xlsx"
asciiFallback := url.PathEscape(filename)
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"; filename*=UTF-8''%s", asciiFallback, asciiFallback))
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", buf.Bytes())
Comment on lines +265 to +269

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Content-Disposition filename= should be ASCII; only filename*= should hold UTF-8.

title can contain non‑ASCII characters (e.g. 用户名-令牌-2026-04-27 ~ 2026-04-27), so you’re emitting a filename="..." value with raw UTF‑8 bytes. RFC 6266 requires the unquoted/quoted filename parameter to be ASCII; only filename* carries the encoded UTF-8 form. Some browsers/proxies will mishandle the raw value (garbled names, header parsing errors).

Use an ASCII-safe fallback for filename= and keep the encoded one for filename*:

Proposed fix
-	filename := title + ".xlsx"
-	// URL-encode filename for non-ASCII characters
-	encodedFilename := url.PathEscape(filename)
-	c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
-	c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"; filename*=UTF-8''%s", filename, encodedFilename))
+	filename := title + ".xlsx"
+	asciiFallback := "log_statistics.xlsx"
+	encodedFilename := url.PathEscape(filename)
+	c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+	c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"; filename*=UTF-8''%s", asciiFallback, encodedFilename))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
filename := title + ".xlsx"
// URL-encode filename for non-ASCII characters
encodedFilename := url.PathEscape(filename)
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"; filename*=UTF-8''%s", filename, encodedFilename))
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", buf.Bytes())
filename := title + ".xlsx"
asciiFallback := "log_statistics.xlsx"
encodedFilename := url.PathEscape(filename)
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"; filename*=UTF-8''%s", asciiFallback, encodedFilename))
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", buf.Bytes())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controller/log.go` around lines 240 - 245, The current Content-Disposition
emits raw UTF-8 in filename (variable filename) which is not allowed by RFC
6266; create an ASCII-safe fallback (e.g. fallbackFilename) by
stripping/transliterating or replacing non-ASCII chars (or falling back to a
safe name like "export.xlsx"), keep encodedFilename for filename* (the UTF-8
percent-encoded value), and set the header via c.Header("Content-Disposition",
fmt.Sprintf("attachment; filename=\"%s\"; filename*=UTF-8''%s",
fallbackFilename, encodedFilename)); ensure fallbackFilename is strictly ASCII
and properly quoted.

}

func GetLogsSelfStat(c *gin.Context) {
username := c.GetString("username")
logType, _ := strconv.Atoi(c.Query("type"))
Expand Down Expand Up @@ -171,3 +318,139 @@ func DeleteHistoryLogs(c *gin.Context) {
})
return
}

func GetStatisticsUserOptions(c *gin.Context) {
keyword := c.Query("keyword")
page, _ := strconv.Atoi(c.Query("p"))
if page < 1 {
page = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}

var usernames []string
if keyword != "" {
users, _, err := model.SearchUsers(keyword, "", (page-1)*pageSize, pageSize)
if err != nil {
common.ApiError(c, err)
return
}
for _, u := range users {
usernames = append(usernames, u.Username)
}
} else {
pageInfo := &common.PageInfo{Page: page, PageSize: pageSize}
users, _, err := model.GetAllUsers(pageInfo)
if err != nil {
common.ApiError(c, err)
return
}
for _, u := range users {
usernames = append(usernames, u.Username)
}
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"data": usernames,
})
}

func GetStatisticsTokenOptions(c *gin.Context) {
role := c.GetInt("role")
userId := c.GetInt("id")
username := c.Query("username")

if role < common.RoleAdminUser {
username = c.GetString("username")
}

var user model.User
if err := model.DB.Where("username = ?", username).First(&user).Error; err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": []string{},
})
return
}

if role < common.RoleAdminUser && user.Id != userId {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": []string{},
})
return
}

tokens, err := model.GetAllUserTokens(user.Id, 0, 100)
Comment thread
kK-2004 marked this conversation as resolved.
Resolved
if err != nil {
common.ApiError(c, err)
return
}

var tokenNames []string
for _, t := range tokens {
tokenNames = append(tokenNames, t.Name)
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"data": tokenNames,
})
}

func GetStatisticsModelOptions(c *gin.Context) {
username := c.Query("username")
tokenName := c.Query("token_name")

if tokenName != "" && username != "" {
var user model.User
if err := model.DB.Where("username = ?", username).First(&user).Error; err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": model.GetEnabledModels(),
})
return
}

var token model.Token
if err := model.DB.Where("user_id = ? AND name = ?", user.Id, tokenName).First(&token).Error; err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": model.GetEnabledModels(),
})
return
}
Comment thread
kK-2004 marked this conversation as resolved.
Resolved

if token.IsModelLimitsEnabled() {
limits := token.GetModelLimits()
if len(limits) > 0 {
enabledModels := model.GetEnabledModels()
enabledMap := make(map[string]bool, len(enabledModels))
for _, m := range enabledModels {
enabledMap[m] = true
}
var result []string
for _, m := range limits {
if enabledMap[m] {
result = append(result, m)
}
}
if len(result) > 0 {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result,
})
return
}
}
}
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"data": model.GetEnabledModels(),
})
}
15 changes: 12 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,28 @@ require (
github.com/tidwall/sjson v1.2.5
github.com/tiktoken-go/tokenizer v0.6.2
github.com/waffo-com/waffo-go v1.3.1
github.com/xuri/excelize/v2 v2.10.1
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
golang.org/x/crypto v0.45.0
golang.org/x/crypto v0.48.0
golang.org/x/image v0.38.0
golang.org/x/net v0.47.0
golang.org/x/net v0.50.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.38.0
golang.org/x/sys v0.41.0
golang.org/x/text v0.35.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.4.3
gorm.io/driver/postgres v1.5.2
gorm.io/gorm v1.25.2
)

require (
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
)

require (
github.com/DmitriyVTitov/size v1.5.0 // indirect
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
Expand Down
Loading