1024 lines
31 KiB
Go
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)
|
||
}
|