diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8f8d49f --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + name: Lint + Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.26" + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }} + restore-keys: ${{ runner.os }}-go- + + - name: Install tools + run: | + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 + go install mvdan.cc/gofumpt@latest + go install golang.org/x/tools/cmd/goimports@latest + + - name: CI (strict) + run: bash scripts/ci.sh --strict diff --git a/cmd/server/main.go b/cmd/server/main.go index 1c2ef34..07a3e16 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -6,6 +6,7 @@ import ( "metazone.cc/mce/internal/common" "metazone.cc/mce/internal/config" + "metazone.cc/mce/internal/middleware" "metazone.cc/mce/internal/model" "metazone.cc/mce/internal/repository" "metazone.cc/mce/internal/router" @@ -22,6 +23,16 @@ func main() { // 配置 cfg := config.Load("config.yaml") + // 设置时区 + if cfg.Server.Timezone != "" { + loc, err := time.LoadLocation(cfg.Server.Timezone) + if err != nil { + log.Printf("警告:时区 %s 无效,使用系统时区: %v", cfg.Server.Timezone, err) + } else { + time.Local = loc + } + } + // 从配置初始化角色系统(OCP:新增角色无需修改源代码) model.InitRoles(cfg.Roles.Levels, cfg.Roles.Names, cfg.Roles.Permissions.OperableRoles) @@ -98,6 +109,9 @@ func main() { log.Fatalf("初始化站点设置失败: %v", err) } + // 启动数据库健康检测(每 5s ping,失败自动降级) + middleware.StartDBHealthCheck(db) + // 路由注册 router.Setup(r, db, cfg, siteSettings, redisClient) diff --git a/config.yaml b/config.yaml index 1995c07..0044c9d 100644 --- a/config.yaml +++ b/config.yaml @@ -5,6 +5,7 @@ server: port: 8080 mode: debug # debug | release | test cookie_secure: false # Cookie Secure 标志(生产环境应设为 true,部分反向代理环境下可能需要灵活控制) + timezone: Asia/Shanghai # 时区(影响 time.Now() 和模板时间格式化) database: host: 127.0.0.1 diff --git a/internal/common/context.go b/internal/common/context.go index 202220b..9b85a38 100644 --- a/internal/common/context.go +++ b/internal/common/context.go @@ -1,7 +1,11 @@ // Package common 提供通用工具函数、错误哨兵和响应辅助方法。 package common -import "github.com/gin-gonic/gin" +import ( + "strconv" + + "github.com/gin-gonic/gin" +) // GetGinUser 从 Gin context 中提取当前登录用户的 uid 和 role // 如果用户未登录,ok 返回 false;role 可能为空字符串 @@ -19,3 +23,28 @@ func GetGinUser(c *gin.Context) (uid uint, role string, ok bool) { } return } + +// GetGinExp 从 Gin context 中提取当前用户的经验值 +// 如果用户未登录或 exp 不存在,ok 返回 false +func GetGinExp(c *gin.Context) (exp int, ok bool) { + if v, exists := c.Get("exp"); exists { + if e, isInt := v.(int); isInt { + return e, true + } + } + return 0, false +} + +// ParseUIDParam 从 URL 路径参数中提取 uint 类型的 UID +// 统一 UID 解析逻辑,避免各 Controller 重复 strconv.ParseUint +func ParseUIDParam(c *gin.Context, paramName string) (uint, error) { + s := c.Param(paramName) + if s == "" { + return 0, ErrInvalidParam + } + v, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0, ErrInvalidParam + } + return uint(v), nil +} diff --git a/internal/common/errors.go b/internal/common/errors.go index 3c72945..f33e5ca 100644 --- a/internal/common/errors.go +++ b/internal/common/errors.go @@ -58,4 +58,7 @@ var ( // 评论相关 ErrCommentNotFound = errors.New("评论不存在") ErrCommentEmpty = errors.New("评论内容不能为空") + + // 通用参数校验 + ErrInvalidParam = errors.New("参数无效") ) diff --git a/internal/config/config.go b/internal/config/config.go index b76b010..4c5235c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,6 +33,7 @@ type ServerConfig struct { Port string `mapstructure:"port"` Mode string `mapstructure:"mode"` CookieSecure bool `mapstructure:"cookie_secure"` + Timezone string `mapstructure:"timezone"` } type DatabaseConfig struct { diff --git a/internal/controller/admin/admin_controller.go b/internal/controller/admin/admin_controller.go index 89609c9..82720c8 100644 --- a/internal/controller/admin/admin_controller.go +++ b/internal/controller/admin/admin_controller.go @@ -134,11 +134,7 @@ func (ac *AdminController) ResetToken(c *gin.Context) { // parseUIDParam 从 URL 路径参数解析 uid func parseUIDParam(c *gin.Context) (uint, error) { - uid, err := strconv.ParseUint(c.Param("uid"), 10, 64) - if err != nil { - return 0, err - } - return uint(uid), nil + return common.ParseUIDParam(c, "uid") } // handleServiceError 统一处理 service 层返回的错误 diff --git a/internal/controller/admin/site_setting_controller.go b/internal/controller/admin/site_setting_controller.go index 4fb8c85..db64b30 100644 --- a/internal/controller/admin/site_setting_controller.go +++ b/internal/controller/admin/site_setting_controller.go @@ -21,12 +21,48 @@ func NewSiteSettingController(settingService siteSettingUseCase, defaults *servi return &SiteSettingController{settingService: settingService, defaults: defaults} } -// SiteSettingsPage 站点设置管理页面 +// SiteSettingsPage 旧站点设置页 — 301 重定向到品牌页 func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) { - c.HTML(http.StatusOK, "admin/site-settings/index.html", common.BuildAdminPageData(c, gin.H{ - "Title": "站点设置", - "ExtraCSS": "/admin/static/css/site-settings.css", - "ExtraJS": "/admin/static/js/site-settings.js", + c.Redirect(http.StatusMovedPermanently, "/admin/settings/brand") +} + +// BrandPage 品牌与合规设置页 +func (sc *SiteSettingController) BrandPage(c *gin.Context) { + c.HTML(http.StatusOK, "admin/site-settings/brand.html", common.BuildAdminPageData(c, gin.H{ + "Title": "站点设置 - 品牌与合规", + "ExtraCSS": "/admin/static/css/site-settings.css", + "ExtraJS": "/admin/static/js/site-settings.js", + "ActiveSetting": "brand", + })) +} + +// SecurityPage 安全与维护设置页 +func (sc *SiteSettingController) SecurityPage(c *gin.Context) { + c.HTML(http.StatusOK, "admin/site-settings/security.html", common.BuildAdminPageData(c, gin.H{ + "Title": "站点设置 - 安全与维护", + "ExtraCSS": "/admin/static/css/site-settings.css", + "ExtraJS": "/admin/static/js/site-settings.js", + "ActiveSetting": "security", + })) +} + +// RegistrationPage 注册策略设置页 +func (sc *SiteSettingController) RegistrationPage(c *gin.Context) { + c.HTML(http.StatusOK, "admin/site-settings/registration.html", common.BuildAdminPageData(c, gin.H{ + "Title": "站点设置 - 注册策略", + "ExtraCSS": "/admin/static/css/site-settings.css", + "ExtraJS": "/admin/static/js/site-settings.js", + "ActiveSetting": "registration", + })) +} + +// ContentPage 审核与内容设置页 +func (sc *SiteSettingController) ContentPage(c *gin.Context) { + c.HTML(http.StatusOK, "admin/site-settings/content.html", common.BuildAdminPageData(c, gin.H{ + "Title": "站点设置 - 审核与内容", + "ExtraCSS": "/admin/static/css/site-settings.css", + "ExtraJS": "/admin/static/js/site-settings.js", + "ActiveSetting": "content", })) } diff --git a/internal/controller/comment_upload_controller.go b/internal/controller/comment_upload_controller.go index 692b12b..a4c0d6c 100644 --- a/internal/controller/comment_upload_controller.go +++ b/internal/controller/comment_upload_controller.go @@ -28,12 +28,11 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) { } // LV4+ 检查(Exp ≥ 1500) - expVal, exists := c.Get("exp") - if !exists { + exp, ok := common.GetGinExp(c) + if !ok { common.Error(c, http.StatusForbidden, "无法获取经验值") return } - exp := expVal.(int) if exp < 1500 { common.Error(c, http.StatusForbidden, "需 Lv4 以上才能上传评论图片") return diff --git a/internal/controller/follow_controller.go b/internal/controller/follow_controller.go index 61e9e6f..6db74cb 100644 --- a/internal/controller/follow_controller.go +++ b/internal/controller/follow_controller.go @@ -29,13 +29,13 @@ func (fc *FollowController) Toggle(c *gin.Context) { return } - targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64) + targetUID, err := common.ParseUIDParam(c, "uid") if err != nil { common.Error(c, http.StatusBadRequest, "无效的用户ID") return } - isFollowing, err := fc.followSvc.Toggle(uid, uint(targetUID)) + isFollowing, err := fc.followSvc.Toggle(uid, targetUID) if err != nil { if err.Error() == "不能关注自己" { common.Error(c, http.StatusBadRequest, err.Error()) @@ -46,7 +46,7 @@ func (fc *FollowController) Toggle(c *gin.Context) { } // 获取最新状态 - status, _ := fc.followSvc.GetStatus(uid, uint(targetUID)) + status, _ := fc.followSvc.GetStatus(uid, targetUID) common.Ok(c, gin.H{ "is_following": isFollowing, @@ -56,7 +56,7 @@ func (fc *FollowController) Toggle(c *gin.Context) { // GetStatus 查询关注关系 GET /api/users/:uid/follow-status func (fc *FollowController) GetStatus(c *gin.Context) { - targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64) + targetUID, err := common.ParseUIDParam(c, "uid") if err != nil { common.Error(c, http.StatusBadRequest, "无效的用户ID") return @@ -64,7 +64,7 @@ func (fc *FollowController) GetStatus(c *gin.Context) { uid, _, _ := common.GetGinUser(c) // 允许未登录(返回全是 false) - status, err := fc.followSvc.GetStatus(uid, uint(targetUID)) + status, err := fc.followSvc.GetStatus(uid, targetUID) if err != nil { common.Error(c, http.StatusInternalServerError, "查询失败") return @@ -75,7 +75,7 @@ func (fc *FollowController) GetStatus(c *gin.Context) { // Followers 粉丝列表 GET /api/users/:uid/followers func (fc *FollowController) Followers(c *gin.Context) { - targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64) + targetUID, err := common.ParseUIDParam(c, "uid") if err != nil { common.Error(c, http.StatusBadRequest, "无效的用户ID") return @@ -86,7 +86,7 @@ func (fc *FollowController) Followers(c *gin.Context) { uid, _, _ := common.GetGinUser(c) // 允许未登录 - result, err := fc.followSvc.ListFollowers(uint(targetUID), uid, page, pageSize) + result, err := fc.followSvc.ListFollowers(targetUID, uid, page, pageSize) if err != nil { common.Error(c, http.StatusInternalServerError, "查询失败") return @@ -97,7 +97,7 @@ func (fc *FollowController) Followers(c *gin.Context) { // Following 关注列表 GET /api/users/:uid/following func (fc *FollowController) Following(c *gin.Context) { - targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64) + targetUID, err := common.ParseUIDParam(c, "uid") if err != nil { common.Error(c, http.StatusBadRequest, "无效的用户ID") return @@ -108,7 +108,7 @@ func (fc *FollowController) Following(c *gin.Context) { uid, _, _ := common.GetGinUser(c) - result, err := fc.followSvc.ListFollowing(uint(targetUID), uid, page, pageSize) + result, err := fc.followSvc.ListFollowing(targetUID, uid, page, pageSize) if err != nil { common.Error(c, http.StatusInternalServerError, "查询失败") return @@ -129,7 +129,7 @@ func NewFollowPageController(followSvc followUseCase) *FollowPageController { // FollowersPage 粉丝列表页面 func (fpc *FollowPageController) FollowersPage(c *gin.Context) { - targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64) + targetUID, err := common.ParseUIDParam(c, "uid") if err != nil { common.Error(c, http.StatusBadRequest, "无效的用户ID") return @@ -138,7 +138,7 @@ func (fpc *FollowPageController) FollowersPage(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) uid, _, _ := common.GetGinUser(c) - result, err := fpc.followSvc.ListFollowers(uint(targetUID), uid, page, 20) + result, err := fpc.followSvc.ListFollowers(targetUID, uid, page, 20) if err != nil { result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true} } @@ -159,7 +159,7 @@ func (fpc *FollowPageController) FollowersPage(c *gin.Context) { // FollowingPage 关注列表页面 func (fpc *FollowPageController) FollowingPage(c *gin.Context) { - targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64) + targetUID, err := common.ParseUIDParam(c, "uid") if err != nil { common.Error(c, http.StatusBadRequest, "无效的用户ID") return @@ -168,7 +168,7 @@ func (fpc *FollowPageController) FollowingPage(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) uid, _, _ := common.GetGinUser(c) - result, err := fpc.followSvc.ListFollowing(uint(targetUID), uid, page, 20) + result, err := fpc.followSvc.ListFollowing(targetUID, uid, page, 20) if err != nil { result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true} } diff --git a/internal/controller/settings_controller.go b/internal/controller/settings_controller.go index f53476b..f98c03e 100644 --- a/internal/controller/settings_controller.go +++ b/internal/controller/settings_controller.go @@ -68,6 +68,7 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) { data := gin.H{ "Title": "个人设置", "ExtraCSS": "/static/css/settings.css", + "ExtraJS": "/static/js/settings-" + tab + ".js", "ActiveTab": tab, "User": user, "RoleName": model.RoleDisplayNames[user.Role], @@ -113,5 +114,22 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) { data["CurrentSID"] = currentSID } - c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, data)) + // 按 tab 渲染独立模板 + tplName := "settings/index.html" + switch tab { + case "profile": + tplName = "settings/profile.html" + case "account": + tplName = "settings/account.html" + case "sessions": + tplName = "settings/sessions.html" + case "energy": + tplName = "settings/energy.html" + case "favorites": + tplName = "settings/favorites.html" + case "notify": + tplName = "settings/notify.html" + } + + c.HTML(http.StatusOK, tplName, common.BuildPageData(c, data)) } diff --git a/internal/middleware/db_health.go b/internal/middleware/db_health.go new file mode 100644 index 0000000..0b8bedc --- /dev/null +++ b/internal/middleware/db_health.go @@ -0,0 +1,84 @@ +package middleware + +import ( + "log" + "net/http" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// dbHealthy 全局数据库健康状态(原子读写,默认为健康) +var dbHealthy atomic.Bool + +func init() { + dbHealthy.Store(true) +} + +// StartDBHealthCheck 启动定时 DB 健康检测(在 main goroutine 中调用) +// 每 5 秒 ping 一次,失败则标记不健康,恢复则重新标记健康 +func StartDBHealthCheck(db *gorm.DB) { + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for range ticker.C { + sqlDB, err := db.DB() + if err != nil { + dbHealthy.Store(false) + log.Printf("[DBHealth] 获取底层连接失败: %v", err) + continue + } + if err := sqlDB.Ping(); err != nil { + if dbHealthy.Swap(false) { + log.Printf("[DBHealth] 数据库不可达,整站进入降级模式: %v", err) + } + } else { + if !dbHealthy.Swap(true) { + log.Println("[DBHealth] 数据库已恢复,退出降级模式") + } + } + } + }() +} + +// DBHealthGuard 数据库健康检查中间件 +// 检测到 DB 不可达时,返回固定维护页面,避免暴露内部堆栈信息 +func DBHealthGuard() gin.HandlerFunc { + return func(c *gin.Context) { + if dbHealthy.Load() { + c.Next() + return + } + + path := c.Request.URL.Path + // 静态资源和 API 直接返回错误,不渲染页面 + if isStaticPath(path) { + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{ + "success": false, + "message": "服务暂时不可用,请稍后重试", + }) + return + } + // 页面请求返回友好 HTML + c.AbortWithStatus(http.StatusServiceUnavailable) + c.Header("Content-Type", "text/html; charset=utf-8") + _, _ = c.Writer.WriteString(` + +服务暂时不可用 + +

服务暂时不可用

数据库连接异常,请稍后刷新页面重试

`) + } +} + +// isStaticPath 判断是否为静态资源或 API 路径 +func isStaticPath(path string) bool { + for _, prefix := range []string{"/static/", "/shared/", "/admin/static/", "/api/"} { + if len(path) >= len(prefix) && path[:len(prefix)] == prefix { + return true + } + } + return false +} diff --git a/internal/middleware/security.go b/internal/middleware/security.go index 90d346d..2828a62 100644 --- a/internal/middleware/security.go +++ b/internal/middleware/security.go @@ -3,8 +3,7 @@ package middleware import ( "crypto/rand" "encoding/base64" - - "metazone.cc/mce/internal/config" + "fmt" "github.com/gin-gonic/gin" ) @@ -28,12 +27,13 @@ func SecurityHeaders() gin.HandlerFunc { c.Set("csp_nonce", nonce) // Content-Security-Policy - // script-src/style-src: 本站 + nonce(替代 unsafe-inline) + // script-src: 本站 + nonce(替代 unsafe-inline),保留 unsafe-eval 供 Vditor 使用 + // style-src: 本站(所有内联样式已使用 nonce) // img-src: 本站 + data: URI(头像裁切)+ blob:(粘贴图片) c.Header("Content-Security-Policy", "default-src 'self'; "+ - "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+ - "style-src 'self' 'unsafe-inline'; "+ + fmt.Sprintf("script-src 'self' 'unsafe-eval' 'nonce-%s'; ", nonce)+ + "style-src 'self' 'nonce-"+nonce+"'; "+ "img-src 'self' data: blob:; "+ "connect-src 'self'") @@ -46,10 +46,8 @@ func SecurityHeaders() gin.HandlerFunc { // 引用策略 c.Header("Referrer-Policy", "strict-origin-when-cross-origin") - // HSTS:仅在 release 模式启用,避免开发环境 localhost 证书问题 - if config.App != nil && config.App.Server.Mode == "release" { - c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload") - } + // HSTS:始终启用(max-age=1年,含子域名,允许 preload 列表收录) + c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload") c.Next() } diff --git a/internal/router/admin.go b/internal/router/admin.go index 057c3b4..2526eaa 100644 --- a/internal/router/admin.go +++ b/internal/router/admin.go @@ -28,6 +28,14 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { adminPages.GET("/posts", d.adminPostCtrl.PostsPage) adminPages.GET("/site-settings", d.siteSettingCtrl.SiteSettingsPage, middleware.RequirePageRole(model.RoleOwner)) + adminPages.GET("/settings/brand", d.siteSettingCtrl.BrandPage, + middleware.RequirePageRole(model.RoleOwner)) + adminPages.GET("/settings/security", d.siteSettingCtrl.SecurityPage, + middleware.RequirePageRole(model.RoleOwner)) + adminPages.GET("/settings/registration", d.siteSettingCtrl.RegistrationPage, + middleware.RequirePageRole(model.RoleOwner)) + adminPages.GET("/settings/content", d.siteSettingCtrl.ContentPage, + middleware.RequirePageRole(model.RoleOwner)) adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage) adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage) adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage) diff --git a/internal/router/api.go b/internal/router/api.go index df08d9b..23e899e 100644 --- a/internal/router/api.go +++ b/internal/router/api.go @@ -2,197 +2,17 @@ package router import ( "metazone.cc/mce/internal/config" - "metazone.cc/mce/internal/middleware" "github.com/gin-gonic/gin" ) -// setupAPIRoutes 注册 API 路由 +// setupAPIRoutes 按域分发 API 路由注册 func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { - // --- 设置页 API(需登录) --- - settingsAPI := r.Group("/api/settings") - settingsAPI.Use(d.authMdw.Required()) - settingsAPI.Use(d.authMdw.BannedWriteGuard()) - settingsAPI.Use(middleware.CSRF(cfg)) - { - settingsAPI.PUT("/profile", d.settingsCtrl.UpdateProfile) - settingsAPI.POST("/avatar", d.settingsCtrl.UploadAvatar) - settingsAPI.PUT("/password", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.ChangePassword) - settingsAPI.POST("/delete-account", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.DeleteAccount) - settingsAPI.GET("/audit-status", d.settingsCtrl.AuditStatus) - // 登录管理 - settingsAPI.GET("/sessions", d.settingsCtrl.ListSessions) - settingsAPI.DELETE("/sessions/:sid", d.settingsCtrl.DestroySession) - settingsAPI.POST("/sessions/destroy-others", d.settingsCtrl.DestroyOtherSessions) - settingsAPI.PUT("/sessions/:sid/remark", d.settingsCtrl.UpdateSessionRemark) - // 通知偏好 - settingsAPI.GET("/notify-prefs", d.settingsCtrl.GetNotifyPrefs) - settingsAPI.PUT("/notify-prefs", d.settingsCtrl.UpdateNotifyPref) - } - - // --- 消息中心 API(需登录) --- - messagesAPI := r.Group("/api/messages") - messagesAPI.Use(d.authMdw.Required()) - messagesAPI.Use(d.authMdw.BannedWriteGuard()) - messagesAPI.Use(middleware.CSRF(cfg)) - { - messagesAPI.GET("/unread", d.messageCtrl.UnreadCount) - messagesAPI.POST("/:id/read", d.messageCtrl.MarkRead) - messagesAPI.POST("/read-all", d.messageCtrl.MarkAllRead) - } - - // --- 积分/等级 API(需登录) --- - levelAPI := r.Group("/api/level") - levelAPI.Use(d.authMdw.Required()) - levelAPI.Use(d.authMdw.BannedWriteGuard()) - levelAPI.Use(middleware.CSRF(cfg)) - { - levelAPI.POST("/checkin", middleware.SensitiveRateLimit(d.rateLimiter), d.levelCtrl.CheckIn) - levelAPI.GET("/info", d.levelCtrl.GetLevel) - } - - // --- 认证 API --- - api := r.Group("/api") - api.Use(middleware.CSRF(cfg)) - { - api.POST("/auth/check-email", d.authCtrl.CheckEmail) - api.POST("/auth/register", d.authCtrl.Register) - api.POST("/auth/login", d.authCtrl.Login) - api.POST("/auth/confirm-restore", d.authCtrl.ConfirmRestore) - api.POST("/auth/logout", d.authCtrl.Logout) - api.POST("/auth/refresh", d.authCtrl.RefreshToken) - - // 帖子公开 API(无需登录) - api.GET("/posts", d.postCtrl.ListAPI) - api.GET("/posts/:id", d.postCtrl.ShowAPI) - } - - // --- 帖子 API(需登录) --- - postsAPI := r.Group("/api/posts") - postsAPI.Use(d.authMdw.Required()) - postsAPI.Use(d.authMdw.BannedWriteGuard()) - postsAPI.Use(middleware.CSRF(cfg)) - { - postsAPI.POST("/:id/submit", d.postCtrl.Submit) - postsAPI.POST("/upload-image", d.postCtrl.UploadImage) - } - - // --- 创作中心 API(需登录) --- - studioAPI := r.Group("/api/studio") - studioAPI.Use(d.authMdw.Required()) - studioAPI.Use(d.authMdw.BannedWriteGuard()) - studioAPI.Use(middleware.CSRF(cfg)) - { - studioAPI.GET("/overview", d.studioCtrl.OverviewAPI) - studioAPI.GET("/posts", d.studioCtrl.ListPostsAPI) - studioAPI.POST("/posts", d.studioCtrl.Create) - studioAPI.PUT("/posts/:id", d.studioCtrl.Update) - studioAPI.DELETE("/posts/:id", d.studioCtrl.Delete) - studioAPI.POST("/posts/:id/submit", d.studioCtrl.Submit) - } - - // --- 域能 API(需登录) --- - energyAPI := r.Group("/api/energy") - energyAPI.Use(d.authMdw.Required()) - energyAPI.Use(d.authMdw.BannedWriteGuard()) - energyAPI.Use(middleware.CSRF(cfg)) - { - energyAPI.POST("/energize", d.energyCtrl.Energize) - energyAPI.GET("/info", d.energyCtrl.GetEnergyInfo) - energyAPI.GET("/logs", d.energyCtrl.GetEnergyLogs) - } - - // --- 评论 API(部分需登录) --- - // 公开读取 - commentsPublic := r.Group("/api/posts/:id/comments") - { - commentsPublic.GET("", d.commentCtrl.ListRootComments) - commentsPublic.GET("/:root_id/replies", d.commentCtrl.ListReplies) - } - // 需要登录的操作 - commentsAPI := r.Group("/api") - commentsAPI.Use(d.authMdw.Required()) - commentsAPI.Use(d.authMdw.BannedWriteGuard()) - commentsAPI.Use(middleware.CSRF(cfg)) - { - commentsAPI.POST("/posts/:id/comments", d.commentCtrl.CreateRoot) - commentsAPI.POST("/comments/:id/reply", d.commentCtrl.CreateReply) - commentsAPI.DELETE("/comments/:id", d.commentCtrl.Delete) - commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers) - commentsAPI.POST("/comments/upload-image", d.commentCtrl.UploadImage) - } - - // --- 赞/踩 API --- - // 公开:查询反应状态(未登录返回 none + 点赞数) - r.GET("/api/posts/:id/reaction", d.reactionCtrl.GetReaction) - // 需登录:切换赞/踩 - reactionAPI := r.Group("/api/posts/:id") - reactionAPI.Use(d.authMdw.Required()) - reactionAPI.Use(d.authMdw.BannedWriteGuard()) - reactionAPI.Use(middleware.CSRF(cfg)) - { - reactionAPI.POST("/like", d.reactionCtrl.ToggleLike) - reactionAPI.POST("/dislike", d.reactionCtrl.ToggleDislike) - } - - // --- 关注 API --- - // 公开(Optional 认证以获取当前用户关注状态,未登录返回 false) - followPublic := r.Group("/api/users/:uid") - followPublic.Use(d.authMdw.Optional()) - { - followPublic.GET("/follow-status", d.followCtrl.GetStatus) - followPublic.GET("/followers", d.followCtrl.Followers) - followPublic.GET("/following", d.followCtrl.Following) - } - // 需登录:切换关注 - followAPI := r.Group("/api/users/:uid") - followAPI.Use(d.authMdw.Required()) - followAPI.Use(d.authMdw.BannedWriteGuard()) - followAPI.Use(middleware.CSRF(cfg)) - { - followAPI.POST("/follow", d.followCtrl.Toggle) - } - - // --- 收藏夹 API --- - // 公开:文章收藏状态(未登录返回 false) - r.GET("/api/posts/:id/folder-status", d.favoriteCtrl.GetPostStatus) - // 公开:查看公开收藏夹内容 - r.GET("/api/folders/:id/posts", d.favoriteCtrl.ListFolderItems) - // 需登录:收藏操作 - folderAPI := r.Group("/api/folders") - folderAPI.Use(d.authMdw.Required()) - folderAPI.Use(d.authMdw.BannedWriteGuard()) - folderAPI.Use(middleware.CSRF(cfg)) - { - folderAPI.GET("", d.favoriteCtrl.ListFolders) - folderAPI.POST("", d.favoriteCtrl.CreateFolder) - folderAPI.PUT("/:id", d.favoriteCtrl.UpdateFolder) - folderAPI.DELETE("/:id", d.favoriteCtrl.DeleteFolder) - folderAPI.POST("/:id/posts", d.favoriteCtrl.AddToFolder) - folderAPI.DELETE("/:id/posts/:postId", d.favoriteCtrl.RemoveFromFolder) - } - // 需登录:切换收藏(Toggle) - favToggleAPI := r.Group("/api/posts/:id") - favToggleAPI.Use(d.authMdw.Required()) - favToggleAPI.Use(d.authMdw.BannedWriteGuard()) - favToggleAPI.Use(middleware.CSRF(cfg)) - { - favToggleAPI.POST("/favorite", d.favoriteCtrl.ToggleFavorite) - } - - // --- 空间/隐私设置 API(需登录) --- - spaceAPI := r.Group("/api/space") - spaceAPI.Use(d.authMdw.Required()) - spaceAPI.Use(d.authMdw.BannedWriteGuard()) - spaceAPI.Use(middleware.CSRF(cfg)) - { - spaceAPI.PUT("/privacy", d.spaceCtrl.UpdatePrivacy) - } - - // --- Studio 趋势 API(需登录) --- - trendsAPI := r.Group("/api/studio") - trendsAPI.Use(d.authMdw.Required()) - { - trendsAPI.GET("/trends", d.studioCtrl.TrendsAPI) - } + setupAuthAPIRoutes(r, cfg, d) + setupSettingsAPIRoutes(r, cfg, d) + setupPostsAPIRoutes(r, cfg, d) + setupCommentsAPIRoutes(r, cfg, d) + setupStudioAPIRoutes(r, cfg, d) + setupReactionsAPIRoutes(r, cfg, d) + setupSocialAPIRoutes(r, cfg, d) } diff --git a/internal/router/api_auth.go b/internal/router/api_auth.go new file mode 100644 index 0000000..a9c6694 --- /dev/null +++ b/internal/router/api_auth.go @@ -0,0 +1,27 @@ +package router + +import ( + "metazone.cc/mce/internal/config" + "metazone.cc/mce/internal/middleware" + + "github.com/gin-gonic/gin" +) + +// setupAuthAPIRoutes 注册认证相关 API 路由 +func setupAuthAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { + api := r.Group("/api") + api.Use(middleware.CSRF(cfg)) + { + // 认证 + api.POST("/auth/check-email", d.authCtrl.CheckEmail) + api.POST("/auth/register", d.authCtrl.Register) + api.POST("/auth/login", d.authCtrl.Login) + api.POST("/auth/confirm-restore", d.authCtrl.ConfirmRestore) + api.POST("/auth/logout", d.authCtrl.Logout) + api.POST("/auth/refresh", d.authCtrl.RefreshToken) + + // 帖子公开 API(无需登录) + api.GET("/posts", d.postCtrl.ListAPI) + api.GET("/posts/:id", d.postCtrl.ShowAPI) + } +} diff --git a/internal/router/api_comments.go b/internal/router/api_comments.go new file mode 100644 index 0000000..2f9427c --- /dev/null +++ b/internal/router/api_comments.go @@ -0,0 +1,31 @@ +package router + +import ( + "metazone.cc/mce/internal/config" + "metazone.cc/mce/internal/middleware" + + "github.com/gin-gonic/gin" +) + +// setupCommentsAPIRoutes 注册评论相关 API 路由 +func setupCommentsAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { + // 公开读取 + commentsPublic := r.Group("/api/posts/:id/comments") + { + commentsPublic.GET("", d.commentCtrl.ListRootComments) + commentsPublic.GET("/:root_id/replies", d.commentCtrl.ListReplies) + } + + // 需要登录的操作 + commentsAPI := r.Group("/api") + commentsAPI.Use(d.authMdw.Required()) + commentsAPI.Use(d.authMdw.BannedWriteGuard()) + commentsAPI.Use(middleware.CSRF(cfg)) + { + commentsAPI.POST("/posts/:id/comments", d.commentCtrl.CreateRoot) + commentsAPI.POST("/comments/:id/reply", d.commentCtrl.CreateReply) + commentsAPI.DELETE("/comments/:id", d.commentCtrl.Delete) + commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers) + commentsAPI.POST("/comments/upload-image", d.commentCtrl.UploadImage) + } +} diff --git a/internal/router/api_posts.go b/internal/router/api_posts.go new file mode 100644 index 0000000..574f8fa --- /dev/null +++ b/internal/router/api_posts.go @@ -0,0 +1,20 @@ +package router + +import ( + "metazone.cc/mce/internal/config" + "metazone.cc/mce/internal/middleware" + + "github.com/gin-gonic/gin" +) + +// setupPostsAPIRoutes 注册帖子相关 API 路由 +func setupPostsAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { + postsAPI := r.Group("/api/posts") + postsAPI.Use(d.authMdw.Required()) + postsAPI.Use(d.authMdw.BannedWriteGuard()) + postsAPI.Use(middleware.CSRF(cfg)) + { + postsAPI.POST("/:id/submit", d.postCtrl.Submit) + postsAPI.POST("/upload-image", d.postCtrl.UploadImage) + } +} diff --git a/internal/router/api_reactions.go b/internal/router/api_reactions.go new file mode 100644 index 0000000..6c06548 --- /dev/null +++ b/internal/router/api_reactions.go @@ -0,0 +1,24 @@ +package router + +import ( + "metazone.cc/mce/internal/config" + "metazone.cc/mce/internal/middleware" + + "github.com/gin-gonic/gin" +) + +// setupReactionsAPIRoutes 注册赞/踩相关 API 路由 +func setupReactionsAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { + // 公开:查询反应状态 + r.GET("/api/posts/:id/reaction", d.reactionCtrl.GetReaction) + + // 需登录:切换赞/踩 + reactionAPI := r.Group("/api/posts/:id") + reactionAPI.Use(d.authMdw.Required()) + reactionAPI.Use(d.authMdw.BannedWriteGuard()) + reactionAPI.Use(middleware.CSRF(cfg)) + { + reactionAPI.POST("/like", d.reactionCtrl.ToggleLike) + reactionAPI.POST("/dislike", d.reactionCtrl.ToggleDislike) + } +} diff --git a/internal/router/api_settings.go b/internal/router/api_settings.go new file mode 100644 index 0000000..fabe02b --- /dev/null +++ b/internal/router/api_settings.go @@ -0,0 +1,51 @@ +package router + +import ( + "metazone.cc/mce/internal/config" + "metazone.cc/mce/internal/middleware" + + "github.com/gin-gonic/gin" +) + +// setupSettingsAPIRoutes 注册设置、消息、等级相关 API 路由 +func setupSettingsAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { + // 设置页 API + settingsAPI := r.Group("/api/settings") + settingsAPI.Use(d.authMdw.Required()) + settingsAPI.Use(d.authMdw.BannedWriteGuard()) + settingsAPI.Use(middleware.CSRF(cfg)) + { + settingsAPI.PUT("/profile", d.settingsCtrl.UpdateProfile) + settingsAPI.POST("/avatar", d.settingsCtrl.UploadAvatar) + settingsAPI.PUT("/password", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.ChangePassword) + settingsAPI.POST("/delete-account", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.DeleteAccount) + settingsAPI.GET("/audit-status", d.settingsCtrl.AuditStatus) + settingsAPI.GET("/sessions", d.settingsCtrl.ListSessions) + settingsAPI.DELETE("/sessions/:sid", d.settingsCtrl.DestroySession) + settingsAPI.POST("/sessions/destroy-others", d.settingsCtrl.DestroyOtherSessions) + settingsAPI.PUT("/sessions/:sid/remark", d.settingsCtrl.UpdateSessionRemark) + settingsAPI.GET("/notify-prefs", d.settingsCtrl.GetNotifyPrefs) + settingsAPI.PUT("/notify-prefs", d.settingsCtrl.UpdateNotifyPref) + } + + // 消息中心 API + messagesAPI := r.Group("/api/messages") + messagesAPI.Use(d.authMdw.Required()) + messagesAPI.Use(d.authMdw.BannedWriteGuard()) + messagesAPI.Use(middleware.CSRF(cfg)) + { + messagesAPI.GET("/unread", d.messageCtrl.UnreadCount) + messagesAPI.POST("/:id/read", d.messageCtrl.MarkRead) + messagesAPI.POST("/read-all", d.messageCtrl.MarkAllRead) + } + + // 积分/等级 API + levelAPI := r.Group("/api/level") + levelAPI.Use(d.authMdw.Required()) + levelAPI.Use(d.authMdw.BannedWriteGuard()) + levelAPI.Use(middleware.CSRF(cfg)) + { + levelAPI.POST("/checkin", middleware.SensitiveRateLimit(d.rateLimiter), d.levelCtrl.CheckIn) + levelAPI.GET("/info", d.levelCtrl.GetLevel) + } +} diff --git a/internal/router/api_social.go b/internal/router/api_social.go new file mode 100644 index 0000000..25026b6 --- /dev/null +++ b/internal/router/api_social.go @@ -0,0 +1,64 @@ +package router + +import ( + "metazone.cc/mce/internal/config" + "metazone.cc/mce/internal/middleware" + + "github.com/gin-gonic/gin" +) + +// setupSocialAPIRoutes 注册关注、收藏夹、空间相关 API 路由 +func setupSocialAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { + // 关注 — 公开(Optional 认证) + followPublic := r.Group("/api/users/:uid") + followPublic.Use(d.authMdw.Optional()) + { + followPublic.GET("/follow-status", d.followCtrl.GetStatus) + followPublic.GET("/followers", d.followCtrl.Followers) + followPublic.GET("/following", d.followCtrl.Following) + } + // 需登录:切换关注 + followAPI := r.Group("/api/users/:uid") + followAPI.Use(d.authMdw.Required()) + followAPI.Use(d.authMdw.BannedWriteGuard()) + followAPI.Use(middleware.CSRF(cfg)) + { + followAPI.POST("/follow", d.followCtrl.Toggle) + } + + // 收藏夹 — 公开 + r.GET("/api/posts/:id/folder-status", d.favoriteCtrl.GetPostStatus) + r.GET("/api/folders/:id/posts", d.favoriteCtrl.ListFolderItems) + + // 收藏夹 — 需登录 + folderAPI := r.Group("/api/folders") + folderAPI.Use(d.authMdw.Required()) + folderAPI.Use(d.authMdw.BannedWriteGuard()) + folderAPI.Use(middleware.CSRF(cfg)) + { + folderAPI.GET("", d.favoriteCtrl.ListFolders) + folderAPI.POST("", d.favoriteCtrl.CreateFolder) + folderAPI.PUT("/:id", d.favoriteCtrl.UpdateFolder) + folderAPI.DELETE("/:id", d.favoriteCtrl.DeleteFolder) + folderAPI.POST("/:id/posts", d.favoriteCtrl.AddToFolder) + folderAPI.DELETE("/:id/posts/:postId", d.favoriteCtrl.RemoveFromFolder) + } + + // 切换收藏 Toggle + favToggleAPI := r.Group("/api/posts/:id") + favToggleAPI.Use(d.authMdw.Required()) + favToggleAPI.Use(d.authMdw.BannedWriteGuard()) + favToggleAPI.Use(middleware.CSRF(cfg)) + { + favToggleAPI.POST("/favorite", d.favoriteCtrl.ToggleFavorite) + } + + // 空间/隐私设置 + spaceAPI := r.Group("/api/space") + spaceAPI.Use(d.authMdw.Required()) + spaceAPI.Use(d.authMdw.BannedWriteGuard()) + spaceAPI.Use(middleware.CSRF(cfg)) + { + spaceAPI.PUT("/privacy", d.spaceCtrl.UpdatePrivacy) + } +} diff --git a/internal/router/api_studio.go b/internal/router/api_studio.go new file mode 100644 index 0000000..0a56ccd --- /dev/null +++ b/internal/router/api_studio.go @@ -0,0 +1,43 @@ +package router + +import ( + "metazone.cc/mce/internal/config" + "metazone.cc/mce/internal/middleware" + + "github.com/gin-gonic/gin" +) + +// setupStudioAPIRoutes 注册创作中心、域能、趋势相关 API 路由 +func setupStudioAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { + // 创作中心 + studioAPI := r.Group("/api/studio") + studioAPI.Use(d.authMdw.Required()) + studioAPI.Use(d.authMdw.BannedWriteGuard()) + studioAPI.Use(middleware.CSRF(cfg)) + { + studioAPI.GET("/overview", d.studioCtrl.OverviewAPI) + studioAPI.GET("/posts", d.studioCtrl.ListPostsAPI) + studioAPI.POST("/posts", d.studioCtrl.Create) + studioAPI.PUT("/posts/:id", d.studioCtrl.Update) + studioAPI.DELETE("/posts/:id", d.studioCtrl.Delete) + studioAPI.POST("/posts/:id/submit", d.studioCtrl.Submit) + } + + // 域能 + energyAPI := r.Group("/api/energy") + energyAPI.Use(d.authMdw.Required()) + energyAPI.Use(d.authMdw.BannedWriteGuard()) + energyAPI.Use(middleware.CSRF(cfg)) + { + energyAPI.POST("/energize", d.energyCtrl.Energize) + energyAPI.GET("/info", d.energyCtrl.GetEnergyInfo) + energyAPI.GET("/logs", d.energyCtrl.GetEnergyLogs) + } + + // 趋势 + trendsAPI := r.Group("/api/studio") + trendsAPI.Use(d.authMdw.Required()) + { + trendsAPI.GET("/trends", d.studioCtrl.TrendsAPI) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 739e3c5..dbcd515 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -14,6 +14,7 @@ import ( func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) { r.Use(middleware.SecurityHeaders()) r.Use(middleware.InjectSiteInfo(siteSettings)) + r.Use(middleware.DBHealthGuard()) deps := buildDeps(db, cfg, siteSettings, redisClient) diff --git a/templates/MetaLab-2026/html/settings/_footer.html b/templates/MetaLab-2026/html/settings/_footer.html new file mode 100644 index 0000000..f8b91d6 --- /dev/null +++ b/templates/MetaLab-2026/html/settings/_footer.html @@ -0,0 +1,5 @@ + + +{{template "layout/footer.html" .}} + + diff --git a/templates/MetaLab-2026/html/settings/_header.html b/templates/MetaLab-2026/html/settings/_header.html new file mode 100644 index 0000000..9b560f7 --- /dev/null +++ b/templates/MetaLab-2026/html/settings/_header.html @@ -0,0 +1,56 @@ +{{template "layout/header.html" .}} + +{{template "layout/nav.html" .}} +
+
+ +
diff --git a/templates/MetaLab-2026/html/settings/account.html b/templates/MetaLab-2026/html/settings/account.html new file mode 100644 index 0000000..f333802 --- /dev/null +++ b/templates/MetaLab-2026/html/settings/account.html @@ -0,0 +1,85 @@ +{{template "settings/_header.html" .}} + +
+
+

账号信息

+

账号安全和基本状态

+
+
+
+ 用户 ID + {{.User.ID}} +
+
+ 邮箱 + {{.User.Email}} +
+
+ 账号状态 + + {{.StatusName}} + +
+
+ 注册时间 + {{.User.CreatedAt.Format "2006-01-02 15:04:05"}} +
+ {{if .User.LastLoginAt}} +
+ 上次登录 + {{.User.LastLoginAt.Format "2006-01-02 15:04:05"}} +
+ {{end}} +
+ 最后更新 + {{.User.UpdatedAt.Format "2006-01-02 15:04:05"}} +
+
+
+ + +
+
+

修改密码

+

修改密码后需重新登录

+
+
+
+ + +
+
+ + +
+ +
+
+ + +
+
+

注销账号

+

账号将在 7 天后正式注销,期间可随时重新登录撤销注销

+
+
+
+ + +
+
+ + +
+ +
+
+ {{end}} +{{template "settings/_footer.html" .}} + diff --git a/templates/MetaLab-2026/html/settings/energy.html b/templates/MetaLab-2026/html/settings/energy.html new file mode 100644 index 0000000..c671c03 --- /dev/null +++ b/templates/MetaLab-2026/html/settings/energy.html @@ -0,0 +1,29 @@ +{{template "settings/_header.html" .}} + +
+
+

我的域能

+

域能余额和经验

+
+
+
+
+ 域能余额 + 加载中... +
+
+ 经验 + 加载中... +
+
+ 等级 + 加载中... +
+
+
+
加载中...
+
+
+
+{{template "settings/_footer.html" .}} + diff --git a/templates/MetaLab-2026/html/settings/favorites.html b/templates/MetaLab-2026/html/settings/favorites.html new file mode 100644 index 0000000..758cac2 --- /dev/null +++ b/templates/MetaLab-2026/html/settings/favorites.html @@ -0,0 +1,18 @@ +{{template "settings/_header.html" .}} + +
+
+

我的收藏夹

+

管理和查看你的收藏夹

+
+
+
+ +
+
+
加载中...
+
+
+
+{{template "settings/_footer.html" .}} + diff --git a/templates/MetaLab-2026/html/settings/notify.html b/templates/MetaLab-2026/html/settings/notify.html new file mode 100644 index 0000000..fa4896f --- /dev/null +++ b/templates/MetaLab-2026/html/settings/notify.html @@ -0,0 +1,13 @@ +{{template "settings/_header.html" .}} + +
+
+

通知偏好

+

管理你的通知推送偏好,关闭后不影响通知记录存储

+
+
+
加载中...
+
+
+{{template "settings/_footer.html" .}} + diff --git a/templates/MetaLab-2026/html/settings/profile.html b/templates/MetaLab-2026/html/settings/profile.html new file mode 100644 index 0000000..47528ba --- /dev/null +++ b/templates/MetaLab-2026/html/settings/profile.html @@ -0,0 +1,61 @@ +{{template "settings/_header.html" .}} + +
+
+

个人资料

+

你的公开信息和基本资料

+
+ {{if .PendingTypes}} +
+ + + + + {{range $i, $t := .PendingTypes}}{{if $i}}、{{end}}{{index $.AuditTypes $t}}{{end}}审核中 + +
+ {{end}} +
+
+ 用户名 + + + + +
+
+ 头像 + +
+ {{if .User.Avatar}} + avatar + {{else}} +
+ +
+ {{end}} + 点击更换 +
+ +
+
+
+ 个性签名 + + +
+
+
+
+ 角色 + + {{.RoleName}} + +
+ +
+
+{{template "settings/_footer.html" .}} + diff --git a/templates/MetaLab-2026/html/settings/sessions.html b/templates/MetaLab-2026/html/settings/sessions.html new file mode 100644 index 0000000..6689c42 --- /dev/null +++ b/templates/MetaLab-2026/html/settings/sessions.html @@ -0,0 +1,53 @@ +{{template "settings/_header.html" .}} + +
+
+

登录管理

+

查看和管理你的登录设备,保护账号安全

+
+
+
+ {{range .Sessions}} +
+
+ {{if eq .DeviceInfo.Device "Mobile"}} + {{else if eq .DeviceInfo.Device "Tablet"}} + {{else}} + {{end}} +
+
+
+ {{.DeviceInfo.Browser}} + / {{.DeviceInfo.OS}} + {{if .IsCurrent}}当前设备{{end}} + {{if not .RememberMe}}临时会话{{end}} +
+
+ IP: {{.IP}} + 登录于 {{.CreatedAt.Format "2006-01-02 15:04"}} + 最后活动 {{.LastAccess.Format "2006-01-02 15:04"}} + {{if not .IsCurrent}}剩余约 {{formatTTL .TTLMinutes}}{{end}} +
+
+ +
+
+
+ {{if not .IsCurrent}} + + {{end}} +
+
+ {{end}} + {{if not .Sessions}} +
暂无活跃的登录会话
+ {{end}} +
+
+ +
+{{template "settings/_footer.html" .}} + diff --git a/templates/MetaLab-2026/static/js/settings-account.js b/templates/MetaLab-2026/static/js/settings-account.js new file mode 100644 index 0000000..1b949c6 --- /dev/null +++ b/templates/MetaLab-2026/static/js/settings-account.js @@ -0,0 +1,111 @@ +(function(){'use strict'; +