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,228 @@
package app
import (
"net/http"
"longheng.io/server/internal/platform/httpx"
)
func adminConsole(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", http.MethodGet)
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(adminConsoleHTML))
}
const adminConsoleHTML = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LongHeng 龙恒 Admin Console</title>
<style>
:root { color-scheme: light; --ink:#17202a; --muted:#5f6c7b; --line:#d8dee7; --bg:#f6f8fb; --panel:#ffffff; --accent:#0f766e; --danger:#b42318; }
* { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--ink); }
header { min-height: 88px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 20px clamp(18px, 4vw, 48px); border-bottom: 1px solid var(--line); background: #ffffff; }
h1 { margin: 0; font-size: 22px; line-height: 1.2; font-weight: 700; letter-spacing: 0; }
main { display: grid; grid-template-columns: minmax(220px, 300px) minmax(0, 1fr); gap: 18px; padding: 18px clamp(18px, 4vw, 48px) 32px; }
.token { display: flex; align-items: center; gap: 8px; min-width: min(100%, 520px); }
.token input { width: 100%; min-height: 38px; border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; font: inherit; }
nav { display: flex; flex-direction: column; gap: 8px; }
button { min-height: 36px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--ink); padding: 8px 10px; font: inherit; text-align: left; cursor: pointer; }
button:hover, button.active { border-color: var(--accent); color: var(--accent); }
section { min-width: 0; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 38px; margin-bottom: 10px; }
.request { display: grid; grid-template-columns: 92px minmax(0, 1fr) 110px; gap: 8px; margin-bottom: 10px; }
.admin-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; }
.request input, .request select, .admin-fields input, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; font: inherit; background: #fff; }
textarea { min-height: 96px; resize: vertical; margin-bottom: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.endpoint { width: min(100%, 680px); border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; color: var(--muted); background: #fff; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
pre { min-height: 420px; margin: 0; padding: 16px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.error { color: var(--danger); }
@media (max-width: 760px) { header { align-items: flex-start; flex-direction: column; } main { grid-template-columns: 1fr; } nav, .admin-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } }
</style>
</head>
<body>
<header>
<h1>LongHeng 龙恒 Admin Console</h1>
<label class="token"><span>Token</span><input id="token" autocomplete="off" placeholder="X-Admin-Token"></label>
</header>
<main>
<nav id="nav">
<button data-endpoint="/healthz">Health</button>
<button data-endpoint="/debug/config">Config</button>
<button data-endpoint="/debug/metrics">Metrics</button>
<button data-endpoint="/debug/topology?refresh=true">Topology</button>
<button data-endpoint="/debug/framework">Framework</button>
<button data-endpoint="/debug/adapters">Adapters</button>
<button data-endpoint="/debug/session-contract">Session Contract</button>
<button data-endpoint="/debug/lifecycle">Lifecycle</button>
<button data-endpoint="/debug/readiness">Readiness</button>
<button data-endpoint="/debug/audit">Audit</button>
<button data-endpoint="/debug/logiclog">Logic Log</button>
<button data-endpoint="/debug/bilog">BI Log</button>
<button data-endpoint="/debug/hotupdate">Hot Update</button>
<button data-endpoint="/debug/statesync">State Sync</button>
<button data-endpoint="/debug/rpc">RPC</button>
<button data-endpoint="/debug/i18n">I18N</button>
<button data-endpoint="/gateway/sessions">Gateway Sessions</button>
<button data-endpoint="/gateway/presence">Presence</button>
<button data-endpoint="/onlinepush">Online Push</button>
<button data-endpoint="/onlinepush/send" data-method="POST">Send Push</button>
<button data-endpoint="/onlinepush/offline?account_id=">Offline Push</button>
<button data-endpoint="/gateway/backends">Gateway Backends</button>
<button data-endpoint="/gateway/transports">Transports</button>
<button data-endpoint="/gateway/drain">Gateway Drain</button>
<button data-endpoint="/gateway/drain?enabled=true&close_existing=false" data-method="POST">Start Drain</button>
<button data-endpoint="/gateway/drain?enabled=false" data-method="POST">Stop Drain</button>
<button data-endpoint="/gateway/session/kick?session_id=" data-method="POST">Kick Session</button>
<button data-endpoint="/logic/accounts">Accounts</button>
<button data-endpoint="/accountcenter">Account Center</button>
<button data-endpoint="/accountcenter/account?account_id=">Account Query</button>
<button data-endpoint="/accountcenter/shards" data-method="POST">Account Shards</button>
<button data-endpoint="/accountcenter/gates" data-method="POST">Account Gates</button>
<button data-endpoint="/accountcenter/login" data-method="POST">GetGate Login</button>
<button data-endpoint="/accountcenter/bind" data-method="POST">Account Bind</button>
<button data-endpoint="/accountcenter/ban" data-method="POST">Account Ban</button>
<button data-endpoint="/accountcenter/allow" data-method="POST">Account Allow</button>
<button data-endpoint="/logic/ops/players?account_id=">Ops Player Query</button>
<button data-endpoint="/logic/ops/profile?account_id=">Ops Profile</button>
<button data-endpoint="/logic/ops/profile/repair" data-method="POST">Profile Repair</button>
<button data-endpoint="/logic/ops/events/replay" data-method="POST">Event Replay</button>
<button data-endpoint="/logic/ops/leaderboard">Leaderboard</button>
<button data-endpoint="/logic/ops/leaderboard/repair" data-method="POST">Leaderboard Repair</button>
<button data-endpoint="/logic/ops/bans">Bans</button>
<button data-endpoint="/logic/notice">Notice</button>
<button data-endpoint="/logic/notice/publish" data-method="POST">Notice Publish</button>
<button data-endpoint="/logic/notice/account?account_id=">Notice Account</button>
<button data-endpoint="/logic/notice/announcements">Announcements</button>
<button data-endpoint="/logic/playerstore">Storage</button>
<button data-endpoint="/logic/playerstore/deadletters">Storage Deadletters</button>
<button data-endpoint="/logic/playerstore/deadletters/requeue?account_id=" data-method="POST">Storage Requeue</button>
<button data-endpoint="/storage/objects?collection=&index.region=">Storage Object</button>
<button data-endpoint="/storage/objects/read" data-method="POST">Storage Batch Read</button>
<button data-endpoint="/storage/objects/write" data-method="POST">Storage Batch Write</button>
<button data-endpoint="/storage/objects/delete" data-method="POST">Storage Batch Delete</button>
<button data-endpoint="/storage/object?collection=&key=">Storage Object Read</button>
<button data-endpoint="/storage/object" data-method="POST">Storage Object Write</button>
<button data-endpoint="/storage/object?collection=&key=&user_id=&version=" data-method="DELETE">Storage Object Delete</button>
<button data-endpoint="/logic/idempotency">Idempotency</button>
<button data-endpoint="/logic/handlers">Handlers</button>
<button data-endpoint="/eventlog">Event Log</button>
<button data-endpoint="/eventlog/snapshot">Event Snapshot</button>
<button data-endpoint="/eventlog/deadletters">Event Deadletters</button>
<button data-endpoint="/eventlog/deadletters/requeue?id=" data-method="POST">Event Requeue</button>
<button data-endpoint="/debug/moderation">Moderation</button>
</nav>
<section>
<div class="toolbar"><strong id="title">Health</strong><input class="endpoint" id="endpoint" autocomplete="off" value="/healthz" aria-label="endpoint"></div>
<div class="request">
<select id="method"><option>GET</option><option>POST</option><option>DELETE</option></select>
<input id="confirm" autocomplete="off" placeholder="X-Admin-Confirm">
<button id="send">Send</button>
</div>
<div class="admin-fields">
<input id="actor" autocomplete="off" placeholder="X-Admin-Actor">
<input id="idem" autocomplete="off" placeholder="X-Admin-Idempotency-Key">
</div>
<textarea id="body" spellcheck="false" placeholder='{"account_id":"acc-1"}'></textarea>
<pre id="output">{}</pre>
</section>
</main>
<script>
const token = document.getElementById('token');
const output = document.getElementById('output');
const title = document.getElementById('title');
const endpoint = document.getElementById('endpoint');
const method = document.getElementById('method');
const confirm = document.getElementById('confirm');
const actor = document.getElementById('actor');
const idem = document.getElementById('idem');
const body = document.getElementById('body');
const send = document.getElementById('send');
const buttons = Array.from(document.querySelectorAll('button[data-endpoint]'));
const requestBodies = {
'/gateway/push': { account_id: 'acc-100001', msg_id: 30142, body: '{"notice_id":"notice-1","title":"hello","content":"world"}' },
'/gateway/drain?enabled=true&close_existing=false': { enabled: true, reason: 'rolling-upgrade', close_existing: false },
'/gateway/drain?enabled=false': { enabled: false, reason: 'rollback' },
'/gateway/session/kick?session_id=': { reason: 'admin-kick' },
'/onlinepush/send': { idempotency_key: 'push-1', account_id: 'acc-100001', packet_id: 1, msg_id: 1, body: 'aGVsbG8=', note: 'admin-smoke' },
'/accountcenter/shards': { shards: [{ id: '1001', name: '一区', state: 'open', weight: 1 }] },
'/accountcenter/gates': { gates: [{ node_id: 'gateway-1', address: '127.0.0.1:3341', shard_ids: ['1001'], weight: 1 }] },
'/accountcenter/login': { identity: { kind: 'device', device_id: 'device-001' }, preferred_shard_id: '1001' },
'/accountcenter/bind': { account_id: 'acc-100001', identity: { kind: 'email', email: 'user@example.com' } },
'/accountcenter/ban': { subject: 'acc-100001', scope: 'shard:1001', reason: 'manual-review' },
'/accountcenter/allow': { subject: 'acc-100001', scope: 'global', reason: 'allowlist' },
'/logic/ops/profile/repair': { account_id: 'acc-100001', reason: 'operator-repair' },
'/logic/ops/events/replay': { id: 'event-id' },
'/logic/ops/leaderboard/repair': { action: 'upsert_record', leaderboard_id: 'power', owner_id: 'acc-100001', score: 100, operator: 'admin' },
'/logic/notice/publish': { notice: { id: 'notice-1', title: '维护通知', content: '今晚例行维护。', category: 'system' }, recipients: ['acc-100001'], idempotency_key: 'notice-1' },
'/logic/playerstore/deadletters/requeue?account_id=': { reason: 'operator-requeue' },
'/storage/objects/read': { keys: [{ collection: 'global', key: 'motd' }] },
'/storage/objects/write': { writes: [{ object: { key: { collection: 'global', key: 'motd' }, value: { text: 'hello' }, index: { region: 'cn', kind: 'notice' }, permission_read: 2, permission_write: 1 } }] },
'/storage/objects/delete': { deletes: [{ key: { collection: 'global', key: 'motd' } }] },
'/storage/object': { object: { key: { collection: 'global', key: 'motd' }, value: { text: 'hello' }, index: { region: 'cn', kind: 'notice' }, permission_read: 2, permission_write: 1 } },
'/eventlog/deadletters/requeue?id=': { reason: 'operator-requeue' }
};
token.value = localStorage.getItem('longheng_admin_token') || '';
actor.value = localStorage.getItem('longheng_admin_actor') || '';
token.addEventListener('input', () => localStorage.setItem('longheng_admin_token', token.value));
actor.addEventListener('input', () => localStorage.setItem('longheng_admin_actor', actor.value));
function operationID(methodValue, rawEndpoint) {
return methodValue + ' ' + rawEndpoint.split('?')[0];
}
function newIdempotencyKey(op) {
const safe = op.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
return safe + '-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
}
async function requestCurrent() {
const selected = buttons.find(item => item.classList.contains('active')) || buttons[0];
title.textContent = selected.textContent;
const targetEndpoint = endpoint.value.trim() || selected.dataset.endpoint;
endpoint.value = targetEndpoint;
output.className = '';
output.textContent = 'loading';
try {
const headers = {};
if (token.value.trim()) headers['X-Admin-Token'] = token.value.trim();
if (actor.value.trim()) headers['X-Admin-Actor'] = actor.value.trim();
if (confirm.value.trim()) headers['X-Admin-Confirm'] = confirm.value.trim();
if (idem.value.trim()) headers['X-Admin-Idempotency-Key'] = idem.value.trim();
const init = { method: method.value, headers };
if (method.value !== 'GET' && method.value !== 'HEAD' && body.value.trim()) {
headers['Content-Type'] = 'application/json';
init.body = body.value.trim();
}
const res = await fetch(targetEndpoint, init);
const text = await res.text();
let body = text;
try { body = JSON.stringify(JSON.parse(text), null, 2); } catch (_) {}
output.className = res.ok ? '' : 'error';
output.textContent = 'status=' + res.status + '\n' + body;
} catch (err) {
output.className = 'error';
output.textContent = String(err);
}
}
function load(button) {
buttons.forEach(item => item.classList.toggle('active', item === button));
method.value = button.dataset.method || 'GET';
title.textContent = button.textContent;
endpoint.value = button.dataset.endpoint;
const op = operationID(method.value, endpoint.value);
confirm.value = method.value === 'GET' ? '' : op;
idem.value = method.value === 'GET' ? '' : newIdempotencyKey(op);
const preset = requestBodies[button.dataset.endpoint];
body.value = method.value === 'GET' || !preset ? '' : JSON.stringify(preset, null, 2);
requestCurrent();
}
send.addEventListener('click', requestCurrent);
buttons.forEach(button => button.addEventListener('click', () => load(button)));
load(buttons[0]);
</script>
</body>
</html>`
@@ -0,0 +1,66 @@
package app
import (
"log/slog"
"net/http"
"longheng.io/server/internal/platform/audit"
"longheng.io/server/internal/platform/config"
"longheng.io/server/internal/platform/httpx"
"longheng.io/server/internal/platform/ratelimit"
)
type adminSecurity struct {
configProvider func() config.ServiceConfig
audit *audit.Logger
logger *slog.Logger
rateLimiter *ratelimit.Limiter
}
func newAdminSecurity(configProvider func() config.ServiceConfig, auditLogger *audit.Logger, logger *slog.Logger, limiter *ratelimit.Limiter) adminSecurity {
return adminSecurity{
configProvider: configProvider,
audit: auditLogger,
logger: logger,
rateLimiter: limiter,
}
}
func (s adminSecurity) wrap(scope string, next http.HandlerFunc) http.HandlerFunc {
handler := httpx.WrapAdminPolicy(s.policy, s.logger, scope, next)
if s.rateLimiter == nil {
return handler
}
return s.rateLimiter.WrapFunc(handler)
}
func (s adminSecurity) wrapDangerous(scope, operation string, next http.HandlerFunc) http.HandlerFunc {
handler := httpx.WrapDangerousAdminPolicy(s.policy, s.logger, scope, operation, next)
if s.rateLimiter == nil {
return handler
}
return s.rateLimiter.WrapFunc(handler)
}
func (s adminSecurity) policy() httpx.AdminPolicy {
cfg := config.ServiceConfig{}
if s.configProvider != nil {
cfg = s.configProvider()
}
tokens := make([]httpx.AdminToken, 0, len(cfg.Admin.Tokens))
for _, token := range cfg.Admin.Tokens {
tokens = append(tokens, httpx.AdminToken{
Name: token.Name,
Token: token.Token,
Role: token.Role,
Scopes: append([]string(nil), token.Scopes...),
})
}
return httpx.AdminPolicy{
Token: cfg.Admin.Token,
RBACEnabled: cfg.Admin.RBACEnabled,
RequireConfirmation: cfg.Admin.DangerousConfirm,
Tokens: tokens,
Audit: s.audit,
}
}
+24
View File
@@ -0,0 +1,24 @@
package app
import (
"longheng.io/server/internal/platform/config"
)
func (a *Application) configSnapshot() config.ServiceConfig {
if a == nil {
return config.ServiceConfig{}
}
a.cfgMu.RLock()
cfg := a.cfg
a.cfgMu.RUnlock()
return cfg
}
func (a *Application) setConfig(cfg config.ServiceConfig) {
if a == nil {
return
}
a.cfgMu.Lock()
a.cfg = cfg
a.cfgMu.Unlock()
}
+106
View File
@@ -0,0 +1,106 @@
package app
import (
"net/url"
"strconv"
"strings"
"longheng.io/server/internal/platform/audit"
"longheng.io/server/internal/platform/config"
)
func auditDebugPayload(cfg config.ServiceConfig, auditLogger *audit.Logger, query url.Values) map[string]any {
events := []audit.Event(nil)
if auditLogger != nil {
events = auditLogger.Snapshot()
}
filtered := filterAuditEvents(events, query)
return map[string]any{
"enabled": cfg.Audit.Enabled,
"kind": cfg.Audit.Kind,
"query": auditQuerySummary(query),
"count": len(filtered),
"events": filtered,
}
}
func filterAuditEvents(events []audit.Event, query url.Values) []audit.Event {
if len(events) == 0 {
return nil
}
out := make([]audit.Event, 0, len(events))
for _, event := range events {
if !auditEventMatches(event, query) {
continue
}
out = append(out, event)
}
if limit := auditQueryLimit(query); limit > 0 && len(out) > limit {
return append([]audit.Event(nil), out[len(out)-limit:]...)
}
return out
}
func auditEventMatches(event audit.Event, query url.Values) bool {
return auditQueryEquals(query, "actor", event.Actor) &&
auditQueryEquals(query, "action", event.Action) &&
auditQueryEquals(query, "target", event.Target) &&
auditQueryEquals(query, "service", event.Service) &&
auditQueryEquals(query, "node_id", event.NodeID) &&
auditQueryEquals(query, "trace_id", event.TraceID) &&
auditQueryEquals(query, "request_id", event.RequestID) &&
auditQueryEquals(query, "idempotency_key", event.IdempotencyKey) &&
auditQueryEquals(query, "reason_code", event.ReasonCode) &&
auditQueryEquals(query, "scope", event.Fields["scope"]) &&
auditQueryEquals(query, "status", event.Fields["status"])
}
func auditQueryEquals(query url.Values, key string, value string) bool {
want := strings.TrimSpace(query.Get(key))
if want == "" {
return true
}
return strings.EqualFold(strings.TrimSpace(value), want)
}
func auditQueryLimit(query url.Values) int {
raw := strings.TrimSpace(query.Get("limit"))
if raw == "" {
return 0
}
limit, err := strconv.Atoi(raw)
if err != nil || limit <= 0 {
return 0
}
if limit > 1000 {
return 1000
}
return limit
}
func auditQuerySummary(query url.Values) map[string]string {
keys := []string{
"actor",
"action",
"target",
"service",
"node_id",
"trace_id",
"request_id",
"idempotency_key",
"reason_code",
"scope",
"status",
"limit",
}
out := make(map[string]string, len(keys))
for _, key := range keys {
if value := strings.TrimSpace(query.Get(key)); value != "" {
out[key] = value
}
}
if len(out) == 0 {
return nil
}
return out
}
+55
View File
@@ -0,0 +1,55 @@
package app
import (
"context"
"errors"
"flag"
"fmt"
"os"
)
// EmitCompatDeprecation 在旧版 cmd/<service> 二进制启动时向 stderr 打一行警告。
// 生产构建应切到 cmd/server/<service>;CI smoke 如需静默,可设置
// 设置 LONGHENG_SUPPRESS_DEPRECATION=1。
func EmitCompatDeprecation(serviceName string) {
if os.Getenv("LONGHENG_SUPPRESS_DEPRECATION") == "1" {
return
}
fmt.Fprintf(os.Stderr,
"[deprecated] cmd/%s is a compat entry; production builds must use cmd/server/%s. "+
"See cmd/README.md. Set LONGHENG_SUPPRESS_DEPRECATION=1 to silence.\n",
serviceName, serviceName)
}
// RunFromFlags 使用进程默认命令行参数启动基于 Builder 的服务。
func RunFromFlags(serviceName, defaultConfigPath string, builder Builder) error {
return RunFromFlagSet(context.Background(), flag.CommandLine, os.Args[1:], serviceName, defaultConfigPath, builder)
}
// RunManifestFromFlags 使用进程默认命令行参数启动基于 ServiceManifest 的服务。
func RunManifestFromFlags(serviceName, defaultConfigPath string, manifest ServiceManifest) error {
return RunManifestFromFlagSet(context.Background(), flag.CommandLine, os.Args[1:], serviceName, defaultConfigPath, manifest)
}
// RunFromFlagSet 使用指定 FlagSet 和参数启动基于 Builder 的服务,便于测试或自定义入口复用。
func RunFromFlagSet(ctx context.Context, fs *flag.FlagSet, args []string, serviceName, defaultConfigPath string, builder Builder) error {
return RunManifestFromFlagSet(ctx, fs, args, serviceName, defaultConfigPath, ServiceManifest{Components: builder})
}
// RunManifestFromFlagSet 解析配置路径并按 ServiceManifest 完成服务装配和运行。
func RunManifestFromFlagSet(ctx context.Context, fs *flag.FlagSet, args []string, serviceName, defaultConfigPath string, manifest ServiceManifest) error {
if fs == nil {
return errors.New("flag set is nil")
}
configPath := defaultConfigPath
if existing := fs.Lookup("config"); existing == nil {
fs.StringVar(&configPath, "config", defaultConfigPath, "path to config file")
}
if err := fs.Parse(args); err != nil {
return err
}
if existing := fs.Lookup("config"); existing != nil {
configPath = existing.Value.String()
}
return RunWithManifest(ctx, serviceName, configPath, manifest)
}
@@ -0,0 +1,316 @@
package app
import (
"log/slog"
"net/http"
"net/http/pprof"
"longheng.io/server/internal/platform/adapters"
"longheng.io/server/internal/platform/adminspec"
"longheng.io/server/internal/platform/audit"
"longheng.io/server/internal/platform/bilog"
"longheng.io/server/internal/platform/config"
"longheng.io/server/internal/platform/datatable"
"longheng.io/server/internal/platform/discovery"
"longheng.io/server/internal/platform/framework"
"longheng.io/server/internal/platform/health"
"longheng.io/server/internal/platform/httpx"
"longheng.io/server/internal/platform/lifecycle"
"longheng.io/server/internal/platform/logiclog"
"longheng.io/server/internal/platform/logx"
"longheng.io/server/internal/platform/metrics"
"longheng.io/server/internal/platform/pvf"
"longheng.io/server/internal/platform/ratelimit"
"longheng.io/server/internal/platform/readiness"
"longheng.io/server/internal/platform/reload"
rpckit "longheng.io/server/internal/platform/rpc"
"longheng.io/server/internal/platform/scheduler"
"longheng.io/server/internal/platform/sessioncontract"
"longheng.io/server/internal/platform/topology"
"longheng.io/server/internal/platform/tracing"
)
// commonRouteContext 集中承载平台公共管理路由依赖,避免装配层继续堆叠长参数列表。
type commonRouteContext struct {
Mux *http.ServeMux
ConfigProvider func() config.ServiceConfig
Health *health.Service
Metrics *metrics.Registry
RateLimiter *ratelimit.Limiter
Discovery *discovery.Resolver
RPC *rpckit.Endpoint
Topology *topology.Manager
Scheduler *scheduler.Scheduler
Tracer *tracing.Tracer
PVF *pvf.Archive
DataTables *datatable.Registry
DataTableViews *datatable.ViewManager
Audit *audit.Logger
LogicLog *logiclog.Logger
BILog *bilog.Logger
Reload *reload.Manager
Logger *slog.Logger
}
func registerCommonRoutes(routes commonRouteContext) {
mux := routes.Mux
configProvider := routes.ConfigProvider
healthSvc := routes.Health
metricsRegistry := routes.Metrics
rateLimiter := routes.RateLimiter
discoveryResolver := routes.Discovery
rpcEndpoint := routes.RPC
topologyManager := routes.Topology
schedulerSvc := routes.Scheduler
tracer := routes.Tracer
pvfArchive := routes.PVF
dataTables := routes.DataTables
dataTableViews := routes.DataTableViews
auditLogger := routes.Audit
logicLogger := routes.LogicLog
biLogger := routes.BILog
reloadManager := routes.Reload
logger := routes.Logger
adminGuard := newAdminSecurity(configProvider, auditLogger, logger, rateLimiter)
wrapAdmin := adminGuard.wrap
wrapDangerousAdmin := adminGuard.wrapDangerous
mux.HandleFunc("/healthz/live", healthSvc.LiveHandler)
mux.HandleFunc("/healthz/ready", healthSvc.ReadyHandler)
mux.HandleFunc("/healthz", healthSvc.DebugHandler)
mux.HandleFunc("/admin/openapi.json", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", http.MethodGet)
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
httpx.WriteJSON(w, http.StatusOK, adminspec.Default())
})
mux.HandleFunc("/admin", adminConsole)
mux.HandleFunc("/admin/", adminConsole)
mux.HandleFunc("/debug/config", wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
cfg := configProvider()
httpx.WriteJSON(w, http.StatusOK, cfg.Redacted())
}))
mux.HandleFunc("/debug/metrics", wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
metrics.ObserveRuntime(metricsRegistry)
metricsRegistry.RunObservers()
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"metrics": metricsRegistry.Snapshot(),
})
}))
mux.HandleFunc("/debug/rate_limit", wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
httpx.WriteJSON(w, http.StatusOK, rateLimiter.Snapshot())
}))
mux.HandleFunc("/debug/discovery", wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
httpx.WriteJSON(w, http.StatusOK, discoveryResolver.Snapshot())
}))
mux.HandleFunc("/debug/rpc", wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
httpx.WriteJSON(w, http.StatusOK, rpcEndpoint.Snapshot())
}))
mux.HandleFunc("/debug/topology", wrapAdmin("platform", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("refresh") == "true" {
if err := topologyManager.Refresh(r.Context()); err != nil {
httpx.WriteJSON(w, http.StatusServiceUnavailable, topologyManager.Snapshot())
return
}
}
httpx.WriteJSON(w, http.StatusOK, topologyManager.Snapshot())
}))
mux.HandleFunc("/debug/framework", wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
contract := framework.DefaultRealtimeContract()
status := "ok"
errText := ""
if err := framework.Validate(contract); err != nil {
status = "invalid"
errText = err.Error()
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"status": status,
"error": errText,
"contract": contract,
})
}))
mux.HandleFunc("/debug/adapters", wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
matrix := adapters.MatrixFromConfig(configProvider())
status := "ok"
errText := ""
if err := adapters.ValidateSelected(matrix); err != nil {
status = "degraded"
errText = err.Error()
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"status": status,
"error": errText,
"matrix": matrix,
})
}))
mux.HandleFunc("/debug/session-contract", wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
contract := sessioncontract.Default()
status := "ok"
errText := ""
if err := sessioncontract.Validate(contract); err != nil {
status = "invalid"
errText = err.Error()
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"status": status,
"error": errText,
"contract": contract,
})
}))
mux.HandleFunc("/debug/lifecycle", wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
contract := lifecycle.DefaultContract()
status := "ok"
errText := ""
if err := lifecycle.Validate(contract); err != nil {
status = "invalid"
errText = err.Error()
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"status": status,
"error": errText,
"contract": contract,
})
}))
mux.HandleFunc("/debug/readiness", wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
plan := readiness.DefaultPlan()
status := "ok"
errText := ""
if err := readiness.Validate(plan); err != nil {
status = "invalid"
errText = err.Error()
}
if len(plan.Gaps) > 0 && status == "ok" {
status = "gaps"
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"status": status,
"error": errText,
"plan": plan,
})
}))
mux.HandleFunc("/debug/reload", wrapDangerousAdmin("platform", httpx.AdminOperationID(http.MethodPost, "/debug/reload"), func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
httpx.WriteJSON(w, http.StatusOK, reloadManager.Snapshot())
case http.MethodPost:
result, err := reloadManager.Reload(r.Context(), reload.TriggerManual, r.URL.Query().Get("reason"))
if err != nil {
httpx.WriteJSON(w, http.StatusServiceUnavailable, result)
return
}
httpx.WriteJSON(w, http.StatusOK, result)
default:
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
}))
mux.HandleFunc("/debug/scheduler", wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
httpx.WriteJSON(w, http.StatusOK, map[string]any{"jobs": schedulerSvc.Snapshot()})
}))
mux.HandleFunc("/debug/traces", wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"enabled": tracer.Enabled(),
"spans": tracer.Snapshot(),
})
}))
mux.HandleFunc("/debug/pvf", wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
cfg := configProvider()
payload := map[string]any{
"enabled": cfg.PVF.Enabled,
"loaded": pvfArchive != nil,
}
if pvfArchive != nil {
payload["snapshot"] = pvfArchive.Snapshot()
}
httpx.WriteJSON(w, http.StatusOK, payload)
}))
mux.HandleFunc("/debug/datatables", wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
cfg := configProvider()
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"enabled": cfg.DataTables.Enabled,
"root": dataTables.Root(),
"version": dataTables.Version(),
"manifest": dataTables.Manifest(),
"tables": dataTables.Snapshot(),
})
}))
mux.HandleFunc("/debug/datatable_views", wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
httpx.WriteJSON(w, http.StatusOK, dataTableViews.Snapshot())
}))
mux.HandleFunc("/debug/audit", wrapAdmin("platform", func(w http.ResponseWriter, r *http.Request) {
cfg := configProvider()
httpx.WriteJSON(w, http.StatusOK, auditDebugPayload(cfg, auditLogger, r.URL.Query()))
}))
mux.HandleFunc("/debug/logiclog", wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
cfg := configProvider()
events := []logiclog.Event(nil)
if logicLogger != nil {
events = logicLogger.Events()
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"enabled": cfg.LogicLog.Enabled,
"kind": cfg.LogicLog.Kind,
"events": events,
})
}))
mux.HandleFunc("/debug/bilog", wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
cfg := configProvider()
events := []bilog.Event(nil)
if biLogger != nil {
events = biLogger.Events()
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"enabled": cfg.BILog.Enabled,
"kind": cfg.BILog.Kind,
"events": events,
})
}))
mux.HandleFunc("/debug/log_level", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
wrapAdmin("platform", func(w http.ResponseWriter, _ *http.Request) {
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"level": logx.DefaultLevel().String(),
})
})(w, r)
case http.MethodPost:
wrapDangerousAdmin("platform", httpx.AdminOperationID(http.MethodPost, "/debug/log_level"), func(w http.ResponseWriter, r *http.Request) {
level := r.URL.Query().Get("level")
parsed, err := logx.SetDefaultLevel(level)
if err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if logger != nil {
logger.Warn("log level changed by admin", "level", parsed.String())
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"level": parsed.String(),
})
})(w, r)
default:
w.Header().Set("Allow", "GET, POST")
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
})
cfg := configProvider()
if cfg.Metrics.PrometheusEnabled {
handler := metrics.Handler(metricsRegistry)
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
cfg := configProvider()
if cfg.Metrics.RequireAdminToken {
wrapAdmin("platform", handler)(w, r)
return
}
rateLimiter.Wrap(handler).ServeHTTP(w, r)
})
}
if cfg.Debug.PprofEnabled {
mux.HandleFunc("/debug/pprof/", wrapAdmin("platform-pprof", pprof.Index))
mux.HandleFunc("/debug/pprof/cmdline", wrapAdmin("platform-pprof", pprof.Cmdline))
mux.HandleFunc("/debug/pprof/profile", wrapAdmin("platform-pprof", pprof.Profile))
mux.HandleFunc("/debug/pprof/symbol", wrapAdmin("platform-pprof", pprof.Symbol))
mux.HandleFunc("/debug/pprof/trace", wrapAdmin("platform-pprof", pprof.Trace))
}
}
@@ -0,0 +1,326 @@
package app
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"
"longheng.io/server/internal/platform/health"
"longheng.io/server/internal/platform/otelx"
"longheng.io/server/internal/platform/systemd"
)
// Build 按 manifest 配置顺序生成组件列表,并在返回前拒绝 nil 组件。
func (m ServiceManifest) Build(env *Env) ([]Component, error) {
if env == nil {
env = &Env{}
}
for _, configure := range m.Configure {
if configure == nil {
continue
}
if err := configure(env); err != nil {
return nil, err
}
}
configured := append([]Component(nil), env.Components...)
if m.Components == nil {
for idx, component := range configured {
if component == nil {
return configured, fmt.Errorf("configured service component %d is nil", idx)
}
}
return configured, nil
}
components, err := m.Components(env)
if err != nil {
return components, err
}
components = append(configured, components...)
for idx, component := range components {
if component == nil {
return components, fmt.Errorf("service component %d is nil", idx)
}
}
return components, nil
}
func (a *Application) start(ctx context.Context) error {
a.health.SetServiceState(health.StateStarting, "bootstrapping")
if err := a.preflight(ctx); err != nil {
_ = cleanupComponents(context.Background(), a.health, a.constructed)
_, _ = a.closePlatformRes()
_ = shutdownOTel(context.Background(), a.otelShutdown)
return err
}
a.health.SetStartupStep("components", health.StateStarting, "starting components")
for idx, comp := range a.components {
a.health.SetComponent(comp.Name(), health.StateStarting, "")
if err := comp.Start(ctx); err != nil {
a.health.SetComponent(comp.Name(), health.StateDegraded, err.Error())
a.health.SetStartupStep("components", health.StateDegraded, err.Error())
a.incMetric("service_start_errors_total", map[string]string{"component": comp.Name(), "phase": "start"})
_ = a.stopComponents(ctx, idx+1)
_, _ = a.closePlatformRes()
_ = shutdownOTel(context.Background(), a.otelShutdown)
return fmt.Errorf("start component %s: %w", comp.Name(), err)
}
a.health.SetComponent(comp.Name(), health.StateReady, "running")
}
a.health.SetStartupStep("components", health.StateReady, "components running")
a.health.SetStartupStep("after-start-hooks", health.StateStarting, "running after start hooks")
for _, comp := range a.components {
hook, ok := comp.(AfterStarter)
if !ok {
continue
}
if err := hook.AfterStart(ctx); err != nil {
a.health.SetComponent(comp.Name(), health.StateDegraded, err.Error())
a.health.SetStartupStep("after-start-hooks", health.StateDegraded, err.Error())
a.incMetric("service_start_errors_total", map[string]string{"component": comp.Name(), "phase": "after_start"})
_ = a.stopComponents(ctx, len(a.components))
_, _ = a.closePlatformRes()
_ = shutdownOTel(context.Background(), a.otelShutdown)
return fmt.Errorf("after start component %s: %w", comp.Name(), err)
}
}
a.health.SetStartupStep("after-start-hooks", health.StateReady, "after start hooks complete")
a.health.SetServiceState(health.StateReady, "running")
if err := systemd.Ready(); err != nil && a.logger != nil {
a.logger.Warn("systemd ready notification failed", "error", err)
}
a.watchdogStop = systemd.StartWatchdog(context.Background(), func(err error) {
if a.logger != nil {
a.logger.Warn("systemd watchdog notification failed", "error", err)
}
})
a.incMetric("service_start_total", nil)
if a.logger != nil {
cfg := a.configSnapshot()
a.logger.Info("service started", "service", cfg.Service.Name, "node_id", cfg.Service.NodeID)
}
return nil
}
func (a *Application) stop(ctx context.Context) error {
a.health.SetServiceState(health.StateStopping, "shutting down")
if a.watchdogStop != nil {
a.watchdogStop()
a.watchdogStop = nil
}
if err := systemd.Stopping(); err != nil && a.logger != nil {
a.logger.Warn("systemd stopping notification failed", "error", err)
}
err := a.beforeStopComponents(ctx, len(a.components))
drain := a.waitStopDrain(ctx)
a.recordDrainResult(drain)
err = errors.Join(err, a.stopCompInstances(ctx, len(a.components)))
busErr, regErr := a.closePlatformRes()
otelErr := shutdownOTel(ctx, a.otelShutdown)
a.health.SetServiceState(health.StateStopped, "stopped")
a.incMetric("service_stop_total", nil)
if a.logger != nil {
cfg := a.configSnapshot()
a.logger.Info("service stopped", "service", cfg.Service.Name)
}
if err != nil {
return err
}
if busErr != nil {
return busErr
}
if regErr != nil {
return regErr
}
return otelErr
}
func cleanupComponents(ctx context.Context, healthSvc *health.Service, components []Component) error {
cleanupCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
var firstErr error
for i := len(components) - 1; i >= 0; i-- {
comp := components[i]
if comp == nil {
continue
}
if healthSvc != nil {
healthSvc.SetComponent(comp.Name(), health.StateStopping, "preflight cleanup")
}
if err := comp.Stop(cleanupCtx); err != nil && firstErr == nil {
firstErr = fmt.Errorf("cleanup component %s: %w", comp.Name(), err)
if healthSvc != nil {
healthSvc.SetComponent(comp.Name(), health.StateDegraded, err.Error())
}
continue
}
if healthSvc != nil {
healthSvc.SetComponent(comp.Name(), health.StateStopped, "preflight cleanup")
}
}
return firstErr
}
func shutdownOTel(ctx context.Context, shutdown otelx.Shutdown) error {
if shutdown == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
return shutdown(ctx)
}
func (a *Application) stopComponents(ctx context.Context, count int) error {
err := a.beforeStopComponents(ctx, count)
return errors.Join(err, a.stopCompInstances(ctx, count))
}
func (a *Application) beforeStopComponents(ctx context.Context, count int) error {
var firstErr error
for i := count - 1; i >= 0; i-- {
comp := a.components[i]
hook, ok := comp.(BeforeStopper)
if !ok {
continue
}
if err := hook.BeforeStop(ctx); err != nil && firstErr == nil {
firstErr = fmt.Errorf("before stop component %s: %w", comp.Name(), err)
a.health.SetComponent(comp.Name(), health.StateDegraded, err.Error())
}
}
return firstErr
}
func (a *Application) stopCompInstances(ctx context.Context, count int) error {
var firstErr error
for i := count - 1; i >= 0; i-- {
comp := a.components[i]
a.health.SetComponent(comp.Name(), health.StateStopping, "")
if err := comp.Stop(ctx); err != nil && firstErr == nil {
firstErr = fmt.Errorf("stop component %s: %w", comp.Name(), err)
a.health.SetComponent(comp.Name(), health.StateDegraded, err.Error())
continue
}
a.health.SetComponent(comp.Name(), health.StateStopped, "stopped")
}
return firstErr
}
func (a *Application) recordDrainResult(result StopPolicyResult) {
if result.Outcome == StopPolicyNoProbe {
return
}
labels := map[string]string{"outcome": string(result.Outcome)}
if result.Err != nil {
a.incMetric("service_stop_drain_errors_total", labels)
} else {
a.incMetric("service_stop_drain_total", labels)
}
if a.health != nil && result.Outcome == StopPolicyStableAbandoned {
a.health.SetStartupStep("stop-drain", health.StateDegraded, formatDrainDetail(result))
} else if a.health != nil {
a.health.SetStartupStep("stop-drain", health.StateReady, formatDrainDetail(result))
}
if a.logger == nil {
return
}
args := []any{
"outcome", result.Outcome,
"remaining", result.Remaining,
"probes", result.Probes,
"elapsed", result.Elapsed.String(),
}
if result.Detail != "" {
args = append(args, "detail", result.Detail)
}
if result.Err != nil {
args = append(args, "error", result.Err)
a.logger.Warn("service stop drain probe failed", args...)
return
}
a.logger.Info("service stop drain observed", args...)
}
func (a *Application) incMetric(name string, labels map[string]string) {
if a.metrics != nil {
a.metrics.Inc(name, a.metricLabels(labels))
}
}
func (a *Application) metricLabels(labels map[string]string) map[string]string {
cfg := a.configSnapshot()
out := make(map[string]string, len(labels)+2)
if cfg.Service.Name != "" {
out["service"] = cfg.Service.Name
}
if cfg.Service.NodeID != "" {
out["node_id"] = cfg.Service.NodeID
}
for key, value := range labels {
out[key] = value
}
return out
}
// Run 启动 Application 并在收到取消或系统信号后按停服策略关闭组件。
func (a *Application) Run(ctx context.Context) error {
runCtx, stopSignal := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM, syscall.SIGINT)
defer stopSignal()
if err := a.start(runCtx); err != nil {
return err
}
<-runCtx.Done()
stopTimeout := a.configSnapshot().Service.StopTimeout()
if stopTimeout <= 0 {
stopTimeout = 10 * time.Second
}
stopCtx, cancel := context.WithTimeout(context.Background(), stopTimeout)
defer cancel()
var dumpOnce sync.Once
dumpShutdown := func() {
cfg := a.configSnapshot()
path, err := DumpGoroutineStack(ShutdownDumpOptions{Service: cfg.Service.Name})
if a.logger == nil {
return
}
if err != nil {
a.logger.Warn("failed to dump goroutine stack on shutdown timeout", "error", err)
return
}
a.logger.Warn("dumped goroutine stack on shutdown timeout", "path", path)
}
done := make(chan struct{})
go func() {
select {
case <-stopCtx.Done():
if errors.Is(stopCtx.Err(), context.DeadlineExceeded) {
dumpOnce.Do(dumpShutdown)
}
case <-done:
}
}()
err := a.stop(stopCtx)
close(done)
if errors.Is(stopCtx.Err(), context.DeadlineExceeded) {
dumpOnce.Do(dumpShutdown)
}
return err
}
@@ -0,0 +1,31 @@
package app
import (
"fmt"
"longheng.io/server/internal/platform/config"
"longheng.io/server/internal/platform/pvf"
)
// newPVFArchive adapts the DNF Script.pvf resource into the generic platform
// environment while leaving the framework's core resource model unchanged.
func newPVFArchive(cfg config.ServiceConfig) (*pvf.Archive, error) {
if !cfg.PVF.Enabled {
return nil, nil
}
archive, err := pvf.LoadArchive(pvf.Options{
Path: cfg.PVF.Path,
MaxBytes: cfg.PVF.MaxBytes,
ChunkCacheBytes: cfg.PVF.ChunkCacheBytes,
TextCacheBytes: cfg.PVF.TextCacheBytes,
})
if err != nil {
return nil, fmt.Errorf("load pvf archive: %w", err)
}
if cfg.PVF.PreloadChunks {
if _, err := archive.PreloadAll(); err != nil {
return nil, fmt.Errorf("preload pvf chunks: %w", err)
}
}
return archive, nil
}
+5
View File
@@ -0,0 +1,5 @@
// Package app 负责把配置、平台基础件、Admin 路由、热更新钩子和优雅停机流程装配成服务运行时。
//
// 这里是所有服务二进制的组合边界。业务规则应放在 service/runtime 包里,app
// 只负责选择具体实现、校验运维参数,并按确定顺序关闭资源。
package app
+371
View File
@@ -0,0 +1,371 @@
package app
import (
"context"
"fmt"
"net/http"
"time"
"longheng.io/server/internal/platform/admincmdqueue"
"longheng.io/server/internal/platform/cluster"
"longheng.io/server/internal/platform/config"
"longheng.io/server/internal/platform/discovery"
"longheng.io/server/internal/platform/reload"
rpckit "longheng.io/server/internal/platform/rpc"
"longheng.io/server/internal/platform/servergroup"
"longheng.io/server/internal/platform/topology"
"longheng.io/server/internal/platform/workerpool"
)
// New 创建只装配不运行的 Application,适合测试或上层进程自行托管生命周期。
func New(serviceName, configPath string, builder Builder) (*Application, error) {
return NewWithManifest(serviceName, configPath, ServiceManifest{Components: builder})
}
// Run 创建并运行基于 Builder 的服务,直到传入 context 或系统信号触发停服。
func Run(ctx context.Context, serviceName, configPath string, builder Builder) error {
return RunWithManifest(ctx, serviceName, configPath, ServiceManifest{Components: builder})
}
// RunWithManifest 创建并运行基于 ServiceManifest 的服务,统一复用平台基础设施装配。
func RunWithManifest(ctx context.Context, serviceName, configPath string, manifest ServiceManifest) error {
if ctx == nil {
ctx = context.Background()
}
application, err := NewWithManifest(serviceName, configPath, manifest)
if err != nil {
return err
}
return application.Run(ctx)
}
// NewWithManifest 创建 Application 并完成平台、runtime、admin 与业务组件的统一装配。
func NewWithManifest(serviceName, configPath string, manifest ServiceManifest) (*Application, error) {
foundation, err := newPlatformBase(serviceName, configPath)
if err != nil {
return nil, err
}
cfg := foundation.cfg
logger := foundation.logger
otelShutdown := foundation.otelShutdown
pvfArchive := foundation.pvfArchive
dataTables := foundation.dataTables
dataTableViews := foundation.dataTableViews
stateSyncStore := foundation.stateSyncStore
serverGroupManager := foundation.serverGroupManager
serverGroupStore := foundation.serverGroupStore
auditLogger := foundation.auditLogger
logicLogger := foundation.logicLogger
biLogger := foundation.biLogger
healthSvc := foundation.healthSvc
metricsRegistry := foundation.metricsRegistry
tracer := foundation.tracer
rateLimiter := foundation.rateLimiter
adminTimeouts := foundation.adminTimeouts
adminServer := foundation.adminServer
schedulerSvc := foundation.schedulerSvc
var application *Application
configProvider := func() config.ServiceConfig {
if application != nil {
return application.configSnapshot()
}
return cfg
}
adminGuard := newAdminSecurity(configProvider, auditLogger, logger, rateLimiter)
storageObjects, err := newStorageObjSvc(cfg, adminServer.Mux(), adminGuard.wrap, adminGuard.wrapDangerous)
if err != nil {
_ = foundation.close(context.Background())
return nil, err
}
accountCenter, err := newAccountCenter(cfg, adminServer.Mux(), adminGuard.wrap, adminGuard.wrapDangerous)
if err != nil {
_ = storageObjects.Close()
_ = foundation.close(context.Background())
return nil, err
}
reg, err := newRegistry(cfg, logger)
if err != nil {
_ = foundation.close(context.Background())
return nil, err
}
eventBus, err := newBus(cfg, logger)
if err != nil {
_ = reg.Close()
_ = foundation.close(context.Background())
return nil, err
}
attachBusMetrics(eventBus, metricsRegistry, cfg)
cacheStore, err := newCache(cfg, metricsRegistry)
if err != nil {
_ = eventBus.Close()
_ = reg.Close()
_ = foundation.close(context.Background())
return nil, err
}
lockManager, err := newLockManager(cfg, metricsRegistry)
if err != nil {
_ = cacheStore.Close()
_ = eventBus.Close()
_ = reg.Close()
_ = foundation.close(context.Background())
return nil, err
}
presenceRuntime, err := newPresenceRuntime(cfg)
if err != nil {
_ = lockManager.Close()
_ = cacheStore.Close()
_ = eventBus.Close()
_ = reg.Close()
_ = foundation.close(context.Background())
return nil, err
}
regPresenceMetrics(metricsRegistry, presenceRuntime, cfg)
onlinePush, err := newOnlinePushService(eventBus, presenceRuntime, adminServer.Mux(), adminGuard.wrap, adminGuard.wrapDangerous)
if err != nil {
if closer, ok := presenceRuntime.(interface{ Close() error }); ok {
_ = closer.Close()
}
_ = lockManager.Close()
_ = cacheStore.Close()
_ = eventBus.Close()
_ = reg.Close()
_ = foundation.close(context.Background())
return nil, err
}
eventLog, eventLogWorker, err := newEventLog(cfg, eventBus, adminServer.Mux(), adminGuard.wrap, logger)
if err != nil {
if closer, ok := presenceRuntime.(interface{ Close() error }); ok {
_ = closer.Close()
}
_ = lockManager.Close()
_ = cacheStore.Close()
_ = eventBus.Close()
_ = reg.Close()
_ = foundation.close(context.Background())
return nil, err
}
var adminCommands *admincmdqueue.Executor
if eventLog != nil {
adminCommands, err = admincmdqueue.NewExecutor(admincmdqueue.ExecutorOptions{Log: eventLog})
if err != nil {
_ = eventLog.Close()
if closer, ok := presenceRuntime.(interface{ Close() error }); ok {
_ = closer.Close()
}
_ = lockManager.Close()
_ = cacheStore.Close()
_ = eventBus.Close()
_ = reg.Close()
_ = foundation.close(context.Background())
return nil, err
}
}
hotUpdateManager, err := newHotUpdateManager(cfg, dataTables, dataTableViews, eventLog, logger)
if err != nil {
if eventLog != nil {
_ = eventLog.Close()
}
if closer, ok := presenceRuntime.(interface{ Close() error }); ok {
_ = closer.Close()
}
_ = lockManager.Close()
_ = cacheStore.Close()
_ = eventBus.Close()
_ = reg.Close()
_ = foundation.close(context.Background())
return nil, err
}
workers := workerpool.New("worker-pool", cfg.Worker.Size, cfg.Worker.Queue, logger)
rpcEndpoint := rpckit.NewEndpoint(cfg.Service.Name, cfg.Service.NodeID, eventBus, logger)
rpcEndpoint.SetOptionsExact(rpckit.Options{
CallTimeout: time.Duration(cfg.RPC.CallTimeoutSeconds) * time.Second,
MaxPending: cfg.RPC.MaxPending,
MaxPayloadBytes: cfg.RPC.MaxPayloadBytes,
})
discoveryResolver := discovery.NewResolver(
reg,
time.Duration(cfg.Discovery.CacheTTLSeconds)*time.Second,
time.Duration(cfg.Discovery.FailureTTLSeconds)*time.Second,
!cfg.Discovery.AllowMaintaining,
)
discoveryResolver.SetFailureThreshold(cfg.Discovery.FailureThreshold)
discoveryResolver.SetStrategy(discovery.Strategy(cfg.Discovery.Strategy))
rpcEndpoint.SetDiscoveryResolver(discoveryResolver)
rpcEndpoint.SetTracer(tracer)
registerRPCMetrics(metricsRegistry, rpcEndpoint, cfg)
topologyManager := topology.New(topologyConfig(cfg), reg, logger)
clusterCoord := cluster.New(cfg, reg, eventBus, healthSvc, adminServer.Mux(), cfg.Admin.Token, logger, func(next http.HandlerFunc) http.HandlerFunc {
return adminGuard.wrap("cluster", next)
})
application = &Application{
cfg: cfg,
configPath: configPath,
logger: logger,
bus: eventBus,
cache: cacheStore,
lockManager: lockManager,
presence: presenceRuntime,
registry: reg,
cluster: clusterCoord,
health: healthSvc,
metrics: metricsRegistry,
rateLimiter: rateLimiter,
discovery: discoveryResolver,
rpc: rpcEndpoint,
topology: topologyManager,
scheduler: schedulerSvc,
tracer: tracer,
pvfArchive: pvfArchive,
dataTables: dataTables,
dataTableViews: dataTableViews,
audit: auditLogger,
logicLog: logicLogger,
biLog: biLogger,
eventLog: eventLog,
adminCommands: adminCommands,
accountCenter: accountCenter,
onlinePush: onlinePush,
otelShutdown: otelShutdown,
workers: workers,
admin: adminServer,
hotUpdate: hotUpdateManager,
stateSync: stateSyncStore,
storageObjects: storageObjects,
serverGroup: serverGroupManager,
serverGroupStore: serverGroupStore,
serverGroupArchives: foundation.serverGroupArchives,
}
reloadManager := reload.New("config-reloader", reloadConfig(cfg), application.applyRuntimeReload, logger)
application.reloader = reloadManager
registerCommonRoutes(commonRouteContext{
Mux: adminServer.Mux(),
ConfigProvider: application.configSnapshot,
Health: healthSvc,
Metrics: metricsRegistry,
RateLimiter: rateLimiter,
Discovery: discoveryResolver,
RPC: rpcEndpoint,
Topology: topologyManager,
Scheduler: schedulerSvc,
Tracer: tracer,
PVF: pvfArchive,
DataTables: dataTables,
DataTableViews: dataTableViews,
Audit: auditLogger,
LogicLog: logicLogger,
BILog: biLogger,
Reload: reloadManager,
Logger: logger,
})
regUpdateStateRoutes(adminServer.Mux(), adminGuard.wrapDangerous, adminCommands, hotUpdateManager, stateSyncStore, time.Duration(cfg.StateSync.TTLSeconds)*time.Second)
if serverGroupManager != nil {
servergroup.RegisterAdminRoutes(adminServer.Mux(), serverGroupManager, servergroup.AdminOptions{
Prefix: cfg.ServerGroup.AdminPrefix,
Store: serverGroupStore,
Archives: foundation.serverGroupArchives,
WarZoneInZoneDelaySecs: cfg.ServerGroup.InZoneDelaySeconds,
WarZoneNoticeLeadSecs: cfg.ServerGroup.NoticeLeadSeconds,
Wrap: adminGuard.wrap,
WrapDangerous: adminGuard.wrapDangerous,
})
}
env := &Env{
Config: cfg,
ConfigPath: configPath,
Logger: logger,
Bus: eventBus,
Cache: cacheStore,
Lock: lockManager,
Presence: presenceRuntime,
Cluster: clusterCoord,
Registry: reg,
Health: healthSvc,
Metrics: metricsRegistry,
RateLimiter: rateLimiter,
Discovery: discoveryResolver,
RPC: rpcEndpoint,
Topology: topologyManager,
Scheduler: schedulerSvc,
Tracer: tracer,
PVF: pvfArchive,
DataTables: dataTables,
DataTableViews: dataTableViews,
Audit: auditLogger,
LogicLog: logicLogger,
BILog: biLogger,
EventLog: eventLog,
AdminCommands: adminCommands,
AccountCenter: accountCenter,
OnlinePush: onlinePush,
Reload: reloadManager,
HotUpdate: hotUpdateManager,
StateSync: stateSyncStore,
StorageObjects: storageObjects,
ServerGroup: serverGroupManager,
ServerGroupStore: serverGroupStore,
ServerGroupArchives: foundation.serverGroupArchives,
Workers: workers,
AdminMux: adminServer.Mux(),
AdminTimeouts: adminTimeouts,
AdminAuth: adminGuard.wrap,
AdminDangerous: adminGuard.wrapDangerous,
ConfigProvider: application.configSnapshot,
}
components := []Component{workers, schedulerSvc, rpcEndpoint, reloadManager}
if hotUpdateManager != nil {
components = append(components, hotUpdateManager)
}
if stateSyncSweeper := newStateSyncSweeper(stateSyncStore, time.Duration(cfg.StateSync.CleanupIntervalSeconds)*time.Second, logger); stateSyncSweeper != nil {
components = append(components, stateSyncSweeper)
}
if eventLogWorker != nil {
components = append(components, eventLogWorker)
}
extra, err := manifest.Build(env)
if err != nil {
_ = cleanupComponents(context.Background(), healthSvc, extra)
closeHotCtl(hotUpdateManager)
if closer, ok := presenceRuntime.(interface{ Close() error }); ok {
_ = closer.Close()
}
_ = lockManager.Close()
_ = cacheStore.Close()
_ = eventBus.Close()
_ = reg.Close()
if eventLog != nil {
_ = eventLog.Close()
}
_ = foundation.close(context.Background())
return nil, err
}
application.i18nCatalog = env.I18N
if cfg.DataTables.Enabled {
if _, err := dataTableViews.RefreshAll(context.Background(), dataTables); err != nil {
_ = cleanupComponents(context.Background(), healthSvc, extra)
closeHotCtl(hotUpdateManager)
if closer, ok := presenceRuntime.(interface{ Close() error }); ok {
_ = closer.Close()
}
_ = lockManager.Close()
_ = cacheStore.Close()
_ = eventBus.Close()
_ = reg.Close()
if eventLog != nil {
_ = eventLog.Close()
}
_ = foundation.close(context.Background())
return nil, fmt.Errorf("refresh data table views: %w", err)
}
}
components = append(components, extra...)
components = append(components, adminServer)
components = append(components, clusterCoord)
components = append(components, topologyManager)
application.components = components
application.constructed = extra
return application, nil
}
@@ -0,0 +1,157 @@
package app
import (
"log/slog"
"net/http"
"longheng.io/server/internal/platform/admincmdqueue"
"longheng.io/server/internal/platform/audit"
"longheng.io/server/internal/platform/bilog"
"longheng.io/server/internal/platform/bus"
cachekit "longheng.io/server/internal/platform/cache"
"longheng.io/server/internal/platform/config"
"longheng.io/server/internal/platform/discovery"
"longheng.io/server/internal/platform/eventlog"
"longheng.io/server/internal/platform/health"
"longheng.io/server/internal/platform/httpx"
lockkit "longheng.io/server/internal/platform/lock"
"longheng.io/server/internal/platform/logiclog"
"longheng.io/server/internal/platform/metrics"
"longheng.io/server/internal/platform/onlinepush"
"longheng.io/server/internal/platform/presence"
"longheng.io/server/internal/platform/ratelimit"
"longheng.io/server/internal/platform/registry"
rpckit "longheng.io/server/internal/platform/rpc"
"longheng.io/server/internal/platform/servergroup"
"longheng.io/server/internal/platform/statesync"
"longheng.io/server/internal/platform/storageobject"
"longheng.io/server/internal/platform/topology"
"longheng.io/server/internal/platform/workerpool"
)
// CoreDeps 是服务最小启动上下文。新服务优先依赖 CoreEnv,避免直接消费完整 Env。
type CoreDeps struct {
Config config.ServiceConfig
Logger *slog.Logger
Health *health.Service
Metrics *metrics.Registry
RateLimiter *ratelimit.Limiter
}
// RealtimeDeps 聚合实时通信能力。网关、房间、在线推送等服务按需消费。
type RealtimeDeps struct {
Bus bus.Bus
Presence presence.Runtime
OnlinePush *onlinepush.Service
Discovery *discovery.Resolver
RPC *rpckit.Endpoint
Topology *topology.Manager
Registry registry.Registry
Workers *workerpool.Pool
}
// PersistenceDeps 聚合缓存、锁、可靠事件和通用状态能力。关键写路径应显式依赖这里的 EventLog。
type PersistenceDeps struct {
Cache cachekit.Store
Lock lockkit.Manager
EventLog *eventlog.Log
StateSync statesync.Store
StorageObjects *storageobject.Service
ServerGroup *servergroup.Manager
}
// AdminDeps 聚合运营后台能力。危险操作入口应通过这里拿鉴权、审计和 receipt 队列。
type AdminDeps struct {
Mux *http.ServeMux
Timeouts httpx.Timeouts
Audit *audit.Logger
LogicLog *logiclog.Logger
BILog *bilog.Logger
Commands *admincmdqueue.Executor
Auth func(string, http.HandlerFunc) http.HandlerFunc
Dangerous func(string, string, http.HandlerFunc) http.HandlerFunc
}
// CoreEnv 暴露服务最小启动依赖,供模块收窄对完整 Env 的依赖。
type CoreEnv interface {
Core() CoreDeps
}
// RealtimeEnv 暴露实时通信依赖,供网关、房间和在线模块使用。
type RealtimeEnv interface {
Realtime() RealtimeDeps
}
// PersistenceEnv 暴露持久化和可靠事件依赖,供关键写路径使用。
type PersistenceEnv interface {
Persistence() PersistenceDeps
}
// AdminEnv 暴露运营后台依赖,供管理路由和危险操作使用。
type AdminEnv interface {
AdminRuntime() AdminDeps
}
// Core 返回服务最小启动依赖。
func (e *Env) Core() CoreDeps {
if e == nil {
return CoreDeps{}
}
return CoreDeps{
Config: e.Config,
Logger: e.Logger,
Health: e.Health,
Metrics: e.Metrics,
RateLimiter: e.RateLimiter,
}
}
// Realtime 返回实时通信依赖集合。
func (e *Env) Realtime() RealtimeDeps {
if e == nil {
return RealtimeDeps{}
}
return RealtimeDeps{
Bus: e.Bus,
Presence: e.Presence,
OnlinePush: e.OnlinePush,
Discovery: e.Discovery,
RPC: e.RPC,
Topology: e.Topology,
Registry: e.Registry,
Workers: e.Workers,
}
}
// Persistence 返回持久化、锁和可靠事件依赖集合。
func (e *Env) Persistence() PersistenceDeps {
if e == nil {
return PersistenceDeps{}
}
return PersistenceDeps{
Cache: e.Cache,
Lock: e.Lock,
EventLog: e.EventLog,
StateSync: e.StateSync,
StorageObjects: e.StorageObjects,
ServerGroup: e.ServerGroup,
}
}
// AdminRuntime 返回运营后台依赖集合。
func (e *Env) AdminRuntime() AdminDeps {
if e == nil {
return AdminDeps{}
}
return AdminDeps{
Mux: e.AdminMux,
Timeouts: e.AdminTimeouts,
Audit: e.Audit,
LogicLog: e.LogicLog,
BILog: e.BILog,
Commands: e.AdminCommands,
Auth: e.AdminAuth,
Dangerous: e.AdminDangerous,
}
}
@@ -0,0 +1,152 @@
package app
import (
"context"
"fmt"
"log/slog"
"longheng.io/server/internal/platform/audit"
"longheng.io/server/internal/platform/bilog"
"longheng.io/server/internal/platform/config"
"longheng.io/server/internal/platform/datatable"
"longheng.io/server/internal/platform/health"
"longheng.io/server/internal/platform/httpx"
"longheng.io/server/internal/platform/logiclog"
"longheng.io/server/internal/platform/logx"
"longheng.io/server/internal/platform/metrics"
"longheng.io/server/internal/platform/otelx"
"longheng.io/server/internal/platform/pvf"
"longheng.io/server/internal/platform/ratelimit"
"longheng.io/server/internal/platform/scheduler"
"longheng.io/server/internal/platform/servergroup"
"longheng.io/server/internal/platform/statesync"
"longheng.io/server/internal/platform/tracing"
)
type platformFoundation struct {
cfg config.ServiceConfig
logger *slog.Logger
otelShutdown otelx.Shutdown
pvfArchive *pvf.Archive
dataTables *datatable.Registry
dataTableViews *datatable.ViewManager
stateSyncStore statesync.Store
serverGroupManager *servergroup.Manager
serverGroupStore servergroup.Store
serverGroupArchives servergroup.MergeArchiveStore
auditLogger *audit.Logger
logicLogger *logiclog.Logger
biLogger *bilog.Logger
healthSvc *health.Service
metricsRegistry *metrics.Registry
tracer *tracing.Tracer
rateLimiter *ratelimit.Limiter
adminTimeouts httpx.Timeouts
adminServer *httpx.Server
schedulerSvc *scheduler.Scheduler
}
func newPlatformBase(serviceName, configPath string) (*platformFoundation, error) {
cfg, err := config.Load(configPath, serviceName)
if err != nil {
return nil, fmt.Errorf("load config: %w", err)
}
pvfArchive, err := newPVFArchive(cfg)
if err != nil {
return nil, err
}
logger := logx.New(cfg.Service.Name, cfg.Service.NodeID, cfg.Service.Environment)
otelShutdown, err := setupOTel(cfg, logger)
if err != nil {
return nil, fmt.Errorf("setup opentelemetry: %w", err)
}
foundation := &platformFoundation{
cfg: cfg,
logger: logger,
otelShutdown: otelShutdown,
pvfArchive: pvfArchive,
}
if foundation.dataTables, err = newDataTables(cfg); err != nil {
_ = foundation.close(context.Background())
return nil, err
}
if foundation.dataTableViews, err = datatable.NewViewManager(); err != nil {
_ = foundation.close(context.Background())
return nil, err
}
if foundation.stateSyncStore, err = newStateSyncStore(cfg); err != nil {
_ = foundation.close(context.Background())
return nil, err
}
foundation.serverGroupManager, foundation.serverGroupStore, foundation.serverGroupArchives, err = newGroupManager(context.Background(), cfg)
if err != nil {
_ = foundation.close(context.Background())
return nil, err
}
if foundation.auditLogger, err = newAuditLogger(cfg); err != nil {
_ = foundation.close(context.Background())
return nil, err
}
if foundation.logicLogger, err = newLogicLogger(cfg); err != nil {
_ = foundation.close(context.Background())
return nil, err
}
if foundation.biLogger, err = newBILogger(cfg); err != nil {
_ = foundation.close(context.Background())
return nil, err
}
foundation.healthSvc = health.New(cfg.Service.Name)
foundation.metricsRegistry = metrics.New()
foundation.tracer = tracing.New(cfg.Tracing.Enabled || cfg.OTel.Enabled, cfg.Tracing.MaxSpans, logger)
if foundation.rateLimiter, err = newRateLimiter(cfg); err != nil {
_ = foundation.close(context.Background())
return nil, err
}
foundation.adminTimeouts = httpx.Timeouts{
ReadTimeout: cfg.Admin.ReadTimeout(),
ReadHeaderTimeout: cfg.Admin.ReadHeaderTimeout(),
WriteTimeout: cfg.Admin.WriteTimeout(),
IdleTimeout: cfg.Admin.IdleTimeout(),
}
foundation.adminServer = httpx.New("admin-http", cfg.Admin.Listen, logger, foundation.adminTimeouts)
foundation.schedulerSvc = scheduler.New("scheduler", logger)
return foundation, nil
}
func (f *platformFoundation) close(ctx context.Context) error {
if f == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
var err error
if f.stateSyncStore != nil {
err = f.stateSyncStore.Close()
f.stateSyncStore = nil
}
if f.auditLogger != nil {
if closeErr := f.auditLogger.Close(); closeErr != nil && err == nil {
err = closeErr
}
f.auditLogger = nil
}
if f.logicLogger != nil {
if closeErr := f.logicLogger.Close(); closeErr != nil && err == nil {
err = closeErr
}
f.logicLogger = nil
}
if f.biLogger != nil {
if closeErr := f.biLogger.Close(); closeErr != nil && err == nil {
err = closeErr
}
f.biLogger = nil
}
if closeErr := shutdownOTel(ctx, f.otelShutdown); closeErr != nil && err == nil {
err = closeErr
}
f.otelShutdown = nil
return err
}
@@ -0,0 +1,465 @@
package app
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"time"
"longheng.io/server/internal/platform/admincmd"
"longheng.io/server/internal/platform/admincmdqueue"
"longheng.io/server/internal/platform/config"
"longheng.io/server/internal/platform/datatable"
"longheng.io/server/internal/platform/eventlog"
"longheng.io/server/internal/platform/hotupdate"
"longheng.io/server/internal/platform/httpx"
"longheng.io/server/internal/platform/nativepatch"
"longheng.io/server/internal/platform/statesync"
)
const maxUpdateJSONBytes = 1 << 20
func newStateSyncStore(cfg config.ServiceConfig) (statesync.Store, error) {
if !cfg.StateSync.Enabled {
return nil, nil
}
return statesync.NewMemory(statesync.Options{
MaxPayloadBytes: cfg.StateSync.MaxPayloadBytes,
}), nil
}
func newHotUpdateManager(cfg config.ServiceConfig, dataTables *datatable.Registry, dataTableViews *datatable.ViewManager, eventLog *eventlog.Log, logger *slog.Logger) (*hotupdate.Manager, error) {
if !cfg.HotUpdate.Enabled {
return nil, nil
}
var control hotupdate.Control
switch cfg.HotUpdate.ControlKind {
case "", "memory":
control = hotupdate.NewMemoryControl()
case "eventlog":
if eventLog == nil {
return nil, hotupdate.ErrControlLogRequired
}
var err error
control, err = hotupdate.NewEventLogControl(hotupdate.EventLogControlOptions{Log: eventLog})
if err != nil {
return nil, err
}
default:
return nil, errors.New("unsupported hot update control kind")
}
applier := hotupdate.Applier(hotupdate.DataTableApplier{Registry: dataTables, Views: dataTableViews})
if cfg.HotUpdate.ApplierKind == "native_patch" {
applier = hotupdate.NativePatchApplier{Policy: nativepatch.Policy{
AllowedTargets: cfg.HotUpdate.NativePatchAllowedTargets,
AllowedOldSymbols: cfg.HotUpdate.PatchOldSymbols,
RequireBuildID: cfg.HotUpdate.NativePatchRequireBuildID,
RequireRequestedBy: cfg.HotUpdate.PatchRequireActor,
RequireReason: cfg.HotUpdate.NativePatchRequireReason,
MinReasonLength: cfg.HotUpdate.NativePatchMinReasonLength,
MaxSymbols: cfg.HotUpdate.NativePatchMaxSymbols,
MaxLiveDuration: time.Duration(cfg.HotUpdate.NativePatchMaxLiveSeconds) * time.Second,
}}
}
return hotupdate.NewManager(hotupdate.ManagerOptions{
Target: cfg.HotUpdate.Target,
NodeID: cfg.Service.NodeID,
Workspace: cfg.HotUpdate.Workspace,
ApplyTimeout: time.Duration(cfg.HotUpdate.ApplyTimeoutSeconds) * time.Second,
Control: control,
Source: hotupdate.LocalSource{VerifyOptions: hotupdate.BundleVerifyOptions{
SigningKey: hotUpdateSigningKey(cfg),
RequireSignature: cfg.HotUpdate.RequireSignature,
}},
Applier: applier,
Logger: logger,
})
}
func hotUpdateSigningKey(cfg config.ServiceConfig) []byte {
keyEnv := strings.TrimSpace(cfg.HotUpdate.SigningKeyEnv)
if keyEnv == "" {
return nil
}
key := strings.TrimSpace(os.Getenv(keyEnv))
if key == "" {
return nil
}
return []byte(key)
}
func closeHotCtl(manager *hotupdate.Manager) {
if manager == nil || manager.Control() == nil {
return
}
_ = manager.Control().Close()
}
func newStateSyncSweeper(store statesync.Store, interval time.Duration, logger *slog.Logger) Component {
if store == nil || interval <= 0 {
return nil
}
return &stateSyncSweeper{
store: store,
interval: interval,
logger: logger,
}
}
type stateSyncSweeper struct {
store statesync.Store
interval time.Duration
logger *slog.Logger
mu sync.Mutex
cancel context.CancelFunc
done chan struct{}
stopping bool
}
// Name 返回状态同步清理组件名。
func (s *stateSyncSweeper) Name() string {
return "state-sync-sweeper"
}
// Start 启动状态同步过期数据清理循环。
func (s *stateSyncSweeper) Start(ctx context.Context) error {
if s == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
s.mu.Lock()
if s.done != nil || s.stopping {
s.mu.Unlock()
return nil
}
runCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
s.cancel = cancel
s.done = done
s.mu.Unlock()
go func() {
defer func() {
s.clearStopped(done)
close(done)
}()
sweepStateSync(runCtx, s.store, s.interval, s.logger)
}()
return nil
}
// Stop 停止状态同步过期数据清理循环。
func (s *stateSyncSweeper) Stop(ctx context.Context) error {
if s == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
s.mu.Lock()
cancel := s.cancel
done := s.done
shouldSignal := cancel != nil
if shouldSignal {
s.cancel = nil
s.stopping = true
}
s.mu.Unlock()
if shouldSignal {
cancel()
}
if done == nil {
return nil
}
select {
case <-done:
s.clearStopped(done)
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *stateSyncSweeper) clearStopped(done chan struct{}) {
s.mu.Lock()
defer s.mu.Unlock()
if s.done != done {
return
}
s.cancel = nil
s.done = nil
s.stopping = false
}
func regUpdateStateRoutes(mux *http.ServeMux, wrapDangerous func(string, string, http.HandlerFunc) http.HandlerFunc, adminCommands *admincmdqueue.Executor, hotUpdateManager *hotupdate.Manager, stateSyncStore statesync.Store, stateSyncDefaultTTL time.Duration) {
if stateSyncDefaultTTL <= 0 {
stateSyncDefaultTTL = time.Minute
}
hotUpdateScope := hotUpdateAdminScope(hotUpdateManager)
mux.HandleFunc("/debug/hotupdate", wrapDangerous(hotUpdateScope, httpx.AdminOperationID(http.MethodPost, "/debug/hotupdate"), func(w http.ResponseWriter, r *http.Request) {
if hotUpdateManager == nil {
httpx.WriteJSON(w, http.StatusOK, map[string]any{"enabled": false})
return
}
switch r.Method {
case http.MethodGet:
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"enabled": true,
"snapshot": hotUpdateManager.Snapshot(),
"control": hotUpdateManager.Control().Snapshot(),
})
case http.MethodPost:
var intent hotupdate.Intent
if err := httpx.DecodeStrictJSON(w, r, maxUpdateJSONBytes, &intent); err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
target := hotUpdateManager.Snapshot().Target
receipt, duplicate, submitted, err := submitHotUpdateCmd(r, adminCommands, hotUpdateScope, target, intent)
if writeUpdateCmdError(w, err) {
return
}
if duplicate {
writeAdminDuplicate(w, receipt)
return
}
if err := hotUpdateManager.Control().Publish(r.Context(), target, intent); err != nil {
if submitted {
markUpdateCmdFailed(r.Context(), adminCommands, receipt, err)
}
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if submitted {
receipt = markUpdateOK(r.Context(), adminCommands, receipt)
}
setAdminReceipt(w, receipt)
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{
"accepted": true,
"target": target,
"intent": intent,
"admin_receipt": receipt,
})
default:
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
}))
mux.HandleFunc("/debug/statesync", wrapDangerous("platform", httpx.AdminOperationID(http.MethodPost, "/debug/statesync"), func(w http.ResponseWriter, r *http.Request) {
if stateSyncStore == nil {
httpx.WriteJSON(w, http.StatusOK, map[string]any{"enabled": false})
return
}
switch r.Method {
case http.MethodGet:
scope := r.URL.Query().Get("scope")
if scope == "" {
httpx.WriteJSON(w, http.StatusOK, map[string]any{"enabled": true, "snapshot": stateSyncStore.Snapshot()})
return
}
records, err := stateSyncStore.List(r.Context(), scope)
if err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"enabled": true, "scope": scope, "records": records})
case http.MethodPost:
var request stateSyncAdminReq
if err := httpx.DecodeStrictJSON(w, r, maxUpdateJSONBytes, &request); err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
record := request.Record
expiresAtProvided := !record.ExpiresAt.IsZero()
if record.ExpiresAt.IsZero() {
record.ExpiresAt = time.Now().UTC().Add(stateSyncDefaultTTL)
}
receipt, duplicate, submitted, err := submitStateSyncCmd(r, adminCommands, record, request.Reason, expiresAtProvided)
if writeUpdateCmdError(w, err) {
return
}
if duplicate {
writeAdminDuplicate(w, receipt)
return
}
stored, err := stateSyncStore.Upsert(r.Context(), record)
if err != nil {
if submitted {
markUpdateCmdFailed(r.Context(), adminCommands, receipt, err)
}
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if submitted {
receipt = markUpdateOK(r.Context(), adminCommands, receipt)
}
setAdminReceipt(w, receipt)
httpx.WriteJSON(w, http.StatusOK, map[string]any{"record": stored, "admin_receipt": receipt})
default:
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
}))
}
func hotUpdateAdminScope(manager *hotupdate.Manager) string {
if manager != nil && manager.NativePatchEnabled() {
return "platform.nativepatch"
}
return "platform.hotupdate"
}
type stateSyncAdminReq struct {
statesync.Record
Reason string `json:"reason,omitempty"`
}
func submitHotUpdateCmd(r *http.Request, commands *admincmdqueue.Executor, scope, target string, intent hotupdate.Intent) (admincmd.Receipt, bool, bool, error) {
if commands == nil {
return admincmd.Receipt{}, false, false, nil
}
action := strings.TrimSpace(string(intent.Action))
if action == "" {
action = string(hotupdate.ActionApply)
}
operation := "platform.hotupdate." + action
result, err := commands.Submit(r.Context(), admincmd.Command{
Operation: operation,
Scope: scope,
Target: strings.TrimSpace(target),
Actor: hotUpdateAdminActor(r),
Reason: strings.TrimSpace(intent.Reason),
IdempotencyKey: strings.TrimSpace(r.Header.Get(httpx.AdminIdempotencyHeader)),
Confirmation: strings.TrimSpace(r.Header.Get(httpx.AdminConfirmHeader)),
Params: map[string]any{
"action": action,
"version": intent.Version,
"source_uri": intent.SourceURI,
"checksum": intent.Checksum,
"available_at": intent.AvailableAt,
"requested_by": intent.RequestedBy,
"sequence": intent.Sequence,
},
}, admincmd.DangerousPolicy())
if err != nil {
return admincmd.Receipt{}, false, true, err
}
return result.Receipt, result.Duplicate, true, nil
}
func hotUpdateAdminActor(r *http.Request) string {
actor := httpx.AuthenticatedAdminActor(r)
if actor == "" {
actor = "admin"
}
return actor
}
func submitStateSyncCmd(r *http.Request, commands *admincmdqueue.Executor, record statesync.Record, reason string, expiresAtProvided bool) (admincmd.Receipt, bool, bool, error) {
if commands == nil {
return admincmd.Receipt{}, false, false, nil
}
target := strings.TrimSpace(record.Scope)
if key := strings.TrimSpace(record.Key); key != "" {
target += "/" + key
}
params := map[string]any{
"scope": record.Scope,
"key": record.Key,
"version": record.Version,
"owner_node": record.OwnerNode,
"payload": record.Payload,
}
if expiresAtProvided {
params["expires_at"] = record.ExpiresAt
}
result, err := commands.Submit(r.Context(), admincmd.Command{
Operation: "platform.statesync.upsert",
Scope: "platform",
Target: target,
Actor: hotUpdateAdminActor(r),
Reason: strings.TrimSpace(reason),
IdempotencyKey: strings.TrimSpace(r.Header.Get(httpx.AdminIdempotencyHeader)),
Confirmation: strings.TrimSpace(r.Header.Get(httpx.AdminConfirmHeader)),
Params: params,
}, admincmd.DangerousPolicy())
if err != nil {
return admincmd.Receipt{}, false, true, err
}
return result.Receipt, result.Duplicate, true, nil
}
func markUpdateOK(ctx context.Context, commands *admincmdqueue.Executor, receipt admincmd.Receipt) admincmd.Receipt {
if commands == nil || receipt.ID == "" {
return receipt
}
updated, err := commands.MarkSucceeded(ctx, receipt.ID)
if err != nil {
slog.Default().Error("mark hotupdate admin command succeeded failed", "receipt_id", receipt.ID, "error", err)
return receipt
}
return updated
}
func markUpdateCmdFailed(ctx context.Context, commands *admincmdqueue.Executor, receipt admincmd.Receipt, err error) {
if commands == nil || receipt.ID == "" || err == nil {
return
}
if _, markErr := commands.MarkFailed(ctx, receipt.ID, err.Error(), time.Now().UTC().Add(time.Minute)); markErr != nil {
slog.Default().Error("mark hotupdate admin command failed failed", "receipt_id", receipt.ID, "error", markErr)
}
}
func writeUpdateCmdError(w http.ResponseWriter, err error) bool {
if err == nil {
return false
}
status := http.StatusBadRequest
if errors.Is(err, admincmdqueue.ErrIdempotencyConflict) {
status = http.StatusConflict
}
if errors.Is(err, admincmd.ErrMissingIdempotencyKey) ||
errors.Is(err, admincmd.ErrMissingConfirmation) ||
errors.Is(err, admincmd.ErrMissingReason) {
status = http.StatusPreconditionRequired
}
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
return true
}
func writeAdminDuplicate(w http.ResponseWriter, receipt admincmd.Receipt) {
setAdminReceipt(w, receipt)
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"status": "duplicate",
"admin_receipt": receipt,
})
}
func setAdminReceipt(w http.ResponseWriter, receipt admincmd.Receipt) {
if w != nil && receipt.ID != "" {
w.Header().Set(httpx.AdminReceiptHeader, receipt.ID)
}
}
func sweepStateSync(ctx context.Context, store statesync.Store, interval time.Duration, logger *slog.Logger) {
if store == nil || interval <= 0 {
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if _, err := store.SweepExpired(ctx); err != nil && logger != nil {
logger.Warn("state sync cleanup failed", "error", err)
}
}
}
}
@@ -0,0 +1,597 @@
package app
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
"strings"
"sync/atomic"
"time"
"longheng.io/server/internal/platform/accountcenter"
"longheng.io/server/internal/platform/admincmdqueue"
"longheng.io/server/internal/platform/audit"
"longheng.io/server/internal/platform/bilog"
"longheng.io/server/internal/platform/bus"
cachekit "longheng.io/server/internal/platform/cache"
"longheng.io/server/internal/platform/config"
"longheng.io/server/internal/platform/datatable"
"longheng.io/server/internal/platform/eventlog"
"longheng.io/server/internal/platform/httpx"
lockkit "longheng.io/server/internal/platform/lock"
"longheng.io/server/internal/platform/logiclog"
"longheng.io/server/internal/platform/metrics"
"longheng.io/server/internal/platform/onlinepush"
"longheng.io/server/internal/platform/otelx"
"longheng.io/server/internal/platform/presence"
"longheng.io/server/internal/platform/ratelimit"
"longheng.io/server/internal/platform/registry"
"longheng.io/server/internal/platform/storageobject"
"longheng.io/server/internal/platform/topology"
_ "github.com/go-sql-driver/mysql"
)
func topologyConfig(cfg config.ServiceConfig) topology.Config {
return topology.Config{
Enabled: cfg.Topology.Enabled,
Services: cfg.Topology.Services,
GatewayService: cfg.Topology.GatewayService,
GatewayBackendService: cfg.Topology.GatewayBackendService,
RejectMaintaining: cfg.Topology.RejectMaintaining,
RefreshInterval: time.Duration(cfg.Topology.RefreshIntervalSeconds) * time.Second,
}
}
func newStorageObjSvc(cfg config.ServiceConfig, mux *http.ServeMux, wrapAdmin func(string, http.HandlerFunc) http.HandlerFunc, wrapDangerous func(string, string, http.HandlerFunc) http.HandlerFunc) (*storageobject.Service, error) {
if !cfg.StorageObject.Enabled {
return nil, nil
}
var store storageobject.Store
switch cfg.StorageObject.StoreKind {
case "", "memory":
store = storageobject.NewMemoryStore(nil)
case "mysql":
db, err := sql.Open("mysql", cfg.StorageObject.MySQLDSN)
if err != nil {
return nil, fmt.Errorf("open storage object mysql store: %w", err)
}
sqlStore, err := storageobject.NewSQLStore(storageobject.SQLOptions{
DB: db,
Table: cfg.StorageObject.MySQLTable,
})
if err != nil {
_ = db.Close()
return nil, fmt.Errorf("create storage object mysql store: %w", err)
}
if cfg.StorageObject.MySQLEnsureSchema {
if err := sqlStore.EnsureSchema(context.Background()); err != nil {
_ = sqlStore.Close()
return nil, fmt.Errorf("ensure storage object mysql schema: %w", err)
}
}
store = sqlStore
default:
return nil, fmt.Errorf("unsupported storage_object.store_kind %q", cfg.StorageObject.StoreKind)
}
service := storageobject.NewService(store)
if mux != nil {
wrap := func(next http.HandlerFunc) http.HandlerFunc {
if wrapAdmin != nil {
return wrapAdmin("storageobject", next)
}
return next
}
dangerous := func(operation string, next http.HandlerFunc) http.HandlerFunc {
if wrapDangerous != nil {
return wrapDangerous("storageobject", operation, next)
}
return wrap(next)
}
if err := storageobject.RegisterAdminRoutes(mux, service, storageobject.AdminOptions{Prefix: cfg.StorageObject.AdminPrefix, Wrap: wrap, WrapDangerous: dangerous}); err != nil {
_ = service.Close()
return nil, err
}
}
return service, nil
}
func newAccountCenter(cfg config.ServiceConfig, mux *http.ServeMux, wrapAdmin func(string, http.HandlerFunc) http.HandlerFunc, wrapDangerous func(string, string, http.HandlerFunc) http.HandlerFunc) (*accountcenter.Center, error) {
if !cfg.AccountCenter.Enabled {
return nil, nil
}
var store accountcenter.Store
switch cfg.AccountCenter.StoreKind {
case "", "memory":
case "mysql":
db, err := sql.Open("mysql", cfg.AccountCenter.MySQLDSN)
if err != nil {
return nil, fmt.Errorf("open account center mysql store: %w", err)
}
db.SetMaxOpenConns(16)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(300 * time.Second)
sqlStore, err := accountcenter.NewSQLStore(accountcenter.SQLOptions{
DB: db,
Table: cfg.AccountCenter.MySQLTable,
})
if err != nil {
_ = db.Close()
return nil, fmt.Errorf("create account center mysql store: %w", err)
}
if cfg.AccountCenter.MySQLEnsureSchema {
if err := sqlStore.EnsureSchema(context.Background()); err != nil {
_ = sqlStore.Close()
return nil, fmt.Errorf("ensure account center mysql schema: %w", err)
}
}
store = sqlStore
default:
return nil, fmt.Errorf("unsupported account_center.store_kind %q", cfg.AccountCenter.StoreKind)
}
center, err := accountcenter.NewCenter(context.Background(), accountcenter.CenterOptions{Store: store})
if err != nil {
if closer, ok := store.(interface{ Close() error }); ok {
_ = closer.Close()
}
return nil, err
}
if mux != nil {
wrap := func(next http.HandlerFunc) http.HandlerFunc {
if wrapAdmin != nil {
return wrapAdmin("accountcenter", next)
}
return next
}
dangerous := func(operation string, next http.HandlerFunc) http.HandlerFunc {
if wrapDangerous != nil {
return wrapDangerous("accountcenter", operation, next)
}
return wrap(next)
}
if err := accountcenter.RegisterAdminRoutes(mux, center, accountcenter.AdminOptions{Prefix: cfg.AccountCenter.AdminPrefix, Wrap: wrap, WrapDangerous: dangerous}); err != nil {
_ = center.Close()
return nil, err
}
}
return center, nil
}
func newOnlinePushService(eventBus bus.Bus, presenceRuntime presence.Runtime, mux *http.ServeMux, wrapAdmin func(string, http.HandlerFunc) http.HandlerFunc, wrapDangerous func(string, string, http.HandlerFunc) http.HandlerFunc) (*onlinepush.Service, error) {
service := onlinepush.New(onlinepush.Options{Bus: eventBus, Presence: presenceRuntime})
if mux == nil {
return service, nil
}
wrap := func(next http.HandlerFunc) http.HandlerFunc {
if wrapAdmin != nil {
return wrapAdmin("onlinepush", next)
}
return next
}
dangerous := func(operation string, next http.HandlerFunc) http.HandlerFunc {
if wrapDangerous != nil {
return wrapDangerous("onlinepush", operation, next)
}
return wrap(next)
}
if err := onlinepush.RegisterAdminRoutes(mux, service, onlinepush.AdminOptions{Prefix: "/onlinepush", Wrap: wrap, WrapDangerous: dangerous}); err != nil {
return nil, err
}
return service, nil
}
func newRateLimiter(cfg config.ServiceConfig) (*ratelimit.Limiter, error) {
limitConfig, err := rateLimitConfig(cfg)
if err != nil {
return nil, err
}
return ratelimit.New(limitConfig), nil
}
func rateLimitConfig(cfg config.ServiceConfig) (ratelimit.Config, error) {
rules, err := ratelimit.ParseRules(cfg.RateLimit.Rules)
if err != nil {
return ratelimit.Config{}, fmt.Errorf("parse rate_limit.rules: %w", err)
}
return ratelimit.Config{
Enabled: cfg.RateLimit.Enabled,
Algorithm: cfg.RateLimit.Algorithm,
Window: time.Duration(cfg.RateLimit.WindowSeconds) * time.Second,
MaxRequests: cfg.RateLimit.MaxRequests,
Rules: rules,
CleanupEvery: time.Duration(cfg.RateLimit.CleanupIntervalSeconds) * time.Second,
TrustedHeader: cfg.RateLimit.TrustedHeader,
}, nil
}
func setupOTel(cfg config.ServiceConfig, logger *slog.Logger) (otelx.Shutdown, error) {
return otelx.Setup(context.Background(), cfg.OTel, cfg.Service.Name, cfg.Service.NodeID, cfg.Service.Environment, logger)
}
func newDataTables(cfg config.ServiceConfig) (*datatable.Registry, error) {
registry := datatable.NewRegistry(cfg.DataTables.Directory, cfg.DataTables.Version)
if !cfg.DataTables.Enabled {
return registry, nil
}
if err := registry.Load(context.Background()); err != nil {
return nil, fmt.Errorf("load data tables: %w", err)
}
return registry, nil
}
func newAuditLogger(cfg config.ServiceConfig) (*audit.Logger, error) {
if !cfg.Audit.Enabled {
return audit.NewLogger(cfg.Service.Name, cfg.Service.NodeID, nil), nil
}
var sink audit.Sink
switch cfg.Audit.Kind {
case "memory":
sink = audit.NewMemory(cfg.Audit.MemoryLimit)
case "file":
fileSink, err := audit.NewFile(cfg.Audit.FilePath, cfg.Audit.MemoryLimit)
if err != nil {
return nil, err
}
sink = fileSink
default:
return nil, fmt.Errorf("unsupported audit.kind %q", cfg.Audit.Kind)
}
return audit.NewLogger(cfg.Service.Name, cfg.Service.NodeID, sink), nil
}
func newLogicLogger(cfg config.ServiceConfig) (*logiclog.Logger, error) {
if !cfg.LogicLog.Enabled {
return logiclog.NewLogger(nil, nil), nil
}
catalog, err := logiclog.LoadCatalogCSV(cfg.LogicLog.ReasonsPath)
if err != nil {
return nil, fmt.Errorf("load logic_log.reasons_path: %w", err)
}
var sink logiclog.Sink
switch cfg.LogicLog.Kind {
case "memory":
sink = logiclog.NewBoundedMemorySink(cfg.LogicLog.MemoryLimit)
case "file":
fileSink, err := logiclog.NewFileSink(cfg.LogicLog.FilePath, cfg.LogicLog.MemoryLimit)
if err != nil {
return nil, err
}
sink = fileSink
default:
return nil, fmt.Errorf("unsupported logic_log.kind %q", cfg.LogicLog.Kind)
}
return logiclog.NewLogger(catalog, sink), nil
}
func newBILogger(cfg config.ServiceConfig) (*bilog.Logger, error) {
if !cfg.BILog.Enabled {
return bilog.NewLogger(nil, nil), nil
}
schema, err := bilog.LoadSchema(cfg.BILog.SchemaPath)
if err != nil {
return nil, fmt.Errorf("load bi_log.schema_path: %w", err)
}
var sink bilog.Sink
switch cfg.BILog.Kind {
case "memory":
sink = bilog.NewBoundedMemorySink(cfg.BILog.MemoryLimit)
case "file":
fileSink, err := bilog.NewFileSink(cfg.BILog.FilePath, cfg.BILog.MemoryLimit)
if err != nil {
return nil, err
}
sink = fileSink
default:
return nil, fmt.Errorf("unsupported bi_log.kind %q", cfg.BILog.Kind)
}
return bilog.NewLogger(schema, sink), nil
}
func newEventLog(cfg config.ServiceConfig, eventBus bus.Bus, mux *http.ServeMux, wrapAdmin func(string, http.HandlerFunc) http.HandlerFunc, logger *slog.Logger) (*eventlog.Log, Component, error) {
if !cfg.EventLog.Enabled {
return nil, nil, nil
}
var store eventlog.Store
switch cfg.EventLog.StoreKind {
case "", "memory":
store = eventlog.NewMemoryStore(cfg.Service.Name + "-outbox")
case "mysql":
db, err := sql.Open("mysql", cfg.EventLog.MySQLDSN)
if err != nil {
return nil, nil, fmt.Errorf("open eventlog mysql store: %w", err)
}
db.SetMaxOpenConns(cfg.EventLog.MySQLMaxOpenConns)
db.SetMaxIdleConns(cfg.EventLog.MySQLMaxIdleConns)
db.SetConnMaxLifetime(time.Duration(cfg.EventLog.MySQLConnMaxLifetime) * time.Second)
sqlStore, err := eventlog.NewSQLStoreFromDB(db, eventlog.SQLStoreOptions{
Name: cfg.Service.Name + "-outbox",
Table: cfg.EventLog.MySQLTable,
})
if err != nil {
_ = db.Close()
return nil, nil, fmt.Errorf("create eventlog mysql store: %w", err)
}
if cfg.EventLog.MySQLEnsureSchema {
if err := sqlStore.EnsureSchema(context.Background()); err != nil {
_ = sqlStore.Close()
return nil, nil, fmt.Errorf("ensure eventlog mysql schema: %w", err)
}
}
store = sqlStore
default:
return nil, nil, fmt.Errorf("unsupported eventlog.store_kind %q", cfg.EventLog.StoreKind)
}
log := eventlog.New(eventlog.Options{
Name: cfg.Service.Name + "-outbox",
Store: store,
IDGenerator: newEventIDGen(cfg),
})
if mux != nil {
wrap := func(next http.HandlerFunc) http.HandlerFunc {
if wrapAdmin != nil {
return wrapAdmin("eventlog", next)
}
return httpx.WrapAdmin(cfg.Admin.Token, logger, "eventlog", next)
}
if err := eventlog.RegisterAdminRoutes(mux, log, eventlog.AdminOptions{Prefix: cfg.EventLog.AdminPrefix, Wrap: wrap}); err != nil {
_ = log.Close()
return nil, nil, err
}
}
if !cfg.EventLog.PublishEnabled {
return log, nil, nil
}
publisher, err := newEventLogPublisher(cfg, eventBus)
if err != nil {
_ = log.Close()
return nil, nil, err
}
worker, err := eventlog.NewPublishWorker(eventlog.PublishWorkerOptions{
Name: cfg.Service.Name + "-eventlog-publisher",
Log: log,
Publisher: publisher,
Interval: time.Duration(cfg.EventLog.PublishIntervalSeconds) * time.Second,
Limit: cfg.EventLog.PublishBatchSize,
RetryDelay: time.Duration(cfg.EventLog.PublishRetrySeconds) * time.Second,
MaxAttempts: cfg.EventLog.PublishMaxAttempts,
ExcludeStreams: []string{admincmdqueue.DefaultStream},
Logger: logger,
})
if err != nil {
_ = log.Close()
return nil, nil, err
}
return log, worker, nil
}
func newEventLogPublisher(cfg config.ServiceConfig, eventBus bus.Bus) (eventlog.Publisher, error) {
switch cfg.EventLog.PublishKind {
case "", "bus":
return newEventBusPub(eventBus)
case "http":
return newEventLogPub(cfg)
case "bus_http":
busPublisher, err := newEventBusPub(eventBus)
if err != nil {
return nil, err
}
httpPublisher, err := newEventLogPub(cfg)
if err != nil {
return nil, err
}
return eventlog.NewFanoutPublisher(busPublisher, httpPublisher)
default:
return nil, fmt.Errorf("unsupported eventlog.publish_kind %q", cfg.EventLog.PublishKind)
}
}
func newEventBusPub(eventBus bus.Bus) (*eventlog.BusPublisher, error) {
return eventlog.NewBusPublisher(eventBus, eventlog.BusPublisherOptions{
Topic: eventlog.OriginalTopic,
Payload: eventlog.OriginalTopicPayload,
})
}
func newEventLogPub(cfg config.ServiceConfig) (*eventlog.HTTPPublisher, error) {
body := eventlog.EventJSONBody
if cfg.EventLog.PublishHTTPPayload == "payload" {
body = eventlog.PayloadJSONBody
}
return eventlog.NewHTTPPublisher(eventlog.HTTPPublisherOptions{
URL: cfg.EventLog.PublishHTTPURL,
Method: cfg.EventLog.PublishHTTPMethod,
Client: &http.Client{Timeout: time.Duration(cfg.EventLog.PublishHTTPTimeout) * time.Second},
Headers: cfg.EventLog.PublishHTTPHeaders,
Body: body,
})
}
func newEventIDGen(cfg config.ServiceConfig) eventlog.IDGenerator {
prefix := eventLogIDPrefix(cfg)
var seq uint64
return func() string {
n := atomic.AddUint64(&seq, 1)
return fmt.Sprintf("%s-%d-%d", prefix, time.Now().UTC().UnixNano(), n)
}
}
func eventLogIDPrefix(cfg config.ServiceConfig) string {
service := cleanEventIDPart(cfg.Service.Name)
node := cleanEventIDPart(cfg.Service.NodeID)
if service == "" {
service = "service"
}
if node == "" {
node = "node"
}
prefix := service + "-" + node
if len(prefix) > 96 {
return prefix[:96]
}
return prefix
}
func cleanEventIDPart(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
var b strings.Builder
lastDash := false
for _, r := range value {
ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_'
if ok {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
return strings.Trim(b.String(), "-")
}
func newRegistry(cfg config.ServiceConfig, logger *slog.Logger) (registry.Registry, error) {
switch cfg.Registry.Kind {
case "", "memory":
return registry.NewMemory(), nil
case "redis":
return registry.NewRedis(cfg.Registry.Endpoints, cfg.Registry.Namespace, cfg.Registry.LeaseTTL, cfg.Registry.RedisPassword, cfg.Registry.RedisDB, logger)
case "etcd":
return registry.NewETCD(cfg.Registry.Endpoints, cfg.Registry.Namespace, cfg.Registry.LeaseTTL, logger)
default:
return nil, fmt.Errorf("unsupported registry.kind %q", cfg.Registry.Kind)
}
}
func newBus(cfg config.ServiceConfig, logger *slog.Logger) (bus.Bus, error) {
switch cfg.Bus.Kind {
case "", "memory":
return bus.NewMemory(logger), nil
case "redis":
return bus.NewRedisWithOptions(cfg.Bus.Endpoints, cfg.Bus.Namespace, logger, bus.RedisOptions{
Username: cfg.Bus.RedisUsername,
Password: cfg.Bus.RedisPassword,
Database: cfg.Bus.RedisDB,
TLSEnabled: cfg.Bus.RedisTLSEnabled,
TLSServerName: cfg.Bus.RedisTLSServerName,
})
case "nats":
return bus.NewNATSWithOptions(cfg.Bus.Endpoints, cfg.Bus.Namespace, logger, bus.NATSOptions{
Name: cfg.Bus.NATSName,
Timeout: time.Duration(cfg.Bus.NATSTimeoutSeconds) * time.Second,
PingInterval: time.Duration(cfg.Bus.NATSPingIntervalSeconds) * time.Second,
MaxPingsOutstanding: cfg.Bus.NATSMaxPingsOutstanding,
MaxReconnects: cfg.Bus.NATSMaxReconnects,
ReconnectWait: time.Duration(cfg.Bus.NATSReconnectWaitSeconds) * time.Second,
RetryOnFailedConnect: true,
WireEncoding: cfg.Bus.NATSWireEncoding,
Token: cfg.Bus.NATSToken,
Username: cfg.Bus.NATSUsername,
Password: cfg.Bus.NATSPassword,
Credentials: cfg.Bus.NATSCredentials,
TLSEnabled: cfg.Bus.NATSTLSEnabled,
TLSServerName: cfg.Bus.NATSTLSServerName,
CAFile: cfg.Bus.NATSCAFile,
CertFile: cfg.Bus.NATSCertFile,
KeyFile: cfg.Bus.NATSKeyFile,
})
default:
return nil, fmt.Errorf("unsupported bus.kind %q", cfg.Bus.Kind)
}
}
// busMetricsAttacher 由所有 bus adapter 实现,让平台 wiring 可以挂接 Metrics 发射器,
// 而不需要绑定具体类型。
type busMetricsAttacher interface {
SetMetrics(*bus.Metrics)
}
func attachBusMetrics(eventBus bus.Bus, reg *metrics.Registry, cfg config.ServiceConfig) {
if reg == nil || eventBus == nil {
return
}
kind := cfg.Bus.Kind
if kind == "" {
kind = "memory"
}
attacher, ok := eventBus.(busMetricsAttacher)
if !ok {
return
}
attacher.SetMetrics(bus.NewMetrics(reg, kind))
}
func newCache(cfg config.ServiceConfig, reg *metrics.Registry) (cachekit.Store, error) {
ttl := time.Duration(cfg.Cache.DefaultTTLSeconds) * time.Second
kind := cfg.Cache.Kind
if kind == "" {
kind = "memory"
}
var store cachekit.Store
switch cfg.Cache.Kind {
case "", "memory":
store = cachekit.NewMemory(cachekit.MemoryOptions{
Name: cfg.Service.Name + "-cache",
DefaultTTL: ttl,
MaxEntries: cfg.Cache.MaxEntries,
})
case "redis":
store = cachekit.NewRedis(cachekit.RedisOptions{
Name: cfg.Service.Name + "-cache",
KeyPrefix: cfg.Cache.KeyPrefix,
Address: cfg.Cache.RedisAddress,
Password: cfg.Cache.RedisPassword,
DB: cfg.Cache.RedisDB,
PoolSize: cfg.Cache.RedisPoolSize,
Timeout: time.Duration(cfg.Cache.RedisTimeoutSeconds) * time.Second,
ConnectTimeout: time.Duration(cfg.Cache.RedisConnectTimeout) * time.Second,
ReadTimeout: time.Duration(cfg.Cache.RedisReadTimeout) * time.Second,
WriteTimeout: time.Duration(cfg.Cache.RedisWriteTimeout) * time.Second,
DefaultTTL: ttl,
})
default:
return nil, fmt.Errorf("unsupported cache.kind %q", cfg.Cache.Kind)
}
if attacher, ok := store.(interface{ SetMetrics(*cachekit.Metrics) }); ok {
attacher.SetMetrics(cachekit.NewMetrics(reg, kind))
}
return store, nil
}
func newLockManager(cfg config.ServiceConfig, reg *metrics.Registry) (lockkit.Manager, error) {
ttl := time.Duration(cfg.Lock.TTLSeconds) * time.Second
kind := cfg.Lock.Kind
if kind == "" {
kind = "memory"
}
var manager lockkit.Manager
switch cfg.Lock.Kind {
case "", "memory":
manager = lockkit.NewMemory(lockkit.MemoryOptions{Name: cfg.Service.Name + "-lock", DefaultTTL: ttl})
case "redis":
manager = lockkit.NewRedis(lockkit.RedisOptions{
Name: cfg.Service.Name + "-lock",
KeyPrefix: cfg.Lock.KeyPrefix,
Address: cfg.Lock.RedisAddress,
Password: cfg.Lock.RedisPassword,
DB: cfg.Lock.RedisDB,
PoolSize: cfg.Lock.RedisPoolSize,
Timeout: time.Duration(cfg.Lock.RedisTimeoutSeconds) * time.Second,
ConnectTimeout: time.Duration(cfg.Lock.RedisConnectTimeout) * time.Second,
ReadTimeout: time.Duration(cfg.Lock.RedisReadTimeout) * time.Second,
WriteTimeout: time.Duration(cfg.Lock.RedisWriteTimeout) * time.Second,
DefaultTTL: ttl,
})
default:
return nil, fmt.Errorf("unsupported lock.kind %q", cfg.Lock.Kind)
}
if attacher, ok := manager.(interface{ SetMetrics(*lockkit.Metrics) }); ok {
attacher.SetMetrics(lockkit.NewMetrics(reg, kind))
}
return manager, nil
}
+78
View File
@@ -0,0 +1,78 @@
package app
import (
"context"
"fmt"
"strings"
"longheng.io/server/internal/platform/config"
"longheng.io/server/internal/platform/health"
)
func (a *Application) preflight(ctx context.Context) error {
a.health.SetStartupStep("preflight", health.StateStarting, "checking dependencies")
if err := a.checkStartupSafety(); err != nil {
a.health.SetStartupStep("preflight", health.StateDegraded, err.Error())
a.incMetric("service_start_errors_total", map[string]string{"component": "config", "phase": "preflight"})
return err
}
checkCtx, cancel := context.WithTimeout(ctx, defPreflight)
defer cancel()
if err := a.checkDependency(checkCtx, "registry", a.registry); err != nil {
return err
}
if err := a.checkDependency(checkCtx, "bus", a.bus); err != nil {
return err
}
for _, comp := range a.components {
checker, ok := comp.(PreflightChecker)
if !ok {
continue
}
a.health.SetComponent(comp.Name(), health.StateStarting, "preflight")
if err := checker.Preflight(checkCtx); err != nil {
a.health.SetComponent(comp.Name(), health.StateDegraded, err.Error())
a.health.SetStartupStep("preflight", health.StateDegraded, err.Error())
a.incMetric("service_start_errors_total", map[string]string{"component": comp.Name(), "phase": "preflight"})
return fmt.Errorf("preflight component %s: %w", comp.Name(), err)
}
a.health.SetComponent(comp.Name(), health.StateStarting, "preflight ok")
}
a.health.SetStartupStep("preflight", health.StateReady, "dependencies ready")
return nil
}
func (a *Application) checkStartupSafety() error {
cfg := a.configSnapshot()
environment := strings.ToLower(strings.TrimSpace(cfg.Service.Environment))
if !config.IsProdLikeEnv(environment) {
return nil
}
if strings.EqualFold(strings.TrimSpace(cfg.Admin.Token), "local-admin-token") {
return fmt.Errorf("admin.token must not use local-admin-token outside local/dev/test environments")
}
if err := cfg.Validate(); err != nil {
return fmt.Errorf("startup config safety: %w", err)
}
return nil
}
func (a *Application) checkDependency(ctx context.Context, name string, dependency any) error {
if dependency == nil {
return nil
}
checker, ok := dependency.(interface {
Check(context.Context) error
})
if !ok {
return nil
}
if err := checker.Check(ctx); err != nil {
a.health.SetStartupStep("preflight", health.StateDegraded, err.Error())
a.incMetric("service_start_errors_total", map[string]string{"component": name, "phase": "preflight"})
return fmt.Errorf("preflight %s: %w", name, err)
}
return nil
}
@@ -0,0 +1,70 @@
package app
import (
"fmt"
"time"
"longheng.io/server/internal/platform/config"
"longheng.io/server/internal/platform/db"
"longheng.io/server/internal/platform/metrics"
"longheng.io/server/internal/platform/presence"
rpckit "longheng.io/server/internal/platform/rpc"
)
func regPresenceMetrics(reg *metrics.Registry, runtime presence.Runtime, cfg config.ServiceConfig) {
if reg == nil {
return
}
observer, ok := runtime.(presence.MetricsObserver)
if !ok {
return
}
labels := map[string]string{
"service": cfg.Service.Name,
"node_id": cfg.Service.NodeID,
}
reg.RegisterObserver("presence", func(target *metrics.Registry) {
observer.ObserveMetrics(target, labels)
})
}
func registerRPCMetrics(reg *metrics.Registry, endpoint *rpckit.Endpoint, cfg config.ServiceConfig) {
if reg == nil || endpoint == nil {
return
}
labels := map[string]string{
"service": cfg.Service.Name,
"node_id": cfg.Service.NodeID,
}
endpoint.SetMetrics(reg, labels)
reg.RegisterObserver("rpc", func(target *metrics.Registry) {
endpoint.ObserveMetrics(target, labels)
})
}
func newPresenceRuntime(cfg config.ServiceConfig) (presence.Runtime, error) {
ttl := time.Duration(cfg.Presence.TTLSeconds) * time.Second
switch cfg.Presence.Kind {
case "", "memory":
return presence.New(presence.Options{Name: cfg.Service.Name + "-presence"}), nil
case "redis":
executor := db.NewRedigoExecutor(db.RedigoOptions{
Address: cfg.Presence.RedisAddress,
Password: cfg.Presence.RedisPassword,
DB: cfg.Presence.RedisDB,
PoolSize: cfg.Presence.RedisPoolSize,
Timeout: time.Duration(cfg.Presence.RedisTimeoutSeconds) * time.Second,
ConnectTimeout: time.Duration(cfg.Presence.RedisConnectTimeout) * time.Second,
ReadTimeout: time.Duration(cfg.Presence.RedisReadTimeout) * time.Second,
WriteTimeout: time.Duration(cfg.Presence.RedisWriteTimeout) * time.Second,
})
return presence.NewRedis(presence.RedisOptions{
Name: cfg.Service.Name + "-presence",
Executor: executor,
KeyPrefix: cfg.Presence.KeyPrefix,
TTL: ttl,
}), nil
default:
return nil, fmt.Errorf("unsupported presence.kind %q", cfg.Presence.Kind)
}
}
@@ -0,0 +1,273 @@
package app
import (
"context"
"errors"
"fmt"
"reflect"
"strings"
"time"
"longheng.io/server/internal/platform/config"
"longheng.io/server/internal/platform/datatable"
"longheng.io/server/internal/platform/discovery"
"longheng.io/server/internal/platform/reload"
rpckit "longheng.io/server/internal/platform/rpc"
)
func (a *Application) applyRuntimeReload(ctx context.Context, trigger reload.Trigger, reason string) (reload.Result, error) {
result := reload.Result{
Trigger: string(trigger),
Reason: reason,
At: time.Now().UTC(),
}
if a == nil {
return result, errors.New("application is nil")
}
if ctx == nil {
ctx = context.Background()
}
oldCfg := a.configSnapshot()
newCfg, err := config.Load(a.configPath, oldCfg.Service.Name)
if err != nil {
result.Error = err.Error()
return result, err
}
result.Changed = !reflect.DeepEqual(oldCfg, newCfg)
if trigger == reload.TriggerWatch && !result.Changed {
result.Skipped = append(result.Skipped, "config unchanged")
return result, nil
}
effective := oldCfg
var applyErr error
apply := func(name string, changed bool, fn func() error) {
if applyErr != nil || (!changed && trigger != reload.TriggerManual) {
return
}
if err := fn(); err != nil {
applyErr = fmt.Errorf("reload %s: %w", name, err)
result.Error = applyErr.Error()
return
}
result.Applied = append(result.Applied, name)
}
apply("rate_limit", !reflect.DeepEqual(oldCfg.RateLimit, newCfg.RateLimit), func() error {
cfg, err := rateLimitConfig(newCfg)
if err != nil {
return err
}
a.rateLimiter.Configure(cfg)
effective.RateLimit = newCfg.RateLimit
return nil
})
apply("discovery", !reflect.DeepEqual(oldCfg.Discovery, newCfg.Discovery), func() error {
a.discovery.Configure(
time.Duration(newCfg.Discovery.CacheTTLSeconds)*time.Second,
time.Duration(newCfg.Discovery.FailureTTLSeconds)*time.Second,
newCfg.Discovery.FailureThreshold,
!newCfg.Discovery.AllowMaintaining,
discovery.Strategy(newCfg.Discovery.Strategy),
)
effective.Discovery = newCfg.Discovery
return nil
})
apply("rpc", !reflect.DeepEqual(oldCfg.RPC, newCfg.RPC), func() error {
a.rpc.SetOptionsExact(rpckit.Options{
CallTimeout: time.Duration(newCfg.RPC.CallTimeoutSeconds) * time.Second,
MaxPending: newCfg.RPC.MaxPending,
MaxPayloadBytes: newCfg.RPC.MaxPayloadBytes,
})
effective.RPC = newCfg.RPC
return nil
})
apply("topology", !reflect.DeepEqual(oldCfg.Topology, newCfg.Topology), func() error {
a.topology.Configure(topologyConfig(newCfg))
if newCfg.Topology.Enabled {
if err := a.topology.Refresh(ctx); err != nil {
return err
}
}
effective.Topology = newCfg.Topology
return nil
})
apply("tracing", !reflect.DeepEqual(oldCfg.Tracing, newCfg.Tracing), func() error {
a.tracer.Configure(newCfg.Tracing.Enabled || oldCfg.OTel.Enabled, newCfg.Tracing.MaxSpans)
effective.Tracing = newCfg.Tracing
return nil
})
apply("data_tables", !reflect.DeepEqual(oldCfg.DataTables, newCfg.DataTables), func() error {
if newCfg.DataTables.Enabled {
options := datatable.ReloadOptions{}
if a.dataTableViews != nil {
options.Validators = append(options.Validators, a.dataTableViews)
}
loadResult, err := a.dataTables.ReloadDir(ctx, newCfg.DataTables.Directory, newCfg.DataTables.Version, options)
if err != nil {
return err
}
effective.DataTables = newCfg.DataTables
if a.dataTableViews != nil {
viewResult, err := a.dataTableViews.Refresh(context.WithoutCancel(ctx), a.dataTables, loadResult)
if err != nil {
return err
}
addReloadDetail(&result, "data_table_views", viewResult)
}
addReloadDetail(&result, "data_tables", loadResult)
} else {
a.dataTables.Configure(newCfg.DataTables.Directory, newCfg.DataTables.Version)
a.dataTables.Clear()
if a.dataTableViews != nil {
addReloadDetail(&result, "data_table_views", a.dataTableViews.Clear())
}
addReloadDetail(&result, "data_tables", map[string]any{
"enabled": false,
"manifest": a.dataTables.Manifest(),
})
}
effective.DataTables = newCfg.DataTables
return nil
})
apply("i18n", !reflect.DeepEqual(oldCfg.I18N, newCfg.I18N), func() error {
if a.i18nCatalog == nil {
effective.I18N = newCfg.I18N
return nil
}
if newCfg.I18N.Enabled {
if err := a.i18nCatalog.LoadDirWithConfig(ctx, newCfg.I18N.Directory, newCfg.I18N.DefaultLanguage, newCfg.I18N.Version); err != nil {
return err
}
if !a.i18nCatalog.HasLanguage(newCfg.I18N.DefaultLanguage) {
return fmt.Errorf("default language %q is not loaded", newCfg.I18N.DefaultLanguage)
}
a.i18nCatalog.Configure(newCfg.I18N.DefaultLanguage, newCfg.I18N.Version)
} else {
a.i18nCatalog.Configure(newCfg.I18N.DefaultLanguage, newCfg.I18N.Version)
a.i18nCatalog.Clear()
}
effective.I18N = newCfg.I18N
return nil
})
apply("reload", !reflect.DeepEqual(oldCfg.Reload, newCfg.Reload), func() error {
if a.reloader != nil {
a.reloader.Configure(reloadConfig(newCfg))
}
effective.Reload = newCfg.Reload
return nil
})
apply("admin_security", adminSecurityChanged(oldCfg.Admin, newCfg.Admin), func() error {
effective.Admin.Token = newCfg.Admin.Token
effective.Admin.Tokens = cloneAdminTokens(newCfg.Admin.Tokens)
effective.Admin.RBACEnabled = newCfg.Admin.RBACEnabled
effective.Admin.DangerousConfirm = newCfg.Admin.DangerousConfirm
return nil
})
apply("metrics_admin_policy", oldCfg.Metrics.RequireAdminToken != newCfg.Metrics.RequireAdminToken, func() error {
effective.Metrics.RequireAdminToken = newCfg.Metrics.RequireAdminToken
return nil
})
result.RestartRequired = restartReqSections(oldCfg, newCfg)
if applyErr != nil {
a.setConfig(effective)
return result, applyErr
}
a.setConfig(effective)
if len(result.Applied) == 0 && len(result.RestartRequired) == 0 {
result.Skipped = append(result.Skipped, "no runtime-applicable changes")
}
return result, nil
}
func adminSecurityChanged(oldAdmin, newAdmin config.AdminSection) bool {
return oldAdmin.Token != newAdmin.Token ||
oldAdmin.RBACEnabled != newAdmin.RBACEnabled ||
oldAdmin.DangerousConfirm != newAdmin.DangerousConfirm ||
!reflect.DeepEqual(oldAdmin.Tokens, newAdmin.Tokens)
}
func cloneAdminTokens(tokens []config.AdminTokenSection) []config.AdminTokenSection {
if len(tokens) == 0 {
return nil
}
out := make([]config.AdminTokenSection, len(tokens))
copy(out, tokens)
for idx := range out {
if len(out[idx].Scopes) > 0 {
out[idx].Scopes = append([]string(nil), out[idx].Scopes...)
}
}
return out
}
func restartReqSections(oldCfg, newCfg config.ServiceConfig) []string {
var out []string
if !reflect.DeepEqual(oldCfg.Service, newCfg.Service) {
out = append(out, "service")
}
if !reflect.DeepEqual(oldCfg.Cluster, newCfg.Cluster) {
out = append(out, "cluster")
}
if oldCfg.Admin.Listen != newCfg.Admin.Listen ||
oldCfg.Admin.ReadTimeoutSeconds != newCfg.Admin.ReadTimeoutSeconds ||
oldCfg.Admin.ReadHeaderTimeoutSeconds != newCfg.Admin.ReadHeaderTimeoutSeconds ||
oldCfg.Admin.WriteTimeoutSeconds != newCfg.Admin.WriteTimeoutSeconds ||
oldCfg.Admin.IdleTimeoutSeconds != newCfg.Admin.IdleTimeoutSeconds {
out = append(out, "admin_http")
}
if oldCfg.Metrics.PrometheusEnabled != newCfg.Metrics.PrometheusEnabled {
out = append(out, "metrics_endpoint")
}
if !reflect.DeepEqual(oldCfg.OTel, newCfg.OTel) {
out = append(out, "otel")
}
if !reflect.DeepEqual(oldCfg.Debug, newCfg.Debug) {
out = append(out, "debug_routes")
}
if !reflect.DeepEqual(oldCfg.PVF, newCfg.PVF) {
out = append(out, "pvf")
}
if !reflect.DeepEqual(oldCfg.Audit, newCfg.Audit) {
out = append(out, "audit_sink")
}
if !reflect.DeepEqual(oldCfg.Registry, newCfg.Registry) {
out = append(out, "registry")
}
if !reflect.DeepEqual(oldCfg.Bus, newCfg.Bus) {
out = append(out, "bus")
}
if !reflect.DeepEqual(oldCfg.Worker, newCfg.Worker) {
out = append(out, "worker_pool")
}
if !reflect.DeepEqual(oldCfg.ProfileStore, newCfg.ProfileStore) {
out = append(out, "profile_store")
}
if !reflect.DeepEqual(oldCfg.Gateway, newCfg.Gateway) {
out = append(out, "gateway")
}
return out
}
func addReloadDetail(result *reload.Result, key string, value any) {
if result == nil {
return
}
key = strings.TrimSpace(key)
if key == "" {
return
}
if result.Details == nil {
result.Details = make(map[string]any)
}
result.Details[key] = value
}
func reloadConfig(cfg config.ServiceConfig) reload.Config {
return reload.Config{
Enabled: cfg.Reload.Enabled,
PollInterval: time.Duration(cfg.Reload.PollIntervalSeconds) * time.Second,
ApplyTimeout: time.Duration(cfg.Reload.ApplyTimeoutSeconds) * time.Second,
}
}
@@ -0,0 +1,48 @@
package app
import "errors"
func (a *Application) closePlatformRes() (error, error) {
var busErr error
if a.bus != nil {
busErr = a.bus.Close()
}
if a.audit != nil {
busErr = errors.Join(busErr, a.audit.Close())
}
if a.logicLog != nil {
busErr = errors.Join(busErr, a.logicLog.Close())
}
if a.biLog != nil {
busErr = errors.Join(busErr, a.biLog.Close())
}
if a.eventLog != nil {
busErr = errors.Join(busErr, a.eventLog.Close())
}
if a.hotUpdate != nil && a.hotUpdate.Control() != nil {
busErr = errors.Join(busErr, a.hotUpdate.Control().Close())
}
if a.stateSync != nil {
busErr = errors.Join(busErr, a.stateSync.Close())
}
if a.storageObjects != nil {
busErr = errors.Join(busErr, a.storageObjects.Close())
}
if a.accountCenter != nil {
busErr = errors.Join(busErr, a.accountCenter.Close())
}
if a.lockManager != nil {
busErr = errors.Join(busErr, a.lockManager.Close())
}
if closer, ok := a.presence.(interface{ Close() error }); ok {
busErr = errors.Join(busErr, closer.Close())
}
if a.cache != nil {
busErr = errors.Join(busErr, a.cache.Close())
}
var regErr error
if a.registry != nil {
regErr = a.registry.Close()
}
return busErr, regErr
}
@@ -0,0 +1,77 @@
package app
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"longheng.io/server/internal/platform/config"
"longheng.io/server/internal/platform/servergroup"
)
func newGroupManager(ctx context.Context, cfg config.ServiceConfig) (*servergroup.Manager, servergroup.Store, servergroup.MergeArchiveStore, error) {
if !cfg.ServerGroup.Enabled {
return nil, nil, nil, nil
}
store, err := servergroup.NewFileStore(cfg.ServerGroup.PlanFile)
if err != nil {
return nil, nil, nil, err
}
manager, err := servergroup.LoadManager(ctx, store, defaultGroupPlan(cfg))
if err != nil {
return nil, nil, nil, err
}
archives, err := servergroup.NewFileMergeArchiveStore(cfg.ServerGroup.MergeArchiveDir)
if err != nil {
return nil, nil, nil, err
}
return manager, store, archives, nil
}
func defaultGroupPlan(cfg config.ServiceConfig) servergroup.Plan {
shardID := strconv.FormatInt(cfg.Cluster.ShardID, 10)
if cfg.Cluster.ShardID <= 0 {
shardID = strings.TrimSpace(cfg.Service.Name)
}
groupID := strings.TrimSpace(cfg.Service.Name + "-" + cfg.Service.NodeID)
now := time.Now().UTC()
openAt := now
if cfg.Cluster.ShardID <= 0 {
openAt = time.Time{}
}
return servergroup.Plan{
Version: firstConfigString(cfg.Cluster.Version, "local-servergroup"),
Shards: []servergroup.Shard{{
ID: shardID,
GroupID: groupID,
State: servergroup.StateOpen,
OpenAt: openAt,
PublicOpenAt: openAt,
UpdatedAt: now,
Meta: map[string]string{
"cluster_id": cfg.Cluster.ID,
"gid": fmt.Sprint(cfg.Cluster.GID),
},
}},
Groups: []servergroup.Group{{
ID: groupID,
Service: cfg.Service.Name,
MemberID: cfg.Service.NodeID,
State: servergroup.StateOpen,
UpdatedAt: now,
}},
UpdatedAt: now,
}
}
func firstConfigString(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
@@ -0,0 +1,72 @@
package app
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"unicode"
)
// ShutdownDumpOptions 控制关停堆栈 dump 的目录、服务名和时间来源。
type ShutdownDumpOptions struct {
Dir string
Service string
Now func() time.Time
}
// DumpGoroutineStack 把当前进程所有 goroutine 栈写入文件,用于关停超时排查。
func DumpGoroutineStack(options ShutdownDumpOptions) (string, error) {
dir := strings.TrimSpace(options.Dir)
if dir == "" {
dir = "reports"
}
now := options.Now
if now == nil {
now = time.Now
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return "", err
}
service := sanitizeDumpPart(options.Service)
if service == "" {
service = "service"
}
filename := fmt.Sprintf("shutdown_%s_%s.stack", service, now().UTC().Format("20060102T150405Z"))
path := filepath.Join(dir, filename)
data := goroutineStackDump()
if err := os.WriteFile(path, data, 0o600); err != nil {
return "", err
}
return path, nil
}
func goroutineStackDump() []byte {
size := 1 << 20
for {
buf := make([]byte, size)
n := runtime.Stack(buf, true)
if n < len(buf) {
return buf[:n]
}
size *= 2
}
}
func sanitizeDumpPart(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
var b strings.Builder
for _, r := range value {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' {
b.WriteRune(r)
continue
}
b.WriteByte('_')
}
return strings.Trim(b.String(), "_")
}
+233
View File
@@ -0,0 +1,233 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
"time"
"longheng.io/server/internal/platform/config"
)
// StopDrainSnapshot 描述停服排空探针看到的剩余工作量。
type StopDrainSnapshot struct {
Remaining int
Detail string
}
// StopDrainProber 由需要优雅排空的组件实现,用于停服策略轮询。
type StopDrainProber interface {
StopDrainSnapshot(context.Context) (StopDrainSnapshot, error)
}
// StopDrainProbe 是停服排空探针函数,便于测试和轻量组件接入。
type StopDrainProbe func(context.Context) (StopDrainSnapshot, error)
// StopPolicy 定义停服排空的初始宽限、探测间隔和稳定窗口。
type StopPolicy struct {
InitialGrace time.Duration
ProbeInterval time.Duration
StableFor time.Duration
}
// StopPolicyOutcome 表示停服排空策略最终落在哪一种结果上。
type StopPolicyOutcome string
const (
// StopPolicyNoProbe 表示组件没有提供排空探针。
StopPolicyNoProbe StopPolicyOutcome = "no_probe"
// StopPolicyDrained 表示排空探针确认剩余工作已经归零。
StopPolicyDrained StopPolicyOutcome = "drained"
// StopPolicyStableAbandoned 表示剩余工作长时间稳定不变,允许继续停服。
StopPolicyStableAbandoned StopPolicyOutcome = "stable_abandoned"
// StopPolicyContextDone 表示停服上下文先于排空完成而结束。
StopPolicyContextDone StopPolicyOutcome = "context_done"
// StopPolicyProbeFailed 表示排空探针返回错误。
StopPolicyProbeFailed StopPolicyOutcome = "probe_failed"
)
// StopPolicyResult 汇总停服排空策略的结果、探测次数和耗时。
type StopPolicyResult struct {
Outcome StopPolicyOutcome
Remaining int
Detail string
Probes int
Err error
Elapsed time.Duration
}
func stopPolicyFromConfig(cfg config.ServiceConfig) StopPolicy {
policy := StopPolicy{
InitialGrace: cfg.Service.StopInitialGrace(),
ProbeInterval: cfg.Service.StopProbeInterval(),
StableFor: cfg.Service.StopStableWindow(),
}
if policy.ProbeInterval <= 0 {
policy.ProbeInterval = time.Second
}
if policy.StableFor <= 0 {
policy.StableFor = 5 * time.Second
}
return policy
}
// WaitStopDrain 按停服策略轮询排空探针,直到排空、稳定放弃或上下文结束。
func WaitStopDrain(ctx context.Context, policy StopPolicy, probe StopDrainProbe) StopPolicyResult {
startedAt := time.Now()
if ctx == nil {
ctx = context.Background()
}
if probe == nil {
return StopPolicyResult{Outcome: StopPolicyNoProbe, Elapsed: time.Since(startedAt)}
}
if policy.ProbeInterval <= 0 {
policy.ProbeInterval = time.Second
}
if policy.StableFor <= 0 {
policy.StableFor = 5 * time.Second
}
if policy.InitialGrace > 0 {
if err := sleepContext(ctx, policy.InitialGrace); err != nil {
return StopPolicyResult{Outcome: StopPolicyContextDone, Err: err, Elapsed: time.Since(startedAt)}
}
}
var lastRemaining int
var lastDetail string
var stableSince time.Time
for {
now := time.Now()
snapshot, err := probe(ctx)
if err != nil {
return StopPolicyResult{
Outcome: StopPolicyProbeFailed,
Remaining: snapshot.Remaining,
Detail: snapshot.Detail,
Probes: 1,
Err: err,
Elapsed: time.Since(startedAt),
}
}
result := StopPolicyResult{
Outcome: StopPolicyDrained,
Remaining: snapshot.Remaining,
Detail: snapshot.Detail,
Probes: 1,
Elapsed: time.Since(startedAt),
}
if snapshot.Remaining <= 0 {
return result
}
lastRemaining = snapshot.Remaining
lastDetail = snapshot.Detail
stableSince = now
for {
if err := sleepContext(ctx, policy.ProbeInterval); err != nil {
return StopPolicyResult{
Outcome: StopPolicyContextDone,
Remaining: lastRemaining,
Detail: lastDetail,
Probes: result.Probes,
Err: err,
Elapsed: time.Since(startedAt),
}
}
now = time.Now()
snapshot, err = probe(ctx)
result.Probes++
result.Elapsed = time.Since(startedAt)
result.Remaining = snapshot.Remaining
result.Detail = snapshot.Detail
if err != nil {
result.Outcome = StopPolicyProbeFailed
result.Err = err
return result
}
if snapshot.Remaining <= 0 {
result.Outcome = StopPolicyDrained
return result
}
if snapshot.Remaining != lastRemaining || snapshot.Detail != lastDetail {
lastRemaining = snapshot.Remaining
lastDetail = snapshot.Detail
stableSince = now
continue
}
if now.Sub(stableSince) >= policy.StableFor {
result.Outcome = StopPolicyStableAbandoned
return result
}
}
}
}
func sleepContext(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return nil
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func (a *Application) waitStopDrain(ctx context.Context) StopPolicyResult {
probe := a.stopDrainProbe()
if probe == nil {
return StopPolicyResult{Outcome: StopPolicyNoProbe}
}
return WaitStopDrain(ctx, stopPolicyFromConfig(a.configSnapshot()), probe)
}
func (a *Application) stopDrainProbe() StopDrainProbe {
if a == nil {
return nil
}
var probers []StopDrainProber
for _, component := range a.components {
if prober, ok := component.(StopDrainProber); ok {
probers = append(probers, prober)
}
}
if len(probers) == 0 {
return nil
}
return func(ctx context.Context) (StopDrainSnapshot, error) {
var remaining int
var details []string
var errs []error
for _, prober := range probers {
snapshot, err := prober.StopDrainSnapshot(ctx)
if snapshot.Remaining > 0 {
remaining += snapshot.Remaining
}
if snapshot.Detail != "" {
details = append(details, snapshot.Detail)
}
if err != nil {
errs = append(errs, err)
}
}
var err error
if len(errs) > 0 {
err = errors.Join(errs...)
}
return StopDrainSnapshot{
Remaining: remaining,
Detail: strings.Join(details, "; "),
}, err
}
}
func formatDrainDetail(result StopPolicyResult) string {
if result.Detail == "" {
return fmt.Sprintf("outcome=%s remaining=%d probes=%d", result.Outcome, result.Remaining, result.Probes)
}
return fmt.Sprintf("outcome=%s remaining=%d probes=%d detail=%s", result.Outcome, result.Remaining, result.Probes, result.Detail)
}
+179
View File
@@ -0,0 +1,179 @@
package app
import (
"context"
"log/slog"
"net/http"
"sync"
"time"
"longheng.io/server/internal/platform/accountcenter"
"longheng.io/server/internal/platform/admincmdqueue"
"longheng.io/server/internal/platform/audit"
"longheng.io/server/internal/platform/bilog"
"longheng.io/server/internal/platform/bus"
cachekit "longheng.io/server/internal/platform/cache"
"longheng.io/server/internal/platform/cluster"
"longheng.io/server/internal/platform/config"
"longheng.io/server/internal/platform/datatable"
"longheng.io/server/internal/platform/discovery"
"longheng.io/server/internal/platform/eventlog"
"longheng.io/server/internal/platform/health"
"longheng.io/server/internal/platform/hotupdate"
"longheng.io/server/internal/platform/httpx"
lockkit "longheng.io/server/internal/platform/lock"
"longheng.io/server/internal/platform/logiclog"
"longheng.io/server/internal/platform/metrics"
"longheng.io/server/internal/platform/onlinepush"
"longheng.io/server/internal/platform/otelx"
"longheng.io/server/internal/platform/presence"
"longheng.io/server/internal/platform/pvf"
"longheng.io/server/internal/platform/ratelimit"
"longheng.io/server/internal/platform/registry"
"longheng.io/server/internal/platform/reload"
rpckit "longheng.io/server/internal/platform/rpc"
"longheng.io/server/internal/platform/scheduler"
"longheng.io/server/internal/platform/servergroup"
"longheng.io/server/internal/platform/statesync"
"longheng.io/server/internal/platform/storageobject"
"longheng.io/server/internal/platform/topology"
"longheng.io/server/internal/platform/tracing"
"longheng.io/server/internal/platform/workerpool"
)
// Component 是应用统一托管的生命周期组件。
type Component interface {
Name() string
Start(context.Context) error
Stop(context.Context) error
}
// AfterStarter 是组件启动完成后的回调扩展点。
type AfterStarter interface {
AfterStart(context.Context) error
}
// BeforeStopper 是组件停止前的回调扩展点。
type BeforeStopper interface {
BeforeStop(context.Context) error
}
// PreflightChecker 是组件启动前的健康预检扩展点。
type PreflightChecker interface {
Preflight(context.Context) error
}
// Builder 根据 Env 构造一组服务组件。
type Builder func(*Env) ([]Component, error)
// EnvConfigurer 在组件构造前补充或替换 Env 里的依赖。
type EnvConfigurer func(*Env) error
// I18NCatalog 是应用层使用的多语言文本目录接口。
type I18NCatalog interface {
LoadDirWithConfig(context.Context, string, string, string) error
HasLanguage(string) bool
Configure(string, string)
Clear()
Translate(string, string, map[string]string) (string, bool)
}
// ServiceManifest 描述服务入口需要的环境配置器和组件构造器。
type ServiceManifest struct {
Configure []EnvConfigurer
Components Builder
}
const defPreflight = 5 * time.Second
// Env 保存服务装配阶段共享的运行时依赖。
type Env struct {
Config config.ServiceConfig
ConfigPath string
Logger *slog.Logger
Bus bus.Bus
Cache cachekit.Store
Lock lockkit.Manager
Presence presence.Runtime
Cluster *cluster.Coordinator
Registry registry.Registry
Health *health.Service
Metrics *metrics.Registry
RateLimiter *ratelimit.Limiter
Discovery *discovery.Resolver
RPC *rpckit.Endpoint
Topology *topology.Manager
Scheduler *scheduler.Scheduler
Tracer *tracing.Tracer
PVF *pvf.Archive
DataTables *datatable.Registry
DataTableViews *datatable.ViewManager
I18N I18NCatalog
Audit *audit.Logger
LogicLog *logiclog.Logger
BILog *bilog.Logger
EventLog *eventlog.Log
AdminCommands *admincmdqueue.Executor
AccountCenter *accountcenter.Center
OnlinePush *onlinepush.Service
Reload *reload.Manager
HotUpdate *hotupdate.Manager
StateSync statesync.Store
StorageObjects *storageobject.Service
ServerGroup *servergroup.Manager
ServerGroupStore servergroup.Store
ServerGroupArchives servergroup.MergeArchiveStore
Workers *workerpool.Pool
AdminMux *http.ServeMux
AdminTimeouts httpx.Timeouts
AdminAuth func(string, http.HandlerFunc) http.HandlerFunc
AdminDangerous func(string, string, http.HandlerFunc) http.HandlerFunc
ConfigProvider func() config.ServiceConfig
Components []Component
}
// Application 管理服务配置、平台依赖和组件生命周期。
type Application struct {
cfgMu sync.RWMutex
cfg config.ServiceConfig
configPath string
logger *slog.Logger
bus bus.Bus
cache cachekit.Store
lockManager lockkit.Manager
presence presence.Runtime
registry registry.Registry
cluster *cluster.Coordinator
health *health.Service
metrics *metrics.Registry
rateLimiter *ratelimit.Limiter
discovery *discovery.Resolver
rpc *rpckit.Endpoint
topology *topology.Manager
scheduler *scheduler.Scheduler
tracer *tracing.Tracer
pvfArchive *pvf.Archive
dataTables *datatable.Registry
dataTableViews *datatable.ViewManager
i18nCatalog I18NCatalog
audit *audit.Logger
logicLog *logiclog.Logger
biLog *bilog.Logger
eventLog *eventlog.Log
adminCommands *admincmdqueue.Executor
accountCenter *accountcenter.Center
onlinePush *onlinepush.Service
reloader *reload.Manager
hotUpdate *hotupdate.Manager
stateSync statesync.Store
storageObjects *storageobject.Service
serverGroup *servergroup.Manager
serverGroupStore servergroup.Store
serverGroupArchives servergroup.MergeArchiveStore
otelShutdown otelx.Shutdown
workers *workerpool.Pool
admin *httpx.Server
components []Component
constructed []Component
watchdogStop func()
}