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

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

318 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}