diff --git a/api/admin/admin.go b/api/admin/admin.go index 649b62a..3481f55 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -37,6 +37,7 @@ type IAdminV1 interface { ForwardingTraceGet(ctx context.Context, req *v1.ForwardingTraceGetReq) (res *v1.ForwardingTraceGetRes, err error) OperationLogExport(ctx context.Context, req *v1.OperationLogExportReq) (res *v1.OperationLogExportRes, err error) ContentFilterLogList(ctx context.Context, req *v1.ContentFilterLogListReq) (res *v1.ContentFilterLogListRes, err error) + ContentFilterLogClear(ctx context.Context, req *v1.ContentFilterLogClearReq) (res *v1.ContentFilterLogClearRes, err error) AdminLogin(ctx context.Context, req *v1.AdminLoginReq) (res *v1.AdminLoginRes, err error) AdminMe(ctx context.Context, req *v1.AdminMeReq) (res *v1.AdminMeRes, err error) AdminLogout(ctx context.Context, req *v1.AdminLogoutReq) (res *v1.AdminLogoutRes, err error) @@ -75,6 +76,7 @@ type IAdminV1 interface { ChannelErrorTrend(ctx context.Context, req *v1.ChannelErrorTrendReq) (res *v1.ChannelErrorTrendRes, err error) ChannelErrorTopChannels(ctx context.Context, req *v1.ChannelErrorTopChannelsReq) (res *v1.ChannelErrorTopChannelsRes, err error) ChannelErrorCategories(ctx context.Context, req *v1.ChannelErrorCategoriesReq) (res *v1.ChannelErrorCategoriesRes, err error) + ChannelErrorClear(ctx context.Context, req *v1.ChannelErrorClearReq) (res *v1.ChannelErrorClearRes, err error) ChannelOAuthAuthURL(ctx context.Context, req *v1.ChannelOAuthAuthURLReq) (res *v1.ChannelOAuthAuthURLRes, err error) ChannelOAuthExchange(ctx context.Context, req *v1.ChannelOAuthExchangeReq) (res *v1.ChannelOAuthExchangeRes, err error) ChannelOAuthRefresh(ctx context.Context, req *v1.ChannelOAuthRefreshReq) (res *v1.ChannelOAuthRefreshRes, err error) diff --git a/api/admin/v1/audit.go b/api/admin/v1/audit.go index 2647980..24b53d9 100644 --- a/api/admin/v1/audit.go +++ b/api/admin/v1/audit.go @@ -142,3 +142,13 @@ type ContentFilterLogListRes struct { Page int `json:"page"` PageSize int `json:"page_size"` } + +// ContentFilterLogClearReq 清空全部内容过滤拦截日志(硬删除,用于日志大量堆积时快速释放数据库空间) +type ContentFilterLogClearReq struct { + g.Meta `path:"/audit/content-filter-logs/clear" method:"delete" mime:"json" tags:"管理后台-审计" summary:"清空内容过滤拦截日志(硬删除全部)"` +} + +// ContentFilterLogClearRes 清空内容过滤拦截日志响应 +type ContentFilterLogClearRes struct { + Deleted int64 `json:"deleted" dc:"删除的记录数"` +} diff --git a/api/admin/v1/auth.go b/api/admin/v1/auth.go index 9e067dc..0cc4bef 100644 --- a/api/admin/v1/auth.go +++ b/api/admin/v1/auth.go @@ -97,11 +97,14 @@ type AdminSessionItem struct { UserId int64 `json:"user_id"` Username string `json:"username"` DisplayName string `json:"display_name"` - IpAddress string `json:"ip_address"` - DeviceInfo string `json:"device_info"` - ExpiresAt string `json:"expires_at"` - CreatedAt string `json:"created_at"` - IsCurrent bool `json:"is_current"` + // Role 是会话所属账号的角色(super_admin / admin), + // 前端据此隐藏非本人超管会话的撤销入口(后端另有 assertCanOperateSessions 兜底) + Role string `json:"role"` + IpAddress string `json:"ip_address"` + DeviceInfo string `json:"device_info"` + ExpiresAt string `json:"expires_at"` + CreatedAt string `json:"created_at"` + IsCurrent bool `json:"is_current"` } // AdminRevokeSessionReq 踢出指定会话请求 diff --git a/api/admin/v1/channel_error.go b/api/admin/v1/channel_error.go index dce4a73..66a8292 100644 --- a/api/admin/v1/channel_error.go +++ b/api/admin/v1/channel_error.go @@ -64,3 +64,13 @@ type ChannelErrorCategoriesReq struct { type ChannelErrorCategoriesRes struct { Data []map[string]string `json:"data"` } + +// ChannelErrorClearReq 清空全部渠道错误事件(硬删除,用于错误大量堆积时快速释放数据库空间) +type ChannelErrorClearReq struct { + g.Meta `path:"/monitor/channel-errors/clear" method:"delete" mime:"json" tags:"管理后台-监控" summary:"清空渠道错误事件(硬删除全部)"` +} + +// ChannelErrorClearRes 清空渠道错误事件响应 +type ChannelErrorClearRes struct { + Deleted int64 `json:"deleted" dc:"删除的记录数"` +} diff --git a/internal/controller/admin/admin_v1_audit.go b/internal/controller/admin/admin_v1_audit.go index e2346d9..5517f3c 100644 --- a/internal/controller/admin/admin_v1_audit.go +++ b/internal/controller/admin/admin_v1_audit.go @@ -34,3 +34,6 @@ func (c *ControllerV1) ContentFilterLogList(ctx context.Context, req *v1.Content func (c *ControllerV1) ForwardingTraceGet(ctx context.Context, req *v1.ForwardingTraceGetReq) (res *v1.ForwardingTraceGetRes, err error) { return service.Admin().ForwardingTraceGet(ctx, req) } +func (c *ControllerV1) ContentFilterLogClear(ctx context.Context, req *v1.ContentFilterLogClearReq) (res *v1.ContentFilterLogClearRes, err error) { + return service.Admin().ContentFilterLogClear(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_channel_error.go b/internal/controller/admin/admin_v1_channel_error.go index 51f3ed7..697699f 100644 --- a/internal/controller/admin/admin_v1_channel_error.go +++ b/internal/controller/admin/admin_v1_channel_error.go @@ -22,3 +22,6 @@ func (c *ControllerV1) ChannelErrorTopChannels(ctx context.Context, req *v1.Chan func (c *ControllerV1) ChannelErrorCategories(ctx context.Context, req *v1.ChannelErrorCategoriesReq) (res *v1.ChannelErrorCategoriesRes, err error) { return service.Admin().ChannelErrorCategories(ctx, req) } +func (c *ControllerV1) ChannelErrorClear(ctx context.Context, req *v1.ChannelErrorClearReq) (res *v1.ChannelErrorClearRes, err error) { + return service.Admin().ChannelErrorClear(ctx, req) +} diff --git a/internal/logic/admin/audit.go b/internal/logic/admin/audit.go index 808998c..c6fffb1 100644 --- a/internal/logic/admin/audit.go +++ b/internal/logic/admin/audit.go @@ -600,3 +600,18 @@ func (s *sAdmin) ContentFilterLogList(ctx context.Context, req *v1.ContentFilter PageSize: pageSize, }, nil } + +// ContentFilterLogClear 硬删除全部内容过滤拦截日志。 +// 拦截日志为追加型审计流水,堆积后用 TRUNCATE 秒级清空并立即归还磁盘空间 +// (DELETE 需等 VACUUM 回收)。TRUNCATE 不返回行数,故先取删除前条数作为反馈。 +// 表无数据库级外键,TRUNCATE 安全。 +func (s *sAdmin) ContentFilterLogClear(ctx context.Context, _ *v1.ContentFilterLogClearReq) (*v1.ContentFilterLogClearRes, error) { + count, err := dao.AudContentFilterLogs.Ctx(ctx).Count() + if err != nil { + return nil, err + } + if _, err := g.DB().Ctx(ctx).Exec(ctx, "TRUNCATE TABLE aud_content_filter_logs RESTART IDENTITY"); err != nil { + return nil, err + } + return &v1.ContentFilterLogClearRes{Deleted: int64(count)}, nil +} diff --git a/internal/logic/admin/auth.go b/internal/logic/admin/auth.go index aea3726..e74330c 100644 --- a/internal/logic/admin/auth.go +++ b/internal/logic/admin/auth.go @@ -344,9 +344,9 @@ func (s *sAdmin) ListSessions(ctx context.Context, req *v1.AdminSessionListReq) ids = append(ids, id) } var users []entity.SysAdminUsers - _ = dao.SysAdminUsers.Ctx(ctx).Fields("id, username, display_name").WhereIn("id", ids).Scan(&users) + _ = dao.SysAdminUsers.Ctx(ctx).Fields("id, username, display_name, role").WhereIn("id", ids).Scan(&users) for _, u := range users { - userMap[u.Id] = adminUserBrief{Username: u.Username, DisplayName: u.DisplayName} + userMap[u.Id] = adminUserBrief{Username: u.Username, DisplayName: u.DisplayName, Role: u.Role} } } @@ -357,6 +357,7 @@ func (s *sAdmin) ListSessions(ctx context.Context, req *v1.AdminSessionListReq) UserId: sess.UserId, Username: userMap[sess.UserId].Username, DisplayName: userMap[sess.UserId].DisplayName, + Role: userMap[sess.UserId].Role, IpAddress: sess.IpAddress, DeviceInfo: sess.DeviceInfo, IsCurrent: sess.Id == currentSessionID, @@ -377,6 +378,31 @@ func (s *sAdmin) ListSessions(ctx context.Context, req *v1.AdminSessionListReq) }, nil } +// assertCanOperateSessions 校验当前调用者能否处置目标管理员账号的会话。 +// +// 超级管理员的登录态任何人都不能代为操作(包括其他超管):踢掉超管会话等同于 +// 掐断平台最高权限入口,一旦下放就存在「借会话管理打击超管」的滥用路径。 +// 唯一例外是账号本人管理自己的登录设备(等同在其他设备登出)。 +// 查看不受限:会话列表对超管账号仍然展示,只是不可操作。 +func assertCanOperateSessions(ctx context.Context, targetUserID int64) error { + if targetUserID == 0 { + return nil + } + var user *entity.SysAdminUsers + err := dao.SysAdminUsers.Ctx(ctx).Where("id", targetUserID).Scan(&user) + if err = common.IgnoreScanNoRows(err); err != nil { + return err + } + // 目标账号不存在或不是超管:无可保护对象,交由路由层 user:edit 把关 + if user == nil || user.Role != "super_admin" { + return nil + } + if common.GetCtxUserID(ctx) == targetUserID { + return nil + } + return common.NewBusinessError(consts.CodeForbidden, "超级管理员的会话仅限本人管理,其他人只能查看") +} + // RevokeSession revokes a specific session. func (s *sAdmin) RevokeSession(ctx context.Context, req *v1.AdminRevokeSessionReq) (*v1.AdminRevokeSessionRes, error) { // Look up session to get jti for Redis revocation @@ -385,6 +411,9 @@ func (s *sAdmin) RevokeSession(ctx context.Context, req *v1.AdminRevokeSessionRe return nil, err } if sess != nil { + if err := assertCanOperateSessions(ctx, sess.UserId); err != nil { + return nil, err + } common.MarkSessionRevoked(ctx, sess.Jti) } err = common.RevokeSession(ctx, "admin", req.Id) @@ -396,6 +425,9 @@ func (s *sAdmin) RevokeSession(ctx context.Context, req *v1.AdminRevokeSessionRe // ForceLogout revokes all sessions for a specific user. func (s *sAdmin) ForceLogout(ctx context.Context, req *v1.AdminForceLogoutReq) (*v1.AdminForceLogoutRes, error) { + if err := assertCanOperateSessions(ctx, req.Id); err != nil { + return nil, err + } err := common.RevokeAllSessions(ctx, "admin", req.Id) if err != nil { return nil, err diff --git a/internal/logic/admin/channel_error.go b/internal/logic/admin/channel_error.go index b1fb755..8a831d3 100644 --- a/internal/logic/admin/channel_error.go +++ b/internal/logic/admin/channel_error.go @@ -9,6 +9,7 @@ import ( "github.com/gogf/gf/v2/os/gtime" v1 "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/logic/common" ) @@ -195,3 +196,18 @@ func (s *sAdmin) ChannelErrorCategories(ctx context.Context, req *v1.ChannelErro }, }, nil } + +// ChannelErrorClear 硬删除全部渠道错误事件。 +// 错误风暴时事件表堆积很快,用 TRUNCATE 而非逐行 DELETE 秒级清空并立即归还磁盘空间。 +// TRUNCATE 不返回行数,故先取删除前条数作为反馈。表无数据库级外键,TRUNCATE 安全。 +// 注意异步写入器(DefaultChannelErrorWriter)清空后继续写入新事件,属预期行为。 +func (s *sAdmin) ChannelErrorClear(ctx context.Context, _ *v1.ChannelErrorClearReq) (*v1.ChannelErrorClearRes, error) { + count, err := dao.ChnErrorEvents.Ctx(ctx).Count() + if err != nil { + return nil, err + } + if _, err := g.DB().Ctx(ctx).Exec(ctx, "TRUNCATE TABLE chn_error_events RESTART IDENTITY"); err != nil { + return nil, err + } + return &v1.ChannelErrorClearRes{Deleted: int64(count)}, nil +} diff --git a/internal/logic/admin/permission.go b/internal/logic/admin/permission.go index d0e9e06..523cfc5 100644 --- a/internal/logic/admin/permission.go +++ b/internal/logic/admin/permission.go @@ -69,7 +69,7 @@ var predefinedPermissionGroups = []v1.PermissionGroup{ Name: "audit", Label: "审计日志", Permissions: []string{ - "audit:view", "audit:export", "audit:read_sensitive", + "audit:view", "audit:export", "audit:read_sensitive", "audit:clear", }, }, { @@ -97,7 +97,8 @@ var predefinedPermissionGroups = []v1.PermissionGroup{ Name: "system", Label: "系统设置", Permissions: []string{ - "system:view", "system:edit", "system:update", "system:plugin", + // 注:在线自更新无权限点 —— 整个 update 域硬性限定超管(superAdminOnlyRules) + "system:view", "system:edit", "system:plugin", }, }, { diff --git a/internal/logic/admin/role.go b/internal/logic/admin/role.go index b5ca125..0ff0424 100644 --- a/internal/logic/admin/role.go +++ b/internal/logic/admin/role.go @@ -110,7 +110,7 @@ var tierPermissions = map[string]map[string][]string{ "audit": { TierRead: {"audit:view"}, TierOperate: {"audit:view", "audit:export"}, - TierFull: {"audit:view", "audit:export", "audit:read_sensitive"}, + TierFull: {"audit:view", "audit:export", "audit:read_sensitive", "audit:clear"}, }, "user": { TierRead: {"user:view"}, @@ -120,7 +120,8 @@ var tierPermissions = map[string]map[string][]string{ "system": { TierRead: {"system:view"}, TierOperate: {"system:view", "system:edit"}, - TierFull: {"system:view", "system:edit", "system:update", "system:plugin"}, + // 在线自更新硬性限定超管、无权限点,「完全」档与「操作」档的差异只剩插件 + TierFull: {"system:view", "system:edit", "system:plugin"}, }, } @@ -130,8 +131,8 @@ var dangerousPermissions = map[string]string{ "billing:refund": "可对任意租户钱包退款/调账", "order:refund": "可对订单发起退款到支付渠道", "audit:read_sensitive": "可查看敏感数据访问日志", + "audit:clear": "可硬删除全部拦截日志(不可恢复)", "system:edit": "可修改支付、汇率、存储、邮件等全站配置", - "system:update": "可触发系统版本更新", "system:plugin": "可安装/启停插件(等同于部署代码)", "user:create": "可创建管理员账号", "user:edit": "可修改管理员账号与其角色(含提权)", diff --git a/internal/logic/admin/role_crud.go b/internal/logic/admin/role_crud.go index f2a4423..1a7c202 100644 --- a/internal/logic/admin/role_crud.go +++ b/internal/logic/admin/role_crud.go @@ -22,7 +22,8 @@ import ( // // 注意这些只是初始值而非系统契约:用户可以任意修改,甚至删掉预置角色自建一套。 var builtinRoleDefaults = map[string][]string{ - // 管理员:除 user:*(账号与角色)、system:update/plugin 外的全部权限。 + // 管理员:除 user:*(账号与角色)与 system:plugin 外的全部权限 + //(在线自更新为超管专属,不设权限点)。 // 与运营的区别就是「能碰钱(退款)和系统配置」—— 去掉这两项,两个角色就没有分开的必要。 "admin": { "dashboard:view", diff --git a/internal/logic/admin/role_test.go b/internal/logic/admin/role_test.go index 43c4372..f6463e4 100644 --- a/internal/logic/admin/role_test.go +++ b/internal/logic/admin/role_test.go @@ -159,9 +159,9 @@ func TestBuiltinRoleBoundaries(t *testing.T) { } } - // 插件是代码执行面、更新是版本变更,仅限超管 + // 插件是代码执行面、敏感日志是隐私面,仅限超管(在线自更新无权限点,超管专属) for code := range builtinRoleDefaults { - for _, perm := range []string{"system:plugin", "system:update", "audit:read_sensitive"} { + for _, perm := range []string{"system:plugin", "audit:read_sensitive"} { if has(code, perm) { t.Errorf("预置角色 %s 不应拥有 %s(仅限超级管理员)", code, perm) } diff --git a/internal/logic/admin/security.go b/internal/logic/admin/security.go index 65623ad..ed4c998 100644 --- a/internal/logic/admin/security.go +++ b/internal/logic/admin/security.go @@ -255,6 +255,8 @@ func (s *sAdmin) LoginHistory(ctx context.Context, req *v1.AdminLoginHistoryReq) type adminUserBrief struct { Username string DisplayName string + // Role 用于会话列表标记超管会话(前端隐藏撤销入口),其他场景可为空 + Role string } func adminUserIdsByUsername(ctx context.Context, keyword string) ([]int64, error) { diff --git a/internal/middleware/rbac.go b/internal/middleware/rbac.go index 4ca7770..298b1ba 100644 --- a/internal/middleware/rbac.go +++ b/internal/middleware/rbac.go @@ -203,6 +203,7 @@ var adminPermissionRules = []permissionRule{ {method: "GET", prefix: "/api/admin/audit/forwarding-trace/", perm: "audit:view"}, {method: "GET", path: "/api/admin/audit/sensitive-logs", perm: "audit:read_sensitive"}, {method: "GET", path: "/api/admin/audit/content-filter-logs", perm: "audit:view"}, + {method: "DELETE", path: "/api/admin/audit/content-filter-logs/clear", perm: "audit:clear"}, // ── file 文件管理 ── {method: "GET", path: "/api/admin/files", perm: "file:view"}, @@ -228,6 +229,7 @@ var adminPermissionRules = []permissionRule{ {method: "PUT", prefix: "/api/admin/error-logs/", perm: "monitor:edit"}, {method: "PUT", path: "/api/admin/error-logs/batch-resolve", perm: "monitor:edit"}, {method: "DELETE", path: "/api/admin/error-logs/clear", perm: "monitor:edit"}, + {method: "DELETE", path: "/api/admin/monitor/channel-errors/clear", perm: "monitor:edit"}, {method: "GET", path: "/api/admin/error-logs/stats", perm: "monitor:view"}, {method: "GET", path: "/api/admin/cron-jobs", perm: "system:view"}, {method: "POST", prefix: "/api/admin/cron-jobs/", suffix: "/trigger", perm: "system:edit"}, @@ -243,8 +245,6 @@ var adminPermissionRules = []permissionRule{ {method: "PUT", prefix: "/api/admin/payment-channels/", perm: "system:edit"}, {method: "GET", path: "/api/admin/payment-settings", perm: "system:view"}, {method: "PUT", path: "/api/admin/payment-settings", perm: "system:edit"}, - {method: "GET", prefix: "/api/admin/update/", perm: "system:update"}, - {method: "POST", prefix: "/api/admin/update/", perm: "system:update"}, {method: "GET", path: "/api/admin/data-governance/settings", perm: "system:view"}, {method: "PUT", path: "/api/admin/data-governance/settings", perm: "system:edit"}, {method: "POST", prefix: "/api/admin/data-governance/", perm: "system:edit"}, @@ -305,9 +305,11 @@ var adminPermissionRules = []permissionRule{ {method: "POST", path: "/api/admin/auth/logout", perm: "self:access"}, // 当前用户信息与有效权限:登录即可访问自己的资料,不需要任何业务权限点 {method: "GET", path: "/api/admin/auth/me", perm: "self:access"}, - {method: "GET", path: "/api/admin/auth/sessions", perm: "self:access"}, - {method: "DELETE", prefix: "/api/admin/auth/sessions/user/", perm: "user:edit"}, - {method: "DELETE", prefix: "/api/admin/auth/sessions/", perm: "self:access"}, + // 会话管理列出并处置的是【全部管理员】的登录态,属于账号管理(user 域)的操作能力: + // 按用户管理模块的操作档位(user:edit)授权,只读档位不可见。 + // 超管会话的额外保护在 logic 层(assertCanOperateSessions):仅账号本人可操作。 + {method: "GET", path: "/api/admin/auth/sessions", perm: "user:edit"}, + {method: "DELETE", prefix: "/api/admin/auth/sessions/", perm: "user:edit"}, {method: "PUT", path: "/api/admin/auth/change-password", perm: "self:access"}, {method: "POST", prefix: "/api/admin/security/2fa/", perm: "self:access"}, {method: "GET", path: "/api/admin/security/login-history", perm: "audit:view"}, @@ -356,6 +358,13 @@ var superAdminOnlyRules = []permissionRule{ // 给账号分配角色 —— 这是最直接的提权入口 {method: "GET", prefix: "/api/admin/users/", suffix: "/roles"}, {method: "PUT", prefix: "/api/admin/users/", suffix: "/roles"}, + + // 在线自更新(版本状态 / 检查 / 执行 / 回滚):替换平台二进制并重启服务, + // 等同于在服务器上执行代码,是比角色管理更根本的平台级元操作,不下放。 + // 版本检查(GET)看似只读,但前端每次刷新都会请求,若走权限点授权, + // 未授权角色会持续收到 403 —— 硬性限定超管后非超管前端不再发起请求。 + {method: "GET", prefix: "/api/admin/update/"}, + {method: "POST", prefix: "/api/admin/update/"}, } // isSuperAdminOnly 判断路由是否仅限超级管理员。 @@ -407,9 +416,9 @@ func AdminPermissionGuard(r *ghttp.Request) { return } - // 角色管理是权限体系的元操作,硬性限定超管,不参与权限点授权 + // 角色管理与在线自更新是平台级元操作,硬性限定超管,不参与权限点授权 if isSuperAdminOnly(r.Method, r.URL.Path) { - response.ErrorMsg(r, consts.CodeForbidden, "角色权限管理仅超级管理员可用") + response.ErrorMsg(r, consts.CodeForbidden, "该操作仅超级管理员可用") return } diff --git a/internal/middleware/rbac_coverage_test.go b/internal/middleware/rbac_coverage_test.go index 096e8eb..bc07a9c 100644 --- a/internal/middleware/rbac_coverage_test.go +++ b/internal/middleware/rbac_coverage_test.go @@ -170,6 +170,11 @@ func TestEveryAdminRouteHasPermissionRule(t *testing.T) { if isAdminPublicPath(path) { continue } + // 超管专属路由(角色管理、在线自更新)由 superAdminOnlyRules 硬性拦截, + // 先于权限规则判定,不走权限点授权,因此不要求出现在 adminPermissionRules 里。 + if isSuperAdminOnly(r.Method, path) { + continue + } if perm := matchPermission(r.Method, path); perm == "" { missing = append(missing, r.Method+" "+r.Path+" ("+r.Source+")") } diff --git a/internal/service/admin.go b/internal/service/admin.go index 8beaeb0..3c8473f 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -69,6 +69,11 @@ type ( // ContentFilterLogList returns a paginated list of content filter interception logs. // 审计数据从审计库查询,关联信息从主库批量查询后在应用层合并。 ContentFilterLogList(ctx context.Context, req *v1.ContentFilterLogListReq) (*v1.ContentFilterLogListRes, error) + // ContentFilterLogClear 硬删除全部内容过滤拦截日志。 + // 拦截日志为追加型审计流水,堆积后用 TRUNCATE 秒级清空并立即归还磁盘空间 + // (DELETE 需等 VACUUM 回收)。TRUNCATE 不返回行数,故先取删除前条数作为反馈。 + // 表无数据库级外键,TRUNCATE 安全。 + ContentFilterLogClear(ctx context.Context, _ *v1.ContentFilterLogClearReq) (*v1.ContentFilterLogClearRes, error) // Login handles admin login. Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLoginRes, error) // Logout handles admin logout. @@ -151,6 +156,11 @@ type ( ChannelErrorTopChannels(ctx context.Context, req *v1.ChannelErrorTopChannelsReq) (*v1.ChannelErrorTopChannelsRes, error) // ChannelErrorCategories 错误分类选项 ChannelErrorCategories(ctx context.Context, req *v1.ChannelErrorCategoriesReq) (*v1.ChannelErrorCategoriesRes, error) + // ChannelErrorClear 硬删除全部渠道错误事件。 + // 错误风暴时事件表堆积很快,用 TRUNCATE 而非逐行 DELETE 秒级清空并立即归还磁盘空间。 + // TRUNCATE 不返回行数,故先取删除前条数作为反馈。表无数据库级外键,TRUNCATE 安全。 + // 注意异步写入器(DefaultChannelErrorWriter)清空后继续写入新事件,属预期行为。 + ChannelErrorClear(ctx context.Context, _ *v1.ChannelErrorClearReq) (*v1.ChannelErrorClearRes, error) // ChannelOAuthAuthURL 生成 OAuth 授权链接 ChannelOAuthAuthURL(ctx context.Context, req *v1.ChannelOAuthAuthURLReq) (*v1.ChannelOAuthAuthURLRes, error) // ChannelOAuthExchange OAuth 授权码换取令牌 diff --git a/web/admin/src/router/admin.ts b/web/admin/src/router/admin.ts index f2555ca..27a2b8e 100644 --- a/web/admin/src/router/admin.ts +++ b/web/admin/src/router/admin.ts @@ -119,7 +119,7 @@ const adminRoutes: RouteRecordRaw[] = [ path: 'sessions', name: 'AdminSessions', component: () => import('@/views/security/SessionsPage.vue'), - meta: { title: '会话管理' }, + meta: { title: '会话管理', perm: 'user:edit' }, }, { path: 'permissions', diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index e0e4ceb..cb9278e 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -128,6 +128,8 @@ async function fetchUpdateStatus() { } function openUpdateModal() { + // 在线更新是超管专属能力(后端 superAdminOnlyRules 拦截),非超管不打开弹窗也不发请求 + if (!isSuperAdminUser.value) return updateModalVisible.value = true fetchUpdateStatus() checkUpdate() // 打开弹窗时刷新版本检查,确保当前版本/最新版本为最新 @@ -356,7 +358,7 @@ const ALL_MENU_GROUPS = [ items: [ { name: 'AdminLoginHistory', label: '登录历史', icon: IconClockCircle, perm: 'audit:view' }, { name: 'AdminTenantLoginHistory', label: '租户登录历史', icon: IconClockCircle, perm: 'audit:view' }, - { name: 'AdminSessions', label: '会话管理', icon: IconClockCircle }, + { name: 'AdminSessions', label: '会话管理', icon: IconClockCircle, perm: 'user:edit' }, { name: 'AdminAudit', label: '操作日志', icon: IconFile, perm: 'audit:view' }, ], }, @@ -515,9 +517,12 @@ onMounted(() => { updateMobile() window.addEventListener('resize', updateMobile) - // Check for updates on mount - checkUpdate() - fetchUpdateStatus() // 取后端实时版本,用于准确的"是否可更新"判断 + // 在线更新是超管专属能力(后端 superAdminOnlyRules 拦截), + // 非超管不发请求,避免每次刷新页面都弹「缺少权限」错误提示 + if (isSuperAdminUser.value) { + checkUpdate() + fetchUpdateStatus() // 取后端实时版本,用于准确的"是否可更新"判断 + } fetchWorkbenchBadges() badgeTimer = setInterval(fetchWorkbenchBadges, 60000) diff --git a/web/admin/src/views/monitor/ChannelErrorsPage.vue b/web/admin/src/views/monitor/ChannelErrorsPage.vue index eb1ab1d..40d54ff 100644 --- a/web/admin/src/views/monitor/ChannelErrorsPage.vue +++ b/web/admin/src/views/monitor/ChannelErrorsPage.vue @@ -1,11 +1,12 @@ @@ -442,7 +444,7 @@ onMounted(() => { 手动清理过期文件 - + 触发数据导出