服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
318 lines
11 KiB
Go
318 lines
11 KiB
Go
package config
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"reflect"
|
||
"strings"
|
||
)
|
||
|
||
func validateProdSafety(errs *[]error, cfg ServiceConfig) {
|
||
// 生产类环境不能只看 environment=prod,k8s/helm/staging 也按上线标准 fail-fast。
|
||
validEnvPlaceholders(errs, reflect.ValueOf(cfg), "")
|
||
rejectProdDefault(errs, "cluster.id", cfg.Cluster.ID)
|
||
rejectProdDefault(errs, "cluster.version", cfg.Cluster.Version)
|
||
rejectProdDefault(errs, "cluster.data_version", cfg.Cluster.DataVersion)
|
||
rejectKnownSecret(errs, "admin.token", cfg.Admin.Token)
|
||
if len(cfg.Admin.Tokens) == 0 {
|
||
*errs = append(*errs, fmt.Errorf("admin.tokens requires at least one scoped token for production-like environments"))
|
||
}
|
||
if !cfg.Metrics.RequireAdminToken {
|
||
*errs = append(*errs, fmt.Errorf("metrics.require_admin_token is required for production-like environments"))
|
||
}
|
||
if cfg.HotUpdate.Enabled && !cfg.HotUpdate.RequireSignature {
|
||
*errs = append(*errs, fmt.Errorf("hot_update.require_signature is required for production-like environments"))
|
||
}
|
||
if cfg.Bus.Kind == "" || cfg.Bus.Kind == "memory" {
|
||
*errs = append(*errs, fmt.Errorf("bus.kind memory is not allowed for production-like environments"))
|
||
}
|
||
if cfg.Cache.Kind == "" || cfg.Cache.Kind == "memory" {
|
||
*errs = append(*errs, fmt.Errorf("cache.kind memory is not allowed for production-like environments"))
|
||
}
|
||
if cfg.Lock.Kind == "" || cfg.Lock.Kind == "memory" {
|
||
*errs = append(*errs, fmt.Errorf("lock.kind memory is not allowed for production-like environments"))
|
||
}
|
||
if cfg.Registry.Kind == "" || cfg.Registry.Kind == "memory" {
|
||
*errs = append(*errs, fmt.Errorf("registry.kind memory is not allowed for production-like environments"))
|
||
}
|
||
if !cfg.Tracing.Enabled && !cfg.OTel.Enabled {
|
||
*errs = append(*errs, fmt.Errorf("tracing.enabled or otel.enabled is required for production-like environments"))
|
||
}
|
||
if cfg.Audit.Kind != "file" {
|
||
*errs = append(*errs, fmt.Errorf("audit.kind file is required for production-like environments"))
|
||
}
|
||
if sdkInsecureEnv() {
|
||
*errs = append(*errs, fmt.Errorf("LONGHENG_SDK_INSECURE must not be enabled for production-like environments"))
|
||
}
|
||
for idx, token := range cfg.Admin.Tokens {
|
||
rejectKnownSecret(errs, fmt.Sprintf("admin.tokens[%d].token", idx), token.Token)
|
||
}
|
||
if isGatewayService(cfg.Service.Name) {
|
||
// 多 gateway 必须共享在线状态,否则跨网关推送、踢人和重连恢复都会失真。
|
||
if cfg.Presence.Kind != "redis" {
|
||
*errs = append(*errs, fmt.Errorf("presence.kind redis is required for production gateway services"))
|
||
}
|
||
if !cfg.Gateway.ConnectionRateLimitEnabled {
|
||
*errs = append(*errs, fmt.Errorf("gateway.connection_rate_limit_enabled is required for production gateway services"))
|
||
}
|
||
if cfg.Gateway.AuthToken != "" {
|
||
rejectKnownSecret(errs, "gateway.auth_token", cfg.Gateway.AuthToken)
|
||
}
|
||
if cfg.Gateway.LoginToken != "" {
|
||
rejectKnownSecret(errs, "gateway.login_token", cfg.Gateway.LoginToken)
|
||
}
|
||
requireProdSecret(errs, "gateway.session_key_master_secret", cfg.Gateway.SessionKeyMasterSecret)
|
||
}
|
||
if cfg.Idempotency.Kind == "redis" || cfg.Idempotency.Kind == "mysql_redis" {
|
||
requireProdSecret(errs, "idempotency.redis_password", cfg.Idempotency.RedisPassword)
|
||
}
|
||
if cfg.Registry.Kind == "redis" {
|
||
requireProdSecret(errs, "registry.redis_password", cfg.Registry.RedisPassword)
|
||
}
|
||
if cfg.Bus.Kind == "redis" {
|
||
requireProdSecret(errs, "bus.redis_password", cfg.Bus.RedisPassword)
|
||
if !cfg.Bus.RedisTLSEnabled {
|
||
*errs = append(*errs, fmt.Errorf("bus.redis_tls_enabled is required for production-like redis bus"))
|
||
}
|
||
}
|
||
if cfg.Bus.Kind == "nats" {
|
||
if strings.TrimSpace(cfg.Bus.NATSToken) == "" && strings.TrimSpace(cfg.Bus.NATSCredentials) == "" &&
|
||
(strings.TrimSpace(cfg.Bus.NATSUsername) == "" || strings.TrimSpace(cfg.Bus.NATSPassword) == "") {
|
||
*errs = append(*errs, fmt.Errorf("bus nats requires token, credentials, or username/password for production-like environments"))
|
||
}
|
||
if strings.TrimSpace(cfg.Bus.NATSToken) != "" {
|
||
rejectKnownSecret(errs, "bus.nats_token", cfg.Bus.NATSToken)
|
||
}
|
||
if strings.TrimSpace(cfg.Bus.NATSPassword) != "" {
|
||
rejectKnownSecret(errs, "bus.nats_password", cfg.Bus.NATSPassword)
|
||
}
|
||
if !cfg.Bus.NATSTLSEnabled && strings.TrimSpace(cfg.Bus.NATSCAFile) == "" && strings.TrimSpace(cfg.Bus.NATSCertFile) == "" {
|
||
*errs = append(*errs, fmt.Errorf("bus.nats_tls_enabled or nats CA/client cert is required for production-like nats bus"))
|
||
}
|
||
}
|
||
if cfg.Cache.Kind == "redis" {
|
||
requireProdSecret(errs, "cache.redis_password", cfg.Cache.RedisPassword)
|
||
}
|
||
if cfg.Lock.Kind == "redis" {
|
||
requireProdSecret(errs, "lock.redis_password", cfg.Lock.RedisPassword)
|
||
}
|
||
if cfg.Presence.Kind == "redis" {
|
||
requireProdSecret(errs, "presence.redis_password", cfg.Presence.RedisPassword)
|
||
}
|
||
if cfg.ProfileStore.StoreKind == "redis" || cfg.ProfileStore.StoreKind == "pika" || cfg.ProfileStore.StoreKind == "profiledb" || cfg.ProfileStore.StoreKind == "profile_db" || cfg.ProfileStore.StoreKind == "mysql_redis" {
|
||
requireProdSecret(errs, "profile_store.store_password", cfg.ProfileStore.StorePassword)
|
||
}
|
||
if cfg.ProfileStore.SummaryStoreKind == "redis" || cfg.ProfileStore.SummaryStoreKind == "pika" {
|
||
requireProdSecret(errs, "profile_store.summary_store_password", cfg.ProfileStore.SummaryStorePassword)
|
||
}
|
||
if isProfileCriticalSvc(cfg.Service.Name) {
|
||
// logic/playerd 是 Profile 权威写路径,生产不能退回文件、内存或纯 Redis 存档。
|
||
switch cfg.ProfileStore.StoreKind {
|
||
case "mysql", "mysql_redis":
|
||
default:
|
||
*errs = append(*errs, fmt.Errorf("profile_store.store_kind mysql or mysql_redis is required for production %s services", cfg.Service.Name))
|
||
}
|
||
switch cfg.ProfileStore.SummaryStoreKind {
|
||
case "redis", "pika":
|
||
default:
|
||
*errs = append(*errs, fmt.Errorf("profile_store.summary_store_kind redis or pika is required for production %s services", cfg.Service.Name))
|
||
}
|
||
}
|
||
}
|
||
|
||
func sdkInsecureEnv() bool {
|
||
switch strings.ToLower(strings.TrimSpace(os.Getenv("LONGHENG_SDK_INSECURE"))) {
|
||
case "1", "true", "yes", "y", "on":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func validEnvPlaceholders(errs *[]error, value reflect.Value, path string) {
|
||
if !value.IsValid() {
|
||
return
|
||
}
|
||
if value.Kind() == reflect.Pointer {
|
||
if value.IsNil() {
|
||
return
|
||
}
|
||
validEnvPlaceholders(errs, value.Elem(), path)
|
||
return
|
||
}
|
||
switch value.Kind() {
|
||
case reflect.Struct:
|
||
valueType := value.Type()
|
||
for idx := 0; idx < value.NumField(); idx++ {
|
||
fieldType := valueType.Field(idx)
|
||
if fieldType.PkgPath != "" {
|
||
continue
|
||
}
|
||
fieldPath := joinConfigPath(path, tomlFieldName(fieldType))
|
||
validEnvPlaceholders(errs, value.Field(idx), fieldPath)
|
||
}
|
||
case reflect.Slice, reflect.Array:
|
||
for idx := 0; idx < value.Len(); idx++ {
|
||
validEnvPlaceholders(errs, value.Index(idx), fmt.Sprintf("%s[%d]", path, idx))
|
||
}
|
||
case reflect.String:
|
||
text := value.String()
|
||
if configEnvPattern.MatchString(text) || isShellEnvOnly(text) {
|
||
// 未解析占位符比空值更危险:看起来非空,实际会把字面量当密钥或地址启动。
|
||
*errs = append(*errs, fmt.Errorf("%s contains unresolved environment variable placeholder", path))
|
||
}
|
||
}
|
||
}
|
||
|
||
func tomlFieldName(field reflect.StructField) string {
|
||
tag := field.Tag.Get("toml")
|
||
if tag == "" || tag == "-" {
|
||
return strings.ToLower(field.Name)
|
||
}
|
||
name := strings.Split(tag, ",")[0]
|
||
if name == "" {
|
||
return strings.ToLower(field.Name)
|
||
}
|
||
return name
|
||
}
|
||
|
||
func joinConfigPath(prefix, name string) string {
|
||
if prefix == "" {
|
||
return name
|
||
}
|
||
return prefix + "." + name
|
||
}
|
||
|
||
func isShellEnvOnly(value string) bool {
|
||
value = strings.TrimSpace(value)
|
||
if len(value) < 2 || value[0] != '$' {
|
||
return false
|
||
}
|
||
for idx, r := range value[1:] {
|
||
if idx == 0 {
|
||
if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
|
||
continue
|
||
}
|
||
return false
|
||
}
|
||
if r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||
continue
|
||
}
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func requireProdSecret(errs *[]error, field, value string) {
|
||
requireString(errs, field, value)
|
||
if strings.TrimSpace(value) == "" {
|
||
return
|
||
}
|
||
// 非空还不够,常见本地默认值也要直接拒绝。
|
||
rejectKnownSecret(errs, field, value)
|
||
}
|
||
|
||
func rejectKnownSecret(errs *[]error, field, value string) {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "local-admin-token",
|
||
"local-login-token",
|
||
"local-auth-token",
|
||
"local-session-key-master-secret",
|
||
"local-token",
|
||
"admin",
|
||
"token",
|
||
"password",
|
||
"redis",
|
||
"mysql",
|
||
"changeme",
|
||
"change-me",
|
||
"change_me":
|
||
*errs = append(*errs, fmt.Errorf("%s must not use a local/default secret in production-like environments", field))
|
||
}
|
||
}
|
||
|
||
func rejectProdDefault(errs *[]error, field, value string) {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "", "local", "default", "changeme", "change-me", "change_me":
|
||
*errs = append(*errs, fmt.Errorf("%s must not use a local/default value in production-like environments", field))
|
||
}
|
||
}
|
||
|
||
func IsProdLikeEnv(environment string) bool {
|
||
return isProdLikeEnv(environment)
|
||
}
|
||
|
||
// UsesDistributedRuntimeAuthority 判断配置是否已经启用分布式或持久化权威路径,
|
||
// 用于承载有状态运行时决策。
|
||
//
|
||
// 它不会单独把整份配置判定为生产环境:本地开发也可能使用 MySQL/Redis。
|
||
// 该信号只服务于运行时装配和预检,避免调用方只依赖 service.environment。
|
||
func UsesDistributedRuntimeAuthority(cfg ServiceConfig) bool {
|
||
if cfg.Topology.Enabled {
|
||
return true
|
||
}
|
||
if isProfileCriticalSvc(cfg.Service.Name) && ProfileStoreUsesDurableAuthority(cfg.ProfileStore) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// ProfileStoreUsesDurableAuthority 判断 profile_store.store_kind 是否由持久化 DB
|
||
// 作为权威存储,而不是进程本地或仅文件的开发存储。
|
||
func ProfileStoreUsesDurableAuthority(section ProfileStoreSection) bool {
|
||
switch strings.ToLower(strings.TrimSpace(section.StoreKind)) {
|
||
case "mysql", "mysql_redis":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isProdLikeEnv(environment string) bool {
|
||
environment = strings.ToLower(strings.TrimSpace(environment))
|
||
if environment == "" {
|
||
return false
|
||
}
|
||
for _, token := range []string{"prod", "production", "staging", "stage", "preprod", "pre-production", "k8s", "kubernetes", "helm", "cluster", "live"} {
|
||
if strings.Contains(environment, token) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isGatewayService(serviceName string) bool {
|
||
return strings.EqualFold(strings.TrimSpace(serviceName), "gateway")
|
||
}
|
||
|
||
func isEventLogCritical(serviceName string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(serviceName)) {
|
||
case "gateway", "logic", "playerd", "payx":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isIdemCriticalSvc(serviceName string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(serviceName)) {
|
||
case "logic", "playerd":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isProfileCriticalSvc(serviceName string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(serviceName)) {
|
||
case "logic", "playerd":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isBattleAgentService(serviceName string) bool {
|
||
return strings.EqualFold(strings.TrimSpace(serviceName), "battleagent")
|
||
}
|