diff --git a/Dockerfile b/Dockerfile index 85a0abe..c8e69a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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; \ diff --git a/controller/channel.go b/controller/channel.go index 861d9e8..2c2a162 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -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": "", diff --git a/controller/model_meta.go b/controller/model_meta.go index d8d25ac..6f2729e 100644 --- a/controller/model_meta.go +++ b/controller/model_meta.go @@ -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) } diff --git a/controller/operation_log.go b/controller/operation_log.go new file mode 100644 index 0000000..3e63769 --- /dev/null +++ b/controller/operation_log.go @@ -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, + }) +} diff --git a/controller/option.go b/controller/option.go index 7138988..938a6b7 100644 --- a/controller/option.go +++ b/controller/option.go @@ -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": "", diff --git a/controller/redemption.go b/controller/redemption.go index 76c35bc..679d0e7 100644 --- a/controller/redemption.go +++ b/controller/redemption.go @@ -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": "", diff --git a/controller/token.go b/controller/token.go index 889b962..1d795b5 100644 --- a/controller/token.go +++ b/controller/token.go @@ -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": "", diff --git a/controller/user.go b/controller/user.go index bbecc10..837026b 100644 --- a/controller/user.go +++ b/controller/user.go @@ -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) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 478ac9c..d5a944d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/middleware/operation_log.go b/middleware/operation_log.go new file mode 100644 index 0000000..7419f01 --- /dev/null +++ b/middleware/operation_log.go @@ -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, "") +} diff --git a/model/main.go b/model/main.go index d9932a3..a29dd36 100644 --- a/model/main.go +++ b/model/main.go @@ -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)) diff --git a/model/operation_log.go b/model/operation_log.go new file mode 100644 index 0000000..347f462 --- /dev/null +++ b/model/operation_log.go @@ -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 +} diff --git a/router/api-router.go b/router/api-router.go index 133dad9..d71c13d 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -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) diff --git a/service/operation_log.go b/service/operation_log.go new file mode 100644 index 0000000..327baed --- /dev/null +++ b/service/operation_log.go @@ -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) +} diff --git a/web/src/App.jsx b/web/src/App.jsx index 9eb1fc7..2dfe636 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -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() { } /> + + + + } + /> {} }) => { to: '/user', className: isAdmin() ? '' : 'tableHiddle', }, + { + text: t('操作记录'), + itemKey: 'operation-log', + to: '/console/operation-log', + className: isAdmin() ? '' : 'tableHiddle', + }, { text: t('代理管理'), itemKey: 'distributor', diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index bae8ab1..2609041 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -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 ; case 'supplier-list': return ; + case 'operation-log': + return ; default: return ; } diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index 1d1f888..d21fcbf 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -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, diff --git a/web/src/pages/OperationLog/index.jsx b/web/src/pages/OperationLog/index.jsx new file mode 100644 index 0000000..13d54dd --- /dev/null +++ b/web/src/pages/OperationLog/index.jsx @@ -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 . + +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) => ( + + {actionLabels[text] || text} + + ), + }, + { + title: t('资源类型'), + dataIndex: 'target_type', + key: 'target_type', + width: 110, + render: (text) => ( + {targetTypeLabels[text] || text} + ), + }, + { + 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) => ( + + + {isAdmin() && ( + + )} + + + { + setPageSize(size); + setPage(1); + }, + showSizeChanger: true, + pageSizeOpts: [10, 20, 50, 100], + }} + size='small' + /> + + + setDetailVisible(false)} + footer={null} + width={600} + > + {selectedLog && ( +
+
+ {t('时间')}: + {timestamp2string(selectedLog.created_at)} +
+
+ {t('操作者')}: + {selectedLog.username} +
+
+ {t('操作类型')}: + + {actionLabels[selectedLog.action] || selectedLog.action} + +
+
+ {t('资源类型')}: + {targetTypeLabels[selectedLog.target_type] || selectedLog.target_type} +
+
+ {t('资源ID')}: + {selectedLog.target_id} +
+
+ {t('资源名称')}: + {selectedLog.target_name || '-'} +
+
+ {t('操作描述')}: + {selectedLog.content} +
+
+ {t('IP地址')}: + {selectedLog.ip || '-'} +
+ {selectedLog.request_body && ( +
+ {t('请求内容')}: +
+                  {selectedLog.request_body}
+                
+
+ )} +
+ )} +
+ + ); +}; + +export default OperationLog; diff --git a/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx b/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx index 5fbfee1..ce336fe 100644 --- a/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx +++ b/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx @@ -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) { // 使用默认值