mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-10-31 02:01:44 +08:00
feat: show the release version in upgrade notes
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -74,6 +74,7 @@ type ApiGroup struct {
|
||||
WafAppRouter
|
||||
WafAIRouter
|
||||
WafUIPreferenceRouter
|
||||
UpgradeNoticeRouter
|
||||
}
|
||||
type PublicApiGroup struct {
|
||||
LoginRouter
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 执行迁移
|
||||
|
||||
@@ -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
@@ -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不是初始化加载
|
||||
|
||||
@@ -8,6 +8,7 @@ var (
|
||||
wafSysLogService = waf_service.WafSysLogServiceApp
|
||||
wafSystemConfigService = waf_service.WafSystemConfigServiceApp
|
||||
wafLogService = waf_service.WafLogServiceApp
|
||||
wafUpgradeNoticeSvc = waf_service.WafUpgradeNoticeServiceApp
|
||||
)
|
||||
|
||||
type LastCounter struct {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("期望校验失败,实际通过了")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user