2075 lines
66 KiB
Go
2075 lines
66 KiB
Go
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})
|
||
}
|