Skip to content
Merged
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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,11 @@ For request structs that are parsed from client JSON and then re-marshaled to up
### Rule 7: Billing Expression System — Read `pkg/billingexpr/expr.md`

When working on tiered/dynamic billing (expression-based pricing), you MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language (variables, functions, examples), full system architecture (editor → storage → pre-consume → settlement → log display), token normalization rules (`p`/`c` auto-exclusion), quota conversion, and expression versioning. All code changes to the billing expression system must follow the patterns described in that document.

### Rule 8: Pull Requests — Identify AI-Generated Contributions When Appropriate

When creating a pull request:

- First compare the current git user (`git config user.name` / `git config user.email`) with the repository's historical core developers (for example, the recurring top authors in `git log`). Do not change git config.
- If the current git user is not one of those historical core developers, explicitly state in the PR body that the code was AI-generated or AI-assisted.
- Always use the repository PR template at `.github/PULL_REQUEST_TEMPLATE.md` when drafting the PR title/body. Preserve the template structure and fill in the relevant sections instead of replacing it with an ad hoc format.
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,11 @@ For request structs that are parsed from client JSON and then re-marshaled to up
### Rule 7: Billing Expression System — Read `pkg/billingexpr/expr.md`

When working on tiered/dynamic billing (expression-based pricing), you MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language (variables, functions, examples), full system architecture (editor → storage → pre-consume → settlement → log display), token normalization rules (`p`/`c` auto-exclusion), quota conversion, and expression versioning. All code changes to the billing expression system must follow the patterns described in that document.

### Rule 8: Pull Requests — Identify AI-Generated Contributions When Appropriate

When creating a pull request:

- First compare the current git user (`git config user.name` / `git config user.email`) with the repository's historical core developers (for example, the recurring top authors in `git log`). Do not change git config.
- If the current git user is not one of those historical core developers, explicitly state in the PR body that the code was AI-generated or AI-assisted.
- Always use the repository PR template at `.github/PULL_REQUEST_TEMPLATE.md` when drafting the PR title/body. Preserve the template structure and fill in the relevant sections instead of replacing it with an ad hoc format.
6 changes: 6 additions & 0 deletions constant/context_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,10 @@ const (
// ContextKeyLanguage stores the user's language preference for i18n
ContextKeyLanguage ContextKey = "language"
ContextKeyIsStream ContextKey = "is_stream"

// ContextKeyAuditLogged marks that the current request has already recorded
// a manage/operation audit log inside the handler. When set, the admin-audit
// fallback in authHelper (finishAdminAudit) skips its record to avoid
// duplicate entries.
ContextKeyAuditLogged ContextKey = "audit_logged"
)
97 changes: 97 additions & 0 deletions controller/audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package controller

import (
"fmt"
"os"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"

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

// auditContentTemplates 将稳定的操作标识 action 映射为英文兜底模板,渲染后写入
// Log.Content(供导出 / 经典前端等非本地化消费者使用)。占位符为 ${name},由该
// 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}",

"channel.create": "Created channel ${name} (type ${type}, count ${count})",
"channel.update": "Updated channel ${name} (ID: ${id})",
"channel.delete": "Deleted channel ${name} (ID: ${id})",
"channel.delete_batch": "Batch deleted ${count} channels",
"channel.delete_disabled": "Deleted all disabled channels (${count})",
"channel.key_view": "Viewed channel key ${name} (ID: ${id})",
"channel.tag_disable": "Disabled channels with tag ${tag}",
"channel.tag_enable": "Enabled channels with tag ${tag}",
"channel.tag_edit": "Edited channels with tag ${tag}",
"channel.tag_batch_set": "Batch set tag for ${count} channels",
"channel.copy": "Copied channel (source ID: ${sourceId}) to ${name} (new ID: ${id})",
"channel.multi_key_manage": "Multi-key management ${action} on channel (ID: ${id})",
"channel.upstream_apply": "Applied upstream model changes to channel (ID: ${id})",
"channel.upstream_apply_all": "Applied upstream model changes to ${count} channels",

"redemption.create": "Created ${count} redemption codes named ${name} (${quota} each)",
}

// auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。
func auditContentEN(action string, params map[string]interface{}) string {
tmpl, ok := auditContentTemplates[action]
if !ok {
return action
}
return os.Expand(tmpl, func(key string) string {
if v, ok := params[key]; ok {
return fmt.Sprintf("%v", v)
}
return ""
})
}

// auditOperatorInfo 从上下文构建操作者身份信息(管理员 id/用户名/角色)。
func auditOperatorInfo(c *gin.Context) map[string]interface{} {
return map[string]interface{}{
"admin_id": c.GetInt("id"),
"admin_username": c.GetString("username"),
"admin_role": c.GetInt("role"),
}
}

// markAuditLogged 标记当前请求已在 handler 内手动记录审计日志,
// 使鉴权链路中的审计兜底(finishAdminAudit)跳过兜底记录,避免重复。
func markAuditLogged(c *gin.Context) {
common.SetContextKey(c, constant.ContextKeyAuditLogged, true)
}

// recordManageAudit 记录一条由操作者本人归属的管理/高危审计日志(资源类操作:
// 渠道 / 系统设置 / 兑换码等)。content 由 action+params 自动渲染。
func recordManageAudit(c *gin.Context, action string, params map[string]interface{}) {
recordManageAuditFor(c, c.GetInt("id"), action, params)
}

// recordManageAuditFor 记录一条归属于 logUserId 的管理审计日志(面向用户的操作:
// 对目标用户的额度调整 / 解绑 / 2FA 等,使该用户也能在自己的日志中看到)。
func recordManageAuditFor(c *gin.Context, logUserId int, action string, params map[string]interface{}) {
model.RecordOperationAuditLog(logUserId, auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), nil)
markAuditLogged(c)
}

// recordUserSecurityAudit 记录普通用户自己的安全敏感操作(如 passkey 绑定/解绑)。
// 这类日志没有管理员操作者,不写 admin_info;同时不依赖 AdminAuth/RootAuth 的兜底。
func recordUserSecurityAudit(c *gin.Context, userId int, action string, params map[string]interface{}) {
model.RecordOperationAuditLog(userId, auditContentEN(action, params), c.ClientIP(), action, params, nil, nil)
}
90 changes: 87 additions & 3 deletions controller/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,6 @@ func GetChannel(c *gin.Context) {
// GetChannelKey 获取渠道密钥(需要通过安全验证中间件)
// 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证
func GetChannelKey(c *gin.Context) {
userId := c.GetInt("id")
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err))
Expand All @@ -423,8 +422,11 @@ func GetChannelKey(c *gin.Context) {
return
}

// 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId))
// 记录操作审计日志(高危:查看渠道密钥)
recordManageAudit(c, "channel.key_view", map[string]interface{}{
"id": channelId,
"name": channel.Name,
})

// 返回渠道密钥
c.JSON(http.StatusOK, gin.H{
Expand Down Expand Up @@ -677,6 +679,11 @@ func AddChannel(c *gin.Context) {
return
}
service.ResetProxyClientCache()
recordManageAudit(c, "channel.create", map[string]interface{}{
"name": addChannelRequest.Channel.Name,
"type": addChannelRequest.Channel.Type,
"count": len(channels),
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand All @@ -686,13 +693,21 @@ func AddChannel(c *gin.Context) {

func DeleteChannel(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
channelName := ""
if existing, err := model.GetChannelById(id, false); err == nil && existing != nil {
channelName = existing.Name
}
channel := model.Channel{Id: id}
err := channel.Delete()
if err != nil {
common.ApiError(c, err)
return
}
model.InitChannelCache()
recordManageAudit(c, "channel.delete", map[string]interface{}{
"id": id,
"name": channelName,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand All @@ -707,6 +722,9 @@ func DeleteDisabledChannel(c *gin.Context) {
return
}
model.InitChannelCache()
recordManageAudit(c, "channel.delete_disabled", map[string]interface{}{
"count": rows,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand Down Expand Up @@ -743,6 +761,9 @@ func DisableTagChannels(c *gin.Context) {
return
}
model.InitChannelCache()
recordManageAudit(c, "channel.tag_disable", map[string]interface{}{
"tag": channelTag.Tag,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand All @@ -766,6 +787,9 @@ func EnableTagChannels(c *gin.Context) {
return
}
model.InitChannelCache()
recordManageAudit(c, "channel.tag_enable", map[string]interface{}{
"tag": channelTag.Tag,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand Down Expand Up @@ -818,6 +842,9 @@ func EditTagChannels(c *gin.Context) {
return
}
model.InitChannelCache()
recordManageAudit(c, "channel.tag_edit", map[string]interface{}{
"tag": channelTag.Tag,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand Down Expand Up @@ -846,6 +873,9 @@ func DeleteChannelBatch(c *gin.Context) {
return
}
model.InitChannelCache()
recordManageAudit(c, "channel.delete_batch", map[string]interface{}{
"count": len(channelBatch.Ids),
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand Down Expand Up @@ -981,6 +1011,31 @@ func UpdateChannel(c *gin.Context) {
}
model.InitChannelCache()
service.ResetProxyClientCache()
// 记录变更的字段名(语言无关的字段标识),密钥仅记录"已更换"绝不记录内容。
changedFields := make([]string, 0)
if channel.Status != originChannel.Status {
changedFields = append(changedFields, "status")
}
if channel.Models != originChannel.Models {
changedFields = append(changedFields, "models")
}
if channel.Group != originChannel.Group {
changedFields = append(changedFields, "group")
}
if channel.Type != originChannel.Type {
changedFields = append(changedFields, "type")
}
if !equalStringPtr(channel.BaseURL, originChannel.BaseURL) {
changedFields = append(changedFields, "base_url")
}
if channel.Key != "" && channel.Key != originChannel.Key {
changedFields = append(changedFields, "key")
}
recordManageAudit(c, "channel.update", map[string]interface{}{
"id": channel.Id,
"name": channel.Name,
"changed_fields": changedFields,
})
channel.Key = ""
clearChannelInfo(&channel.Channel)
c.JSON(http.StatusOK, gin.H{
Expand All @@ -991,6 +1046,17 @@ func UpdateChannel(c *gin.Context) {
return
}

// equalStringPtr 比较两个 *string 是否相等(均为 nil 视为相等)。
func equalStringPtr(a, b *string) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}

func FetchModels(c *gin.Context) {
var req struct {
BaseURL string `json:"base_url"`
Expand Down Expand Up @@ -1127,6 +1193,9 @@ func BatchSetChannelTag(c *gin.Context) {
return
}
model.InitChannelCache()
recordManageAudit(c, "channel.tag_batch_set", map[string]interface{}{
"count": len(channelBatch.Ids),
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand Down Expand Up @@ -1224,6 +1293,11 @@ func CopyChannel(c *gin.Context) {
return
}
model.InitChannelCache()
recordManageAudit(c, "channel.copy", map[string]interface{}{
"sourceId": id,
"id": clone.Id,
"name": clone.Name,
})
// success
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"id": clone.Id}})
}
Expand Down Expand Up @@ -1285,6 +1359,16 @@ func ManageMultiKeys(c *gin.Context) {
return
}

// get_key_status 为只读查询,不记录审计;其余为修改操作,记录审计并跳过中间件兜底。
if request.Action == "get_key_status" {
markAuditLogged(c)
} else {
recordManageAudit(c, "channel.multi_key_manage", map[string]interface{}{
"action": request.Action,
"id": channel.Id,
})
}

lock := model.GetChannelPollingLock(channel.Id)
lock.Lock()
defer lock.Unlock()
Expand Down
6 changes: 6 additions & 0 deletions controller/channel_upstream_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,9 @@ func ApplyChannelUpstreamModelUpdates(c *gin.Context) {
refreshChannelRuntimeCache()
}

recordManageAudit(c, "channel.upstream_apply", map[string]interface{}{
"id": channel.Id,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand Down Expand Up @@ -912,6 +915,9 @@ func ApplyAllChannelUpstreamModelUpdates(c *gin.Context) {
refreshChannelRuntimeCache()
}

recordManageAudit(c, "channel.upstream_apply_all", map[string]interface{}{
"count": len(results),
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand Down
4 changes: 4 additions & 0 deletions controller/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,10 @@ func UpdateOption(c *gin.Context) {
common.ApiError(c, err)
return
}
// 出于安全考虑只记录被修改的配置项名称,不记录配置值(可能含密钥等敏感信息)。
recordManageAudit(c, "option.update", map[string]interface{}{
"key": option.Key,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand Down
7 changes: 6 additions & 1 deletion controller/passkey.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ func PasskeyRegisterFinish(c *gin.Context) {
return
}

recordUserSecurityAudit(c, user.Id, "user.passkey_register", nil)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 注册成功",
Expand All @@ -168,6 +169,7 @@ func PasskeyDelete(c *gin.Context) {
return
}

recordUserSecurityAudit(c, user.Id, "user.passkey_delete", nil)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 已解绑",
Expand Down Expand Up @@ -335,7 +337,6 @@ func PasskeyLoginFinish(c *gin.Context) {
}

setupLogin(modelUser, c)
return
}

func AdminResetPasskey(c *gin.Context) {
Expand Down Expand Up @@ -373,6 +374,10 @@ func AdminResetPasskey(c *gin.Context) {
return
}

recordManageAuditFor(c, user.Id, "user.reset_passkey", map[string]interface{}{
"username": user.Username,
"id": user.Id,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 已重置",
Expand Down
Loading