Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
779 lines
25 KiB
Go
779 lines
25 KiB
Go
package model
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/logger"
|
|
"github.com/QuantumNous/new-api/types"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func applyExplicitLogTextFilter(tx *gorm.DB, column string, value string) (*gorm.DB, error) {
|
|
if value == "" {
|
|
return tx, nil
|
|
}
|
|
if strings.Contains(value, "%") {
|
|
condition, pattern, err := buildLogLikeCondition(column, value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return tx.Where(condition, pattern), nil
|
|
}
|
|
return tx.Where(column+" = ?", value), nil
|
|
}
|
|
|
|
func buildLogLikeCondition(column string, value string) (string, string, error) {
|
|
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
|
|
pattern, err := sanitizeClickHouseLikePattern(value)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return column + " LIKE ?", pattern, nil
|
|
}
|
|
|
|
pattern, err := sanitizeLikePattern(value)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return column + " LIKE ? ESCAPE '!'", pattern, nil
|
|
}
|
|
|
|
func sanitizeClickHouseLikePattern(input string) (string, error) {
|
|
input = strings.ReplaceAll(input, `\`, `\\`)
|
|
input = strings.ReplaceAll(input, `_`, `\_`)
|
|
|
|
if err := validateLikePattern(input); err != nil {
|
|
return "", err
|
|
}
|
|
return input, nil
|
|
}
|
|
|
|
type Log struct {
|
|
AttemptKey *string `json:"attempt_key,omitempty" gorm:"type:varchar(96);uniqueIndex"`
|
|
Id int `json:"id" gorm:"index:idx_created_at_id,priority:2;index:idx_user_id_id,priority:2"`
|
|
UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"`
|
|
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:1;index:idx_created_at_type"`
|
|
Type int `json:"type" gorm:"index:idx_created_at_type"`
|
|
Content string `json:"content"`
|
|
Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"`
|
|
TokenName string `json:"token_name" gorm:"index;default:''"`
|
|
ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"`
|
|
Quota int `json:"quota" gorm:"default:0"`
|
|
PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
|
|
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
|
|
UseTime int `json:"use_time" gorm:"default:0"`
|
|
IsStream bool `json:"is_stream"`
|
|
ChannelId int `json:"channel" gorm:"index"`
|
|
ChannelName string `json:"channel_name" gorm:"->"`
|
|
TokenId int `json:"token_id" gorm:"default:0;index"`
|
|
Group string `json:"group" gorm:"index"`
|
|
Ip string `json:"ip" gorm:"index;default:''"`
|
|
RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
|
|
UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"`
|
|
CorrelationKey string `json:"-" gorm:"type:varchar(64);index:idx_logs_correlation;default:''"`
|
|
Other string `json:"other"`
|
|
}
|
|
|
|
// don't use iota, avoid change log type value
|
|
const (
|
|
LogTypeUnknown = 0
|
|
LogTypeTopup = 1
|
|
LogTypeConsume = 2
|
|
LogTypeManage = 3
|
|
LogTypeSystem = 4
|
|
LogTypeError = 5
|
|
LogTypeRefund = 6
|
|
LogTypeLogin = 7
|
|
)
|
|
|
|
func ensureLogRequestId(log *Log) {
|
|
if log != nil && log.RequestId == "" {
|
|
log.RequestId = common.NewRequestId()
|
|
}
|
|
}
|
|
|
|
func createLog(log *Log) error {
|
|
ensureLogRequestId(log)
|
|
return LOG_DB.Create(log).Error
|
|
}
|
|
|
|
func clickHouseLogOrder(prefix string) string {
|
|
return prefix + "created_at desc, " + prefix + "request_id desc"
|
|
}
|
|
|
|
func assignDisplayLogIds(logs []*Log, startIdx int) {
|
|
for i := range logs {
|
|
logs[i].Id = startIdx + i + 1
|
|
}
|
|
}
|
|
|
|
func formatUserLogs(logs []*Log, startIdx int) {
|
|
for i := range logs {
|
|
logs[i].ChannelName = ""
|
|
normalizeErrorLogGroup(logs[i])
|
|
logs[i].Other = formatLogOtherJSON(logs[i].Other, logOtherVisibilityUser)
|
|
}
|
|
assignDisplayLogIds(logs, startIdx)
|
|
}
|
|
|
|
// FormatAdminLogs removes root-only diagnostics while retaining operational
|
|
// admin_info. Root callers must not pass their results through this formatter.
|
|
func FormatAdminLogs(logs []*Log) {
|
|
for i := range logs {
|
|
normalizeErrorLogGroup(logs[i])
|
|
logs[i].Other = formatLogOtherJSON(logs[i].Other, logOtherVisibilityAdmin)
|
|
}
|
|
}
|
|
|
|
// FormatRootLogs normalizes legacy metadata into the current scoped shape
|
|
// without removing root-only diagnostics.
|
|
func FormatRootLogs(logs []*Log) {
|
|
for i := range logs {
|
|
normalizeErrorLogGroup(logs[i])
|
|
logs[i].Other = formatLogOtherJSON(logs[i].Other, logOtherVisibilityRoot)
|
|
}
|
|
}
|
|
|
|
func GetLogByTokenId(tokenId int) (logs []*Log, err error) {
|
|
order := "id desc"
|
|
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
|
|
order = clickHouseLogOrder("")
|
|
}
|
|
err = LOG_DB.Model(&Log{}).Where("token_id = ?", tokenId).Order(order).Limit(common.MaxRecentItems).Find(&logs).Error
|
|
formatUserLogs(logs, 0)
|
|
return logs, err
|
|
}
|
|
|
|
func RecordLog(userId int, logType int, content string) {
|
|
if logType == LogTypeConsume && !common.LogConsumeEnabled {
|
|
return
|
|
}
|
|
username, _ := GetUsernameById(userId, false)
|
|
log := &Log{
|
|
UserId: userId,
|
|
Username: username,
|
|
CreatedAt: common.GetTimestamp(),
|
|
Type: logType,
|
|
Content: content,
|
|
}
|
|
err := createLog(log)
|
|
if err != nil {
|
|
common.SysLog("failed to record log: " + err.Error())
|
|
}
|
|
}
|
|
|
|
// RecordLogWithAdminInfo stores operator metadata under other.admin_info and
|
|
// an optional, user-visible operation descriptor under other.op for localization.
|
|
func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo *AuditAdminInfo, operation *AuditOperation, request ...*gin.Context) {
|
|
if logType == LogTypeConsume && !common.LogConsumeEnabled {
|
|
return
|
|
}
|
|
username, _ := GetUsernameById(userId, false)
|
|
log := &Log{
|
|
UserId: userId,
|
|
Username: username,
|
|
CreatedAt: common.GetTimestamp(),
|
|
Type: logType,
|
|
Content: content,
|
|
}
|
|
if logType == LogTypeManage {
|
|
var c *gin.Context
|
|
if len(request) > 0 {
|
|
c = request[0]
|
|
}
|
|
actorRole := 0
|
|
if c != nil {
|
|
actorRole = c.GetInt("role")
|
|
}
|
|
RecordAuditLog(c, AuditLog{UserId: userId, Username: username, ActorRole: actorRole, Category: AuditCategoryOperation, Content: content, Other: AuditOther{AdminInfo: adminInfo, Op: operation}, Success: true})
|
|
return
|
|
}
|
|
if len(request) > 0 && request[0] != nil {
|
|
log.RequestId = request[0].GetString(common.RequestIdKey)
|
|
}
|
|
if adminInfo != nil || operation != nil {
|
|
data, err := common.Marshal(AuditOther{AdminInfo: adminInfo, Op: operation})
|
|
if err != nil {
|
|
common.SysError("failed to encode log admin info: " + err.Error())
|
|
return
|
|
}
|
|
log.Other = string(data)
|
|
}
|
|
if err := createLog(log); err != nil {
|
|
common.SysLog("failed to record log: " + err.Error())
|
|
}
|
|
}
|
|
|
|
// RecordLoginLog writes new login events to the independent audit table.
|
|
// username 由调用方传入(登录流程已持有用户对象),避免额外的数据库查询。
|
|
// content 为英文兜底文本(用于导出);action+params 供前端本地化渲染。
|
|
// other 包含 login_method、user_agent 等结构化信息。
|
|
func RecordLoginLog(userId, actorRole int, username string, content string, ip string, action string, params map[string]any, other AuditOther, request ...*gin.Context) {
|
|
other.Op = &AuditOperation{Action: action, Params: params}
|
|
var c *gin.Context
|
|
if len(request) > 0 {
|
|
c = request[0]
|
|
}
|
|
RecordAuditLog(c, AuditLog{UserId: userId, Username: username, ActorRole: actorRole, Category: AuditCategoryLogin, Action: action, Content: content, Ip: ip, Other: other, Success: true})
|
|
}
|
|
|
|
// RecordOperationAuditLog writes new operation/security events to the audit table.
|
|
// logUserId 为日志归属者,管理审计日志应归属实际操作者;目标资源/用户放入
|
|
// action params。username 内部按 logUserId 查询。content 为英文兜底文本(供导出使用)。
|
|
// action+params 写入 Other.op,供前端本地化渲染(普通用户可见,不含敏感信息)。
|
|
// adminInfo 存放操作者身份(写入 Other.admin_info,普通用户查询时剥离);
|
|
// auditInfo 存放路由/方法/结果等中间件兜底信息(写入 Other.audit_info,普通用户查询时剥离)。
|
|
func RecordOperationAuditLog(logUserId, actorRole int, content string, ip string, action string, params map[string]any, adminInfo *AuditAdminInfo, auditInfo *AuditRequestInfo, request ...*gin.Context) {
|
|
username, _ := GetUsernameById(logUserId, false)
|
|
other := AuditOther{
|
|
Op: &AuditOperation{Action: action, Params: params},
|
|
AdminInfo: adminInfo,
|
|
AuditInfo: auditInfo,
|
|
}
|
|
var c *gin.Context
|
|
if len(request) > 0 {
|
|
c = request[0]
|
|
}
|
|
category := AuditCategoryOperation
|
|
if adminInfo == nil {
|
|
category = AuditCategorySecurity
|
|
}
|
|
status, success := 200, true
|
|
if auditInfo != nil {
|
|
status = auditInfo.Status
|
|
success = auditInfo.Success
|
|
}
|
|
RecordAuditLog(c, AuditLog{UserId: logUserId, Username: username, ActorRole: actorRole, Category: category, Action: action, Content: content, Ip: ip, Status: status, Success: success, Other: other})
|
|
}
|
|
|
|
func RecordTopupLog(userId int, content string, callerIp string, paymentMethod string, callbackPaymentMethod string) {
|
|
username, _ := GetUsernameById(userId, false)
|
|
other := NewLogOther()
|
|
other.MergeAdmin(map[string]any{
|
|
"server_ip": common.GetIp(),
|
|
"node_name": common.NodeName,
|
|
"caller_ip": callerIp,
|
|
"payment_method": paymentMethod,
|
|
"callback_payment_method": callbackPaymentMethod,
|
|
"version": common.Version,
|
|
})
|
|
log := &Log{
|
|
UserId: userId,
|
|
Username: username,
|
|
CreatedAt: common.GetTimestamp(),
|
|
Type: LogTypeTopup,
|
|
Content: content,
|
|
Ip: callerIp,
|
|
Other: other.JSONString(),
|
|
}
|
|
err := createLog(log)
|
|
if err != nil {
|
|
common.SysLog("failed to record topup log: " + err.Error())
|
|
}
|
|
}
|
|
|
|
func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int,
|
|
isStream bool, group string, other *LogOther) {
|
|
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, common.LocalLogPreview(content)))
|
|
username := c.GetString("username")
|
|
requestId := c.GetString(common.RequestIdKey)
|
|
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
|
|
otherStr := other.JSONString()
|
|
// 判断是否需要记录 IP
|
|
needRecordIp := false
|
|
if settingMap, err := GetUserSetting(userId, false); err == nil {
|
|
if settingMap.RecordIpLog {
|
|
needRecordIp = true
|
|
}
|
|
}
|
|
log := &Log{
|
|
UserId: userId,
|
|
Username: username,
|
|
CreatedAt: common.GetTimestamp(),
|
|
Type: LogTypeError,
|
|
Content: content,
|
|
PromptTokens: 0,
|
|
CompletionTokens: 0,
|
|
TokenName: tokenName,
|
|
ModelName: modelName,
|
|
Quota: 0,
|
|
ChannelId: channelId,
|
|
TokenId: tokenId,
|
|
UseTime: useTimeSeconds,
|
|
IsStream: isStream,
|
|
Group: group,
|
|
Ip: func() string {
|
|
if needRecordIp {
|
|
return c.ClientIP()
|
|
}
|
|
return ""
|
|
}(),
|
|
RequestId: requestId,
|
|
UpstreamRequestId: upstreamRequestId,
|
|
CorrelationKey: c.GetString("log_correlation_key"),
|
|
Other: otherStr,
|
|
}
|
|
normalizeErrorLogGroup(log)
|
|
err := createLog(log)
|
|
if err != nil {
|
|
logger.LogError(c, "failed to record log: "+err.Error())
|
|
}
|
|
}
|
|
|
|
type RecordConsumeLogParams struct {
|
|
ChannelId int `json:"channel_id"`
|
|
PromptTokens int `json:"prompt_tokens"`
|
|
CompletionTokens int `json:"completion_tokens"`
|
|
ModelName string `json:"model_name"`
|
|
TokenName string `json:"token_name"`
|
|
Quota int `json:"quota"`
|
|
Content string `json:"content"`
|
|
TokenId int `json:"token_id"`
|
|
UseTimeSeconds int `json:"use_time_seconds"`
|
|
IsStream bool `json:"is_stream"`
|
|
Group string `json:"group"`
|
|
Other *LogOther `json:"other"`
|
|
}
|
|
|
|
func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) {
|
|
if !common.LogConsumeEnabled {
|
|
return
|
|
}
|
|
logger.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params)))
|
|
username := c.GetString("username")
|
|
requestId := c.GetString(common.RequestIdKey)
|
|
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
|
|
createdAt := common.GetTimestamp()
|
|
otherStr := params.Other.JSONString()
|
|
// 判断是否需要记录 IP
|
|
needRecordIp := false
|
|
if settingMap, err := GetUserSetting(userId, false); err == nil {
|
|
if settingMap.RecordIpLog {
|
|
needRecordIp = true
|
|
}
|
|
}
|
|
log := &Log{
|
|
UserId: userId,
|
|
Username: username,
|
|
CreatedAt: createdAt,
|
|
Type: LogTypeConsume,
|
|
Content: params.Content,
|
|
PromptTokens: params.PromptTokens,
|
|
CompletionTokens: params.CompletionTokens,
|
|
TokenName: params.TokenName,
|
|
ModelName: params.ModelName,
|
|
Quota: params.Quota,
|
|
ChannelId: params.ChannelId,
|
|
TokenId: params.TokenId,
|
|
UseTime: params.UseTimeSeconds,
|
|
IsStream: params.IsStream,
|
|
Group: params.Group,
|
|
Ip: func() string {
|
|
if needRecordIp {
|
|
return c.ClientIP()
|
|
}
|
|
return ""
|
|
}(),
|
|
RequestId: requestId,
|
|
UpstreamRequestId: upstreamRequestId,
|
|
CorrelationKey: c.GetString("log_correlation_key"),
|
|
Other: otherStr,
|
|
}
|
|
err := createLog(log)
|
|
if err != nil {
|
|
logger.LogError(c, "failed to record log: "+err.Error())
|
|
}
|
|
if common.DataExportEnabled {
|
|
LogQuotaData(QuotaDataLogParams{
|
|
UserID: userId,
|
|
Username: username,
|
|
ModelName: params.ModelName,
|
|
Quota: params.Quota,
|
|
CreatedAt: createdAt,
|
|
TokenUsed: params.PromptTokens + params.CompletionTokens,
|
|
UseGroup: params.Group,
|
|
TokenID: params.TokenId,
|
|
ChannelID: params.ChannelId,
|
|
NodeName: common.NodeName,
|
|
})
|
|
}
|
|
}
|
|
|
|
type RecordTaskBillingLogParams struct {
|
|
UserId int
|
|
LogType int
|
|
Content string
|
|
ChannelId int
|
|
ModelName string
|
|
Quota int
|
|
TokenId int
|
|
Group string
|
|
Other *LogOther
|
|
NodeName string // 任务发起节点;为空时回退当前节点
|
|
}
|
|
|
|
func RecordTaskBillingLog(params RecordTaskBillingLogParams) {
|
|
if params.LogType == LogTypeConsume && !common.LogConsumeEnabled {
|
|
return
|
|
}
|
|
username, _ := GetUsernameById(params.UserId, false)
|
|
tokenName := ""
|
|
if params.TokenId > 0 {
|
|
if token, err := GetTokenById(params.TokenId); err == nil {
|
|
tokenName = token.Name
|
|
}
|
|
}
|
|
createdAt := common.GetTimestamp()
|
|
log := &Log{
|
|
UserId: params.UserId,
|
|
Username: username,
|
|
CreatedAt: createdAt,
|
|
Type: params.LogType,
|
|
Content: params.Content,
|
|
TokenName: tokenName,
|
|
ModelName: params.ModelName,
|
|
Quota: params.Quota,
|
|
ChannelId: params.ChannelId,
|
|
TokenId: params.TokenId,
|
|
Group: params.Group,
|
|
Other: params.Other.JSONString(),
|
|
}
|
|
err := createLog(log)
|
|
if err != nil {
|
|
common.SysLog("failed to record task billing log: " + err.Error())
|
|
}
|
|
if params.LogType == LogTypeConsume && common.DataExportEnabled {
|
|
nodeName := params.NodeName
|
|
if nodeName == "" {
|
|
nodeName = common.NodeName
|
|
}
|
|
LogQuotaData(QuotaDataLogParams{
|
|
UserID: params.UserId,
|
|
Username: username,
|
|
ModelName: params.ModelName,
|
|
Quota: params.Quota,
|
|
CreatedAt: createdAt,
|
|
UseGroup: params.Group,
|
|
TokenID: params.TokenId,
|
|
ChannelID: params.ChannelId,
|
|
NodeName: nodeName,
|
|
})
|
|
}
|
|
}
|
|
|
|
func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string, upstreamRequestId string, correlationKey ...string) (logs []*Log, total int64, err error) {
|
|
var tx *gorm.DB
|
|
if logType == LogTypeUnknown {
|
|
tx = LOG_DB
|
|
} else {
|
|
tx = LOG_DB.Where("logs.type = ?", logType)
|
|
}
|
|
|
|
if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if tx, err = applyExplicitLogTextFilter(tx, "logs.username", username); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if tokenName != "" {
|
|
tx = tx.Where("logs.token_name = ?", tokenName)
|
|
}
|
|
if len(correlationKey) > 1 {
|
|
tx = applyAttemptLogFilter(tx, correlationKey[1])
|
|
}
|
|
if len(correlationKey) > 0 && correlationKey[0] != "" {
|
|
tx = tx.Where("logs.correlation_key = ?", correlationKey[0])
|
|
}
|
|
if requestId != "" {
|
|
tx = tx.Where("logs.request_id = ?", requestId)
|
|
}
|
|
if upstreamRequestId != "" {
|
|
tx = tx.Where("logs.upstream_request_id = ?", upstreamRequestId)
|
|
}
|
|
if startTimestamp != 0 {
|
|
tx = tx.Where("logs.created_at >= ?", startTimestamp)
|
|
}
|
|
if endTimestamp != 0 {
|
|
tx = tx.Where("logs.created_at <= ?", endTimestamp)
|
|
}
|
|
if channel != 0 {
|
|
tx = tx.Where("logs.channel_id = ?", channel)
|
|
}
|
|
if group != "" {
|
|
tx = tx.Where("logs."+logGroupCol+" = ?", group)
|
|
}
|
|
err = tx.Model(&Log{}).Count(&total).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
order := "logs.created_at desc, logs.id desc"
|
|
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
|
|
order = clickHouseLogOrder("logs.")
|
|
}
|
|
err = tx.Order(order).Limit(num).Offset(startIdx).Find(&logs).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
|
|
assignDisplayLogIds(logs, startIdx)
|
|
}
|
|
|
|
channelIds := types.NewSet[int]()
|
|
for _, log := range logs {
|
|
if log.ChannelId != 0 {
|
|
channelIds.Add(log.ChannelId)
|
|
}
|
|
}
|
|
|
|
if channelIds.Len() > 0 {
|
|
var channels []struct {
|
|
Id int `gorm:"column:id"`
|
|
Name string `gorm:"column:name"`
|
|
}
|
|
if common.MemoryCacheEnabled {
|
|
// Cache get channel
|
|
for _, channelId := range channelIds.Items() {
|
|
if cacheChannel, err := CacheGetChannel(channelId); err == nil {
|
|
channels = append(channels, struct {
|
|
Id int `gorm:"column:id"`
|
|
Name string `gorm:"column:name"`
|
|
}{
|
|
Id: channelId,
|
|
Name: cacheChannel.Name,
|
|
})
|
|
}
|
|
}
|
|
} else {
|
|
// Bulk query channels from DB
|
|
if err = DB.Table("channels").Select("id, name").Where("id IN ?", channelIds.Items()).Find(&channels).Error; err != nil {
|
|
return logs, total, err
|
|
}
|
|
}
|
|
channelMap := make(map[int]string, len(channels))
|
|
for _, channel := range channels {
|
|
channelMap[channel.Id] = channel.Name
|
|
}
|
|
for i := range logs {
|
|
logs[i].ChannelName = channelMap[logs[i].ChannelId]
|
|
}
|
|
}
|
|
|
|
return logs, total, err
|
|
}
|
|
|
|
const logSearchCountLimit = 10000
|
|
|
|
func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string, upstreamRequestId string, attemptStatus ...string) (logs []*Log, total int64, err error) {
|
|
var tx *gorm.DB
|
|
if logType == LogTypeUnknown {
|
|
tx = LOG_DB.Where("logs.user_id = ?", userId)
|
|
} else {
|
|
tx = LOG_DB.Where("logs.user_id = ? and logs.type = ?", userId, logType)
|
|
}
|
|
|
|
if len(attemptStatus) > 0 {
|
|
tx = applyAttemptLogFilter(tx, attemptStatus[0])
|
|
}
|
|
|
|
if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if tokenName != "" {
|
|
tx = tx.Where("logs.token_name = ?", tokenName)
|
|
}
|
|
if requestId != "" {
|
|
tx = tx.Where("logs.request_id = ?", requestId)
|
|
}
|
|
if upstreamRequestId != "" {
|
|
tx = tx.Where("logs.upstream_request_id = ?", upstreamRequestId)
|
|
}
|
|
if startTimestamp != 0 {
|
|
tx = tx.Where("logs.created_at >= ?", startTimestamp)
|
|
}
|
|
if endTimestamp != 0 {
|
|
tx = tx.Where("logs.created_at <= ?", endTimestamp)
|
|
}
|
|
if group != "" {
|
|
tx = tx.Where("logs."+logGroupCol+" = ?", group)
|
|
}
|
|
err = tx.Model(&Log{}).Limit(logSearchCountLimit).Count(&total).Error
|
|
if err != nil {
|
|
common.SysError("failed to count user logs: " + err.Error())
|
|
return nil, 0, errors.New("查询日志失败")
|
|
}
|
|
order := "logs.id desc"
|
|
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
|
|
order = clickHouseLogOrder("logs.")
|
|
}
|
|
err = tx.Order(order).Limit(num).Offset(startIdx).Find(&logs).Error
|
|
if err != nil {
|
|
common.SysError("failed to search user logs: " + err.Error())
|
|
return nil, 0, errors.New("查询日志失败")
|
|
}
|
|
|
|
formatUserLogs(logs, startIdx)
|
|
return logs, total, err
|
|
}
|
|
|
|
type Stat struct {
|
|
Quota int `json:"quota"`
|
|
Rpm int `json:"rpm"`
|
|
Tpm int `json:"tpm"`
|
|
}
|
|
|
|
func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int, group string, correlationKey ...string) (stat Stat, err error) {
|
|
tx := LOG_DB.Table("logs").Select("COALESCE(sum(quota), 0) quota")
|
|
|
|
// 为rpm和tpm创建单独的查询
|
|
rpmTpmQuery := LOG_DB.Table("logs").Select("count(*) rpm, COALESCE(sum(prompt_tokens), 0) + COALESCE(sum(completion_tokens), 0) tpm")
|
|
if len(correlationKey) > 0 && correlationKey[0] != "" {
|
|
tx = tx.Where("correlation_key = ?", correlationKey[0])
|
|
rpmTpmQuery = rpmTpmQuery.Where("correlation_key = ?", correlationKey[0])
|
|
}
|
|
|
|
if tx, err = applyExplicitLogTextFilter(tx, "username", username); err != nil {
|
|
return stat, err
|
|
}
|
|
if rpmTpmQuery, err = applyExplicitLogTextFilter(rpmTpmQuery, "username", username); err != nil {
|
|
return stat, err
|
|
}
|
|
if tokenName != "" {
|
|
tx = tx.Where("token_name = ?", tokenName)
|
|
rpmTpmQuery = rpmTpmQuery.Where("token_name = ?", tokenName)
|
|
}
|
|
if startTimestamp != 0 {
|
|
tx = tx.Where("created_at >= ?", startTimestamp)
|
|
}
|
|
if endTimestamp != 0 {
|
|
tx = tx.Where("created_at <= ?", endTimestamp)
|
|
}
|
|
if tx, err = applyExplicitLogTextFilter(tx, "model_name", modelName); err != nil {
|
|
return stat, err
|
|
}
|
|
if rpmTpmQuery, err = applyExplicitLogTextFilter(rpmTpmQuery, "model_name", modelName); err != nil {
|
|
return stat, err
|
|
}
|
|
if channel != 0 {
|
|
tx = tx.Where("channel_id = ?", channel)
|
|
rpmTpmQuery = rpmTpmQuery.Where("channel_id = ?", channel)
|
|
}
|
|
if group != "" {
|
|
tx = tx.Where(logGroupCol+" = ?", group)
|
|
rpmTpmQuery = rpmTpmQuery.Where(logGroupCol+" = ?", group)
|
|
}
|
|
|
|
tx = tx.Where("type = ?", LogTypeConsume)
|
|
rpmTpmQuery = rpmTpmQuery.Where("type = ?", LogTypeConsume)
|
|
|
|
if len(correlationKey) > 1 {
|
|
tx = applyAttemptLogFilter(tx, correlationKey[1])
|
|
rpmTpmQuery = applyAttemptLogFilter(rpmTpmQuery, correlationKey[1])
|
|
}
|
|
|
|
// 只统计最近60秒的rpm和tpm
|
|
rpmTpmQuery = rpmTpmQuery.Where("created_at >= ?", time.Now().Add(-60*time.Second).Unix())
|
|
|
|
// 执行查询
|
|
if err := tx.Scan(&stat).Error; err != nil {
|
|
common.SysError("failed to query log stat: " + err.Error())
|
|
return stat, errors.New("查询统计数据失败")
|
|
}
|
|
var rateStat struct {
|
|
Rpm int
|
|
Tpm int
|
|
}
|
|
if err := rpmTpmQuery.Scan(&rateStat).Error; err != nil {
|
|
common.SysError("failed to query rpm/tpm stat: " + err.Error())
|
|
return stat, errors.New("查询统计数据失败")
|
|
}
|
|
stat.Rpm = rateStat.Rpm
|
|
stat.Tpm = rateStat.Tpm
|
|
|
|
return stat, nil
|
|
}
|
|
|
|
func SumUsedToken(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string) (token int) {
|
|
tx := LOG_DB.Table("logs").Select("COALESCE(sum(prompt_tokens), 0) + COALESCE(sum(completion_tokens), 0)")
|
|
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 = ?", modelName)
|
|
}
|
|
tx.Where("type = ?", LogTypeConsume).Scan(&token)
|
|
return token
|
|
}
|
|
|
|
func CountOldLog(ctx context.Context, targetTimestamp int64) (int64, error) {
|
|
var total int64
|
|
if err := LOG_DB.WithContext(ctx).Model(&Log{}).Where("created_at < ?", targetTimestamp).Count(&total).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
func DeleteOldLogBatch(ctx context.Context, targetTimestamp int64, limit int) (int64, error) {
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
if nil != ctx.Err() {
|
|
return 0, ctx.Err()
|
|
}
|
|
|
|
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
|
|
// ClickHouse DELETE is a heavy mutation that rewrites data parts, so
|
|
// per-batch mutations would be pathologically slow. Remove all matching
|
|
// rows in a single synchronous mutation regardless of limit; the reported
|
|
// count lets the caller's progress loop complete in one pass.
|
|
total, err := CountOldLog(ctx, targetTimestamp)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if total == 0 {
|
|
return 0, nil
|
|
}
|
|
if err := LOG_DB.WithContext(ctx).Exec(
|
|
"ALTER TABLE logs DELETE WHERE created_at < ? SETTINGS mutations_sync = 1",
|
|
targetTimestamp,
|
|
).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
result := LOG_DB.WithContext(ctx).Where("created_at < ?", targetTimestamp).Limit(limit).Delete(&Log{})
|
|
if nil != result.Error {
|
|
return 0, result.Error
|
|
}
|
|
return result.RowsAffected, nil
|
|
}
|
|
|
|
// A fixed top-level marker keeps filtering portable across SQL log stores.
|
|
func applyAttemptLogFilter(tx *gorm.DB, status string) *gorm.DB {
|
|
switch status {
|
|
case "verified", "unchecked", "rejected", "charged_rejection", "switched_success", "mismatch_allowed", "transport_error":
|
|
return tx.Where("other LIKE ?", `%"routing_status":"`+status+`"%`)
|
|
case "estimated":
|
|
return tx.Where("other LIKE ?", `%"attempt_usage_source":"estimated"%`)
|
|
default:
|
|
return tx
|
|
}
|
|
}
|