服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
445 lines
13 KiB
Go
445 lines
13 KiB
Go
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()
|
|
}
|