feat: 操作日志功能实现及字段级diff优化

- 新增操作日志完整功能: model/service/controller/middleware + 前端页面
- 用户管理 UpdateUser/ManageUser 改为字段级 diff,仅记录实际变更字段
- 模型定价 UpdateOption 改为模型粒度 diff,仅记录变更的模型条目
- 防止中间件与 controller 重复记录操作日志
- 修复前端侧边栏操作日志模块可见性及权限配置合并
This commit is contained in:
xiezhouwei 2026-06-04 15:16:42 +08:00
parent ad2fb8b64f
commit aa1c9559c3
20 changed files with 1362 additions and 8 deletions

View File

@ -6,11 +6,11 @@ COPY web/bun.lock .
RUN bun install
COPY ./web .
COPY ./VERSION .
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) NODE_OPTIONS="--max-old-space-size=4096" bun run build
FROM golang:1.26.2-alpine@sha256:c2a1f7b2095d046ae14b286b18413a05bb82c9bca9b25fe7ff5efef0f0826166 AS builder2
ENV GO111MODULE=on CGO_ENABLED=0
ENV GOPROXY=https://goproxy.cn,direct
ENV GOPROXY=https://goproxy.cn,https://goproxy.io,https://proxy.golang.org,off
ARG TARGETOS
ARG TARGETARCH
@ -27,8 +27,8 @@ RUN --mount=type=secret,id=github_token \
token="$(cat /run/secrets/github_token || true)"; \
if [ -n "$token" ]; then \
git config --global url."https://x-access-token:${token}@github.com/".insteadOf "https://github.com/"; \
go env -w GOPRIVATE=github.com/fyinfor/*; \
fi; \
go env -w GOPRIVATE=github.com/fyinfor/*; \
go mod download; \
if [ -n "$token" ]; then \
git config --global --unset-all url."https://x-access-token:${token}@github.com/".insteadOf || true; \

View File

@ -1343,6 +1343,12 @@ func AddChannel(c *gin.Context) {
return
}
service.ResetProxyClientCache()
// 记录操作日志
channelName := ""
if addChannelRequest.Channel != nil {
channelName = addChannelRequest.Channel.Name
}
service.RecordCreateOperation(c, "channel", 0, channelName, "创建渠道: "+channelName, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@ -1359,6 +1365,8 @@ func DeleteChannel(c *gin.Context) {
return
}
model.InitChannelCache()
// 记录操作日志
service.RecordDeleteOperation(c, "channel", id, "", fmt.Sprintf("删除渠道 (ID: %d)", id), "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@ -1712,6 +1720,8 @@ func UpdateChannel(c *gin.Context) {
service.ResetProxyClientCache()
channel.Key = ""
clearChannelInfo(&channel.Channel)
// 记录操作日志
service.RecordUpdateOperation(c, "channel", channel.Id, channel.Name, "更新渠道: "+channel.Name, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",

View File

@ -2,6 +2,7 @@ package controller
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
@ -9,6 +10,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
@ -268,6 +270,7 @@ func CreateModelMeta(c *gin.Context) {
return
}
model.RefreshPricing()
service.RecordCreateOperation(c, "model", m.Id, m.ModelName, "创建模型: "+m.ModelName, "")
common.ApiSuccess(c, &m)
}
@ -316,6 +319,7 @@ func UpdateModelMeta(c *gin.Context) {
}
}
model.RefreshPricing()
service.RecordUpdateOperation(c, "model", m.Id, m.ModelName, "更新模型: "+m.ModelName, "")
common.ApiSuccess(c, &m)
}
@ -332,6 +336,7 @@ func DeleteModelMeta(c *gin.Context) {
return
}
model.RefreshPricing()
service.RecordDeleteOperation(c, "model", id, "", fmt.Sprintf("删除模型 (ID: %d)", id), "")
common.ApiSuccess(c, nil)
}

View File

@ -0,0 +1,89 @@
package controller
import (
"net/http"
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// GetAllOperationLogs 管理员查询所有操作日志。
func GetAllOperationLogs(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
action := c.Query("action")
targetType := c.Query("target_type")
targetId, _ := strconv.Atoi(c.Query("target_id"))
username := c.Query("username")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
logs, total, err := model.GetOperationLogs(action, targetType, targetId, username, startTimestamp, endTimestamp, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs)
common.ApiSuccess(c, pageInfo)
}
// GetUserOperationLogs 用户查询自己的操作日志。
func GetUserOperationLogs(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
userId := c.GetInt("id")
action := c.Query("action")
targetType := c.Query("target_type")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
logs, total, err := model.GetUserOperationLogs(userId, action, targetType, startTimestamp, endTimestamp, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs)
common.ApiSuccess(c, pageInfo)
}
// GetOperationLogStats 管理员查看操作日志统计。
func GetOperationLogStats(c *gin.Context) {
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
stats, err := model.GetOperationLogStats(startTimestamp, endTimestamp)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": stats,
})
}
// DeleteHistoryOperationLogs 清理历史操作日志。
func DeleteHistoryOperationLogs(c *gin.Context) {
targetTimestamp, _ := strconv.ParseInt(c.Query("target_timestamp"), 10, 64)
if targetTimestamp == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "target timestamp is required",
})
return
}
count, err := model.DeleteOldOperationLogs(c.Request.Context(), targetTimestamp, 100)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": count,
})
}

View File

@ -7,6 +7,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/console_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
@ -35,6 +36,102 @@ var completionRatioMetaOptionKeys = []string{
"ChannelImagePricingRules",
}
// modelPricingOptionKeys 包含值格式为模型名→价格/倍率 JSON 对象的选项 key。
// 对这些 key 的更新会按模型粒度比较新旧值,仅记录实际变化的条目。
var modelPricingOptionKeys = map[string]bool{
"ModelPrice": true,
"ModelRatio": true,
"CompletionRatio": true,
"CacheRatio": true,
"CreateCacheRatio": true,
"ImageRatio": true,
"AudioRatio": true,
"AudioCompletionRatio": true,
"VideoRatio": true,
"VideoCompletionRatio": true,
"VideoPrice": true,
"VideoPricingRules": true,
"ChannelVideoPricingRules": true,
"ImagePrice": true,
"ImagePricingRules": true,
"ChannelImagePricingRules": true,
"GroupRatio": true,
"ChannelModelPrice": true,
"ChannelModelRatio": true,
"GroupModelPrice": true,
}
// computeModelPricingDiff 比较模型定价类选项的新旧 JSON 值,返回仅包含变更条目的 FieldChange 列表。
// oldJSON 和 newJSON 应为形如 {"model-a": 1.5, "model-b": 0.8} 的 JSON 字符串。
func computeModelPricingDiff(oldJSON, newJSON string) []service.FieldChange {
if oldJSON == "" {
oldJSON = "{}"
}
if newJSON == "" {
newJSON = "{}"
}
if strings.TrimSpace(oldJSON) == strings.TrimSpace(newJSON) {
return nil
}
var oldMap, newMap map[string]any
if err := common.UnmarshalJsonStr(oldJSON, &oldMap); err != nil {
return nil
}
if err := common.UnmarshalJsonStr(newJSON, &newMap); err != nil {
return nil
}
var changes []service.FieldChange
// 检测修改和新增的条目
for k, newV := range newMap {
oldV, existed := oldMap[k]
newStr := formatPricingValue(newV)
if !existed {
changes = append(changes, service.FieldChange{
Field: k,
OldValue: "",
NewValue: newStr,
})
} else {
oldStr := formatPricingValue(oldV)
if oldStr != newStr {
changes = append(changes, service.FieldChange{
Field: k,
OldValue: oldStr,
NewValue: newStr,
})
}
}
}
// 检测删除的条目
for k, oldV := range oldMap {
if _, stillExists := newMap[k]; !stillExists {
changes = append(changes, service.FieldChange{
Field: k,
OldValue: formatPricingValue(oldV),
NewValue: "",
})
}
}
return changes
}
// formatPricingValue 将定价值格式化为可读字符串。
func formatPricingValue(v any) string {
switch val := v.(type) {
case float64:
if val == float64(int64(val)) {
return fmt.Sprintf("%d", int64(val))
}
return fmt.Sprintf("%g", val)
case string:
return val
default:
return fmt.Sprintf("%v", val)
}
}
func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) {
if strings.TrimSpace(raw) == "" {
return
@ -578,11 +675,27 @@ func UpdateOption(c *gin.Context) {
return
}
}
// 捕获旧值,用于计算模型定价类选项的字段级变更
common.OptionMapRWMutex.Lock()
oldVal := strings.TrimSpace(common.Interface2String(common.OptionMap[option.Key]))
common.OptionMapRWMutex.Unlock()
err = model.UpdateOption(option.Key, option.Value.(string))
if err != nil {
common.ApiError(c, err)
return
}
// 对模型定价类选项,仅记录实际发生变化的模型条目
if modelPricingOptionKeys[option.Key] {
changes := computeModelPricingDiff(oldVal, valStr)
if len(changes) > 0 {
service.RecordUpdateWithDiff(c, "setting", 0, option.Key, "更新系统设置: "+option.Key, changes, "")
}
} else {
service.RecordUpdateOperation(c, "setting", 0, option.Key, "更新系统设置: "+option.Key, "")
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",

View File

@ -1,6 +1,7 @@
package controller
import (
"fmt"
"net/http"
"strconv"
"unicode/utf8"
@ -8,6 +9,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
@ -104,6 +106,7 @@ func AddRedemption(c *gin.Context) {
}
keys = append(keys, key)
}
service.RecordCreateOperation(c, "redemption", 0, redemption.Name, "创建兑换码: "+redemption.Name, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@ -119,6 +122,7 @@ func DeleteRedemption(c *gin.Context) {
common.ApiError(c, err)
return
}
service.RecordDeleteOperation(c, "redemption", id, "", fmt.Sprintf("删除兑换码 (ID: %d)", id), "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@ -157,6 +161,7 @@ func UpdateRedemption(c *gin.Context) {
common.ApiError(c, err)
return
}
service.RecordUpdateOperation(c, "redemption", cleanRedemption.Id, cleanRedemption.Name, "更新兑换码: "+cleanRedemption.Name, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",

View File

@ -9,6 +9,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
@ -227,6 +228,7 @@ func AddToken(c *gin.Context) {
common.ApiError(c, err)
return
}
service.RecordCreateOperation(c, "token", cleanToken.Id, cleanToken.Name, "创建令牌: "+cleanToken.Name, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@ -241,6 +243,7 @@ func DeleteToken(c *gin.Context) {
common.ApiError(c, err)
return
}
service.RecordDeleteOperation(c, "token", id, "", fmt.Sprintf("删除令牌 (ID: %d)", id), "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@ -305,6 +308,7 @@ func UpdateToken(c *gin.Context) {
common.ApiError(c, err)
return
}
service.RecordUpdateOperation(c, "token", cleanToken.Id, cleanToken.Name, "更新令牌: "+cleanToken.Name, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",

View File

@ -1133,6 +1133,62 @@ func UpdateUser(c *gin.Context) {
if originUser.Quota != updatedUser.Quota {
model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", logger.LogQuota(originUser.Quota), logger.LogQuota(updatedUser.Quota)))
}
// 构建字段级变更描述
var changes []service.FieldChange
if updatePassword {
tmpPwd := "******"
changes = append(changes, service.FieldChange{Field: "密码", OldValue: tmpPwd, NewValue: tmpPwd})
}
if originUser.Email != updatedUser.Email {
changes = append(changes, service.FieldChange{Field: "邮箱", OldValue: originUser.Email, NewValue: updatedUser.Email})
}
if originUser.Phone != updatedUser.Phone {
changes = append(changes, service.FieldChange{Field: "手机号", OldValue: originUser.Phone, NewValue: updatedUser.Phone})
}
if originUser.DisplayName != updatedUser.DisplayName {
changes = append(changes, service.FieldChange{Field: "显示名称", OldValue: originUser.DisplayName, NewValue: updatedUser.DisplayName})
}
if originUser.Group != updatedUser.Group {
changes = append(changes, service.FieldChange{Field: "分组", OldValue: originUser.Group, NewValue: updatedUser.Group})
}
if originUser.Quota != updatedUser.Quota {
changes = append(changes, service.FieldChange{Field: "额度", OldValue: logger.LogQuota(originUser.Quota), NewValue: logger.LogQuota(updatedUser.Quota)})
}
if originUser.Role != updatedUser.Role {
changes = append(changes, service.FieldChange{Field: "角色", OldValue: roleLabel(originUser.Role), NewValue: roleLabel(updatedUser.Role)})
}
if originUser.Status != updatedUser.Status {
changes = append(changes, service.FieldChange{Field: "状态", OldValue: statusLabel(originUser.Status), NewValue: statusLabel(updatedUser.Status)})
}
if originUser.Remark != updatedUser.Remark {
changes = append(changes, service.FieldChange{Field: "备注", OldValue: originUser.Remark, NewValue: updatedUser.Remark})
}
if originUser.Tags != updatedUser.Tags {
changes = append(changes, service.FieldChange{Field: "标签", OldValue: originUser.Tags, NewValue: updatedUser.Tags})
}
if originUser.IsDistributor != updatedUser.IsDistributor {
changes = append(changes, service.FieldChange{Field: "分销商", OldValue: distributorLabel(originUser.IsDistributor), NewValue: distributorLabel(updatedUser.IsDistributor)})
}
if originUser.IsStudent != updatedUser.IsStudent || originUser.StudentStatus != updatedUser.StudentStatus {
var oldStudent, newStudent string
if originUser.IsStudent == 1 {
oldStudent = "是(" + studentStatusLabel(originUser.StudentStatus) + ")"
} else {
oldStudent = "否"
}
if updatedUser.IsStudent == 1 {
newStudent = "是(" + studentStatusLabel(updatedUser.StudentStatus) + ")"
} else {
newStudent = "否"
}
changes = append(changes, service.FieldChange{Field: "学员", OldValue: oldStudent, NewValue: newStudent})
}
if originUser.DistributorCommissionBps != updatedUser.DistributorCommissionBps {
changes = append(changes, service.FieldChange{Field: "分销佣金比例", OldValue: fmt.Sprintf("%d‱", originUser.DistributorCommissionBps), NewValue: fmt.Sprintf("%d‱", updatedUser.DistributorCommissionBps)})
}
service.RecordUpdateWithDiff(c, "user", updatedUser.Id, updatedUser.Username, "更新用户: "+updatedUser.Username, changes, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@ -1171,6 +1227,7 @@ func AdminClearUserBinding(c *gin.Context) {
}
model.RecordLog(user.Id, model.LogTypeManage, fmt.Sprintf("admin cleared %s binding for user %s", bindingType, user.Username))
service.RecordDeleteOperation(c, "user_binding", user.Id, user.Username, "清除用户绑定["+bindingType+"]: "+user.Username, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
@ -1345,6 +1402,7 @@ func DeleteUser(c *gin.Context) {
})
return
}
service.RecordDeleteOperation(c, "user", id, originUser.Username, "删除用户: "+originUser.Username, "")
}
func DeleteSelf(c *gin.Context) {
@ -1424,6 +1482,7 @@ func CreateUser(c *gin.Context) {
common.ApiError(c, err)
return
}
service.RecordCreateOperation(c, "user", cleanUser.Id, cleanUser.Username, "创建用户: "+cleanUser.Username, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
@ -1459,6 +1518,12 @@ func ManageUser(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
return
}
// 记录变更前的原始值
oldStatus := user.Status
oldRole := user.Role
oldIsDistributor := user.IsDistributor
oldIsStudent := user.IsStudent
oldStudentStatus := user.StudentStatus
beforeAdminDemote := false
switch req.Action {
case "disable":
@ -1657,6 +1722,49 @@ func ManageUser(c *gin.Context) {
model.RecordLog(user.Id, model.LogTypeManage, fmt.Sprintf("%s赠送 %s", actionLabel, logger.LogQuota(rewardQuota)))
}
}
// 记录操作日志(字段级变更描述)
manageActionMap := map[string]string{
"disable": "禁用用户",
"enable": "启用用户",
"delete": "删除用户",
"promote": "提升用户角色",
"demote": "降级用户角色",
"set_distributor": "设为分销商",
"unset_distributor": "取消分销商",
"approve_student": "审批通过学员",
"reject_student": "拒绝学员申请",
"unset_student": "取消学员身份",
"set_student": "设置学员身份",
}
if label, ok := manageActionMap[req.Action]; ok {
var changes []service.FieldChange
// 根据实际变更字段生成 diff
if oldStatus != user.Status {
changes = append(changes, service.FieldChange{Field: "状态", OldValue: statusLabel(oldStatus), NewValue: statusLabel(user.Status)})
}
if oldRole != user.Role {
changes = append(changes, service.FieldChange{Field: "角色", OldValue: roleLabel(oldRole), NewValue: roleLabel(user.Role)})
}
if oldIsDistributor != user.IsDistributor {
changes = append(changes, service.FieldChange{Field: "分销商", OldValue: distributorLabel(oldIsDistributor), NewValue: distributorLabel(user.IsDistributor)})
}
if oldIsStudent != user.IsStudent || oldStudentStatus != user.StudentStatus {
var oldLabel, newLabel string
if oldIsStudent == 1 {
oldLabel = "是(" + studentStatusLabel(oldStudentStatus) + ")"
} else {
oldLabel = "否"
}
if user.IsStudent == 1 {
newLabel = "是(" + studentStatusLabel(user.StudentStatus) + ")"
} else {
newLabel = "否"
}
changes = append(changes, service.FieldChange{Field: "学员", OldValue: oldLabel, NewValue: newLabel})
}
service.RecordUpdateWithDiff(c, "user", user.Id, user.Username, label+": "+user.Username, changes, "")
}
switch req.Action {
case "set_distributor":
service.NotifyDistributorRoleGranted(user.Id)
@ -2053,3 +2161,55 @@ func GetUserTags(c *gin.Context) {
}
common.ApiSuccess(c, merged)
}
// --- 操作日志字段标签辅助函数 ---
func roleLabel(role int) string {
switch role {
case common.RoleCommonUser:
return "普通用户"
case common.RoleDistributorUser:
return "分销商(旧)"
case common.RoleAdminUser:
return "管理员"
case common.RoleRootUser:
return "超级管理员"
case common.RoleGuestUser:
return "访客"
default:
return fmt.Sprintf("未知(%d)", role)
}
}
func statusLabel(status int) string {
switch status {
case common.UserStatusEnabled:
return "启用"
case common.UserStatusDisabled:
return "禁用"
default:
return fmt.Sprintf("未知(%d)", status)
}
}
func distributorLabel(v int) string {
if v == common.DistributorFlagYes {
return "是"
}
return "否"
}
func studentStatusLabel(v int) string {
switch v {
case common.StudentStatusNone:
return "未申请"
case common.StudentStatusPending:
return "待审批"
case common.StudentStatusApproved:
return "已通过"
case common.StudentStatusRejected:
return "已拒绝"
default:
return fmt.Sprintf("未知(%d)", v)
}
}

View File

@ -16,8 +16,6 @@
#
# ⚠️ IMPORTANT: Change all default passwords before deploying to production!
version: '3.4' # For compatibility with older Docker versions
services:
token-factory:
image: token-factory:latest

241
middleware/operation_log.go Normal file
View File

@ -0,0 +1,241 @@
package middleware
import (
"bytes"
"io"
"net/http"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
// operationLogConfig 记录操作日志的路径配置。
// key: HTTP method + path pattern, value: {action, targetType}
type operationLogConfig struct {
Action string
TargetType string
}
// auditRoutes 需要记录操作日志的路由配置。
// 仅记录增删改操作,不记录查询操作。
var auditRoutes = map[string]operationLogConfig{
// 渠道管理
"POST /api/channel/": {Action: model.OperationActionCreate, TargetType: "channel"},
"PUT /api/channel/": {Action: model.OperationActionUpdate, TargetType: "channel"},
"DELETE /api/channel/:id": {Action: model.OperationActionDelete, TargetType: "channel"},
"POST /api/channel/batch": {Action: model.OperationActionDelete, TargetType: "channel"},
"POST /api/channel/copy/:id": {Action: model.OperationActionCreate, TargetType: "channel"},
// 令牌管理
"POST /api/token/": {Action: model.OperationActionCreate, TargetType: "token"},
"PUT /api/token/": {Action: model.OperationActionUpdate, TargetType: "token"},
"DELETE /api/token/:id": {Action: model.OperationActionDelete, TargetType: "token"},
"POST /api/token/batch": {Action: model.OperationActionDelete, TargetType: "token"},
// 用户管理
"POST /api/user/": {Action: model.OperationActionCreate, TargetType: "user"},
"PUT /api/user/": {Action: model.OperationActionUpdate, TargetType: "user"},
"DELETE /api/user/:id": {Action: model.OperationActionDelete, TargetType: "user"},
"POST /api/user/manage": {Action: model.OperationActionUpdate, TargetType: "user"},
// 兑换码管理
"POST /api/redemption/": {Action: model.OperationActionCreate, TargetType: "redemption"},
"PUT /api/redemption/": {Action: model.OperationActionUpdate, TargetType: "redemption"},
"DELETE /api/redemption/:id": {Action: model.OperationActionDelete, TargetType: "redemption"},
"DELETE /api/redemption/invalid": {Action: model.OperationActionDelete, TargetType: "redemption"},
// 模型管理
"POST /api/models/": {Action: model.OperationActionCreate, TargetType: "model"},
"PUT /api/models/": {Action: model.OperationActionUpdate, TargetType: "model"},
"DELETE /api/models/:id": {Action: model.OperationActionDelete, TargetType: "model"},
// 供应商管理
"POST /api/user/supplier/application": {Action: model.OperationActionCreate, TargetType: "supplier_application"},
"PUT /api/user/supplier/application/self": {Action: model.OperationActionUpdate, TargetType: "supplier_application"},
"PUT /api/user/supplier/application/:id/capability": {Action: model.OperationActionUpdate, TargetType: "supplier_capability"},
// 系统设置
"PUT /api/option/": {Action: model.OperationActionUpdate, TargetType: "setting"},
}
// OperationLogMiddleware 自动记录匹配路由的增删改操作。
// 对于路径参数路由(如 /api/channel/:id尝试用实际路径匹配。
func OperationLogMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
method := c.Request.Method
// 仅拦截写操作
if method != http.MethodPost && method != http.MethodPut && method != http.MethodPatch && method != http.MethodDelete {
c.Next()
return
}
path := c.Request.URL.Path
key := method + " " + path
config, matched := auditRoutes[key]
if !matched {
// 尝试匹配带 :id 的路由模式
// 通过遍历已知模式,将 :id 部分替换为实际值进行匹配
for pattern, cfg := range auditRoutes {
if matchRoutePattern(pattern, method, path) {
config = cfg
matched = true
break
}
}
}
if !matched {
c.Next()
return
}
// 读取请求体用于审计
var requestBody string
if c.Request.Body != nil && (method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err == nil {
requestBody = string(bodyBytes)
// 将请求体放回,供后续处理器读取
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
}
// 先执行后续处理器
c.Next()
// 如果 controller 已经手动记录了操作日志,则跳过中间件记录,避免重复
if already, _ := c.Get("_operation_logged"); already != nil {
if logged, ok := already.(bool); ok && logged {
return
}
}
// 只有成功时才记录2xx 状态码)
status := c.Writer.Status()
if status < 200 || status >= 300 {
return
}
userId := c.GetInt("id")
if userId == 0 {
return
}
// 从上下文中获取目标 ID 和名称(由各 controller 设置)
targetId, _ := c.Get("operation_target_id")
targetName, _ := c.Get("operation_target_name")
content, _ := c.Get("operation_content")
targetIdInt := 0
if v, ok := targetId.(int); ok {
targetIdInt = v
}
targetNameStr := ""
if v, ok := targetName.(string); ok {
targetNameStr = v
}
contentStr := ""
if v, ok := content.(string); ok {
contentStr = v
} else {
// 自动生成默认描述
contentStr = commonActionLabel(config.Action, config.TargetType, targetNameStr)
}
sanitizedBody := model.SanitizeRequestBody(requestBody)
model.RecordOperationLog(c, userId, config.Action, config.TargetType, targetIdInt, targetNameStr, contentStr, sanitizedBody)
}
}
// matchRoutePattern 检查实际请求路径是否匹配带 :param 的路由模式。
func matchRoutePattern(pattern string, method string, path string) bool {
// 简单实现:检查方法前缀和路径段数量是否一致
if len(pattern) < len(method)+2 || pattern[:len(method)+1] != method+" " {
return false
}
patternPath := pattern[len(method)+1:]
// 分段比较
patternSegments := splitPath(patternPath)
pathSegments := splitPath(path)
if len(patternSegments) != len(pathSegments) {
return false
}
for i := range patternSegments {
if len(patternSegments[i]) > 0 && patternSegments[i][0] == ':' {
// 动态段,跳过
continue
}
if patternSegments[i] != pathSegments[i] {
return false
}
}
return true
}
func splitPath(path string) []string {
var segments []string
start := 0
for i := 0; i <= len(path); i++ {
if i == len(path) || path[i] == '/' {
if i > start {
segments = append(segments, path[start:i])
}
start = i + 1
}
}
return segments
}
// commonActionLabel 生成默认的操作描述。
func commonActionLabel(action string, targetType string, targetName string) string {
actionLabel := ""
switch action {
case model.OperationActionCreate:
actionLabel = "创建"
case model.OperationActionUpdate:
actionLabel = "更新"
case model.OperationActionDelete:
actionLabel = "删除"
default:
actionLabel = action
}
typeLabel := targetType
switch targetType {
case "channel":
typeLabel = "渠道"
case "token":
typeLabel = "令牌"
case "user":
typeLabel = "用户"
case "redemption":
typeLabel = "兑换码"
case "model":
typeLabel = "模型"
case "setting":
typeLabel = "系统设置"
case "supplier_application":
typeLabel = "供应商申请"
case "supplier_capability":
typeLabel = "供应商能力"
}
if targetName != "" {
return actionLabel + typeLabel + ": " + targetName
}
return actionLabel + typeLabel
}
// SetOperationTarget 在 controller 中设置操作目标信息,供中间件读取。
func SetOperationTarget(c *gin.Context, targetId int, targetName string, content string) {
c.Set("operation_target_id", targetId)
c.Set("operation_target_name", targetName)
c.Set("operation_content", content)
}
// RecordOperationDirectly 在 controller 中直接记录操作日志(不走中间件)。
// 适用于需要自定义逻辑的场景。
func RecordOperationDirectly(c *gin.Context, action string, targetType string, targetId int, targetName string, content string) {
service.RecordOperation(c, action, targetType, targetId, targetName, content, "")
}

View File

@ -321,6 +321,7 @@ func migrateDB() error {
&DistributorWithdrawal{},
&ModelTag{},
&ModelTestResult{},
&OperationLog{},
)
if err != nil {
return err
@ -395,6 +396,7 @@ func migrateDBFast() error {
{&DistributorWithdrawal{}, "DistributorWithdrawal"},
{&ModelTag{}, "ModelTag"},
{&ModelTestResult{}, "ModelTestResult"},
{&OperationLog{}, "OperationLog"},
}
// 动态计算migration数量确保errChan缓冲区足够大
errChan := make(chan error, len(migrations))

189
model/operation_log.go Normal file
View File

@ -0,0 +1,189 @@
package model
import (
"context"
"fmt"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
)
// OperationLog 记录用户对数据的增删改操作,用于审计。
type OperationLog struct {
Id int `json:"id" gorm:"index:idx_oplog_created_at_id,priority:1"`
UserId int `json:"user_id" gorm:"index;index:idx_oplog_user_id_id,priority:1"`
Username string `json:"username" gorm:"index;default:''"`
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_oplog_created_at_id,priority:2;index:idx_oplog_created_at_action"`
Action string `json:"action" gorm:"type:varchar(32);index;index:idx_oplog_created_at_action"` // create / update / delete
TargetType string `json:"target_type" gorm:"type:varchar(64);index;default:''"` // 资源类型: channel, token, user, redemption, model, setting …
TargetId int `json:"target_id" gorm:"index;default:0"` // 资源 ID
TargetName string `json:"target_name" gorm:"type:varchar(255);default:''"` // 资源名称(便于检索)
Content string `json:"content"` // 操作描述
Ip string `json:"ip" gorm:"index;default:''"` // 操作者 IP
RequestBody string `json:"request_body" gorm:"type:text"` // 请求体快照(脱敏后)
UserRole int `json:"user_role" gorm:"default:0"` // 操作者角色
}
// 操作类型常量
const (
OperationActionCreate = "create"
OperationActionUpdate = "update"
OperationActionDelete = "delete"
)
// RecordOperationLog 记录一条操作日志。
func RecordOperationLog(c *gin.Context, userId int, action string, targetType string, targetId int, targetName string, content string, requestBody string) {
username := c.GetString("username")
userRole := c.GetInt("role")
ip := c.ClientIP()
log := &OperationLog{
UserId: userId,
Username: username,
CreatedAt: common.GetTimestamp(),
Action: action,
TargetType: targetType,
TargetId: targetId,
TargetName: targetName,
Content: content,
Ip: ip,
RequestBody: requestBody,
UserRole: userRole,
}
err := DB.Create(log).Error
if err != nil {
common.SysError("failed to record operation log: " + err.Error())
}
}
// GetOperationLogs 管理员分页查询操作日志。
func GetOperationLogs(action string, targetType string, targetId int, username string, startTimestamp int64, endTimestamp int64, startIdx int, num int) (logs []*OperationLog, total int64, err error) {
tx := DB.Model(&OperationLog{})
if action != "" {
tx = tx.Where("action = ?", action)
}
if targetType != "" {
tx = tx.Where("target_type = ?", targetType)
}
if targetId != 0 {
tx = tx.Where("target_id = ?", targetId)
}
if username != "" {
tx = tx.Where("username = ?", username)
}
if startTimestamp != 0 {
tx = tx.Where("created_at >= ?", startTimestamp)
}
if endTimestamp != 0 {
tx = tx.Where("created_at <= ?", endTimestamp)
}
err = tx.Count(&total).Error
if err != nil {
return nil, 0, err
}
err = tx.Order("id desc").Limit(num).Offset(startIdx).Find(&logs).Error
return logs, total, err
}
// GetUserOperationLogs 普通用户查询自己的操作日志。
func GetUserOperationLogs(userId int, action string, targetType string, startTimestamp int64, endTimestamp int64, startIdx int, num int) (logs []*OperationLog, total int64, err error) {
tx := DB.Model(&OperationLog{}).Where("user_id = ?", userId)
if action != "" {
tx = tx.Where("action = ?", action)
}
if targetType != "" {
tx = tx.Where("target_type = ?", targetType)
}
if startTimestamp != 0 {
tx = tx.Where("created_at >= ?", startTimestamp)
}
if endTimestamp != 0 {
tx = tx.Where("created_at <= ?", endTimestamp)
}
err = tx.Count(&total).Error
if err != nil {
return nil, 0, err
}
err = tx.Order("id desc").Limit(num).Offset(startIdx).Find(&logs).Error
return logs, total, err
}
// DeleteOldOperationLogs 清理指定时间之前的操作日志。
func DeleteOldOperationLogs(ctx context.Context, targetTimestamp int64, limit int) (int64, error) {
var total int64 = 0
for {
if ctx.Err() != nil {
return total, ctx.Err()
}
result := DB.Where("created_at < ?", targetTimestamp).Limit(limit).Delete(&OperationLog{})
if result.Error != nil {
return total, result.Error
}
total += result.RowsAffected
if result.RowsAffected < int64(limit) {
break
}
}
return total, nil
}
// GetOperationLogStats 按操作类型统计数量。
func GetOperationLogStats(startTimestamp int64, endTimestamp int64) (map[string]int64, error) {
type result struct {
Action string
Count int64
}
var results []result
tx := DB.Model(&OperationLog{}).Select("action, count(*) as count")
if startTimestamp != 0 {
tx = tx.Where("created_at >= ?", startTimestamp)
}
if endTimestamp != 0 {
tx = tx.Where("created_at <= ?", endTimestamp)
}
tx = tx.Group("action")
if err := tx.Scan(&results).Error; err != nil {
return nil, fmt.Errorf("查询操作日志统计失败: %w", err)
}
stats := make(map[string]int64, len(results))
for _, r := range results {
stats[r.Action] = r.Count
}
return stats, nil
}
// SanitizeRequestBody 脱敏请求体中的敏感字段。
func SanitizeRequestBody(body string) string {
if body == "" {
return ""
}
var m map[string]interface{}
if err := common.Unmarshal([]byte(body), &m); err != nil {
// 无法解析为 JSON直接返回截断后的原始内容
if len(body) > 2000 {
return body[:2000] + "...(truncated)"
}
return body
}
// 脱敏敏感字段
sensitiveKeys := []string{"password", "key", "secret", "token", "access_token", "refresh_token", "api_key", "credential"}
for _, k := range sensitiveKeys {
if _, ok := m[k]; ok {
m[k] = "******"
}
}
sanitized, err := common.Marshal(m)
if err != nil {
return body
}
result := string(sanitized)
if len(result) > 4000 {
result = result[:4000] + "...(truncated)"
}
return result
}

View File

@ -431,6 +431,15 @@ func SetApiRouter(router *gin.Engine) {
{
logRoute.GET("/token", middleware.TokenAuthReadOnly(), controller.GetLogByKey)
}
operationLogRoute := apiRouter.Group("/operation_log")
{
operationLogRoute.GET("/", middleware.AdminAuth(), controller.GetAllOperationLogs)
operationLogRoute.GET("/stat", middleware.AdminAuth(), controller.GetOperationLogStats)
operationLogRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryOperationLogs)
operationLogRoute.GET("/self", middleware.UserAuth(), controller.GetUserOperationLogs)
}
groupRoute := apiRouter.Group("/group")
{
groupRoute.GET("/", middleware.UserAuth(), middleware.AdminOrApprovedSupplierAuth(), controller.GetGroups)

79
service/operation_log.go Normal file
View File

@ -0,0 +1,79 @@
package service
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// RecordOperation 便捷方法:记录操作日志。
func RecordOperation(c *gin.Context, action string, targetType string, targetId int, targetName string, content string, requestBody string) {
userId := c.GetInt("id")
if userId == 0 {
return
}
sanitizedBody := model.SanitizeRequestBody(requestBody)
model.RecordOperationLog(c, userId, action, targetType, targetId, targetName, content, sanitizedBody)
// 设置标记,防止中间件重复记录同一操作
c.Set("_operation_logged", true)
}
// RecordCreateOperation 记录创建操作。
func RecordCreateOperation(c *gin.Context, targetType string, targetId int, targetName string, content string, requestBody string) {
RecordOperation(c, model.OperationActionCreate, targetType, targetId, targetName, content, requestBody)
}
// RecordUpdateOperation 记录更新操作。
func RecordUpdateOperation(c *gin.Context, targetType string, targetId int, targetName string, content string, requestBody string) {
RecordOperation(c, model.OperationActionUpdate, targetType, targetId, targetName, content, requestBody)
}
// RecordDeleteOperation 记录删除操作。
func RecordDeleteOperation(c *gin.Context, targetType string, targetId int, targetName string, content string, requestBody string) {
RecordOperation(c, model.OperationActionDelete, targetType, targetId, targetName, content, requestBody)
}
// FieldChange 表示单个字段的变更信息。
type FieldChange struct {
Field string // 字段中文名
OldValue string // 变更前的值
NewValue string // 变更后的值
}
// FormatUpdateDiff 将字段变更列表格式化为操作描述。
// 仅记录实际发生变化的字段,生成形如 "邮箱: old@a.com → new@b.com; 额度: 100 → 500" 的描述。
func FormatUpdateDiff(changes []FieldChange) string {
if len(changes) == 0 {
return ""
}
var parts []string
for _, c := range changes {
if c.OldValue == c.NewValue {
continue
}
if c.OldValue == "" {
parts = append(parts, fmt.Sprintf("%s: 设置为「%s」", c.Field, c.NewValue))
} else if c.NewValue == "" {
parts = append(parts, fmt.Sprintf("%s: 清空(原「%s」", c.Field, c.OldValue))
} else {
parts = append(parts, fmt.Sprintf("%s: 「%s」→「%s」", c.Field, c.OldValue, c.NewValue))
}
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, "; ")
}
// RecordUpdateWithDiff 记录更新操作并附带字段级变更详情。
// baseContent 为基础描述(如 "更新用户: xxx"changes 为变更字段列表。
func RecordUpdateWithDiff(c *gin.Context, targetType string, targetId int, targetName string, baseContent string, changes []FieldChange, requestBody string) {
diffStr := FormatUpdateDiff(changes)
if diffStr != "" {
baseContent = baseContent + " | " + diffStr
}
RecordUpdateOperation(c, targetType, targetId, targetName, baseContent, requestBody)
}

9
web/src/App.jsx vendored
View File

@ -56,6 +56,7 @@ import SupplierApplication from './pages/SupplierAdmin/application';
import Suppliers from './pages/SupplierAdmin/list';
import Setup from './pages/Setup';
import SetupCheck from './components/layout/SetupCheck';
import OperationLog from './pages/OperationLog';
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
@ -398,6 +399,14 @@ function App() {
</PrivateRoute>
}
/>
<Route
path='/console/operation-log'
element={
<AdminRoute>
<OperationLog />
</AdminRoute>
}
/>
<Route
path='/console'
element={

View File

@ -40,6 +40,7 @@ const routerMap = {
user: '/console/user',
subscription: '/console/subscription',
log: '/console/log',
'operation-log': '/console/operation-log',
midjourney: '/console/midjourney',
setting: '/console/setting',
about: '/about',
@ -236,6 +237,12 @@ const SiderBar = ({ onNavigate = () => {} }) => {
to: '/user',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('操作记录'),
itemKey: 'operation-log',
to: '/console/operation-log',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('代理管理'),
itemKey: 'distributor',

View File

@ -90,6 +90,7 @@ import {
BookOpen,
Info,
LayoutGrid,
ClipboardCheck,
} from 'lucide-react';
import {
SiAtlassian,
@ -187,6 +188,8 @@ export function getLucideIcon(key, selected = false) {
return <ClipboardList {...commonProps} color={iconColor} />;
case 'supplier-list':
return <Store {...commonProps} color={iconColor} />;
case 'operation-log':
return <ClipboardCheck {...commonProps} color={iconColor} />;
default:
return <CircleUser {...commonProps} color={iconColor} />;
}

View File

@ -59,6 +59,7 @@ export const DEFAULT_SIDEBAR_CONFIG = {
'model-heat': true,
redemption: true,
user: true,
'operation-log': true,
subscription: true,
setting: true,
distributor: true,

View File

@ -0,0 +1,408 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Table,
Tag,
Select,
Input,
DatePicker,
Button,
Card,
Typography,
Space,
Modal,
Tooltip,
} from '@douyinfe/semi-ui';
import { IconSearch, IconDelete, IconEyeOpened } from '@douyinfe/semi-icons';
import {
API,
isAdmin,
showError,
showSuccess,
timestamp2string,
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
const { Text } = Typography;
const actionColors = {
create: 'green',
update: 'blue',
delete: 'red',
};
const actionLabels = {
create: '创建',
update: '更新',
delete: '删除',
};
const targetTypeLabels = {
channel: '渠道',
token: '令牌',
user: '用户',
redemption: '兑换码',
model: '模型',
setting: '系统设置',
supplier_application: '供应商申请',
supplier_capability: '供应商能力',
};
const OperationLog = () => {
const { t } = useTranslation();
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
// Filters
const [action, setAction] = useState('');
const [targetType, setTargetType] = useState('');
const [username, setUsername] = useState('');
const [dateRange, setDateRange] = useState([]);
// Detail modal
const [detailVisible, setDetailVisible] = useState(false);
const [selectedLog, setSelectedLog] = useState(null);
const fetchLogs = async () => {
setLoading(true);
try {
let url = isAdmin()
? '/api/operation_log/'
: '/api/operation_log/self';
const params = new URLSearchParams();
params.append('p', page);
params.append('page_size', pageSize);
if (action) params.append('action', action);
if (targetType) params.append('target_type', targetType);
if (isAdmin() && username) params.append('username', username);
if (dateRange && dateRange[0]) {
params.append(
'start_timestamp',
Math.floor(new Date(dateRange[0]).getTime() / 1000)
);
}
if (dateRange && dateRange[1]) {
params.append(
'end_timestamp',
Math.floor(new Date(dateRange[1]).getTime() / 1000)
);
}
const res = await API.get(`${url}?${params.toString()}`);
const { success, data, message } = res.data;
if (success) {
setLogs(data.items || []);
setTotal(data.total || 0);
} else {
showError(message);
}
} catch (error) {
showError(error.message);
}
setLoading(false);
};
useEffect(() => {
fetchLogs();
}, [page, pageSize, action, targetType]);
const handleSearch = () => {
setPage(1);
fetchLogs();
};
const handleDeleteOldLogs = async () => {
if (!dateRange || !dateRange[1]) {
showError('请选择截止日期');
return;
}
const targetTimestamp = Math.floor(
new Date(dateRange[1]).getTime() / 1000
);
Modal.confirm({
title: '确认清理',
content: `确定要清理 ${timestamp2string(targetTimestamp)} 之前的操作日志吗?此操作不可逆。`,
onOk: async () => {
try {
const res = await API.delete(
`/api/operation_log/?target_timestamp=${targetTimestamp}`
);
const { success, data, message } = res.data;
if (success) {
showSuccess(`成功清理 ${data} 条操作日志`);
fetchLogs();
} else {
showError(message);
}
} catch (error) {
showError(error.message);
}
},
});
};
const showDetail = (log) => {
setSelectedLog(log);
setDetailVisible(true);
};
const columns = [
{
title: t('时间'),
dataIndex: 'created_at',
key: 'created_at',
width: 170,
render: (text) => timestamp2string(text),
},
{
title: t('操作者'),
dataIndex: 'username',
key: 'username',
width: 120,
},
{
title: t('操作类型'),
dataIndex: 'action',
key: 'action',
width: 90,
render: (text) => (
<Tag color={actionColors[text] || 'grey'}>
{actionLabels[text] || text}
</Tag>
),
},
{
title: t('资源类型'),
dataIndex: 'target_type',
key: 'target_type',
width: 110,
render: (text) => (
<Tag>{targetTypeLabels[text] || text}</Tag>
),
},
{
title: t('资源名称'),
dataIndex: 'target_name',
key: 'target_name',
width: 160,
render: (text, record) => text || `ID: ${record.target_id}`,
},
{
title: t('操作描述'),
dataIndex: 'content',
key: 'content',
ellipsis: true,
},
{
title: t('IP'),
dataIndex: 'ip',
key: 'ip',
width: 130,
render: (text) => text || '-',
},
{
title: t('操作'),
key: 'actions',
width: 60,
render: (_, record) => (
<Tooltip content={t('查看详情')}>
<Button
theme='borderless'
type='primary'
icon={<IconEyeOpened />}
onClick={() => showDetail(record)}
/>
</Tooltip>
),
},
];
return (
<div style={{ padding: '20px' }}>
<Card>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 12,
marginBottom: 16,
alignItems: 'center',
}}
>
<Select
placeholder={t('操作类型')}
style={{ width: 120 }}
value={action}
onChange={(v) => {
setAction(v);
setPage(1);
}}
optionList={[
{ label: t('全部'), value: '' },
{ label: t('创建'), value: 'create' },
{ label: t('更新'), value: 'update' },
{ label: t('删除'), value: 'delete' },
]}
/>
<Select
placeholder={t('资源类型')}
style={{ width: 130 }}
value={targetType}
onChange={(v) => {
setTargetType(v);
setPage(1);
}}
optionList={[
{ label: t('全部'), value: '' },
{ label: t('渠道'), value: 'channel' },
{ label: t('令牌'), value: 'token' },
{ label: t('用户'), value: 'user' },
{ label: t('兑换码'), value: 'redemption' },
{ label: t('模型'), value: 'model' },
{ label: t('系统设置'), value: 'setting' },
]}
/>
{isAdmin() && (
<Input
placeholder={t('用户名')}
style={{ width: 120 }}
value={username}
onChange={setUsername}
/>
)}
<DatePicker
type='dateTimeRange'
placeholder={[t('开始时间'), t('结束时间')]}
value={dateRange}
onChange={setDateRange}
style={{ width: 360 }}
/>
<Button
icon={<IconSearch />}
theme='solid'
type='primary'
onClick={handleSearch}
>
{t('搜索')}
</Button>
{isAdmin() && (
<Button
icon={<IconDelete />}
type='danger'
onClick={handleDeleteOldLogs}
>
{t('清理日志')}
</Button>
)}
</div>
<Table
columns={columns}
dataSource={logs}
loading={loading}
rowKey='id'
pagination={{
currentPage: page,
pageSize: pageSize,
total: total,
onPageChange: setPage,
onPageSizeChange: (size) => {
setPageSize(size);
setPage(1);
},
showSizeChanger: true,
pageSizeOpts: [10, 20, 50, 100],
}}
size='small'
/>
</Card>
<Modal
title={t('操作详情')}
visible={detailVisible}
onCancel={() => setDetailVisible(false)}
footer={null}
width={600}
>
{selectedLog && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div>
<Text strong>{t('时间')}</Text>
<Text>{timestamp2string(selectedLog.created_at)}</Text>
</div>
<div>
<Text strong>{t('操作者')}</Text>
<Text>{selectedLog.username}</Text>
</div>
<div>
<Text strong>{t('操作类型')}</Text>
<Tag color={actionColors[selectedLog.action] || 'grey'}>
{actionLabels[selectedLog.action] || selectedLog.action}
</Tag>
</div>
<div>
<Text strong>{t('资源类型')}</Text>
<Tag>{targetTypeLabels[selectedLog.target_type] || selectedLog.target_type}</Tag>
</div>
<div>
<Text strong>{t('资源ID')}</Text>
<Text>{selectedLog.target_id}</Text>
</div>
<div>
<Text strong>{t('资源名称')}</Text>
<Text>{selectedLog.target_name || '-'}</Text>
</div>
<div>
<Text strong>{t('操作描述')}</Text>
<Text>{selectedLog.content}</Text>
</div>
<div>
<Text strong>{t('IP地址')}</Text>
<Text>{selectedLog.ip || '-'}</Text>
</div>
{selectedLog.request_body && (
<div>
<Text strong>{t('请求内容')}</Text>
<pre
style={{
background: 'var(--semi-color-fill-0)',
padding: 8,
borderRadius: 4,
maxHeight: 300,
overflow: 'auto',
fontSize: 12,
}}
>
{selectedLog.request_body}
</pre>
</div>
)}
</div>
)}
</Modal>
</div>
);
};
export default OperationLog;

View File

@ -134,6 +134,16 @@ const sectionConfigs = [
desc_key: '兑换码生成管理',
},
{ key: 'user', title_key: '用户管理', desc_key: '用户账户管理' },
{
key: 'operation-log',
title_key: '操作记录',
desc_key: '系统操作日志审计',
},
{
key: 'model-heat',
title_key: '热度配置',
desc_key: '模型热度排行配置',
},
{
key: 'distributor',
title_key: '代理管理',
@ -205,15 +215,27 @@ export default function SettingsSidebarModulesAdmin(props) {
});
const [selectedRole, setSelectedRole] = useState(String(USER_ROLES.USER));
//
const mergeWithDefaults = (saved) => {
const defaults = buildDefaultRoleConfig();
if (!saved || typeof saved !== 'object') return defaults;
const merged = { ...defaults };
for (const [sectionKey, sectionConfig] of Object.entries(saved)) {
if (!sectionConfig || typeof sectionConfig !== 'object') continue;
merged[sectionKey] = { ...(merged[sectionKey] || {}), ...sectionConfig };
}
return merged;
};
//
useEffect(() => {
if (props.options && props.options.SidebarModulesByRole) {
try {
const config = JSON.parse(props.options.SidebarModulesByRole);
setRoleModulesConfig({
[USER_ROLES.USER]: config[USER_ROLES.USER] || buildDefaultRoleConfig(),
[USER_ROLES.ADMIN]: config[USER_ROLES.ADMIN] || buildDefaultRoleConfig(),
[USER_ROLES.ROOT]: config[USER_ROLES.ROOT] || buildDefaultRoleConfig(),
[USER_ROLES.USER]: mergeWithDefaults(config[USER_ROLES.USER]),
[USER_ROLES.ADMIN]: mergeWithDefaults(config[USER_ROLES.ADMIN]),
[USER_ROLES.ROOT]: mergeWithDefaults(config[USER_ROLES.ROOT]),
});
} catch (error) {
// 使