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