feat: show the release version in upgrade notes

This commit is contained in:
samwaf
2026-08-22 18:31:41 +08:00
parent 88eef34d65
commit 89ae2bad67
18 changed files with 1647 additions and 14 deletions
+3
View File
@@ -77,6 +77,7 @@ type APIGroup struct {
WafAppApi
WafAIApi
WafUIPreferenceApi
WafUpgradeNoticeApi
}
var APIGroupAPP = new(APIGroup)
@@ -171,4 +172,6 @@ var (
wafAppChangeLogService = waf_service.WafAppChangeLogServiceApp
wafUIPreferenceService = waf_service.WafUIPreferenceServiceApp
wafUpgradeNoticeService = waf_service.WafUpgradeNoticeServiceApp
)
+156
View File
@@ -0,0 +1,156 @@
package api
import (
"SamWaf/model"
"SamWaf/model/common/response"
"SamWaf/model/request"
"github.com/gin-gonic/gin"
)
type WafUpgradeNoticeApi struct {
}
// GetSummaryApi 升级须知汇总
// @Summary 升级须知汇总
// @Description 首页顶部提示条与登录弹窗用:本次从哪个版本升到哪个版本、还有几项待处理、是否需要弹窗、是否处于降级运行
// @Tags 升级须知
// @Produce json
// @Param lang query string false "语言(zh_CN/en_US),默认中文"
// @Success 200 {object} response.Response{data=response.UpgradeNoticeSummary} "获取成功"
// @Security ApiKeyAuth
// @Router /upgradenotice/summary [get]
func (w *WafUpgradeNoticeApi) GetSummaryApi(c *gin.Context) {
var req request.WafUpgradeNoticeSummaryReq
if err := c.ShouldBind(&req); err != nil {
response.FailWithMessage("解析失败", c)
return
}
response.OkWithDetailed(wafUpgradeNoticeService.GetSummary(req.Lang), "获取成功", c)
}
// GetListApi 升级须知列表
// @Summary 升级须知列表
// @Description 分页查询本次升级带来的须知事项,可按状态/类型/引入版本过滤
// @Tags 升级须知
// @Accept json
// @Produce json
// @Param data body request.WafUpgradeNoticeSearchReq true "查询条件"
// @Success 200 {object} response.Response{data=response.PageResult} "获取成功"
// @Security ApiKeyAuth
// @Router /upgradenotice/list [post]
func (w *WafUpgradeNoticeApi) GetListApi(c *gin.Context) {
var req request.WafUpgradeNoticeSearchReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败", c)
return
}
beans, total, err := wafUpgradeNoticeService.GetListApi(req)
if err != nil {
response.FailWithMessage("查询失败:"+err.Error(), c)
return
}
response.OkWithDetailed(response.PageResult{
List: beans,
Total: total,
PageIndex: req.PageIndex,
PageSize: req.PageSize,
}, "获取成功", c)
}
// AckApi 标记为已处理
// @Summary 升级须知标记已处理
// @Description 用户点「我知道了」后调用
// @Tags 升级须知
// @Accept json
// @Produce json
// @Param data body request.WafUpgradeNoticeIdReq true "条目ID"
// @Success 200 {object} response.Response "操作成功"
// @Security ApiKeyAuth
// @Router /upgradenotice/ack [post]
func (w *WafUpgradeNoticeApi) AckApi(c *gin.Context) {
w.setStatus(c, model.UpgradeNoticeStatusDone)
}
// IgnoreApi 忽略
// @Summary 升级须知忽略
// @Description 忽略后不再计入待处理,仍可在「全部」里查到
// @Tags 升级须知
// @Accept json
// @Produce json
// @Param data body request.WafUpgradeNoticeIdReq true "条目ID"
// @Success 200 {object} response.Response "操作成功"
// @Security ApiKeyAuth
// @Router /upgradenotice/ignore [post]
func (w *WafUpgradeNoticeApi) IgnoreApi(c *gin.Context) {
w.setStatus(c, model.UpgradeNoticeStatusIgnored)
}
// RestoreApi 恢复为待处理
// @Summary 升级须知恢复待处理
// @Description 把已处理/已忽略的条目退回待处理
// @Tags 升级须知
// @Accept json
// @Produce json
// @Param data body request.WafUpgradeNoticeIdReq true "条目ID"
// @Success 200 {object} response.Response "操作成功"
// @Security ApiKeyAuth
// @Router /upgradenotice/restore [post]
func (w *WafUpgradeNoticeApi) RestoreApi(c *gin.Context) {
w.setStatus(c, model.UpgradeNoticeStatusPending)
}
func (w *WafUpgradeNoticeApi) setStatus(c *gin.Context, status string) {
var req request.WafUpgradeNoticeIdReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败", c)
return
}
if err := wafUpgradeNoticeService.SetStatus(req.NoticeId, status, upgradeNoticeOperator(c)); err != nil {
response.FailWithMessage("操作失败:"+err.Error(), c)
return
}
response.OkWithMessage("操作成功", c)
}
// PopupShownApi 弹窗已展示回写
// @Summary 升级须知弹窗已展示
// @Description 登录弹窗关闭后调用,此后不再弹(一辈子只弹一次)
// @Tags 升级须知
// @Produce json
// @Success 200 {object} response.Response "操作成功"
// @Security ApiKeyAuth
// @Router /upgradenotice/popupshown [post]
func (w *WafUpgradeNoticeApi) PopupShownApi(c *gin.Context) {
if err := wafUpgradeNoticeService.MarkPopupShown(); err != nil {
response.FailWithMessage("操作失败:"+err.Error(), c)
return
}
response.OkWithMessage("操作成功", c)
}
// DowngradeAckApi 确认降级告警
// @Summary 确认降级告警
// @Description 用户点掉首页/列表页顶部的红色降级提示后调用;曾经运行过的最高版本再次变高时告警会重新出现
// @Tags 升级须知
// @Produce json
// @Success 200 {object} response.Response "操作成功"
// @Security ApiKeyAuth
// @Router /upgradenotice/downgradeack [post]
func (w *WafUpgradeNoticeApi) DowngradeAckApi(c *gin.Context) {
if err := wafUpgradeNoticeService.AckDowngrade(); err != nil {
response.FailWithMessage("操作失败:"+err.Error(), c)
return
}
response.OkWithMessage("操作成功", c)
}
// upgradeNoticeOperator 取当前登录账号,用于记录"谁处理的"
func upgradeNoticeOperator(c *gin.Context) string {
if v, ok := c.Get("loginAccount"); ok {
if account, ok := v.(string); ok {
return account
}
}
return ""
}
+25
View File
@@ -0,0 +1,25 @@
package request
import "SamWaf/model/common/request"
// WafUpgradeNoticeSearchReq 升级须知列表查询
type WafUpgradeNoticeSearchReq struct {
Status string `json:"status" form:"status"` // pending / done / ignored,空=全部
Kind string `json:"kind" form:"kind"` // notice / action / check,空=全部
Version string `json:"version" form:"version"` // 按引入版本过滤,空=全部
Lang string `json:"lang" form:"lang"` // zh_CN / en_US,空=中文
request.PageInfo
}
// WafUpgradeNoticeIdReq 只接受条目 id。
//
// v2 的一键应用同样复用它:配置项名与目标值一律由后端从内置清单取,
// 绝不允许前端传 item/value,否则等于开了一个任意系统配置写入口。
type WafUpgradeNoticeIdReq struct {
NoticeId string `json:"notice_id" form:"notice_id"`
}
// WafUpgradeNoticeSummaryReq 顶部提示条 / 弹窗用的汇总
type WafUpgradeNoticeSummaryReq struct {
Lang string `json:"lang" form:"lang"`
}
+45
View File
@@ -0,0 +1,45 @@
package response
// UpgradeNoticeItem 一条升级须知(文案已按请求语言取好)
type UpgradeNoticeItem struct {
NoticeId string `json:"notice_id"`
Version string `json:"version"` // 引入版本
Kind string `json:"kind"` // notice / action / check
Level string `json:"level"` // high / normal / low
Status string `json:"status"` // pending / done / ignored
FreshInstall bool `json:"fresh_install"` // 是否属于"全新安装建议"
Title string `json:"title"`
Detail string `json:"detail"`
EffectOn string `json:"effect_on"` // 做了之后有什么变化
EffectOff string `json:"effect_off"` // 不做会怎样(必须写代价)
Revert string `json:"revert"` // 怎么撤销
Page string `json:"page"` // 「去设置」跳转的站内路径
Doc string `json:"doc"` // 官方文档链接
ApplyType string `json:"apply_type"` // none / navigate / config_set
AppliedTime string `json:"applied_time"`
AppliedUser string `json:"applied_user"`
}
// UpgradeNoticeSummary 顶部提示条与登录弹窗需要的汇总信息
type UpgradeNoticeSummary struct {
CurrentVersion string `json:"current_version"`
FromVersion string `json:"from_version"` // 本次升级的起点,空=无历史记录
ToVersion string `json:"to_version"`
PendingCount int64 `json:"pending_count"`
HighPendingCount int64 `json:"high_pending_count"`
TotalCount int64 `json:"total_count"`
// NeedPopup 仅当存在 level=high 且未处理、且从未弹过时为 true。
// 弹窗一辈子只弹一次,回写靠 /popupshown。
NeedPopup bool `json:"need_popup"`
PopupItems []UpgradeNoticeItem `json:"popup_items"`
// Downgrade 旧程序 + 新库(容器重建后回退到镜像自带版本,issue #938)。
// 过去只打日志、界面完全看不见,这里把它暴露出来。
Downgrade bool `json:"downgrade"`
DowngradeMsg string `json:"downgrade_msg"`
}
+38
View File
@@ -0,0 +1,38 @@
package model
import (
"SamWaf/customtype"
"SamWaf/model/baseorm"
)
// 升级须知的处理状态
const (
UpgradeNoticeStatusPending = "pending" // 待处理
UpgradeNoticeStatusDone = "done" // 已处理
UpgradeNoticeStatusIgnored = "ignored" // 已忽略
)
// UpgradeNoticeRecord 升级须知的处理状态。
//
// 只落 notice_id 与状态,**文案不落库** —— 渲染时用当前二进制内置清单里的文案。
// 这样文案修订随版本走,库里也不会堆冗余文本;代价是清单里被删掉的条目会在库里
// 留下孤儿记录,列表侧按 notice_id 过滤掉即可。
type UpgradeNoticeRecord struct {
baseorm.BaseOrm
NoticeId string `gorm:"size:100;index" json:"notice_id"` // 对应内置清单的 id
Version string `gorm:"size:32" json:"version"` // 条目所属版本
FromVersion string `gorm:"size:32" json:"from_version"` // 本次升级的起点
ToVersion string `gorm:"size:32" json:"to_version"` // 本次升级的终点
Kind string `gorm:"size:20" json:"kind"` // notice / action / check
Level string `gorm:"size:20" json:"level"` // high / normal / low
Status string `gorm:"size:20;index" json:"status"` // pending / done / ignored
OldValue string `gorm:"type:text" json:"old_value"` // v2 一键应用前的原值,供撤销
AppliedTime customtype.JsonTime `json:"applied_time"` // 处理时间
AppliedUser string `gorm:"size:64" json:"applied_user"` // 处理人
PopupShown int `json:"popup_shown"` // 1=已弹过窗,此后不再弹
}
// TableName 表名
func (UpgradeNoticeRecord) TableName() string {
return "upgrade_notice_record"
}
+1
View File
@@ -74,6 +74,7 @@ type ApiGroup struct {
WafAppRouter
WafAIRouter
WafUIPreferenceRouter
UpgradeNoticeRouter
}
type PublicApiGroup struct {
LoginRouter
+28
View File
@@ -0,0 +1,28 @@
package router
import (
"SamWaf/api"
"github.com/gin-gonic/gin"
)
type UpgradeNoticeRouter struct {
}
// InitUpgradeNoticeRouter 升级须知:升级后告知本次变更与建议操作
//
// 只读查询 + 本机处理状态标记,不改任何防护策略与系统配置,
// 因此挂在"任意已登录角色"组(首页提示条对所有角色都要可见)。
// v2 的一键写配置属于系统管理员域,届时单独开一个受 RequireRole 保护的路由组。
func (receiver *UpgradeNoticeRouter) InitUpgradeNoticeRouter(group *gin.RouterGroup) {
upgradeNoticeApi := api.APIGroupAPP.WafUpgradeNoticeApi
router := group.Group("")
router.GET("/api/v1/upgradenotice/summary", upgradeNoticeApi.GetSummaryApi) // 顶部提示条/弹窗汇总
router.POST("/api/v1/upgradenotice/list", upgradeNoticeApi.GetListApi) // 列表
router.POST("/api/v1/upgradenotice/ack", upgradeNoticeApi.AckApi) // 我知道了
router.POST("/api/v1/upgradenotice/ignore", upgradeNoticeApi.IgnoreApi) // 忽略
router.POST("/api/v1/upgradenotice/restore", upgradeNoticeApi.RestoreApi) // 恢复待处理
router.POST("/api/v1/upgradenotice/popupshown", upgradeNoticeApi.PopupShownApi) // 弹窗已展示
router.POST("/api/v1/upgradenotice/downgradeack", upgradeNoticeApi.DowngradeAckApi) // 确认降级告警
}
@@ -32,6 +32,8 @@ func runCoreCases(t *testing.T, db *gorm.DB) {
// ————— 新增:各 service 完整 CRUD 回路 —————
runCoreCRUDCases(t, db)
runCoreCRUDCasesB(t, db)
// ————— 升级须知:生成幂等 / 状态流转 / 降级不生成 —————
runUpgradeNoticeCases(t, db)
}
// ========================= 修改类(保留原有覆盖)=========================
@@ -0,0 +1,200 @@
//go:build crossdb
// 升级须知(upgrade_notice_record)三库回归:生成幂等、状态流转、列表过滤、降级不生成。
package waf_service
import (
"SamWaf/model"
commonreq "SamWaf/model/common/request"
"SamWaf/model/request"
"SamWaf/wafupgradenotice"
"strings"
"testing"
"gorm.io/gorm"
)
func runUpgradeNoticeCases(t *testing.T, db *gorm.DB) {
svc := WafUpgradeNoticeServiceApp
clean := func() {
must(t, db.Where("1 = 1").Delete(&model.UpgradeNoticeRecord{}).Error)
}
countRecords := func() int64 {
var n int64
must(t, db.Model(&model.UpgradeNoticeRecord{}).Count(&n).Error)
return n
}
t.Run("UpgradeNotice_生成与幂等", func(t *testing.T) {
clean()
// v1.3.21 -> v1.3.23:应当取到内置清单里 (v1.3.21, v1.3.23] 区间的条目
want := len(wafupgradenotice.Select("v1.3.21", "v1.3.23", false))
if want == 0 {
t.Skip("内置清单在该区间没有条目")
}
svc.Generate("v1.3.21", "v1.3.23", "v1.3.21")
if got := countRecords(); got != int64(want) {
t.Fatalf("首次生成条数不对: got %d want %d", got, want)
}
// 再跑一次(模拟重启)不得重复生成
svc.Generate("v1.3.21", "v1.3.23", "v1.3.21")
if got := countRecords(); got != int64(want) {
t.Fatalf("重复启动产生了重复记录: got %d want %d", got, want)
}
})
t.Run("UpgradeNotice_状态流转不被再次生成覆盖", func(t *testing.T) {
clean()
notes := wafupgradenotice.Select("v1.3.21", "v1.3.23", false)
if len(notes) == 0 {
t.Skip("内置清单在该区间没有条目")
}
svc.Generate("v1.3.21", "v1.3.23", "v1.3.21")
target := notes[0].Id
fatalIf(t, svc.SetStatus(target, model.UpgradeNoticeStatusDone, "tester"))
var bean model.UpgradeNoticeRecord
firstBy(t, db, &bean, "notice_id = ?", target)
if bean.Status != model.UpgradeNoticeStatusDone || bean.AppliedUser != "tester" {
t.Fatalf("状态未落库: %+v", bean)
}
// 重新生成不得把已处理改回待处理
svc.Generate("v1.3.21", "v1.3.23", "v1.3.21")
firstBy(t, db, &bean, "notice_id = ?", target)
if bean.Status != model.UpgradeNoticeStatusDone {
t.Fatalf("重复生成覆盖了用户的处理状态: %+v", bean)
}
// 恢复待处理时要把处理人/处理时间清掉
fatalIf(t, svc.SetStatus(target, model.UpgradeNoticeStatusPending, "tester"))
firstBy(t, db, &bean, "notice_id = ?", target)
if bean.Status != model.UpgradeNoticeStatusPending || bean.AppliedUser != "" {
t.Fatalf("恢复待处理未清理处理人: %+v", bean)
}
})
t.Run("UpgradeNotice_伪造ID被拒", func(t *testing.T) {
if err := svc.SetStatus("not_in_manifest", model.UpgradeNoticeStatusDone, "tester"); err == nil {
t.Fatal("清单外的 notice_id 应当被拒绝")
}
if err := svc.SetStatus("", model.UpgradeNoticeStatusDone, "tester"); err == nil {
t.Fatal("空 notice_id 应当被拒绝")
}
})
t.Run("UpgradeNotice_列表与汇总", func(t *testing.T) {
clean()
notes := wafupgradenotice.Select("v1.3.21", "v1.3.23", false)
if len(notes) == 0 {
t.Skip("内置清单在该区间没有条目")
}
svc.Generate("v1.3.21", "v1.3.23", "v1.3.21")
items, total, err := svc.GetListApi(request.WafUpgradeNoticeSearchReq{
Status: model.UpgradeNoticeStatusPending,
Lang: "zh_CN",
PageInfo: commonreq.PageInfo{PageIndex: 1, PageSize: 50},
})
fatalIf(t, err)
if total != int64(len(notes)) || len(items) != len(notes) {
t.Fatalf("列表条数不对: total=%d items=%d want=%d", total, len(items), len(notes))
}
for _, item := range items {
if item.Title == "" {
t.Fatalf("条目文案为空(清单未接上): %+v", item)
}
}
// 英文必须给出另一套文案
enItems, _, err := svc.GetListApi(request.WafUpgradeNoticeSearchReq{
Status: model.UpgradeNoticeStatusPending,
Lang: "en_US",
PageInfo: commonreq.PageInfo{PageIndex: 1, PageSize: 50},
})
fatalIf(t, err)
if len(enItems) != len(items) || enItems[0].Title == items[0].Title {
t.Fatalf("英文文案未生效: zh=%q en=%q", items[0].Title, enItems[0].Title)
}
summary := svc.GetSummary("zh_CN")
if summary.PendingCount != int64(len(notes)) {
t.Fatalf("汇总待处理数不对: %d want %d", summary.PendingCount, len(notes))
}
if summary.HighPendingCount > 0 && !summary.NeedPopup {
t.Fatal("存在未弹过窗的重要条目时应当需要弹窗")
}
// 弹过一次之后不再弹
fatalIf(t, svc.MarkPopupShown())
if svc.GetSummary("zh_CN").NeedPopup {
t.Fatal("弹窗回写后不应再要求弹窗")
}
})
t.Run("UpgradeNotice_降级不生成只给告警", func(t *testing.T) {
clean()
svc.Generate("v1.3.24", "v1.3.22", "v1.3.24")
if got := countRecords(); got != 0 {
t.Fatalf("降级路径不应生成须知,实际生成了 %d 条", got)
}
summary := svc.GetSummary("zh_CN")
if !summary.Downgrade || summary.DowngradeMsg == "" {
t.Fatalf("降级运行应当在汇总里给出告警: %+v", summary)
}
// 恢复正常状态,避免影响后续用例
svc.Generate("v1.3.23", "v1.3.23", "v1.3.23")
})
// 降级判定必须走"历史最高版本":last 会被写回成当前这个较低版本,
// 用 last 判的话重启一次告警就消失,而旧程序+新库的状态还在。
t.Run("UpgradeNotice_降级判定用最高版本而非上次版本", func(t *testing.T) {
clean()
// 模拟"降级后又重启一次":last 已经被上一轮写成了当前的低版本,只有 maxSeen 还记得高版本
svc.Generate("v1.3.22", "v1.3.22", "v1.3.24")
summary := svc.GetSummary("zh_CN")
if !summary.Downgrade {
t.Fatal("重启后仍应保留降级告警(证据在 max_run_version 里)")
}
if !strings.Contains(summary.DowngradeMsg, "v1.3.24") {
t.Fatalf("告警应指向历史最高版本 v1.3.24: %s", summary.DowngradeMsg)
}
svc.Generate("v1.3.23", "v1.3.23", "v1.3.23")
})
t.Run("UpgradeNotice_降级告警确认与复现", func(t *testing.T) {
clean()
must(t, db.Where("item = ?", downgradeAckItem).Delete(&model.SystemConfig{}).Error)
svc.Generate("v1.3.22", "v1.3.22", "v1.3.24")
if !svc.GetSummary("zh_CN").Downgrade {
t.Fatal("确认前应有降级告警")
}
fatalIf(t, svc.AckDowngrade())
if svc.GetSummary("zh_CN").Downgrade {
t.Fatal("确认后不应再显示降级告警")
}
// 同一个历史最高版本重启多少次都不再提示
svc.Generate("v1.3.22", "v1.3.22", "v1.3.24")
if svc.GetSummary("zh_CN").Downgrade {
t.Fatal("同一最高版本重启后不应重新提示")
}
// 最高版本又变高 = 用户又升级又回退了一次,是新证据,告警必须重新出现
svc.Generate("v1.3.22", "v1.3.22", "v1.3.25")
if !svc.GetSummary("zh_CN").Downgrade {
t.Fatal("历史最高版本变高后应重新提示")
}
// 没有告警时确认应当被拒绝
svc.Generate("v1.3.23", "v1.3.23", "v1.3.23")
if err := svc.AckDowngrade(); err == nil {
t.Fatal("没有降级告警时不应允许确认")
}
must(t, db.Where("item = ?", downgradeAckItem).Delete(&model.SystemConfig{}).Error)
})
}
@@ -0,0 +1,368 @@
package waf_service
import (
"SamWaf/common/uuid"
"SamWaf/common/zlog"
"SamWaf/customtype"
"SamWaf/global"
"SamWaf/model"
"SamWaf/model/baseorm"
"SamWaf/model/request"
"SamWaf/model/response"
"SamWaf/wafupgradenotice"
"errors"
"strings"
"sync"
"time"
"golang.org/x/mod/semver"
)
// 升级须知:升级完成后在产品内告诉用户"这次变了什么、建议点哪个开关、点了和不点有什么区别"。
//
// 数据源是随版本编译进二进制的内置清单(wafupgradenotice),库里只存 notice_id 与处理状态,
// 文案渲染时现取——文案修订随版本走,库里也不留冗余文本。
type WafUpgradeNoticeService struct{}
var WafUpgradeNoticeServiceApp = new(WafUpgradeNoticeService)
// downgradeAckItem 记录用户已确认过的降级告警对应的"历史最高版本"。
// 只有 max_run_version 再次变高(说明又升级又回退了一次,是新证据)时告警才会重新出现。
const downgradeAckItem = "downgrade_notice_ack"
// 启动时算出来的一次性状态:本次是从哪个版本升到哪个版本、是否在降级运行
var (
upgradeStateMu sync.RWMutex
upgradeFromVersion string
upgradeToVersion string
downgradeMessage string
downgradeMaxSeen string // 触发本次降级告警的历史最高版本,确认告警时记它
)
// Generate 按"上次运行版本 → 当前版本"生成本次应当提示的须知。
//
// 由 main 在 CheckVersionDowngrade 之后调用(那个函数读完 last_run_version 会立刻写回当前版本,
// 所以旧版本号必须由它交出来,不能在这里再查一次)。
//
// last 为空 + 库里没有任何站点 → 认作全新安装,只给"全新安装建议"
// last 为空 + 库里已有站点 → 老实例首次升到带本功能的版本,起点未知,只给当前这一版的条目
// last < current → 给 (last, current] 区间内的条目
// last >= current → 不生成;降级时另记一条界面可见的告警
//
// maxSeen 是"曾经运行过的最高版本"(只升不降),**降级判定用它而不是 last**:
// last 会被写回成当前这个较低的版本,用它判定的话重启一次告警就消失了,
// 而"旧程序 + 新库"的状态还在(见 waftask.CheckVersionDowngrade 的说明)。
//
// 任何失败都只记日志,绝不阻断启动。
func (receiver *WafUpgradeNoticeService) Generate(last, current, maxSeen string) {
if global.GWAF_LOCAL_DB == nil {
return
}
last = strings.TrimSpace(last)
current = strings.TrimSpace(current)
maxSeen = strings.TrimSpace(maxSeen)
if maxSeen == "" {
maxSeen = last
}
upgradeStateMu.Lock()
// 同版本重启时不报"从 vX 升级到 vX":留空让界面改用"当前版本 vX"的说法。
// 上次升级遗留的未处理条目仍在,提示条也还会出现,只是不再声称刚发生过一次升级。
upgradeFromVersion = last
if last == current {
upgradeFromVersion = ""
}
upgradeToVersion = current
downgradeMessage = ""
downgradeMaxSeen = ""
if maxSeen != "" && semver.IsValid(maxSeen) && semver.IsValid(current) && semver.Compare(maxSeen, current) > 0 {
downgradeMaxSeen = maxSeen
downgradeMessage = "检测到降级运行:数据库记录曾经运行过的最高版本是 " + maxSeen + ",当前程序版本是 " + current +
"。若为容器部署,通常是应用内升级后容器被重建、程序回退到镜像自带版本所致;数据库已按新版本迁移且无法回退,请把镜像更新到 " + maxSeen + " 或更高版本后重建容器"
}
upgradeStateMu.Unlock()
if err := wafupgradenotice.LoadError(); err != nil {
zlog.Warn("升级须知内置清单解析失败", "error", err.Error())
return
}
notes := wafupgradenotice.Select(last, current, receiver.isFreshInstall())
if len(notes) == 0 {
return
}
created := 0
for _, note := range notes {
var count int64
if err := global.GWAF_LOCAL_DB.Model(&model.UpgradeNoticeRecord{}).
Where("notice_id = ?", note.Id).Count(&count).Error; err != nil {
zlog.Debug("查询升级须知记录失败", err.Error())
continue
}
// 已生成过就跳过:不覆盖用户已有的处理状态
if count > 0 {
continue
}
bean := model.UpgradeNoticeRecord{
BaseOrm: baseorm.BaseOrm{
Id: uuid.GenUUID(),
USER_CODE: global.GWAF_USER_CODE,
Tenant_ID: global.GWAF_TENANT_ID,
CREATE_TIME: customtype.JsonTime(time.Now()),
UPDATE_TIME: customtype.JsonTime(time.Now()),
},
NoticeId: note.Id,
Version: note.Version,
FromVersion: last,
ToVersion: current,
Kind: note.Kind,
Level: note.Level,
Status: model.UpgradeNoticeStatusPending,
}
if err := global.GWAF_LOCAL_DB.Create(&bean).Error; err != nil {
zlog.Debug("写入升级须知记录失败", err.Error())
continue
}
created++
}
if created > 0 {
zlog.Info("升级须知已生成", "from", last, "to", current, "count", created)
}
}
// isFreshInstall 判断是不是全新安装。
//
// 没有站点 = 这个实例还没被用起来。存量用户首次升到带本功能的版本时 last_run_version 同样为空,
// 靠这个信号把两者分开,避免给老用户倒一堆"改初始密码"之类的新手提示。
func (receiver *WafUpgradeNoticeService) isFreshInstall() bool {
var hostCount int64
if err := global.GWAF_LOCAL_DB.Model(&model.Hosts{}).Count(&hostCount).Error; err != nil {
return false
}
return hostCount == 0
}
// GetListApi 分页查询升级须知
func (receiver *WafUpgradeNoticeService) GetListApi(req request.WafUpgradeNoticeSearchReq) ([]response.UpgradeNoticeItem, int64, error) {
if global.GWAF_LOCAL_DB == nil {
return nil, 0, errors.New("数据库未就绪")
}
known := knownNoticeIds()
if len(known) == 0 {
return []response.UpgradeNoticeItem{}, 0, nil
}
// 清单里已删除的条目会在库里留下孤儿记录,用 IN 直接排除,保证总数与分页都对得上
tx := global.GWAF_LOCAL_DB.Model(&model.UpgradeNoticeRecord{}).Where("notice_id in ?", known)
if req.Status != "" {
tx = tx.Where("status = ?", req.Status)
}
if req.Kind != "" {
tx = tx.Where("kind = ?", req.Kind)
}
if req.Version != "" {
// 前端传的是归一化后的展示版本(v1.3.24),库里存的是原始值(可能是 v1.3.24-beta.15)
tx = tx.Where("version in ?", wafupgradenotice.RawVersionsFor(req.Version))
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
pageIndex := req.PageIndex
if pageIndex <= 0 {
pageIndex = 1
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = 10
}
var beans []model.UpgradeNoticeRecord
// 重要的排前面,同级按引入版本倒序(新版本的事更要紧)
if err := tx.Order("case level when 'high' then 0 when 'normal' then 1 else 2 end, version desc, create_time desc").
Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&beans).Error; err != nil {
return nil, 0, err
}
items := make([]response.UpgradeNoticeItem, 0, len(beans))
for _, bean := range beans {
if item, ok := toItem(bean, req.Lang); ok {
items = append(items, item)
}
}
return items, total, nil
}
// GetSummary 顶部提示条与登录弹窗需要的汇总
func (receiver *WafUpgradeNoticeService) GetSummary(lang string) response.UpgradeNoticeSummary {
upgradeStateMu.RLock()
summary := response.UpgradeNoticeSummary{
CurrentVersion: global.GWAF_RELEASE_VERSION,
FromVersion: upgradeFromVersion,
ToVersion: upgradeToVersion,
Downgrade: downgradeMessage != "",
DowngradeMsg: downgradeMessage,
}
maxSeen := downgradeMaxSeen
upgradeStateMu.RUnlock()
// 用户确认过同一个"历史最高版本"就不再显示;max 再变高说明是新证据,告警重新出现
if summary.Downgrade && WafSystemConfigServiceApp.GetDetailByItem(downgradeAckItem).Value == maxSeen {
summary.Downgrade = false
summary.DowngradeMsg = ""
}
if global.GWAF_LOCAL_DB == nil {
return summary
}
known := knownNoticeIds()
if len(known) == 0 {
return summary
}
global.GWAF_LOCAL_DB.Model(&model.UpgradeNoticeRecord{}).
Where("notice_id in ?", known).Count(&summary.TotalCount)
global.GWAF_LOCAL_DB.Model(&model.UpgradeNoticeRecord{}).
Where("notice_id in ? and status = ?", known, model.UpgradeNoticeStatusPending).
Count(&summary.PendingCount)
var highPending []model.UpgradeNoticeRecord
global.GWAF_LOCAL_DB.Model(&model.UpgradeNoticeRecord{}).
Where("notice_id in ? and status = ? and level = ?", known, model.UpgradeNoticeStatusPending, wafupgradenotice.LevelHigh).
Order("version desc").Find(&highPending)
summary.HighPendingCount = int64(len(highPending))
for _, bean := range highPending {
item, ok := toItem(bean, lang)
if !ok {
continue
}
summary.PopupItems = append(summary.PopupItems, item)
// 只要有一条重要须知还没弹过窗,本次登录就弹一次
if bean.PopupShown == 0 {
summary.NeedPopup = true
}
}
return summary
}
// SetStatus 标记处理状态(我知道了 / 忽略 / 恢复待处理)
func (receiver *WafUpgradeNoticeService) SetStatus(noticeId, status, operator string) error {
if global.GWAF_LOCAL_DB == nil {
return errors.New("数据库未就绪")
}
noticeId = strings.TrimSpace(noticeId)
if noticeId == "" {
return errors.New("条目不能为空")
}
// 只认内置清单里存在的条目,杜绝伪造 id 往表里写状态
if _, ok := wafupgradenotice.ByID(noticeId); !ok {
return errors.New("条目不存在")
}
switch status {
case model.UpgradeNoticeStatusPending, model.UpgradeNoticeStatusDone, model.UpgradeNoticeStatusIgnored:
default:
return errors.New("状态非法")
}
updates := map[string]interface{}{
"status": status,
"UPDATE_TIME": customtype.JsonTime(time.Now()),
}
if status == model.UpgradeNoticeStatusPending {
updates["applied_user"] = ""
updates["applied_time"] = nil
} else {
updates["applied_user"] = operator
updates["applied_time"] = customtype.JsonTime(time.Now())
}
return global.GWAF_LOCAL_DB.Model(&model.UpgradeNoticeRecord{}).
Where("notice_id = ?", noticeId).Updates(updates).Error
}
// MarkPopupShown 弹窗关闭后回写:此后不再弹,靠首页提示条和列表页兜底
func (receiver *WafUpgradeNoticeService) MarkPopupShown() error {
if global.GWAF_LOCAL_DB == nil {
return errors.New("数据库未就绪")
}
return global.GWAF_LOCAL_DB.Model(&model.UpgradeNoticeRecord{}).
Where("popup_shown = ?", 0).
Updates(map[string]interface{}{
"popup_shown": 1,
"UPDATE_TIME": customtype.JsonTime(time.Now()),
}).Error
}
// AckDowngrade 确认降级告警:记下当前这个"历史最高版本",此后不再显示。
//
// 不做成"永久关闭":只要 max_run_version 再次变高(用户又升级又回退了一次),
// 就是新的证据,告警会重新出现。
func (receiver *WafUpgradeNoticeService) AckDowngrade() error {
upgradeStateMu.RLock()
maxSeen := downgradeMaxSeen
hasWarning := downgradeMessage != ""
upgradeStateMu.RUnlock()
if !hasWarning || maxSeen == "" {
return errors.New("当前没有降级告警")
}
existing := WafSystemConfigServiceApp.GetDetailByItem(downgradeAckItem)
if existing.Id != "" {
return WafSystemConfigServiceApp.ModifyByItemApi(request.WafSystemConfigEditByItemReq{
Item: downgradeAckItem,
Value: maxSeen,
})
}
return WafSystemConfigServiceApp.AddApi(request.WafSystemConfigAddReq{
ItemClass: "system",
Item: downgradeAckItem,
Value: maxSeen,
Remarks: "已确认过的降级告警对应的历史最高版本;曾经运行过的最高版本再次变高时告警会重新出现,请勿手工修改",
ItemType: "string",
})
}
// knownNoticeIds 当前二进制内置清单里的全部条目 id
func knownNoticeIds() []string {
notes := wafupgradenotice.All()
ids := make([]string, 0, len(notes))
for _, n := range notes {
ids = append(ids, n.Id)
}
return ids
}
// toItem 把库记录 + 内置清单文案拼成一条返回项;清单里已没有的条目返回 false 由调用方丢弃
func toItem(bean model.UpgradeNoticeRecord, lang string) (response.UpgradeNoticeItem, bool) {
note, ok := wafupgradenotice.ByID(bean.NoticeId)
if !ok {
zlog.Debug("升级须知记录在当前版本清单中已不存在,跳过", "notice_id", bean.NoticeId)
return response.UpgradeNoticeItem{}, false
}
text := note.Text(lang)
item := response.UpgradeNoticeItem{
NoticeId: note.Id,
Version: wafupgradenotice.DisplayVersion(note.Version),
Kind: note.Kind,
Level: note.Level,
Status: bean.Status,
FreshInstall: note.FreshInstall,
Title: text.Title,
Detail: text.Detail,
EffectOn: text.EffectOn,
EffectOff: text.EffectOff,
Revert: text.Revert,
Page: note.Page,
Doc: note.Doc,
ApplyType: note.Apply.Type,
AppliedUser: bean.AppliedUser,
}
if applied := time.Time(bean.AppliedTime); !applied.IsZero() {
item.AppliedTime = applied.Format("2006-01-02 15:04:05")
}
return item, true
}
+20
View File
@@ -1920,6 +1920,26 @@ func RunCoreDBMigrations(db *gorm.DB) error {
return nil
},
},
// 迁移: 创建升级须知记录表(升级后在产品内提示本次变更与建议操作)
{
ID: "202608220001_add_upgrade_notice_record_table",
Migrate: func(tx *gorm.DB) error {
zlog.Info("迁移 202608220001: 创建升级须知记录表")
if err := tx.AutoMigrate(&model.UpgradeNoticeRecord{}); err != nil {
return fmt.Errorf("创建升级须知记录表失败: %w", err)
}
// notice_id 唯一:同一条须知跨多次重启只生成一次,用户处理过的状态不会被覆盖
if err := safeCreateIndex(tx, "upgrade_notice_record", "uni_upgrade_notice_id",
"CREATE UNIQUE INDEX IF NOT EXISTS uni_upgrade_notice_id ON upgrade_notice_record(notice_id, user_code, tenant_id)"); err != nil {
zlog.Warn("创建索引 uni_upgrade_notice_id 失败", "error", err.Error())
}
return nil
},
Rollback: func(tx *gorm.DB) error {
zlog.Info("回滚 202608220001: 删除升级须知记录表")
return tx.Migrator().DropTable(&model.UpgradeNoticeRecord{})
},
},
})
// 执行迁移
+3
View File
@@ -177,6 +177,9 @@ func (web *WafWebManager) initRouter(r *gin.Engine) {
// 界面偏好:按登录账号隔离(账号归属即授权边界),任意已登录角色均可存取本人的界面偏好
router.ApiGroupApp.InitWafUIPreferenceRouter(RouterGroup)
// 升级须知:只读查询 + 本机处理状态标记,首页提示条对所有角色都要可见
router.ApiGroupApp.InitUpgradeNoticeRouter(RouterGroup)
// 自助改密:任意已登录角色均可修改本人密码(含首次登录/到期强制改密场景),不受角色域限制
RouterGroup.POST("/api/v1/account/changemypwd", api.APIGroupAPP.WafAccountApi.ChangeMyPasswordApi)
+54 -14
View File
@@ -607,12 +607,23 @@ func TaskLoadSettingCron() {
TaskLoadSetting(false)
}
// CheckVersionDowngrade 记录/校验"上次运行的版本",发现降级运行时打日志。
// CheckVersionDowngrade 维护两个版本水位并做降级校验,顺带生成本次的「升级须知」。
//
// 场景:容器环境点了应用内升级后,新版本会把数据库迁移到新 schema,但程序文件在镜像可写层,
// 容器一旦重建就回退成镜像自带的旧版本,而数据库回不去 —— 形成"旧程序 + 新库"。
// 这种状态过去是完全静默的,只能靠"任务方法 xxx 未找到"这类间接报错才能猜出来(issue #938)。
// 这里只记日志、不阻断启动,也不做界面提示。
// 这里只记日志、不阻断启动。
//
// 为什么要两个配置项:
// - last_run_version 上次运行的版本,**跟着当前版本走(会下调)**,用于算升级须知的版本区间
// - max_run_version 曾经运行过的最高版本,**只升不降**,用于降级判定
//
// 降级判定必须用后者。若沿用 last_run_version,判完降级后它会被改写成当前这个较低的版本,
// 等于自己把证据抹掉:重启一次警告就永远消失,而"旧程序 + 新库"的真实状态还在。
// 容器反复重建恰恰是这个警告要防的场景,只警告第一次等于没警告。
//
// 升级须知刻意写在这个函数里、而不是另找地方再查一次 last_run_version:
// 本函数读完就会把库里的值改写成当前版本,一旦写回,旧版本号就再也拿不到了。
func CheckVersionDowngrade() {
current := strings.TrimSpace(global.GWAF_RELEASE_VERSION)
if current == "" {
@@ -624,39 +635,68 @@ func CheckVersionDowngrade() {
last = strings.TrimSpace(item.Value)
}
if last != "" && semver.IsValid(last) && semver.IsValid(current) && semver.Compare(last, current) > 0 {
zlog.Error(fmt.Sprintf("检测到降级运行:数据库记录的上次运行版本是 %v,当前程序版本是 %v。"+
maxItem := wafSystemConfigService.GetDetailByItem(maxVersionRecordItem)
maxSeen := ""
if maxItem.Id != "" {
maxSeen = strings.TrimSpace(maxItem.Value)
}
// 老库没有 max_run_version 这一行,用 last_run_version 当初值:
// 升级到本版本之前跑过的最高版本,最好的已知近似就是上次运行的那个。
if maxSeen == "" {
maxSeen = last
}
if maxSeen != "" && semver.IsValid(maxSeen) && semver.IsValid(current) && semver.Compare(maxSeen, current) > 0 {
zlog.Error(fmt.Sprintf("检测到降级运行:数据库记录曾经运行过的最高版本是 %v,当前程序版本是 %v。"+
"若为容器部署,通常是应用内升级后容器被重建、程序回退到镜像自带版本所致;"+
"数据库已按新版本迁移且无法回退,请把镜像更新到 %v 或更高版本后重建容器", last, current, last))
"数据库已按新版本迁移且无法回退,请把镜像更新到 %v 或更高版本后重建容器", maxSeen, current, maxSeen))
}
// 生成本次升级须知(内置清单 + 版本区间)。必须赶在下面写回 last_run_version 之前。
wafUpgradeNoticeSvc.Generate(last, current, maxSeen)
// 版本水位只升不降:降级运行时保留历史最高版本,警告才不会因为一次重启就消失
if maxSeen == "" || (semver.IsValid(current) && (!semver.IsValid(maxSeen) || semver.Compare(current, maxSeen) > 0)) {
writeVersionRecord(maxItem, maxVersionRecordItem, current,
"曾经成功启动过的最高程序版本(只升不降),用于识别\"程序被降级、数据库却已按新版本迁移\"的不一致状态,请勿手工修改")
}
// 记录本次版本(只在变化时写库)
if last == current {
return
}
if item.Id != "" {
writeVersionRecord(item, versionRecordItem, current,
"上次成功启动的程序版本,用于计算升级须知的版本区间,请勿手工修改")
}
// writeVersionRecord 写入/更新一个版本水位配置项。失败只记日志,不影响启动。
func writeVersionRecord(existing model.SystemConfig, item, value, remarks string) {
if existing.Id != "" {
if err := wafSystemConfigService.ModifyByItemApi(request.WafSystemConfigEditByItemReq{
Item: versionRecordItem,
Value: current,
Item: item,
Value: value,
}); err != nil {
zlog.Debug("记录本次运行版本失败", err.Error())
zlog.Debug("更新版本记录失败", item, err.Error())
}
return
}
if err := wafSystemConfigService.AddApi(request.WafSystemConfigAddReq{
ItemClass: "system",
Item: versionRecordItem,
Value: current,
Remarks: "上次成功启动的程序版本,用于识别\"程序被降级、数据库却已按新版本迁移\"的不一致状态,请勿手工修改",
Item: item,
Value: value,
Remarks: remarks,
ItemType: "string",
}); err != nil {
zlog.Debug("写入本次运行版本失败", err.Error())
zlog.Debug("写入版本记录失败", item, err.Error())
}
}
// versionRecordItem 记录"上次运行版本"的配置项名
// versionRecordItem 记录"上次运行版本"的配置项名(跟着当前版本走,可下调)
const versionRecordItem = "last_run_version"
// maxVersionRecordItem 记录"曾经运行过的最高版本"的配置项名(只升不降,降级判定用它)
const maxVersionRecordItem = "max_run_version"
// TaskLoadSetting 加载配置数据
//
// initLoad true 是初始化加载,false不是初始化加载
+1
View File
@@ -8,6 +8,7 @@ var (
wafSysLogService = waf_service.WafSysLogServiceApp
wafSystemConfigService = waf_service.WafSystemConfigServiceApp
wafLogService = waf_service.WafLogServiceApp
wafUpgradeNoticeSvc = waf_service.WafUpgradeNoticeServiceApp
)
type LastCounter struct {
+307
View File
@@ -0,0 +1,307 @@
// Package wafupgradenotice 提供「升级须知」的内置清单:解析、校验与按版本区间挑选。
//
// 清单随版本编译进二进制(见 upgrade_notes.yaml),不走网络:
// 内网/离线部署升完级同样能看到,也不新增任何对外请求。
// 本包是纯函数层,不碰数据库——落库与状态流转在 service/waf_service。
package wafupgradenotice
import (
_ "embed"
"fmt"
"regexp"
"strings"
"sync"
"golang.org/x/mod/semver"
"gopkg.in/yaml.v3"
)
//go:embed upgrade_notes.yaml
var notesYAML []byte
// 条目类型
const (
KindNotice = "notice" // 知悉:行为变了,用户无需动作
KindAction = "action" // 建议操作:建议开/关某个能力
KindCheck = "check" // 场景确认:只有部分用户受影响,需自查
)
// 重要程度:只有 high 会在登录后弹一次窗
const (
LevelHigh = "high"
LevelNormal = "normal"
LevelLow = "low"
)
// 执行方式
const (
ApplyNone = "none" // 无可执行动作
ApplyNavigate = "navigate" // 跳到对应页面由用户自己操作
ApplyConfigSet = "config_set" // v2:一键写配置(前端只传 id,item/value 由后端从本清单取)
)
// docPrefix 文档链接只允许指向官方文档站,防止清单将来接云端补丁时被用作跳板
const docPrefix = "https://doc.samwaf.com/"
// idPattern 条目 id 只允许小写字母、数字与下划线
var idPattern = regexp.MustCompile(`^[a-z0-9_]+$`)
// Text 一条须知的单语言文案
type Text struct {
Title string `yaml:"title"`
Detail string `yaml:"detail"`
EffectOn string `yaml:"effect_on"`
EffectOff string `yaml:"effect_off"`
Revert string `yaml:"revert"`
}
// Apply 建议操作的执行方式
type Apply struct {
Type string `yaml:"type"`
Item string `yaml:"item"`
Value string `yaml:"value"`
}
// Note 内置清单里的一条须知
type Note struct {
Id string `yaml:"id"` // 全局唯一,幂等键,一旦发布不得修改
Version string `yaml:"version"` // 哪个版本引入(semver,参与区间计算)
Kind string `yaml:"kind"` // notice / action / check
Level string `yaml:"level"` // high / normal / low
FreshInstall bool `yaml:"fresh_install"` // true 表示这是"全新安装建议",只在无历史版本时生成
Page string `yaml:"page"` // 前端路由,「去设置」跳这里;只允许站内相对路径
Doc string `yaml:"doc"` // 官方文档链接
Apply Apply `yaml:"apply"`
ZH Text `yaml:"zh"`
EN Text `yaml:"en"`
}
// Text 按语言取文案,非 en 一律回落中文
func (n Note) Text(lang string) Text {
if strings.HasPrefix(strings.ToLower(lang), "en") {
return n.EN
}
return n.ZH
}
type noteFile struct {
Notes []Note `yaml:"notes"`
}
var (
loadOnce sync.Once
loaded []Note
loadErr error
)
// All 返回内置清单里全部**通过校验**的条目。
//
// 解析失败或个别条目非法时只跳过、不 panic:清单出问题绝不能拖垮启动。
// 真正的把关在 TestNotesYAML(构建期),发布前就会炸出来。
func All() []Note {
loadOnce.Do(func() {
var f noteFile
if err := yaml.Unmarshal(notesYAML, &f); err != nil {
loadErr = fmt.Errorf("解析内置升级须知清单失败: %w", err)
return
}
seen := make(map[string]bool, len(f.Notes))
for _, n := range f.Notes {
if err := ValidateNote(n); err != nil {
continue
}
if seen[n.Id] {
continue
}
seen[n.Id] = true
loaded = append(loaded, n)
}
})
return loaded
}
// ParseAll 原样解析内置清单,不做任何过滤。
// 只给构建期单测用:它要看到全部条目(含非法的)才能把问题炸出来。
func ParseAll() ([]Note, error) {
var f noteFile
if err := yaml.Unmarshal(notesYAML, &f); err != nil {
return nil, err
}
return f.Notes, nil
}
// LoadError 返回清单解析错误(仅供启动日志与单测使用)
func LoadError() error {
All()
return loadErr
}
// ByID 按 id 取条目
func ByID(id string) (Note, bool) {
for _, n := range All() {
if n.Id == id {
return n, true
}
}
return Note{}, false
}
// ValidateNote 校验单条须知。构建期单测对全量清单逐条调用,任何一条不过即发布不了。
func ValidateNote(n Note) error {
if !idPattern.MatchString(n.Id) {
return fmt.Errorf("id %q 非法:只允许小写字母、数字与下划线", n.Id)
}
if !semver.IsValid(n.Version) {
return fmt.Errorf("[%s] version %q 不是合法 semver(应形如 v1.3.24)", n.Id, n.Version)
}
switch n.Kind {
case KindNotice, KindAction, KindCheck:
default:
return fmt.Errorf("[%s] kind %q 非法", n.Id, n.Kind)
}
switch n.Level {
case LevelHigh, LevelNormal, LevelLow:
default:
return fmt.Errorf("[%s] level %q 非法", n.Id, n.Level)
}
switch n.Apply.Type {
case ApplyNone, ApplyNavigate:
if n.Apply.Item != "" || n.Apply.Value != "" {
return fmt.Errorf("[%s] apply.type=%s 不应带 item/value", n.Id, n.Apply.Type)
}
case ApplyConfigSet:
if n.Apply.Item == "" || n.Apply.Value == "" {
return fmt.Errorf("[%s] apply.type=config_set 必须同时给出 item 与 value", n.Id)
}
default:
return fmt.Errorf("[%s] apply.type %q 非法", n.Id, n.Apply.Type)
}
if n.Apply.Type == ApplyNavigate && n.Page == "" {
return fmt.Errorf("[%s] apply.type=navigate 必须给出 page", n.Id)
}
if n.Page != "" && !strings.HasPrefix(n.Page, "/") {
return fmt.Errorf("[%s] page %q 必须是以 / 开头的站内相对路径", n.Id, n.Page)
}
if n.Doc != "" && !strings.HasPrefix(n.Doc, docPrefix) {
return fmt.Errorf("[%s] doc %q 必须指向 %s", n.Id, n.Doc, docPrefix)
}
if err := validateText(n, "zh", n.ZH); err != nil {
return err
}
return validateText(n, "en", n.EN)
}
// validateText 中英两套文案都必须齐全。
//
// kind=action 强制要求 effect_on / effect_off / revert 三项:
// "点了之后和不点有什么区别、后悔了怎么撤" 正是这个功能存在的理由,缺一项就不该发出去。
func validateText(n Note, lang string, t Text) error {
if strings.TrimSpace(t.Title) == "" {
return fmt.Errorf("[%s] %s.title 不能为空", n.Id, lang)
}
if strings.TrimSpace(t.Detail) == "" {
return fmt.Errorf("[%s] %s.detail 不能为空", n.Id, lang)
}
if n.Kind != KindAction {
return nil
}
if strings.TrimSpace(t.EffectOn) == "" {
return fmt.Errorf("[%s] kind=action 时 %s.effect_on 必填(做了之后有什么变化)", n.Id, lang)
}
if strings.TrimSpace(t.EffectOff) == "" {
return fmt.Errorf("[%s] kind=action 时 %s.effect_off 必填(不做会怎样,必须写代价)", n.Id, lang)
}
if strings.TrimSpace(t.Revert) == "" {
return fmt.Errorf("[%s] kind=action 时 %s.revert 必填(怎么撤销)", n.Id, lang)
}
return nil
}
// DisplayVersion 把条目版本号归一化成给用户看的版本。
//
// 清单里的 version 必须填「首次包含该改动的构建号」,beta 阶段加的就填 beta 号
// (否则整个 beta 期都不会提示,见设计说明 §7)。但正式版用户在列表里看到
// v1.3.24-beta.15 会疑惑"我用的不是 beta 啊",所以**存精确值、显示干净值**:
// 区间计算永远用原始值,只有展示这一层砍掉 -beta.x 后缀。
func DisplayVersion(v string) string {
if !semver.IsValid(v) {
return v
}
canonical := semver.Canonical(v)
// Prerelease 返回带前导 '-' 的后缀(如 "-beta.15"),直接裁掉就是主版本号
if pre := semver.Prerelease(canonical); pre != "" {
return strings.TrimSuffix(canonical, pre)
}
return canonical
}
// RawVersionsFor 把用户在下拉里选的展示版本,还原成清单里所有对应的原始版本号。
//
// 列表按 version 列过滤,而库里存的是原始值(可能带 -beta.x),
// 前端传过来的是归一化后的值,两边对不上,必须在这里展开。
func RawVersionsFor(display string) []string {
display = strings.TrimSpace(display)
if display == "" {
return nil
}
var out []string
seen := make(map[string]bool)
for _, n := range All() {
if DisplayVersion(n.Version) != display || seen[n.Version] {
continue
}
seen[n.Version] = true
out = append(out, n.Version)
}
// 清单里找不到对应条目时,原样返回,让查询自然落空而不是退化成"不过滤"
if len(out) == 0 {
return []string{display}
}
return out
}
// Select 挑出本次启动应当生成的条目。
//
// last 为空 + 全新安装 → 只取 fresh_install 条目(不倒历史,否则新用户一上来就被淹)
// last 为空 + 存量库 → 只取"当前这一版"的条目(老用户首次升到带本功能的版本,起点未知)
// last < current → 取 (last, current] 区间内的非 fresh_install 条目
// last >= current 或版本非法 → 不生成(降级路径只保留系统告警,见 service 层)
func Select(last, current string, freshInstall bool) []Note {
return selectFrom(All(), last, current, freshInstall)
}
// selectFrom 是 Select 的纯函数内核,单测直接喂构造好的清单
func selectFrom(all []Note, last, current string, freshInstall bool) []Note {
if !semver.IsValid(current) {
return nil
}
out := make([]Note, 0, len(all))
if strings.TrimSpace(last) == "" {
for _, n := range all {
if freshInstall {
if n.FreshInstall {
out = append(out, n)
}
continue
}
if !n.FreshInstall && semver.Compare(n.Version, current) == 0 {
out = append(out, n)
}
}
return out
}
if !semver.IsValid(last) || semver.Compare(last, current) >= 0 {
return nil
}
for _, n := range all {
if n.FreshInstall {
continue
}
if semver.Compare(n.Version, last) > 0 && semver.Compare(n.Version, current) <= 0 {
out = append(out, n)
}
}
return out
}
+125
View File
@@ -0,0 +1,125 @@
package wafupgradenotice
import (
"os"
"strings"
"testing"
)
// TestNotesYAML 构建期校验内置清单:任何一条不合规都不许发出去。
//
// 这是本功能唯一的把关点——清单在运行期是"跳过非法条目"的容错策略,
// 真出了问题界面上只会少一条,不会报错,所以必须在这里拦住。
func TestNotesYAML(t *testing.T) {
notes, err := ParseAll()
if err != nil {
t.Fatalf("解析 upgrade_notes.yaml 失败: %v", err)
}
if len(notes) == 0 {
t.Fatal("内置清单为空")
}
seen := make(map[string]bool, len(notes))
for _, n := range notes {
if err := ValidateNote(n); err != nil {
t.Errorf("清单条目不合规: %v", err)
continue
}
if seen[n.Id] {
t.Errorf("条目 id 重复: %s(id 是幂等键,必须全局唯一)", n.Id)
}
seen[n.Id] = true
}
}
// TestNotesTextNotIdentical 中英文案必须真的是两套,不能把中文原样复制到 en。
func TestNotesTextNotIdentical(t *testing.T) {
notes, err := ParseAll()
if err != nil {
t.Fatalf("解析失败: %v", err)
}
for _, n := range notes {
if n.ZH.Title == n.EN.Title {
t.Errorf("[%s] 中英标题完全相同,en 文案疑似没翻译", n.Id)
}
if n.ZH.Detail == n.EN.Detail {
t.Errorf("[%s] 中英详情完全相同,en 文案疑似没翻译", n.Id)
}
}
}
// TestConfigSetItemExists 一键应用(v2)引用的配置项必须真实存在。
//
// 配置项在 waftask/task_config.go 里注册,这里直接扫源码做交叉比对:
// 清单里写错一个 item 名,v2 上线时就是"点了没反应",而且只有用户会发现。
func TestConfigSetItemExists(t *testing.T) {
notes, err := ParseAll()
if err != nil {
t.Fatalf("解析失败: %v", err)
}
var wanted []string
for _, n := range notes {
if n.Apply.Type == ApplyConfigSet && n.Apply.Item != "" {
wanted = append(wanted, n.Apply.Item)
}
}
if len(wanted) == 0 {
t.Skip("清单里暂无 config_set 条目")
}
src, err := os.ReadFile("../waftask/task_config.go")
if err != nil {
t.Fatalf("读取 task_config.go 失败: %v", err)
}
text := string(src)
for _, item := range wanted {
if !strings.Contains(text, `"`+item+`"`) {
t.Errorf("配置项 %q 在 waftask/task_config.go 里没有注册", item)
}
}
}
// TestValidateNoteRejectsBadInput 逐项验证校验规则本身没写漏。
func TestValidateNoteRejectsBadInput(t *testing.T) {
good := Note{
Id: "sample_note",
Version: "v1.3.24",
Kind: KindAction,
Level: LevelNormal,
Page: "/sys/SystemConfig",
Doc: "https://doc.samwaf.com/x.html",
Apply: Apply{Type: ApplyNavigate},
ZH: Text{Title: "标题", Detail: "详情", EffectOn: "开了会怎样", EffectOff: "不开的代价", Revert: "怎么撤"},
EN: Text{Title: "Title", Detail: "Detail", EffectOn: "on", EffectOff: "off", Revert: "revert"},
}
if err := ValidateNote(good); err != nil {
t.Fatalf("合法条目被判为不合法: %v", err)
}
cases := []struct {
name string
mutit func(n *Note)
}{
{"id 含大写", func(n *Note) { n.Id = "BadId" }},
{"version 非 semver", func(n *Note) { n.Version = "1.3.24" }},
{"kind 非法", func(n *Note) { n.Kind = "whatever" }},
{"level 非法", func(n *Note) { n.Level = "urgent" }},
{"apply.type 非法", func(n *Note) { n.Apply = Apply{Type: "exec"} }},
{"config_set 缺 item", func(n *Note) { n.Apply = Apply{Type: ApplyConfigSet, Value: "1"} }},
{"navigate 带 item", func(n *Note) { n.Apply = Apply{Type: ApplyNavigate, Item: "x", Value: "1"} }},
{"navigate 缺 page", func(n *Note) { n.Page = "" }},
{"page 是外链", func(n *Note) { n.Page = "https://evil.example.com" }},
{"doc 不是官方站", func(n *Note) { n.Doc = "https://evil.example.com/a" }},
{"action 缺 effect_off", func(n *Note) { n.ZH.EffectOff = "" }},
{"action 缺 revert(英文)", func(n *Note) { n.EN.Revert = "" }},
{"英文标题为空", func(n *Note) { n.EN.Title = "" }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n := good
c.mutit(&n)
if err := ValidateNote(n); err == nil {
t.Errorf("期望校验失败,实际通过了")
}
})
}
}
+138
View File
@@ -0,0 +1,138 @@
package wafupgradenotice
import (
"sort"
"strings"
"testing"
)
func fixture() []Note {
return []Note{
{Id: "fresh_a", Version: "v1.3.20", FreshInstall: true},
{Id: "fresh_b", Version: "v1.3.21", FreshInstall: true},
{Id: "n_21", Version: "v1.3.21"},
{Id: "n_22", Version: "v1.3.22"},
{Id: "n_23", Version: "v1.3.23"},
{Id: "n_24", Version: "v1.3.24"},
}
}
func ids(notes []Note) string {
out := make([]string, 0, len(notes))
for _, n := range notes {
out = append(out, n.Id)
}
sort.Strings(out)
return strings.Join(out, ",")
}
func TestSelectFrom(t *testing.T) {
cases := []struct {
name string
last string
current string
freshInstall bool
want string
}{
{"升级跨两个版本", "v1.3.21", "v1.3.23", false, "n_22,n_23"},
{"升级一个版本", "v1.3.22", "v1.3.23", false, "n_23"},
{"同版本重启不生成", "v1.3.23", "v1.3.23", false, ""},
{"降级不生成", "v1.3.24", "v1.3.22", false, ""},
{"全新安装只给新手清单", "", "v1.3.23", true, "fresh_a,fresh_b"},
{"老库无版本记录只给当前版本条目", "", "v1.3.23", false, "n_23"},
{"老库无版本记录且当前版本无条目", "", "v1.3.99", false, ""},
{"当前版本非法", "v1.3.21", "dev", false, ""},
{"上次版本非法当作无区间", "garbage", "v1.3.23", false, ""},
{"区间上界闭合", "v1.3.23", "v1.3.24", false, "n_24"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := ids(selectFrom(fixture(), c.last, c.current, c.freshInstall))
if got != c.want {
t.Errorf("last=%q current=%q fresh=%v\n got: %q\nwant: %q", c.last, c.current, c.freshInstall, got, c.want)
}
})
}
}
// TestSelectFromExcludesFreshOnUpgrade 升级路径下绝不能混进"全新安装建议",
// 否则老用户升个级就会被要求"立即修改初始口令"。
func TestSelectFromExcludesFreshOnUpgrade(t *testing.T) {
for _, n := range selectFrom(fixture(), "v1.3.19", "v1.3.24", false) {
if n.FreshInstall {
t.Errorf("升级路径混入了全新安装条目: %s", n.Id)
}
}
}
// TestNoteTextLang 语言回落:非 en 一律给中文
func TestNoteTextLang(t *testing.T) {
n := Note{
ZH: Text{Title: "中文"},
EN: Text{Title: "English"},
}
if got := n.Text("en_US").Title; got != "English" {
t.Errorf("en_US 应取英文,实际 %q", got)
}
if got := n.Text("zh_CN").Title; got != "中文" {
t.Errorf("zh_CN 应取中文,实际 %q", got)
}
if got := n.Text("").Title; got != "中文" {
t.Errorf("空语言应回落中文,实际 %q", got)
}
}
// TestAllSkipsInvalid 运行期加载必须只吐合法条目
func TestAllSkipsInvalid(t *testing.T) {
if err := LoadError(); err != nil {
t.Fatalf("内置清单加载失败: %v", err)
}
for _, n := range All() {
if err := ValidateNote(n); err != nil {
t.Errorf("All() 返回了不合法条目: %v", err)
}
}
}
// TestDisplayVersion 展示层砍掉 -beta.x 后缀:存精确值、显示干净值
func TestDisplayVersion(t *testing.T) {
cases := map[string]string{
"v1.3.24-beta.15": "v1.3.24",
"v1.3.24": "v1.3.24",
"v1.3.24-rc.1": "v1.3.24",
"v1.3": "v1.3.0", // Canonical 会补齐 patch 位
"garbage": "garbage",
"": "",
}
for in, want := range cases {
if got := DisplayVersion(in); got != want {
t.Errorf("DisplayVersion(%q) = %q, want %q", in, got, want)
}
}
}
// TestRawVersionsFor 下拉里选的展示版本要能还原回清单里的原始版本号,
// 否则库里存着 v1.3.24-beta.15、前端传 v1.3.24,过滤永远查不到东西。
func TestRawVersionsFor(t *testing.T) {
if got := RawVersionsFor(""); got != nil {
t.Errorf("空版本应返回 nil,实际 %v", got)
}
// 清单里没有的版本原样返回,让查询自然落空而不是退化成"不过滤"
got := RawVersionsFor("v9.9.9")
if len(got) != 1 || got[0] != "v9.9.9" {
t.Errorf("未知版本应原样返回,实际 %v", got)
}
// 内置清单里的真实版本必须能被自己的展示值还原
for _, n := range All() {
display := DisplayVersion(n.Version)
found := false
for _, raw := range RawVersionsFor(display) {
if raw == n.Version {
found = true
}
}
if !found {
t.Errorf("%s(%s) 的展示版本 %s 还原不回原始版本", n.Id, n.Version, display)
}
}
}
+133
View File
@@ -0,0 +1,133 @@
# 升级须知内置清单
#
# 随版本编译进二进制,不走网络。发版时按 SamWafTechDoc/Plan/2026-08-20-版本升级须知-实施计划.md §10
# 与 Update.md 的「重要说明」一起维护。
#
# 硬性要求(构建期单测 wafupgradenotice/notes_test.go 强制):
# 1. id 全局唯一、只含小写字母/数字/下划线,一旦发布不得修改(它是幂等键)
# 2. version 必须是合法 semver(形如 v1.3.24)
# 3. kind=action 时,中英两套的 effect_on / effect_off / revert 都必填,
# 且 effect_off 必须写清代价(磁盘、性能、可能误拦、可能掉线),不许只写好处
# 4. page 只能是站内相对路径,doc 只能指向 https://doc.samwaf.com/
# 5. fresh_install: true 的条目只在"全新安装"时生成,不参与版本区间计算
notes:
# ---------------- 全新安装建议 ----------------
- id: fresh_change_default_password
version: v1.3.21
kind: action
level: high
fresh_install: true
page: /account/Account
doc: https://doc.samwaf.com/quickstart/Update.html
apply:
type: navigate
zh:
title: 立即修改初始登录口令
detail: 全新安装的初始口令是一次性凭据,写在安装目录 data/initial_password.txt 里,同时也打印在启动日志中。
effect_on: 改完之后:初始口令立即失效,data/initial_password.txt 与启动日志里的那串字符再也登不进来。
effect_off: 不改:任何拿到过安装日志、备份包或服务器文件的人,都能直接登录管理端,等同于把 WAF 的控制权公开。
revert: 口令可以随时再改;忘记时可用后台命令行重置。
en:
title: Change the initial login password now
detail: A fresh install generates a one-time password, written to data/initial_password.txt under the install directory and also printed to the startup log.
effect_on: After changing it the initial password stops working immediately, so the string in data/initial_password.txt and in the startup log can no longer be used to log in.
effect_off: If you skip this, anyone who has ever seen the install log, a backup archive or the server filesystem can log into the console - effectively handing over control of the WAF.
revert: You can change the password again at any time; if you forget it, reset it from the command line.
- id: fresh_manage_trusted_proxies
version: v1.3.21
kind: check
level: high
fresh_install: true
page: /sys/SystemConfig
doc: https://doc.samwaf.com/quickstart/Update.html
apply:
type: navigate
zh:
title: 管理端前面有反向代理时,需要声明可信代理网段
detail: 如果管理端(默认 26666 端口)前面还有 Nginx、云负载或 CDN,SamWaf 默认只认直连来源 IP,取到的会是代理机的地址。请在 conf/config.yml 的 security.manage_trusted_proxies 或「参数设置」里填写可信代理网段(CIDR,逗号分隔)。管理端直接对外、前面没有任何代理时,不需要动。
effect_on: 配置后:管理端 IP 白名单、登录失败封禁、登录历史都基于真实客户端 IP 判定。
effect_off: 不配置:以上判定全部落在代理机 IP 上——IP 白名单形同虚设,一次爆破就可能把所有人一起挡在门外。
revert: 清空该配置即恢复为"只认直连 IP"。
en:
title: Declare trusted proxy ranges if the console sits behind a reverse proxy
detail: If Nginx, a cloud load balancer or a CDN sits in front of the console (port 26666 by default), SamWaf only trusts the direct peer address, which is the proxy's IP. Set security.manage_trusted_proxies in conf/config.yml (CIDR list, comma separated) or on the Parameter Settings page. Nothing to do if the console is exposed directly.
effect_on: Once configured, the console IP allowlist, login failure banning and login history all work off the real client IP.
effect_off: Left unset, all of those decisions are made against the proxy's IP - the IP allowlist becomes meaningless and a single brute-force burst can lock everyone out at once.
revert: Clearing the setting restores the "direct peer only" behaviour.
- id: fresh_enable_otp
version: v1.3.21
kind: action
level: normal
fresh_install: true
page: /account/OTP
doc: https://doc.samwaf.com/quickstart/Update.html
apply:
type: navigate
zh:
title: 建议为管理端开启两步验证(OTP)
detail: 给管理端登录加一层 TOTP 动态口令,支持任意标准验证器 App。
effect_on: 开启后:登录除口令外还需输入 6 位动态码,口令泄露也无法单独登录;请务必留存好绑定二维码/密钥,换手机时需要它。
effect_off: 保持关闭:口令一旦泄露(撞库、截图、备份外流),攻击者可以直接登录管理端。
revert: 可在「双因素认证」页解绑;密钥丢失时可用后台命令行重置。
en:
title: Turn on two-factor authentication for the console
detail: Adds a TOTP one-time code to console logins, compatible with any standard authenticator app.
effect_on: Once enabled, logging in requires a 6-digit code in addition to the password, so a leaked password alone is not enough. Keep the enrolment QR code or secret - you will need it when changing phones.
effect_off: Left off, a leaked password (credential stuffing, a screenshot, a stray backup) is enough for an attacker to log straight into the console.
revert: Unbind it on the two-factor page; if you lose the secret, reset it from the command line.
- id: fresh_notify_channel
version: v1.3.21
kind: action
level: normal
fresh_install: true
page: /notify/channel
doc: https://doc.samwaf.com/quickstart/Update.html
apply:
type: navigate
zh:
title: 建议至少配置一个通知渠道
detail: 钉钉、飞书、企业微信、Server 酱、邮件或自定义 Webhook 任选其一,用于接收攻击、证书到期、异常登录等告警。
effect_on: 配置后:告警会主动推送到你填写的地址;这会产生指向该地址的出网请求(地址、请求头、报文模板都由你自己填)。
effect_off: 不配置:所有告警只留在管理端里,需要你主动登录才看得到——证书到期、被打这类事往往就错过了。
revert: 在「通知渠道」页停用或删除即可,停用后不再发起任何相关出网请求。
en:
title: Configure at least one notification channel
detail: Pick DingTalk, Feishu, WeCom, ServerChan, e-mail or a custom webhook to receive attack, certificate expiry and abnormal login alerts.
effect_on: Once configured, alerts are pushed to the address you entered. This creates outbound requests to that address - the URL, headers and payload template are entirely yours to define.
effect_off: Without a channel, every alert stays inside the console and is only visible when you log in - which is exactly how expiring certificates and ongoing attacks get missed.
revert: Disable or delete the channel on the Notification Channel page; no related outbound request is made once it is off.
# ---------------- 版本升级须知 ----------------
- id: v1_3_21_password_bcrypt
version: v1.3.21
kind: notice
level: high
page: ''
doc: https://doc.samwaf.com/quickstart/Update.html
apply:
type: none
zh:
title: 登录口令存储方式由 MD5 切换为 bcrypt
detail: 向上升级无感知,登录方式与口令都不变。注意:如需回滚到 v1.3.21-beta.8 之前的版本,由于加密方式已切换,旧版本无法校验新格式的口令,需手工使用后台命令行重置密码。
en:
title: Console passwords are now stored with bcrypt instead of MD5
detail: Upgrading is transparent - the login flow and your password are unchanged. Note that if you roll back to a build older than v1.3.21-beta.8, that build cannot verify the new hash format and you will have to reset the password from the command line.
- id: v1_3_22_postgresql
version: v1.3.22
kind: notice
level: low
page: ''
doc: https://doc.samwaf.com/quickstart/Update.html
apply:
type: none
zh:
title: 数据库新增支持 PostgreSQL
detail: 自 v1.3.22 起 conf/config.yml 的 database.driver 可选 postgres。现有 SQLite / MySQL 部署完全不受影响,无需任何操作。
en:
title: PostgreSQL is now supported as a database backend
detail: From v1.3.22 you can set database.driver to postgres in conf/config.yml. Existing SQLite and MySQL deployments are unaffected and need no action.