DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,290 @@
|
||||
package servergroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/platform/httpx"
|
||||
)
|
||||
|
||||
// AdminOptions 是服务器分组管理路由的存储、归档、延迟和包装器配置。
|
||||
type AdminOptions struct {
|
||||
Prefix string
|
||||
Store Store
|
||||
Archives MergeArchiveStore
|
||||
WarZoneInZoneDelaySecs int
|
||||
WarZoneNoticeLeadSecs int
|
||||
Wrap func(string, http.HandlerFunc) http.HandlerFunc
|
||||
WrapDangerous func(string, string, http.HandlerFunc) http.HandlerFunc
|
||||
}
|
||||
|
||||
const maxAdminJSONBytes = 1 << 20
|
||||
|
||||
// RegisterAdminRoutes 注册服务器分组查询、合服、分片操作、战区和回滚管理路由。
|
||||
func RegisterAdminRoutes(mux *http.ServeMux, manager *Manager, options AdminOptions) {
|
||||
if mux == nil || manager == nil {
|
||||
return
|
||||
}
|
||||
prefix := strings.TrimRight(strings.TrimSpace(options.Prefix), "/")
|
||||
if prefix == "" {
|
||||
prefix = "/debug/servergroup"
|
||||
}
|
||||
wrap := options.Wrap
|
||||
if wrap == nil {
|
||||
wrap = func(_ string, next http.HandlerFunc) http.HandlerFunc { return next }
|
||||
}
|
||||
wrapDangerous := options.WrapDangerous
|
||||
if wrapDangerous == nil {
|
||||
wrapDangerous = func(_ string, _ string, next http.HandlerFunc) http.HandlerFunc { return next }
|
||||
}
|
||||
regArchiveRoutes(mux, prefix, wrap, options.Archives)
|
||||
|
||||
mux.HandleFunc(prefix, wrap("servergroup", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
writeMethodNA(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
snapshot := manager.Snapshot()
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"plan": snapshot,
|
||||
"war_zones": WarZoneSnapshot(snapshot),
|
||||
})
|
||||
}))
|
||||
mux.HandleFunc(prefix+"/resolve", wrap("servergroup", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
writeMethodNA(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
feature := strings.TrimSpace(r.URL.Query().Get("feature"))
|
||||
shardID := strings.TrimSpace(r.URL.Query().Get("shard_id"))
|
||||
mode := strings.TrimSpace(r.URL.Query().Get("mode"))
|
||||
if mode != "" && mode != "read" && mode != "write" {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "mode must be read or write"})
|
||||
return
|
||||
}
|
||||
var (
|
||||
target Target
|
||||
ok bool
|
||||
err error
|
||||
)
|
||||
if mode == "write" {
|
||||
target, ok, err = manager.ResolveWrite(r.Context(), feature, shardID)
|
||||
} else {
|
||||
target, ok, err = manager.Resolve(r.Context(), feature, shardID)
|
||||
}
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"found": ok, "target": target})
|
||||
}))
|
||||
mux.HandleFunc(prefix+"/merge/dry-run", wrapDangerous("servergroup", httpx.AdminOperationID(http.MethodPost, prefix+"/merge/dry-run"), func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNA(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var request MergeRequest
|
||||
if err := decodeJSON(w, r, &request); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
report, err := DryRunMerge(r.Context(), manager.Snapshot(), request)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, report)
|
||||
}))
|
||||
mux.HandleFunc(prefix+"/merge/apply", wrapDangerous("servergroup", httpx.AdminOperationID(http.MethodPost, prefix+"/merge/apply"), func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNA(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var request MergeRequest
|
||||
if err := decodeJSON(w, r, &request); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result, err := prepareMergeApply(r.Context(), manager.Snapshot(), request)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if options.Store != nil {
|
||||
if err := options.Store.Save(r.Context(), result.DryRun.Next); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := manager.Replace(context.Background(), result.DryRun.Next); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, result)
|
||||
}))
|
||||
mux.HandleFunc(prefix+"/shard/operation/dry-run", wrapDangerous("servergroup", httpx.AdminOperationID(http.MethodPost, prefix+"/shard/operation/dry-run"), func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNA(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var request ShardOperationRequest
|
||||
if err := decodeJSON(w, r, &request); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
report, err := DryRunShardOperation(r.Context(), manager.Snapshot(), request)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, report)
|
||||
}))
|
||||
mux.HandleFunc(prefix+"/shard/operation/apply", wrapDangerous("servergroup", httpx.AdminOperationID(http.MethodPost, prefix+"/shard/operation/apply"), func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNA(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var request ShardOperationRequest
|
||||
if err := decodeJSON(w, r, &request); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result, err := DryRunShardOperation(r.Context(), manager.Snapshot(), request)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if options.Store != nil {
|
||||
if err := options.Store.Save(r.Context(), result.Next); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := manager.Replace(context.Background(), result.Next); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, result)
|
||||
}))
|
||||
mux.HandleFunc(prefix+"/warzone/refresh", wrapDangerous("servergroup", httpx.AdminOperationID(http.MethodPost, prefix+"/warzone/refresh"), func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNA(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var request WarZoneRefreshRequest
|
||||
if err := decodeJSON(w, r, &request); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
request = applyWarZoneDefaults(request, options)
|
||||
apply := strings.EqualFold(r.URL.Query().Get("apply"), "true")
|
||||
if !apply {
|
||||
report, err := DryRunWarZoneRefresh(r.Context(), manager.Snapshot(), request)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, report)
|
||||
return
|
||||
}
|
||||
report, err := DryRunWarZoneRefresh(r.Context(), manager.Snapshot(), request)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if options.Store != nil {
|
||||
if err := options.Store.Save(r.Context(), report.Next); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := manager.Replace(context.Background(), report.Next); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, report)
|
||||
}))
|
||||
mux.HandleFunc(prefix+"/rollback/apply", wrapDangerous("servergroup", httpx.AdminOperationID(http.MethodPost, prefix+"/rollback/apply"), func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNA(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var request RollbackApplyRequest
|
||||
if err := decodeJSON(w, r, &request); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result, err := PrepareRollbackApply(r.Context(), manager.Snapshot(), request)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, ErrRollbackVersionMismatch) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if options.Store != nil {
|
||||
if err := options.Store.Save(r.Context(), result.Restored); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := manager.Replace(context.Background(), result.Restored); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result.Applied = true
|
||||
httpx.WriteJSON(w, http.StatusOK, result)
|
||||
}))
|
||||
mux.HandleFunc(prefix+"/save", wrapDangerous("servergroup", httpx.AdminOperationID(http.MethodPost, prefix+"/save"), func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNA(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
if options.Store == nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": ErrStoreEmpty.Error()})
|
||||
return
|
||||
}
|
||||
if err := options.Store.Save(r.Context(), manager.Snapshot()); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]string{"status": "saved"})
|
||||
}))
|
||||
}
|
||||
|
||||
func applyWarZoneDefaults(request WarZoneRefreshRequest, options AdminOptions) WarZoneRefreshRequest {
|
||||
if request.InZoneDelaySeconds <= 0 && options.WarZoneInZoneDelaySecs > 0 {
|
||||
request.InZoneDelaySeconds = options.WarZoneInZoneDelaySecs
|
||||
}
|
||||
if request.NoticeLeadSeconds <= 0 && options.WarZoneNoticeLeadSecs > 0 {
|
||||
request.NoticeLeadSeconds = options.WarZoneNoticeLeadSecs
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) error {
|
||||
return httpx.DecodeStrictJSON(w, r, maxAdminJSONBytes, target)
|
||||
}
|
||||
|
||||
func writeMethodNA(w http.ResponseWriter, allowed string) {
|
||||
if allowed == http.MethodGet {
|
||||
allowed = http.MethodGet + ", " + http.MethodHead
|
||||
}
|
||||
w.Header().Set("Allow", allowed)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
package servergroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidPlan 表示服务器分组计划缺少分片、分组或路由关系。
|
||||
ErrInvalidPlan = errors.New("server group plan is invalid")
|
||||
// ErrNotFound 表示未找到可用的服务器分组路由。
|
||||
ErrNotFound = errors.New("server group route not found")
|
||||
)
|
||||
|
||||
const (
|
||||
// StateOpen 表示分片或分组处于可服务状态。
|
||||
StateOpen = "open"
|
||||
// StateClosed 表示分片或分组已关闭。
|
||||
StateClosed = "closed"
|
||||
// StateDraining 表示分片或分组正在排空。
|
||||
StateDraining = "draining"
|
||||
)
|
||||
|
||||
const (
|
||||
// WarZoneStatusNotStart 表示战区尚未进入开启流程。
|
||||
WarZoneStatusNotStart = "not_start"
|
||||
// WarZoneStatusNotOpen 表示战区当前不可开放。
|
||||
WarZoneStatusNotOpen = "not_open"
|
||||
// WarZoneStatusNotNotice 表示战区尚未进入公告期。
|
||||
WarZoneStatusNotNotice = "not_notice"
|
||||
// WarZoneStatusNoticed 表示战区已进入公告期。
|
||||
WarZoneStatusNoticed = "noticed"
|
||||
// WarZoneStatusOpened 表示战区已开放。
|
||||
WarZoneStatusOpened = "opened"
|
||||
// WarZoneStatusNoConfig 表示战区缺少有效配置。
|
||||
WarZoneStatusNoConfig = "no_config"
|
||||
// WarZoneStatusReverted 表示战区状态已回退。
|
||||
WarZoneStatusReverted = "reverted"
|
||||
)
|
||||
|
||||
// Plan 是服务器分组、分片、战区、合服组和路由的完整配置快照。
|
||||
type Plan struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
Shards []Shard `json:"shards,omitempty"`
|
||||
Groups []Group `json:"groups,omitempty"`
|
||||
WarZones []WarZone `json:"war_zones,omitempty"`
|
||||
MergeGroups []MergeGroup `json:"merge_groups,omitempty"`
|
||||
Routes []Route `json:"routes,omitempty"`
|
||||
Conflicts []Conflict `json:"conflicts,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// Shard 是单个逻辑分片的状态和路由归属。
|
||||
type Shard struct {
|
||||
ID string `json:"id"`
|
||||
GroupID string `json:"group_id"`
|
||||
State string `json:"state,omitempty"`
|
||||
OpenAt time.Time `json:"open_at,omitempty"`
|
||||
PublicOpenAt time.Time `json:"public_open_at,omitempty"`
|
||||
WarZoneID string `json:"warzone_id,omitempty"`
|
||||
MergeGroupID string `json:"merge_group_id,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// Group 是承载一组分片的服务分组。
|
||||
type Group struct {
|
||||
ID string `json:"id"`
|
||||
Service string `json:"service,omitempty"`
|
||||
MemberID string `json:"member_id,omitempty"`
|
||||
Shards []string `json:"shards,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Weight int `json:"weight,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// WarZone 是跨分片战区的开启状态和成员分片集合。
|
||||
type WarZone struct {
|
||||
ID string `json:"id"`
|
||||
LeaderGroupID string `json:"leader_group_id,omitempty"`
|
||||
Shards []string `json:"shards,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
SeasonID string `json:"season_id,omitempty"`
|
||||
NoticeAt time.Time `json:"notice_at,omitempty"`
|
||||
OpenAt time.Time `json:"open_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// MergeGroup 描述合服后的主分片和被合并分片集合。
|
||||
type MergeGroup struct {
|
||||
ID string `json:"id"`
|
||||
MainShardID string `json:"main_shard_id"`
|
||||
Shards []string `json:"shards,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// Route 是功能和分片到服务分组的读写路由。
|
||||
type Route struct {
|
||||
Feature string `json:"feature"`
|
||||
ShardID string `json:"shard_id"`
|
||||
GroupID string `json:"group_id"`
|
||||
Service string `json:"service,omitempty"`
|
||||
MemberID string `json:"member_id,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// Conflict 描述合服或迁移期间需要阻断的功能路由。
|
||||
type Conflict struct {
|
||||
Feature string `json:"feature"`
|
||||
ShardID string `json:"shard_id,omitempty"`
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Target 是解析路由后的目标分组和可用性结果。
|
||||
type Target struct {
|
||||
Feature string `json:"feature,omitempty"`
|
||||
ShardID string `json:"shard_id,omitempty"`
|
||||
GroupID string `json:"group_id"`
|
||||
Service string `json:"service,omitempty"`
|
||||
MemberID string `json:"member_id,omitempty"`
|
||||
Shards []string `json:"shards,omitempty"`
|
||||
WarZoneID string `json:"warzone_id,omitempty"`
|
||||
MergeGroupID string `json:"merge_group_id,omitempty"`
|
||||
MainShardID string `json:"main_shard_id,omitempty"`
|
||||
RedirectShardID string `json:"redirect_shard_id,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// Snapshot 是服务器分组计划的只读快照。
|
||||
type Snapshot = Plan
|
||||
|
||||
// Change 描述服务器分组计划版本变化。
|
||||
type Change struct {
|
||||
PreviousVersion string `json:"previous_version,omitempty"`
|
||||
CurrentVersion string `json:"current_version,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Manager 维护服务器分组计划、索引和变更订阅。
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
plan Plan
|
||||
index planIndex
|
||||
watchers map[chan Change]struct{}
|
||||
}
|
||||
|
||||
// New 创建服务器分组管理器并构建路由索引。
|
||||
func New(plan Plan) (*Manager, error) {
|
||||
normalized, index, err := normalizePlan(plan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Manager{
|
||||
plan: normalized,
|
||||
index: index,
|
||||
watchers: make(map[chan Change]struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate 校验服务器分组计划并构建临时索引。
|
||||
func Validate(plan Plan) error {
|
||||
_, _, err := normalizePlan(plan)
|
||||
return err
|
||||
}
|
||||
|
||||
// Replace 原子替换服务器分组计划并通知订阅者。
|
||||
func (m *Manager) Replace(ctx context.Context, plan Plan) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
normalized, index, err := normalizePlan(plan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
previous := m.plan.Version
|
||||
m.plan = normalized
|
||||
m.index = index
|
||||
change := Change{PreviousVersion: previous, CurrentVersion: normalized.Version, UpdatedAt: normalized.UpdatedAt}
|
||||
for ch := range m.watchers {
|
||||
select {
|
||||
case ch <- change:
|
||||
default:
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshot 返回当前服务器分组计划快照。
|
||||
func (m *Manager) Snapshot() Snapshot {
|
||||
if m == nil {
|
||||
return Snapshot{}
|
||||
}
|
||||
m.mu.RLock()
|
||||
snapshot := clonePlan(m.plan)
|
||||
m.mu.RUnlock()
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// Resolve 按功能和分片解析读路由目标。
|
||||
func (m *Manager) Resolve(ctx context.Context, feature, shardID string) (Target, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Target{}, false, err
|
||||
}
|
||||
if m == nil {
|
||||
return Target{}, false, ErrNotFound
|
||||
}
|
||||
feature = normalizeID(feature)
|
||||
shardID = normalizeID(shardID)
|
||||
if feature == "" || shardID == "" {
|
||||
return Target{}, false, ErrNotFound
|
||||
}
|
||||
m.mu.RLock()
|
||||
target, ok := m.index.resolve(feature, shardID)
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return Target{}, false, ErrNotFound
|
||||
}
|
||||
return target, true, nil
|
||||
}
|
||||
|
||||
// ResolveWrite 按功能和分片解析写路由目标,并处理合服重定向。
|
||||
func (m *Manager) ResolveWrite(ctx context.Context, feature, shardID string) (Target, bool, error) {
|
||||
target, ok, err := m.Resolve(ctx, feature, shardID)
|
||||
if err != nil || !ok {
|
||||
return target, ok, err
|
||||
}
|
||||
if target.MainShardID != "" && target.ShardID != target.MainShardID {
|
||||
target.Available = false
|
||||
target.RedirectShardID = target.MainShardID
|
||||
target.Reason = "merged_to:" + target.MainShardID
|
||||
}
|
||||
return target, ok, nil
|
||||
}
|
||||
|
||||
// ResolveGroup 按分组 ID 解析服务分组目标。
|
||||
func (m *Manager) ResolveGroup(ctx context.Context, groupID string) (Target, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Target{}, false, err
|
||||
}
|
||||
if m == nil {
|
||||
return Target{}, false, ErrNotFound
|
||||
}
|
||||
groupID = normalizeID(groupID)
|
||||
if groupID == "" {
|
||||
return Target{}, false, ErrNotFound
|
||||
}
|
||||
m.mu.RLock()
|
||||
group, ok := m.index.groups[groupID]
|
||||
if !ok {
|
||||
m.mu.RUnlock()
|
||||
return Target{}, false, ErrNotFound
|
||||
}
|
||||
target := targetFromGroup(group)
|
||||
m.mu.RUnlock()
|
||||
return target, true, nil
|
||||
}
|
||||
|
||||
// Watch 订阅服务器分组计划变更。
|
||||
func (m *Manager) Watch(ctx context.Context, buffer int) (<-chan Change, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if m == nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if buffer < 0 {
|
||||
buffer = 0
|
||||
}
|
||||
ch := make(chan Change, buffer)
|
||||
m.mu.Lock()
|
||||
if m.watchers == nil {
|
||||
m.watchers = make(map[chan Change]struct{})
|
||||
}
|
||||
m.watchers[ch] = struct{}{}
|
||||
m.mu.Unlock()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
m.mu.Lock()
|
||||
if _, ok := m.watchers[ch]; ok {
|
||||
delete(m.watchers, ch)
|
||||
close(ch)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
type planIndex struct {
|
||||
shards map[string]Shard
|
||||
groups map[string]Group
|
||||
warZones map[string]WarZone
|
||||
mergeGroups map[string]MergeGroup
|
||||
routes map[string]Route
|
||||
conflicts map[string]Conflict
|
||||
}
|
||||
|
||||
func (idx planIndex) resolve(feature, shardID string) (Target, bool) {
|
||||
shard, ok := idx.shards[shardID]
|
||||
if !ok {
|
||||
return Target{}, false
|
||||
}
|
||||
groupID := shard.GroupID
|
||||
route, hasRoute := idx.routes[routeKey(feature, shardID)]
|
||||
if hasRoute {
|
||||
groupID = route.GroupID
|
||||
}
|
||||
group, ok := idx.groups[groupID]
|
||||
if !ok {
|
||||
return Target{}, false
|
||||
}
|
||||
target := targetFromGroup(group)
|
||||
target.Feature = feature
|
||||
target.ShardID = shardID
|
||||
target.WarZoneID = shard.WarZoneID
|
||||
target.MergeGroupID = shard.MergeGroupID
|
||||
if hasRoute {
|
||||
if route.Service != "" {
|
||||
target.Service = route.Service
|
||||
}
|
||||
if route.MemberID != "" {
|
||||
target.MemberID = route.MemberID
|
||||
}
|
||||
target.Meta = mergeMeta(target.Meta, route.Meta)
|
||||
}
|
||||
if target.Meta == nil {
|
||||
target.Meta = cloneStringMap(shard.Meta)
|
||||
} else {
|
||||
target.Meta = mergeMeta(target.Meta, shard.Meta)
|
||||
}
|
||||
if shard.WarZoneID != "" {
|
||||
if warZone, ok := idx.warZones[shard.WarZoneID]; ok {
|
||||
target.Meta = mergeMeta(target.Meta, prefixedMeta("warzone.", warZone.Meta))
|
||||
if warZone.SeasonID != "" {
|
||||
target.Meta = mergeMeta(target.Meta, map[string]string{"warzone.season_id": warZone.SeasonID})
|
||||
}
|
||||
if warZone.Status != "" {
|
||||
target.Meta = mergeMeta(target.Meta, map[string]string{"warzone.status": warZone.Status})
|
||||
}
|
||||
if !warZone.NoticeAt.IsZero() {
|
||||
target.Meta = mergeMeta(target.Meta, map[string]string{"warzone.notice_at": warZone.NoticeAt.Format(time.RFC3339)})
|
||||
}
|
||||
if !warZone.OpenAt.IsZero() {
|
||||
target.Meta = mergeMeta(target.Meta, map[string]string{"warzone.open_at": warZone.OpenAt.Format(time.RFC3339)})
|
||||
}
|
||||
if warZone.Status != "" && warZone.Status != WarZoneStatusOpened {
|
||||
target.Available = false
|
||||
target.Reason = "warzone_status:" + warZone.Status
|
||||
}
|
||||
if warZone.State != StateOpen && (warZone.Status == "" || warZone.Status == WarZoneStatusOpened) {
|
||||
target.Available = false
|
||||
target.Reason = "warzone_state:" + warZone.State
|
||||
}
|
||||
}
|
||||
}
|
||||
if shard.MergeGroupID != "" {
|
||||
if mergeGroup, ok := idx.mergeGroups[shard.MergeGroupID]; ok {
|
||||
target.MainShardID = mergeGroup.MainShardID
|
||||
target.Meta = mergeMeta(target.Meta, prefixedMeta("merge.", mergeGroup.Meta))
|
||||
if mergeGroup.State != StateOpen {
|
||||
target.Available = false
|
||||
target.Reason = "merge_state:" + mergeGroup.State
|
||||
}
|
||||
}
|
||||
}
|
||||
if shard.State != StateOpen {
|
||||
target.Available = false
|
||||
target.Reason = "shard_state:" + shard.State
|
||||
}
|
||||
if hasRoute && route.State != StateOpen {
|
||||
target.Available = false
|
||||
target.Reason = "route_state:" + route.State
|
||||
}
|
||||
if conflict, blocked := idx.conflicts[conflictKey(feature, shardID, "")]; blocked {
|
||||
target.Available = false
|
||||
target.Reason = firstNonEmpty(conflict.Reason, "shard_conflict")
|
||||
}
|
||||
if conflict, blocked := idx.conflicts[conflictKey(feature, "", group.ID)]; blocked {
|
||||
target.Available = false
|
||||
target.Reason = firstNonEmpty(conflict.Reason, "group_conflict")
|
||||
}
|
||||
return target, true
|
||||
}
|
||||
|
||||
func mergeMeta(base map[string]string, overlays ...map[string]string) map[string]string {
|
||||
out := cloneStringMap(base)
|
||||
for _, overlay := range overlays {
|
||||
for key, value := range overlay {
|
||||
if out == nil {
|
||||
out = make(map[string]string)
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func prefixedMeta(prefix string, values map[string]string) map[string]string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
out[prefix+key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ctxErr(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package servergroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MergeRequest 是一次合服操作的目标主分片、参与分片和检查范围。
|
||||
type MergeRequest struct {
|
||||
ID string `json:"id"`
|
||||
MainShardID string `json:"main_shard_id"`
|
||||
Shards []string `json:"shards"`
|
||||
State string `json:"state,omitempty"`
|
||||
NextVersion string `json:"next_version,omitempty"`
|
||||
BlockFeatures []string `json:"block_features,omitempty"`
|
||||
CheckFeatures []string `json:"check_features,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
OperatorID string `json:"operator_id,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// MergeDryRun 是合服预演生成的新计划、回滚点和写路由检查结果。
|
||||
type MergeDryRun struct {
|
||||
Request MergeRequest `json:"request"`
|
||||
Valid bool `json:"valid"`
|
||||
Next Plan `json:"next"`
|
||||
Rollback Plan `json:"rollback"`
|
||||
RollbackPoint RollbackPoint `json:"rollback_point"`
|
||||
AddedConflicts []Conflict `json:"added_conflicts,omitempty"`
|
||||
WriteChecks []Target `json:"write_checks,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// MergeApplyResult 是合服正式应用后的计划变更和执行步骤。
|
||||
type MergeApplyResult struct {
|
||||
Request MergeRequest `json:"request"`
|
||||
Applied bool `json:"applied"`
|
||||
DryRun MergeDryRun `json:"dry_run"`
|
||||
Change Change `json:"change"`
|
||||
Execution MergeExecutionPlan `json:"execution"`
|
||||
Rollback Plan `json:"rollback"`
|
||||
RollbackPoint RollbackPoint `json:"rollback_point"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// MergeExecutionPlan 描述合服执行时需要项目侧按阶段完成的步骤。
|
||||
type MergeExecutionPlan struct {
|
||||
ID string `json:"id"`
|
||||
MainShard string `json:"main_shard"`
|
||||
Shards []string `json:"shards"`
|
||||
Steps []MergeExecutionStep `json:"steps"`
|
||||
}
|
||||
|
||||
// MergeExecutionStep 是合服执行计划中的单个步骤。
|
||||
type MergeExecutionStep struct {
|
||||
Name string `json:"name"`
|
||||
Phase string `json:"phase"`
|
||||
Required bool `json:"required"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Checks []string `json:"checks,omitempty"`
|
||||
}
|
||||
|
||||
// DryRunMerge 预演合服计划并生成回滚点,不修改当前管理器。
|
||||
func DryRunMerge(ctx context.Context, current Plan, request MergeRequest) (MergeDryRun, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return MergeDryRun{}, err
|
||||
}
|
||||
request = normMergeReq(request)
|
||||
if request.ID == "" || request.MainShardID == "" || len(request.Shards) == 0 {
|
||||
return MergeDryRun{}, fmt.Errorf("%w: merge request id, main shard and shards are required", ErrInvalidPlan)
|
||||
}
|
||||
if !containsID(request.Shards, request.MainShardID) {
|
||||
return MergeDryRun{}, fmt.Errorf("%w: merge request %s does not include main shard %s", ErrInvalidPlan, request.ID, request.MainShardID)
|
||||
}
|
||||
normalized, _, err := normalizePlan(current)
|
||||
if err != nil {
|
||||
return MergeDryRun{}, err
|
||||
}
|
||||
next := clonePlan(normalized)
|
||||
next.Version = mergeNextVersion(next.Version, request.ID, request.NextVersion)
|
||||
next.UpdatedAt = firstTime(request.UpdatedAt, time.Now().UTC())
|
||||
next.MergeGroups = append(next.MergeGroups, MergeGroup{
|
||||
ID: request.ID,
|
||||
MainShardID: request.MainShardID,
|
||||
Shards: append([]string(nil), request.Shards...),
|
||||
State: request.State,
|
||||
UpdatedAt: request.UpdatedAt,
|
||||
Meta: cloneStringMap(request.Meta),
|
||||
})
|
||||
addedConflicts := mergeReqConflicts(request)
|
||||
next.Conflicts = append(next.Conflicts, addedConflicts...)
|
||||
manager, err := New(next)
|
||||
if err != nil {
|
||||
return MergeDryRun{}, err
|
||||
}
|
||||
writeChecks, err := mergeWriteChecks(ctx, manager, request)
|
||||
if err != nil {
|
||||
return MergeDryRun{}, err
|
||||
}
|
||||
normalizedNext := manager.Snapshot()
|
||||
rollbackPoint := newRollbackPoint(RollbackOperationMerge, normalized.Version, normalizedNext.Version, request.Reason, request.OperatorID, normalized, normalizedNext.UpdatedAt)
|
||||
return MergeDryRun{
|
||||
Request: request,
|
||||
Valid: true,
|
||||
Next: normalizedNext,
|
||||
Rollback: normalized,
|
||||
RollbackPoint: rollbackPoint,
|
||||
AddedConflicts: addedConflicts,
|
||||
WriteChecks: writeChecks,
|
||||
Warnings: mergeWarnings(request, writeChecks),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ApplyMerge 校验并应用合服计划到管理器。
|
||||
func ApplyMerge(ctx context.Context, manager *Manager, request MergeRequest) (MergeApplyResult, error) {
|
||||
if manager == nil {
|
||||
return MergeApplyResult{}, ErrNotFound
|
||||
}
|
||||
result, err := prepareMergeApply(ctx, manager.Snapshot(), request)
|
||||
if err != nil {
|
||||
return MergeApplyResult{}, err
|
||||
}
|
||||
if err := manager.Replace(ctx, result.DryRun.Next); err != nil {
|
||||
return MergeApplyResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func prepareMergeApply(ctx context.Context, current Plan, request MergeRequest) (MergeApplyResult, error) {
|
||||
previous, _, err := normalizePlan(current)
|
||||
if err != nil {
|
||||
return MergeApplyResult{}, err
|
||||
}
|
||||
report, err := DryRunMerge(ctx, previous, request)
|
||||
if err != nil {
|
||||
return MergeApplyResult{}, err
|
||||
}
|
||||
if len(report.Warnings) > 0 {
|
||||
return MergeApplyResult{}, fmt.Errorf("%w: merge has warnings: %v", ErrInvalidPlan, report.Warnings)
|
||||
}
|
||||
change := Change{
|
||||
PreviousVersion: previous.Version,
|
||||
CurrentVersion: report.Next.Version,
|
||||
UpdatedAt: report.Next.UpdatedAt,
|
||||
}
|
||||
return MergeApplyResult{
|
||||
Request: report.Request,
|
||||
Applied: true,
|
||||
DryRun: report,
|
||||
Change: change,
|
||||
Execution: mergeExecutionPlan(report.Request),
|
||||
Rollback: report.Rollback,
|
||||
RollbackPoint: report.RollbackPoint,
|
||||
Warnings: report.Warnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normMergeReq(request MergeRequest) MergeRequest {
|
||||
request.ID = normalizeID(request.ID)
|
||||
request.MainShardID = normalizeID(request.MainShardID)
|
||||
request.Shards = normalizeIDs(request.Shards)
|
||||
request.State = normalizeState(request.State)
|
||||
request.NextVersion = normalizeID(request.NextVersion)
|
||||
request.BlockFeatures = normalizeIDs(request.BlockFeatures)
|
||||
request.CheckFeatures = normalizeIDs(request.CheckFeatures)
|
||||
request.Reason = firstNonEmpty(request.Reason, "merge_dry_run:"+request.ID)
|
||||
request.OperatorID = firstNonEmpty(request.OperatorID)
|
||||
request.Meta = normalizeStringMap(request.Meta)
|
||||
request.UpdatedAt = normalizeTime(request.UpdatedAt)
|
||||
return request
|
||||
}
|
||||
|
||||
func mergeReqConflicts(request MergeRequest) []Conflict {
|
||||
if len(request.BlockFeatures) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Conflict, 0, len(request.BlockFeatures)*len(request.Shards))
|
||||
for _, feature := range request.BlockFeatures {
|
||||
for _, shardID := range request.Shards {
|
||||
if shardID == request.MainShardID {
|
||||
continue
|
||||
}
|
||||
out = append(out, Conflict{
|
||||
Feature: feature,
|
||||
ShardID: shardID,
|
||||
Reason: request.Reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeWriteChecks(ctx context.Context, manager *Manager, request MergeRequest) ([]Target, error) {
|
||||
features := request.CheckFeatures
|
||||
if len(features) == 0 {
|
||||
features = request.BlockFeatures
|
||||
}
|
||||
if len(features) == 0 {
|
||||
features = []string{"default"}
|
||||
}
|
||||
checks := make([]Target, 0, len(features)*len(request.Shards))
|
||||
for _, feature := range features {
|
||||
for _, shardID := range request.Shards {
|
||||
target, ok, err := manager.ResolveWrite(ctx, feature, shardID)
|
||||
if err != nil || !ok {
|
||||
return nil, err
|
||||
}
|
||||
checks = append(checks, target)
|
||||
}
|
||||
}
|
||||
return checks, nil
|
||||
}
|
||||
|
||||
func mergeWarnings(request MergeRequest, checks []Target) []string {
|
||||
var warnings []string
|
||||
for _, check := range checks {
|
||||
if check.ShardID == request.MainShardID && !check.Available {
|
||||
warnings = append(warnings, "main shard write check is unavailable:"+check.Reason)
|
||||
}
|
||||
if check.ShardID != request.MainShardID && check.RedirectShardID != request.MainShardID {
|
||||
warnings = append(warnings, "merged shard does not redirect to main:"+check.ShardID)
|
||||
}
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
func mergeNextVersion(currentVersion, mergeID, requested string) string {
|
||||
if requested = normalizeID(requested); requested != "" {
|
||||
return requested
|
||||
}
|
||||
currentVersion = normalizeID(currentVersion)
|
||||
mergeID = normalizeID(mergeID)
|
||||
switch {
|
||||
case currentVersion == "":
|
||||
return mergeID
|
||||
case mergeID == "":
|
||||
return currentVersion
|
||||
default:
|
||||
return currentVersion + "-" + mergeID
|
||||
}
|
||||
}
|
||||
|
||||
func mergeExecutionPlan(request MergeRequest) MergeExecutionPlan {
|
||||
return MergeExecutionPlan{
|
||||
ID: request.ID,
|
||||
MainShard: request.MainShardID,
|
||||
Shards: append([]string(nil), request.Shards...),
|
||||
Steps: []MergeExecutionStep{
|
||||
{
|
||||
Name: "freeze_non_main_writes",
|
||||
Phase: "prepare",
|
||||
Required: true,
|
||||
Description: "block critical writes on non-main shards before data movement",
|
||||
Checks: append([]string(nil), request.BlockFeatures...),
|
||||
},
|
||||
{
|
||||
Name: "publish_merge_group",
|
||||
Phase: "route",
|
||||
Required: true,
|
||||
Description: "publish MergeGroup and route metadata into the server group plan",
|
||||
},
|
||||
{
|
||||
Name: "verify_write_redirects",
|
||||
Phase: "verify",
|
||||
Required: true,
|
||||
Description: "ResolveWrite must keep main shard writable and redirect non-main shards",
|
||||
Checks: append([]string(nil), request.CheckFeatures...),
|
||||
},
|
||||
{
|
||||
Name: "persist_rollback_snapshot",
|
||||
Phase: "rollback",
|
||||
Required: true,
|
||||
Description: "retain DryRun.Rollback until data verification and customer support window finish",
|
||||
},
|
||||
{
|
||||
Name: "release_blocked_features",
|
||||
Phase: "finish",
|
||||
Required: false,
|
||||
Description: "remove temporary conflicts after data merge jobs and business verification pass",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func firstTime(values ...time.Time) time.Time {
|
||||
for _, value := range values {
|
||||
if !value.IsZero() {
|
||||
return value.UTC()
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package servergroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/admincmd"
|
||||
"longheng.io/server/internal/platform/adminworkflow"
|
||||
)
|
||||
|
||||
// MergeAdminOperation 是合服工作流接入 admincmd 的操作名。
|
||||
const MergeAdminOperation = "servergroup.merge.workflow"
|
||||
|
||||
// MergeAdminOptions 描述合服工作流接入 Admin 工作流状态机所需依赖。
|
||||
type MergeAdminOptions struct {
|
||||
Runner *MergeRunner
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// MergeAdminAdapter 把合服 runner 适配为 adminworkflow 的 Previewer 和 Executor。
|
||||
type MergeAdminAdapter struct {
|
||||
runner *MergeRunner
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
var _ adminworkflow.Previewer = (*MergeAdminAdapter)(nil)
|
||||
var _ adminworkflow.Executor = (*MergeAdminAdapter)(nil)
|
||||
|
||||
// NewMergeAdminAdapter 创建 Admin 工作流适配器。
|
||||
func NewMergeAdminAdapter(options MergeAdminOptions) (*MergeAdminAdapter, error) {
|
||||
if options.Runner == nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &MergeAdminAdapter{runner: options.Runner, now: now}, nil
|
||||
}
|
||||
|
||||
// MergeCmdFromAdmin 把 admincmd.Command 转为合服工作流命令。
|
||||
func MergeCmdFromAdmin(command admincmd.Command) (MergeCmd, error) {
|
||||
command = admincmd.Normalize(command)
|
||||
if command.Operation != MergeAdminOperation {
|
||||
return MergeCmd{}, fmt.Errorf("%w: admin command operation %s is not %s", ErrInvalidMigration, command.Operation, MergeAdminOperation)
|
||||
}
|
||||
workflowCommand, err := MergeCmdFromParams(command.Params)
|
||||
if err != nil {
|
||||
return MergeCmd{}, err
|
||||
}
|
||||
workflowCommand.IdempotencyKey = firstNonEmpty(workflowCommand.IdempotencyKey, command.IdempotencyKey)
|
||||
workflowCommand.OperatorID = firstNonEmpty(workflowCommand.OperatorID, command.Actor)
|
||||
workflowCommand.Reason = firstNonEmpty(workflowCommand.Reason, command.Reason)
|
||||
workflowCommand.Meta = mergeArchiveMeta(workflowCommand, map[string]string{
|
||||
"admin_operation": command.Operation,
|
||||
"admin_scope": command.Scope,
|
||||
"admin_target": command.Target,
|
||||
"admin_environment": command.Environment,
|
||||
"admin_shard_id": command.ShardID,
|
||||
})
|
||||
return normalizeMergeCmd(workflowCommand)
|
||||
}
|
||||
|
||||
// DryRunAdminWorkflow 生成合服工作流预览,不执行真实写入。
|
||||
func (a *MergeAdminAdapter) DryRunAdminWorkflow(ctx context.Context, command admincmd.Command) (adminworkflow.DryRun, error) {
|
||||
if a == nil || a.runner == nil {
|
||||
return adminworkflow.DryRun{}, ErrNotFound
|
||||
}
|
||||
workflowCommand, err := MergeCmdFromAdmin(command)
|
||||
if err != nil {
|
||||
return adminworkflow.DryRun{}, err
|
||||
}
|
||||
result, err := a.runner.Preview(ctx, workflowCommand)
|
||||
if err != nil {
|
||||
return adminworkflow.DryRun{}, err
|
||||
}
|
||||
return adminDryRunResult(result), nil
|
||||
}
|
||||
|
||||
// ExecuteAdminWorkflow 执行已审批的合服工作流,并返回运营回执和回滚说明。
|
||||
func (a *MergeAdminAdapter) ExecuteAdminWorkflow(ctx context.Context, record adminworkflow.Record) (admincmd.Receipt, adminworkflow.RollbackNote, error) {
|
||||
if a == nil || a.runner == nil {
|
||||
return admincmd.Receipt{}, adminworkflow.RollbackNote{}, ErrNotFound
|
||||
}
|
||||
workflowCommand, err := MergeCmdFromAdmin(record.Command)
|
||||
if err != nil {
|
||||
return admincmd.Receipt{}, adminworkflow.RollbackNote{}, err
|
||||
}
|
||||
workflowCommand.WorkflowID = firstNonEmpty(workflowCommand.WorkflowID, record.ID)
|
||||
if record.Approval != nil {
|
||||
workflowCommand.ApprovalID = firstNonEmpty(workflowCommand.ApprovalID, record.Approval.ID)
|
||||
}
|
||||
result, err := a.runner.Execute(ctx, workflowCommand)
|
||||
if err != nil {
|
||||
return admincmd.Receipt{}, adminworkflow.RollbackNote{}, err
|
||||
}
|
||||
receipt, err := admincmd.NewReceipt(record.Command, "succeeded", a.now().UTC())
|
||||
if err != nil {
|
||||
return admincmd.Receipt{}, adminworkflow.RollbackNote{}, err
|
||||
}
|
||||
return receipt, rollbackNoteResult(result), nil
|
||||
}
|
||||
|
||||
func adminDryRunResult(result MergeRunResult) adminworkflow.DryRun {
|
||||
return adminworkflow.DryRun{
|
||||
Summary: mergeAdminSummary(result),
|
||||
Diffs: mergeAdminDiffs(result),
|
||||
Warnings: mergeAdminWarnings(result.Archive),
|
||||
Meta: mergeAdminMeta(result),
|
||||
}
|
||||
}
|
||||
|
||||
func mergeAdminSummary(result MergeRunResult) string {
|
||||
archive := result.Archive
|
||||
return fmt.Sprintf("区服合服工作流预览 mode=%s archive=%s ready=%t ok=%t applied=%t", result.Mode, archive.ArchiveID, archive.Ready, archive.OK, result.Applied)
|
||||
}
|
||||
|
||||
func mergeAdminDiffs(result MergeRunResult) []adminworkflow.Diff {
|
||||
archive := result.Archive
|
||||
risk := mergeAdminRisk(archive)
|
||||
var diffs []adminworkflow.Diff
|
||||
if result.Migration != nil {
|
||||
diffs = append(diffs, adminworkflow.Diff{
|
||||
Path: "servergroup.plan.version",
|
||||
Before: result.Migration.Rollback.Version,
|
||||
After: result.Migration.DryRun.Next.Version,
|
||||
Risk: risk,
|
||||
})
|
||||
diffs = append(diffs, adminworkflow.Diff{
|
||||
Path: "servergroup.merge.request",
|
||||
Before: result.Migration.Request.Shards,
|
||||
After: result.Migration.Request.MainShardID,
|
||||
Risk: risk,
|
||||
})
|
||||
}
|
||||
if result.Apply != nil {
|
||||
diffs = append(diffs, adminworkflow.Diff{
|
||||
Path: "servergroup.merge.apply",
|
||||
Before: result.Apply.Change.PreviousVersion,
|
||||
After: result.Apply.Change.CurrentVersion,
|
||||
Risk: risk,
|
||||
})
|
||||
}
|
||||
if result.RollbackApply != nil {
|
||||
diffs = append(diffs, adminworkflow.Diff{
|
||||
Path: "servergroup.rollback.version",
|
||||
Before: result.RollbackApply.Previous.Version,
|
||||
After: result.RollbackApply.Restored.Version,
|
||||
Risk: risk,
|
||||
})
|
||||
}
|
||||
if archive.RollbackPoint.ID != "" {
|
||||
diffs = append(diffs, adminworkflow.Diff{
|
||||
Path: "servergroup.rollback_point",
|
||||
Before: "",
|
||||
After: archive.RollbackPoint.ID,
|
||||
Risk: "rollback",
|
||||
})
|
||||
}
|
||||
if len(diffs) == 0 {
|
||||
diffs = append(diffs, adminworkflow.Diff{
|
||||
Path: "servergroup.merge.archive",
|
||||
Before: "",
|
||||
After: archive.ArchiveID,
|
||||
Risk: risk,
|
||||
})
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
|
||||
func mergeAdminWarnings(archive MergeArchive) []string {
|
||||
warnings := cloneMergeStrings(archive.Warnings)
|
||||
if !archive.Ready {
|
||||
warnings = append(warnings, "合服迁移计划未 ready")
|
||||
}
|
||||
if !archive.OK {
|
||||
warnings = append(warnings, "合服归档存在 blocker 或失败模块")
|
||||
}
|
||||
for _, finding := range archive.Findings {
|
||||
if finding.Severity != MigrationSeverityWarning && finding.Severity != MigrationSeverityBlocker {
|
||||
continue
|
||||
}
|
||||
warnings = append(warnings, mergeFindingText(finding))
|
||||
}
|
||||
return cloneMergeStrings(warnings)
|
||||
}
|
||||
|
||||
func mergeFindingText(finding ConflictFinding) string {
|
||||
parts := []string{finding.Severity, finding.Code}
|
||||
if finding.Subject != "" {
|
||||
parts = append(parts, finding.Subject)
|
||||
}
|
||||
if finding.Detail != "" {
|
||||
parts = append(parts, finding.Detail)
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
func mergeAdminMeta(result MergeRunResult) map[string]string {
|
||||
archive := result.Archive
|
||||
meta := map[string]string{
|
||||
"archive_id": archive.ArchiveID,
|
||||
"workflow": archive.Workflow,
|
||||
"stage": archive.Stage,
|
||||
"mode": result.Mode,
|
||||
"ready": strconv.FormatBool(archive.Ready),
|
||||
"ok": strconv.FormatBool(archive.OK),
|
||||
"applied": strconv.FormatBool(result.Applied),
|
||||
"request_id": archive.Request.ID,
|
||||
"rollback_point_id": archive.RollbackPoint.ID,
|
||||
}
|
||||
if archive.WorkflowID != "" {
|
||||
meta["workflow_id"] = archive.WorkflowID
|
||||
}
|
||||
if archive.ApprovalID != "" {
|
||||
meta["approval_id"] = archive.ApprovalID
|
||||
}
|
||||
if archive.IdempotencyKey != "" {
|
||||
meta["idempotency_key"] = archive.IdempotencyKey
|
||||
}
|
||||
return normalizeStringMap(meta)
|
||||
}
|
||||
|
||||
func mergeAdminRisk(archive MergeArchive) string {
|
||||
switch {
|
||||
case !archive.OK:
|
||||
return "blocker"
|
||||
case !archive.Ready:
|
||||
return "warning"
|
||||
case archive.Stage == MergeStageApply || archive.Stage == MergeStageRollback:
|
||||
return "dangerous"
|
||||
default:
|
||||
return "review"
|
||||
}
|
||||
}
|
||||
|
||||
func rollbackNoteResult(result MergeRunResult) adminworkflow.RollbackNote {
|
||||
archive := result.Archive
|
||||
if archive.ArchiveID == "" {
|
||||
return adminworkflow.RollbackNote{}
|
||||
}
|
||||
switch archive.Stage {
|
||||
case MergeStageApply:
|
||||
return adminworkflow.RollbackNote{
|
||||
Summary: "合服执行已归档,可按归档 ID 发起回滚",
|
||||
Steps: cloneMergeStrings(append([]string{
|
||||
"使用 servergroup_merge mode=rollback_dry_run rollback_archive_id=" + archive.ArchiveID + " 预检回滚",
|
||||
"审批后使用 servergroup_merge mode=rollback rollback_archive_id=" + archive.ArchiveID + " 执行业务模块和平台路由回滚",
|
||||
}, archive.Rollback...)),
|
||||
EvidenceRef: "merge_workflow_archive:" + archive.ArchiveID,
|
||||
Meta: mergeAdminMeta(result),
|
||||
}
|
||||
case MergeStageRollback:
|
||||
steps := []string{"核对回滚归档和模块 rollback 报告"}
|
||||
if result.RollbackApply != nil && result.RollbackApply.Restored.Version != "" {
|
||||
steps = append(steps, "确认平台路由版本恢复到 "+result.RollbackApply.Restored.Version)
|
||||
}
|
||||
return adminworkflow.RollbackNote{
|
||||
Summary: "合服回滚已归档",
|
||||
Steps: steps,
|
||||
EvidenceRef: "merge_workflow_archive:" + archive.ArchiveID,
|
||||
Meta: mergeAdminMeta(result),
|
||||
}
|
||||
default:
|
||||
return adminworkflow.RollbackNote{}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package servergroup
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
// MergeWorkflowName 是合服运营工作流归档名。
|
||||
MergeWorkflowName = "servergroup_merge"
|
||||
)
|
||||
|
||||
const (
|
||||
// MergeStageDryRun 表示合服工作流处于预演阶段。
|
||||
MergeStageDryRun = "dry_run"
|
||||
// MergeStageApply 表示合服工作流处于正式应用阶段。
|
||||
MergeStageApply = "apply"
|
||||
// MergeStageRollbackDry 表示合服工作流处于回滚预演阶段。
|
||||
MergeStageRollbackDry = "rollback_dry_run"
|
||||
// MergeStageRollback 表示合服工作流处于正式回滚阶段。
|
||||
MergeStageRollback = "rollback"
|
||||
)
|
||||
|
||||
// MergeArchiveOptions 描述一次合服运营归档需要汇总的输入。
|
||||
type MergeArchiveOptions struct {
|
||||
// Stage 是归档阶段,取值为 dry_run、apply、rollback_dry_run 或 rollback。
|
||||
Stage string
|
||||
// ArchiveID 是外部工作流或工单系统传入的归档 ID,空值会自动推导。
|
||||
ArchiveID string
|
||||
// WorkflowID 是 adminworkflow 记录 ID,便于从归档反查审批记录。
|
||||
WorkflowID string
|
||||
// ApprovalID 是审批单或工单 ID。
|
||||
ApprovalID string
|
||||
// IdempotencyKey 是执行侧幂等键。
|
||||
IdempotencyKey string
|
||||
// OperatorID 是触发本次归档的操作者。
|
||||
OperatorID string
|
||||
// Reason 是本次合服或回滚的原因。
|
||||
Reason string
|
||||
// Request 是合服请求,空值时会从迁移计划、执行结果或模块报告中推导。
|
||||
Request MergeRequest
|
||||
// GeneratedAt 是归档生成时间,空值时使用当前 UTC 时间。
|
||||
GeneratedAt time.Time
|
||||
// Migration 是 dry-run 迁移计划。
|
||||
Migration *MigrationPlan
|
||||
// Apply 是平台路由 apply 结果。
|
||||
Apply *MergeApplyResult
|
||||
// RollbackApply 是平台路由回滚预检或 apply 结果。
|
||||
RollbackApply *RollbackApplyResult
|
||||
// ModuleReports 是项目侧模块 GenDB、Merge、Rollback 阶段报告。
|
||||
ModuleReports []MergeModuleRunReport
|
||||
// Meta 是项目侧附加元数据。
|
||||
Meta map[string]string
|
||||
}
|
||||
|
||||
// MergeArchive 是合服运营工作流可落库、可审计、可导出的归档包。
|
||||
type MergeArchive struct {
|
||||
// ArchiveID 是归档主键,通常等于 adminworkflow ID、幂等键或合服请求 ID。
|
||||
ArchiveID string `json:"archive_id"`
|
||||
// Workflow 是运营工作流名,固定为 servergroup_merge。
|
||||
Workflow string `json:"workflow"`
|
||||
// Stage 是归档阶段。
|
||||
Stage string `json:"stage"`
|
||||
// WorkflowID 是 adminworkflow 记录 ID。
|
||||
WorkflowID string `json:"workflow_id,omitempty"`
|
||||
// ApprovalID 是审批单或工单 ID。
|
||||
ApprovalID string `json:"approval_id,omitempty"`
|
||||
// IdempotencyKey 是执行侧幂等键。
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
// OperatorID 是操作者。
|
||||
OperatorID string `json:"operator_id,omitempty"`
|
||||
// Reason 是操作原因。
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// Request 是合服请求快照。
|
||||
Request MergeRequest `json:"request"`
|
||||
// GeneratedAt 是归档生成时间,统一保存为 UTC。
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
// Ready 表示 dry-run 迁移计划是否允许进入执行窗口。
|
||||
Ready bool `json:"ready"`
|
||||
// OK 表示迁移计划、平台执行和模块报告都没有 blocker 或失败。
|
||||
OK bool `json:"ok"`
|
||||
// Applied 表示平台路由 apply 或 rollback apply 已真正执行。
|
||||
Applied bool `json:"applied,omitempty"`
|
||||
// RollbackPoint 是本次操作可用于恢复平台路由的回滚锚点。
|
||||
RollbackPoint RollbackPoint `json:"rollback_point,omitempty"`
|
||||
// Migration 是 dry-run 迁移计划快照。
|
||||
Migration *MigrationPlan `json:"migration,omitempty"`
|
||||
// Apply 是平台路由 apply 结果。
|
||||
Apply *MergeApplyResult `json:"apply,omitempty"`
|
||||
// RollbackApply 是平台路由回滚预检或 apply 结果。
|
||||
RollbackApply *RollbackApplyResult `json:"rollback_apply,omitempty"`
|
||||
// ModuleReports 是项目侧模块阶段报告。
|
||||
ModuleReports []MergeModuleRunReport `json:"module_reports,omitempty"`
|
||||
// Findings 汇总迁移计划和模块报告中的风险项。
|
||||
Findings []ConflictFinding `json:"findings,omitempty"`
|
||||
// Warnings 汇总 dry-run 警告。
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
// Evidence 汇总模块报告中的证据引用。
|
||||
Evidence []string `json:"evidence,omitempty"`
|
||||
// Rollback 汇总模块报告中的业务回滚动作。
|
||||
Rollback []string `json:"rollback,omitempty"`
|
||||
// Meta 是项目侧附加元数据。
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// NewMergeArchive 把合服计划、平台执行结果和模块报告聚合成运营归档包。
|
||||
func NewMergeArchive(options MergeArchiveOptions) (MergeArchive, error) {
|
||||
stage := normalizeID(options.Stage)
|
||||
if stage == "" {
|
||||
stage = MergeStageDryRun
|
||||
}
|
||||
if !validMergeStage(stage) {
|
||||
return MergeArchive{}, ErrInvalidMigration
|
||||
}
|
||||
generatedAt := normalizeTime(options.GeneratedAt)
|
||||
if generatedAt.IsZero() {
|
||||
generatedAt = time.Now().UTC()
|
||||
}
|
||||
request := mergeArchiveRequest(options)
|
||||
archive := MergeArchive{
|
||||
ArchiveID: mergeArchiveID(options, request),
|
||||
Workflow: MergeWorkflowName,
|
||||
Stage: stage,
|
||||
WorkflowID: firstNonEmpty(options.WorkflowID),
|
||||
ApprovalID: firstNonEmpty(options.ApprovalID),
|
||||
IdempotencyKey: firstNonEmpty(options.IdempotencyKey),
|
||||
OperatorID: firstNonEmpty(options.OperatorID, request.OperatorID),
|
||||
Reason: firstNonEmpty(options.Reason, request.Reason),
|
||||
Request: request,
|
||||
GeneratedAt: generatedAt,
|
||||
Ready: true,
|
||||
OK: true,
|
||||
Meta: normalizeStringMap(options.Meta),
|
||||
}
|
||||
if options.Migration != nil {
|
||||
migration := cloneMigrationPlan(*options.Migration)
|
||||
archive.Migration = &migration
|
||||
archive.Ready = migration.Ready
|
||||
archive.Findings = append(archive.Findings, migration.Findings...)
|
||||
archive.Warnings = append(archive.Warnings, migration.DryRun.Warnings...)
|
||||
archive.RollbackPoint = cloneRollbackPoint(migration.RollbackPoint)
|
||||
if !migration.Ready || hasBlockerFinding(migration.Findings) {
|
||||
archive.OK = false
|
||||
}
|
||||
}
|
||||
if options.Apply != nil {
|
||||
apply := cloneMergeResult(*options.Apply)
|
||||
archive.Apply = &apply
|
||||
archive.Applied = apply.Applied
|
||||
if archive.RollbackPoint.ID == "" {
|
||||
archive.RollbackPoint = cloneRollbackPoint(apply.RollbackPoint)
|
||||
}
|
||||
if stage == MergeStageApply && !apply.Applied {
|
||||
archive.OK = false
|
||||
}
|
||||
}
|
||||
if options.RollbackApply != nil {
|
||||
rollback := cloneRollbackApply(*options.RollbackApply)
|
||||
archive.RollbackApply = &rollback
|
||||
archive.Applied = rollback.Applied
|
||||
if archive.RollbackPoint.ID == "" {
|
||||
archive.RollbackPoint = cloneRollbackPoint(rollback.RollbackPoint)
|
||||
}
|
||||
if stage == MergeStageRollback && !rollback.Applied {
|
||||
archive.OK = false
|
||||
}
|
||||
}
|
||||
archive.ModuleReports = cloneMergeReports(options.ModuleReports)
|
||||
for _, report := range archive.ModuleReports {
|
||||
archive.Findings = append(archive.Findings, report.Findings...)
|
||||
archive.Evidence = append(archive.Evidence, report.Evidence...)
|
||||
archive.Rollback = append(archive.Rollback, report.Rollback...)
|
||||
if !report.OK {
|
||||
archive.OK = false
|
||||
}
|
||||
}
|
||||
archive.Findings = cloneClashFindings(archive.Findings)
|
||||
archive.Warnings = cloneMergeStrings(archive.Warnings)
|
||||
archive.Evidence = cloneMergeStrings(archive.Evidence)
|
||||
archive.Rollback = cloneMergeStrings(archive.Rollback)
|
||||
if hasBlockerFinding(archive.Findings) {
|
||||
archive.OK = false
|
||||
}
|
||||
if archive.ArchiveID == "" {
|
||||
return MergeArchive{}, ErrInvalidMigration
|
||||
}
|
||||
return archive, nil
|
||||
}
|
||||
|
||||
func validMergeStage(stage string) bool {
|
||||
switch stage {
|
||||
case MergeStageDryRun, MergeStageApply, MergeStageRollbackDry, MergeStageRollback:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func mergeArchiveRequest(options MergeArchiveOptions) MergeRequest {
|
||||
request := normMergeReq(options.Request)
|
||||
if request.ID != "" {
|
||||
return request
|
||||
}
|
||||
if options.Migration != nil {
|
||||
request = normMergeReq(options.Migration.Request)
|
||||
if request.ID != "" {
|
||||
return request
|
||||
}
|
||||
}
|
||||
if options.Apply != nil {
|
||||
request = normMergeReq(options.Apply.Request)
|
||||
if request.ID != "" {
|
||||
return request
|
||||
}
|
||||
}
|
||||
for _, report := range options.ModuleReports {
|
||||
request = normMergeReq(report.Request)
|
||||
if request.ID != "" {
|
||||
return request
|
||||
}
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func mergeArchiveID(options MergeArchiveOptions, request MergeRequest) string {
|
||||
for _, value := range []string{
|
||||
options.ArchiveID,
|
||||
options.WorkflowID,
|
||||
options.IdempotencyKey,
|
||||
request.ID,
|
||||
} {
|
||||
if value = normalizeID(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if options.RollbackApply != nil {
|
||||
return normalizeID(options.RollbackApply.RollbackPoint.ID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cloneMergeArchive(archive MergeArchive) MergeArchive {
|
||||
archive.Request = normMergeReq(archive.Request)
|
||||
archive.RollbackPoint = cloneRollbackPoint(archive.RollbackPoint)
|
||||
if archive.Migration != nil {
|
||||
migration := cloneMigrationPlan(*archive.Migration)
|
||||
archive.Migration = &migration
|
||||
}
|
||||
if archive.Apply != nil {
|
||||
apply := cloneMergeResult(*archive.Apply)
|
||||
archive.Apply = &apply
|
||||
}
|
||||
if archive.RollbackApply != nil {
|
||||
rollback := cloneRollbackApply(*archive.RollbackApply)
|
||||
archive.RollbackApply = &rollback
|
||||
}
|
||||
archive.ModuleReports = cloneMergeReports(archive.ModuleReports)
|
||||
archive.Findings = cloneClashFindings(archive.Findings)
|
||||
archive.Warnings = cloneMergeStrings(archive.Warnings)
|
||||
archive.Evidence = cloneMergeStrings(archive.Evidence)
|
||||
archive.Rollback = cloneMergeStrings(archive.Rollback)
|
||||
archive.Meta = cloneStringMap(archive.Meta)
|
||||
return archive
|
||||
}
|
||||
|
||||
func cloneMigrationPlan(plan MigrationPlan) MigrationPlan {
|
||||
return MigrationPlan{
|
||||
Request: normMergeReq(plan.Request),
|
||||
DryRun: cloneMergeDryRun(plan.DryRun),
|
||||
Steps: cloneMigrationSteps(plan.Steps),
|
||||
Findings: cloneClashFindings(plan.Findings),
|
||||
Ready: plan.Ready,
|
||||
Rollback: clonePlan(plan.Rollback),
|
||||
RollbackPoint: cloneRollbackPoint(plan.RollbackPoint),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMergeResult(result MergeApplyResult) MergeApplyResult {
|
||||
return MergeApplyResult{
|
||||
Request: normMergeReq(result.Request),
|
||||
Applied: result.Applied,
|
||||
DryRun: cloneMergeDryRun(result.DryRun),
|
||||
Change: result.Change,
|
||||
Execution: cloneMergePlan(result.Execution),
|
||||
Rollback: clonePlan(result.Rollback),
|
||||
RollbackPoint: cloneRollbackPoint(result.RollbackPoint),
|
||||
Warnings: cloneMergeStrings(result.Warnings),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMergePlan(plan MergeExecutionPlan) MergeExecutionPlan {
|
||||
plan.Shards = append([]string(nil), plan.Shards...)
|
||||
if len(plan.Steps) == 0 {
|
||||
return plan
|
||||
}
|
||||
steps := make([]MergeExecutionStep, len(plan.Steps))
|
||||
for i, step := range plan.Steps {
|
||||
step.Checks = append([]string(nil), step.Checks...)
|
||||
steps[i] = step
|
||||
}
|
||||
plan.Steps = steps
|
||||
return plan
|
||||
}
|
||||
|
||||
func cloneRollbackApply(result RollbackApplyResult) RollbackApplyResult {
|
||||
result.Previous = clonePlan(result.Previous)
|
||||
result.Restored = clonePlan(result.Restored)
|
||||
result.RollbackPoint = cloneRollbackPoint(result.RollbackPoint)
|
||||
result.Request = cloneRollbackReq(result.Request)
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneRollbackReq(request RollbackApplyRequest) RollbackApplyRequest {
|
||||
request.RollbackPoint = cloneRollbackPoint(request.RollbackPoint)
|
||||
return request
|
||||
}
|
||||
|
||||
func cloneMergeReports(reports []MergeModuleRunReport) []MergeModuleRunReport {
|
||||
if len(reports) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]MergeModuleRunReport, len(reports))
|
||||
for i, report := range reports {
|
||||
out[i] = MergeModuleRunReport{
|
||||
Phase: normalizeID(report.Phase),
|
||||
Request: normMergeReq(report.Request),
|
||||
GeneratedAt: normalizeTime(report.GeneratedAt),
|
||||
OK: report.OK,
|
||||
Results: cloneMergeResults(report.Results),
|
||||
Findings: cloneClashFindings(report.Findings),
|
||||
Evidence: cloneMergeStrings(report.Evidence),
|
||||
Rollback: cloneMergeStrings(report.Rollback),
|
||||
Meta: cloneStringMap(report.Meta),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package servergroup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/httpx"
|
||||
)
|
||||
|
||||
const (
|
||||
defArchiveLimit = 100
|
||||
maxArchiveListLimit = 500
|
||||
)
|
||||
|
||||
// MergeArchiveSummary 是后台列表页使用的轻量归档摘要。
|
||||
type MergeArchiveSummary struct {
|
||||
ArchiveID string `json:"archive_id"`
|
||||
Workflow string `json:"workflow"`
|
||||
Stage string `json:"stage"`
|
||||
WorkflowID string `json:"workflow_id,omitempty"`
|
||||
ApprovalID string `json:"approval_id,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
OperatorID string `json:"operator_id,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
MainShardID string `json:"main_shard_id,omitempty"`
|
||||
Shards []string `json:"shards,omitempty"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Ready bool `json:"ready"`
|
||||
OK bool `json:"ok"`
|
||||
Applied bool `json:"applied"`
|
||||
RollbackPointID string `json:"rollback_point_id,omitempty"`
|
||||
EvidenceCount int `json:"evidence_count"`
|
||||
FindingCount int `json:"finding_count"`
|
||||
WarningCount int `json:"warning_count"`
|
||||
RollbackCount int `json:"rollback_count"`
|
||||
ModuleReportCount int `json:"module_report_count"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type archiveListFilter struct {
|
||||
stage string
|
||||
workflowID string
|
||||
approvalID string
|
||||
requestID string
|
||||
operatorID string
|
||||
idempotencyKey string
|
||||
ok *bool
|
||||
ready *bool
|
||||
applied *bool
|
||||
limit int
|
||||
}
|
||||
|
||||
func regArchiveRoutes(mux *http.ServeMux, prefix string, wrap func(string, http.HandlerFunc) http.HandlerFunc, archives MergeArchiveStore) {
|
||||
listPath := prefix + "/merge/workflow/archives"
|
||||
mux.HandleFunc(listPath, wrap("servergroup", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleArchiveList(w, r, archives)
|
||||
}))
|
||||
mux.HandleFunc(listPath+"/", wrap("servergroup", func(w http.ResponseWriter, r *http.Request) {
|
||||
suffix := strings.TrimPrefix(r.URL.Path, listPath+"/")
|
||||
handleArchiveItem(w, r, archives, suffix)
|
||||
}))
|
||||
}
|
||||
|
||||
func handleArchiveList(w http.ResponseWriter, r *http.Request, archives MergeArchiveStore) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
writeMethodNA(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if archives == nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": ErrStoreEmpty.Error()})
|
||||
return
|
||||
}
|
||||
filter, err := archiveFilterFromReq(r)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
all, err := archives.ListMergeArchives(r.Context())
|
||||
if err != nil {
|
||||
writeArchiveErr(w, err)
|
||||
return
|
||||
}
|
||||
sort.SliceStable(all, func(i, j int) bool {
|
||||
left := normalizeTime(all[i].GeneratedAt)
|
||||
right := normalizeTime(all[j].GeneratedAt)
|
||||
if !left.Equal(right) {
|
||||
return left.After(right)
|
||||
}
|
||||
return all[i].ArchiveID < all[j].ArchiveID
|
||||
})
|
||||
summaries := make([]MergeArchiveSummary, 0, minInt(len(all), filter.limit))
|
||||
total := 0
|
||||
for _, archive := range all {
|
||||
if !filter.match(archive) {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if len(summaries) >= filter.limit {
|
||||
continue
|
||||
}
|
||||
summaries = append(summaries, MergeArchiveSummaryFromArchive(archive))
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"archives": summaries,
|
||||
"count": len(summaries),
|
||||
"total": total,
|
||||
"limit": filter.limit,
|
||||
})
|
||||
}
|
||||
|
||||
func handleArchiveItem(w http.ResponseWriter, r *http.Request, archives MergeArchiveStore, suffix string) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
writeMethodNA(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if archives == nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": ErrStoreEmpty.Error()})
|
||||
return
|
||||
}
|
||||
archiveID, export, ok := parseArchiveItemPath(suffix)
|
||||
if !ok {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": ErrMergeArchiveNotFound.Error()})
|
||||
return
|
||||
}
|
||||
archive, found, err := archives.GetMergeArchive(r.Context(), archiveID)
|
||||
if err != nil {
|
||||
writeArchiveErr(w, err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": ErrMergeArchiveNotFound.Error()})
|
||||
return
|
||||
}
|
||||
if export {
|
||||
fileName, err := archiveFileName(archive.ArchiveID)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fileName))
|
||||
httpx.WriteJSON(w, http.StatusOK, archive)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"archive": archive,
|
||||
"summary": MergeArchiveSummaryFromArchive(archive),
|
||||
})
|
||||
}
|
||||
|
||||
// MergeArchiveSummaryFromArchive 从完整归档生成列表摘要。
|
||||
func MergeArchiveSummaryFromArchive(archive MergeArchive) MergeArchiveSummary {
|
||||
archive = cloneMergeArchive(archive)
|
||||
return MergeArchiveSummary{
|
||||
ArchiveID: archive.ArchiveID,
|
||||
Workflow: archive.Workflow,
|
||||
Stage: archive.Stage,
|
||||
WorkflowID: archive.WorkflowID,
|
||||
ApprovalID: archive.ApprovalID,
|
||||
IdempotencyKey: archive.IdempotencyKey,
|
||||
OperatorID: archive.OperatorID,
|
||||
Reason: archive.Reason,
|
||||
RequestID: archive.Request.ID,
|
||||
MainShardID: archive.Request.MainShardID,
|
||||
Shards: append([]string(nil), archive.Request.Shards...),
|
||||
GeneratedAt: normalizeTime(archive.GeneratedAt),
|
||||
Ready: archive.Ready,
|
||||
OK: archive.OK,
|
||||
Applied: archive.Applied,
|
||||
RollbackPointID: archive.RollbackPoint.ID,
|
||||
EvidenceCount: len(archive.Evidence),
|
||||
FindingCount: len(archive.Findings),
|
||||
WarningCount: len(archive.Warnings),
|
||||
RollbackCount: len(archive.Rollback),
|
||||
ModuleReportCount: len(archive.ModuleReports),
|
||||
Meta: cloneStringMap(archive.Meta),
|
||||
}
|
||||
}
|
||||
|
||||
func archiveFilterFromReq(r *http.Request) (archiveListFilter, error) {
|
||||
query := r.URL.Query()
|
||||
limit, err := parseArchiveLimit(query.Get("limit"))
|
||||
if err != nil {
|
||||
return archiveListFilter{}, err
|
||||
}
|
||||
ok, err := parseArchiveBool(query.Get("ok"), "ok")
|
||||
if err != nil {
|
||||
return archiveListFilter{}, err
|
||||
}
|
||||
ready, err := parseArchiveBool(query.Get("ready"), "ready")
|
||||
if err != nil {
|
||||
return archiveListFilter{}, err
|
||||
}
|
||||
applied, err := parseArchiveBool(query.Get("applied"), "applied")
|
||||
if err != nil {
|
||||
return archiveListFilter{}, err
|
||||
}
|
||||
return archiveListFilter{
|
||||
stage: normalizeID(query.Get("stage")),
|
||||
workflowID: normalizeID(query.Get("workflow_id")),
|
||||
approvalID: normalizeID(query.Get("approval_id")),
|
||||
requestID: normalizeID(query.Get("request_id")),
|
||||
operatorID: normalizeID(query.Get("operator_id")),
|
||||
idempotencyKey: strings.TrimSpace(query.Get("idempotency_key")),
|
||||
ok: ok,
|
||||
ready: ready,
|
||||
applied: applied,
|
||||
limit: limit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f archiveListFilter) match(archive MergeArchive) bool {
|
||||
archive = cloneMergeArchive(archive)
|
||||
if f.stage != "" && normalizeID(archive.Stage) != f.stage {
|
||||
return false
|
||||
}
|
||||
if f.workflowID != "" && normalizeID(archive.WorkflowID) != f.workflowID {
|
||||
return false
|
||||
}
|
||||
if f.approvalID != "" && normalizeID(archive.ApprovalID) != f.approvalID {
|
||||
return false
|
||||
}
|
||||
if f.requestID != "" && normalizeID(archive.Request.ID) != f.requestID {
|
||||
return false
|
||||
}
|
||||
if f.operatorID != "" && normalizeID(archive.OperatorID) != f.operatorID {
|
||||
return false
|
||||
}
|
||||
if f.idempotencyKey != "" && strings.TrimSpace(archive.IdempotencyKey) != f.idempotencyKey {
|
||||
return false
|
||||
}
|
||||
if f.ok != nil && archive.OK != *f.ok {
|
||||
return false
|
||||
}
|
||||
if f.ready != nil && archive.Ready != *f.ready {
|
||||
return false
|
||||
}
|
||||
if f.applied != nil && archive.Applied != *f.applied {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseArchiveItemPath(suffix string) (string, bool, bool) {
|
||||
suffix = strings.Trim(suffix, "/")
|
||||
if suffix == "" {
|
||||
return "", false, false
|
||||
}
|
||||
export := false
|
||||
if strings.HasSuffix(suffix, "/export") {
|
||||
export = true
|
||||
suffix = strings.TrimSuffix(suffix, "/export")
|
||||
}
|
||||
if suffix == "" || strings.Contains(suffix, "/") {
|
||||
return "", false, false
|
||||
}
|
||||
archiveID := normalizeID(suffix)
|
||||
if archiveID == "" {
|
||||
return "", false, false
|
||||
}
|
||||
return archiveID, export, true
|
||||
}
|
||||
|
||||
func parseArchiveLimit(raw string) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return defArchiveLimit, nil
|
||||
}
|
||||
limit, err := strconv.Atoi(raw)
|
||||
if err != nil || limit < 0 {
|
||||
return 0, fmt.Errorf("%w: limit must be a non-negative integer", ErrInvalidMigration)
|
||||
}
|
||||
if limit > maxArchiveListLimit {
|
||||
return maxArchiveListLimit, nil
|
||||
}
|
||||
return limit, nil
|
||||
}
|
||||
|
||||
func parseArchiveBool(raw, name string) (*bool, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
value, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s must be boolean", ErrInvalidMigration, name)
|
||||
}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
func writeArchiveErr(w http.ResponseWriter, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, ErrStoreEmpty) {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
func minInt(left, right int) int {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package servergroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrMergeArchiveNotFound 表示找不到指定合服工作流归档。
|
||||
var ErrMergeArchiveNotFound = errors.New("server group merge workflow archive not found")
|
||||
|
||||
// MergeArchiveStore 保存合服工作流归档,项目侧可以替换成 MySQL、对象存储或工单系统。
|
||||
type MergeArchiveStore interface {
|
||||
SaveMergeArchive(context.Context, MergeArchive) error
|
||||
GetMergeArchive(context.Context, string) (MergeArchive, bool, error)
|
||||
ListMergeArchives(context.Context) ([]MergeArchive, error)
|
||||
}
|
||||
|
||||
// MemoryMergeArchiveStore 是测试和单进程工具使用的内存归档存储。
|
||||
type MemoryMergeArchiveStore struct {
|
||||
mu sync.RWMutex
|
||||
archives map[string]MergeArchive
|
||||
}
|
||||
|
||||
// NewMemoryMergeArchiveStore 创建内存归档存储,并可预置一组归档。
|
||||
func NewMemoryMergeArchiveStore(archives ...MergeArchive) (*MemoryMergeArchiveStore, error) {
|
||||
store := &MemoryMergeArchiveStore{archives: make(map[string]MergeArchive, len(archives))}
|
||||
for _, archive := range archives {
|
||||
normalized, err := normArchiveStore(archive)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store.archives[normalized.ArchiveID] = normalized
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// SaveMergeArchive 保存或覆盖内存中的合服归档。
|
||||
func (s *MemoryMergeArchiveStore) SaveMergeArchive(ctx context.Context, archive MergeArchive) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil {
|
||||
return ErrStoreEmpty
|
||||
}
|
||||
normalized, err := normArchiveStore(archive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.archives == nil {
|
||||
s.archives = make(map[string]MergeArchive)
|
||||
}
|
||||
s.archives[normalized.ArchiveID] = normalized
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMergeArchive 从内存归档存储读取指定归档。
|
||||
func (s *MemoryMergeArchiveStore) GetMergeArchive(ctx context.Context, archiveID string) (MergeArchive, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return MergeArchive{}, false, err
|
||||
}
|
||||
if s == nil {
|
||||
return MergeArchive{}, false, ErrStoreEmpty
|
||||
}
|
||||
archiveID = normalizeID(archiveID)
|
||||
if archiveID == "" {
|
||||
return MergeArchive{}, false, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
archive, ok := s.archives[archiveID]
|
||||
return cloneMergeArchive(archive), ok, nil
|
||||
}
|
||||
|
||||
// ListMergeArchives 返回内存中的全部合服归档。
|
||||
func (s *MemoryMergeArchiveStore) ListMergeArchives(ctx context.Context) ([]MergeArchive, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil {
|
||||
return nil, ErrStoreEmpty
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
archives := make([]MergeArchive, 0, len(s.archives))
|
||||
for _, archive := range s.archives {
|
||||
archives = append(archives, cloneMergeArchive(archive))
|
||||
}
|
||||
sort.Slice(archives, func(i, j int) bool { return archives[i].ArchiveID < archives[j].ArchiveID })
|
||||
return archives, nil
|
||||
}
|
||||
|
||||
// FileMergeArchiveStore 把每份归档保存为独立 JSON 文件,适合本地演练和离线交付。
|
||||
type FileMergeArchiveStore struct {
|
||||
dir string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewFileMergeArchiveStore 创建文件归档存储。
|
||||
func NewFileMergeArchiveStore(dir string) (*FileMergeArchiveStore, error) {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
return nil, fmt.Errorf("%w: archive store dir is required", ErrInvalidMigration)
|
||||
}
|
||||
return &FileMergeArchiveStore{dir: dir}, nil
|
||||
}
|
||||
|
||||
// SaveMergeArchive 将合服归档保存为 JSON 文件。
|
||||
func (s *FileMergeArchiveStore) SaveMergeArchive(ctx context.Context, archive MergeArchive) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil {
|
||||
return ErrStoreEmpty
|
||||
}
|
||||
normalized, err := normArchiveStore(archive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := s.pathForArchive(normalized.ArchiveID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(normalized, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return writeFileReplace(path, data)
|
||||
}
|
||||
|
||||
// GetMergeArchive 从文件归档存储读取指定归档。
|
||||
func (s *FileMergeArchiveStore) GetMergeArchive(ctx context.Context, archiveID string) (MergeArchive, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return MergeArchive{}, false, err
|
||||
}
|
||||
if s == nil {
|
||||
return MergeArchive{}, false, ErrStoreEmpty
|
||||
}
|
||||
path, err := s.pathForArchive(archiveID)
|
||||
if err != nil {
|
||||
return MergeArchive{}, false, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
data, err := os.ReadFile(path) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return MergeArchive{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return MergeArchive{}, false, err
|
||||
}
|
||||
archive, err := decodeMergeArchive(data)
|
||||
if err != nil {
|
||||
return MergeArchive{}, false, err
|
||||
}
|
||||
return archive, true, nil
|
||||
}
|
||||
|
||||
// ListMergeArchives 返回文件目录中的全部合服归档。
|
||||
func (s *FileMergeArchiveStore) ListMergeArchives(ctx context.Context) ([]MergeArchive, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil {
|
||||
return nil, ErrStoreEmpty
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
archives := make([]MergeArchive, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(s.dir, entry.Name())) //nolint:gosec // G703:路径来自框架配置或 CLI 显式输出目标,调用点负责限定输入范围。
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
archive, err := decodeMergeArchive(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
archives = append(archives, archive)
|
||||
}
|
||||
sort.Slice(archives, func(i, j int) bool { return archives[i].ArchiveID < archives[j].ArchiveID })
|
||||
return archives, nil
|
||||
}
|
||||
|
||||
func (s *FileMergeArchiveStore) pathForArchive(archiveID string) (string, error) {
|
||||
if s == nil {
|
||||
return "", ErrStoreEmpty
|
||||
}
|
||||
fileName, err := archiveFileName(archiveID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(s.dir, fileName), nil
|
||||
}
|
||||
|
||||
func archiveFileName(archiveID string) (string, error) {
|
||||
archiveID = normalizeID(archiveID)
|
||||
if archiveID == "" {
|
||||
return "", fmt.Errorf("%w: archive id is required", ErrInvalidMigration)
|
||||
}
|
||||
if archiveID == "." || archiveID == ".." || strings.ContainsAny(archiveID, `/\`) {
|
||||
return "", fmt.Errorf("%w: archive id %q must be a file name", ErrInvalidMigration, archiveID)
|
||||
}
|
||||
return archiveID + ".json", nil
|
||||
}
|
||||
|
||||
func decodeMergeArchive(data []byte) (MergeArchive, error) {
|
||||
var archive MergeArchive
|
||||
if err := json.Unmarshal(data, &archive); err != nil {
|
||||
return MergeArchive{}, err
|
||||
}
|
||||
return normArchiveStore(archive)
|
||||
}
|
||||
|
||||
func normArchiveStore(archive MergeArchive) (MergeArchive, error) {
|
||||
archive = cloneMergeArchive(archive)
|
||||
archive.ArchiveID = normalizeID(archive.ArchiveID)
|
||||
archive.Workflow = firstNonEmpty(archive.Workflow, MergeWorkflowName)
|
||||
archive.Stage = normalizeID(archive.Stage)
|
||||
if archive.Stage == "" {
|
||||
archive.Stage = MergeStageDryRun
|
||||
}
|
||||
if archive.ArchiveID == "" {
|
||||
return MergeArchive{}, fmt.Errorf("%w: archive id is required", ErrInvalidMigration)
|
||||
}
|
||||
if archive.Workflow != MergeWorkflowName {
|
||||
return MergeArchive{}, fmt.Errorf("%w: archive workflow %s is invalid", ErrInvalidMigration, archive.Workflow)
|
||||
}
|
||||
if !validMergeStage(archive.Stage) {
|
||||
return MergeArchive{}, fmt.Errorf("%w: archive stage %s is invalid", ErrInvalidMigration, archive.Stage)
|
||||
}
|
||||
archive.GeneratedAt = normalizeTime(archive.GeneratedAt)
|
||||
if archive.GeneratedAt.IsZero() {
|
||||
archive.GeneratedAt = time.Now().UTC()
|
||||
}
|
||||
archive.WorkflowID = firstNonEmpty(archive.WorkflowID)
|
||||
archive.ApprovalID = firstNonEmpty(archive.ApprovalID)
|
||||
archive.IdempotencyKey = firstNonEmpty(archive.IdempotencyKey)
|
||||
archive.OperatorID = firstNonEmpty(archive.OperatorID)
|
||||
archive.Reason = firstNonEmpty(archive.Reason)
|
||||
archive.Meta = normalizeStringMap(archive.Meta)
|
||||
return archive, nil
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package servergroup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// MergeModuleRecipe 描述项目侧实现一个合服模块时必须准备的输入、阶段动作、证据和验收项。
|
||||
type MergeModuleRecipe struct {
|
||||
// Name 是模块名,和 MergeModuleTemplate、MergeModuleSpec 对齐。
|
||||
Name string `json:"name"`
|
||||
// Description 是模块职责说明。
|
||||
Description string `json:"description,omitempty"`
|
||||
// Features 是模块覆盖的合服 feature。
|
||||
Features []string `json:"features,omitempty"`
|
||||
// Required 表示正式项目是否必须实现该模块。
|
||||
Required bool `json:"required"`
|
||||
// Inputs 是实现该模块前必须能读取或生成的数据输入。
|
||||
Inputs []string `json:"inputs,omitempty"`
|
||||
// Phases 描述 gendb、merge、rollback 三个阶段的实现要求。
|
||||
Phases []MergePhaseRecipe `json:"phases,omitempty"`
|
||||
// Acceptance 是上线前验收该模块实现时必须检查的结果。
|
||||
Acceptance []string `json:"acceptance,omitempty"`
|
||||
// Notes 是接入时容易漏掉的工程注意事项。
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// MergePhaseRecipe 描述单个阶段的目标、步骤、证据和失败策略。
|
||||
type MergePhaseRecipe struct {
|
||||
// Phase 是阶段名,取值为 gendb、merge 或 rollback。
|
||||
Phase string `json:"phase"`
|
||||
// Goal 是该阶段要达成的目标。
|
||||
Goal string `json:"goal,omitempty"`
|
||||
// Steps 是项目侧处理器应该完成的主要步骤。
|
||||
Steps []string `json:"steps,omitempty"`
|
||||
// Evidence 是该阶段必须输出到 MergeModuleResult.Evidence 或归档系统的证据。
|
||||
Evidence []string `json:"evidence,omitempty"`
|
||||
// FailurePolicy 描述失败时应该返回 error 还是 blocker finding。
|
||||
FailurePolicy string `json:"failure_policy,omitempty"`
|
||||
}
|
||||
|
||||
type mergeRecipeDetail struct {
|
||||
Inputs []string
|
||||
GenDB []string
|
||||
Merge []string
|
||||
Rollback []string
|
||||
Acceptance []string
|
||||
Notes []string
|
||||
}
|
||||
|
||||
// MergeModuleRecipes 返回从成熟项目合服模块经验抽出的默认实现配方,调用方可以安全修改返回值。
|
||||
func MergeModuleRecipes() []MergeModuleRecipe {
|
||||
templates := MergeModuleTemplates()
|
||||
recipes := make([]MergeModuleRecipe, 0, len(templates))
|
||||
for _, template := range templates {
|
||||
recipe, err := MergeRecipeFromTemplate(template)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
recipes = append(recipes, recipe)
|
||||
}
|
||||
return cloneMergeRecipes(recipes)
|
||||
}
|
||||
|
||||
// FindMergeRecipe 按模块名查找默认合服模块实现配方。
|
||||
func FindMergeRecipe(name string) (MergeModuleRecipe, bool) {
|
||||
name = normalizeID(name)
|
||||
if name == "" {
|
||||
return MergeModuleRecipe{}, false
|
||||
}
|
||||
for _, template := range defModuleTpls {
|
||||
if template.Name != name {
|
||||
continue
|
||||
}
|
||||
recipe, err := MergeRecipeFromTemplate(template)
|
||||
if err != nil {
|
||||
return MergeModuleRecipe{}, false
|
||||
}
|
||||
return cloneMergeRecipe(recipe), true
|
||||
}
|
||||
return MergeModuleRecipe{}, false
|
||||
}
|
||||
|
||||
// MergeRecipeFromTemplate 把默认模板转换成项目侧实现配方。
|
||||
func MergeRecipeFromTemplate(template MergeModuleTemplate) (MergeModuleRecipe, error) {
|
||||
template, err := normModuleTpl(template)
|
||||
if err != nil {
|
||||
return MergeModuleRecipe{}, err
|
||||
}
|
||||
detail, ok := defaultRecipeDetails[template.Name]
|
||||
if !ok {
|
||||
detail = genericRecipeDetail(template)
|
||||
}
|
||||
recipe := MergeModuleRecipe{
|
||||
Name: template.Name,
|
||||
Description: template.Description,
|
||||
Features: append([]string(nil), template.Features...),
|
||||
Required: template.Required,
|
||||
Inputs: detail.Inputs,
|
||||
Phases: []MergePhaseRecipe{
|
||||
{
|
||||
Phase: MergeModulePhaseGenDB,
|
||||
Goal: "生成合服前置数据、冲突方案和 dry-run 证据",
|
||||
Steps: append(cloneMergeStrings(detail.GenDB), template.Checks...),
|
||||
Evidence: append(cloneMergeStrings(template.Evidence), "gendb 阶段批次号", "抽样校验输入"),
|
||||
FailurePolicy: "可修复风险返回 blocker finding;读取失败、生成失败或证据落盘失败返回 error",
|
||||
},
|
||||
{
|
||||
Phase: MergeModulePhaseMerge,
|
||||
Goal: "在审批后的合服窗口内执行幂等业务合并",
|
||||
Steps: detail.Merge,
|
||||
Evidence: append(cloneMergeStrings(template.Evidence), "merge 阶段批次号", "写入数量和抽样校验摘要"),
|
||||
FailurePolicy: "业务写入失败必须返回 error;不能把半完成写入伪装成 warning",
|
||||
},
|
||||
{
|
||||
Phase: MergeModulePhaseRollback,
|
||||
Goal: "按归档和回滚点执行业务回滚或补偿",
|
||||
Steps: append(cloneMergeStrings(detail.Rollback), template.Rollback...),
|
||||
Evidence: append(cloneMergeStrings(template.Rollback), "rollback 阶段批次号", "恢复后抽样校验摘要"),
|
||||
FailurePolicy: "回滚失败必须返回 error,并保留可人工继续处理的证据引用",
|
||||
},
|
||||
},
|
||||
Acceptance: detail.Acceptance,
|
||||
Notes: detail.Notes,
|
||||
}
|
||||
return normMergeRecipe(recipe)
|
||||
}
|
||||
|
||||
// ValidateMergeModuleRecipe 校验项目侧自定义配方是否包含三段式实现要求。
|
||||
func ValidateMergeModuleRecipe(recipe MergeModuleRecipe) error {
|
||||
_, err := normMergeRecipe(recipe)
|
||||
return err
|
||||
}
|
||||
|
||||
var defaultRecipeDetails = map[string]mergeRecipeDetail{
|
||||
MergeModuleNameProfile: {
|
||||
Inputs: []string{"Profile 快照", "角色名唯一索引", "账号绑定关系", "玩家镜像索引", "清理白名单"},
|
||||
GenDB: []string{"批量读取源服和目标服玩家档案", "生成角色重名改名方案", "计算账号清理候选并套用白名单", "生成玩家镜像重建计划"},
|
||||
Merge: []string{"按幂等批次写入目标 Profile", "重建角色名唯一索引", "重建账号到玩家镜像", "记录清理或保留结果"},
|
||||
Rollback: []string{"读取 Profile 回滚快照", "恢复角色名唯一索引", "恢复账号到玩家镜像", "撤销误清理账号"},
|
||||
Acceptance: []string{"角色名索引全局唯一", "账号绑定能定位到主服玩家", "抽样 Profile 可读取且版本正确", "清理名单和保留名单可审计"},
|
||||
Notes: []string{"名字冲突必须在 gendb 阶段生成确定方案", "清理策略必须可配置并写入证据"},
|
||||
},
|
||||
MergeModuleNameMail: {
|
||||
Inputs: []string{"个人邮件快照", "全服邮件快照", "附件发放记录", "补偿邮件批次"},
|
||||
GenDB: []string{"统计未领取邮件和附件", "生成全服邮件保留策略", "生成补偿邮件去重键", "冻结合服窗口新增邮件策略"},
|
||||
Merge: []string{"按批次迁移个人邮件", "保留或重建全服邮件可见关系", "校验附件发放幂等记录", "写入补偿邮件批次"},
|
||||
Rollback: []string{"删除合服批次新增邮件", "恢复旧服邮件索引", "冻结异常附件领取"},
|
||||
Acceptance: []string{"未领取邮件不丢失", "附件不可重复领取", "补偿邮件幂等键可复核", "全服邮件可见范围正确"},
|
||||
Notes: []string{"邮件附件属于关键写路径,不能绕过幂等和发放 owner"},
|
||||
},
|
||||
MergeModuleNameLeaderboard: {
|
||||
Inputs: []string{"本地榜快照", "跨服榜快照", "赛季窗口", "排行榜补充信息"},
|
||||
GenDB: []string{"冻结或确认赛季结算窗口", "生成榜单重算计划", "检查同玩家多榜去重策略", "生成榜单补充信息校验集合"},
|
||||
Merge: []string{"按赛季和榜单维度重算排名", "写入榜单补充信息", "记录榜单重算批次", "抽样校验排序稳定性"},
|
||||
Rollback: []string{"恢复旧榜快照", "撤销新榜重算批次", "恢复榜单补充信息"},
|
||||
Acceptance: []string{"榜单排序稳定", "同玩家去重策略生效", "赛季结算窗口无交叉写入", "抽样榜单和补充信息一致"},
|
||||
Notes: []string{"排行榜重算应输出 before/after 摘要,便于运营复核"},
|
||||
},
|
||||
MergeModuleNameGuild: {
|
||||
Inputs: []string{"公会快照", "公会名唯一索引", "公会成员关系", "公会镜像", "公会榜快照"},
|
||||
GenDB: []string{"生成公会重名改名方案", "校验会长和成员关系", "生成公会镜像重建计划", "确认公会活动结算窗口"},
|
||||
Merge: []string{"迁移公会主体和成员关系", "重建公会名唯一索引", "重建公会镜像", "重算公会榜"},
|
||||
Rollback: []string{"恢复公会主体快照", "恢复公会名索引", "恢复公会镜像", "撤销公会榜重算"},
|
||||
Acceptance: []string{"公会名索引唯一", "会长和成员关系完整", "公会镜像可查询", "公会榜抽样正确"},
|
||||
Notes: []string{"公会成员关系和公会榜必须同批次留证,避免只迁主表"},
|
||||
},
|
||||
MergeModuleNameFriend: {
|
||||
Inputs: []string{"好友双向边快照", "黑名单快照", "最近联系人", "好友活动记录"},
|
||||
GenDB: []string{"检查好友双向边一致性", "确认黑名单优先级", "生成好友活动合并计划", "识别跨服好友引用"},
|
||||
Merge: []string{"合并好友图谱", "合并黑名单和最近联系人", "迁移好友活动记录", "抽样校验双向边"},
|
||||
Rollback: []string{"恢复好友图谱快照", "恢复黑名单和最近联系人", "撤销好友活动批次"},
|
||||
Acceptance: []string{"好友双向边一致", "黑名单优先级正确", "好友活动记录可追踪", "跨服引用有明确处理策略"},
|
||||
Notes: []string{"好友模块应避免只写单向边"},
|
||||
},
|
||||
MergeModuleNameAuction: {
|
||||
Inputs: []string{"拍卖订单", "竞价记录", "流拍记录", "战区归属"},
|
||||
GenDB: []string{"冻结拍卖结算窗口", "校验竞价订单幂等键", "生成流拍记录去重计划", "确认战区归属变更"},
|
||||
Merge: []string{"迁移未结算拍卖", "迁移竞价和流拍记录", "重建战区拍卖归属", "记录结算冻结解除点"},
|
||||
Rollback: []string{"恢复拍卖快照", "撤销合服后竞价写入", "恢复战区拍卖归属"},
|
||||
Acceptance: []string{"拍卖结算窗口无并发写入", "竞价订单不重复", "流拍记录去重正确", "战区归属可复核"},
|
||||
Notes: []string{"拍卖和支付订单交叉时必须明确 owner"},
|
||||
},
|
||||
MergeModuleNameActivity: {
|
||||
Inputs: []string{"活动状态", "活动榜", "奖励领取记录", "活动开放窗口"},
|
||||
GenDB: []string{"确认活动开放和结算窗口", "生成活动状态去重计划", "生成活动榜重算计划", "检查奖励领取幂等记录"},
|
||||
Merge: []string{"合并活动状态", "重算活动榜", "合并奖励领取记录", "记录活动批次和窗口"},
|
||||
Rollback: []string{"恢复活动状态快照", "撤销活动榜重算", "冻结异常奖励领取"},
|
||||
Acceptance: []string{"活动窗口正确", "活动状态无重复", "奖励领取不重复", "活动榜抽样正确"},
|
||||
Notes: []string{"活动模块必须区分已结算、结算中和未开启状态"},
|
||||
},
|
||||
MergeModuleNamePayment: {
|
||||
Inputs: []string{"支付订单", "IAP 订单", "发货记录", "补单复核单"},
|
||||
GenDB: []string{"检查订单唯一键冲突", "统计已发货和未完成订单", "冻结合服窗口补单", "生成补单复核清单"},
|
||||
Merge: []string{"迁移订单索引", "迁移发货幂等记录", "保留未完成订单冻结状态", "输出补单复核结果"},
|
||||
Rollback: []string{"恢复订单索引", "冻结合服窗口补单", "恢复发货幂等记录"},
|
||||
Acceptance: []string{"订单唯一键不冲突", "已发货订单不可重复发货", "未完成订单状态可复核", "补单清单可人工追踪"},
|
||||
Notes: []string{"支付模块失败必须走 error 和人工复核,不能只给 warning"},
|
||||
},
|
||||
MergeModuleNameServerInfo: {
|
||||
Inputs: []string{"来源区服记录", "目标区服版本", "路由元数据", "合服批次号"},
|
||||
GenDB: []string{"校验来源区服和主服关系", "生成合服批次号", "检查路由元数据一致性", "生成服务端公告和查询映射"},
|
||||
Merge: []string{"写入合服批次记录", "写入来源区服映射", "更新路由元数据", "输出合服完成摘要"},
|
||||
Rollback: []string{"恢复合服信息快照", "恢复旧路由元数据", "标记合服批次回滚"},
|
||||
Acceptance: []string{"来源区服能映射到主服", "路由元数据版本正确", "合服批次号幂等", "查询面能展示当前合服关系"},
|
||||
Notes: []string{"合服信息是运营排障入口,必须和平台路由归档互相引用"},
|
||||
},
|
||||
}
|
||||
|
||||
func genericRecipeDetail(template MergeModuleTemplate) mergeRecipeDetail {
|
||||
return mergeRecipeDetail{
|
||||
Inputs: []string{template.Name + " 模块快照", template.Name + " 模块索引", "合服批次号"},
|
||||
GenDB: []string{"读取模块快照", "生成冲突清单", "生成抽样校验集合"},
|
||||
Merge: []string{"按幂等批次写入目标模块", "重建模块索引", "输出写入摘要"},
|
||||
Rollback: []string{"读取模块回滚快照", "恢复模块索引", "输出恢复摘要"},
|
||||
Acceptance: []string{"模块索引可查询", "抽样数据一致", "批次号可审计"},
|
||||
Notes: []string{"自定义模块必须声明 feature,并接入 gendb、merge、rollback 三段处理器"},
|
||||
}
|
||||
}
|
||||
|
||||
func normMergeRecipe(recipe MergeModuleRecipe) (MergeModuleRecipe, error) {
|
||||
recipe.Name = normalizeID(recipe.Name)
|
||||
recipe.Description = firstNonEmpty(recipe.Description)
|
||||
recipe.Features = normalizeIDs(recipe.Features)
|
||||
recipe.Inputs = cloneMergeStrings(recipe.Inputs)
|
||||
recipe.Acceptance = cloneMergeStrings(recipe.Acceptance)
|
||||
recipe.Notes = cloneMergeStrings(recipe.Notes)
|
||||
if recipe.Name == "" {
|
||||
return MergeModuleRecipe{}, fmt.Errorf("%w: recipe name is required", ErrMergeModuleInvalid)
|
||||
}
|
||||
if len(recipe.Features) == 0 {
|
||||
return MergeModuleRecipe{}, fmt.Errorf("%w: recipe %s features are required", ErrMergeModuleInvalid, recipe.Name)
|
||||
}
|
||||
if len(recipe.Inputs) == 0 {
|
||||
return MergeModuleRecipe{}, fmt.Errorf("%w: recipe %s inputs are required", ErrMergeModuleInvalid, recipe.Name)
|
||||
}
|
||||
if len(recipe.Acceptance) == 0 {
|
||||
return MergeModuleRecipe{}, fmt.Errorf("%w: recipe %s acceptance items are required", ErrMergeModuleInvalid, recipe.Name)
|
||||
}
|
||||
phases, err := normPhaseRecipes(recipe.Phases)
|
||||
if err != nil {
|
||||
return MergeModuleRecipe{}, err
|
||||
}
|
||||
recipe.Phases = phases
|
||||
for _, phase := range []string{MergeModulePhaseGenDB, MergeModulePhaseMerge, MergeModulePhaseRollback} {
|
||||
if !recipeHasPhase(recipe.Phases, phase) {
|
||||
return MergeModuleRecipe{}, fmt.Errorf("%w: recipe %s missing %s phase", ErrMergeModuleInvalid, recipe.Name, phase)
|
||||
}
|
||||
}
|
||||
return recipe, nil
|
||||
}
|
||||
|
||||
func normPhaseRecipes(phases []MergePhaseRecipe) ([]MergePhaseRecipe, error) {
|
||||
if len(phases) == 0 {
|
||||
return nil, fmt.Errorf("%w: recipe phases are required", ErrMergeModuleInvalid)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(phases))
|
||||
out := make([]MergePhaseRecipe, 0, len(phases))
|
||||
for _, phase := range phases {
|
||||
phase.Phase = normalizeID(phase.Phase)
|
||||
phase.Goal = firstNonEmpty(phase.Goal)
|
||||
phase.Steps = cloneMergeStrings(phase.Steps)
|
||||
phase.Evidence = cloneMergeStrings(phase.Evidence)
|
||||
phase.FailurePolicy = firstNonEmpty(phase.FailurePolicy)
|
||||
if !validMergePhase(phase.Phase) {
|
||||
return nil, fmt.Errorf("%w: recipe phase %s is invalid", ErrMergeModuleInvalid, phase.Phase)
|
||||
}
|
||||
if _, exists := seen[phase.Phase]; exists {
|
||||
return nil, fmt.Errorf("%w: recipe phase %s duplicated", ErrMergeModuleInvalid, phase.Phase)
|
||||
}
|
||||
if phase.Goal == "" || len(phase.Steps) == 0 || len(phase.Evidence) == 0 || phase.FailurePolicy == "" {
|
||||
return nil, fmt.Errorf("%w: recipe phase %s is incomplete", ErrMergeModuleInvalid, phase.Phase)
|
||||
}
|
||||
seen[phase.Phase] = struct{}{}
|
||||
out = append(out, phase)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return recipePhaseOrder(out[i].Phase) < recipePhaseOrder(out[j].Phase)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func recipeHasPhase(phases []MergePhaseRecipe, phase string) bool {
|
||||
phase = normalizeID(phase)
|
||||
for _, item := range phases {
|
||||
if item.Phase == phase {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func recipePhaseOrder(phase string) int {
|
||||
switch phase {
|
||||
case MergeModulePhaseGenDB:
|
||||
return 1
|
||||
case MergeModulePhaseMerge:
|
||||
return 2
|
||||
case MergeModulePhaseRollback:
|
||||
return 3
|
||||
default:
|
||||
return 100
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMergeRecipes(recipes []MergeModuleRecipe) []MergeModuleRecipe {
|
||||
if len(recipes) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]MergeModuleRecipe, len(recipes))
|
||||
for i, recipe := range recipes {
|
||||
out[i] = cloneMergeRecipe(recipe)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMergeRecipe(recipe MergeModuleRecipe) MergeModuleRecipe {
|
||||
recipe.Features = append([]string(nil), recipe.Features...)
|
||||
recipe.Inputs = cloneMergeStrings(recipe.Inputs)
|
||||
recipe.Phases = clonePhaseRecipes(recipe.Phases)
|
||||
recipe.Acceptance = cloneMergeStrings(recipe.Acceptance)
|
||||
recipe.Notes = cloneMergeStrings(recipe.Notes)
|
||||
return recipe
|
||||
}
|
||||
|
||||
func clonePhaseRecipes(phases []MergePhaseRecipe) []MergePhaseRecipe {
|
||||
if len(phases) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]MergePhaseRecipe, len(phases))
|
||||
for i, phase := range phases {
|
||||
out[i] = MergePhaseRecipe{
|
||||
Phase: phase.Phase,
|
||||
Goal: phase.Goal,
|
||||
Steps: cloneMergeStrings(phase.Steps),
|
||||
Evidence: cloneMergeStrings(phase.Evidence),
|
||||
FailurePolicy: phase.FailurePolicy,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
package servergroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrMergeModuleInvalid 表示合服模块缺少名称、阶段或执行函数。
|
||||
ErrMergeModuleInvalid = errors.New("server group merge module is invalid")
|
||||
// ErrMergeModuleExists 表示合服模块名称已经注册。
|
||||
ErrMergeModuleExists = errors.New("server group merge module already exists")
|
||||
// ErrMergeModuleFailed 表示合服模块执行失败。
|
||||
ErrMergeModuleFailed = errors.New("server group merge module failed")
|
||||
)
|
||||
|
||||
const (
|
||||
// MergeModulePhaseGenDB 表示合服模块处于预生成数据库阶段。
|
||||
MergeModulePhaseGenDB = "gendb"
|
||||
// MergeModulePhaseMerge 表示合服模块处于正式合并阶段。
|
||||
MergeModulePhaseMerge = "merge"
|
||||
// MergeModulePhaseRollback 表示合服模块处于回滚阶段。
|
||||
MergeModulePhaseRollback = "rollback"
|
||||
)
|
||||
|
||||
// MergeModuleFunc 是项目侧合服模块的统一执行函数。
|
||||
type MergeModuleFunc func(context.Context, MergeModuleInput) (MergeModuleResult, error)
|
||||
|
||||
// MergeGenDBFunc 对应合服前的数据生成和冲突预检阶段。
|
||||
type MergeGenDBFunc = MergeModuleFunc
|
||||
|
||||
// MergeApplyFunc 对应业务数据真正迁移或合并阶段。
|
||||
type MergeApplyFunc = MergeModuleFunc
|
||||
|
||||
// MergeRollbackFunc 对应业务数据回滚或补偿阶段。
|
||||
type MergeRollbackFunc = MergeModuleFunc
|
||||
|
||||
// MergeModuleSpec 描述一个项目侧合服模块,平台只保存通用契约,不保存具体业务表结构。
|
||||
type MergeModuleSpec struct {
|
||||
// Name 是模块唯一名,建议使用 profile、mail、leaderboard 这类稳定业务域名。
|
||||
Name string `json:"name"`
|
||||
// Description 描述模块职责,主要用于报告和运营后台展示。
|
||||
Description string `json:"description,omitempty"`
|
||||
// Features 是模块影响的功能域,用于和合服请求里的 feature 对齐。
|
||||
Features []string `json:"features,omitempty"`
|
||||
// Required 表示该模块是项目合服的必跑模块,必须同时提供三段处理器。
|
||||
Required bool `json:"required"`
|
||||
// GenDB 负责合服前生成预检数据、冲突方案和 dry-run 证据。
|
||||
GenDB MergeGenDBFunc `json:"-"`
|
||||
// Merge 负责合服窗口内的真实业务数据合并。
|
||||
Merge MergeApplyFunc `json:"-"`
|
||||
// Rollback 负责合服失败后的业务回滚或补偿。
|
||||
Rollback MergeRollbackFunc `json:"-"`
|
||||
}
|
||||
|
||||
// MergeModuleSnapshot 是注册器对外暴露的只读模块摘要。
|
||||
type MergeModuleSnapshot struct {
|
||||
// Name 是模块唯一名。
|
||||
Name string `json:"name"`
|
||||
// Description 是模块职责说明。
|
||||
Description string `json:"description,omitempty"`
|
||||
// Features 是模块影响的功能域。
|
||||
Features []string `json:"features,omitempty"`
|
||||
// Required 表示模块是否为必跑模块。
|
||||
Required bool `json:"required"`
|
||||
// HasGenDB 表示是否注册了预检处理器。
|
||||
HasGenDB bool `json:"has_gendb"`
|
||||
// HasMerge 表示是否注册了合并处理器。
|
||||
HasMerge bool `json:"has_merge"`
|
||||
// HasRollback 表示是否注册了回滚处理器。
|
||||
HasRollback bool `json:"has_rollback"`
|
||||
}
|
||||
|
||||
// MergeModuleInput 是传给项目侧合服模块的通用上下文快照。
|
||||
type MergeModuleInput struct {
|
||||
// Request 是当前合服请求。
|
||||
Request MergeRequest `json:"request"`
|
||||
// Current 是执行前的平台路由计划快照。
|
||||
Current Plan `json:"current"`
|
||||
// DryRun 是平台 dry-run 结果,模块侧可以复用其中的回滚点和写检查。
|
||||
DryRun MergeDryRun `json:"dry_run"`
|
||||
// Steps 是平台迁移步骤快照。
|
||||
Steps []MigrationStep `json:"steps,omitempty"`
|
||||
// Findings 是前置计划已有的风险项,供后续阶段生成证据时参考。
|
||||
Findings []ConflictFinding `json:"findings,omitempty"`
|
||||
// Meta 是项目编排器传入的通用元数据。
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// MergeModuleResult 是项目侧模块执行后的通用回执。
|
||||
type MergeModuleResult struct {
|
||||
// Module 是执行结果所属模块,空值会回填注册器里的模块名。
|
||||
Module string `json:"module"`
|
||||
// Phase 是执行阶段,取值为 gendb、merge 或 rollback。
|
||||
Phase string `json:"phase"`
|
||||
// OK 表示该模块阶段没有 blocker finding。
|
||||
OK bool `json:"ok"`
|
||||
// Skipped 表示模块与当前请求无关,不进入阶段报告。
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
// Findings 是模块返回的冲突、风险或阻断项。
|
||||
Findings []ConflictFinding `json:"findings,omitempty"`
|
||||
// Evidence 是模块返回的证据路径、批次号或校验摘要。
|
||||
Evidence []string `json:"evidence,omitempty"`
|
||||
// Rollback 是模块建议的回滚动作或补偿步骤。
|
||||
Rollback []string `json:"rollback,omitempty"`
|
||||
// Meta 是模块返回的通用元数据。
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// MergeModuleRunReport 汇总一次模块级合服阶段执行结果,用于运营审计和上线证据归档。
|
||||
type MergeModuleRunReport struct {
|
||||
// Phase 是本次报告对应的阶段,取值为 gendb、merge 或 rollback。
|
||||
Phase string `json:"phase"`
|
||||
// Request 是合服请求快照,便于证据脱离执行进程后仍能复核。
|
||||
Request MergeRequest `json:"request"`
|
||||
// GeneratedAt 是报告生成时间,统一保存为 UTC。
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
// OK 表示本阶段没有 blocker finding,业务错误应直接返回 error。
|
||||
OK bool `json:"ok"`
|
||||
// Results 保存每个模块的原始阶段回执。
|
||||
Results []MergeModuleResult `json:"results,omitempty"`
|
||||
// Findings 汇总所有模块返回的冲突或风险项。
|
||||
Findings []ConflictFinding `json:"findings,omitempty"`
|
||||
// Evidence 汇总所有模块返回的证据路径、批次号或校验摘要。
|
||||
Evidence []string `json:"evidence,omitempty"`
|
||||
// Rollback 汇总所有模块声明的回滚动作或补偿步骤。
|
||||
Rollback []string `json:"rollback,omitempty"`
|
||||
// Meta 保存项目编排器附加的通用元数据。
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// MergeModuleCoverageReport 描述合服请求 feature 与模块处理器之间的覆盖关系。
|
||||
type MergeModuleCoverageReport struct {
|
||||
// Phase 是本次覆盖检查对应的阶段。
|
||||
Phase string `json:"phase"`
|
||||
// Request 是被检查的合服请求快照。
|
||||
Request MergeRequest `json:"request"`
|
||||
// OK 表示所有声明的 feature 都有对应阶段处理器覆盖。
|
||||
OK bool `json:"ok"`
|
||||
// Features 是本次请求声明需要检查或冻结的功能域。
|
||||
Features []string `json:"features,omitempty"`
|
||||
// Missing 是缺少模块处理器覆盖的功能域。
|
||||
Missing []string `json:"missing,omitempty"`
|
||||
// Modules 是注册器当前模块快照。
|
||||
Modules []MergeModuleSnapshot `json:"modules,omitempty"`
|
||||
// Findings 是缺失覆盖转换出的 blocker 风险项。
|
||||
Findings []ConflictFinding `json:"findings,omitempty"`
|
||||
}
|
||||
|
||||
// MergeOpRegistry 把成熟项目里按模块注册 GenDB/Merge/Rollback 的经验抽成龙恒通用扩展点。
|
||||
type MergeOpRegistry struct {
|
||||
mu sync.RWMutex
|
||||
modules map[string]MergeModuleSpec
|
||||
}
|
||||
|
||||
// NewMergeOpRegistry 创建模块注册器,并按注册顺序校验重复和必填处理器。
|
||||
func NewMergeOpRegistry(specs ...MergeModuleSpec) (*MergeOpRegistry, error) {
|
||||
registry := &MergeOpRegistry{modules: make(map[string]MergeModuleSpec, len(specs))}
|
||||
for _, spec := range specs {
|
||||
if err := registry.Register(spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
// Register 注册一个项目侧合服模块,重复模块名会被拒绝。
|
||||
func (r *MergeOpRegistry) Register(spec MergeModuleSpec) error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("%w: registry is nil", ErrMergeModuleInvalid)
|
||||
}
|
||||
normalized, err := normMergeSpec(spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, exists := r.modules[normalized.Name]; exists {
|
||||
return fmt.Errorf("%w: %s", ErrMergeModuleExists, normalized.Name)
|
||||
}
|
||||
r.modules[normalized.Name] = normalized
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshot 返回按模块名排序的注册器快照,调用方可以安全修改返回值。
|
||||
func (r *MergeOpRegistry) Snapshot() []MergeModuleSnapshot {
|
||||
specs := r.snapshotSpecs()
|
||||
if len(specs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]MergeModuleSnapshot, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
out = append(out, MergeModuleSnapshot{
|
||||
Name: spec.Name,
|
||||
Description: spec.Description,
|
||||
Features: append([]string(nil), spec.Features...),
|
||||
Required: spec.Required,
|
||||
HasGenDB: spec.GenDB != nil,
|
||||
HasMerge: spec.Merge != nil,
|
||||
HasRollback: spec.Rollback != nil,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildChecker 把注册器转换为合服迁移计划可用的冲突检查器。
|
||||
func (r *MergeOpRegistry) BuildChecker() ConflictChecker {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return ConflictCheckFunc(func(ctx context.Context, request ConflictCheckRequest) ([]ConflictFinding, error) {
|
||||
coverage, err := r.CheckCoverage(request.DryRun.Request, MergeModulePhaseGenDB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results, err := r.RunGenDB(ctx, MergeModuleInput{
|
||||
Request: request.DryRun.Request,
|
||||
Current: request.Current,
|
||||
DryRun: request.DryRun,
|
||||
Steps: request.Steps,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
findings := cloneClashFindings(coverage.Findings)
|
||||
for _, result := range results {
|
||||
findings = append(findings, result.Findings...)
|
||||
}
|
||||
return findings, nil
|
||||
})
|
||||
}
|
||||
|
||||
// CheckCoverage 检查合服请求声明的 feature 是否有对应阶段模块处理器覆盖。
|
||||
func (r *MergeOpRegistry) CheckCoverage(request MergeRequest, phase string) (MergeModuleCoverageReport, error) {
|
||||
phase = normalizeID(phase)
|
||||
if !validMergePhase(phase) {
|
||||
return MergeModuleCoverageReport{}, fmt.Errorf("%w: invalid coverage phase %s", ErrMergeModuleInvalid, phase)
|
||||
}
|
||||
request = normMergeReq(request)
|
||||
specs := r.snapshotSpecs()
|
||||
features := requestedFeatures(request)
|
||||
report := MergeModuleCoverageReport{
|
||||
Phase: phase,
|
||||
Request: request,
|
||||
OK: true,
|
||||
Features: features,
|
||||
Modules: snapshotsFromSpecs(specs),
|
||||
}
|
||||
if len(features) == 0 || !hasFeatureDecls(specs) {
|
||||
return report, nil
|
||||
}
|
||||
for _, feature := range features {
|
||||
if mergeFeatureCovered(specs, phase, feature) {
|
||||
continue
|
||||
}
|
||||
report.Missing = append(report.Missing, feature)
|
||||
report.Findings = append(report.Findings, ConflictFinding{
|
||||
Code: "merge_module_feature_missing",
|
||||
Severity: MigrationSeverityBlocker,
|
||||
Subject: feature,
|
||||
Detail: fmt.Sprintf("合服 feature %s 缺少 %s 阶段模块处理器", feature, phase),
|
||||
})
|
||||
}
|
||||
report.Missing = normalizeIDs(report.Missing)
|
||||
report.Findings = cloneClashFindings(report.Findings)
|
||||
report.OK = len(report.Missing) == 0
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// CoverageFindings 返回覆盖缺口对应的 blocker findings,便于接入自定义计划检查器。
|
||||
func (r *MergeOpRegistry) CoverageFindings(request MergeRequest, phase string) ([]ConflictFinding, error) {
|
||||
report, err := r.CheckCoverage(request, phase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cloneClashFindings(report.Findings), nil
|
||||
}
|
||||
|
||||
// RunGenDB 执行所有已注册的合服前数据生成和冲突预检模块。
|
||||
func (r *MergeOpRegistry) RunGenDB(ctx context.Context, input MergeModuleInput) ([]MergeModuleResult, error) {
|
||||
return r.runPhase(ctx, MergeModulePhaseGenDB, input)
|
||||
}
|
||||
|
||||
// RunGenDBReport 执行合服前数据生成和冲突预检,并生成可归档报告。
|
||||
func (r *MergeOpRegistry) RunGenDBReport(ctx context.Context, input MergeModuleInput) (MergeModuleRunReport, error) {
|
||||
return r.runPhaseReport(ctx, MergeModulePhaseGenDB, input)
|
||||
}
|
||||
|
||||
// RunMerge 执行所有已注册的业务合并模块。
|
||||
func (r *MergeOpRegistry) RunMerge(ctx context.Context, input MergeModuleInput) ([]MergeModuleResult, error) {
|
||||
return r.runPhase(ctx, MergeModulePhaseMerge, input)
|
||||
}
|
||||
|
||||
// RunMergeReport 执行业务合并模块,并生成可归档报告。
|
||||
func (r *MergeOpRegistry) RunMergeReport(ctx context.Context, input MergeModuleInput) (MergeModuleRunReport, error) {
|
||||
return r.runPhaseReport(ctx, MergeModulePhaseMerge, input)
|
||||
}
|
||||
|
||||
// RunRollback 执行所有已注册的业务回滚模块。
|
||||
func (r *MergeOpRegistry) RunRollback(ctx context.Context, input MergeModuleInput) ([]MergeModuleResult, error) {
|
||||
return r.runPhase(ctx, MergeModulePhaseRollback, input)
|
||||
}
|
||||
|
||||
// RunRollbackReport 执行业务回滚模块,并生成可归档报告。
|
||||
func (r *MergeOpRegistry) RunRollbackReport(ctx context.Context, input MergeModuleInput) (MergeModuleRunReport, error) {
|
||||
return r.runPhaseReport(ctx, MergeModulePhaseRollback, input)
|
||||
}
|
||||
|
||||
// MergeModuleInputFromPlan 根据平台迁移计划生成项目侧模块输入,便于业务编排器复用同一份 dry-run 证据。
|
||||
func MergeModuleInputFromPlan(current Plan, plan MigrationPlan) MergeModuleInput {
|
||||
return MergeModuleInput{
|
||||
Request: plan.Request,
|
||||
Current: current,
|
||||
DryRun: plan.DryRun,
|
||||
Steps: plan.Steps,
|
||||
Findings: plan.Findings,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MergeOpRegistry) runPhase(ctx context.Context, phase string, input MergeModuleInput) ([]MergeModuleResult, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
phase = normalizeID(phase)
|
||||
if !validMergePhase(phase) {
|
||||
return nil, fmt.Errorf("%w: invalid phase %s", ErrMergeModuleInvalid, phase)
|
||||
}
|
||||
specs := r.snapshotSpecs()
|
||||
if len(specs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
input = cloneMergeInput(input)
|
||||
results := make([]MergeModuleResult, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return results, err
|
||||
}
|
||||
fn := mergeFuncForPhase(spec, phase)
|
||||
if fn == nil {
|
||||
continue
|
||||
}
|
||||
result, err := fn(ctx, cloneMergeInput(input))
|
||||
if err != nil {
|
||||
return results, fmt.Errorf("%w: %s %s: %w", ErrMergeModuleFailed, spec.Name, phase, err)
|
||||
}
|
||||
normalized, err := normMergeResult(spec.Name, phase, result)
|
||||
if err != nil {
|
||||
return results, err
|
||||
}
|
||||
if normalized.Skipped {
|
||||
continue
|
||||
}
|
||||
results = append(results, normalized)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (r *MergeOpRegistry) runPhaseReport(ctx context.Context, phase string, input MergeModuleInput) (MergeModuleRunReport, error) {
|
||||
coverage, err := r.CheckCoverage(mergeInputReq(input), phase)
|
||||
if err != nil {
|
||||
return MergeModuleRunReport{}, err
|
||||
}
|
||||
results, err := r.runPhase(ctx, phase, input)
|
||||
if err != nil {
|
||||
return MergeModuleRunReport{}, err
|
||||
}
|
||||
report, err := newMergeRunReport(phase, input, results, time.Now().UTC())
|
||||
if err != nil {
|
||||
return MergeModuleRunReport{}, err
|
||||
}
|
||||
report.Findings = append(cloneClashFindings(coverage.Findings), report.Findings...)
|
||||
if !coverage.OK {
|
||||
report.OK = false
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func (r *MergeOpRegistry) snapshotSpecs() []MergeModuleSpec {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if len(r.modules) == 0 {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(r.modules))
|
||||
for name := range r.modules {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
out := make([]MergeModuleSpec, 0, len(names))
|
||||
for _, name := range names {
|
||||
out = append(out, cloneMergeModuleSpec(r.modules[name]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normMergeSpec(spec MergeModuleSpec) (MergeModuleSpec, error) {
|
||||
spec.Name = normalizeID(spec.Name)
|
||||
spec.Description = firstNonEmpty(spec.Description)
|
||||
spec.Features = normalizeIDs(spec.Features)
|
||||
if spec.Name == "" {
|
||||
return MergeModuleSpec{}, fmt.Errorf("%w: module name is required", ErrMergeModuleInvalid)
|
||||
}
|
||||
if spec.GenDB == nil && spec.Merge == nil && spec.Rollback == nil {
|
||||
return MergeModuleSpec{}, fmt.Errorf("%w: module %s has no handlers", ErrMergeModuleInvalid, spec.Name)
|
||||
}
|
||||
if spec.Required && (spec.GenDB == nil || spec.Merge == nil || spec.Rollback == nil) {
|
||||
return MergeModuleSpec{}, fmt.Errorf("%w: required module %s must provide gendb, merge and rollback handlers", ErrMergeModuleInvalid, spec.Name)
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func snapshotsFromSpecs(specs []MergeModuleSpec) []MergeModuleSnapshot {
|
||||
if len(specs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]MergeModuleSnapshot, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
out = append(out, MergeModuleSnapshot{
|
||||
Name: spec.Name,
|
||||
Description: spec.Description,
|
||||
Features: append([]string(nil), spec.Features...),
|
||||
Required: spec.Required,
|
||||
HasGenDB: spec.GenDB != nil,
|
||||
HasMerge: spec.Merge != nil,
|
||||
HasRollback: spec.Rollback != nil,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMergeModuleSpec(spec MergeModuleSpec) MergeModuleSpec {
|
||||
spec.Features = append([]string(nil), spec.Features...)
|
||||
return spec
|
||||
}
|
||||
|
||||
func mergeFuncForPhase(spec MergeModuleSpec, phase string) MergeModuleFunc {
|
||||
switch phase {
|
||||
case MergeModulePhaseGenDB:
|
||||
return spec.GenDB
|
||||
case MergeModulePhaseMerge:
|
||||
return spec.Merge
|
||||
case MergeModulePhaseRollback:
|
||||
return spec.Rollback
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validMergePhase(phase string) bool {
|
||||
switch phase {
|
||||
case MergeModulePhaseGenDB, MergeModulePhaseMerge, MergeModulePhaseRollback:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func requestedFeatures(request MergeRequest) []string {
|
||||
request = normMergeReq(request)
|
||||
features := append([]string(nil), request.CheckFeatures...)
|
||||
features = append(features, request.BlockFeatures...)
|
||||
return normalizeIDs(features)
|
||||
}
|
||||
|
||||
func hasFeatureDecls(specs []MergeModuleSpec) bool {
|
||||
for _, spec := range specs {
|
||||
if len(spec.Features) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mergeFeatureCovered(specs []MergeModuleSpec, phase, feature string) bool {
|
||||
feature = normalizeID(feature)
|
||||
for _, spec := range specs {
|
||||
if len(spec.Features) == 0 || !containsID(spec.Features, feature) {
|
||||
continue
|
||||
}
|
||||
if mergeFuncForPhase(spec, phase) != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mergeInputReq(input MergeModuleInput) MergeRequest {
|
||||
request := normMergeReq(input.Request)
|
||||
if request.ID != "" {
|
||||
return request
|
||||
}
|
||||
return normMergeReq(input.DryRun.Request)
|
||||
}
|
||||
|
||||
func cloneMergeInput(input MergeModuleInput) MergeModuleInput {
|
||||
return MergeModuleInput{
|
||||
Request: mergeInputReq(input),
|
||||
Current: clonePlan(input.Current),
|
||||
DryRun: cloneMergeDryRun(input.DryRun),
|
||||
Steps: cloneMigrationSteps(input.Steps),
|
||||
Findings: cloneClashFindings(input.Findings),
|
||||
Meta: normalizeStringMap(input.Meta),
|
||||
}
|
||||
}
|
||||
|
||||
func normMergeResult(module, phase string, result MergeModuleResult) (MergeModuleResult, error) {
|
||||
result.Module = firstNonEmpty(result.Module, module)
|
||||
result.Phase = firstNonEmpty(result.Phase, phase)
|
||||
result.Evidence = cloneMergeStrings(result.Evidence)
|
||||
result.Rollback = cloneMergeStrings(result.Rollback)
|
||||
result.Meta = normalizeStringMap(result.Meta)
|
||||
if result.Module == "" || result.Phase == "" {
|
||||
return MergeModuleResult{}, fmt.Errorf("%w: result module and phase are required", ErrMergeModuleInvalid)
|
||||
}
|
||||
if !validMergePhase(result.Phase) {
|
||||
return MergeModuleResult{}, fmt.Errorf("%w: result %s has invalid phase %s", ErrMergeModuleInvalid, result.Module, result.Phase)
|
||||
}
|
||||
findings, err := normalizeFindings(result.Module, result.Findings)
|
||||
if err != nil {
|
||||
return MergeModuleResult{}, err
|
||||
}
|
||||
result.Findings = findings
|
||||
result.OK = !hasBlockerFinding(result.Findings)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func newMergeRunReport(phase string, input MergeModuleInput, results []MergeModuleResult, generatedAt time.Time) (MergeModuleRunReport, error) {
|
||||
phase = normalizeID(phase)
|
||||
if !validMergePhase(phase) {
|
||||
return MergeModuleRunReport{}, fmt.Errorf("%w: invalid report phase %s", ErrMergeModuleInvalid, phase)
|
||||
}
|
||||
if generatedAt.IsZero() {
|
||||
generatedAt = time.Now().UTC()
|
||||
} else {
|
||||
generatedAt = generatedAt.UTC()
|
||||
}
|
||||
input = cloneMergeInput(input)
|
||||
results = cloneMergeResults(results)
|
||||
report := MergeModuleRunReport{
|
||||
Phase: phase,
|
||||
Request: input.Request,
|
||||
GeneratedAt: generatedAt,
|
||||
OK: true,
|
||||
Results: results,
|
||||
Meta: cloneStringMap(input.Meta),
|
||||
}
|
||||
for _, result := range results {
|
||||
report.Findings = append(report.Findings, result.Findings...)
|
||||
report.Evidence = append(report.Evidence, result.Evidence...)
|
||||
report.Rollback = append(report.Rollback, result.Rollback...)
|
||||
if !result.OK {
|
||||
report.OK = false
|
||||
}
|
||||
}
|
||||
report.Findings = cloneClashFindings(report.Findings)
|
||||
report.Evidence = cloneMergeStrings(report.Evidence)
|
||||
report.Rollback = cloneMergeStrings(report.Rollback)
|
||||
if hasBlockerFinding(report.Findings) {
|
||||
report.OK = false
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func normalizeFindings(module string, findings []ConflictFinding) ([]ConflictFinding, error) {
|
||||
if len(findings) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]ConflictFinding, 0, len(findings))
|
||||
for _, finding := range findings {
|
||||
if finding.Subject == "" {
|
||||
finding.Subject = module
|
||||
}
|
||||
normalized, err := normConflict(finding)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, normalized)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func cloneMergeResults(results []MergeModuleResult) []MergeModuleResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]MergeModuleResult, len(results))
|
||||
for i, result := range results {
|
||||
out[i] = MergeModuleResult{
|
||||
Module: result.Module,
|
||||
Phase: result.Phase,
|
||||
OK: result.OK,
|
||||
Skipped: result.Skipped,
|
||||
Findings: cloneClashFindings(result.Findings),
|
||||
Evidence: cloneMergeStrings(result.Evidence),
|
||||
Rollback: cloneMergeStrings(result.Rollback),
|
||||
Meta: cloneStringMap(result.Meta),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMergeStrings(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = firstNonEmpty(value)
|
||||
if value != "" {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package servergroup
|
||||
|
||||
import "context"
|
||||
|
||||
const (
|
||||
// MergeModuleNameProfile 是角色档案合服模块名。
|
||||
MergeModuleNameProfile = "profile"
|
||||
// MergeModuleNameMail 是邮件合服模块名。
|
||||
MergeModuleNameMail = "mail"
|
||||
// MergeModuleNameLeaderboard 是排行榜合服模块名。
|
||||
MergeModuleNameLeaderboard = "leaderboard"
|
||||
// MergeModuleNameGuild 是公会合服模块名。
|
||||
MergeModuleNameGuild = "guild"
|
||||
// MergeModuleNameFriend 是好友合服模块名。
|
||||
MergeModuleNameFriend = "friend"
|
||||
// MergeModuleNameAuction 是拍卖行合服模块名。
|
||||
MergeModuleNameAuction = "auction"
|
||||
// MergeModuleNameActivity 是活动合服模块名。
|
||||
MergeModuleNameActivity = "activity"
|
||||
// MergeModuleNamePayment 是支付合服模块名。
|
||||
MergeModuleNamePayment = "payment"
|
||||
// MergeModuleNameServerInfo 是服务器信息合服模块名。
|
||||
MergeModuleNameServerInfo = "server_merge_info"
|
||||
)
|
||||
|
||||
const (
|
||||
// MergeFeatureProfile 是角色档案合服功能域。
|
||||
MergeFeatureProfile = "profile"
|
||||
// MergeFeatureRoleName 是角色名冲突检查功能域。
|
||||
MergeFeatureRoleName = "role_name"
|
||||
// MergeFeaturePlayerMirror 是角色镜像数据功能域。
|
||||
MergeFeaturePlayerMirror = "player_mirror"
|
||||
// MergeFeatureAccountClean 是账号清理功能域。
|
||||
MergeFeatureAccountClean = "account_clean"
|
||||
// MergeFeatureMail 是邮件合服功能域。
|
||||
MergeFeatureMail = "mail"
|
||||
// MergeFeatureGlobalMail 是全服邮件合服功能域。
|
||||
MergeFeatureGlobalMail = "global_mail"
|
||||
// MergeFeatureLeaderboard 是排行榜合服功能域。
|
||||
MergeFeatureLeaderboard = "leaderboard"
|
||||
// MergeFeatureRank 是通用排名功能域。
|
||||
MergeFeatureRank = "rank"
|
||||
// MergeFeatureLocalRank 是本服排名功能域。
|
||||
MergeFeatureLocalRank = "local_rank"
|
||||
// MergeFeatureCrossRank 是跨服排名功能域。
|
||||
MergeFeatureCrossRank = "cross_rank"
|
||||
// MergeFeatureGuild 是公会合服功能域。
|
||||
MergeFeatureGuild = "guild"
|
||||
// MergeFeatureGuildName 是公会名冲突检查功能域。
|
||||
MergeFeatureGuildName = "guild_name"
|
||||
// MergeFeatureGuildRank 是公会排行功能域。
|
||||
MergeFeatureGuildRank = "guild_rank"
|
||||
// MergeFeatureGuildMirror 是公会镜像数据功能域。
|
||||
MergeFeatureGuildMirror = "guild_mirror"
|
||||
// MergeFeatureFriend 是好友合服功能域。
|
||||
MergeFeatureFriend = "friend"
|
||||
// MergeFeatureFriendRecharge 是好友充值关系功能域。
|
||||
MergeFeatureFriendRecharge = "friend_recharge"
|
||||
// MergeFeatureAuction 是拍卖行合服功能域。
|
||||
MergeFeatureAuction = "auction"
|
||||
// MergeFeatureWarzoneAuction 是战区拍卖行功能域。
|
||||
MergeFeatureWarzoneAuction = "warzone_auction"
|
||||
// MergeFeatureActivity 是活动合服功能域。
|
||||
MergeFeatureActivity = "activity"
|
||||
// MergeFeatureActivityState 是活动状态功能域。
|
||||
MergeFeatureActivityState = "activity_state"
|
||||
// MergeFeatureActivityRank 是活动排行功能域。
|
||||
MergeFeatureActivityRank = "activity_rank"
|
||||
// MergeFeaturePayment 是支付合服功能域。
|
||||
MergeFeaturePayment = "payment"
|
||||
// MergeFeatureOrder 是订单合服功能域。
|
||||
MergeFeatureOrder = "order"
|
||||
// MergeFeatureIAP 是内购回执合服功能域。
|
||||
MergeFeatureIAP = "iap"
|
||||
// MergeFeatureServerGroup 是服务器分组路由功能域。
|
||||
MergeFeatureServerGroup = "servergroup"
|
||||
// MergeFeatureServerInfo 是服务器信息合服功能域。
|
||||
MergeFeatureServerInfo = "server_merge_info"
|
||||
)
|
||||
|
||||
// MergeModuleHandlers 保存项目侧对一个默认合服模块的三段式处理器。
|
||||
type MergeModuleHandlers struct {
|
||||
// GenDB 负责合服前生成预检数据、冲突方案和 dry-run 证据。
|
||||
GenDB MergeGenDBFunc
|
||||
// Merge 负责合服窗口内的真实业务数据合并。
|
||||
Merge MergeApplyFunc
|
||||
// Rollback 负责合服失败后的业务回滚或补偿。
|
||||
Rollback MergeRollbackFunc
|
||||
}
|
||||
|
||||
// MergeModuleTemplate 描述从成熟项目合服对象中抽出的默认模块规格,不包含项目私有表结构。
|
||||
type MergeModuleTemplate struct {
|
||||
// Name 是默认模块名,和 MergeModuleSpec.Name 保持一致。
|
||||
Name string `json:"name"`
|
||||
// Description 是模块职责说明,用于报告和运营后台展示。
|
||||
Description string `json:"description,omitempty"`
|
||||
// Features 是该模块覆盖的合服 feature 别名集合。
|
||||
Features []string `json:"features,omitempty"`
|
||||
// Required 表示正式项目接入时该模块是否必须提供三段处理器。
|
||||
Required bool `json:"required"`
|
||||
// Checks 是项目侧实现至少要输出或验证的检查项。
|
||||
Checks []string `json:"checks,omitempty"`
|
||||
// Evidence 是项目侧实现应归档的证据类型说明。
|
||||
Evidence []string `json:"evidence,omitempty"`
|
||||
// Rollback 是项目侧实现应准备的回滚动作说明。
|
||||
Rollback []string `json:"rollback,omitempty"`
|
||||
}
|
||||
|
||||
// MergeModuleTemplates 返回龙恒默认合服模块模板,调用方可以安全修改返回值。
|
||||
func MergeModuleTemplates() []MergeModuleTemplate {
|
||||
return cloneMergeTemplates(defModuleTpls)
|
||||
}
|
||||
|
||||
// FindMergeTemplate 按模块名查找默认合服模板。
|
||||
func FindMergeTemplate(name string) (MergeModuleTemplate, bool) {
|
||||
name = normalizeID(name)
|
||||
for _, template := range defModuleTpls {
|
||||
if template.Name == name {
|
||||
return cloneMergeTemplate(template), true
|
||||
}
|
||||
}
|
||||
return MergeModuleTemplate{}, false
|
||||
}
|
||||
|
||||
// Spec 用项目侧处理器把模板转换为可注册的合服模块规格。
|
||||
func (t MergeModuleTemplate) Spec(handlers MergeModuleHandlers) (MergeModuleSpec, error) {
|
||||
template, err := normModuleTpl(t)
|
||||
if err != nil {
|
||||
return MergeModuleSpec{}, err
|
||||
}
|
||||
return normMergeSpec(MergeModuleSpec{
|
||||
Name: template.Name,
|
||||
Description: template.Description,
|
||||
Features: template.Features,
|
||||
Required: template.Required,
|
||||
GenDB: handlers.GenDB,
|
||||
Merge: handlers.Merge,
|
||||
Rollback: handlers.Rollback,
|
||||
})
|
||||
}
|
||||
|
||||
// MergeModuleSpecs 返回带占位处理器的默认模块规格,占位处理器会在请求命中对应 feature 时阻断计划。
|
||||
func MergeModuleSpecs() ([]MergeModuleSpec, error) {
|
||||
templates := MergeModuleTemplates()
|
||||
specs := make([]MergeModuleSpec, 0, len(templates))
|
||||
for _, template := range templates {
|
||||
spec, err := tmplPlaceholderSpec(template)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
specs = append(specs, spec)
|
||||
}
|
||||
return specs, nil
|
||||
}
|
||||
|
||||
// BuildMergeModuleSpecs 用项目侧实现覆盖默认占位模块,未覆盖模块仍会在请求命中时阻断。
|
||||
func BuildMergeModuleSpecs(overrides ...MergeModuleSpec) ([]MergeModuleSpec, error) {
|
||||
defaults, err := MergeModuleSpecs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byName := make(map[string]MergeModuleSpec, len(defaults)+len(overrides))
|
||||
names := make([]string, 0, len(defaults)+len(overrides))
|
||||
for _, spec := range defaults {
|
||||
byName[spec.Name] = spec
|
||||
names = append(names, spec.Name)
|
||||
}
|
||||
for _, override := range overrides {
|
||||
spec, err := templateOverrideSpec(override)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := byName[spec.Name]; !exists {
|
||||
names = append(names, spec.Name)
|
||||
}
|
||||
byName[spec.Name] = spec
|
||||
}
|
||||
names = normalizeIDs(names)
|
||||
specs := make([]MergeModuleSpec, 0, len(names))
|
||||
for _, name := range names {
|
||||
specs = append(specs, byName[name])
|
||||
}
|
||||
return specs, nil
|
||||
}
|
||||
|
||||
// NewDefaultMergeRegistry 创建带默认模板保护的注册器,适合新项目接入期防止遗漏模块。
|
||||
func NewDefaultMergeRegistry(overrides ...MergeModuleSpec) (*MergeOpRegistry, error) {
|
||||
specs, err := BuildMergeModuleSpecs(overrides...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewMergeOpRegistry(specs...)
|
||||
}
|
||||
|
||||
var defModuleTpls = []MergeModuleTemplate{
|
||||
{
|
||||
Name: MergeModuleNameProfile,
|
||||
Description: "玩家档案、角色名、账号清理和玩家镜像合服模板",
|
||||
Features: []string{MergeFeatureProfile, MergeFeatureRoleName, MergeFeaturePlayerMirror, MergeFeatureAccountClean},
|
||||
Required: true,
|
||||
Checks: []string{"角色名唯一键冲突", "账号绑定与玩家镜像完整性", "低活跃账号清理白名单", "Profile 抽样读写校验"},
|
||||
Evidence: []string{"角色改名方案", "玩家镜像合并摘要", "账号清理名单", "Profile 抽样校验报告"},
|
||||
Rollback: []string{"恢复角色名索引", "恢复玩家镜像键", "恢复被清理账号快照"},
|
||||
},
|
||||
{
|
||||
Name: MergeModuleNameMail,
|
||||
Description: "个人邮件、全服邮件和合服补偿邮件模板",
|
||||
Features: []string{MergeFeatureMail, MergeFeatureGlobalMail},
|
||||
Required: true,
|
||||
Checks: []string{"未领取邮件迁移", "全服邮件过滤策略", "补偿邮件去重", "邮件附件幂等发放"},
|
||||
Evidence: []string{"邮件迁移批次", "全服邮件保留清单", "附件发放校验摘要"},
|
||||
Rollback: []string{"删除新服邮件批次", "恢复旧服邮件索引"},
|
||||
},
|
||||
{
|
||||
Name: MergeModuleNameLeaderboard,
|
||||
Description: "本地榜、跨服榜和排行榜补充信息合服模板",
|
||||
Features: []string{MergeFeatureLeaderboard, MergeFeatureRank, MergeFeatureLocalRank, MergeFeatureCrossRank},
|
||||
Required: true,
|
||||
Checks: []string{"榜单排序稳定性", "同玩家多榜去重", "赛季结算窗口", "榜单补充信息一致性"},
|
||||
Evidence: []string{"榜单重算摘要", "赛季窗口确认", "榜单抽样校验报告"},
|
||||
Rollback: []string{"恢复旧榜快照", "撤销新榜重算批次"},
|
||||
},
|
||||
{
|
||||
Name: MergeModuleNameGuild,
|
||||
Description: "公会、公会名、公会镜像和公会排行榜合服模板",
|
||||
Features: []string{MergeFeatureGuild, MergeFeatureGuildName, MergeFeatureGuildRank, MergeFeatureGuildMirror},
|
||||
Required: true,
|
||||
Checks: []string{"公会名唯一键冲突", "公会成员与会长关系", "公会镜像完整性", "公会活动结算窗口"},
|
||||
Evidence: []string{"公会改名方案", "公会镜像合并摘要", "公会榜重算报告"},
|
||||
Rollback: []string{"恢复公会名索引", "恢复公会镜像", "撤销公会榜重算"},
|
||||
},
|
||||
{
|
||||
Name: MergeModuleNameFriend,
|
||||
Description: "好友关系、黑名单和好友充值活动合服模板",
|
||||
Features: []string{MergeFeatureFriend, MergeFeatureFriendRecharge},
|
||||
Required: true,
|
||||
Checks: []string{"好友双向边一致性", "黑名单优先级", "好友活动参与记录合并", "跨服好友引用"},
|
||||
Evidence: []string{"好友边合并摘要", "好友活动合并批次"},
|
||||
Rollback: []string{"恢复好友图谱快照", "撤销好友活动批次"},
|
||||
},
|
||||
{
|
||||
Name: MergeModuleNameAuction,
|
||||
Description: "拍卖、战区拍卖和流拍记录合服模板",
|
||||
Features: []string{MergeFeatureAuction, MergeFeatureWarzoneAuction},
|
||||
Required: true,
|
||||
Checks: []string{"拍卖结算窗口", "竞价订单幂等", "流拍记录去重", "战区归属确认"},
|
||||
Evidence: []string{"拍卖冻结确认", "流拍记录合并摘要", "订单幂等校验报告"},
|
||||
Rollback: []string{"恢复拍卖快照", "撤销合服后竞价写入"},
|
||||
},
|
||||
{
|
||||
Name: MergeModuleNameActivity,
|
||||
Description: "活动状态、活动榜和运营活动过期数据合服模板",
|
||||
Features: []string{MergeFeatureActivity, MergeFeatureActivityState, MergeFeatureActivityRank},
|
||||
Required: true,
|
||||
Checks: []string{"活动开放窗口", "活动状态去重", "活动榜结算", "奖励领取幂等"},
|
||||
Evidence: []string{"活动状态合并摘要", "活动榜重算报告", "奖励领取抽样"},
|
||||
Rollback: []string{"恢复活动状态快照", "撤销活动榜重算", "冻结异常奖励领取"},
|
||||
},
|
||||
{
|
||||
Name: MergeModuleNamePayment,
|
||||
Description: "支付订单、IAP 订单和补单复核合服模板",
|
||||
Features: []string{MergeFeaturePayment, MergeFeatureOrder, MergeFeatureIAP},
|
||||
Required: true,
|
||||
Checks: []string{"订单唯一键冲突", "已发货订单幂等", "未完成订单冻结", "补单复核窗口"},
|
||||
Evidence: []string{"订单迁移批次", "发货幂等校验", "补单复核清单"},
|
||||
Rollback: []string{"恢复订单索引", "冻结合服窗口补单"},
|
||||
},
|
||||
{
|
||||
Name: MergeModuleNameServerInfo,
|
||||
Description: "区服合服信息、来源区服记录和路由元数据模板",
|
||||
Features: []string{MergeFeatureServerGroup, MergeFeatureServerInfo},
|
||||
Required: true,
|
||||
Checks: []string{"来源区服记录", "目标区服版本", "路由元数据一致性", "合服批次号幂等"},
|
||||
Evidence: []string{"合服批次记录", "来源区服映射", "路由元数据校验"},
|
||||
Rollback: []string{"恢复合服信息快照", "恢复旧路由元数据"},
|
||||
},
|
||||
}
|
||||
|
||||
func tmplPlaceholderSpec(template MergeModuleTemplate) (MergeModuleSpec, error) {
|
||||
normalized, err := normModuleTpl(template)
|
||||
if err != nil {
|
||||
return MergeModuleSpec{}, err
|
||||
}
|
||||
return normMergeSpec(MergeModuleSpec{
|
||||
Name: normalized.Name,
|
||||
Description: normalized.Description,
|
||||
Features: normalized.Features,
|
||||
Required: normalized.Required,
|
||||
GenDB: tmplPlaceholderFunc(normalized, MergeModulePhaseGenDB),
|
||||
Merge: tmplPlaceholderFunc(normalized, MergeModulePhaseMerge),
|
||||
Rollback: tmplPlaceholderFunc(normalized, MergeModulePhaseRollback),
|
||||
})
|
||||
}
|
||||
|
||||
func templateOverrideSpec(override MergeModuleSpec) (MergeModuleSpec, error) {
|
||||
name := normalizeID(override.Name)
|
||||
if template, ok := FindMergeTemplate(name); ok {
|
||||
if override.Description == "" {
|
||||
override.Description = template.Description
|
||||
}
|
||||
if len(override.Features) == 0 {
|
||||
override.Features = template.Features
|
||||
}
|
||||
override.Required = override.Required || template.Required
|
||||
}
|
||||
return normMergeSpec(override)
|
||||
}
|
||||
|
||||
func tmplPlaceholderFunc(template MergeModuleTemplate, phase string) MergeModuleFunc {
|
||||
template = cloneMergeTemplate(template)
|
||||
phase = normalizeID(phase)
|
||||
return func(ctx context.Context, input MergeModuleInput) (MergeModuleResult, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return MergeModuleResult{}, err
|
||||
}
|
||||
if !templateRequested(template, mergeInputReq(input)) {
|
||||
return MergeModuleResult{Skipped: true}, nil
|
||||
}
|
||||
return MergeModuleResult{
|
||||
Findings: []ConflictFinding{{
|
||||
Code: "merge_module_template_unimplemented",
|
||||
Severity: MigrationSeverityBlocker,
|
||||
Subject: template.Name,
|
||||
Detail: "默认合服模块 " + template.Name + " 尚未接入 " + phase + " 阶段项目处理器",
|
||||
}},
|
||||
Meta: map[string]string{
|
||||
"template": "default",
|
||||
"phase": phase,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func templateRequested(template MergeModuleTemplate, request MergeRequest) bool {
|
||||
features := requestedFeatures(request)
|
||||
if len(features) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, feature := range template.Features {
|
||||
if containsID(features, feature) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normModuleTpl(template MergeModuleTemplate) (MergeModuleTemplate, error) {
|
||||
template.Name = normalizeID(template.Name)
|
||||
template.Description = firstNonEmpty(template.Description)
|
||||
template.Features = normalizeIDs(template.Features)
|
||||
template.Checks = cloneMergeStrings(template.Checks)
|
||||
template.Evidence = cloneMergeStrings(template.Evidence)
|
||||
template.Rollback = cloneMergeStrings(template.Rollback)
|
||||
if template.Name == "" {
|
||||
return MergeModuleTemplate{}, ErrMergeModuleInvalid
|
||||
}
|
||||
if len(template.Features) == 0 {
|
||||
return MergeModuleTemplate{}, ErrMergeModuleInvalid
|
||||
}
|
||||
return template, nil
|
||||
}
|
||||
|
||||
func cloneMergeTemplates(templates []MergeModuleTemplate) []MergeModuleTemplate {
|
||||
if len(templates) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]MergeModuleTemplate, len(templates))
|
||||
for i, template := range templates {
|
||||
out[i] = cloneMergeTemplate(template)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMergeTemplate(template MergeModuleTemplate) MergeModuleTemplate {
|
||||
template.Features = append([]string(nil), template.Features...)
|
||||
template.Checks = cloneMergeStrings(template.Checks)
|
||||
template.Evidence = cloneMergeStrings(template.Evidence)
|
||||
template.Rollback = cloneMergeStrings(template.Rollback)
|
||||
return template
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package servergroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MergeWorkerOptions 是合服 worker 的存储、初始计划、步骤和检查器配置。
|
||||
type MergeWorkerOptions struct {
|
||||
Store Store
|
||||
Initial Plan
|
||||
Steps []MigrationStep
|
||||
Checkers []ConflictChecker
|
||||
// MergeOps 挂接项目侧模块级合服扩展点,用于把 GenDB 预检并入迁移计划。
|
||||
MergeOps *MergeOpRegistry
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// MergeWorker 封装合服预演、应用和回滚执行流程。
|
||||
type MergeWorker struct {
|
||||
manager *Manager
|
||||
store Store
|
||||
steps []MigrationStep
|
||||
checkers []ConflictChecker
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// MergeWorkerReport 是合服 worker 预演或应用后的报告。
|
||||
type MergeWorkerReport struct {
|
||||
Mode string `json:"mode"`
|
||||
Applied bool `json:"applied"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Migration MigrationPlan `json:"migration"`
|
||||
Apply *MergeApplyResult `json:"apply,omitempty"`
|
||||
}
|
||||
|
||||
// MergeRollbackWorkerReport 是合服回滚 worker 的预演或应用报告。
|
||||
type MergeRollbackWorkerReport struct {
|
||||
Mode string `json:"mode"`
|
||||
Applied bool `json:"applied"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Rollback RollbackApplyResult `json:"rollback"`
|
||||
}
|
||||
|
||||
// NewMergeWorker 创建合服 worker 并加载当前服务器分组计划。
|
||||
func NewMergeWorker(ctx context.Context, options MergeWorkerOptions) (*MergeWorker, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
manager, err := LoadManager(ctx, options.Store, options.Initial)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
checkers := append([]ConflictChecker(nil), options.Checkers...)
|
||||
if options.MergeOps != nil {
|
||||
checkers = append(checkers, options.MergeOps.BuildChecker())
|
||||
}
|
||||
return &MergeWorker{
|
||||
manager: manager,
|
||||
store: options.Store,
|
||||
steps: cloneMigrationSteps(options.Steps),
|
||||
checkers: checkers,
|
||||
now: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Snapshot 返回合服 worker 当前计划快照。
|
||||
func (w *MergeWorker) Snapshot() Plan {
|
||||
if w == nil || w.manager == nil {
|
||||
return Plan{}
|
||||
}
|
||||
return w.manager.Snapshot()
|
||||
}
|
||||
|
||||
// Plan 生成合服迁移计划但不应用。
|
||||
func (w *MergeWorker) Plan(ctx context.Context, request MergeRequest) (MigrationPlan, error) {
|
||||
if w == nil || w.manager == nil {
|
||||
return MigrationPlan{}, ErrNotFound
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return BuildMergeMigrationPlan(ctx, w.manager.Snapshot(), request, w.steps, w.checkers...)
|
||||
}
|
||||
|
||||
// Run 执行合服预演,apply 为 true 时正式应用。
|
||||
func (w *MergeWorker) Run(ctx context.Context, request MergeRequest, apply bool) (MergeWorkerReport, error) {
|
||||
if w == nil || w.manager == nil {
|
||||
return MergeWorkerReport{}, ErrNotFound
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
plan, err := w.Plan(ctx, request)
|
||||
if err != nil {
|
||||
return MergeWorkerReport{}, err
|
||||
}
|
||||
report := MergeWorkerReport{
|
||||
Mode: "dry-run",
|
||||
Applied: false,
|
||||
GeneratedAt: w.now().UTC(),
|
||||
Migration: plan,
|
||||
}
|
||||
if !apply {
|
||||
return report, nil
|
||||
}
|
||||
if !plan.Ready {
|
||||
return MergeWorkerReport{}, fmt.Errorf("%w: merge migration plan is not ready", ErrInvalidMigration)
|
||||
}
|
||||
applied, err := ApplyMerge(ctx, w.manager, plan.Request)
|
||||
if err != nil {
|
||||
return MergeWorkerReport{}, err
|
||||
}
|
||||
if w.store != nil {
|
||||
if err := SaveManager(ctx, w.store, w.manager); err != nil {
|
||||
return MergeWorkerReport{}, err
|
||||
}
|
||||
}
|
||||
report.Mode = "apply"
|
||||
report.Applied = true
|
||||
report.Migration = plan
|
||||
report.Apply = &applied
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// Rollback 执行合服回滚预演,apply 为 true 时正式回滚。
|
||||
func (w *MergeWorker) Rollback(ctx context.Context, request RollbackApplyRequest, apply bool) (MergeRollbackWorkerReport, error) {
|
||||
if w == nil || w.manager == nil {
|
||||
return MergeRollbackWorkerReport{}, ErrNotFound
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
result, err := PrepareRollbackApply(ctx, w.manager.Snapshot(), request)
|
||||
if err != nil {
|
||||
return MergeRollbackWorkerReport{}, err
|
||||
}
|
||||
report := MergeRollbackWorkerReport{
|
||||
Mode: "rollback-dry-run",
|
||||
Applied: false,
|
||||
GeneratedAt: w.now().UTC(),
|
||||
Rollback: result,
|
||||
}
|
||||
if !apply {
|
||||
return report, nil
|
||||
}
|
||||
result, err = ApplyRollbackPoint(ctx, w.manager, request)
|
||||
if err != nil {
|
||||
return MergeRollbackWorkerReport{}, err
|
||||
}
|
||||
if w.store != nil {
|
||||
if err := SaveManager(ctx, w.store, w.manager); err != nil {
|
||||
return MergeRollbackWorkerReport{}, err
|
||||
}
|
||||
}
|
||||
report.Mode = "rollback"
|
||||
report.Applied = true
|
||||
report.Rollback = result
|
||||
return report, nil
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
package servergroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MergeRunnerOptions 组合合服 worker、模块注册器和归档存储。
|
||||
type MergeRunnerOptions struct {
|
||||
Worker *MergeWorker
|
||||
MergeOps *MergeOpRegistry
|
||||
Archives MergeArchiveStore
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// MergeRunner 串起 dry-run、业务模块报告、平台 apply、回滚和归档。
|
||||
type MergeRunner struct {
|
||||
worker *MergeWorker
|
||||
mergeOps *MergeOpRegistry
|
||||
archives MergeArchiveStore
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// MergeCmd 是后台、CLI 或流水线传给合服工作流的通用命令。
|
||||
type MergeCmd struct {
|
||||
Mode string `json:"mode"`
|
||||
ArchiveID string `json:"archive_id,omitempty"`
|
||||
RollbackArchiveID string `json:"rollback_archive_id,omitempty"`
|
||||
WorkflowID string `json:"workflow_id,omitempty"`
|
||||
ApprovalID string `json:"approval_id,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
OperatorID string `json:"operator_id,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Request MergeRequest `json:"request"`
|
||||
RollbackPoint RollbackPoint `json:"rollback_point,omitempty"`
|
||||
RestoreVersion string `json:"restore_version,omitempty"`
|
||||
AllowVersionMismatch bool `json:"allow_version_mismatch,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// MergeRunResult 是一次合服工作流预检或执行后的结构化结果。
|
||||
type MergeRunResult struct {
|
||||
Mode string `json:"mode"`
|
||||
Applied bool `json:"applied"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Archive MergeArchive `json:"archive"`
|
||||
Migration *MigrationPlan `json:"migration,omitempty"`
|
||||
Apply *MergeApplyResult `json:"apply,omitempty"`
|
||||
RollbackApply *RollbackApplyResult `json:"rollback_apply,omitempty"`
|
||||
ModuleReports []MergeModuleRunReport `json:"module_reports,omitempty"`
|
||||
}
|
||||
|
||||
// NewMergeRunner 创建合服工作流 runner。
|
||||
func NewMergeRunner(options MergeRunnerOptions) (*MergeRunner, error) {
|
||||
if options.Worker == nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &MergeRunner{
|
||||
worker: options.Worker,
|
||||
mergeOps: options.MergeOps,
|
||||
archives: options.Archives,
|
||||
now: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MergeCmdFromParams 把 admincmd 模板参数或 HTTP 参数转换为合服工作流命令。
|
||||
func MergeCmdFromParams(params map[string]any) (MergeCmd, error) {
|
||||
mode, err := mergeStrParam(params, "mode")
|
||||
if err != nil {
|
||||
return MergeCmd{}, err
|
||||
}
|
||||
mergeID, err := mergeStrParam(params, "merge_id")
|
||||
if err != nil {
|
||||
return MergeCmd{}, err
|
||||
}
|
||||
mainShardID, err := mergeStrParam(params, "main_shard_id")
|
||||
if err != nil {
|
||||
return MergeCmd{}, err
|
||||
}
|
||||
shards, err := stringListParam(params, "shards")
|
||||
if err != nil {
|
||||
return MergeCmd{}, err
|
||||
}
|
||||
checkFeatures, err := stringListParam(params, "check_features")
|
||||
if err != nil {
|
||||
return MergeCmd{}, err
|
||||
}
|
||||
blockFeatures, err := stringListParam(params, "block_features")
|
||||
if err != nil {
|
||||
return MergeCmd{}, err
|
||||
}
|
||||
nextVersion, err := mergeStrParam(params, "next_version")
|
||||
if err != nil {
|
||||
return MergeCmd{}, err
|
||||
}
|
||||
archiveID, err := mergeStrParam(params, "archive_id")
|
||||
if err != nil {
|
||||
return MergeCmd{}, err
|
||||
}
|
||||
rollbackArchiveID, err := mergeStrParam(params, "rollback_archive_id")
|
||||
if err != nil {
|
||||
return MergeCmd{}, err
|
||||
}
|
||||
workflowID, err := mergeStrParam(params, "workflow_id")
|
||||
if err != nil {
|
||||
return MergeCmd{}, err
|
||||
}
|
||||
approvalID, err := mergeStrParam(params, "approval_id")
|
||||
if err != nil {
|
||||
return MergeCmd{}, err
|
||||
}
|
||||
restoreVersion, err := mergeStrParam(params, "restore_version")
|
||||
if err != nil {
|
||||
return MergeCmd{}, err
|
||||
}
|
||||
allowMismatch, err := mergeBoolParam(params, "allow_version_mismatch")
|
||||
if err != nil {
|
||||
return MergeCmd{}, err
|
||||
}
|
||||
return normalizeMergeCmd(MergeCmd{
|
||||
Mode: mode,
|
||||
ArchiveID: archiveID,
|
||||
RollbackArchiveID: rollbackArchiveID,
|
||||
WorkflowID: workflowID,
|
||||
ApprovalID: approvalID,
|
||||
Request: MergeRequest{
|
||||
ID: mergeID,
|
||||
MainShardID: mainShardID,
|
||||
Shards: shards,
|
||||
NextVersion: nextVersion,
|
||||
CheckFeatures: checkFeatures,
|
||||
BlockFeatures: blockFeatures,
|
||||
},
|
||||
RestoreVersion: restoreVersion,
|
||||
AllowVersionMismatch: allowMismatch,
|
||||
})
|
||||
}
|
||||
|
||||
// Preview 只生成计划、模块预检报告和归档,不修改平台路由计划。
|
||||
func (r *MergeRunner) Preview(ctx context.Context, command MergeCmd) (MergeRunResult, error) {
|
||||
if r == nil || r.worker == nil {
|
||||
return MergeRunResult{}, ErrNotFound
|
||||
}
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
command, err := normalizeMergeCmd(command)
|
||||
if err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
switch command.Mode {
|
||||
case MergeStageDryRun, MergeStageApply:
|
||||
return r.previewMerge(ctx, command)
|
||||
case MergeStageRollbackDry, MergeStageRollback:
|
||||
return r.previewRollback(ctx, command)
|
||||
default:
|
||||
return MergeRunResult{}, ErrInvalidMigration
|
||||
}
|
||||
}
|
||||
|
||||
// Execute 按命令模式执行 apply 或 rollback;dry-run 模式仍只做预检。
|
||||
func (r *MergeRunner) Execute(ctx context.Context, command MergeCmd) (MergeRunResult, error) {
|
||||
if r == nil || r.worker == nil {
|
||||
return MergeRunResult{}, ErrNotFound
|
||||
}
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
command, err := normalizeMergeCmd(command)
|
||||
if err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
switch command.Mode {
|
||||
case MergeStageDryRun:
|
||||
return r.previewMerge(ctx, command)
|
||||
case MergeStageApply:
|
||||
return r.executeMerge(ctx, command)
|
||||
case MergeStageRollbackDry:
|
||||
return r.previewRollback(ctx, command)
|
||||
case MergeStageRollback:
|
||||
return r.executeRollback(ctx, command)
|
||||
default:
|
||||
return MergeRunResult{}, ErrInvalidMigration
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MergeRunner) previewMerge(ctx context.Context, command MergeCmd) (MergeRunResult, error) {
|
||||
current := r.worker.Snapshot()
|
||||
plan, err := r.worker.Plan(ctx, command.Request)
|
||||
if err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
reports, err := r.runGenDBReport(ctx, current, plan, command.Meta)
|
||||
if err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
archive, err := NewMergeArchive(MergeArchiveOptions{
|
||||
Stage: MergeStageDryRun,
|
||||
ArchiveID: command.ArchiveID,
|
||||
WorkflowID: command.WorkflowID,
|
||||
ApprovalID: command.ApprovalID,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
OperatorID: command.OperatorID,
|
||||
Reason: command.Reason,
|
||||
Request: command.Request,
|
||||
GeneratedAt: r.now().UTC(),
|
||||
Migration: &plan,
|
||||
ModuleReports: reports,
|
||||
Meta: mergeArchiveMeta(command, map[string]string{"command_mode": command.Mode}),
|
||||
})
|
||||
if err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
if err := r.saveArchive(ctx, archive); err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
return MergeRunResult{
|
||||
Mode: command.Mode,
|
||||
Applied: false,
|
||||
GeneratedAt: archive.GeneratedAt,
|
||||
Archive: archive,
|
||||
Migration: cloneMigrationPtr(plan),
|
||||
ModuleReports: cloneMergeReports(reports),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *MergeRunner) executeMerge(ctx context.Context, command MergeCmd) (MergeRunResult, error) {
|
||||
current := r.worker.Snapshot()
|
||||
plan, err := r.worker.Plan(ctx, command.Request)
|
||||
if err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
gendbReports, err := r.runGenDBReport(ctx, current, plan, command.Meta)
|
||||
if err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
if !plan.Ready || !mergeReportsOK(gendbReports) {
|
||||
return MergeRunResult{}, fmt.Errorf("%w: merge dry-run is not ready", ErrInvalidMigration)
|
||||
}
|
||||
moduleInput := MergeModuleInputFromPlan(current, plan)
|
||||
moduleInput.Meta = mergeArchiveMeta(command, map[string]string{"command_mode": command.Mode})
|
||||
mergeReports, err := r.runModuleReport(ctx, MergeModulePhaseMerge, moduleInput)
|
||||
if err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
reports := append(cloneMergeReports(gendbReports), mergeReports...)
|
||||
if !mergeReportsOK(mergeReports) {
|
||||
return MergeRunResult{}, fmt.Errorf("%w: merge module report has blockers", ErrInvalidMigration)
|
||||
}
|
||||
workerReport, err := r.worker.Run(ctx, command.Request, true)
|
||||
if err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
if workerReport.Apply == nil {
|
||||
return MergeRunResult{}, fmt.Errorf("%w: merge apply result is required", ErrInvalidMigration)
|
||||
}
|
||||
archive, err := NewMergeArchive(MergeArchiveOptions{
|
||||
Stage: MergeStageApply,
|
||||
ArchiveID: command.ArchiveID,
|
||||
WorkflowID: command.WorkflowID,
|
||||
ApprovalID: command.ApprovalID,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
OperatorID: command.OperatorID,
|
||||
Reason: command.Reason,
|
||||
Request: command.Request,
|
||||
GeneratedAt: r.now().UTC(),
|
||||
Migration: &plan,
|
||||
Apply: workerReport.Apply,
|
||||
ModuleReports: reports,
|
||||
Meta: mergeArchiveMeta(command, nil),
|
||||
})
|
||||
if err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
if err := r.saveArchive(ctx, archive); err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
return MergeRunResult{
|
||||
Mode: command.Mode,
|
||||
Applied: true,
|
||||
GeneratedAt: archive.GeneratedAt,
|
||||
Archive: archive,
|
||||
Migration: cloneMigrationPtr(plan),
|
||||
Apply: cloneMergeApplyRef(*workerReport.Apply),
|
||||
ModuleReports: cloneMergeReports(reports),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *MergeRunner) previewRollback(ctx context.Context, command MergeCmd) (MergeRunResult, error) {
|
||||
source, point, err := r.resolveRollbackPoint(ctx, command)
|
||||
if err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
request := rollbackRequest(command, point, false)
|
||||
workerReport, err := r.worker.Rollback(ctx, request, false)
|
||||
if err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
archiveID := rollbackArchiveID(command, MergeStageRollbackDry, point)
|
||||
archive, err := NewMergeArchive(MergeArchiveOptions{
|
||||
Stage: MergeStageRollbackDry,
|
||||
ArchiveID: archiveID,
|
||||
WorkflowID: command.WorkflowID,
|
||||
ApprovalID: command.ApprovalID,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
OperatorID: command.OperatorID,
|
||||
Reason: command.Reason,
|
||||
Request: rollbackCmdReq(command, source),
|
||||
GeneratedAt: r.now().UTC(),
|
||||
RollbackApply: &workerReport.Rollback,
|
||||
ModuleReports: nil,
|
||||
Meta: mergeArchiveMeta(command, map[string]string{"rollback_archive_id": source.ArchiveID}),
|
||||
})
|
||||
if err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
if err := r.saveArchive(ctx, archive); err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
return MergeRunResult{
|
||||
Mode: command.Mode,
|
||||
Applied: false,
|
||||
GeneratedAt: archive.GeneratedAt,
|
||||
Archive: archive,
|
||||
RollbackApply: cloneApplyResultPtr(workerReport.Rollback),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *MergeRunner) executeRollback(ctx context.Context, command MergeCmd) (MergeRunResult, error) {
|
||||
source, point, err := r.resolveRollbackPoint(ctx, command)
|
||||
if err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
current := r.worker.Snapshot()
|
||||
moduleInput := rollbackModuleInput(current, source, command)
|
||||
rollbackReports, err := r.runModuleReport(ctx, MergeModulePhaseRollback, moduleInput)
|
||||
if err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
if !mergeReportsOK(rollbackReports) {
|
||||
return MergeRunResult{}, fmt.Errorf("%w: rollback module report has blockers", ErrInvalidMigration)
|
||||
}
|
||||
request := rollbackRequest(command, point, true)
|
||||
workerReport, err := r.worker.Rollback(ctx, request, true)
|
||||
if err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
archiveID := rollbackArchiveID(command, MergeStageRollback, point)
|
||||
archive, err := NewMergeArchive(MergeArchiveOptions{
|
||||
Stage: MergeStageRollback,
|
||||
ArchiveID: archiveID,
|
||||
WorkflowID: command.WorkflowID,
|
||||
ApprovalID: command.ApprovalID,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
OperatorID: command.OperatorID,
|
||||
Reason: command.Reason,
|
||||
Request: rollbackCmdReq(command, source),
|
||||
GeneratedAt: r.now().UTC(),
|
||||
RollbackApply: &workerReport.Rollback,
|
||||
ModuleReports: rollbackReports,
|
||||
Meta: mergeArchiveMeta(command, map[string]string{"rollback_archive_id": source.ArchiveID}),
|
||||
})
|
||||
if err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
if err := r.saveArchive(ctx, archive); err != nil {
|
||||
return MergeRunResult{}, err
|
||||
}
|
||||
return MergeRunResult{
|
||||
Mode: command.Mode,
|
||||
Applied: true,
|
||||
GeneratedAt: archive.GeneratedAt,
|
||||
Archive: archive,
|
||||
RollbackApply: cloneApplyResultPtr(workerReport.Rollback),
|
||||
ModuleReports: cloneMergeReports(rollbackReports),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *MergeRunner) runGenDBReport(ctx context.Context, current Plan, plan MigrationPlan, meta map[string]string) ([]MergeModuleRunReport, error) {
|
||||
input := MergeModuleInputFromPlan(current, plan)
|
||||
input.Meta = cloneStringMap(meta)
|
||||
return r.runModuleReport(ctx, MergeModulePhaseGenDB, input)
|
||||
}
|
||||
|
||||
func (r *MergeRunner) runModuleReport(ctx context.Context, phase string, input MergeModuleInput) ([]MergeModuleRunReport, error) {
|
||||
if r.mergeOps == nil {
|
||||
return nil, nil
|
||||
}
|
||||
report, err := r.mergeOps.runPhaseReport(ctx, phase, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []MergeModuleRunReport{report}, nil
|
||||
}
|
||||
|
||||
func (r *MergeRunner) saveArchive(ctx context.Context, archive MergeArchive) error {
|
||||
if r.archives == nil {
|
||||
return nil
|
||||
}
|
||||
return r.archives.SaveMergeArchive(ctx, archive)
|
||||
}
|
||||
|
||||
func (r *MergeRunner) resolveRollbackPoint(ctx context.Context, command MergeCmd) (MergeArchive, RollbackPoint, error) {
|
||||
if command.RollbackPoint.ID != "" {
|
||||
source := MergeArchive{ArchiveID: firstNonEmpty(command.RollbackArchiveID, command.ArchiveID), Request: normMergeReq(command.Request)}
|
||||
return source, cloneRollbackPoint(command.RollbackPoint), nil
|
||||
}
|
||||
if r.archives == nil {
|
||||
return MergeArchive{}, RollbackPoint{}, ErrMergeArchiveNotFound
|
||||
}
|
||||
archiveID := firstNonEmpty(command.RollbackArchiveID, command.ArchiveID)
|
||||
if archiveID == "" {
|
||||
return MergeArchive{}, RollbackPoint{}, ErrMergeArchiveNotFound
|
||||
}
|
||||
archive, ok, err := r.archives.GetMergeArchive(ctx, archiveID)
|
||||
if err != nil {
|
||||
return MergeArchive{}, RollbackPoint{}, err
|
||||
}
|
||||
if !ok {
|
||||
return MergeArchive{}, RollbackPoint{}, ErrMergeArchiveNotFound
|
||||
}
|
||||
if archive.RollbackPoint.ID == "" {
|
||||
return MergeArchive{}, RollbackPoint{}, fmt.Errorf("%w: rollback point is required", ErrInvalidMigration)
|
||||
}
|
||||
return archive, cloneRollbackPoint(archive.RollbackPoint), nil
|
||||
}
|
||||
|
||||
func normalizeMergeCmd(command MergeCmd) (MergeCmd, error) {
|
||||
command.Mode = normalizeID(command.Mode)
|
||||
if command.Mode == "" {
|
||||
command.Mode = MergeStageDryRun
|
||||
}
|
||||
if !validMergeStage(command.Mode) {
|
||||
return MergeCmd{}, ErrInvalidMigration
|
||||
}
|
||||
command.ArchiveID = normalizeID(command.ArchiveID)
|
||||
command.RollbackArchiveID = normalizeID(command.RollbackArchiveID)
|
||||
command.WorkflowID = firstNonEmpty(command.WorkflowID)
|
||||
command.ApprovalID = firstNonEmpty(command.ApprovalID)
|
||||
command.IdempotencyKey = firstNonEmpty(command.IdempotencyKey)
|
||||
command.OperatorID = firstNonEmpty(command.OperatorID)
|
||||
command.Reason = firstNonEmpty(command.Reason)
|
||||
command.Request = normMergeReq(command.Request)
|
||||
if command.Request.OperatorID == "" {
|
||||
command.Request.OperatorID = command.OperatorID
|
||||
}
|
||||
if command.Request.Reason == "" {
|
||||
command.Request.Reason = command.Reason
|
||||
}
|
||||
command.RollbackPoint = normRollbackPoint(command.RollbackPoint)
|
||||
command.RestoreVersion = normalizeID(command.RestoreVersion)
|
||||
command.Meta = normalizeStringMap(command.Meta)
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func mergeArchiveMeta(command MergeCmd, extra map[string]string) map[string]string {
|
||||
meta := cloneStringMap(command.Meta)
|
||||
if len(meta) == 0 {
|
||||
meta = make(map[string]string, len(extra)+3)
|
||||
}
|
||||
if command.IdempotencyKey != "" {
|
||||
meta["idempotency_key"] = command.IdempotencyKey
|
||||
}
|
||||
if command.WorkflowID != "" {
|
||||
meta["workflow_id"] = command.WorkflowID
|
||||
}
|
||||
if command.ApprovalID != "" {
|
||||
meta["approval_id"] = command.ApprovalID
|
||||
}
|
||||
if command.OperatorID != "" {
|
||||
meta["operator_id"] = command.OperatorID
|
||||
}
|
||||
if command.Reason != "" {
|
||||
meta["reason"] = command.Reason
|
||||
}
|
||||
for key, value := range extra {
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
if key != "" && value != "" {
|
||||
meta[key] = value
|
||||
}
|
||||
}
|
||||
return normalizeStringMap(meta)
|
||||
}
|
||||
|
||||
func rollbackRequest(command MergeCmd, point RollbackPoint, apply bool) RollbackApplyRequest {
|
||||
reason := firstNonEmpty(command.Reason, point.Reason)
|
||||
if apply {
|
||||
reason = firstNonEmpty(reason, "merge rollback:"+point.ID)
|
||||
}
|
||||
return RollbackApplyRequest{
|
||||
RollbackPoint: cloneRollbackPoint(point),
|
||||
RestoreVersion: command.RestoreVersion,
|
||||
Reason: reason,
|
||||
OperatorID: firstNonEmpty(command.OperatorID, point.OperatorID),
|
||||
AllowVersionMismatch: command.AllowVersionMismatch,
|
||||
}
|
||||
}
|
||||
|
||||
func rollbackArchiveID(command MergeCmd, stage string, point RollbackPoint) string {
|
||||
if command.ArchiveID != "" && command.RollbackArchiveID != "" {
|
||||
return command.ArchiveID
|
||||
}
|
||||
if command.IdempotencyKey != "" {
|
||||
return command.IdempotencyKey
|
||||
}
|
||||
seed := firstNonEmpty(point.ID, command.RollbackArchiveID, command.ArchiveID, command.Request.ID)
|
||||
if seed == "" {
|
||||
return ""
|
||||
}
|
||||
return normalizeID(seed + "-" + stage)
|
||||
}
|
||||
|
||||
func rollbackCmdReq(command MergeCmd, source MergeArchive) MergeRequest {
|
||||
request := normMergeReq(command.Request)
|
||||
if request.ID != "" {
|
||||
return request
|
||||
}
|
||||
return normMergeReq(source.Request)
|
||||
}
|
||||
|
||||
func rollbackModuleInput(current Plan, source MergeArchive, command MergeCmd) MergeModuleInput {
|
||||
if source.Migration != nil {
|
||||
input := MergeModuleInputFromPlan(current, *source.Migration)
|
||||
input.Meta = mergeArchiveMeta(command, map[string]string{"rollback_archive_id": source.ArchiveID})
|
||||
return input
|
||||
}
|
||||
return MergeModuleInput{
|
||||
Request: rollbackCmdReq(command, source),
|
||||
Current: clonePlan(current),
|
||||
Findings: cloneClashFindings(source.Findings),
|
||||
Meta: mergeArchiveMeta(command, map[string]string{"rollback_archive_id": source.ArchiveID}),
|
||||
}
|
||||
}
|
||||
|
||||
func mergeReportsOK(reports []MergeModuleRunReport) bool {
|
||||
for _, report := range reports {
|
||||
if !report.OK {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func cloneMigrationPtr(plan MigrationPlan) *MigrationPlan {
|
||||
cloned := cloneMigrationPlan(plan)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneMergeApplyRef(result MergeApplyResult) *MergeApplyResult {
|
||||
cloned := cloneMergeResult(result)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneApplyResultPtr(result RollbackApplyResult) *RollbackApplyResult {
|
||||
cloned := cloneRollbackApply(result)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func mergeStrParam(params map[string]any, name string) (string, error) {
|
||||
value, ok := params[name]
|
||||
if !ok || value == nil {
|
||||
return "", nil
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed), nil
|
||||
case fmt.Stringer:
|
||||
return strings.TrimSpace(typed.String()), nil
|
||||
case int:
|
||||
return strconv.Itoa(typed), nil
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10), nil
|
||||
case float64:
|
||||
if typed == float64(int64(typed)) {
|
||||
return strconv.FormatInt(int64(typed), 10), nil
|
||||
}
|
||||
return "", fmt.Errorf("%w: %s must be string", ErrInvalidMigration, name)
|
||||
default:
|
||||
return "", fmt.Errorf("%w: %s must be string", ErrInvalidMigration, name)
|
||||
}
|
||||
}
|
||||
|
||||
func stringListParam(params map[string]any, name string) ([]string, error) {
|
||||
value, ok := params[name]
|
||||
if !ok || value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(typed) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return normalizeIDs(strings.Split(typed, ",")), nil
|
||||
case []string:
|
||||
return normalizeIDs(typed), nil
|
||||
case []any:
|
||||
out := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
text, err := mergeStrValue(item, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, text)
|
||||
}
|
||||
return normalizeIDs(out), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s must be string list", ErrInvalidMigration, name)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeBoolParam(params map[string]any, name string) (bool, error) {
|
||||
value, ok := params[name]
|
||||
if !ok || value == nil {
|
||||
return false, nil
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed, nil
|
||||
case string:
|
||||
text := strings.TrimSpace(typed)
|
||||
if text == "" {
|
||||
return false, nil
|
||||
}
|
||||
parsed, err := strconv.ParseBool(text)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%w: %s must be bool", ErrInvalidMigration, name)
|
||||
}
|
||||
return parsed, nil
|
||||
default:
|
||||
return false, fmt.Errorf("%w: %s must be bool", ErrInvalidMigration, name)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeStrValue(value any, name string) (string, error) {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed), nil
|
||||
case int:
|
||||
return strconv.Itoa(typed), nil
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10), nil
|
||||
case float64:
|
||||
if typed == float64(int64(typed)) {
|
||||
return strconv.FormatInt(int64(typed), 10), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%w: %s must contain strings", ErrInvalidMigration, name)
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package servergroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ErrInvalidMigration 表示合服迁移计划缺少步骤、阶段或回滚信息。
|
||||
var ErrInvalidMigration = errors.New("server group migration plan is invalid")
|
||||
|
||||
const (
|
||||
// MigrationSeverityInfo 表示迁移检查发现普通提示。
|
||||
MigrationSeverityInfo = "info"
|
||||
// MigrationSeverityWarning 表示迁移检查发现警告。
|
||||
MigrationSeverityWarning = "warning"
|
||||
// MigrationSeverityBlocker 表示迁移检查发现阻断项。
|
||||
MigrationSeverityBlocker = "blocker"
|
||||
)
|
||||
|
||||
const (
|
||||
// MigrationPhasePrepare 表示合服迁移准备阶段。
|
||||
MigrationPhasePrepare = "prepare"
|
||||
// MigrationPhaseMigrate 表示业务数据迁移阶段。
|
||||
MigrationPhaseMigrate = "migrate"
|
||||
// MigrationPhaseRoute 表示路由发布阶段。
|
||||
MigrationPhaseRoute = "route"
|
||||
// MigrationPhaseVerify 表示合服结果验证阶段。
|
||||
MigrationPhaseVerify = "verify"
|
||||
// MigrationPhaseRollback 表示回滚阶段。
|
||||
MigrationPhaseRollback = "rollback"
|
||||
// MigrationPhaseFinish 表示收尾阶段。
|
||||
MigrationPhaseFinish = "finish"
|
||||
)
|
||||
|
||||
// MigrationStep 只描述通用迁移控制动作,不承载具体项目表结构或业务数据。
|
||||
type MigrationStep struct {
|
||||
Name string `json:"name"`
|
||||
Phase string `json:"phase"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Checks []string `json:"checks,omitempty"`
|
||||
Rollback []string `json:"rollback,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// ConflictCheckRequest 是合服冲突检查器的输入快照。
|
||||
type ConflictCheckRequest struct {
|
||||
Current Plan `json:"current"`
|
||||
DryRun MergeDryRun `json:"dry_run"`
|
||||
Steps []MigrationStep `json:"steps"`
|
||||
}
|
||||
|
||||
// ConflictFinding 是合服冲突检查器返回的单条发现。
|
||||
type ConflictFinding struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// ConflictChecker 定义合服迁移计划的冲突检查接口。
|
||||
type ConflictChecker interface {
|
||||
CheckMerge(context.Context, ConflictCheckRequest) ([]ConflictFinding, error)
|
||||
}
|
||||
|
||||
// ConflictCheckFunc 将函数适配为 ConflictChecker。
|
||||
type ConflictCheckFunc func(context.Context, ConflictCheckRequest) ([]ConflictFinding, error)
|
||||
|
||||
// CheckMerge 执行函数型合服冲突检查器。
|
||||
func (f ConflictCheckFunc) CheckMerge(ctx context.Context, request ConflictCheckRequest) ([]ConflictFinding, error) {
|
||||
if f == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return f(ctx, request)
|
||||
}
|
||||
|
||||
// MigrationPlan 是合服迁移预演、步骤、发现和回滚点的组合计划。
|
||||
type MigrationPlan struct {
|
||||
Request MergeRequest `json:"request"`
|
||||
DryRun MergeDryRun `json:"dry_run"`
|
||||
Steps []MigrationStep `json:"steps"`
|
||||
Findings []ConflictFinding `json:"findings,omitempty"`
|
||||
Ready bool `json:"ready"`
|
||||
Rollback Plan `json:"rollback"`
|
||||
RollbackPoint RollbackPoint `json:"rollback_point"`
|
||||
}
|
||||
|
||||
// DefaultMergeMigrationSteps 返回框架内置的合服迁移步骤模板。
|
||||
func DefaultMergeMigrationSteps() []MigrationStep {
|
||||
return []MigrationStep{
|
||||
{
|
||||
Name: "freeze_non_main_writes",
|
||||
Phase: MigrationPhasePrepare,
|
||||
Target: "shards",
|
||||
Required: true,
|
||||
Description: "冻结非主分片关键写入,避免迁移窗口产生新冲突",
|
||||
Checks: []string{"ResolveWrite", "critical_write_blocked"},
|
||||
Rollback: []string{"release_write_block"},
|
||||
},
|
||||
{
|
||||
Name: "capture_rollback_snapshot",
|
||||
Phase: MigrationPhasePrepare,
|
||||
Target: "servergroup_plan",
|
||||
Required: true,
|
||||
Description: "保存 dry-run rollback 快照,供验证失败或回滚演练使用",
|
||||
Checks: []string{"rollback_snapshot_persisted"},
|
||||
Rollback: []string{"restore_rollback_snapshot"},
|
||||
},
|
||||
{
|
||||
Name: "migrate_generic_state",
|
||||
Phase: MigrationPhaseMigrate,
|
||||
Target: "runtime_state",
|
||||
Required: true,
|
||||
Description: "按项目提供的迁移器搬迁通用状态,核心只约束幂等和校验点",
|
||||
Checks: []string{"idempotent_migration_job", "conflict_report_empty"},
|
||||
Rollback: []string{"run_project_rollback_job"},
|
||||
},
|
||||
{
|
||||
Name: "publish_merge_routes",
|
||||
Phase: MigrationPhaseRoute,
|
||||
Target: "servergroup_plan",
|
||||
Required: true,
|
||||
Description: "发布 MergeGroup 和路由计划,让非主分片写入重定向到主分片",
|
||||
Checks: []string{"ResolveWrite_redirects"},
|
||||
Rollback: []string{"restore_previous_routes"},
|
||||
},
|
||||
{
|
||||
Name: "verify_reads_and_writes",
|
||||
Phase: MigrationPhaseVerify,
|
||||
Target: "runtime_state",
|
||||
Required: true,
|
||||
Description: "校验读写目标、冲突报告、审计回执和抽样数据",
|
||||
Checks: []string{"read_sample_passed", "write_redirect_passed", "audit_receipt_saved"},
|
||||
Rollback: []string{"restore_rollback_snapshot"},
|
||||
},
|
||||
{
|
||||
Name: "release_temporary_blocks",
|
||||
Phase: MigrationPhaseFinish,
|
||||
Target: "shards",
|
||||
Required: false,
|
||||
Description: "验证窗口结束后释放临时冲突和冻结项",
|
||||
Checks: []string{"operator_approved"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// BuildMergeMigrationPlan 构建合服迁移计划并运行冲突检查。
|
||||
func BuildMergeMigrationPlan(ctx context.Context, current Plan, request MergeRequest, steps []MigrationStep, checkers ...ConflictChecker) (MigrationPlan, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return MigrationPlan{}, err
|
||||
}
|
||||
normalized, _, err := normalizePlan(current)
|
||||
if err != nil {
|
||||
return MigrationPlan{}, err
|
||||
}
|
||||
dryRun, err := DryRunMerge(ctx, normalized, request)
|
||||
if err != nil {
|
||||
return MigrationPlan{}, err
|
||||
}
|
||||
if len(steps) == 0 {
|
||||
steps = DefaultMergeMigrationSteps()
|
||||
}
|
||||
steps = cloneMigrationSteps(steps)
|
||||
if err := ValidateMigrationSteps(steps); err != nil {
|
||||
return MigrationPlan{}, err
|
||||
}
|
||||
checkRequest := ConflictCheckRequest{
|
||||
Current: clonePlan(normalized),
|
||||
DryRun: cloneMergeDryRun(dryRun),
|
||||
Steps: cloneMigrationSteps(steps),
|
||||
}
|
||||
checkers = append([]ConflictChecker{DefaultConflictChecker()}, checkers...)
|
||||
var findings []ConflictFinding
|
||||
for _, checker := range checkers {
|
||||
if checker == nil {
|
||||
continue
|
||||
}
|
||||
found, err := checker.CheckMerge(ctx, cloneConflictReq(checkRequest))
|
||||
if err != nil {
|
||||
return MigrationPlan{}, err
|
||||
}
|
||||
for _, finding := range found {
|
||||
normalizedFinding, err := normConflict(finding)
|
||||
if err != nil {
|
||||
return MigrationPlan{}, err
|
||||
}
|
||||
findings = append(findings, normalizedFinding)
|
||||
}
|
||||
}
|
||||
return MigrationPlan{
|
||||
Request: dryRun.Request,
|
||||
DryRun: cloneMergeDryRun(dryRun),
|
||||
Steps: cloneMigrationSteps(steps),
|
||||
Findings: cloneClashFindings(findings),
|
||||
Ready: dryRun.Valid && len(dryRun.Warnings) == 0 && !hasBlockerFinding(findings),
|
||||
Rollback: clonePlan(dryRun.Rollback),
|
||||
RollbackPoint: cloneRollbackPoint(dryRun.RollbackPoint),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DefaultConflictChecker 返回框架内置的合服冲突检查器。
|
||||
func DefaultConflictChecker() ConflictChecker {
|
||||
return ConflictCheckFunc(func(_ context.Context, request ConflictCheckRequest) ([]ConflictFinding, error) {
|
||||
var findings []ConflictFinding
|
||||
if request.DryRun.RollbackPoint.ID == "" || request.DryRun.RollbackPoint.Snapshot.Version != request.DryRun.Rollback.Version {
|
||||
findings = append(findings, ConflictFinding{
|
||||
Code: "rollback_point_missing",
|
||||
Severity: MigrationSeverityBlocker,
|
||||
Subject: request.DryRun.Request.ID,
|
||||
Detail: "merge dry-run must carry a rollback point matching the rollback snapshot",
|
||||
})
|
||||
}
|
||||
for _, warning := range request.DryRun.Warnings {
|
||||
findings = append(findings, ConflictFinding{
|
||||
Code: "write_check_warning",
|
||||
Severity: MigrationSeverityBlocker,
|
||||
Subject: request.DryRun.Request.ID,
|
||||
Detail: warning,
|
||||
})
|
||||
}
|
||||
for _, check := range request.DryRun.WriteChecks {
|
||||
switch {
|
||||
case check.ShardID == request.DryRun.Request.MainShardID && !check.Available:
|
||||
findings = append(findings, ConflictFinding{
|
||||
Code: "main_shard_unavailable",
|
||||
Severity: MigrationSeverityBlocker,
|
||||
Subject: check.ShardID,
|
||||
Detail: check.Reason,
|
||||
})
|
||||
case check.ShardID != request.DryRun.Request.MainShardID && check.RedirectShardID != request.DryRun.Request.MainShardID:
|
||||
findings = append(findings, ConflictFinding{
|
||||
Code: "merged_shard_not_redirected",
|
||||
Severity: MigrationSeverityBlocker,
|
||||
Subject: check.ShardID,
|
||||
Detail: "merged shard writes must redirect to the main shard",
|
||||
})
|
||||
}
|
||||
}
|
||||
if !hasMigrationPhase(request.Steps, MigrationPhaseRollback) && !hasRollbackActions(request.Steps) {
|
||||
findings = append(findings, ConflictFinding{
|
||||
Code: "rollback_phase_missing",
|
||||
Severity: MigrationSeverityWarning,
|
||||
Subject: request.DryRun.Request.ID,
|
||||
Detail: "migration steps should include an explicit rollback phase or rollback actions on required steps",
|
||||
})
|
||||
}
|
||||
return findings, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ValidateMigrationSteps 校验迁移步骤名称、阶段和必填检查。
|
||||
func ValidateMigrationSteps(steps []MigrationStep) error {
|
||||
if len(steps) == 0 {
|
||||
return fmt.Errorf("%w: at least one step is required", ErrInvalidMigration)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(steps))
|
||||
for _, step := range steps {
|
||||
step = normMigrationStep(step)
|
||||
if step.Name == "" || step.Phase == "" {
|
||||
return fmt.Errorf("%w: step name and phase are required", ErrInvalidMigration)
|
||||
}
|
||||
if _, exists := seen[step.Name]; exists {
|
||||
return fmt.Errorf("%w: duplicate step %s", ErrInvalidMigration, step.Name)
|
||||
}
|
||||
seen[step.Name] = struct{}{}
|
||||
if !validMigrationPhase(step.Phase) {
|
||||
return fmt.Errorf("%w: step %s has invalid phase %s", ErrInvalidMigration, step.Name, step.Phase)
|
||||
}
|
||||
if step.Required && step.Description == "" && len(step.Checks) == 0 && len(step.Rollback) == 0 {
|
||||
return fmt.Errorf("%w: required step %s must describe checks or rollback", ErrInvalidMigration, step.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normMigrationStep(step MigrationStep) MigrationStep {
|
||||
step.Name = normalizeID(step.Name)
|
||||
step.Phase = normalizeID(step.Phase)
|
||||
step.Target = normalizeID(step.Target)
|
||||
step.Owner = normalizeID(step.Owner)
|
||||
step.Description = firstNonEmpty(step.Description)
|
||||
step.Checks = normalizeIDs(step.Checks)
|
||||
step.Rollback = normalizeIDs(step.Rollback)
|
||||
step.Meta = normalizeStringMap(step.Meta)
|
||||
return step
|
||||
}
|
||||
|
||||
func validMigrationPhase(phase string) bool {
|
||||
switch phase {
|
||||
case MigrationPhasePrepare, MigrationPhaseMigrate, MigrationPhaseRoute, MigrationPhaseVerify, MigrationPhaseRollback, MigrationPhaseFinish:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normConflict(finding ConflictFinding) (ConflictFinding, error) {
|
||||
finding.Code = normalizeID(finding.Code)
|
||||
finding.Severity = normalizeID(finding.Severity)
|
||||
finding.Subject = normalizeID(finding.Subject)
|
||||
finding.Detail = firstNonEmpty(finding.Detail)
|
||||
if finding.Code == "" {
|
||||
return ConflictFinding{}, fmt.Errorf("%w: finding code is required", ErrInvalidMigration)
|
||||
}
|
||||
if finding.Severity == "" {
|
||||
finding.Severity = MigrationSeverityWarning
|
||||
}
|
||||
if !validFindingSeverity(finding.Severity) {
|
||||
return ConflictFinding{}, fmt.Errorf("%w: finding %s has invalid severity %s", ErrInvalidMigration, finding.Code, finding.Severity)
|
||||
}
|
||||
return finding, nil
|
||||
}
|
||||
|
||||
func validFindingSeverity(severity string) bool {
|
||||
switch severity {
|
||||
case MigrationSeverityInfo, MigrationSeverityWarning, MigrationSeverityBlocker:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hasBlockerFinding(findings []ConflictFinding) bool {
|
||||
for _, finding := range findings {
|
||||
if finding.Severity == MigrationSeverityBlocker {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasMigrationPhase(steps []MigrationStep, phase string) bool {
|
||||
phase = normalizeID(phase)
|
||||
for _, step := range steps {
|
||||
if normalizeID(step.Phase) == phase {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasRollbackActions(steps []MigrationStep) bool {
|
||||
for _, step := range steps {
|
||||
if len(step.Rollback) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cloneMigrationSteps(steps []MigrationStep) []MigrationStep {
|
||||
if len(steps) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]MigrationStep, len(steps))
|
||||
for i, step := range steps {
|
||||
out[i] = normMigrationStep(step)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneClashFindings(findings []ConflictFinding) []ConflictFinding {
|
||||
if len(findings) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]ConflictFinding(nil), findings...)
|
||||
}
|
||||
|
||||
func cloneConflictReq(request ConflictCheckRequest) ConflictCheckRequest {
|
||||
return ConflictCheckRequest{
|
||||
Current: clonePlan(request.Current),
|
||||
DryRun: cloneMergeDryRun(request.DryRun),
|
||||
Steps: cloneMigrationSteps(request.Steps),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMergeDryRun(dryRun MergeDryRun) MergeDryRun {
|
||||
dryRun.Request = normMergeReq(dryRun.Request)
|
||||
dryRun.Next = clonePlan(dryRun.Next)
|
||||
dryRun.Rollback = clonePlan(dryRun.Rollback)
|
||||
dryRun.RollbackPoint = cloneRollbackPoint(dryRun.RollbackPoint)
|
||||
dryRun.AddedConflicts = append([]Conflict(nil), dryRun.AddedConflicts...)
|
||||
dryRun.WriteChecks = cloneTargets(dryRun.WriteChecks)
|
||||
dryRun.Warnings = append([]string(nil), dryRun.Warnings...)
|
||||
return dryRun
|
||||
}
|
||||
|
||||
func cloneTargets(targets []Target) []Target {
|
||||
if len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Target, len(targets))
|
||||
for i, target := range targets {
|
||||
target.Shards = append([]string(nil), target.Shards...)
|
||||
target.Meta = cloneStringMap(target.Meta)
|
||||
out[i] = target
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package servergroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// OperationOpenShard 表示打开分片。
|
||||
OperationOpenShard = "open_shard"
|
||||
// OperationDrainShard 表示排空分片。
|
||||
OperationDrainShard = "drain_shard"
|
||||
// OperationCloseShard 表示关闭分片。
|
||||
OperationCloseShard = "close_shard"
|
||||
)
|
||||
|
||||
// ShardOperationRequest 是分片开关服或排空操作请求。
|
||||
type ShardOperationRequest struct {
|
||||
ShardID string `json:"shard_id"`
|
||||
State string `json:"state"`
|
||||
NextVersion string `json:"next_version,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
OperatorID string `json:"operator_id,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// ShardOperationPlan 是分片操作预演后的计划变更。
|
||||
type ShardOperationPlan struct {
|
||||
Operation string `json:"operation"`
|
||||
Request ShardOperationRequest `json:"request"`
|
||||
Valid bool `json:"valid"`
|
||||
Previous Plan `json:"previous"`
|
||||
Next Plan `json:"next"`
|
||||
RollbackPoint RollbackPoint `json:"rollback_point"`
|
||||
AffectedTargets []Target `json:"affected_targets,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// DryRunShardOperation 预演分片状态变更并生成回滚点。
|
||||
func DryRunShardOperation(ctx context.Context, current Plan, request ShardOperationRequest) (ShardOperationPlan, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return ShardOperationPlan{}, err
|
||||
}
|
||||
request = normShardOpReq(request)
|
||||
if request.ShardID == "" || !validShardOpState(request.State) {
|
||||
return ShardOperationPlan{}, fmt.Errorf("%w: shard_id and supported state are required", ErrInvalidPlan)
|
||||
}
|
||||
normalized, _, err := normalizePlan(current)
|
||||
if err != nil {
|
||||
return ShardOperationPlan{}, err
|
||||
}
|
||||
next := clonePlan(normalized)
|
||||
updated := false
|
||||
for idx := range next.Shards {
|
||||
if next.Shards[idx].ID != request.ShardID {
|
||||
continue
|
||||
}
|
||||
next.Shards[idx].State = request.State
|
||||
next.Shards[idx].UpdatedAt = request.UpdatedAt
|
||||
next.Shards[idx].Meta = mergeOperationMeta(next.Shards[idx].Meta, request)
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
if !updated {
|
||||
return ShardOperationPlan{}, ErrNotFound
|
||||
}
|
||||
next.Version = operationNextVersion(normalized.Version, request)
|
||||
next.UpdatedAt = request.UpdatedAt
|
||||
manager, err := New(next)
|
||||
if err != nil {
|
||||
return ShardOperationPlan{}, err
|
||||
}
|
||||
affected, err := shardOpTargets(ctx, normalized, manager, request.ShardID)
|
||||
if err != nil {
|
||||
return ShardOperationPlan{}, err
|
||||
}
|
||||
normalizedNext := manager.Snapshot()
|
||||
return ShardOperationPlan{
|
||||
Operation: shardOperationName(request.State),
|
||||
Request: request,
|
||||
Valid: true,
|
||||
Previous: normalized,
|
||||
Next: normalizedNext,
|
||||
RollbackPoint: newRollbackPoint(RollbackOperationShardOperation, normalized.Version, normalizedNext.Version, request.Reason, request.OperatorID, normalized, normalizedNext.UpdatedAt),
|
||||
AffectedTargets: affected,
|
||||
Warnings: shardOpWarnings(request, affected),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ApplyShardOperation 应用分片状态变更到管理器。
|
||||
func ApplyShardOperation(ctx context.Context, manager *Manager, request ShardOperationRequest) (ShardOperationPlan, error) {
|
||||
if manager == nil {
|
||||
return ShardOperationPlan{}, ErrNotFound
|
||||
}
|
||||
plan, err := DryRunShardOperation(ctx, manager.Snapshot(), request)
|
||||
if err != nil {
|
||||
return ShardOperationPlan{}, err
|
||||
}
|
||||
if err := manager.Replace(ctx, plan.Next); err != nil {
|
||||
return ShardOperationPlan{}, err
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// OpenShard 打开指定分片。
|
||||
func OpenShard(ctx context.Context, manager *Manager, shardID, nextVersion, reason, operatorID string) (ShardOperationPlan, error) {
|
||||
return ApplyShardOperation(ctx, manager, ShardOperationRequest{
|
||||
ShardID: shardID,
|
||||
State: StateOpen,
|
||||
NextVersion: nextVersion,
|
||||
Reason: reason,
|
||||
OperatorID: operatorID,
|
||||
})
|
||||
}
|
||||
|
||||
// DrainShard 将指定分片切为排空状态。
|
||||
func DrainShard(ctx context.Context, manager *Manager, shardID, nextVersion, reason, operatorID string) (ShardOperationPlan, error) {
|
||||
return ApplyShardOperation(ctx, manager, ShardOperationRequest{
|
||||
ShardID: shardID,
|
||||
State: StateDraining,
|
||||
NextVersion: nextVersion,
|
||||
Reason: reason,
|
||||
OperatorID: operatorID,
|
||||
})
|
||||
}
|
||||
|
||||
// CloseShard 关闭指定分片。
|
||||
func CloseShard(ctx context.Context, manager *Manager, shardID, nextVersion, reason, operatorID string) (ShardOperationPlan, error) {
|
||||
return ApplyShardOperation(ctx, manager, ShardOperationRequest{
|
||||
ShardID: shardID,
|
||||
State: StateClosed,
|
||||
NextVersion: nextVersion,
|
||||
Reason: reason,
|
||||
OperatorID: operatorID,
|
||||
})
|
||||
}
|
||||
|
||||
func normShardOpReq(request ShardOperationRequest) ShardOperationRequest {
|
||||
request.ShardID = normalizeID(request.ShardID)
|
||||
request.State = normalizeState(request.State)
|
||||
request.NextVersion = normalizeID(request.NextVersion)
|
||||
request.Reason = strings.TrimSpace(request.Reason)
|
||||
request.OperatorID = strings.TrimSpace(request.OperatorID)
|
||||
request.Metadata = normalizeStringMap(request.Metadata)
|
||||
request.UpdatedAt = normalizeTime(request.UpdatedAt)
|
||||
if request.UpdatedAt.IsZero() {
|
||||
request.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func validShardOpState(state string) bool {
|
||||
return validState(state)
|
||||
}
|
||||
|
||||
func shardOperationName(state string) string {
|
||||
switch state {
|
||||
case StateOpen:
|
||||
return OperationOpenShard
|
||||
case StateDraining:
|
||||
return OperationDrainShard
|
||||
case StateClosed:
|
||||
return OperationCloseShard
|
||||
default:
|
||||
return "shard_operation"
|
||||
}
|
||||
}
|
||||
|
||||
func operationNextVersion(current string, request ShardOperationRequest) string {
|
||||
if request.NextVersion != "" {
|
||||
return request.NextVersion
|
||||
}
|
||||
current = normalizeID(current)
|
||||
suffix := request.ShardID + "-" + request.State
|
||||
if current == "" {
|
||||
return suffix
|
||||
}
|
||||
return current + "-" + suffix
|
||||
}
|
||||
|
||||
func mergeOperationMeta(base map[string]string, request ShardOperationRequest) map[string]string {
|
||||
out := cloneStringMap(base)
|
||||
if out == nil {
|
||||
out = make(map[string]string)
|
||||
}
|
||||
for key, value := range request.Metadata {
|
||||
out[key] = value
|
||||
}
|
||||
if request.Reason != "" {
|
||||
out["operation.reason"] = request.Reason
|
||||
}
|
||||
if request.OperatorID != "" {
|
||||
out["operation.operator_id"] = request.OperatorID
|
||||
}
|
||||
out["operation.state"] = request.State
|
||||
return out
|
||||
}
|
||||
|
||||
func shardOpTargets(ctx context.Context, previous Plan, manager *Manager, shardID string) ([]Target, error) {
|
||||
features := shardOpFeatures(previous, shardID)
|
||||
out := make([]Target, 0, len(features))
|
||||
for _, feature := range features {
|
||||
target, ok, err := manager.Resolve(ctx, feature, shardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
out = append(out, target)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func shardOpFeatures(plan Plan, shardID string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var features []string
|
||||
for _, route := range plan.Routes {
|
||||
if route.ShardID != shardID {
|
||||
continue
|
||||
}
|
||||
if route.Feature == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[route.Feature]; ok {
|
||||
continue
|
||||
}
|
||||
seen[route.Feature] = struct{}{}
|
||||
features = append(features, route.Feature)
|
||||
}
|
||||
for _, shard := range plan.Shards {
|
||||
if shard.ID != shardID {
|
||||
continue
|
||||
}
|
||||
for _, group := range plan.Groups {
|
||||
if group.ID != shard.GroupID {
|
||||
continue
|
||||
}
|
||||
feature := firstNonEmpty(group.Service, "default")
|
||||
if _, ok := seen[feature]; !ok {
|
||||
seen[feature] = struct{}{}
|
||||
features = append(features, feature)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(features) == 0 {
|
||||
features = append(features, "default")
|
||||
}
|
||||
sort.Strings(features)
|
||||
return features
|
||||
}
|
||||
|
||||
func shardOpWarnings(request ShardOperationRequest, targets []Target) []string {
|
||||
var warnings []string
|
||||
if request.State != StateOpen && request.Reason == "" {
|
||||
warnings = append(warnings, "non-open shard operation should include reason")
|
||||
}
|
||||
for _, target := range targets {
|
||||
if request.State == StateOpen && !target.Available {
|
||||
warnings = append(warnings, "opened shard still unavailable:"+target.Reason)
|
||||
}
|
||||
if request.State != StateOpen && target.Available {
|
||||
warnings = append(warnings, "non-open shard still routes as available:"+target.Feature)
|
||||
}
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package servergroup
|
||||
|
||||
import "sort"
|
||||
|
||||
func sortPlan(plan *Plan) {
|
||||
sort.Slice(plan.Shards, func(i, j int) bool {
|
||||
return plan.Shards[i].ID < plan.Shards[j].ID
|
||||
})
|
||||
sort.Slice(plan.Groups, func(i, j int) bool {
|
||||
return plan.Groups[i].ID < plan.Groups[j].ID
|
||||
})
|
||||
sort.Slice(plan.WarZones, func(i, j int) bool {
|
||||
return plan.WarZones[i].ID < plan.WarZones[j].ID
|
||||
})
|
||||
sort.Slice(plan.MergeGroups, func(i, j int) bool {
|
||||
return plan.MergeGroups[i].ID < plan.MergeGroups[j].ID
|
||||
})
|
||||
sort.Slice(plan.Routes, func(i, j int) bool {
|
||||
if plan.Routes[i].Feature != plan.Routes[j].Feature {
|
||||
return plan.Routes[i].Feature < plan.Routes[j].Feature
|
||||
}
|
||||
return plan.Routes[i].ShardID < plan.Routes[j].ShardID
|
||||
})
|
||||
sort.Slice(plan.Conflicts, func(i, j int) bool {
|
||||
if plan.Conflicts[i].Feature != plan.Conflicts[j].Feature {
|
||||
return plan.Conflicts[i].Feature < plan.Conflicts[j].Feature
|
||||
}
|
||||
if plan.Conflicts[i].ShardID != plan.Conflicts[j].ShardID {
|
||||
return plan.Conflicts[i].ShardID < plan.Conflicts[j].ShardID
|
||||
}
|
||||
return plan.Conflicts[i].GroupID < plan.Conflicts[j].GroupID
|
||||
})
|
||||
}
|
||||
|
||||
func clonePlan(plan Plan) Plan {
|
||||
shards := plan.Shards
|
||||
groups := plan.Groups
|
||||
warZones := plan.WarZones
|
||||
mergeGroups := plan.MergeGroups
|
||||
routes := plan.Routes
|
||||
conflicts := plan.Conflicts
|
||||
plan.Shards = make([]Shard, len(shards))
|
||||
for i, shard := range shards {
|
||||
plan.Shards[i] = cloneShard(shard)
|
||||
}
|
||||
plan.Groups = make([]Group, len(groups))
|
||||
for i, group := range groups {
|
||||
plan.Groups[i] = cloneGroup(group)
|
||||
}
|
||||
plan.WarZones = make([]WarZone, len(warZones))
|
||||
for i, warZone := range warZones {
|
||||
plan.WarZones[i] = cloneWarZone(warZone)
|
||||
}
|
||||
plan.MergeGroups = make([]MergeGroup, len(mergeGroups))
|
||||
for i, mergeGroup := range mergeGroups {
|
||||
plan.MergeGroups[i] = cloneMergeGroup(mergeGroup)
|
||||
}
|
||||
plan.Routes = make([]Route, len(routes))
|
||||
for i, route := range routes {
|
||||
plan.Routes[i] = cloneRoute(route)
|
||||
}
|
||||
plan.Conflicts = append([]Conflict(nil), conflicts...)
|
||||
return plan
|
||||
}
|
||||
|
||||
func cloneShard(shard Shard) Shard {
|
||||
shard.Meta = cloneStringMap(shard.Meta)
|
||||
return shard
|
||||
}
|
||||
|
||||
func cloneGroup(group Group) Group {
|
||||
group.Shards = append([]string(nil), group.Shards...)
|
||||
group.Meta = cloneStringMap(group.Meta)
|
||||
return group
|
||||
}
|
||||
|
||||
func cloneWarZone(warZone WarZone) WarZone {
|
||||
warZone.Shards = append([]string(nil), warZone.Shards...)
|
||||
warZone.Meta = cloneStringMap(warZone.Meta)
|
||||
return warZone
|
||||
}
|
||||
|
||||
func cloneMergeGroup(mergeGroup MergeGroup) MergeGroup {
|
||||
mergeGroup.Shards = append([]string(nil), mergeGroup.Shards...)
|
||||
mergeGroup.Meta = cloneStringMap(mergeGroup.Meta)
|
||||
return mergeGroup
|
||||
}
|
||||
|
||||
func cloneRoute(route Route) Route {
|
||||
route.Meta = cloneStringMap(route.Meta)
|
||||
return route
|
||||
}
|
||||
|
||||
func cloneStringMap(values map[string]string) map[string]string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package servergroup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func normalizePlan(plan Plan) (Plan, planIndex, error) {
|
||||
plan.Version = normalizeID(plan.Version)
|
||||
if plan.UpdatedAt.IsZero() {
|
||||
plan.UpdatedAt = time.Now().UTC()
|
||||
} else {
|
||||
plan.UpdatedAt = plan.UpdatedAt.UTC()
|
||||
}
|
||||
index := planIndex{
|
||||
shards: make(map[string]Shard, len(plan.Shards)),
|
||||
groups: make(map[string]Group, len(plan.Groups)),
|
||||
warZones: make(map[string]WarZone, len(plan.WarZones)),
|
||||
mergeGroups: make(map[string]MergeGroup, len(plan.MergeGroups)),
|
||||
routes: make(map[string]Route, len(plan.Routes)),
|
||||
conflicts: make(map[string]Conflict, len(plan.Conflicts)),
|
||||
}
|
||||
for _, group := range plan.Groups {
|
||||
group = normalizeGroup(group)
|
||||
if group.ID == "" {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: group id is required", ErrInvalidPlan)
|
||||
}
|
||||
if !validState(group.State) {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: group %s has invalid state %s", ErrInvalidPlan, group.ID, group.State)
|
||||
}
|
||||
if _, exists := index.groups[group.ID]; exists {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: duplicate group %s", ErrInvalidPlan, group.ID)
|
||||
}
|
||||
index.groups[group.ID] = group
|
||||
}
|
||||
for _, shard := range plan.Shards {
|
||||
shard = normalizeShard(shard)
|
||||
if shard.ID == "" || shard.GroupID == "" {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: shard id and group id are required", ErrInvalidPlan)
|
||||
}
|
||||
if !validState(shard.State) {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: shard %s has invalid state %s", ErrInvalidPlan, shard.ID, shard.State)
|
||||
}
|
||||
if _, exists := index.shards[shard.ID]; exists {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: duplicate shard %s", ErrInvalidPlan, shard.ID)
|
||||
}
|
||||
group, ok := index.groups[shard.GroupID]
|
||||
if !ok {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: shard %s references missing group %s", ErrInvalidPlan, shard.ID, shard.GroupID)
|
||||
}
|
||||
index.shards[shard.ID] = shard
|
||||
group.Shards = appendUnique(group.Shards, shard.ID)
|
||||
index.groups[shard.GroupID] = group
|
||||
}
|
||||
for _, group := range index.groups {
|
||||
for _, shardID := range group.Shards {
|
||||
shard, ok := index.shards[shardID]
|
||||
if !ok {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: group %s references missing shard %s", ErrInvalidPlan, group.ID, shardID)
|
||||
}
|
||||
if shard.GroupID != group.ID {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: group %s references shard %s owned by group %s", ErrInvalidPlan, group.ID, shardID, shard.GroupID)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, warZone := range plan.WarZones {
|
||||
warZone = normalizeWarZone(warZone)
|
||||
if warZone.ID == "" {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: warzone id is required", ErrInvalidPlan)
|
||||
}
|
||||
if !validWarZoneStatus(warZone.Status) {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: warzone %s has invalid status %s", ErrInvalidPlan, warZone.ID, warZone.Status)
|
||||
}
|
||||
if !validState(warZone.State) {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: warzone %s has invalid state %s", ErrInvalidPlan, warZone.ID, warZone.State)
|
||||
}
|
||||
if !warZone.NoticeAt.IsZero() && !warZone.OpenAt.IsZero() && warZone.NoticeAt.After(warZone.OpenAt) {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: warzone %s notice_at must not be after open_at", ErrInvalidPlan, warZone.ID)
|
||||
}
|
||||
if _, exists := index.warZones[warZone.ID]; exists {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: duplicate warzone %s", ErrInvalidPlan, warZone.ID)
|
||||
}
|
||||
if warZone.LeaderGroupID != "" {
|
||||
if _, ok := index.groups[warZone.LeaderGroupID]; !ok {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: warzone %s references missing leader group %s", ErrInvalidPlan, warZone.ID, warZone.LeaderGroupID)
|
||||
}
|
||||
}
|
||||
for _, shardID := range warZone.Shards {
|
||||
shard, ok := index.shards[shardID]
|
||||
if !ok {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: warzone %s references missing shard %s", ErrInvalidPlan, warZone.ID, shardID)
|
||||
}
|
||||
if shard.WarZoneID != "" && shard.WarZoneID != warZone.ID {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: shard %s belongs to warzone %s and %s", ErrInvalidPlan, shardID, shard.WarZoneID, warZone.ID)
|
||||
}
|
||||
shard.WarZoneID = warZone.ID
|
||||
index.shards[shardID] = shard
|
||||
}
|
||||
index.warZones[warZone.ID] = warZone
|
||||
}
|
||||
for shardID, shard := range index.shards {
|
||||
if shard.WarZoneID == "" {
|
||||
continue
|
||||
}
|
||||
warZone, ok := index.warZones[shard.WarZoneID]
|
||||
if !ok {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: shard %s references missing warzone %s", ErrInvalidPlan, shardID, shard.WarZoneID)
|
||||
}
|
||||
if !containsID(warZone.Shards, shardID) {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: shard %s references warzone %s but is not listed by it", ErrInvalidPlan, shardID, shard.WarZoneID)
|
||||
}
|
||||
}
|
||||
for _, mergeGroup := range plan.MergeGroups {
|
||||
mergeGroup = normalizeMergeGroup(mergeGroup)
|
||||
if mergeGroup.ID == "" || mergeGroup.MainShardID == "" {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: merge group id and main shard id are required", ErrInvalidPlan)
|
||||
}
|
||||
if !validState(mergeGroup.State) {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: merge group %s has invalid state %s", ErrInvalidPlan, mergeGroup.ID, mergeGroup.State)
|
||||
}
|
||||
if _, exists := index.mergeGroups[mergeGroup.ID]; exists {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: duplicate merge group %s", ErrInvalidPlan, mergeGroup.ID)
|
||||
}
|
||||
if _, ok := index.shards[mergeGroup.MainShardID]; !ok {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: merge group %s references missing main shard %s", ErrInvalidPlan, mergeGroup.ID, mergeGroup.MainShardID)
|
||||
}
|
||||
mergeGroup.Shards = appendUnique(mergeGroup.Shards, mergeGroup.MainShardID)
|
||||
for _, shardID := range mergeGroup.Shards {
|
||||
shard, ok := index.shards[shardID]
|
||||
if !ok {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: merge group %s references missing shard %s", ErrInvalidPlan, mergeGroup.ID, shardID)
|
||||
}
|
||||
if shard.MergeGroupID != "" && shard.MergeGroupID != mergeGroup.ID {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: shard %s belongs to merge group %s and %s", ErrInvalidPlan, shardID, shard.MergeGroupID, mergeGroup.ID)
|
||||
}
|
||||
shard.MergeGroupID = mergeGroup.ID
|
||||
index.shards[shardID] = shard
|
||||
}
|
||||
index.mergeGroups[mergeGroup.ID] = mergeGroup
|
||||
}
|
||||
for shardID, shard := range index.shards {
|
||||
if shard.MergeGroupID == "" {
|
||||
continue
|
||||
}
|
||||
mergeGroup, ok := index.mergeGroups[shard.MergeGroupID]
|
||||
if !ok {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: shard %s references missing merge group %s", ErrInvalidPlan, shardID, shard.MergeGroupID)
|
||||
}
|
||||
if !containsID(mergeGroup.Shards, shardID) {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: shard %s references merge group %s but is not listed by it", ErrInvalidPlan, shardID, shard.MergeGroupID)
|
||||
}
|
||||
}
|
||||
for _, route := range plan.Routes {
|
||||
route = normalizeRoute(route)
|
||||
if route.Feature == "" || route.ShardID == "" || route.GroupID == "" {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: route feature, shard id and group id are required", ErrInvalidPlan)
|
||||
}
|
||||
if !validState(route.State) {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: route %s/%s has invalid state %s", ErrInvalidPlan, route.Feature, route.ShardID, route.State)
|
||||
}
|
||||
if _, ok := index.shards[route.ShardID]; !ok {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: route references missing shard %s", ErrInvalidPlan, route.ShardID)
|
||||
}
|
||||
if _, ok := index.groups[route.GroupID]; !ok {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: route references missing group %s", ErrInvalidPlan, route.GroupID)
|
||||
}
|
||||
key := routeKey(route.Feature, route.ShardID)
|
||||
if _, exists := index.routes[key]; exists {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: duplicate route %s/%s", ErrInvalidPlan, route.Feature, route.ShardID)
|
||||
}
|
||||
index.routes[key] = route
|
||||
}
|
||||
for _, conflict := range plan.Conflicts {
|
||||
conflict = normalizeConflict(conflict)
|
||||
if conflict.Feature == "" || (conflict.ShardID == "" && conflict.GroupID == "") {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: conflict feature and scope are required", ErrInvalidPlan)
|
||||
}
|
||||
if conflict.ShardID != "" {
|
||||
if _, ok := index.shards[conflict.ShardID]; !ok {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: conflict references missing shard %s", ErrInvalidPlan, conflict.ShardID)
|
||||
}
|
||||
}
|
||||
if conflict.GroupID != "" {
|
||||
if _, ok := index.groups[conflict.GroupID]; !ok {
|
||||
return Plan{}, planIndex{}, fmt.Errorf("%w: conflict references missing group %s", ErrInvalidPlan, conflict.GroupID)
|
||||
}
|
||||
}
|
||||
index.conflicts[conflictKey(conflict.Feature, conflict.ShardID, conflict.GroupID)] = conflict
|
||||
}
|
||||
normalized := Plan{
|
||||
Version: plan.Version,
|
||||
Shards: make([]Shard, 0, len(index.shards)),
|
||||
Groups: make([]Group, 0, len(index.groups)),
|
||||
WarZones: make([]WarZone, 0, len(index.warZones)),
|
||||
MergeGroups: make([]MergeGroup, 0, len(index.mergeGroups)),
|
||||
Routes: make([]Route, 0, len(index.routes)),
|
||||
Conflicts: make([]Conflict, 0, len(index.conflicts)),
|
||||
UpdatedAt: plan.UpdatedAt,
|
||||
}
|
||||
for _, shard := range index.shards {
|
||||
normalized.Shards = append(normalized.Shards, cloneShard(shard))
|
||||
}
|
||||
for _, group := range index.groups {
|
||||
sort.Strings(group.Shards)
|
||||
index.groups[group.ID] = group
|
||||
normalized.Groups = append(normalized.Groups, cloneGroup(group))
|
||||
}
|
||||
for _, warZone := range index.warZones {
|
||||
sort.Strings(warZone.Shards)
|
||||
index.warZones[warZone.ID] = warZone
|
||||
normalized.WarZones = append(normalized.WarZones, cloneWarZone(warZone))
|
||||
}
|
||||
for _, mergeGroup := range index.mergeGroups {
|
||||
sort.Strings(mergeGroup.Shards)
|
||||
index.mergeGroups[mergeGroup.ID] = mergeGroup
|
||||
normalized.MergeGroups = append(normalized.MergeGroups, cloneMergeGroup(mergeGroup))
|
||||
}
|
||||
for _, route := range index.routes {
|
||||
normalized.Routes = append(normalized.Routes, cloneRoute(route))
|
||||
}
|
||||
for _, conflict := range index.conflicts {
|
||||
normalized.Conflicts = append(normalized.Conflicts, conflict)
|
||||
}
|
||||
sortPlan(&normalized)
|
||||
return normalized, index, nil
|
||||
}
|
||||
|
||||
func normalizeShard(shard Shard) Shard {
|
||||
shard.ID = normalizeID(shard.ID)
|
||||
shard.GroupID = normalizeID(shard.GroupID)
|
||||
shard.State = normalizeState(shard.State)
|
||||
shard.OpenAt = normalizeTime(shard.OpenAt)
|
||||
shard.PublicOpenAt = normalizeTime(shard.PublicOpenAt)
|
||||
shard.WarZoneID = normalizeID(shard.WarZoneID)
|
||||
shard.MergeGroupID = normalizeID(shard.MergeGroupID)
|
||||
shard.UpdatedAt = normalizeTime(shard.UpdatedAt)
|
||||
shard.Meta = normalizeStringMap(shard.Meta)
|
||||
return shard
|
||||
}
|
||||
|
||||
func normalizeGroup(group Group) Group {
|
||||
group.ID = normalizeID(group.ID)
|
||||
group.Service = normalizeID(group.Service)
|
||||
group.MemberID = normalizeID(group.MemberID)
|
||||
group.State = normalizeState(group.State)
|
||||
group.Weight = normalizeWeight(group.Weight)
|
||||
group.UpdatedAt = normalizeTime(group.UpdatedAt)
|
||||
group.Shards = normalizeIDs(group.Shards)
|
||||
group.Meta = normalizeStringMap(group.Meta)
|
||||
return group
|
||||
}
|
||||
|
||||
func normalizeWarZone(warZone WarZone) WarZone {
|
||||
warZone.ID = normalizeID(warZone.ID)
|
||||
warZone.LeaderGroupID = normalizeID(warZone.LeaderGroupID)
|
||||
warZone.Shards = normalizeIDs(warZone.Shards)
|
||||
warZone.State = normalizeState(warZone.State)
|
||||
warZone.Status = normWarStatus(warZone.Status)
|
||||
warZone.SeasonID = normalizeID(warZone.SeasonID)
|
||||
warZone.NoticeAt = normalizeTime(warZone.NoticeAt)
|
||||
warZone.OpenAt = normalizeTime(warZone.OpenAt)
|
||||
warZone.UpdatedAt = normalizeTime(warZone.UpdatedAt)
|
||||
warZone.Meta = normalizeStringMap(warZone.Meta)
|
||||
return warZone
|
||||
}
|
||||
|
||||
func normalizeMergeGroup(mergeGroup MergeGroup) MergeGroup {
|
||||
mergeGroup.ID = normalizeID(mergeGroup.ID)
|
||||
mergeGroup.MainShardID = normalizeID(mergeGroup.MainShardID)
|
||||
mergeGroup.Shards = normalizeIDs(mergeGroup.Shards)
|
||||
mergeGroup.State = normalizeState(mergeGroup.State)
|
||||
mergeGroup.UpdatedAt = normalizeTime(mergeGroup.UpdatedAt)
|
||||
mergeGroup.Meta = normalizeStringMap(mergeGroup.Meta)
|
||||
return mergeGroup
|
||||
}
|
||||
|
||||
func normalizeRoute(route Route) Route {
|
||||
route.Feature = normalizeID(route.Feature)
|
||||
route.ShardID = normalizeID(route.ShardID)
|
||||
route.GroupID = normalizeID(route.GroupID)
|
||||
route.Service = normalizeID(route.Service)
|
||||
route.MemberID = normalizeID(route.MemberID)
|
||||
route.State = normalizeState(route.State)
|
||||
route.UpdatedAt = normalizeTime(route.UpdatedAt)
|
||||
route.Meta = normalizeStringMap(route.Meta)
|
||||
return route
|
||||
}
|
||||
|
||||
func normalizeConflict(conflict Conflict) Conflict {
|
||||
conflict.Feature = normalizeID(conflict.Feature)
|
||||
conflict.ShardID = normalizeID(conflict.ShardID)
|
||||
conflict.GroupID = normalizeID(conflict.GroupID)
|
||||
conflict.Reason = strings.TrimSpace(conflict.Reason)
|
||||
return conflict
|
||||
}
|
||||
|
||||
func normalizeState(state string) string {
|
||||
state = normalizeID(state)
|
||||
if state == "" {
|
||||
return StateOpen
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func validState(state string) bool {
|
||||
switch state {
|
||||
case StateOpen, StateClosed, StateDraining:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normWarStatus(status string) string {
|
||||
status = normalizeID(status)
|
||||
if status == "" {
|
||||
return ""
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func normalizeWeight(weight int) int {
|
||||
if weight <= 0 {
|
||||
return 1
|
||||
}
|
||||
return weight
|
||||
}
|
||||
|
||||
func normalizeTime(value time.Time) time.Time {
|
||||
if value.IsZero() {
|
||||
return time.Time{}
|
||||
}
|
||||
return value.UTC()
|
||||
}
|
||||
|
||||
func normalizeID(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func normalizeIDs(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = normalizeID(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeStringMap(values map[string]string) map[string]string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
out[key] = strings.TrimSpace(value)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendUnique(values []string, value string) []string {
|
||||
value = normalizeID(value)
|
||||
if value == "" {
|
||||
return values
|
||||
}
|
||||
for _, existing := range values {
|
||||
if existing == value {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return append(values, value)
|
||||
}
|
||||
|
||||
func containsID(values []string, value string) bool {
|
||||
value = normalizeID(value)
|
||||
for _, existing := range values {
|
||||
if existing == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func targetFromGroup(group Group) Target {
|
||||
target := Target{
|
||||
GroupID: group.ID,
|
||||
Service: group.Service,
|
||||
MemberID: group.MemberID,
|
||||
Shards: append([]string(nil), group.Shards...),
|
||||
Available: group.State == StateOpen,
|
||||
Meta: cloneStringMap(group.Meta),
|
||||
}
|
||||
if !target.Available {
|
||||
target.Reason = "group_state:" + group.State
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func routeKey(feature, shardID string) string {
|
||||
return feature + "\x00" + shardID
|
||||
}
|
||||
|
||||
func conflictKey(feature, shardID, groupID string) string {
|
||||
return feature + "\x00" + shardID + "\x00" + groupID
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package servergroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrRollbackVersionMismatch 表示回滚点版本和当前计划版本不匹配。
|
||||
var ErrRollbackVersionMismatch = errors.New("server group rollback version mismatch")
|
||||
|
||||
const (
|
||||
// RollbackOperationMerge 表示合服操作回滚。
|
||||
RollbackOperationMerge = "merge"
|
||||
// RollbackOperationShardOperation 表示分片状态操作回滚。
|
||||
RollbackOperationShardOperation = "shard_operation"
|
||||
// RollbackOperationWarZoneRefresh 表示战区刷新操作回滚。
|
||||
RollbackOperationWarZoneRefresh = "warzone_refresh"
|
||||
)
|
||||
|
||||
// RollbackPoint 是区服运营动作的通用回滚锚点,只保存平台路由计划快照,不包含项目业务表数据。
|
||||
type RollbackPoint struct {
|
||||
ID string `json:"id"`
|
||||
Operation string `json:"operation"`
|
||||
PreviousVersion string `json:"previous_version,omitempty"`
|
||||
NextVersion string `json:"next_version,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
OperatorID string `json:"operator_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Snapshot Plan `json:"snapshot"`
|
||||
}
|
||||
|
||||
// RollbackApplyRequest 是应用回滚点的请求。
|
||||
type RollbackApplyRequest struct {
|
||||
RollbackPoint RollbackPoint `json:"rollback_point"`
|
||||
RestoreVersion string `json:"restore_version,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
OperatorID string `json:"operator_id,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
AllowVersionMismatch bool `json:"allow_version_mismatch,omitempty"`
|
||||
}
|
||||
|
||||
// RollbackApplyResult 是回滚预演或应用的结果。
|
||||
type RollbackApplyResult struct {
|
||||
Applied bool `json:"applied"`
|
||||
Operation string `json:"operation"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
OperatorID string `json:"operator_id,omitempty"`
|
||||
Previous Plan `json:"previous"`
|
||||
Restored Plan `json:"restored"`
|
||||
RollbackPoint RollbackPoint `json:"rollback_point"`
|
||||
VersionMismatch bool `json:"version_mismatch,omitempty"`
|
||||
Request RollbackApplyRequest `json:"request"`
|
||||
}
|
||||
|
||||
func newRollbackPoint(operation, previousVersion, nextVersion, reason, operatorID string, snapshot Plan, at time.Time) RollbackPoint {
|
||||
operation = normalizeID(operation)
|
||||
previousVersion = normalizeID(previousVersion)
|
||||
nextVersion = normalizeID(nextVersion)
|
||||
reason = strings.TrimSpace(reason)
|
||||
operatorID = strings.TrimSpace(operatorID)
|
||||
at = normalizeTime(at)
|
||||
if at.IsZero() {
|
||||
at = time.Now().UTC()
|
||||
}
|
||||
id := fmt.Sprintf("%s-%s-%s-%s", operation, previousVersion, nextVersion, at.Format("20060102t150405z"))
|
||||
return RollbackPoint{
|
||||
ID: normalizeID(id),
|
||||
Operation: operation,
|
||||
PreviousVersion: previousVersion,
|
||||
NextVersion: nextVersion,
|
||||
Reason: reason,
|
||||
OperatorID: operatorID,
|
||||
CreatedAt: at,
|
||||
Snapshot: clonePlan(snapshot),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneRollbackPoint(point RollbackPoint) RollbackPoint {
|
||||
point.Snapshot = clonePlan(point.Snapshot)
|
||||
return point
|
||||
}
|
||||
|
||||
// PrepareRollbackApply 预演回滚点应用结果。
|
||||
func PrepareRollbackApply(ctx context.Context, current Plan, request RollbackApplyRequest) (RollbackApplyResult, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return RollbackApplyResult{}, err
|
||||
}
|
||||
request = normRollbackApply(request)
|
||||
point := request.RollbackPoint
|
||||
if point.ID == "" || point.Operation == "" {
|
||||
return RollbackApplyResult{}, fmt.Errorf("%w: rollback point id and operation are required", ErrInvalidPlan)
|
||||
}
|
||||
normalizedCurrent, _, err := normalizePlan(current)
|
||||
if err != nil {
|
||||
return RollbackApplyResult{}, err
|
||||
}
|
||||
restored, _, err := normalizePlan(point.Snapshot)
|
||||
if err != nil {
|
||||
return RollbackApplyResult{}, err
|
||||
}
|
||||
if restored.Version == "" {
|
||||
return RollbackApplyResult{}, fmt.Errorf("%w: rollback snapshot version is required", ErrInvalidPlan)
|
||||
}
|
||||
if request.RestoreVersion != "" {
|
||||
restored.Version = request.RestoreVersion
|
||||
}
|
||||
if request.UpdatedAt.IsZero() {
|
||||
request.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
restored.UpdatedAt = request.UpdatedAt
|
||||
if _, _, err := normalizePlan(restored); err != nil {
|
||||
return RollbackApplyResult{}, err
|
||||
}
|
||||
versionMismatch := point.NextVersion != "" && normalizedCurrent.Version != point.NextVersion
|
||||
if versionMismatch && !request.AllowVersionMismatch {
|
||||
return RollbackApplyResult{}, fmt.Errorf("%w: current=%s expected=%s", ErrRollbackVersionMismatch, normalizedCurrent.Version, point.NextVersion)
|
||||
}
|
||||
return RollbackApplyResult{
|
||||
Applied: false,
|
||||
Operation: point.Operation,
|
||||
Reason: request.Reason,
|
||||
OperatorID: request.OperatorID,
|
||||
Previous: normalizedCurrent,
|
||||
Restored: restored,
|
||||
RollbackPoint: cloneRollbackPoint(point),
|
||||
VersionMismatch: versionMismatch,
|
||||
Request: request,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ApplyRollbackPoint 将回滚点应用到服务器分组管理器。
|
||||
func ApplyRollbackPoint(ctx context.Context, manager *Manager, request RollbackApplyRequest) (RollbackApplyResult, error) {
|
||||
if manager == nil {
|
||||
return RollbackApplyResult{}, ErrNotFound
|
||||
}
|
||||
result, err := PrepareRollbackApply(ctx, manager.Snapshot(), request)
|
||||
if err != nil {
|
||||
return RollbackApplyResult{}, err
|
||||
}
|
||||
if err := manager.Replace(ctx, result.Restored); err != nil {
|
||||
return RollbackApplyResult{}, err
|
||||
}
|
||||
result.Applied = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normRollbackApply(request RollbackApplyRequest) RollbackApplyRequest {
|
||||
request.RollbackPoint = normRollbackPoint(request.RollbackPoint)
|
||||
request.RestoreVersion = normalizeID(request.RestoreVersion)
|
||||
request.Reason = strings.TrimSpace(request.Reason)
|
||||
request.OperatorID = strings.TrimSpace(request.OperatorID)
|
||||
request.UpdatedAt = normalizeTime(request.UpdatedAt)
|
||||
return request
|
||||
}
|
||||
|
||||
func normRollbackPoint(point RollbackPoint) RollbackPoint {
|
||||
point.ID = normalizeID(point.ID)
|
||||
point.Operation = normalizeID(point.Operation)
|
||||
point.PreviousVersion = normalizeID(point.PreviousVersion)
|
||||
point.NextVersion = normalizeID(point.NextVersion)
|
||||
point.Reason = strings.TrimSpace(point.Reason)
|
||||
point.OperatorID = strings.TrimSpace(point.OperatorID)
|
||||
point.CreatedAt = normalizeTime(point.CreatedAt)
|
||||
point.Snapshot = clonePlan(point.Snapshot)
|
||||
return point
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package servergroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/filex"
|
||||
)
|
||||
|
||||
// ErrStoreEmpty 表示服务器分组存储为空或尚未初始化。
|
||||
var ErrStoreEmpty = errors.New("server group store is empty")
|
||||
|
||||
// Store 定义服务器分组计划的持久化接口。
|
||||
type Store interface {
|
||||
Load(context.Context) (Plan, error)
|
||||
Save(context.Context, Plan) error
|
||||
}
|
||||
|
||||
// FileStore 用本地 JSON 文件保存服务器分组计划。
|
||||
type FileStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewFileStore 创建文件型服务器分组计划存储。
|
||||
func NewFileStore(path string) (*FileStore, error) {
|
||||
path = normalizeID(path)
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("%w: file store path is required", ErrInvalidPlan)
|
||||
}
|
||||
return &FileStore{path: path}, nil
|
||||
}
|
||||
|
||||
// Path 返回文件型服务器分组存储路径。
|
||||
func (s *FileStore) Path() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.path
|
||||
}
|
||||
|
||||
// Load 从文件读取并校验服务器分组计划。
|
||||
func (s *FileStore) Load(ctx context.Context) (Plan, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
if s == nil {
|
||||
return Plan{}, ErrStoreEmpty
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
data, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Plan{}, ErrStoreEmpty
|
||||
}
|
||||
if err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return Plan{}, ErrStoreEmpty
|
||||
}
|
||||
var plan Plan
|
||||
if err := json.Unmarshal(data, &plan); err != nil {
|
||||
return Plan{}, fmt.Errorf("decode server group plan %s: %w", s.path, err)
|
||||
}
|
||||
normalized, _, err := normalizePlan(plan)
|
||||
if err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// Save 原子写入服务器分组计划文件。
|
||||
func (s *FileStore) Save(ctx context.Context, plan Plan) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil {
|
||||
return ErrStoreEmpty
|
||||
}
|
||||
normalized, _, err := normalizePlan(plan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(normalized, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return writeFileReplace(s.path, data)
|
||||
}
|
||||
|
||||
// LoadManager 从存储加载管理器,存储为空时使用 fallback 初始化。
|
||||
func LoadManager(ctx context.Context, store Store, fallback Plan) (*Manager, error) {
|
||||
if store != nil {
|
||||
plan, err := store.Load(ctx)
|
||||
if err == nil {
|
||||
return New(plan)
|
||||
}
|
||||
if !errors.Is(err, ErrStoreEmpty) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
manager, err := New(fallback)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if store != nil {
|
||||
if err := store.Save(ctx, manager.Snapshot()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// SaveManager 将管理器当前计划保存到存储。
|
||||
func SaveManager(ctx context.Context, store Store, manager *Manager) error {
|
||||
if manager == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if store == nil {
|
||||
return ErrStoreEmpty
|
||||
}
|
||||
return store.Save(ctx, manager.Snapshot())
|
||||
}
|
||||
|
||||
func writeFileReplace(path string, data []byte) error {
|
||||
if err := filex.AtomicWriteFile(path, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Chtimes(path, time.Now(), time.Now())
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
package servergroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defInZoneDelay = 24 * 60 * 60
|
||||
defNoticeLead = 24 * 60 * 60
|
||||
)
|
||||
|
||||
// WarZoneRefreshRequest 是刷新战区状态的请求。
|
||||
type WarZoneRefreshRequest struct {
|
||||
Groups []WarZoneInput `json:"groups"`
|
||||
ShardOpenTimes map[string]time.Time `json:"shard_open_times,omitempty"`
|
||||
At time.Time `json:"at,omitempty"`
|
||||
InZoneDelaySeconds int `json:"in_zone_delay_seconds,omitempty"`
|
||||
NoticeLeadSeconds int `json:"notice_lead_seconds,omitempty"`
|
||||
NextVersion string `json:"next_version,omitempty"`
|
||||
PruneMissing bool `json:"prune_missing,omitempty"`
|
||||
Force bool `json:"force,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
OperatorID string `json:"operator_id,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// WarZoneInput 是单个战区的外部时钟和配置输入。
|
||||
type WarZoneInput struct {
|
||||
ID string `json:"id"`
|
||||
LeaderGroupID string `json:"leader_group_id,omitempty"`
|
||||
Shards []string `json:"shards,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
SeasonID string `json:"season_id,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// WarZoneRefreshPlan 是战区刷新预演后的计划变化。
|
||||
type WarZoneRefreshPlan struct {
|
||||
Request WarZoneRefreshRequest `json:"request"`
|
||||
Valid bool `json:"valid"`
|
||||
Previous Plan `json:"previous"`
|
||||
Next Plan `json:"next"`
|
||||
RollbackPoint RollbackPoint `json:"rollback_point"`
|
||||
Changes []WarZoneChange `json:"changes,omitempty"`
|
||||
RemovedWarZones []string `json:"removed_war_zones,omitempty"`
|
||||
AvailableWarZones []WarZone `json:"available_war_zones,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// WarZoneChange 是单个战区状态变化记录。
|
||||
type WarZoneChange struct {
|
||||
WarZoneID string `json:"warzone_id"`
|
||||
PreviousStatus string `json:"previous_status,omitempty"`
|
||||
CurrentStatus string `json:"current_status"`
|
||||
PreviousState string `json:"previous_state,omitempty"`
|
||||
CurrentState string `json:"current_state"`
|
||||
NoticeAt time.Time `json:"notice_at,omitempty"`
|
||||
OpenAt time.Time `json:"open_at,omitempty"`
|
||||
Shards []string `json:"shards,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// WarZoneRuntime 抽象战区刷新所需的时间和输入来源。
|
||||
type WarZoneRuntime struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
State string `json:"state"`
|
||||
LeaderGroupID string `json:"leader_group_id,omitempty"`
|
||||
SeasonID string `json:"season_id,omitempty"`
|
||||
OpenAt time.Time `json:"open_at,omitempty"`
|
||||
NoticeAt time.Time `json:"notice_at,omitempty"`
|
||||
Shards []string `json:"shards,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// DryRunWarZoneRefresh 预演战区状态刷新。
|
||||
func DryRunWarZoneRefresh(ctx context.Context, current Plan, request WarZoneRefreshRequest) (WarZoneRefreshPlan, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return WarZoneRefreshPlan{}, err
|
||||
}
|
||||
request = normWarzoneRefresh(request)
|
||||
if len(request.Groups) == 0 {
|
||||
return WarZoneRefreshPlan{}, fmt.Errorf("%w: warzone refresh requires at least one group", ErrInvalidPlan)
|
||||
}
|
||||
normalized, _, err := normalizePlan(current)
|
||||
if err != nil {
|
||||
return WarZoneRefreshPlan{}, err
|
||||
}
|
||||
next := clonePlan(normalized)
|
||||
next.Version = warZoneNextVersion(normalized.Version, request.NextVersion, request.At)
|
||||
next.UpdatedAt = request.At
|
||||
clearShardWarZones(&next)
|
||||
|
||||
previousByID := warZonesByID(normalized.WarZones)
|
||||
inputIDs := make(map[string]struct{}, len(request.Groups))
|
||||
var changes []WarZoneChange
|
||||
var warnings []string
|
||||
for _, input := range request.Groups {
|
||||
input = normWarInput(input)
|
||||
if input.ID == "" {
|
||||
return WarZoneRefreshPlan{}, fmt.Errorf("%w: warzone id is required", ErrInvalidPlan)
|
||||
}
|
||||
if _, exists := inputIDs[input.ID]; exists {
|
||||
return WarZoneRefreshPlan{}, fmt.Errorf("%w: duplicate warzone refresh input %s", ErrInvalidPlan, input.ID)
|
||||
}
|
||||
inputIDs[input.ID] = struct{}{}
|
||||
warZone, change, itemWarnings, err := buildWarRefresh(normalized, previousByID[input.ID], input, request)
|
||||
if err != nil {
|
||||
return WarZoneRefreshPlan{}, err
|
||||
}
|
||||
next.WarZones = upsertWarZone(next.WarZones, warZone)
|
||||
changes = append(changes, change)
|
||||
warnings = append(warnings, itemWarnings...)
|
||||
}
|
||||
|
||||
var removed []string
|
||||
if request.PruneMissing {
|
||||
kept := make([]WarZone, 0, len(next.WarZones))
|
||||
for _, warZone := range next.WarZones {
|
||||
if _, ok := inputIDs[warZone.ID]; ok {
|
||||
kept = append(kept, warZone)
|
||||
continue
|
||||
}
|
||||
removed = append(removed, warZone.ID)
|
||||
}
|
||||
next.WarZones = kept
|
||||
sort.Strings(removed)
|
||||
}
|
||||
manager, err := New(next)
|
||||
if err != nil {
|
||||
return WarZoneRefreshPlan{}, err
|
||||
}
|
||||
normalizedNext := manager.Snapshot()
|
||||
return WarZoneRefreshPlan{
|
||||
Request: request,
|
||||
Valid: true,
|
||||
Previous: normalized,
|
||||
Next: normalizedNext,
|
||||
RollbackPoint: newRollbackPoint(RollbackOperationWarZoneRefresh, normalized.Version, normalizedNext.Version, request.Reason, request.OperatorID, normalized, normalizedNext.UpdatedAt),
|
||||
Changes: changes,
|
||||
RemovedWarZones: removed,
|
||||
AvailableWarZones: AvailableWarZones(normalizedNext),
|
||||
Warnings: dedupeStrings(warnings),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ApplyWarZoneRefresh 应用战区刷新计划到管理器。
|
||||
func ApplyWarZoneRefresh(ctx context.Context, manager *Manager, request WarZoneRefreshRequest) (WarZoneRefreshPlan, error) {
|
||||
if manager == nil {
|
||||
return WarZoneRefreshPlan{}, ErrNotFound
|
||||
}
|
||||
plan, err := DryRunWarZoneRefresh(ctx, manager.Snapshot(), request)
|
||||
if err != nil {
|
||||
return WarZoneRefreshPlan{}, err
|
||||
}
|
||||
if err := manager.Replace(ctx, plan.Next); err != nil {
|
||||
return WarZoneRefreshPlan{}, err
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// WarZoneSnapshot 返回计划中的战区列表快照。
|
||||
func WarZoneSnapshot(plan Plan) []WarZoneRuntime {
|
||||
normalized, _, err := normalizePlan(plan)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]WarZoneRuntime, 0, len(normalized.WarZones))
|
||||
for _, warZone := range normalized.WarZones {
|
||||
out = append(out, warZoneRuntime(warZone))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// AvailableWarZones 返回可用战区 ID 列表。
|
||||
func AvailableWarZones(plan Plan) []WarZone {
|
||||
normalized, _, err := normalizePlan(plan)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]WarZone, 0, len(normalized.WarZones))
|
||||
for _, warZone := range normalized.WarZones {
|
||||
if warZone.State == StateOpen && (warZone.Status == "" || warZone.Status == WarZoneStatusOpened) {
|
||||
out = append(out, cloneWarZone(warZone))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsWarZoneOpen 判断指定战区是否已开放。
|
||||
func IsWarZoneOpen(plan Plan, warZoneID string) bool {
|
||||
warZoneID = normalizeID(warZoneID)
|
||||
for _, item := range WarZoneSnapshot(plan) {
|
||||
if item.ID == warZoneID {
|
||||
return item.Available
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normWarzoneRefresh(request WarZoneRefreshRequest) WarZoneRefreshRequest {
|
||||
request.NextVersion = normalizeID(request.NextVersion)
|
||||
request.Reason = strings.TrimSpace(request.Reason)
|
||||
request.OperatorID = strings.TrimSpace(request.OperatorID)
|
||||
request.Meta = normalizeStringMap(request.Meta)
|
||||
request.At = normalizeTime(request.At)
|
||||
if request.At.IsZero() {
|
||||
request.At = time.Now().UTC()
|
||||
}
|
||||
if request.InZoneDelaySeconds <= 0 {
|
||||
request.InZoneDelaySeconds = defInZoneDelay
|
||||
}
|
||||
if request.NoticeLeadSeconds < 0 {
|
||||
request.NoticeLeadSeconds = 0
|
||||
}
|
||||
if request.NoticeLeadSeconds == 0 {
|
||||
request.NoticeLeadSeconds = defNoticeLead
|
||||
}
|
||||
openTimes := make(map[string]time.Time, len(request.ShardOpenTimes))
|
||||
for shardID, openAt := range request.ShardOpenTimes {
|
||||
shardID = normalizeID(shardID)
|
||||
openAt = normalizeTime(openAt)
|
||||
if shardID == "" || openAt.IsZero() {
|
||||
continue
|
||||
}
|
||||
openTimes[shardID] = openAt
|
||||
}
|
||||
if len(openTimes) > 0 {
|
||||
request.ShardOpenTimes = openTimes
|
||||
} else {
|
||||
request.ShardOpenTimes = nil
|
||||
}
|
||||
for idx := range request.Groups {
|
||||
request.Groups[idx] = normWarInput(request.Groups[idx])
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func normWarInput(input WarZoneInput) WarZoneInput {
|
||||
input.ID = normalizeID(input.ID)
|
||||
input.LeaderGroupID = normalizeID(input.LeaderGroupID)
|
||||
input.Shards = normalizeIDs(input.Shards)
|
||||
input.State = normalizeID(input.State)
|
||||
input.Status = normWarStatus(input.Status)
|
||||
input.SeasonID = normalizeID(input.SeasonID)
|
||||
input.Meta = normalizeStringMap(input.Meta)
|
||||
return input
|
||||
}
|
||||
|
||||
func buildWarRefresh(current Plan, previous WarZone, input WarZoneInput, request WarZoneRefreshRequest) (WarZone, WarZoneChange, []string, error) {
|
||||
status, noticeAt, openAt, warnings, err := computeWarZoneStatus(current, input, request)
|
||||
if err != nil {
|
||||
return WarZone{}, WarZoneChange{}, nil, err
|
||||
}
|
||||
if input.Status != "" {
|
||||
status = input.Status
|
||||
}
|
||||
if !request.Force && previous.ID != "" && !canSwitchWarStatus(previous.Status, status) {
|
||||
return WarZone{}, WarZoneChange{}, nil, fmt.Errorf("%w: warzone %s cannot switch from %s to %s without force", ErrInvalidPlan, input.ID, previous.Status, status)
|
||||
}
|
||||
state := input.State
|
||||
if state == "" {
|
||||
state = stateWarStatus(status)
|
||||
}
|
||||
meta := mergeWarZoneMeta(input.Meta, request)
|
||||
warZone := WarZone{
|
||||
ID: input.ID,
|
||||
LeaderGroupID: input.LeaderGroupID,
|
||||
Shards: append([]string(nil), input.Shards...),
|
||||
State: state,
|
||||
Status: status,
|
||||
SeasonID: input.SeasonID,
|
||||
NoticeAt: noticeAt,
|
||||
OpenAt: openAt,
|
||||
UpdatedAt: request.At,
|
||||
Meta: meta,
|
||||
}
|
||||
if warZone.LeaderGroupID == "" && status == WarZoneStatusOpened {
|
||||
warnings = append(warnings, "opened warzone has no leader group:"+warZone.ID)
|
||||
}
|
||||
if previous.ID != "" && !sameStringSet(previous.Shards, warZone.Shards) {
|
||||
warnings = append(warnings, "warzone membership changed:"+warZone.ID)
|
||||
}
|
||||
change := WarZoneChange{
|
||||
WarZoneID: warZone.ID,
|
||||
PreviousStatus: previous.Status,
|
||||
CurrentStatus: warZone.Status,
|
||||
PreviousState: previous.State,
|
||||
CurrentState: warZone.State,
|
||||
NoticeAt: warZone.NoticeAt,
|
||||
OpenAt: warZone.OpenAt,
|
||||
Shards: append([]string(nil), warZone.Shards...),
|
||||
Reason: request.Reason,
|
||||
}
|
||||
return warZone, change, warnings, nil
|
||||
}
|
||||
|
||||
func computeWarZoneStatus(plan Plan, input WarZoneInput, request WarZoneRefreshRequest) (string, time.Time, time.Time, []string, error) {
|
||||
if len(input.Shards) == 0 {
|
||||
return WarZoneStatusNoConfig, time.Time{}, time.Time{}, nil, nil
|
||||
}
|
||||
var warnings []string
|
||||
var latestOpen time.Time
|
||||
for _, shardID := range input.Shards {
|
||||
shard, ok := findShard(plan, shardID)
|
||||
if !ok {
|
||||
return "", time.Time{}, time.Time{}, nil, fmt.Errorf("%w: warzone %s references missing shard %s", ErrInvalidPlan, input.ID, shardID)
|
||||
}
|
||||
openAt := request.ShardOpenTimes[shardID]
|
||||
if openAt.IsZero() {
|
||||
openAt = firstTime(shard.PublicOpenAt, shard.OpenAt)
|
||||
}
|
||||
if openAt.IsZero() {
|
||||
warnings = append(warnings, "shard has no open time:"+shardID)
|
||||
return WarZoneStatusNotStart, time.Time{}, time.Time{}, warnings, nil
|
||||
}
|
||||
if latestOpen.IsZero() || openAt.After(latestOpen) {
|
||||
latestOpen = openAt
|
||||
}
|
||||
}
|
||||
if latestOpen.After(request.At) {
|
||||
return WarZoneStatusNotOpen, time.Time{}, latestOpen.Add(time.Duration(request.InZoneDelaySeconds) * time.Second), warnings, nil
|
||||
}
|
||||
openAt := latestOpen.Add(time.Duration(request.InZoneDelaySeconds) * time.Second)
|
||||
noticeAt := openAt.Add(-time.Duration(request.NoticeLeadSeconds) * time.Second)
|
||||
switch {
|
||||
case request.At.Before(noticeAt):
|
||||
return WarZoneStatusNotNotice, noticeAt, openAt, warnings, nil
|
||||
case request.At.Before(openAt):
|
||||
return WarZoneStatusNoticed, noticeAt, openAt, warnings, nil
|
||||
default:
|
||||
return WarZoneStatusOpened, noticeAt, openAt, warnings, nil
|
||||
}
|
||||
}
|
||||
|
||||
func validWarZoneStatus(status string) bool {
|
||||
switch status {
|
||||
case "", WarZoneStatusNotStart, WarZoneStatusNotOpen, WarZoneStatusNotNotice, WarZoneStatusNoticed, WarZoneStatusOpened, WarZoneStatusNoConfig, WarZoneStatusReverted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func canSwitchWarStatus(previous, current string) bool {
|
||||
previous = normWarStatus(previous)
|
||||
current = normWarStatus(current)
|
||||
if previous == "" || previous == current {
|
||||
return true
|
||||
}
|
||||
switch previous {
|
||||
case WarZoneStatusNotStart:
|
||||
return current != WarZoneStatusReverted
|
||||
case WarZoneStatusNotOpen:
|
||||
return current == WarZoneStatusNotNotice || current == WarZoneStatusNoticed || current == WarZoneStatusOpened || current == WarZoneStatusNoConfig
|
||||
case WarZoneStatusNotNotice:
|
||||
return current == WarZoneStatusNoticed || current == WarZoneStatusOpened || current == WarZoneStatusNoConfig
|
||||
case WarZoneStatusNoticed:
|
||||
return current == WarZoneStatusOpened
|
||||
case WarZoneStatusOpened:
|
||||
return false
|
||||
case WarZoneStatusNoConfig:
|
||||
return current != WarZoneStatusReverted
|
||||
case WarZoneStatusReverted:
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func stateWarStatus(status string) string {
|
||||
switch status {
|
||||
case "", WarZoneStatusOpened:
|
||||
return StateOpen
|
||||
case WarZoneStatusNoticed:
|
||||
return StateDraining
|
||||
default:
|
||||
return StateClosed
|
||||
}
|
||||
}
|
||||
|
||||
func warZoneRuntime(warZone WarZone) WarZoneRuntime {
|
||||
item := WarZoneRuntime{
|
||||
ID: warZone.ID,
|
||||
Status: firstNonEmpty(warZone.Status, WarZoneStatusOpened),
|
||||
State: warZone.State,
|
||||
LeaderGroupID: warZone.LeaderGroupID,
|
||||
SeasonID: warZone.SeasonID,
|
||||
OpenAt: warZone.OpenAt,
|
||||
NoticeAt: warZone.NoticeAt,
|
||||
Shards: append([]string(nil), warZone.Shards...),
|
||||
Available: warZone.State == StateOpen && (warZone.Status == "" || warZone.Status == WarZoneStatusOpened),
|
||||
}
|
||||
if !item.Available {
|
||||
if warZone.Status != "" && warZone.Status != WarZoneStatusOpened {
|
||||
item.Reason = "warzone_status:" + warZone.Status
|
||||
} else {
|
||||
item.Reason = "warzone_state:" + warZone.State
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func mergeWarZoneMeta(base map[string]string, request WarZoneRefreshRequest) map[string]string {
|
||||
out := cloneStringMap(base)
|
||||
if out == nil {
|
||||
out = make(map[string]string)
|
||||
}
|
||||
for key, value := range request.Meta {
|
||||
out[key] = value
|
||||
}
|
||||
if request.Reason != "" {
|
||||
out["operation.reason"] = request.Reason
|
||||
}
|
||||
if request.OperatorID != "" {
|
||||
out["operation.operator_id"] = request.OperatorID
|
||||
}
|
||||
out["operation.kind"] = "warzone_refresh"
|
||||
return out
|
||||
}
|
||||
|
||||
func clearShardWarZones(plan *Plan) {
|
||||
for idx := range plan.Shards {
|
||||
plan.Shards[idx].WarZoneID = ""
|
||||
}
|
||||
}
|
||||
|
||||
func warZonesByID(warZones []WarZone) map[string]WarZone {
|
||||
out := make(map[string]WarZone, len(warZones))
|
||||
for _, warZone := range warZones {
|
||||
out[warZone.ID] = warZone
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func upsertWarZone(warZones []WarZone, next WarZone) []WarZone {
|
||||
for idx := range warZones {
|
||||
if warZones[idx].ID == next.ID {
|
||||
warZones[idx] = next
|
||||
return warZones
|
||||
}
|
||||
}
|
||||
return append(warZones, next)
|
||||
}
|
||||
|
||||
func findShard(plan Plan, shardID string) (Shard, bool) {
|
||||
shardID = normalizeID(shardID)
|
||||
for _, shard := range plan.Shards {
|
||||
if shard.ID == shardID {
|
||||
return shard, true
|
||||
}
|
||||
}
|
||||
return Shard{}, false
|
||||
}
|
||||
|
||||
func sameStringSet(a, b []string) bool {
|
||||
a = normalizeIDs(a)
|
||||
b = normalizeIDs(b)
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for idx := range a {
|
||||
if a[idx] != b[idx] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func warZoneNextVersion(current, requested string, at time.Time) string {
|
||||
if requested = normalizeID(requested); requested != "" {
|
||||
return requested
|
||||
}
|
||||
current = normalizeID(current)
|
||||
suffix := "warzone-" + at.UTC().Format("20060102T150405Z")
|
||||
if current == "" {
|
||||
return suffix
|
||||
}
|
||||
return current + "-" + suffix
|
||||
}
|
||||
|
||||
func dedupeStrings(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user