DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Package app 负责把配置、平台基础件、Admin 路由、热更新钩子和优雅停机流程装配成服务运行时。
|
||||
//
|
||||
// 这里是所有服务二进制的组合边界。业务规则应放在 service/runtime 包里,app
|
||||
// 只负责选择具体实现、校验运维参数,并按确定顺序关闭资源。
|
||||
package app
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(), "_")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user