package web import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net" "net/http" "path/filepath" "regexp" "strconv" "strings" "time" "ops-assistant/internal/channel" "ops-assistant/internal/core/ecode" "ops-assistant/internal/core/ops" "ops-assistant/internal/core/runbook" "ops-assistant/internal/service" "ops-assistant/models" "ops-assistant/version" "github.com/gin-gonic/gin" "gorm.io/gorm" ) const ( cookieUserNew = "ops_user" cookieTokenNew = "ops_token" cookieUserOld = "xiaji_user" cookieTokenOld = "xiaji_token" ) type WebServer struct { db *gorm.DB dbPath string baseDir string finance *service.FinanceService port int username string password string secretKey string reloadFn func() (string, error) } type CurrentUser struct { Username string `json:"username"` Role string `json:"role"` UserID int64 `json:"user_id"` Permissions map[string]bool `json:"-"` PermList []string `json:"permissions"` Flags map[string]bool `json:"flags"` Caps map[string]bool `json:"effective_capabilities"` } type flagPatchReq struct { Enabled bool `json:"enabled"` Reason string `json:"reason"` } type moduleToggleReq struct { Enabled bool `json:"enabled"` Reason string `json:"reason"` } type opsJobActionReq struct { Reason string `json:"reason"` } type channelConfigPatchReq struct { Name *string `json:"name"` Enabled *bool `json:"enabled"` Config json.RawMessage `json:"config"` Secrets json.RawMessage `json:"secrets"` } type cpaSettingsReq struct { ManagementToken string `json:"management_token"` ManagementBase string `json:"management_base"` } type cfSettingsReq struct { AccountID string `json:"account_id"` APIEmail string `json:"api_email"` APIToken string `json:"api_token"` } type aiSettingsReq struct { Enabled *bool `json:"enabled"` BaseURL string `json:"base_url"` APIKey string `json:"api_key"` Model string `json:"model"` TimeoutSeconds int `json:"timeout_seconds"` } type opsTargetReq struct { Name string `json:"name"` Host string `json:"host"` Port int `json:"port"` User string `json:"user"` Enabled bool `json:"enabled"` } var ( validHostRe = regexp.MustCompile(`^(?:[a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+$`) validUserRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) ) func validateTargetFields(host, user string, port int) error { host = strings.TrimSpace(host) user = strings.TrimSpace(user) if host == "" || user == "" { return fmt.Errorf("host/user 不能为空") } if ip := net.ParseIP(host); ip == nil { if !validHostRe.MatchString(host) { return fmt.Errorf("host 非法") } } if !validUserRe.MatchString(user) { return fmt.Errorf("user 非法") } if port <= 0 || port > 65535 { return fmt.Errorf("port 无效") } return nil } var rolePermissions = map[string][]string{ "owner": { "records.read.self", "records.read.all", "records.delete.self", "records.delete.all", "records.export.self", "records.export.all", "settings.flags.read", "settings.flags.write", "channels.read", "channels.write", "channels.test", "audit.read", "ops.read", "ops.cancel", "ops.retry", }, "admin": { "records.read.self", "records.delete.self", "records.export.self", "settings.flags.read", "channels.read", "audit.read", "ops.read", "ops.cancel", "ops.retry", }, "viewer": { "records.read.self", }, } func NewWebServer(db *gorm.DB, dbPath, baseDir string, finance *service.FinanceService, port int, username, password, sessionKey string, reloadFn func() (string, error)) *WebServer { return &WebServer{ db: db, dbPath: dbPath, baseDir: baseDir, finance: finance, port: port, username: username, password: password, secretKey: "ops-assistant-session-" + sessionKey, reloadFn: reloadFn, } } func (s *WebServer) generateToken(username string) string { exp := time.Now().Add(7 * 24 * time.Hour).Unix() payload := fmt.Sprintf("%s|%d", username, exp) mac := hmac.New(sha256.New, []byte(s.secretKey)) mac.Write([]byte(payload)) sig := hex.EncodeToString(mac.Sum(nil)) return fmt.Sprintf("%s|%s", payload, sig) } func (s *WebServer) validateToken(username, token string) bool { parts := strings.Split(token, "|") if len(parts) == 1 { // legacy token: HMAC(username) expected := s.generateLegacyToken(username) return hmac.Equal([]byte(expected), []byte(token)) } if len(parts) != 3 { return false } if parts[0] != username { return false } exp, err := strconv.ParseInt(parts[1], 10, 64) if err != nil { return false } if time.Now().Unix() > exp { return false } payload := fmt.Sprintf("%s|%s", parts[0], parts[1]) mac := hmac.New(sha256.New, []byte(s.secretKey)) mac.Write([]byte(payload)) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(parts[2])) } func (s *WebServer) generateLegacyToken(username string) string { mac := hmac.New(sha256.New, []byte(s.secretKey)) mac.Write([]byte(username)) return hex.EncodeToString(mac.Sum(nil)) } func (s *WebServer) buildCurrentUser(username string) *CurrentUser { role := "viewer" userID := int64(1) if username == s.username { role = "owner" } perms := map[string]bool{} permList := make([]string, 0) for _, p := range rolePermissions[role] { perms[p] = true permList = append(permList, p) } return &CurrentUser{Username: username, Role: role, UserID: userID, Permissions: perms, PermList: permList} } func (s *WebServer) getFlagMap() map[string]bool { res := map[string]bool{} var flags []models.FeatureFlag s.db.Find(&flags) for _, f := range flags { res[f.Key] = f.Enabled } return res } func (s *WebServer) flagEnabled(key string) bool { var ff models.FeatureFlag if err := s.db.Where("key = ?", key).First(&ff).Error; err != nil { return false } return ff.Enabled } func (s *WebServer) hasPermission(u *CurrentUser, perm string) bool { if u == nil { return false } return u.Permissions[perm] } func (s *WebServer) requirePerm(c *gin.Context, u *CurrentUser, perm, msg string) bool { if s.hasPermission(u, perm) { return true } deny(c, msg) return false } func (s *WebServer) renderPage(c *gin.Context, tpl string, u *CurrentUser, extra gin.H) { data := gin.H{"version": "v" + version.Version} if u != nil { data["username"] = u.Username } for k, v := range extra { data[k] = v } c.HTML(http.StatusOK, tpl, data) } func deny(c *gin.Context, msg string) { respondErr(c, http.StatusForbidden, ecode.ErrPermissionDenied, msg) } func currentUser(c *gin.Context) *CurrentUser { if v, ok := c.Get("currentUser"); ok { if u, ok2 := v.(*CurrentUser); ok2 { return u } } return nil } func (s *WebServer) authRequired() gin.HandlerFunc { return func(c *gin.Context) { username, _ := c.Cookie(cookieUserNew) token, _ := c.Cookie(cookieTokenNew) legacy := false if username == "" || token == "" { username, _ = c.Cookie(cookieUserOld) token, _ = c.Cookie(cookieTokenOld) legacy = username != "" && token != "" } if username == "" || token == "" || !s.validateToken(username, token) { path := c.Request.URL.Path if strings.HasPrefix(path, "/api") || c.Request.Method == "POST" || c.Request.Method == "PATCH" { respondErr(c, http.StatusUnauthorized, ecode.ErrPermissionDenied, "未登录") } else { c.Redirect(http.StatusFound, "/login") } c.Abort() return } if legacy { maxAge := 7 * 24 * 3600 c.SetCookie(cookieUserNew, username, maxAge, "/", "", false, true) c.SetCookie(cookieTokenNew, token, maxAge, "/", "", false, true) } else if strings.Contains(token, "|") { // refresh exp for new token format maxAge := 7 * 24 * 3600 fresh := s.generateToken(username) c.SetCookie(cookieUserNew, username, maxAge, "/", "", false, true) c.SetCookie(cookieTokenNew, fresh, maxAge, "/", "", false, true) } c.Set("currentUser", s.buildCurrentUser(username)) c.Next() } } func (s *WebServer) writeAudit(actor int64, action, targetType, targetID, before, after, note string) { _ = s.db.Create(&models.AuditLog{ ActorID: actor, Action: action, TargetType: targetType, TargetID: targetID, BeforeJSON: before, AfterJSON: after, Note: note, }).Error } func (s *WebServer) writeAuditResult(actor int64, action, targetType, targetID, before, after, note, result string) { finalNote := strings.TrimSpace(note) if strings.TrimSpace(result) != "" { if finalNote == "" { finalNote = "result=" + result } else { finalNote = finalNote + " | result=" + result } } s.writeAudit(actor, action, targetType, targetID, before, after, finalNote) } func (s *WebServer) registerAPIV1Routes(auth *gin.RouterGroup) { auth.GET("/api/v1/me", s.handleMe) auth.GET("/api/v1/records", s.handleRecordsV1) auth.POST("/api/v1/records/:id/delete", s.handleDeleteV1) auth.GET("/api/v1/export", s.handleExportV1) auth.GET("/api/v1/admin/settings/flags", s.handleFlagsList) auth.PATCH("/api/v1/admin/settings/flags/:key", s.handleFlagPatch) // CPA settings auth.GET("/api/v1/admin/cpa/settings", s.handleCPASettingsGet) auth.PATCH("/api/v1/admin/cpa/settings", s.handleCPASettingsPatch) // Cloudflare settings auth.GET("/api/v1/admin/cf/settings", s.handleCFSettingsGet) auth.PATCH("/api/v1/admin/cf/settings", s.handleCFSettingsPatch) // AI settings auth.GET("/api/v1/admin/ai/settings", s.handleAISettingsGet) auth.PATCH("/api/v1/admin/ai/settings", s.handleAISettingsPatch) // Ops targets auth.GET("/api/v1/admin/ops/targets", s.handleOpsTargetsList) auth.POST("/api/v1/admin/ops/targets", s.handleOpsTargetsCreate) auth.PATCH("/api/v1/admin/ops/targets/:id", s.handleOpsTargetsPatch) auth.GET("/api/v1/admin/channels", s.handleChannelsList) auth.PATCH("/api/v1/admin/channels/:platform", s.handleChannelPatch) auth.POST("/api/v1/admin/channels/:platform/publish", s.handleChannelPublish) auth.POST("/api/v1/admin/channels/reload", s.handleChannelReload) auth.POST("/api/v1/admin/channels/disable-all", s.handleChannelDisableAll) auth.POST("/api/v1/admin/channels/:platform/enable", s.handleChannelEnable) auth.POST("/api/v1/admin/channels/:platform/disable", s.handleChannelDisable) auth.POST("/api/v1/admin/channels/:platform/test", s.handleChannelTest) auth.POST("/api/v1/admin/channels/:platform/apply", s.handleChannelApply) auth.GET("/api/v1/admin/audit", s.handleAuditList) auth.GET("/api/v1/admin/legacy/usage", s.handleLegacyUsage) auth.GET("/api/v1/admin/legacy/trend", s.handleLegacyTrend) auth.GET("/api/v1/admin/legacy/readiness", s.handleLegacyReadiness) auth.GET("/api/v1/modules", s.handleModulesList) auth.POST("/api/v1/modules/:module/toggle", s.handleModuleToggle) auth.GET("/api/v1/dashboard/overview", s.handleDashboardOverview) auth.GET("/api/v1/dashboard/summary", s.handleDashboardSummary) auth.GET("/api/v1/ops/jobs", s.handleOpsJobs) auth.GET("/api/v1/ops/jobs/request/:requestID", s.handleOpsJobsByRequestID) auth.GET("/api/v1/ops/jobs/:id", s.handleOpsJobDetail) auth.POST("/api/v1/ops/jobs/:id/cancel", s.handleOpsJobCancel) auth.POST("/api/v1/ops/jobs/:id/retry", s.handleOpsJobRetry) } func (s *WebServer) registerLegacyCompatRoutes(auth *gin.RouterGroup) { // 兼容老前端调用,统一复用 v1 handler(兼容层) // // 废弃计划(仅文档约束,当前不删): // 1) 新功能与新页面只允许使用 /api/v1/* // 2) 当确认无旧调用后,再移除以下旧路由映射 // 3) 每次版本发布前,优先检查是否仍存在对旧路由的引用 auth.GET("/api/records", s.handleLegacyRecords) auth.POST("/delete/:id", s.handleLegacyDelete) auth.GET("/export", s.handleLegacyExport) } func (s *WebServer) writeLegacyAccess(c *gin.Context, route string) { u := currentUser(c) uid := int64(0) if u != nil { uid = u.UserID } note := fmt.Sprintf("legacy route=%s method=%s path=%s ua=%s", route, c.Request.Method, c.Request.URL.Path, c.Request.UserAgent()) s.writeAuditResult(uid, "legacy.route.access", "route", route, "", "", note, "success") } func (s *WebServer) markLegacyDeprecated(c *gin.Context, replacement string) { c.Header("X-API-Deprecated", "true") c.Header("X-API-Replacement", replacement) c.Header("Warning", fmt.Sprintf(`299 - "legacy API deprecated, use %s"`, replacement)) } func (s *WebServer) handleLegacyRecords(c *gin.Context) { s.writeLegacyAccess(c, "/api/records") s.markLegacyDeprecated(c, "/api/v1/records") s.handleRecordsV1(c) } func (s *WebServer) handleLegacyDelete(c *gin.Context) { s.writeLegacyAccess(c, "/delete/:id") s.markLegacyDeprecated(c, "/api/v1/records/:id/delete") s.handleDeleteV1(c) } func (s *WebServer) handleLegacyExport(c *gin.Context) { s.writeLegacyAccess(c, "/export") s.markLegacyDeprecated(c, "/api/v1/export") s.handleExportV1(c) } func (s *WebServer) RegisterRoutes(r *gin.Engine) { r.LoadHTMLGlob("templates/*") r.GET("/login", s.handleLoginPage) r.POST("/login", s.handleLogin) r.GET("/logout", s.handleLogout) r.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) auth := r.Group("/") auth.Use(s.authRequired()) { auth.GET("/", s.handleIndex) auth.GET("/channels", s.handleChannelsPage) auth.GET("/audit", s.handleAuditPage) auth.GET("/ops", s.handleOpsPage) auth.GET("/cpa", s.handleCPASettingsPage) auth.GET("/cf", s.handleCFSettingsPage) // AI 配置页临时隐藏(保留后端接口) s.registerAPIV1Routes(auth) s.registerLegacyCompatRoutes(auth) } } func (s *WebServer) Start() { gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) r.Use(gin.Logger()) s.RegisterRoutes(r) logAddr := fmt.Sprintf(":%d", s.port) fmt.Printf("🌐 Web后台运行在 http://127.0.0.1%s\n", logAddr) if err := r.Run(logAddr); err != nil { fmt.Printf("❌ Web服务启动失败: %v\n", err) } } func (s *WebServer) handleLoginPage(c *gin.Context) { username, _ := c.Cookie(cookieUserNew) token, _ := c.Cookie(cookieTokenNew) if username == "" || token == "" { username, _ = c.Cookie(cookieUserOld) token, _ = c.Cookie(cookieTokenOld) } if username != "" && token != "" && s.validateToken(username, token) { c.Redirect(http.StatusFound, "/") return } s.renderPage(c, "login.html", nil, gin.H{"error": ""}) } func (s *WebServer) handleLogin(c *gin.Context) { username := c.PostForm("username") password := c.PostForm("password") if username == s.username && password == s.password { token := s.generateToken(username) maxAge := 7 * 24 * 3600 c.SetCookie(cookieUserNew, username, maxAge, "/", "", false, true) c.SetCookie(cookieTokenNew, token, maxAge, "/", "", false, true) c.SetCookie(cookieUserOld, "", -1, "/", "", false, true) c.SetCookie(cookieTokenOld, "", -1, "/", "", false, true) u := s.buildCurrentUser(username) s.writeAuditResult(u.UserID, "auth.login.success", "user", username, "", "", "", "success") c.Redirect(http.StatusFound, "/") return } s.writeAuditResult(0, "auth.login.failed", "user", username, "", "", "用户名或密码错误", "failed") s.renderPage(c, "login.html", nil, gin.H{"error": "用户名或密码错误"}) } func (s *WebServer) handleLogout(c *gin.Context) { u := currentUser(c) if u != nil { s.writeAuditResult(u.UserID, "auth.logout", "user", u.Username, "", "", "", "success") } c.SetCookie(cookieUserNew, "", -1, "/", "", false, true) c.SetCookie(cookieTokenNew, "", -1, "/", "", false, true) c.SetCookie(cookieUserOld, "", -1, "/", "", false, true) c.SetCookie(cookieTokenOld, "", -1, "/", "", false, true) c.Redirect(http.StatusFound, "/login") } func (s *WebServer) handleIndex(c *gin.Context) { u := currentUser(c) s.renderPage(c, "index.html", u, nil) } func (s *WebServer) handleChannelsPage(c *gin.Context) { u := currentUser(c) if u == nil || !s.hasPermission(u, "channels.read") { c.Redirect(http.StatusFound, "/") return } s.renderPage(c, "channels.html", u, nil) } func (s *WebServer) handleAuditPage(c *gin.Context) { u := currentUser(c) if u == nil || !s.hasPermission(u, "audit.read") { c.Redirect(http.StatusFound, "/") return } s.renderPage(c, "audit.html", u, nil) } func (s *WebServer) handleOpsPage(c *gin.Context) { u := currentUser(c) if u == nil || !s.hasPermission(u, "ops.read") { c.Redirect(http.StatusFound, "/") return } s.renderPage(c, "ops.html", u, nil) } func (s *WebServer) handleCPASettingsPage(c *gin.Context) { u := currentUser(c) if u == nil || !s.hasPermission(u, "settings.flags.read") { c.Redirect(http.StatusFound, "/") return } s.renderPage(c, "cpa_settings.html", u, nil) } func (s *WebServer) handleCFSettingsPage(c *gin.Context) { u := currentUser(c) if u == nil || !s.hasPermission(u, "settings.flags.read") { c.Redirect(http.StatusFound, "/") return } s.renderPage(c, "cf_settings.html", u, nil) } func (s *WebServer) handleAISettingsPage(c *gin.Context) { u := currentUser(c) if u == nil || !s.hasPermission(u, "settings.flags.read") { c.Redirect(http.StatusFound, "/") return } s.renderPage(c, "ai_settings.html", u, nil) } func (s *WebServer) handleCPASettingsGet(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { return } keys := []string{"cpa_management_token", "cpa_management_base"} out := map[string]string{} for _, k := range keys { var sset models.AppSetting if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil { out[k] = sset.Value } else { out[k] = "" } } // do not expose secret token if v := strings.TrimSpace(out["cpa_management_token"]); v != "" { out["cpa_management_token"] = "***" } respondOK(c, "ok", gin.H{"settings": out}) } func (s *WebServer) handleCPASettingsPatch(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { return } var req cpaSettingsReq if err := c.ShouldBindJSON(&req); err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") return } // management_token key := "cpa_management_token" var sset models.AppSetting if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { sset = models.AppSetting{Key: key, Value: req.ManagementToken, UpdatedBy: u.UserID} if err := s.db.Create(&sset).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") return } } else { old := sset.Value sset.Value = req.ManagementToken sset.UpdatedBy = u.UserID if err := s.db.Save(&sset).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") return } _ = s.db.Create(&models.FeatureFlagHistory{FlagKey: key, OldValue: old != "", NewValue: req.ManagementToken != "", ChangedBy: u.UserID, Reason: "cpa_settings_update", RequestID: c.GetHeader("X-Request-ID")}).Error } // management_base key = "cpa_management_base" var ssetBase models.AppSetting if err := s.db.Where("key = ?", key).First(&ssetBase).Error; err != nil { ssetBase = models.AppSetting{Key: key, Value: req.ManagementBase, UpdatedBy: u.UserID} if err := s.db.Create(&ssetBase).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") return } } else { old := ssetBase.Value ssetBase.Value = req.ManagementBase ssetBase.UpdatedBy = u.UserID if err := s.db.Save(&ssetBase).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") return } _ = s.db.Create(&models.FeatureFlagHistory{FlagKey: key, OldValue: old != "", NewValue: req.ManagementBase != "", ChangedBy: u.UserID, Reason: "cpa_settings_update", RequestID: c.GetHeader("X-Request-ID")}).Error } s.writeAuditResult(u.UserID, "cpa.settings.update", "settings", "cpa_management", "", "", "", "success") respondOK(c, "success", gin.H{"keys": []string{"cpa_management_token", "cpa_management_base"}}) } func (s *WebServer) handleCFSettingsGet(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { return } keys := []string{"cf_account_id", "cf_api_email", "cf_api_token"} out := map[string]string{} for _, k := range keys { var sset models.AppSetting if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil { out[k] = sset.Value } else { out[k] = "" } } respondOK(c, "ok", gin.H{"settings": out}) } func (s *WebServer) handleCFSettingsPatch(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { return } var req cfSettingsReq if err := c.ShouldBindJSON(&req); err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") return } // account id if req.AccountID != "" { key := "cf_account_id" var sset models.AppSetting if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { sset = models.AppSetting{Key: key, Value: req.AccountID, UpdatedBy: u.UserID} _ = s.db.Create(&sset).Error } else { sset.Value = req.AccountID sset.UpdatedBy = u.UserID _ = s.db.Save(&sset).Error } } // api email if req.APIEmail != "" { key := "cf_api_email" var sset models.AppSetting if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { sset = models.AppSetting{Key: key, Value: req.APIEmail, UpdatedBy: u.UserID} _ = s.db.Create(&sset).Error } else { sset.Value = req.APIEmail sset.UpdatedBy = u.UserID _ = s.db.Save(&sset).Error } } // api token if req.APIToken != "" { key := "cf_api_token" var sset models.AppSetting if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { sset = models.AppSetting{Key: key, Value: req.APIToken, UpdatedBy: u.UserID} _ = s.db.Create(&sset).Error } else { sset.Value = req.APIToken sset.UpdatedBy = u.UserID _ = s.db.Save(&sset).Error } } respondOK(c, "success", gin.H{"ok": true}) } func (s *WebServer) handleAISettingsGet(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { return } keys := []string{"ai_enabled", "ai_base_url", "ai_api_key", "ai_model", "ai_timeout_seconds"} out := map[string]string{} for _, k := range keys { var sset models.AppSetting if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil { out[k] = sset.Value } else { out[k] = "" } } respondOK(c, "ok", gin.H{"settings": out}) } func (s *WebServer) handleAISettingsPatch(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { return } var req aiSettingsReq if err := c.ShouldBindJSON(&req); err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") return } setKV := func(key, val string) { var sset models.AppSetting if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { sset = models.AppSetting{Key: key, Value: val, UpdatedBy: u.UserID} _ = s.db.Create(&sset).Error return } sset.Value = val sset.UpdatedBy = u.UserID _ = s.db.Save(&sset).Error } if req.Enabled != nil { if *req.Enabled { setKV("ai_enabled", "true") } else { setKV("ai_enabled", "false") } } if strings.TrimSpace(req.BaseURL) != "" { setKV("ai_base_url", strings.TrimSpace(req.BaseURL)) } if strings.TrimSpace(req.APIKey) != "" { setKV("ai_api_key", strings.TrimSpace(req.APIKey)) } if strings.TrimSpace(req.Model) != "" { setKV("ai_model", strings.TrimSpace(req.Model)) } if req.TimeoutSeconds > 0 { setKV("ai_timeout_seconds", strconv.Itoa(req.TimeoutSeconds)) } s.writeAuditResult(u.UserID, "ai.settings.update", "settings", "ai", "", "", "", "success") respondOK(c, "success", gin.H{"ok": true}) } func (s *WebServer) handleOpsTargetsList(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { return } var items []models.OpsTarget s.db.Order("name asc").Find(&items) respondOK(c, "ok", gin.H{"targets": items}) } func (s *WebServer) handleOpsTargetsCreate(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { return } var req opsTargetReq if err := c.ShouldBindJSON(&req); err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") return } if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Host) == "" || strings.TrimSpace(req.User) == "" { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "name/host/user 不能为空") return } if req.Port == 0 { req.Port = 22 } if err := validateTargetFields(req.Host, req.User, req.Port); err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error()) return } item := models.OpsTarget{Name: strings.TrimSpace(req.Name), Host: strings.TrimSpace(req.Host), Port: req.Port, User: strings.TrimSpace(req.User), Enabled: req.Enabled} if err := s.db.Create(&item).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "创建失败") return } s.writeAuditResult(u.UserID, "ops_target.create", "ops_target", item.Name, "", "", "", "success") respondOK(c, "success", gin.H{"target": item}) } func (s *WebServer) handleOpsTargetsPatch(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { return } id, _ := strconv.Atoi(strings.TrimSpace(c.Param("id"))) if id <= 0 { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "id 无效") return } var req opsTargetReq if err := c.ShouldBindJSON(&req); err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") return } var item models.OpsTarget if err := s.db.First(&item, id).Error; err != nil { respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "目标不存在") return } if strings.TrimSpace(req.Name) != "" { item.Name = strings.TrimSpace(req.Name) } if strings.TrimSpace(req.Host) != "" { item.Host = strings.TrimSpace(req.Host) } if strings.TrimSpace(req.User) != "" { item.User = strings.TrimSpace(req.User) } if req.Port != 0 { item.Port = req.Port } item.Enabled = req.Enabled if err := validateTargetFields(item.Host, item.User, item.Port); err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error()) return } if err := s.db.Save(&item).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "更新失败") return } s.writeAuditResult(u.UserID, "ops_target.update", "ops_target", item.Name, "", "", "", "success") respondOK(c, "success", gin.H{"target": item}) } func (s *WebServer) handleMe(c *gin.Context) { u := currentUser(c) if u == nil { respondErr(c, http.StatusUnauthorized, ecode.ErrPermissionDenied, "未登录") return } flags := s.getFlagMap() caps := map[string]bool{ "can_read_self": s.hasPermission(u, "records.read.self"), "can_read_all": s.hasPermission(u, "records.read.all") && flags["allow_cross_user_read"], "can_delete_self": s.hasPermission(u, "records.delete.self"), "can_delete_all": s.hasPermission(u, "records.delete.all") && flags["allow_cross_user_delete"], "can_export_self": s.hasPermission(u, "records.export.self"), "can_export_all": s.hasPermission(u, "records.export.all") && flags["allow_export_all_users"], "can_view_flags": s.hasPermission(u, "settings.flags.read"), "can_edit_flags": s.hasPermission(u, "settings.flags.write"), "can_view_channels": s.hasPermission(u, "channels.read"), "can_edit_channels": s.hasPermission(u, "channels.write"), "can_test_channels": s.hasPermission(u, "channels.test"), "can_view_audit": s.hasPermission(u, "audit.read"), "can_view_ops": s.hasPermission(u, "ops.read"), "can_cancel_ops": s.hasPermission(u, "ops.cancel"), "can_retry_ops": s.hasPermission(u, "ops.retry"), } u.Flags = flags u.Caps = caps respondOK(c, "ok", u) } func (s *WebServer) handleRecordsV1(c *gin.Context) { u := currentUser(c) if !s.hasPermission(u, "records.read.self") { s.writeAuditResult(u.UserID, "record.list.self", "transaction", "*", "", "", "无 records.read.self 权限", "denied") deny(c, "无 records.read.self 权限") return } scope := c.DefaultQuery("scope", "self") q := s.db.Model(&models.Transaction{}).Where("is_deleted = ?", false) action := "record.list.self" note := "" if scope == "all" { action = "record.list.all" if !s.hasPermission(u, "records.read.all") { s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "无 records.read.all 权限", "denied") deny(c, "无 records.read.all 权限") return } if !s.flagEnabled("allow_cross_user_read") { s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "策略开关 allow_cross_user_read 未开启", "denied") deny(c, "策略开关 allow_cross_user_read 未开启") return } } else { q = q.Where("user_id = ?", u.UserID) } var items []models.Transaction q.Order("id desc").Limit(100).Find(&items) type txResponse struct { ID uint `json:"id"` UserID int64 `json:"user_id"` Amount float64 `json:"amount"` Category string `json:"category"` Note string `json:"note"` Date string `json:"date"` } resp := make([]txResponse, len(items)) for i, item := range items { resp[i] = txResponse{ID: item.ID, UserID: item.UserID, Amount: item.AmountYuan(), Category: item.Category, Note: item.Note, Date: item.Date} } note = fmt.Sprintf("scope=%s,count=%d", scope, len(resp)) s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", note, "success") respondOK(c, "ok", gin.H{"records": resp}) } func (s *WebServer) handleDeleteV1(c *gin.Context) { u := currentUser(c) idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "无效的ID") return } var tx models.Transaction if err := s.db.Where("id = ? AND is_deleted = ?", id, false).First(&tx).Error; err != nil { respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "记录不存在或已删除") return } action := "record.delete.self" if tx.UserID == u.UserID { if !s.hasPermission(u, "records.delete.self") { s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", "无 records.delete.self 权限", "denied") deny(c, "无 records.delete.self 权限") return } } else { action = "record.delete.all" if !s.hasPermission(u, "records.delete.all") { s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", "无 records.delete.all 权限", "denied") deny(c, "无 records.delete.all 权限") return } if !s.flagEnabled("allow_cross_user_delete") { s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", "策略开关 allow_cross_user_delete 未开启", "denied") deny(c, "策略开关 allow_cross_user_delete 未开启") return } } result := s.db.Model(&models.Transaction{}).Where("id = ? AND is_deleted = ?", id, false).Update("is_deleted", true) if result.Error != nil { s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", result.Error.Error(), "failed") respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "删除失败") return } s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", `{"is_deleted":true}`, "", "success") respondOK(c, "success", gin.H{"id": id}) } func (s *WebServer) handleExportV1(c *gin.Context) { u := currentUser(c) scope := c.DefaultQuery("scope", "self") action := "record.export.self" q := s.db.Model(&models.Transaction{}).Where("is_deleted = ?", false) if scope == "all" { action = "record.export.all" if !s.hasPermission(u, "records.export.all") { s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "无 records.export.all 权限", "denied") deny(c, "无 records.export.all 权限") return } if !s.flagEnabled("allow_export_all_users") { s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "策略开关 allow_export_all_users 未开启", "denied") deny(c, "策略开关 allow_export_all_users 未开启") return } } else { if !s.hasPermission(u, "records.export.self") { s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "无 records.export.self 权限", "denied") deny(c, "无 records.export.self 权限") return } q = q.Where("user_id = ?", u.UserID) } var items []models.Transaction q.Order("date asc, id asc").Find(&items) now := time.Now().Format("20060102") filename := fmt.Sprintf("ops_assistant_%s.csv", now) c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) c.Writer.WriteString("ID,用户ID,日期,分类,金额(元),备注\n") for _, item := range items { line := fmt.Sprintf("%d,%d,%s,%s,%.2f,\"%s\"\n", item.ID, item.UserID, item.Date, item.Category, item.AmountYuan(), item.Note) c.Writer.WriteString(line) } s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", fmt.Sprintf("scope=%s,count=%d", scope, len(items)), "success") } func (s *WebServer) handleFlagsList(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { return } var flags []models.FeatureFlag s.db.Order("key asc").Find(&flags) respondOK(c, "ok", gin.H{"flags": flags}) } func (s *WebServer) handleFlagPatch(c *gin.Context) { u := currentUser(c) if !s.hasPermission(u, "settings.flags.write") { s.writeAuditResult(u.UserID, "settings.flag.update", "feature_flag", c.Param("key"), "", "", "无 settings.flags.write 权限", "denied") deny(c, "无 settings.flags.write 权限") return } key := c.Param("key") var req flagPatchReq if err := c.ShouldBindJSON(&req); err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") return } var ff models.FeatureFlag if err := s.db.Where("key = ?", key).First(&ff).Error; err != nil { respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "开关不存在") return } if ff.RequireReason && strings.TrimSpace(req.Reason) == "" { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "该开关修改必须提供 reason") return } before := fmt.Sprintf(`{"enabled":%v}`, ff.Enabled) old := ff.Enabled ff.Enabled = req.Enabled ff.UpdatedBy = u.UserID if err := s.db.Save(&ff).Error; err != nil { s.writeAuditResult(u.UserID, "settings.flag.update", "feature_flag", key, before, "", err.Error(), "failed") respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "更新失败") return } after := fmt.Sprintf(`{"enabled":%v}`, ff.Enabled) h := models.FeatureFlagHistory{FlagKey: key, OldValue: old, NewValue: req.Enabled, ChangedBy: u.UserID, Reason: req.Reason, RequestID: c.GetHeader("X-Request-ID")} _ = s.db.Create(&h).Error s.writeAuditResult(u.UserID, "settings.flag.update", "feature_flag", key, before, after, req.Reason, "success") respondOK(c, "success", gin.H{"key": key, "old": old, "new": req.Enabled}) } func sanitizeJSON(raw string) string { if strings.TrimSpace(raw) == "" { return "{}" } var m map[string]any if err := json.Unmarshal([]byte(raw), &m); err != nil { return "{}" } for k := range m { lk := strings.ToLower(k) if strings.Contains(lk, "token") || strings.Contains(lk, "secret") || strings.Contains(lk, "key") || strings.Contains(lk, "password") { m[k] = "***" } } b, _ := json.Marshal(m) return string(b) } func isMaskedSecretsPayload(raw json.RawMessage) bool { if len(raw) == 0 { return false } var v any if err := json.Unmarshal(raw, &v); err != nil { return false } var walk func(any) bool walk = func(x any) bool { switch t := x.(type) { case map[string]any: if len(t) == 0 { return false } allMasked := true for _, vv := range t { if !walk(vv) { allMasked = false break } } return allMasked case []any: if len(t) == 0 { return false } for _, vv := range t { if !walk(vv) { return false } } return true case string: return strings.TrimSpace(t) == "***" default: return false } } return walk(v) } func (s *WebServer) handleChannelsList(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "channels.read", "无 channels.read 权限") { return } var items []models.ChannelConfig s.db.Order("platform asc").Find(&items) type out struct { ID uint `json:"id"` Platform string `json:"platform"` Name string `json:"name"` Enabled bool `json:"enabled"` Status string `json:"status"` ConfigJSON string `json:"config_json"` DraftConfigJSON string `json:"draft_config_json"` Secrets string `json:"secrets"` DraftSecrets string `json:"draft_secrets"` HasDraft bool `json:"has_draft"` PublishedAt *time.Time `json:"published_at"` LastCheck *time.Time `json:"last_check_at"` UpdatedAt time.Time `json:"updated_at"` } resp := make([]out, 0, len(items)) for _, it := range items { sec := channel.MaybeDecryptPublic(it.SecretJSON) draftSec := channel.MaybeDecryptPublic(it.DraftSecretJSON) resp = append(resp, out{ ID: it.ID, Platform: it.Platform, Name: it.Name, Enabled: it.Enabled, Status: it.Status, ConfigJSON: it.ConfigJSON, DraftConfigJSON: it.DraftConfigJSON, Secrets: sanitizeJSON(sec), DraftSecrets: sanitizeJSON(draftSec), HasDraft: strings.TrimSpace(it.DraftConfigJSON) != "" || strings.TrimSpace(it.DraftSecretJSON) != "", PublishedAt: it.PublishedAt, LastCheck: it.LastCheck, UpdatedAt: it.UpdatedAt, }) } respondOK(c, "ok", gin.H{"channels": resp}) } func (s *WebServer) handleChannelPatch(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { return } platform := c.Param("platform") var req channelConfigPatchReq if err := c.ShouldBindJSON(&req); err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") return } var row models.ChannelConfig if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在") return } before := fmt.Sprintf(`{"draft_config":%s,"draft_secrets":%s}`, sanitizeJSON(row.DraftConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.DraftSecretJSON))) if req.Name != nil { row.Name = strings.TrimSpace(*req.Name) } if req.Enabled != nil { row.Enabled = *req.Enabled } if len(req.Config) > 0 { row.DraftConfigJSON = string(req.Config) } if len(req.Secrets) > 0 { if isMaskedSecretsPayload(req.Secrets) { // 前端脱敏占位符(***)不应覆盖真实密钥 } else { row.DraftSecretJSON = channel.EncryptSecretJSON(string(req.Secrets)) } } if err := s.db.Save(&row).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") return } after := fmt.Sprintf(`{"draft_config":%s,"draft_secrets":%s}`, sanitizeJSON(row.DraftConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.DraftSecretJSON))) s.writeAudit(u.UserID, "channel_draft_update", "channel", row.Platform, before, after, "") respondOK(c, "success", gin.H{"mode": "draft"}) } func (s *WebServer) handleChannelPublish(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { return } platform := c.Param("platform") var row models.ChannelConfig if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在") return } before := fmt.Sprintf(`{"config":%s,"secrets":%s}`, sanitizeJSON(row.ConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.SecretJSON))) if strings.TrimSpace(row.DraftConfigJSON) != "" { row.ConfigJSON = row.DraftConfigJSON } if strings.TrimSpace(row.DraftSecretJSON) != "" { row.SecretJSON = row.DraftSecretJSON } now := time.Now() row.PublishedAt = &now row.UpdatedBy = u.UserID if err := s.db.Save(&row).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "发布失败") return } after := fmt.Sprintf(`{"config":%s,"secrets":%s}`, sanitizeJSON(row.ConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.SecretJSON))) s.writeAudit(u.UserID, "channel_publish", "channel", row.Platform, before, after, "") respondOK(c, "success", gin.H{"published_at": now}) } func (s *WebServer) handleChannelReload(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { return } if s.reloadFn == nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "reload 未配置") return } detail, err := s.reloadFn() if err != nil { s.writeAudit(u.UserID, "channel_reload", "system", "runtime", "", "", "failed: "+err.Error()) respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, err.Error()) return } s.writeAudit(u.UserID, "channel_reload", "system", "runtime", "", "", detail) respondOK(c, "success", gin.H{"detail": detail}) } func (s *WebServer) handleChannelEnable(c *gin.Context) { s.handleChannelToggle(c, true) } func (s *WebServer) handleChannelDisable(c *gin.Context) { s.handleChannelToggle(c, false) } func (s *WebServer) handleChannelDisableAll(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { return } res := s.db.Model(&models.ChannelConfig{}).Where("enabled = ?", true).Updates(map[string]any{ "enabled": false, "status": "disabled", "updated_by": u.UserID, }) if res.Error != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "批量关闭失败") return } s.writeAudit(u.UserID, "channel_disable_all", "channel", "*", "", fmt.Sprintf(`{"affected":%d}`, res.RowsAffected), "") respondOK(c, "success", gin.H{"affected": res.RowsAffected}) } func (s *WebServer) handleChannelToggle(c *gin.Context, enable bool) { u := currentUser(c) if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { return } platform := c.Param("platform") var row models.ChannelConfig if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在") return } before := fmt.Sprintf(`{"enabled":%v}`, row.Enabled) row.Enabled = enable if !enable { row.Status = "disabled" } row.UpdatedBy = u.UserID if err := s.db.Save(&row).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") return } after := fmt.Sprintf(`{"enabled":%v}`, row.Enabled) action := "channel_disable" if enable { action = "channel_enable" } s.writeAudit(u.UserID, action, "channel", row.Platform, before, after, "") respondOK(c, "success", gin.H{"enabled": row.Enabled, "platform": row.Platform}) } func (s *WebServer) handleChannelTest(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "channels.test", "无 channels.test 权限") { return } platform := c.Param("platform") var row models.ChannelConfig if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在") return } if strings.TrimSpace(row.DraftConfigJSON) != "" { row.ConfigJSON = row.DraftConfigJSON } if strings.TrimSpace(row.DraftSecretJSON) != "" { row.SecretJSON = row.DraftSecretJSON } now := time.Now() status, detail := channel.TestChannelConnectivity(context.Background(), row) row.LastCheck = &now row.Status = status if err := s.db.Save(&row).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "测试写入失败") return } s.writeAudit(u.UserID, "channel_test", "channel", row.Platform, "", fmt.Sprintf(`{"status":%q,"detail":%q}`, row.Status, detail), "manual test") respondOK(c, "ok", gin.H{"status": row.Status, "detail": detail, "platform": row.Platform, "checked_at": now}) } func (s *WebServer) handleChannelApply(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { return } platform := c.Param("platform") var req channelConfigPatchReq if err := c.ShouldBindJSON(&req); err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误(stage=patch,committed=false)") return } var row models.ChannelConfig if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在(stage=patch,committed=false)") return } beforeEnabled := row.Enabled beforeConfig := row.ConfigJSON beforeDraftConfig := row.DraftConfigJSON beforeSecret := channel.MaybeDecryptPublic(row.SecretJSON) beforeDraftSecret := channel.MaybeDecryptPublic(row.DraftSecretJSON) if req.Name != nil { row.Name = strings.TrimSpace(*req.Name) } if req.Enabled != nil { row.Enabled = *req.Enabled } if len(req.Config) > 0 { row.DraftConfigJSON = string(req.Config) } if len(req.Secrets) > 0 { if isMaskedSecretsPayload(req.Secrets) { // 前端脱敏占位符(***)不应覆盖真实密钥 } else { row.DraftSecretJSON = channel.EncryptSecretJSON(string(req.Secrets)) } } if !row.Enabled { row.Status = "disabled" } if strings.TrimSpace(row.DraftConfigJSON) != "" { row.ConfigJSON = row.DraftConfigJSON } if strings.TrimSpace(row.DraftSecretJSON) != "" { row.SecretJSON = row.DraftSecretJSON } publishAt := time.Now() row.PublishedAt = &publishAt row.UpdatedBy = u.UserID if err := s.db.Save(&row).Error; err != nil { s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, "", "", "failed stage=publish: "+err.Error()) respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存并发布失败(stage=publish,committed=false)") return } before := fmt.Sprintf(`{"enabled":%v,"config":%s,"draft_config":%s,"secrets":%s,"draft_secrets":%s}`, beforeEnabled, sanitizeJSON(beforeConfig), sanitizeJSON(beforeDraftConfig), sanitizeJSON(beforeSecret), sanitizeJSON(beforeDraftSecret), ) after := fmt.Sprintf(`{"enabled":%v,"config":%s,"draft_config":%s,"secrets":%s,"draft_secrets":%s}`, row.Enabled, sanitizeJSON(row.ConfigJSON), sanitizeJSON(row.DraftConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.SecretJSON)), sanitizeJSON(channel.MaybeDecryptPublic(row.DraftSecretJSON)), ) if s.reloadFn == nil { s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, "failed stage=reload: reload 未配置") respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "reload 未配置(stage=reload,committed=true)") return } detail, err := s.reloadFn() if err != nil { s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, "failed stage=reload: "+err.Error()) respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "reload失败(stage=reload,committed=true): "+err.Error()) return } note := fmt.Sprintf("apply(patch+publish+reload) detail=%s", detail) s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, note) respondOK(c, "success", gin.H{ "platform": row.Platform, "published_at": publishAt, "detail": detail, }) } func (s *WebServer) handleAuditList(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "audit.read", "无 audit.read 权限") { return } action := strings.TrimSpace(c.Query("action")) targetType := strings.TrimSpace(c.Query("target_type")) result := strings.TrimSpace(c.Query("result")) actorID := strings.TrimSpace(c.Query("actor_id")) from := strings.TrimSpace(c.Query("from")) to := strings.TrimSpace(c.Query("to")) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100")) if limit <= 0 || limit > 500 { limit = 100 } offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) if offset < 0 { offset = 0 } q := s.db.Model(&models.AuditLog{}) if action != "" { q = q.Where("action = ?", action) } if targetType != "" { q = q.Where("target_type = ?", targetType) } if actorID != "" { if aid, err := strconv.ParseInt(actorID, 10, 64); err == nil { q = q.Where("actor_id = ?", aid) } } if from != "" { if t, err := time.Parse(time.RFC3339, from); err == nil { q = q.Where("created_at >= ?", t) } } if to != "" { if t, err := time.Parse(time.RFC3339, to); err == nil { q = q.Where("created_at <= ?", t) } } if result != "" { q = q.Where("note LIKE ?", "%result="+result+"%") } var logs []models.AuditLog if err := q.Order("id desc").Limit(limit).Offset(offset).Find(&logs).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询失败") return } respondOK(c, "ok", gin.H{"logs": logs}) } func (s *WebServer) handleLegacyUsage(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "audit.read", "无 audit.read 权限") { return } type row struct { Route string `json:"route"` Count int64 `json:"count"` } routes := []string{"/api/records", "/delete/:id", "/export"} usage := make([]row, 0, len(routes)) for _, rt := range routes { var cnt int64 err := s.db.Model(&models.AuditLog{}). Where("action = ? AND target_type = ? AND target_id = ?", "legacy.route.access", "route", rt). Count(&cnt).Error if err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy usage失败") return } usage = append(usage, row{Route: rt, Count: cnt}) } var recent []models.AuditLog if err := s.db.Where("action = ? AND target_type = ?", "legacy.route.access", "route").Order("id desc").Limit(50).Find(&recent).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy recent失败") return } respondOK(c, "ok", gin.H{"summary": usage, "recent": recent}) } func (s *WebServer) handleLegacyTrend(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "audit.read", "无 audit.read 权限") { return } days, _ := strconv.Atoi(c.DefaultQuery("days", "7")) if days <= 0 || days > 90 { days = 7 } start := time.Now().AddDate(0, 0, -days+1) type point struct { Day string `json:"day"` Count int64 `json:"count"` } type routeTrend struct { Route string `json:"route"` Points []point `json:"points"` } routes := []string{"/api/records", "/delete/:id", "/export"} trends := make([]routeTrend, 0, len(routes)) for _, rt := range routes { pts := make([]point, 0, days) for i := 0; i < days; i++ { dayStart := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location()).AddDate(0, 0, i) dayEnd := dayStart.Add(24 * time.Hour) var cnt int64 err := s.db.Model(&models.AuditLog{}). Where("action = ? AND target_type = ? AND target_id = ?", "legacy.route.access", "route", rt). Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). Count(&cnt).Error if err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy trend失败") return } pts = append(pts, point{Day: dayStart.Format("2006-01-02"), Count: cnt}) } trends = append(trends, routeTrend{Route: rt, Points: pts}) } respondOK(c, "ok", gin.H{"days": days, "from": start.Format(time.RFC3339), "trends": trends}) } func (s *WebServer) handleLegacyReadiness(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "audit.read", "无 audit.read 权限") { return } days, _ := strconv.Atoi(c.DefaultQuery("days", "7")) if days <= 0 || days > 90 { days = 7 } zeroDays, _ := strconv.Atoi(c.DefaultQuery("zero_days", "3")) if zeroDays <= 0 || zeroDays > 30 { zeroDays = 3 } start := time.Now().AddDate(0, 0, -days+1) routes := []string{"/api/records", "/delete/:id", "/export"} routeTotals := map[string]int64{} zeroStreak := map[string]int{} windowTotal := int64(0) ready := true for _, rt := range routes { var total int64 err := s.db.Model(&models.AuditLog{}). Where("action = ? AND target_type = ? AND target_id = ?", "legacy.route.access", "route", rt). Where("created_at >= ?", start). Count(&total).Error if err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy readiness失败") return } routeTotals[rt] = total windowTotal += total streak := 0 for i := days - 1; i >= 0; i-- { dayStart := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location()).AddDate(0, 0, i) dayEnd := dayStart.Add(24 * time.Hour) var cnt int64 err := s.db.Model(&models.AuditLog{}). Where("action = ? AND target_type = ? AND target_id = ?", "legacy.route.access", "route", rt). Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). Count(&cnt).Error if err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy readiness失败") return } if cnt == 0 { streak++ } else { break } } zeroStreak[rt] = streak if streak < zeroDays { ready = false } } recommendation := "暂不建议下线 legacy 路由(未满足连续0调用阈值)" if ready { recommendation = "可考虑下线 legacy 路由(已满足连续0调用阈值)" } respondOK(c, "ok", gin.H{ "days": days, "zero_days": zeroDays, "window_total": windowTotal, "route_totals": routeTotals, "consecutive_zero_days": zeroStreak, "ready": ready, "recommendation": recommendation, }) } func (s *WebServer) handleModulesList(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { return } type moduleItem struct { Module string `json:"module"` DisplayName string `json:"display_name"` FlagKey string `json:"flag_key"` Enabled bool `json:"enabled"` } items := []moduleItem{ {Module: "cpa", DisplayName: "CPA 管理", FlagKey: "enable_module_cpa"}, {Module: "cf", DisplayName: "CF 管理", FlagKey: "enable_module_cf"}, {Module: "mail", DisplayName: "邮箱管理", FlagKey: "enable_module_mail"}, } for i := range items { var ff models.FeatureFlag if err := s.db.Where("key = ?", items[i].FlagKey).First(&ff).Error; err == nil { items[i].Enabled = ff.Enabled } } respondOK(c, "ok", gin.H{"modules": items}) } func (s *WebServer) handleModuleToggle(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { return } module := strings.TrimSpace(strings.ToLower(c.Param("module"))) flagKey := "" switch module { case "cpa": flagKey = "enable_module_cpa" case "cf": flagKey = "enable_module_cf" case "mail": flagKey = "enable_module_mail" default: respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "unknown module") return } var req moduleToggleReq if err := c.ShouldBindJSON(&req); err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") return } if strings.TrimSpace(req.Reason) == "" { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "修改模块开关必须提供 reason") return } if module == "cpa" && !req.Enabled { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "禁止禁用关键模块 cpa") return } var ff models.FeatureFlag if err := s.db.Where("key = ?", flagKey).First(&ff).Error; err != nil { respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "模块开关不存在") return } old := ff.Enabled if old == req.Enabled { respondOK(c, "noop", gin.H{"module": module, "flag_key": flagKey, "old": old, "new": req.Enabled}) return } ff.Enabled = req.Enabled ff.UpdatedBy = u.UserID if err := s.db.Save(&ff).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "更新失败") return } _ = s.db.Create(&models.FeatureFlagHistory{FlagKey: flagKey, OldValue: old, NewValue: req.Enabled, ChangedBy: u.UserID, Reason: req.Reason, RequestID: c.GetHeader("X-Request-ID")}).Error s.writeAuditResult(u.UserID, "module.toggle", "module", module, fmt.Sprintf(`{"enabled":%v}`, old), fmt.Sprintf(`{"enabled":%v}`, req.Enabled), req.Reason, "success") respondOK(c, "success", gin.H{"module": module, "flag_key": flagKey, "old": old, "new": req.Enabled}) } func (s *WebServer) handleDashboardOverview(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") { return } var jobs []models.OpsJob s.db.Order("id desc").Limit(30).Find(&jobs) statusCount := map[string]int{"pending": 0, "running": 0, "success": 0, "failed": 0, "cancelled": 0} for _, j := range jobs { statusCount[j.Status]++ } type moduleItem struct { Module string `json:"module"` Enabled bool `json:"enabled"` } mods := []moduleItem{{Module: "cpa"}, {Module: "cf"}, {Module: "mail"}} for i := range mods { flagKey := "enable_module_" + mods[i].Module var ff models.FeatureFlag if err := s.db.Where("key = ?", flagKey).First(&ff).Error; err == nil { mods[i].Enabled = ff.Enabled } } var channels []models.ChannelConfig s.db.Order("platform asc").Find(&channels) channelOut := make([]gin.H, 0, len(channels)) for _, ch := range channels { channelOut = append(channelOut, gin.H{"platform": ch.Platform, "enabled": ch.Enabled, "status": ch.Status}) } respondOK(c, "ok", gin.H{"jobs": gin.H{"recent": jobs, "status_count": statusCount}, "modules": mods, "channels": channelOut}) } func (s *WebServer) handleOpsJobs(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") { return } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) if limit <= 0 || limit > 200 { limit = 50 } status := strings.TrimSpace(c.Query("status")) target := strings.TrimSpace(c.Query("target")) runbook := strings.TrimSpace(c.Query("runbook")) requestID := strings.TrimSpace(c.Query("request_id")) operator := strings.TrimSpace(c.Query("operator")) riskLevel := strings.TrimSpace(c.Query("risk_level")) qtext := strings.TrimSpace(c.Query("q")) from := strings.TrimSpace(c.Query("from")) to := strings.TrimSpace(c.Query("to")) q := s.db.Model(&models.OpsJob{}) if status != "" { q = q.Where("status = ?", status) } if target != "" { q = q.Where("target = ?", target) } if runbook != "" { q = q.Where("runbook = ?", runbook) } if requestID != "" { q = q.Where("request_id = ?", requestID) } if operator != "" { opid, err := strconv.ParseInt(operator, 10, 64) if err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid operator") return } q = q.Where("operator = ?", opid) } if riskLevel != "" { q = q.Where("risk_level = ?", riskLevel) } if qtext != "" { like := "%" + qtext + "%" q = q.Where("command LIKE ? OR runbook LIKE ? OR target LIKE ? OR request_id LIKE ?", like, like, like, like) } if from != "" { t, err := time.Parse(time.RFC3339, from) if err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid from, must be RFC3339") return } q = q.Where("created_at >= ?", t) } if to != "" { t, err := time.Parse(time.RFC3339, to) if err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid to, must be RFC3339") return } q = q.Where("created_at <= ?", t) } var jobs []models.OpsJob if err := q.Order("id desc").Limit(limit).Find(&jobs).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询ops jobs失败") return } respondOK(c, "ok", gin.H{"jobs": jobs, "filters": gin.H{"limit": limit, "status": status, "target": target, "runbook": runbook, "request_id": requestID, "operator": operator, "risk_level": riskLevel, "q": qtext, "from": from, "to": to}}) } func (s *WebServer) handleDashboardSummary(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") { return } var total, running, failed, success int64 s.db.Model(&models.OpsJob{}).Count(&total) s.db.Model(&models.OpsJob{}).Where("status = ?", "running").Count(&running) s.db.Model(&models.OpsJob{}).Where("status = ?", "failed").Count(&failed) s.db.Model(&models.OpsJob{}).Where("status = ?", "success").Count(&success) mods := map[string]bool{"cpa": false, "cf": false, "mail": false} for k := range mods { var ff models.FeatureFlag if err := s.db.Where("key = ?", "enable_module_"+k).First(&ff).Error; err == nil { mods[k] = ff.Enabled } } respondOK(c, "ok", gin.H{ "jobs": gin.H{"total": total, "running": running, "failed": failed, "success": success}, "modules": mods, }) } func (s *WebServer) handleOpsJobsByRequestID(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") { return } requestID := strings.TrimSpace(c.Param("requestID")) if requestID == "" { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "request id required") return } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) if limit <= 0 || limit > 200 { limit = 50 } var jobs []models.OpsJob if err := s.db.Where("request_id = ?", requestID).Order("id desc").Limit(limit).Find(&jobs).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询ops jobs失败") return } var total int64 if err := s.db.Model(&models.OpsJob{}).Where("request_id = ?", requestID).Count(&total).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询ops jobs总数失败") return } respondOK(c, "ok", gin.H{"request_id": requestID, "total": total, "jobs": jobs}) } func (s *WebServer) handleOpsJobDetail(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") { return } id, err := strconv.Atoi(c.Param("id")) if err != nil || id <= 0 { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid job id") return } var job models.OpsJob if err := s.db.First(&job, id).Error; err != nil { respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "job not found") return } var steps []models.OpsJobStep if err := s.db.Where("job_id = ?", job.ID).Order("id asc").Find(&steps).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "query steps failed") return } stepStats := map[string]int{"running": 0, "success": 0, "failed": 0, "skipped": 0} var stepDurationMs int64 for _, st := range steps { stepStats[st.Status]++ if !st.StartedAt.IsZero() && !st.EndedAt.IsZero() { d := st.EndedAt.Sub(st.StartedAt).Milliseconds() if d > 0 { stepDurationMs += d } } } var jobDurationMs int64 if !job.StartedAt.IsZero() && !job.EndedAt.IsZero() { d := job.EndedAt.Sub(job.StartedAt).Milliseconds() if d > 0 { jobDurationMs = d } } respondOK(c, "ok", gin.H{"job": job, "steps": steps, "step_stats": stepStats, "step_total": len(steps), "duration": gin.H{"job_ms": jobDurationMs, "steps_ms_sum": stepDurationMs}}) } func (s *WebServer) handleOpsJobCancel(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "ops.cancel", "无 ops.cancel 权限") { return } id, err := strconv.Atoi(c.Param("id")) if err != nil || id <= 0 { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid job id") return } var req opsJobActionReq if err := c.ShouldBindJSON(&req); err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") return } reason := strings.TrimSpace(req.Reason) if reason == "" { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cancel 必须提供 reason") return } var job models.OpsJob if err := s.db.First(&job, id).Error; err != nil { respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "job not found") return } if job.Status == "success" || job.Status == "failed" || job.Status == "cancelled" { respondOK(c, "noop", gin.H{"id": job.ID, "job_status": job.Status, "reason": "job already finished"}) return } if job.Status != "pending" && job.Status != "running" { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "job status not cancellable") return } cancelled := runbook.CancelJob(job.ID) job.Status = "cancelled" job.CancelNote = reason job.EndedAt = time.Now() if err := s.db.Save(&job).Error; err != nil { respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "cancel failed") return } s.writeAuditResult(u.UserID, "ops.job.cancel", "ops_job", strconv.Itoa(int(job.ID)), "", "", reason, "success") respondOK(c, "cancelled", gin.H{"id": job.ID, "reason": reason, "signal_sent": cancelled}) } func (s *WebServer) handleOpsJobRetry(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "ops.retry", "无 ops.retry 权限") { return } id, err := strconv.Atoi(c.Param("id")) if err != nil || id <= 0 { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid job id") return } var req opsJobActionReq if err := c.ShouldBindJSON(&req); err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") return } reason := strings.TrimSpace(req.Reason) if reason == "" { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "retry 必须提供 reason") return } var old models.OpsJob if err := s.db.First(&old, id).Error; err != nil { respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "job not found") return } if strings.TrimSpace(old.Status) != "failed" { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "only failed jobs can retry") return } newID, err := ops.RetryJobWithDB(s.db, filepath.Clean(s.baseDir), uint(id)) if err != nil { respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error()) return } s.writeAuditResult(u.UserID, "ops.job.retry", "ops_job", strconv.Itoa(id), "", "", reason, "success") respondOK(c, "retried", gin.H{"old_job_id": id, "new_job_id": newID, "reason": reason}) }