perf,feat(events),ops: third optimization pass

Overview cold start:
- InstanceResourceService persists every live fetch into status_snapshots
  (category 'resources') and serves a snapshot younger than 2 minutes
  before calling upstream, so the first fleet overview after a restart
  costs zero device requests; instance deletion cleans up via FK cascade

Bounded retention for the four fastest-growing tables (15-minute sweep):
- connection_logs: one row per probe per beat had no automatic cleanup
- audit_events: new pruneBefore (90d)
- operation_preparations: every prepare/retry attempt inserted a row,
  terminal rows now expire after 7 days
- notification_queue: terminal rows pruned after 30 days

SSE events now cover instance lifecycle:
- create/update emit instance envelopes from the routes; the two-phase
  delete emits a job envelope on every terminal transition plus an
  instance envelope when the node actually disappears, so fleet and
  instance views invalidate in real time

Linux ops:
- 'install.sh unit' writes systemd user units for API and gateway with
  Restart=on-failure, 0600 secret injection, and
  MULTI_SIMADMIN_SYSTEMD_UNIT wired so console self-update restarts via
  systemctl -- closing the self-update loop on Linux
This commit is contained in:
chick
2026-09-07 02:43:11 +08:00
parent c1be714ba4
commit 70e598208a
11 changed files with 362 additions and 6 deletions
+10
View File
@@ -45,6 +45,16 @@ sh /tmp/multi-simadmin-install.sh uninstall
`uninstall` 默认只移除程序源码,保留数据库、Gateway token 和日志。默认安装位置 macOS 为 `~/Library/Application Support/multi-simadmin`Linux 为 `~/.local/share/multi-simadmin`(遵循 XDG),均可通过 `MULTI_SIMADMIN_HOME` 覆盖;额外 LAN Host 可通过 `MULTI_SIMADMIN_ALLOWED_HOSTS`(逗号分隔)配置。
#### Linux systemd(可选,但推荐)
`sh install.sh unit` 会生成 `~/.config/systemd/user/` 下的 API 与 Gateway 用户单元(`Restart=on-failure`、开机自启、密钥经 0600 的 `service-env` 注入)。随后:
```bash
systemctl --user enable --now multi-simadmin-api.service multi-simadmin-gateway.service
```
单元内声明了 `MULTI_SIMADMIN_SYSTEMD_UNIT`,因此 Web 控制台的“在线更新”在 Linux 上会把重启交给 systemd 完成,形成自更新闭环。由 systemd 托管后请用 `systemctl` 管理进程,不要再用本脚本 `start/stop`,避免双进程争抢端口。
### Fleet 节点总览与跨域健康
`/fleet` 读取本机 Control Plane 中的节点状态、资源快照与设备号码,形成节点总览。侧栏在实例总数 / 在线 / 需处理之外,还聚合跨域健康指标:
@@ -139,6 +139,17 @@ function parameterSummary(value: unknown): readonly RedactedParameterSummaryItem
export class AuditQueryService {
constructor(private readonly db: Database.Database) {}
/** Retention hook: drops audit records older than the cutoff. */
pruneBefore(cutoffIso: string): number {
if (typeof cutoffIso !== 'string' || !Number.isFinite(Date.parse(cutoffIso)))
throw new RangeError('cutoffIso must be a valid date-time');
return Number(
this.db
.prepare('DELETE FROM audit_events WHERE created_at < ?')
.run(new Date(cutoffIso).toISOString()).changes,
);
}
summary(limit = 5): ControlPlaneAuditSummary {
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 20) validation('limit is invalid');
const totalRow = this.db.prepare('SELECT COUNT(*) AS total FROM audit_events').get() as
@@ -1248,6 +1248,20 @@ export class CentralNotificationService {
if (result.changes !== 1) throw new CentralNotificationServiceError('QUEUE_NOT_FOUND');
}
/** Retention for terminal queue rows; pending/sending backlog is never touched. */
pruneFinishedQueue(olderThanMs: number): number {
if (!Number.isSafeInteger(olderThanMs) || olderThanMs < 0)
throw new RangeError('olderThanMs must be a non-negative integer');
const cutoff = new Date(Date.parse(this.#now()) - olderThanMs).toISOString();
return Number(
this.#db
.prepare(
"DELETE FROM notification_queue WHERE status IN ('succeeded','failed','cancelled') AND updated_at <= ?",
)
.run(cutoff).changes,
);
}
deleteQueueItem(id: string): void {
if (this.#db.prepare('DELETE FROM notification_queue WHERE id=?').run(id).changes !== 1)
throw new CentralNotificationServiceError('QUEUE_NOT_FOUND');
@@ -1,4 +1,5 @@
import type Database from 'better-sqlite3';
import type { EventEnvelope } from '../events/event-journal.js';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import type { Job, Preparation, PrepareOperationRequest } from '@multi-simadmin/contracts';
import { InstanceService, InstanceServiceError } from '../instances/instance-service.js';
@@ -75,6 +76,8 @@ interface Options {
readonly now?: () => Date;
readonly idFactory?: () => string;
readonly tokenFactory?: () => string;
/** Receives job/instance envelopes so the SSE journal can invalidate fleet views. */
readonly emit?: (envelope: EventEnvelope) => void;
}
const digest = (value: string): string => createHash('sha256').update(value, 'utf8').digest('hex');
@@ -93,6 +96,7 @@ export class DeleteInstanceOperation {
private readonly clock: () => Date;
private readonly id: () => string;
private readonly token: () => string;
private readonly emit: Options['emit'];
constructor(options: Options) {
this.db = options.db;
@@ -101,6 +105,7 @@ export class DeleteInstanceOperation {
this.clock = options.now ?? (() => new Date());
this.id = options.idFactory ?? randomUUID;
this.token = options.tokenFactory ?? (() => randomBytes(32).toString('base64url'));
this.emit = options.emit;
}
async prepare(input: PrepareOperationRequest, requestId = this.id()): Promise<Preparation> {
@@ -318,10 +323,19 @@ export class DeleteInstanceOperation {
const release = await this.releaseBindingPort(input.instanceId, input.requestId);
if (release?.status === 'failed') {
this.closeAsFailed(ids, input.requestId, BINDING_REFUSAL_CODE);
this.#emitJob(ids.job, input.requestId);
return this.job(ids.job);
}
try {
await this.instances.delete(input.instanceId, input.revision, { allowJobHistory: true });
this.#emit({
kind: 'instance',
id: this.id(),
occurredAt: this.clock().toISOString(),
requestId: input.requestId,
instanceId: input.instanceId,
});
this.#emitJob(ids.job, input.requestId);
const finished = this.clock().toISOString();
this.db.transaction(() => {
this.db
@@ -379,6 +393,25 @@ export class DeleteInstanceOperation {
}
/** Closes the job without touching the instance row, so the device stays listed and retryable. */
#emit(envelope: EventEnvelope): void {
if (!this.emit) return;
try {
this.emit(envelope);
} catch {
// A journal failure must never fail the operation itself.
}
}
#emitJob(jobId: string, requestId: string): void {
this.#emit({
kind: 'job',
id: this.id(),
occurredAt: this.clock().toISOString(),
requestId,
jobId,
});
}
private closeAsFailed(
ids: { job: string; item: string; attempt: string },
requestId: string,
@@ -449,6 +449,24 @@ export class SecureOperationExecution {
return this.job(ids.job);
}
/**
* Retention for consumed/expired/invalidated preparations. Every prepare
* (manual or a scheduled retry attempt) inserts a row, so without this the
* table grows one row per attempt forever.
*/
pruneTerminalPreparations(olderThanMs: number): number {
if (!Number.isSafeInteger(olderThanMs) || olderThanMs < 0)
throw new RangeError('olderThanMs must be a non-negative integer');
const cutoff = new Date(this.clock().getTime() - olderThanMs).toISOString();
return Number(
this.options.db
.prepare(
"DELETE FROM operation_preparations WHERE status IN ('consumed','expired','invalidated') AND updated_at <= ?",
)
.run(cutoff).changes,
);
}
reconcileInterruptedJobs(): number {
const now = this.clock().toISOString();
return this.options.db.transaction(() => {
@@ -1,4 +1,6 @@
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import {
InstanceResourceService,
@@ -155,6 +157,52 @@ describe('instance resource allowlist parsing', () => {
).toEqual({ simPresent: true, phoneNumbers: ['+8613800000000'] });
});
describe('persisted resource snapshots', () => {
it('serves the first overview after a restart from status_snapshots with zero upstream calls', async () => {
const db = new Database(':memory:');
migrateDatabase(db);
// status_snapshots carries an FK to instances; seed the node it belongs to.
db.prepare(
"INSERT INTO instances (id,name,base_url,created_at,updated_at) VALUES ('device-1','device-1','http://192.168.3.55:3000','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z')",
).run();
let clock = 1_000;
let livePasses = 0;
const build = () => {
const requests: Array<{ url: string }> = [];
const service = new InstanceResourceService({
instances: { get: async () => ({ origin: 'http://192.168.3.55:3000' }) } as never,
sessions: { sessionFor: () => undefined } as never,
request: async (request: { url: string }) => {
requests.push(request);
return request.url.endsWith('/api/device')
? response({ data: { online: true } })
: { status: 503, headers: {}, body: '' };
},
db,
now: () => clock,
} as never);
return { service, requests };
};
const first = build();
await first.service.get('device-1');
livePasses = first.requests.length;
expect(livePasses).toBe(6);
// A brand-new service (process restart) reads the persisted snapshot.
const second = build();
const resources = await second.service.get('device-1');
expect(second.requests).toHaveLength(0);
expect(resources).toEqual({ controlOnline: true });
// Past the freshness window the probe goes live again and repersists.
clock += 121_000;
await second.service.get('device-1');
expect(second.requests.length).toBe(livePasses);
db.close();
});
});
describe('overview cache', () => {
const minimal = (cacheTtlMs?: number) => {
const requests: Array<{ url: string }> = [];
@@ -1,4 +1,6 @@
import { lookupOperator } from '@multi-simadmin/contracts';
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import type { InstanceService } from '../instances/instance-service.js';
import type {
@@ -164,6 +166,8 @@ export function parseSignalStrength(response: UpstreamResponse): InstanceResourc
}
const DEFAULT_CACHE_TTL_MS = 30_000;
const DEFAULT_SNAPSHOT_FRESH_MS = 120_000;
const RESOURCE_SNAPSHOT_CATEGORY = 'resources';
export class InstanceResourceService {
readonly #cache = new Map<
@@ -184,6 +188,14 @@ export class InstanceResourceService {
) => Promise<void>;
/** Fleet overview fan-out reads through this cache; 0 disables it. */
readonly cacheTtlMs?: number;
/**
* When set, every live fetch is persisted into status_snapshots
* (category 'resources') and a snapshot younger than snapshotFreshMs is
* served before any upstream call, so the first overview after a restart
* costs zero device requests.
*/
readonly db?: Database.Database;
readonly snapshotFreshMs?: number;
readonly now?: () => number;
},
) {}
@@ -203,11 +215,18 @@ export class InstanceResourceService {
if (ttl > 0 && !force) {
const cached = this.#cache.get(instanceId);
if (cached && now - cached.fetchedAt < ttl) return cached.resources;
const persisted = this.#readSnapshot(instanceId, now);
if (persisted) {
this.#cache.set(instanceId, { resources: persisted, fetchedAt: now });
return persisted;
}
const inflight = this.#inflight.get(instanceId);
if (inflight) return inflight;
const pending = this.#fetch(instanceId)
.then((resources) => {
this.#cache.set(instanceId, { resources, fetchedAt: this.options.now?.() ?? now });
const fetchedAt = this.options.now?.() ?? now;
this.#cache.set(instanceId, { resources, fetchedAt });
this.#writeSnapshot(instanceId, resources, fetchedAt);
return resources;
})
.finally(() => {
@@ -217,10 +236,64 @@ export class InstanceResourceService {
return pending;
}
const resources = await this.#fetch(instanceId);
if (ttl > 0) this.#cache.set(instanceId, { resources, fetchedAt: now });
if (ttl > 0) {
this.#cache.set(instanceId, { resources, fetchedAt: now });
this.#writeSnapshot(instanceId, resources, now);
}
return resources;
}
#readSnapshot(instanceId: string, now: number): InstanceResources | undefined {
const db = this.options.db;
if (!db) return undefined;
const freshMs = this.options.snapshotFreshMs ?? DEFAULT_SNAPSHOT_FRESH_MS;
try {
const row = db
.prepare(
'SELECT payload_json, observed_at FROM status_snapshots WHERE instance_id=? AND category=?',
)
.get(instanceId, RESOURCE_SNAPSHOT_CATEGORY) as
| { payload_json: string; observed_at: string }
| undefined;
if (!row) return undefined;
const age = now - Date.parse(row.observed_at);
if (!Number.isFinite(age) || age < 0 || age >= freshMs) return undefined;
const payload: unknown = JSON.parse(row.payload_json);
if (typeof payload !== 'object' || payload === null || Array.isArray(payload))
return undefined;
return payload as InstanceResources;
} catch {
return undefined;
}
}
#writeSnapshot(instanceId: string, resources: InstanceResources, observedAt: number): void {
const db = this.options.db;
if (!db) return;
const freshMs = this.options.snapshotFreshMs ?? DEFAULT_SNAPSHOT_FRESH_MS;
const observed = new Date(observedAt).toISOString();
const expiresAt = new Date(observedAt + freshMs).toISOString();
try {
db.prepare(
`INSERT INTO status_snapshots (id, instance_id, category, state, payload_json, observed_at, expires_at, created_at)
VALUES (?, ?, ?, 'fresh', ?, ?, ?, ?)
ON CONFLICT(instance_id, category) DO UPDATE SET
id=excluded.id, state=excluded.state, payload_json=excluded.payload_json,
observed_at=excluded.observed_at, expires_at=excluded.expires_at, created_at=excluded.created_at`,
).run(
randomUUID(),
instanceId,
RESOURCE_SNAPSHOT_CATEGORY,
JSON.stringify(resources),
observed,
expiresAt,
observed,
);
} catch {
// Persistence is an optimization; a failed write must not fail the read.
}
}
async #fetch(instanceId: string): Promise<InstanceResources> {
const [instance, session] = await Promise.all([
this.options.instances.get(instanceId),
+34 -2
View File
@@ -167,6 +167,12 @@ function createHubDeviceReader(
};
}
// Retention windows for operator-facing history. Preparations are short-lived
// protocol state and cycle faster; audit records are the longest-lived.
const RETENTION_HISTORY_MS = 30 * 24 * 60 * 60 * 1000;
const RETENTION_AUDIT_MS = 90 * 24 * 60 * 60 * 1000;
const RETENTION_PREPARATION_MS = 7 * 24 * 60 * 60 * 1000;
export interface ControlPlaneApp extends FastifyInstance {
retryPendingSecretCleanup(): Promise<unknown>;
}
@@ -245,6 +251,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
sessions,
request: options.upstream.request,
ensureSession,
db: options.db,
});
const messages = new InstanceMessageService({
instances,
@@ -347,8 +354,14 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
instances,
releaseBinding,
now: options.now,
emit: (envelope) => eventJournal.append(envelope),
})
: new DeleteInstanceOperation({ db: options.db, instances, releaseBinding });
: new DeleteInstanceOperation({
db: options.db,
instances,
releaseBinding,
emit: (envelope) => eventJournal.append(envelope),
});
deletion.reconcileInterruptedJobs();
const listHubDevices = createHubDeviceReader(options.db, instances);
const resolveOperationCookie = async (
@@ -511,6 +524,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
resources,
messages,
registerDeletionPreparationRoute: false,
onEvent: (envelope) => eventJournal.append(envelope),
});
registerFleetRoutes(app, {
instances,
@@ -585,11 +599,29 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
.catch(() => undefined);
// Delivered and abandoned outbox rows otherwise accumulate forever.
void Promise.resolve()
.then(() => smsOutbox.prune(30 * 24 * 60 * 60 * 1000))
.then(() => smsOutbox.prune(RETENTION_HISTORY_MS))
.catch(() => undefined);
void Promise.resolve()
.then(() => eventJournal.prune())
.catch(() => undefined);
// Connection probes write a log row per beat per node; without a sweep
// that alone is thousands of rows a day.
void Promise.resolve()
.then(() =>
connectionLogs.prune({
before: new Date(Date.now() - RETENTION_HISTORY_MS).toISOString(),
}),
)
.catch(() => undefined);
void Promise.resolve()
.then(() => audit.pruneBefore(new Date(Date.now() - RETENTION_AUDIT_MS).toISOString()))
.catch(() => undefined);
void Promise.resolve()
.then(() => secureExecution.pruneTerminalPreparations(RETENTION_PREPARATION_MS))
.catch(() => undefined);
void Promise.resolve()
.then(() => centralNotifications.pruneFinishedQueue(RETENTION_HISTORY_MS))
.catch(() => undefined);
}, logPruneIntervalMs)
: undefined;
// Scheduled component backups: the tick is cheap and only writes when a period is overdue.
@@ -1,5 +1,6 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { EventEnvelope } from '../../application/events/event-journal.js';
import { buildApp } from '../../app.js';
import {
InstanceSessionStore,
@@ -78,13 +79,36 @@ const fixture = () => {
idFactory: () => `operation-${++id}`,
tokenFactory: () => Buffer.alloc(32, 11).toString('base64url'),
});
const events: Array<Record<string, unknown>> = [];
const onEvent = (envelope: EventEnvelope): void => {
events.push(envelope as unknown as Record<string, unknown>);
};
const app = buildApp({
registerRoutes: (scope) =>
registerInstanceRoutes(scope, { instances, connections, login, deletion }),
registerInstanceRoutes(scope, { instances, connections, login, deletion, onEvent }),
});
return { app, instances };
return { app, instances, events };
};
describe('instance route SSE events', () => {
it('emits an instance envelope when a node is created', async () => {
const { app, events } = fixture();
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name: 'Alpha',
origin: 'http://192.168.1.10:8080',
password: { action: 'set', password: 'never-return-this' },
},
});
expect(created.statusCode).toBe(201);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ kind: 'instance', requestId: expect.any(String) });
expect(String(events[0]?.instanceId)).toBeTruthy();
});
});
describe('instance HTTP routes', () => {
it('creates, lists, gets, updates and deletes redacted instance resources', async () => {
const { app } = fixture();
@@ -20,6 +20,8 @@ import {
DeleteInstanceOperationError,
} from '../../application/operations/delete-instance-operation.js';
import type { InstanceResourceService } from '../../application/resources/instance-resource-service.js';
import { randomUUID } from 'node:crypto';
import type { EventEnvelope } from '../../application/events/event-journal.js';
import {
MessageServiceError,
type InstanceMessageService,
@@ -33,6 +35,8 @@ export interface InstanceRoutesOptions {
readonly resources?: InstanceResourceService;
readonly messages?: InstanceMessageService;
readonly registerDeletionPreparationRoute?: boolean;
/** Receives instance lifecycle envelopes so the SSE journal can invalidate fleet views. */
readonly onEvent?: (envelope: EventEnvelope) => void;
}
const problem = (
request: FastifyRequest,
@@ -56,6 +60,25 @@ const problem = (
detail,
requestId: request.id,
});
const emitInstanceEvent = (
options: { readonly onEvent?: (envelope: EventEnvelope) => void },
instanceId: string,
requestId: string,
): void => {
if (!options.onEvent) return;
try {
options.onEvent({
kind: 'instance',
id: randomUUID(),
occurredAt: new Date().toISOString(),
requestId,
instanceId,
});
} catch {
// A journal failure must never fail the mutation itself.
}
};
const domainProblem = (request: FastifyRequest, error: InstanceServiceError) => {
const status = instanceErrorStatus[error.code];
return problem(
@@ -344,6 +367,7 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
async (request, reply) => {
try {
const created = await options.instances.create(bodyInput(request.body));
emitInstanceEvent(options, created.id, request.id);
return reply.header('ETag', etag(created)).code(201).send(created);
} catch (error) {
if (error instanceof InstanceServiceError)
@@ -434,6 +458,7 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
revision(request),
bodyPatch(request.body),
);
emitInstanceEvent(options, (request.params as { instanceId: string }).instanceId, request.id);
reply.header('ETag', etag(result));
return result;
}),
+68
View File
@@ -36,6 +36,7 @@ Multi SimAdmin 一键安装与服务管理
restart 停止后重新启动
status 显示进程、端口与健康状态
uninstall 卸载程序,默认保留数据、令牌和日志
unit (仅 Linux)生成 systemd 用户单元,支持开机自启与在线自更新重启
环境变量:
MULTI_SIMADMIN_HOME 安装根目录(默认 macOS 为 ~/Library/Application Support/multi-simadminLinux 为 ~/.local/share/multi-simadmin
MULTI_SIMADMIN_ALLOWED_HOSTS 额外允许的 LAN 主机名/IP,逗号分隔
@@ -297,6 +298,72 @@ uninstall_app() {
say "程序已卸载;默认保留数据、Gateway token 和日志:$APP_ROOT"
}
write_systemd_units() {
require_platform
[ "$PLATFORM" = Linux ] || die "systemd 单元仅适用于 Linux"
command -v systemctl >/dev/null 2>&1 || die "缺少 systemctl"
[ -f "$SOURCE_DIR/apps/web/dist/index.html" ] || die "尚未安装,请先运行 install"
[ -f "$TOKEN_FILE" ] || die "缺少 Gateway token,请先运行 install"
node_bin=$(command -v node)
unit_dir=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user
mkdir -p "$unit_dir"
chmod 700 "$unit_dir"
env_file="$APP_ROOT/service-env"
{ printf 'MULTI_SIMADMIN_GATEWAY_TOKEN='; cat "$TOKEN_FILE"; } >"$env_file"
chmod 600 "$env_file"
cat >"$unit_dir/multi-simadmin-api.service" <<EOF
[Unit]
Description=Multi SimAdmin control-plane API
After=network.target
[Service]
WorkingDirectory=$SOURCE_DIR
ExecStart=$node_bin --import tsx $SOURCE_DIR/apps/api/src/production-cli.ts
Restart=on-failure
RestartSec=3
Environment=MULTI_SIMADMIN_DATA_ROOT=$DATA_ROOT
Environment=MULTI_SIMADMIN_DATABASE_PATH=$DATA_ROOT/control-plane.sqlite3
Environment=MULTI_SIMADMIN_SECRET_BACKEND=secret-file
Environment=MULTI_SIMADMIN_SYSTEMD_UNIT=multi-simadmin-api.service
EnvironmentFile=$env_file
UMask=0077
[Install]
WantedBy=default.target
EOF
cat >"$unit_dir/multi-simadmin-gateway.service" <<EOF
[Unit]
Description=Multi SimAdmin LAN gateway
After=network.target multi-simadmin-api.service
[Service]
WorkingDirectory=$SOURCE_DIR
ExecStart=$node_bin --import tsx $SOURCE_DIR/apps/api/src/production-gateway-cli.ts
Restart=on-failure
RestartSec=3
Environment=MULTI_SIMADMIN_CUTOVER_ACK=I ACKNOWLEDGE MULTI-SIMADMIN OWNS PORT 8788
Environment=MULTI_SIMADMIN_GATEWAY_HOST=0.0.0.0
Environment=MULTI_SIMADMIN_GATEWAY_ALLOWED_HOSTS=$(allowed_hosts)
Environment=CANARY_DIST_DIR=$SOURCE_DIR/apps/web/dist
Environment=CANARY_UPSTREAM_PORT=8790
Environment=MULTI_SIMADMIN_SYSTEMD_UNIT=multi-simadmin-gateway.service
EnvironmentFile=$env_file
UMask=0077
[Install]
WantedBy=default.target
EOF
chmod 600 "$unit_dir/multi-simadmin-api.service" "$unit_dir/multi-simadmin-gateway.service"
systemctl --user daemon-reload
say "已写入 systemd 用户单元。启用自启动并启动:"
say " systemctl --user enable --now multi-simadmin-api.service multi-simadmin-gateway.service"
say "注意:由 systemd 托管后请用 systemctl 管理,不要再用本脚本 start/stop,避免双进程争抢端口。"
}
command=${1:-install}
case "$command" in
install) install_app ;;
@@ -305,6 +372,7 @@ case "$command" in
restart) stop_service; start_service ;;
status) status_service ;;
uninstall) uninstall_app ;;
unit) write_systemd_units ;;
help|-h|--help) usage ;;
*) usage >&2; exit 2 ;;
esac