Repository navigation
feat: 添加使用日志按用户/令牌维度的统计与导出功能 #4498
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 6 commits
0bc341d
9df8063
ace5020
70f467e
d2e3231
2ea49c2
0659113
ba0e660
abed316
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Use an ASCII-safe fallback for 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| func GetLogsSelfStat(c *gin.Context) { | ||||||||||||||||||||||||||
| username := c.GetString("username") | ||||||||||||||||||||||||||
| logType, _ := strconv.Atoi(c.Query("type")) | ||||||||||||||||||||||||||
|
|
@@ -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) | ||||||||||||||||||||||||||
|
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 | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
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(), | ||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.