Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
27 changes: 14 additions & 13 deletions controller/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,20 @@ import (
// action 的 params 填充。本地化展示文案在前端 i18n 模板中维护,本表是语言中立的
// 英文基线——调用方因此无需在每个埋点处手写句子(避免与 params 重复书写同一份值)。
var auditContentTemplates = map[string]string{
"user.create": "Created user ${username} (role ${role})",
"user.update": "Updated user ${username} (ID: ${id})",
"user.delete": "Deleted user ${username} (ID: ${id})",
"user.manage": "Performed ${action} on user ${username} (ID: ${id})",
"user.quota_add": "Increased user quota by ${quota}",
"user.quota_subtract": "Decreased user quota by ${quota}",
"user.quota_override": "Overrode user quota from ${from} to ${to}",
"user.binding_clear": "Cleared ${bindingType} binding for user ${username}",
"user.2fa_disable": "Force-disabled two-factor authentication for the user",
"user.passkey_register": "Registered a passkey",
"user.passkey_delete": "Deleted a passkey",
"user.reset_passkey": "Reset the user passkey",
"option.update": "Updated system setting ${key}",
"user.create": "Created user ${username} (role ${role})",
"user.update": "Updated user ${username} (ID: ${id})",
"user.delete": "Deleted user ${username} (ID: ${id})",
"user.manage": "Performed ${action} on user ${username} (ID: ${id})",
"user.quota_add": "Increased user quota by ${quota}",
"user.quota_subtract": "Decreased user quota by ${quota}",
"user.quota_override": "Overrode user quota from ${from} to ${to}",
"user.binding_clear": "Cleared ${bindingType} binding for user ${username}",
"user.2fa_disable": "Force-disabled two-factor authentication for the user",
"user.passkey_register": "Registered a passkey",
"user.passkey_delete": "Deleted a passkey",
"user.reset_passkey": "Reset the user passkey",
"option.update": "Updated system setting ${key}",
"savings.official_price_update": "Updated savings official price setting",

"channel.create": "Created channel ${name} (type ${type}, count ${count})",
"channel.update": "Updated channel ${name} (ID: ${id})",
Expand Down
16 changes: 15 additions & 1 deletion controller/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/setting/savings_setting"
"github.com/QuantumNous/new-api/setting/system_setting"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -299,6 +300,15 @@ func UpdateOption(c *gin.Context) {
})
return
}
case savings_setting.OptionKey:
err = savings_setting.ValidateSettingJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "节省估算设置失败: " + err.Error(),
})
return
}
case "ModelRequestRateLimitGroup":
err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
if err != nil {
Expand Down Expand Up @@ -369,7 +379,11 @@ func UpdateOption(c *gin.Context) {
return
}
// 出于安全考虑只记录被修改的配置项名称,不记录配置值(可能含密钥等敏感信息)。
recordManageAudit(c, "option.update", map[string]interface{}{
auditAction := "option.update"
if option.Key == savings_setting.OptionKey {
auditAction = "savings.official_price_update"
}
recordManageAudit(c, auditAction, map[string]interface{}{
"key": option.Key,
})
c.JSON(http.StatusOK, gin.H{
Expand Down
108 changes: 108 additions & 0 deletions controller/savings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package controller

import (
"errors"
"net/http"
"strconv"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/service"

"github.com/gin-gonic/gin"
)

func GetUserSavingsSummary(c *gin.Context) {
startTimestamp, endTimestamp, err := parseSavingsTimeRange(c)
if err != nil {
respondSavingsBadRequest(c, err)
return
}
effectiveEndTimestamp, err := service.NormalizeSavingsSummaryWindow(startTimestamp, endTimestamp)
if err != nil {
respondSavingsBadRequest(c, err)
return
}

summary, err := service.GetUserSavingsSummary(c.GetInt("id"), startTimestamp, effectiveEndTimestamp)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, summary)
}

func GetUserSavingsLifetime(c *gin.Context) {
summary, err := service.GetUserSavingsLifetimeSummary(c.GetInt("id"))
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, summary)
}

func GetUserSavingsTrend(c *gin.Context) {
startTimestamp, endTimestamp, err := parseSavingsTimeRange(c)
if err != nil {
respondSavingsBadRequest(c, err)
return
}
granularity := c.Query("granularity")
utcOffsetMinutes, err := strconv.Atoi(c.Query("utc_offset_minutes"))
if err != nil {
respondSavingsBadRequest(c, service.ErrSavingsUTCOffsetRequired)
return
}
effectiveEndTimestamp, err := service.NormalizeSavingsTrendWindow(startTimestamp, endTimestamp, granularity, utcOffsetMinutes)
if err != nil {
respondSavingsBadRequest(c, err)
return
}

trend, err := service.GetUserSavingsTrend(c.GetInt("id"), startTimestamp, effectiveEndTimestamp, granularity, utcOffsetMinutes)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, trend)
}

func parseSavingsTimeRange(c *gin.Context) (int64, int64, error) {
startTimestamp, err := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
if err != nil {
return 0, 0, service.ErrSavingsTimeRangeRequired
}
endTimestamp, err := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
if err != nil {
return 0, 0, service.ErrSavingsTimeRangeRequired
}
return startTimestamp, endTimestamp, nil
}

func respondSavingsBadRequest(c *gin.Context, err error) {
messageKey := i18n.MsgInvalidParams
switch {
case errors.Is(err, service.ErrSavingsTimeRangeRequired):
messageKey = i18n.MsgSavingsTimeRangeRequired
case errors.Is(err, service.ErrSavingsUTCOffsetRequired):
messageKey = i18n.MsgSavingsUTCOffsetRequired
case errors.Is(err, service.ErrSavingsUTCOffsetInvalid):
messageKey = i18n.MsgSavingsUTCOffsetInvalid
case errors.Is(err, service.ErrSavingsEndAfterNow):
messageKey = i18n.MsgSavingsEndAfterNow
case errors.Is(err, service.ErrSavingsTimeRangeInvalid):
messageKey = i18n.MsgSavingsTimeRangeInvalid
case errors.Is(err, service.ErrSavingsTimeRangeTooLarge):
messageKey = i18n.MsgSavingsTimeRangeTooLarge
case errors.Is(err, service.ErrSavingsHourRangeTooLarge):
messageKey = i18n.MsgSavingsHourRangeTooLarge
case errors.Is(err, service.ErrSavingsGranularity):
messageKey = i18n.MsgSavingsGranularityInvalid
case errors.Is(err, service.ErrSavingsTooManyBuckets):
messageKey = i18n.MsgSavingsTooManyBuckets
}
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": i18n.T(c, messageKey),
})
}
68 changes: 68 additions & 0 deletions controller/savings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package controller

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"

"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestGetUserSavingsSummaryLocalizesInvalidTimeRange(t *testing.T) {
require.NoError(t, i18n.Init())
tests := []struct {
name string
language string
expected string
}{
{name: "English", language: "en", expected: "Start and end times are required"},
{name: "Chinese", language: "zh-CN", expected: "必须传入开始和结束时间"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/api/user/savings/summary", nil)
ctx.Request.Header.Set("Accept-Language", tt.language)

GetUserSavingsSummary(ctx)

var response struct {
Success bool `json:"success"`
Message string `json:"message"`
}
require.NoError(t, common.DecodeJson(recorder.Body, &response))
assert.Equal(t, http.StatusBadRequest, recorder.Code)
assert.False(t, response.Success)
assert.Equal(t, tt.expected, response.Message)
})
}
}

func TestGetUserSavingsTrendRejectsMissingUTCOffset(t *testing.T) {
require.NoError(t, i18n.Init())
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(
http.MethodGet,
"/api/user/savings/trend?start_timestamp=1&end_timestamp=2&granularity=day",
nil,
)

GetUserSavingsTrend(ctx)

var response struct {
Success bool `json:"success"`
Message string `json:"message"`
}
require.NoError(t, common.DecodeJson(recorder.Body, &response))
assert.Equal(t, http.StatusBadRequest, recorder.Code)
assert.False(t, response.Success)
assert.Equal(t, "UTC offset is required", response.Message)
}
56 changes: 56 additions & 0 deletions controller/system_task.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,62 @@ func CreateLogCleanupSystemTask(c *gin.Context) {
})
}

func CreateSavingsLifetimeBackfillTask(c *gin.Context) {
task, created, err := service.StartSavingsLifetimeBackfill()
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"created": created,
"task": task.ToResponse(),
},
})
}

func GetSavingsLifetimeBackfillTask(c *gin.Context) {
task, err := model.GetLatestSystemTask(model.SystemTaskTypeSavingsBackfill)
if err != nil {
common.ApiError(c, err)
return
}
if task == nil {
common.ApiSuccess(c, nil)
return
}
common.ApiSuccess(c, task.ToResponse())
}

func PauseSavingsLifetimeBackfillTask(c *gin.Context) {
task, err := service.PauseSavingsLifetimeBackfill(c.Query("task_id"))
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, task.ToResponse())
}

func ResumeSavingsLifetimeBackfillTask(c *gin.Context) {
task, err := service.ResumeSavingsLifetimeBackfill(c.Query("task_id"))
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, task.ToResponse())
}

func RetrySavingsLifetimeBackfillTask(c *gin.Context) {
task, err := service.RetrySavingsLifetimeBackfill(c.Query("task_id"))
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, task.ToResponse())
}

func GetCurrentSystemTask(c *gin.Context) {
taskType := c.Query("type")
if taskType == "" {
Expand Down
Loading