Files
Xiaji-go/internal/web/server.go

1024 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/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)
}