Files
ops-assistant/internal/web/server.go
2026-03-19 21:23:28 +08:00

2075 lines
66 KiB
Go
Raw Blame History

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