package web import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "xiaji-go/internal/channel" "xiaji-go/internal/service" "xiaji-go/models" "xiaji-go/version" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type WebServer struct { db *gorm.DB 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 channelConfigPatchReq struct { Name *string `json:"name"` Enabled *bool `json:"enabled"` Config json.RawMessage `json:"config"` Secrets json.RawMessage `json:"secrets"` } 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", }, "admin": { "records.read.self", "records.delete.self", "records.export.self", "settings.flags.read", "channels.read", "audit.read", }, "viewer": { "records.read.self", }, } func NewWebServer(db *gorm.DB, finance *service.FinanceService, port int, username, password, sessionKey string, reloadFn func() (string, error)) *WebServer { return &WebServer{ db: db, finance: finance, port: port, username: username, password: password, secretKey: "xiaji-go-session-" + sessionKey, reloadFn: reloadFn, } } func (s *WebServer) generateToken(username string) string { mac := hmac.New(sha256.New, []byte(s.secretKey)) mac.Write([]byte(username)) return hex.EncodeToString(mac.Sum(nil)) } func (s *WebServer) validateToken(username, token string) bool { expected := s.generateToken(username) return hmac.Equal([]byte(expected), []byte(token)) } 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) { c.JSON(http.StatusForbidden, gin.H{"error": 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("xiaji_user") token, _ := c.Cookie("xiaji_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" { c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) } else { c.Redirect(http.StatusFound, "/login") } c.Abort() return } 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) 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) } func (s *WebServer) registerLegacyCompatRoutes(auth *gin.RouterGroup) { // 兼容老前端调用,统一复用 v1 handler(兼容层) // // 废弃计划(仅文档约束,当前不删): // 1) 新功能与新页面只允许使用 /api/v1/* // 2) 当确认无旧调用后,再移除以下旧路由映射 // 3) 每次版本发布前,优先检查是否仍存在对旧路由的引用 auth.GET("/api/records", s.handleRecordsV1) auth.POST("/delete/:id", s.handleDeleteV1) auth.GET("/export", s.handleExportV1) } 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) 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("xiaji_user") token, _ := c.Cookie("xiaji_token") 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("xiaji_user", username, maxAge, "/", "", false, true) c.SetCookie("xiaji_token", token, maxAge, "/", "", 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("xiaji_user", "", -1, "/", "", false, true) c.SetCookie("xiaji_token", "", -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) handleMe(c *gin.Context) { u := currentUser(c) if u == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) 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"), } u.Flags = flags u.Caps = caps c.JSON(http.StatusOK, 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") c.JSON(http.StatusOK, 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 { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"}) return } var tx models.Transaction if err := s.db.Where("id = ? AND is_deleted = ?", id, false).First(&tx).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "记录不存在或已删除"}) 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") c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"}) return } s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", `{"is_deleted":true}`, "", "success") c.JSON(http.StatusOK, gin.H{"status": "success"}) } 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("xiaji_%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) c.JSON(http.StatusOK, 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 { c.JSON(http.StatusBadRequest, gin.H{"error": "请求体格式错误"}) return } var ff models.FeatureFlag if err := s.db.Where("key = ?", key).First(&ff).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "开关不存在"}) return } if ff.RequireReason && strings.TrimSpace(req.Reason) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "该开关修改必须提供 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") c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"}) 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") c.JSON(http.StatusOK, gin.H{"status": "success", "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, }) } c.JSON(http.StatusOK, 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 { c.JSON(http.StatusBadRequest, gin.H{"error": "请求体格式错误"}) return } var row models.ChannelConfig if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在"}) 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 { c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"}) 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, "") c.JSON(http.StatusOK, gin.H{"status": "success", "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 { c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在"}) 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 { c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败"}) 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, "") c.JSON(http.StatusOK, gin.H{"status": "success", "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 { c.JSON(http.StatusInternalServerError, gin.H{"error": "reload 未配置"}) return } detail, err := s.reloadFn() if err != nil { s.writeAudit(u.UserID, "channel_reload", "system", "runtime", "", "", "failed: "+err.Error()) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } s.writeAudit(u.UserID, "channel_reload", "system", "runtime", "", "", detail) c.JSON(http.StatusOK, gin.H{"status": "success", "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 { c.JSON(http.StatusInternalServerError, gin.H{"error": "批量关闭失败"}) return } s.writeAudit(u.UserID, "channel_disable_all", "channel", "*", "", fmt.Sprintf(`{"affected":%d}`, res.RowsAffected), "") c.JSON(http.StatusOK, gin.H{"status": "success", "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 { c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在"}) 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 { c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"}) 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, "") c.JSON(http.StatusOK, gin.H{"status": "success", "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 { c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在"}) 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 { c.JSON(http.StatusInternalServerError, gin.H{"error": "测试写入失败"}) return } s.writeAudit(u.UserID, "channel_test", "channel", row.Platform, "", fmt.Sprintf(`{"status":%q,"detail":%q}`, row.Status, detail), "manual test") c.JSON(http.StatusOK, 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 { c.JSON(http.StatusBadRequest, gin.H{"error": "请求体格式错误", "stage": "patch", "committed": false}) return } var row models.ChannelConfig if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在", "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()) c.JSON(http.StatusInternalServerError, gin.H{"error": "保存并发布失败", "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 未配置") c.JSON(http.StatusInternalServerError, gin.H{"error": "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()) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "stage": "reload", "committed": true}) return } note := fmt.Sprintf("apply(patch+publish+reload) detail=%s", detail) s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, note) c.JSON(http.StatusOK, gin.H{ "status": "success", "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 { c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"}) return } c.JSON(http.StatusOK, logs) }