DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
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
}