Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
125 changes: 125 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 @@ -120,6 +124,127 @@ func GetLogsStat(c *gin.Context) {
return
}

func GetLogStatistics(c *gin.Context) {
username := c.Query("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "username is required",
})
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
}
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
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
24 changes: 18 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,10 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
Expand Down Expand Up @@ -292,6 +296,8 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/tiktoken-go/tokenizer v0.6.2 h1:t0GN2DvcUZSFWT/62YOgoqb10y7gSXBGs0A+4VCQK+g=
github.com/tiktoken-go/tokenizer v0.6.2/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
Expand All @@ -310,6 +316,12 @@ github.com/waffo-com/waffo-go v1.3.1 h1:NCYD3oQ59DTJj1bwS5T/659LI4h8PuAIW4Qj/w7f
github.com/waffo-com/waffo-go v1.3.1/go.mod h1:IaXVYq6mmYtrLFFsLxPslNwuIZx0mIadWWjhe+eWb0g=
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/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c h1:xA2TJS9Hu/ivzaZIrDcwvpJ3Fnpsk5fDOJ4iSnL6J0w=
Expand All @@ -323,8 +335,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw=
golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
Expand All @@ -333,8 +345,8 @@ golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
Expand All @@ -348,8 +360,8 @@ golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
Expand Down
103 changes: 103 additions & 0 deletions model/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,109 @@ func SumUsedToken(logType int, startTimestamp int64, endTimestamp int64, modelNa
return token
}

type ModelStatistics struct {
ModelName string `json:"model_name"`
Quota int64 `json:"quota"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
RequestCount int64 `json:"request_count"`
}

type TrendPoint struct {
Time string `json:"time"`
ModelName string `json:"model_name"`
Quota int64 `json:"quota"`
RequestCount int64 `json:"request_count"`
}

func buildStatisticsQuery(username string, tokenName string, startTimestamp int64, endTimestamp int64, modelName string) *gorm.DB {
tx := LOG_DB.Table("logs").Where("type = ?", LogTypeConsume)
if username != "" {
tx = tx.Where("username = ?", username)
}
if tokenName != "" {
tx = tx.Where("token_name = ?", tokenName)
}
if startTimestamp != 0 {
tx = tx.Where("created_at >= ?", startTimestamp)
}
if endTimestamp != 0 {
tx = tx.Where("created_at <= ?", endTimestamp)
}
if modelName != "" {
tx = tx.Where("model_name LIKE ?", modelName)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return tx
}

func GetLogStatistics(username string, tokenName string, startTimestamp int64, endTimestamp int64, modelName string) ([]ModelStatistics, error) {
tx := buildStatisticsQuery(username, tokenName, startTimestamp, endTimestamp, modelName)
var results []ModelStatistics
err := tx.Select("model_name, COALESCE(SUM(quota),0) as quota, COALESCE(SUM(prompt_tokens),0) as prompt_tokens, COALESCE(SUM(completion_tokens),0) as completion_tokens, COUNT(*) as request_count").
Group("model_name").
Order("quota DESC").
Find(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
Comment on lines +571 to +582

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 | 🟠 Major

Statistics query lacks an enforced time-range bound and may scan the entire logs table.

When neither startTimestamp nor endTimestamp is provided, buildStatisticsQuery produces a query that filters only by type = LogTypeConsume and aggregates over the entire log history. For a busy deployment this can be an extremely heavy scan/aggregation, easily blocking the request goroutine and stressing the DB.

Consider one of:

  • Requiring a non-zero startTimestamp/endTimestamp at the controller layer (controller.GetLogStatistics/ExportLogStatistics).
  • Defaulting to a sensible window (e.g. last 24h / 30d) when not supplied.
  • Adding a reasonable LIMIT on rows returned and clamping the max range server-side.

Same concern applies to GetLogStatisticsTrend below.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model/log.go` around lines 547 - 558, GetLogStatistics currently calls
buildStatisticsQuery without enforcing a time range which can cause full-table
scans; update the call path so a sane time window is enforced: either validate
in controller.GetLogStatistics/controller.ExportLogStatistics to require
non-zero startTimestamp or endTimestamp and return a 400, or (recommended)
default missing timestamps inside GetLogStatistics/GetLogStatisticsTrend to a
bounded window (e.g., endTimestamp = now, startTimestamp = now - 30*24h) and
clamp any client-supplied range to a max span (e.g., 90 days), so
buildStatisticsQuery always receives a bounded range; apply the same
defaulting/clamping logic to GetLogStatisticsTrend to avoid heavy scans.


func GetLogStatisticsTrend(username string, tokenName string, startTimestamp int64, endTimestamp int64, modelName string) ([]TrendPoint, error) {
tx := buildStatisticsQuery(username, tokenName, startTimestamp, endTimestamp, modelName)

// Determine bucket size in seconds: hourly if <=24h, daily otherwise
bucketSeconds := int64(86400)
if endTimestamp > 0 && startTimestamp > 0 && (endTimestamp-startTimestamp) <= 86400 {
bucketSeconds = 3600
}

type rawRow struct {
BucketStart int64 `gorm:"column:bucket_start"`
ModelName string `gorm:"column:model_name"`
Quota int64 `gorm:"column:quota_sum"`
RequestCount int64 `gorm:"column:request_count"`
}

bucketExpr := fmt.Sprintf("(created_at / %d) * %d", bucketSeconds, bucketSeconds)
if common.UsingMySQL {
bucketExpr = fmt.Sprintf("(created_at DIV %d) * %d", bucketSeconds, bucketSeconds)
} else if common.UsingPostgreSQL {
bucketExpr = fmt.Sprintf("((created_at / %d)::bigint * %d)", bucketSeconds, bucketSeconds)
} else if common.UsingSQLite {
bucketExpr = fmt.Sprintf("CAST(created_at / %d AS INTEGER) * %d", bucketSeconds, bucketSeconds)
}

// Bucket in SQL using integer math so scans into int64 stay cross-database safe.
var rows []rawRow
err := tx.Select(fmt.Sprintf("%s as bucket_start, model_name, COALESCE(SUM(quota),0) as quota_sum, COUNT(*) as request_count", bucketExpr)).
Group(bucketExpr + ", model_name").
Order("bucket_start ASC").
Find(&rows).Error
if err != nil {
return nil, err
}

// Format bucket timestamps into readable strings
result := make([]TrendPoint, 0, len(rows))
for _, row := range rows {
t := time.Unix(row.BucketStart, 0)
var label string
if bucketSeconds == 3600 {
label = t.Format("2006-01-02 15:04")
} else {
label = t.Format("2006-01-02")
}
result = append(result, TrendPoint{
Time: label,
ModelName: row.ModelName,
Quota: row.Quota,
RequestCount: row.RequestCount,
})
}
return result, nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

func DeleteOldLog(ctx context.Context, targetTimestamp int64, limit int) (int64, error) {
var total int64 = 0

Expand Down
2 changes: 2 additions & 0 deletions router/api-router.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,8 @@ func SetApiRouter(router *gin.Engine) {
logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs)
logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs)
logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat)
logRoute.GET("/statistics", middleware.AdminAuth(), controller.GetLogStatistics)
logRoute.GET("/statistics/export", middleware.AdminAuth(), controller.ExportLogStatistics)
logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat)
logRoute.GET("/channel_affinity_usage_cache", middleware.AdminAuth(), controller.GetChannelAffinityUsageCacheStats)
logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs)
Expand Down
1 change: 1 addition & 0 deletions web/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading