Files
ops-assistant/internal/web/server.go
T

2345 lines
74 KiB
Go

package web
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"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/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
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 cfdavSettingsReq struct {
APIBase string `json:"api_base"`
AdminUser string `json:"admin_user"`
AdminPass string `json:"admin_pass"`
}
type cfdavSettings struct {
APIBase string
AdminUser string
AdminPass string
}
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, port int, username, password, sessionKey string, reloadFn func() (string, error)) *WebServer {
return &WebServer{
db: db,
dbPath: dbPath,
baseDir: baseDir,
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)
// CFDAV settings
auth.GET("/api/v1/admin/cfdav/settings", s.handleCFDAVSettingsGet)
auth.PATCH("/api/v1/admin/cfdav/settings", s.handleCFDAVSettingsPatch)
auth.GET("/api/v1/admin/cfdav/users", s.handleCFDAVUsersList)
auth.POST("/api/v1/admin/cfdav/users", s.handleCFDAVUsersCreate)
auth.DELETE("/api/v1/admin/cfdav/users/:id", s.handleCFDAVUsersDelete)
// 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)
auth.GET("/cfdav", s.handleCFDAVSettingsPage)
auth.GET("/modules", s.handleModulesPage)
// 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) handleCFDAVSettingsPage(c *gin.Context) {
u := currentUser(c)
if u == nil || !s.hasPermission(u, "settings.flags.read") {
c.Redirect(http.StatusFound, "/")
return
}
s.renderPage(c, "cfdav_settings.html", u, nil)
}
func (s *WebServer) handleModulesPage(c *gin.Context) {
u := currentUser(c)
if u == nil || !s.hasPermission(u, "settings.flags.read") {
c.Redirect(http.StatusFound, "/")
return
}
s.renderPage(c, "modules.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) handleCFDAVSettingsGet(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
return
}
keys := []string{"cfdav_api_base", "cfdav_admin_user", "cfdav_admin_pass"}
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) handleCFDAVSettingsPatch(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
return
}
var req cfdavSettingsReq
if err := c.ShouldBindJSON(&req); err != nil {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误")
return
}
if req.APIBase != "" {
key := "cfdav_api_base"
var sset models.AppSetting
if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil {
sset = models.AppSetting{Key: key, Value: req.APIBase, UpdatedBy: u.UserID}
_ = s.db.Create(&sset).Error
} else {
sset.Value = req.APIBase
sset.UpdatedBy = u.UserID
_ = s.db.Save(&sset).Error
}
}
if req.AdminUser != "" {
key := "cfdav_admin_user"
var sset models.AppSetting
if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil {
sset = models.AppSetting{Key: key, Value: req.AdminUser, UpdatedBy: u.UserID}
_ = s.db.Create(&sset).Error
} else {
sset.Value = req.AdminUser
sset.UpdatedBy = u.UserID
_ = s.db.Save(&sset).Error
}
}
if req.AdminPass != "" {
key := "cfdav_admin_pass"
var sset models.AppSetting
if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil {
sset = models.AppSetting{Key: key, Value: req.AdminPass, UpdatedBy: u.UserID}
_ = s.db.Create(&sset).Error
} else {
sset.Value = req.AdminPass
sset.UpdatedBy = u.UserID
_ = s.db.Save(&sset).Error
}
}
respondOK(c, "success", gin.H{"ok": true})
}
func (s *WebServer) loadCFDAVSettings() (*cfdavSettings, error) {
keys := []string{"cfdav_api_base", "cfdav_admin_user", "cfdav_admin_pass"}
out := &cfdavSettings{}
for _, k := range keys {
var sset models.AppSetting
val := ""
if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil {
val = sset.Value
}
switch k {
case "cfdav_api_base":
out.APIBase = val
case "cfdav_admin_user":
out.AdminUser = val
case "cfdav_admin_pass":
out.AdminPass = val
}
}
if strings.TrimSpace(out.AdminUser) == "" || strings.TrimSpace(out.AdminPass) == "" {
return nil, fmt.Errorf("cfdav admin 账号未配置")
}
return out, nil
}
func (s *WebServer) doCFDAVRequest(method, url string, body []byte, settings *cfdavSettings) (string, int, error) {
var reader io.Reader
if body != nil {
reader = strings.NewReader(string(body))
}
req, err := http.NewRequest(method, url, reader)
if err != nil {
return "", 0, err
}
auth := settings.AdminUser + ":" + settings.AdminPass
b64 := base64.StdEncoding.EncodeToString([]byte(auth))
req.Header.Set("Authorization", "Basic "+b64)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", resp.StatusCode, err
}
return string(data), resp.StatusCode, nil
}
func (s *WebServer) handleCFDAVUsersList(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
return
}
settings, err := s.loadCFDAVSettings()
if err != nil {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error())
return
}
if strings.TrimSpace(settings.APIBase) == "" {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cfdav api_base 未配置")
return
}
url := strings.TrimRight(settings.APIBase, "/") + "/api/admin/users"
resp, status, err := s.doCFDAVRequest(http.MethodGet, url, nil, settings)
if err != nil {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, err.Error())
return
}
if status >= 400 {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, resp)
return
}
var out map[string]any
if err := json.Unmarshal([]byte(resp), &out); err != nil {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, "响应解析失败")
return
}
respondOK(c, "ok", out)
}
func (s *WebServer) handleCFDAVUsersCreate(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
return
}
settings, err := s.loadCFDAVSettings()
if err != nil {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error())
return
}
if strings.TrimSpace(settings.APIBase) == "" {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cfdav api_base 未配置")
return
}
payload, err := io.ReadAll(c.Request.Body)
if err != nil {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "读取请求失败")
return
}
url := strings.TrimRight(settings.APIBase, "/") + "/api/admin/users"
resp, status, err := s.doCFDAVRequest(http.MethodPost, url, payload, settings)
if err != nil {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, err.Error())
return
}
if status >= 400 {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, resp)
return
}
var out map[string]any
if err := json.Unmarshal([]byte(resp), &out); err != nil {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, "响应解析失败")
return
}
s.writeAuditResult(u.UserID, "cfdav.user.create", "cfdav_user", "*", "", "", "", "success")
respondOK(c, "ok", out)
}
func (s *WebServer) handleCFDAVUsersDelete(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
return
}
settings, err := s.loadCFDAVSettings()
if err != nil {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error())
return
}
if strings.TrimSpace(settings.APIBase) == "" {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cfdav api_base 未配置")
return
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "id required")
return
}
url := strings.TrimRight(settings.APIBase, "/") + "/api/admin/users/" + id
resp, status, err := s.doCFDAVRequest(http.MethodDelete, url, nil, settings)
if err != nil {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, err.Error())
return
}
if status >= 400 {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, resp)
return
}
var out map[string]any
if err := json.Unmarshal([]byte(resp), &out); err != nil {
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, "响应解析失败")
return
}
s.writeAuditResult(u.UserID, "cfdav.user.delete", "cfdav_user", id, "", "", "", "success")
respondOK(c, "ok", out)
}
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"},
{Module: "cfdav", DisplayName: "cfdav 管理", FlagKey: "enable_module_cfdav"},
{Module: "sub", DisplayName: "订阅管理", FlagKey: "enable_module_sub"},
}
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"
case "cfdav":
flagKey = "enable_module_cfdav"
case "sub":
flagKey = "enable_module_sub"
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"}, {Module: "cfdav"}, {Module: "sub"}}
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, "cfdav": false, "sub": 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})
}