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

1024 lines
31 KiB
Go

package web
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"xiaji-go/internal/channel"
"xiaji-go/internal/service"
"xiaji-go/models"
"xiaji-go/version"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type WebServer struct {
db *gorm.DB
finance *service.FinanceService
port int
username string
password string
secretKey string
reloadFn func() (string, error)
}
type CurrentUser struct {
Username string `json:"username"`
Role string `json:"role"`
UserID int64 `json:"user_id"`
Permissions map[string]bool `json:"-"`
PermList []string `json:"permissions"`
Flags map[string]bool `json:"flags"`
Caps map[string]bool `json:"effective_capabilities"`
}
type flagPatchReq struct {
Enabled bool `json:"enabled"`
Reason string `json:"reason"`
}
type channelConfigPatchReq struct {
Name *string `json:"name"`
Enabled *bool `json:"enabled"`
Config json.RawMessage `json:"config"`
Secrets json.RawMessage `json:"secrets"`
}
var rolePermissions = map[string][]string{
"owner": {
"records.read.self", "records.read.all",
"records.delete.self", "records.delete.all",
"records.export.self", "records.export.all",
"settings.flags.read", "settings.flags.write",
"channels.read", "channels.write", "channels.test",
"audit.read",
},
"admin": {
"records.read.self", "records.delete.self", "records.export.self",
"settings.flags.read", "channels.read", "audit.read",
},
"viewer": {
"records.read.self",
},
}
func NewWebServer(db *gorm.DB, finance *service.FinanceService, port int, username, password, sessionKey string, reloadFn func() (string, error)) *WebServer {
return &WebServer{
db: db,
finance: finance,
port: port,
username: username,
password: password,
secretKey: "xiaji-go-session-" + sessionKey,
reloadFn: reloadFn,
}
}
func (s *WebServer) generateToken(username string) string {
mac := hmac.New(sha256.New, []byte(s.secretKey))
mac.Write([]byte(username))
return hex.EncodeToString(mac.Sum(nil))
}
func (s *WebServer) validateToken(username, token string) bool {
expected := s.generateToken(username)
return hmac.Equal([]byte(expected), []byte(token))
}
func (s *WebServer) buildCurrentUser(username string) *CurrentUser {
role := "viewer"
userID := int64(1)
if username == s.username {
role = "owner"
}
perms := map[string]bool{}
permList := make([]string, 0)
for _, p := range rolePermissions[role] {
perms[p] = true
permList = append(permList, p)
}
return &CurrentUser{Username: username, Role: role, UserID: userID, Permissions: perms, PermList: permList}
}
func (s *WebServer) getFlagMap() map[string]bool {
res := map[string]bool{}
var flags []models.FeatureFlag
s.db.Find(&flags)
for _, f := range flags {
res[f.Key] = f.Enabled
}
return res
}
func (s *WebServer) flagEnabled(key string) bool {
var ff models.FeatureFlag
if err := s.db.Where("key = ?", key).First(&ff).Error; err != nil {
return false
}
return ff.Enabled
}
func (s *WebServer) hasPermission(u *CurrentUser, perm string) bool {
if u == nil {
return false
}
return u.Permissions[perm]
}
func (s *WebServer) requirePerm(c *gin.Context, u *CurrentUser, perm, msg string) bool {
if s.hasPermission(u, perm) {
return true
}
deny(c, msg)
return false
}
func (s *WebServer) renderPage(c *gin.Context, tpl string, u *CurrentUser, extra gin.H) {
data := gin.H{"version": "v" + version.Version}
if u != nil {
data["username"] = u.Username
}
for k, v := range extra {
data[k] = v
}
c.HTML(http.StatusOK, tpl, data)
}
func deny(c *gin.Context, msg string) {
c.JSON(http.StatusForbidden, gin.H{"error": msg})
}
func currentUser(c *gin.Context) *CurrentUser {
if v, ok := c.Get("currentUser"); ok {
if u, ok2 := v.(*CurrentUser); ok2 {
return u
}
}
return nil
}
func (s *WebServer) authRequired() gin.HandlerFunc {
return func(c *gin.Context) {
username, _ := c.Cookie("xiaji_user")
token, _ := c.Cookie("xiaji_token")
if username == "" || token == "" || !s.validateToken(username, token) {
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api") || c.Request.Method == "POST" || c.Request.Method == "PATCH" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
} else {
c.Redirect(http.StatusFound, "/login")
}
c.Abort()
return
}
c.Set("currentUser", s.buildCurrentUser(username))
c.Next()
}
}
func (s *WebServer) writeAudit(actor int64, action, targetType, targetID, before, after, note string) {
_ = s.db.Create(&models.AuditLog{
ActorID: actor,
Action: action,
TargetType: targetType,
TargetID: targetID,
BeforeJSON: before,
AfterJSON: after,
Note: note,
}).Error
}
func (s *WebServer) writeAuditResult(actor int64, action, targetType, targetID, before, after, note, result string) {
finalNote := strings.TrimSpace(note)
if strings.TrimSpace(result) != "" {
if finalNote == "" {
finalNote = "result=" + result
} else {
finalNote = finalNote + " | result=" + result
}
}
s.writeAudit(actor, action, targetType, targetID, before, after, finalNote)
}
func (s *WebServer) registerAPIV1Routes(auth *gin.RouterGroup) {
auth.GET("/api/v1/me", s.handleMe)
auth.GET("/api/v1/records", s.handleRecordsV1)
auth.POST("/api/v1/records/:id/delete", s.handleDeleteV1)
auth.GET("/api/v1/export", s.handleExportV1)
auth.GET("/api/v1/admin/settings/flags", s.handleFlagsList)
auth.PATCH("/api/v1/admin/settings/flags/:key", s.handleFlagPatch)
auth.GET("/api/v1/admin/channels", s.handleChannelsList)
auth.PATCH("/api/v1/admin/channels/:platform", s.handleChannelPatch)
auth.POST("/api/v1/admin/channels/:platform/publish", s.handleChannelPublish)
auth.POST("/api/v1/admin/channels/reload", s.handleChannelReload)
auth.POST("/api/v1/admin/channels/disable-all", s.handleChannelDisableAll)
auth.POST("/api/v1/admin/channels/:platform/enable", s.handleChannelEnable)
auth.POST("/api/v1/admin/channels/:platform/disable", s.handleChannelDisable)
auth.POST("/api/v1/admin/channels/:platform/test", s.handleChannelTest)
auth.POST("/api/v1/admin/channels/:platform/apply", s.handleChannelApply)
auth.GET("/api/v1/admin/audit", s.handleAuditList)
}
func (s *WebServer) registerLegacyCompatRoutes(auth *gin.RouterGroup) {
// 兼容老前端调用,统一复用 v1 handler(兼容层)
//
// 废弃计划(仅文档约束,当前不删):
// 1) 新功能与新页面只允许使用 /api/v1/*
// 2) 当确认无旧调用后,再移除以下旧路由映射
// 3) 每次版本发布前,优先检查是否仍存在对旧路由的引用
auth.GET("/api/records", s.handleRecordsV1)
auth.POST("/delete/:id", s.handleDeleteV1)
auth.GET("/export", s.handleExportV1)
}
func (s *WebServer) RegisterRoutes(r *gin.Engine) {
r.LoadHTMLGlob("templates/*")
r.GET("/login", s.handleLoginPage)
r.POST("/login", s.handleLogin)
r.GET("/logout", s.handleLogout)
r.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
auth := r.Group("/")
auth.Use(s.authRequired())
{
auth.GET("/", s.handleIndex)
auth.GET("/channels", s.handleChannelsPage)
auth.GET("/audit", s.handleAuditPage)
s.registerAPIV1Routes(auth)
s.registerLegacyCompatRoutes(auth)
}
}
func (s *WebServer) Start() {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
r.Use(gin.Logger())
s.RegisterRoutes(r)
logAddr := fmt.Sprintf(":%d", s.port)
fmt.Printf("🌐 Web后台运行在 http://127.0.0.1%s\n", logAddr)
if err := r.Run(logAddr); err != nil {
fmt.Printf("❌ Web服务启动失败: %v\n", err)
}
}
func (s *WebServer) handleLoginPage(c *gin.Context) {
username, _ := c.Cookie("xiaji_user")
token, _ := c.Cookie("xiaji_token")
if username != "" && token != "" && s.validateToken(username, token) {
c.Redirect(http.StatusFound, "/")
return
}
s.renderPage(c, "login.html", nil, gin.H{"error": ""})
}
func (s *WebServer) handleLogin(c *gin.Context) {
username := c.PostForm("username")
password := c.PostForm("password")
if username == s.username && password == s.password {
token := s.generateToken(username)
maxAge := 7 * 24 * 3600
c.SetCookie("xiaji_user", username, maxAge, "/", "", false, true)
c.SetCookie("xiaji_token", token, maxAge, "/", "", false, true)
u := s.buildCurrentUser(username)
s.writeAuditResult(u.UserID, "auth.login.success", "user", username, "", "", "", "success")
c.Redirect(http.StatusFound, "/")
return
}
s.writeAuditResult(0, "auth.login.failed", "user", username, "", "", "用户名或密码错误", "failed")
s.renderPage(c, "login.html", nil, gin.H{"error": "用户名或密码错误"})
}
func (s *WebServer) handleLogout(c *gin.Context) {
u := currentUser(c)
if u != nil {
s.writeAuditResult(u.UserID, "auth.logout", "user", u.Username, "", "", "", "success")
}
c.SetCookie("xiaji_user", "", -1, "/", "", false, true)
c.SetCookie("xiaji_token", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/login")
}
func (s *WebServer) handleIndex(c *gin.Context) {
u := currentUser(c)
s.renderPage(c, "index.html", u, nil)
}
func (s *WebServer) handleChannelsPage(c *gin.Context) {
u := currentUser(c)
if u == nil || !s.hasPermission(u, "channels.read") {
c.Redirect(http.StatusFound, "/")
return
}
s.renderPage(c, "channels.html", u, nil)
}
func (s *WebServer) handleAuditPage(c *gin.Context) {
u := currentUser(c)
if u == nil || !s.hasPermission(u, "audit.read") {
c.Redirect(http.StatusFound, "/")
return
}
s.renderPage(c, "audit.html", u, nil)
}
func (s *WebServer) handleMe(c *gin.Context) {
u := currentUser(c)
if u == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
flags := s.getFlagMap()
caps := map[string]bool{
"can_read_self": s.hasPermission(u, "records.read.self"),
"can_read_all": s.hasPermission(u, "records.read.all") && flags["allow_cross_user_read"],
"can_delete_self": s.hasPermission(u, "records.delete.self"),
"can_delete_all": s.hasPermission(u, "records.delete.all") && flags["allow_cross_user_delete"],
"can_export_self": s.hasPermission(u, "records.export.self"),
"can_export_all": s.hasPermission(u, "records.export.all") && flags["allow_export_all_users"],
"can_view_flags": s.hasPermission(u, "settings.flags.read"),
"can_edit_flags": s.hasPermission(u, "settings.flags.write"),
"can_view_channels": s.hasPermission(u, "channels.read"),
"can_edit_channels": s.hasPermission(u, "channels.write"),
"can_test_channels": s.hasPermission(u, "channels.test"),
"can_view_audit": s.hasPermission(u, "audit.read"),
}
u.Flags = flags
u.Caps = caps
c.JSON(http.StatusOK, u)
}
func (s *WebServer) handleRecordsV1(c *gin.Context) {
u := currentUser(c)
if !s.hasPermission(u, "records.read.self") {
s.writeAuditResult(u.UserID, "record.list.self", "transaction", "*", "", "", "无 records.read.self 权限", "denied")
deny(c, "无 records.read.self 权限")
return
}
scope := c.DefaultQuery("scope", "self")
q := s.db.Model(&models.Transaction{}).Where("is_deleted = ?", false)
action := "record.list.self"
note := ""
if scope == "all" {
action = "record.list.all"
if !s.hasPermission(u, "records.read.all") {
s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "无 records.read.all 权限", "denied")
deny(c, "无 records.read.all 权限")
return
}
if !s.flagEnabled("allow_cross_user_read") {
s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "策略开关 allow_cross_user_read 未开启", "denied")
deny(c, "策略开关 allow_cross_user_read 未开启")
return
}
} else {
q = q.Where("user_id = ?", u.UserID)
}
var items []models.Transaction
q.Order("id desc").Limit(100).Find(&items)
type txResponse struct {
ID uint `json:"id"`
UserID int64 `json:"user_id"`
Amount float64 `json:"amount"`
Category string `json:"category"`
Note string `json:"note"`
Date string `json:"date"`
}
resp := make([]txResponse, len(items))
for i, item := range items {
resp[i] = txResponse{ID: item.ID, UserID: item.UserID, Amount: item.AmountYuan(), Category: item.Category, Note: item.Note, Date: item.Date}
}
note = fmt.Sprintf("scope=%s,count=%d", scope, len(resp))
s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", note, "success")
c.JSON(http.StatusOK, resp)
}
func (s *WebServer) handleDeleteV1(c *gin.Context) {
u := currentUser(c)
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
return
}
var tx models.Transaction
if err := s.db.Where("id = ? AND is_deleted = ?", id, false).First(&tx).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "记录不存在或已删除"})
return
}
action := "record.delete.self"
if tx.UserID == u.UserID {
if !s.hasPermission(u, "records.delete.self") {
s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", "无 records.delete.self 权限", "denied")
deny(c, "无 records.delete.self 权限")
return
}
} else {
action = "record.delete.all"
if !s.hasPermission(u, "records.delete.all") {
s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", "无 records.delete.all 权限", "denied")
deny(c, "无 records.delete.all 权限")
return
}
if !s.flagEnabled("allow_cross_user_delete") {
s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", "策略开关 allow_cross_user_delete 未开启", "denied")
deny(c, "策略开关 allow_cross_user_delete 未开启")
return
}
}
result := s.db.Model(&models.Transaction{}).Where("id = ? AND is_deleted = ?", id, false).Update("is_deleted", true)
if result.Error != nil {
s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", result.Error.Error(), "failed")
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", `{"is_deleted":true}`, "", "success")
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
func (s *WebServer) handleExportV1(c *gin.Context) {
u := currentUser(c)
scope := c.DefaultQuery("scope", "self")
action := "record.export.self"
q := s.db.Model(&models.Transaction{}).Where("is_deleted = ?", false)
if scope == "all" {
action = "record.export.all"
if !s.hasPermission(u, "records.export.all") {
s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "无 records.export.all 权限", "denied")
deny(c, "无 records.export.all 权限")
return
}
if !s.flagEnabled("allow_export_all_users") {
s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "策略开关 allow_export_all_users 未开启", "denied")
deny(c, "策略开关 allow_export_all_users 未开启")
return
}
} else {
if !s.hasPermission(u, "records.export.self") {
s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "无 records.export.self 权限", "denied")
deny(c, "无 records.export.self 权限")
return
}
q = q.Where("user_id = ?", u.UserID)
}
var items []models.Transaction
q.Order("date asc, id asc").Find(&items)
now := time.Now().Format("20060102")
filename := fmt.Sprintf("xiaji_%s.csv", now)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
c.Writer.WriteString("ID,用户ID,日期,分类,金额(元),备注\n")
for _, item := range items {
line := fmt.Sprintf("%d,%d,%s,%s,%.2f,\"%s\"\n", item.ID, item.UserID, item.Date, item.Category, item.AmountYuan(), item.Note)
c.Writer.WriteString(line)
}
s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", fmt.Sprintf("scope=%s,count=%d", scope, len(items)), "success")
}
func (s *WebServer) handleFlagsList(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
return
}
var flags []models.FeatureFlag
s.db.Order("key asc").Find(&flags)
c.JSON(http.StatusOK, flags)
}
func (s *WebServer) handleFlagPatch(c *gin.Context) {
u := currentUser(c)
if !s.hasPermission(u, "settings.flags.write") {
s.writeAuditResult(u.UserID, "settings.flag.update", "feature_flag", c.Param("key"), "", "", "无 settings.flags.write 权限", "denied")
deny(c, "无 settings.flags.write 权限")
return
}
key := c.Param("key")
var req flagPatchReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体格式错误"})
return
}
var ff models.FeatureFlag
if err := s.db.Where("key = ?", key).First(&ff).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "开关不存在"})
return
}
if ff.RequireReason && strings.TrimSpace(req.Reason) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "该开关修改必须提供 reason"})
return
}
before := fmt.Sprintf(`{"enabled":%v}`, ff.Enabled)
old := ff.Enabled
ff.Enabled = req.Enabled
ff.UpdatedBy = u.UserID
if err := s.db.Save(&ff).Error; err != nil {
s.writeAuditResult(u.UserID, "settings.flag.update", "feature_flag", key, before, "", err.Error(), "failed")
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"})
return
}
after := fmt.Sprintf(`{"enabled":%v}`, ff.Enabled)
h := models.FeatureFlagHistory{FlagKey: key, OldValue: old, NewValue: req.Enabled, ChangedBy: u.UserID, Reason: req.Reason, RequestID: c.GetHeader("X-Request-ID")}
_ = s.db.Create(&h).Error
s.writeAuditResult(u.UserID, "settings.flag.update", "feature_flag", key, before, after, req.Reason, "success")
c.JSON(http.StatusOK, gin.H{"status": "success", "key": key, "old": old, "new": req.Enabled})
}
func sanitizeJSON(raw string) string {
if strings.TrimSpace(raw) == "" {
return "{}"
}
var m map[string]any
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return "{}"
}
for k := range m {
lk := strings.ToLower(k)
if strings.Contains(lk, "token") || strings.Contains(lk, "secret") || strings.Contains(lk, "key") || strings.Contains(lk, "password") {
m[k] = "***"
}
}
b, _ := json.Marshal(m)
return string(b)
}
func isMaskedSecretsPayload(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return false
}
var walk func(any) bool
walk = func(x any) bool {
switch t := x.(type) {
case map[string]any:
if len(t) == 0 {
return false
}
allMasked := true
for _, vv := range t {
if !walk(vv) {
allMasked = false
break
}
}
return allMasked
case []any:
if len(t) == 0 {
return false
}
for _, vv := range t {
if !walk(vv) {
return false
}
}
return true
case string:
return strings.TrimSpace(t) == "***"
default:
return false
}
}
return walk(v)
}
func (s *WebServer) handleChannelsList(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "channels.read", "无 channels.read 权限") {
return
}
var items []models.ChannelConfig
s.db.Order("platform asc").Find(&items)
type out struct {
ID uint `json:"id"`
Platform string `json:"platform"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Status string `json:"status"`
ConfigJSON string `json:"config_json"`
DraftConfigJSON string `json:"draft_config_json"`
Secrets string `json:"secrets"`
DraftSecrets string `json:"draft_secrets"`
HasDraft bool `json:"has_draft"`
PublishedAt *time.Time `json:"published_at"`
LastCheck *time.Time `json:"last_check_at"`
UpdatedAt time.Time `json:"updated_at"`
}
resp := make([]out, 0, len(items))
for _, it := range items {
sec := channel.MaybeDecryptPublic(it.SecretJSON)
draftSec := channel.MaybeDecryptPublic(it.DraftSecretJSON)
resp = append(resp, out{
ID: it.ID,
Platform: it.Platform,
Name: it.Name,
Enabled: it.Enabled,
Status: it.Status,
ConfigJSON: it.ConfigJSON,
DraftConfigJSON: it.DraftConfigJSON,
Secrets: sanitizeJSON(sec),
DraftSecrets: sanitizeJSON(draftSec),
HasDraft: strings.TrimSpace(it.DraftConfigJSON) != "" || strings.TrimSpace(it.DraftSecretJSON) != "",
PublishedAt: it.PublishedAt,
LastCheck: it.LastCheck,
UpdatedAt: it.UpdatedAt,
})
}
c.JSON(http.StatusOK, resp)
}
func (s *WebServer) handleChannelPatch(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") {
return
}
platform := c.Param("platform")
var req channelConfigPatchReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体格式错误"})
return
}
var row models.ChannelConfig
if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在"})
return
}
before := fmt.Sprintf(`{"draft_config":%s,"draft_secrets":%s}`,
sanitizeJSON(row.DraftConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.DraftSecretJSON)))
if req.Name != nil {
row.Name = strings.TrimSpace(*req.Name)
}
if req.Enabled != nil {
row.Enabled = *req.Enabled
}
if len(req.Config) > 0 {
row.DraftConfigJSON = string(req.Config)
}
if len(req.Secrets) > 0 {
if isMaskedSecretsPayload(req.Secrets) {
// 前端脱敏占位符(***)不应覆盖真实密钥
} else {
row.DraftSecretJSON = channel.EncryptSecretJSON(string(req.Secrets))
}
}
if err := s.db.Save(&row).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"})
return
}
after := fmt.Sprintf(`{"draft_config":%s,"draft_secrets":%s}`,
sanitizeJSON(row.DraftConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.DraftSecretJSON)))
s.writeAudit(u.UserID, "channel_draft_update", "channel", row.Platform, before, after, "")
c.JSON(http.StatusOK, gin.H{"status": "success", "mode": "draft"})
}
func (s *WebServer) handleChannelPublish(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") {
return
}
platform := c.Param("platform")
var row models.ChannelConfig
if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在"})
return
}
before := fmt.Sprintf(`{"config":%s,"secrets":%s}`,
sanitizeJSON(row.ConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.SecretJSON)))
if strings.TrimSpace(row.DraftConfigJSON) != "" {
row.ConfigJSON = row.DraftConfigJSON
}
if strings.TrimSpace(row.DraftSecretJSON) != "" {
row.SecretJSON = row.DraftSecretJSON
}
now := time.Now()
row.PublishedAt = &now
row.UpdatedBy = u.UserID
if err := s.db.Save(&row).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败"})
return
}
after := fmt.Sprintf(`{"config":%s,"secrets":%s}`,
sanitizeJSON(row.ConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.SecretJSON)))
s.writeAudit(u.UserID, "channel_publish", "channel", row.Platform, before, after, "")
c.JSON(http.StatusOK, gin.H{"status": "success", "published_at": now})
}
func (s *WebServer) handleChannelReload(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") {
return
}
if s.reloadFn == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "reload 未配置"})
return
}
detail, err := s.reloadFn()
if err != nil {
s.writeAudit(u.UserID, "channel_reload", "system", "runtime", "", "", "failed: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s.writeAudit(u.UserID, "channel_reload", "system", "runtime", "", "", detail)
c.JSON(http.StatusOK, gin.H{"status": "success", "detail": detail})
}
func (s *WebServer) handleChannelEnable(c *gin.Context) {
s.handleChannelToggle(c, true)
}
func (s *WebServer) handleChannelDisable(c *gin.Context) {
s.handleChannelToggle(c, false)
}
func (s *WebServer) handleChannelDisableAll(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") {
return
}
res := s.db.Model(&models.ChannelConfig{}).Where("enabled = ?", true).Updates(map[string]any{
"enabled": false,
"status": "disabled",
"updated_by": u.UserID,
})
if res.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "批量关闭失败"})
return
}
s.writeAudit(u.UserID, "channel_disable_all", "channel", "*", "", fmt.Sprintf(`{"affected":%d}`, res.RowsAffected), "")
c.JSON(http.StatusOK, gin.H{"status": "success", "affected": res.RowsAffected})
}
func (s *WebServer) handleChannelToggle(c *gin.Context, enable bool) {
u := currentUser(c)
if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") {
return
}
platform := c.Param("platform")
var row models.ChannelConfig
if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在"})
return
}
before := fmt.Sprintf(`{"enabled":%v}`, row.Enabled)
row.Enabled = enable
if !enable {
row.Status = "disabled"
}
row.UpdatedBy = u.UserID
if err := s.db.Save(&row).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"})
return
}
after := fmt.Sprintf(`{"enabled":%v}`, row.Enabled)
action := "channel_disable"
if enable {
action = "channel_enable"
}
s.writeAudit(u.UserID, action, "channel", row.Platform, before, after, "")
c.JSON(http.StatusOK, gin.H{"status": "success", "enabled": row.Enabled, "platform": row.Platform})
}
func (s *WebServer) handleChannelTest(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "channels.test", "无 channels.test 权限") {
return
}
platform := c.Param("platform")
var row models.ChannelConfig
if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在"})
return
}
if strings.TrimSpace(row.DraftConfigJSON) != "" {
row.ConfigJSON = row.DraftConfigJSON
}
if strings.TrimSpace(row.DraftSecretJSON) != "" {
row.SecretJSON = row.DraftSecretJSON
}
now := time.Now()
status, detail := channel.TestChannelConnectivity(context.Background(), row)
row.LastCheck = &now
row.Status = status
if err := s.db.Save(&row).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "测试写入失败"})
return
}
s.writeAudit(u.UserID, "channel_test", "channel", row.Platform, "", fmt.Sprintf(`{"status":%q,"detail":%q}`, row.Status, detail), "manual test")
c.JSON(http.StatusOK, gin.H{"status": row.Status, "detail": detail, "platform": row.Platform, "checked_at": now})
}
func (s *WebServer) handleChannelApply(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") {
return
}
platform := c.Param("platform")
var req channelConfigPatchReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体格式错误", "stage": "patch", "committed": false})
return
}
var row models.ChannelConfig
if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "渠道不存在", "stage": "patch", "committed": false})
return
}
beforeEnabled := row.Enabled
beforeConfig := row.ConfigJSON
beforeDraftConfig := row.DraftConfigJSON
beforeSecret := channel.MaybeDecryptPublic(row.SecretJSON)
beforeDraftSecret := channel.MaybeDecryptPublic(row.DraftSecretJSON)
if req.Name != nil {
row.Name = strings.TrimSpace(*req.Name)
}
if req.Enabled != nil {
row.Enabled = *req.Enabled
}
if len(req.Config) > 0 {
row.DraftConfigJSON = string(req.Config)
}
if len(req.Secrets) > 0 {
if isMaskedSecretsPayload(req.Secrets) {
// 前端脱敏占位符(***)不应覆盖真实密钥
} else {
row.DraftSecretJSON = channel.EncryptSecretJSON(string(req.Secrets))
}
}
if !row.Enabled {
row.Status = "disabled"
}
if strings.TrimSpace(row.DraftConfigJSON) != "" {
row.ConfigJSON = row.DraftConfigJSON
}
if strings.TrimSpace(row.DraftSecretJSON) != "" {
row.SecretJSON = row.DraftSecretJSON
}
publishAt := time.Now()
row.PublishedAt = &publishAt
row.UpdatedBy = u.UserID
if err := s.db.Save(&row).Error; err != nil {
s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, "", "", "failed stage=publish: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存并发布失败", "stage": "publish", "committed": false})
return
}
before := fmt.Sprintf(`{"enabled":%v,"config":%s,"draft_config":%s,"secrets":%s,"draft_secrets":%s}`,
beforeEnabled,
sanitizeJSON(beforeConfig),
sanitizeJSON(beforeDraftConfig),
sanitizeJSON(beforeSecret),
sanitizeJSON(beforeDraftSecret),
)
after := fmt.Sprintf(`{"enabled":%v,"config":%s,"draft_config":%s,"secrets":%s,"draft_secrets":%s}`,
row.Enabled,
sanitizeJSON(row.ConfigJSON),
sanitizeJSON(row.DraftConfigJSON),
sanitizeJSON(channel.MaybeDecryptPublic(row.SecretJSON)),
sanitizeJSON(channel.MaybeDecryptPublic(row.DraftSecretJSON)),
)
if s.reloadFn == nil {
s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, "failed stage=reload: reload 未配置")
c.JSON(http.StatusInternalServerError, gin.H{"error": "reload 未配置", "stage": "reload", "committed": true})
return
}
detail, err := s.reloadFn()
if err != nil {
s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, "failed stage=reload: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "stage": "reload", "committed": true})
return
}
note := fmt.Sprintf("apply(patch+publish+reload) detail=%s", detail)
s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, note)
c.JSON(http.StatusOK, gin.H{
"status": "success",
"platform": row.Platform,
"published_at": publishAt,
"detail": detail,
})
}
func (s *WebServer) handleAuditList(c *gin.Context) {
u := currentUser(c)
if !s.requirePerm(c, u, "audit.read", "无 audit.read 权限") {
return
}
action := strings.TrimSpace(c.Query("action"))
targetType := strings.TrimSpace(c.Query("target_type"))
result := strings.TrimSpace(c.Query("result"))
actorID := strings.TrimSpace(c.Query("actor_id"))
from := strings.TrimSpace(c.Query("from"))
to := strings.TrimSpace(c.Query("to"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
if limit <= 0 || limit > 500 {
limit = 100
}
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if offset < 0 {
offset = 0
}
q := s.db.Model(&models.AuditLog{})
if action != "" {
q = q.Where("action = ?", action)
}
if targetType != "" {
q = q.Where("target_type = ?", targetType)
}
if actorID != "" {
if aid, err := strconv.ParseInt(actorID, 10, 64); err == nil {
q = q.Where("actor_id = ?", aid)
}
}
if from != "" {
if t, err := time.Parse(time.RFC3339, from); err == nil {
q = q.Where("created_at >= ?", t)
}
}
if to != "" {
if t, err := time.Parse(time.RFC3339, to); err == nil {
q = q.Where("created_at <= ?", t)
}
}
if result != "" {
q = q.Where("note LIKE ?", "%result="+result+"%")
}
var logs []models.AuditLog
if err := q.Order("id desc").Limit(limit).Offset(offset).Find(&logs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
return
}
c.JSON(http.StatusOK, logs)
}