feat(contracts,automation): add multi-group scopes, SMS macros and operator registry

- Allow scheduled operations to resolve multiple device groups.

- Add reusable SMS time and random-value macros.

- Add China operator PLMN metadata shared by API and UI.
This commit is contained in:
chick
2026-09-07 00:44:33 +08:00
parent 8b7df8cb65
commit 8406f14469
9 changed files with 276 additions and 3 deletions
@@ -6,6 +6,73 @@ import { ScheduledTaskRepository } from './scheduled-task-repository.js';
import { ScheduledOperationDispatcher } from './scheduled-operation-dispatcher.js';
describe('ScheduledOperationDispatcher', () => {
it('renders SMS time and random macros separately for each target', async () => {
const db = new Database(':memory:');
migrateDatabase(db);
const send = vi.fn(async (_instanceId: string, input: { content: string }) => ({
sent: true as const,
}));
const dispatcher = new ScheduledOperationDispatcher({
db,
operations: { prepare: vi.fn(), execute: vi.fn() },
messages: { send },
store: {
set: vi.fn(),
get: vi.fn(async () =>
JSON.stringify({
recipients: ['13800138000'],
content: 'Status ${time} id ${random}',
}),
),
delete: vi.fn(),
},
repository: new ScheduledTaskRepository(db),
now: () => new Date('2026-09-06T04:05:06.000Z'),
});
for (const [id, name, address] of [
['a', 'Alpha', 'http://10.0.0.1'],
['b', 'Beta', 'http://10.0.0.2'],
] as const) {
db.prepare(
'INSERT INTO instances (id,name,base_url,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
).run(id, name, address, 1, 1, '2026-07-30T00:00:00.000Z', '2026-07-30T00:00:00.000Z');
}
const task = new ScheduledTaskRepository(db).create({
id: 'task-template',
task: {
name: 'Status',
operationType: 'send-sms',
cronExpression: '0 9 * * *',
timezone: 'Asia/Shanghai',
targetSelector: { mode: 'fixed', instanceIds: ['a', 'b'] },
sms: { recipients: ['13800138000'], content: 'Status ${time} id ${random}' },
misfirePolicy: 'skip',
overlapPolicy: 'skip',
retryPolicy: { maxRetries: 0, intervalSeconds: 60 },
enabled: true,
},
smsSecretReference: 'memory://sms',
createdBy: 'operator',
now: '2026-09-06T04:05:06.000Z',
});
const result = await dispatcher.dispatch(
task,
[
{ id: 'a', revision: 1 },
{ id: 'b', revision: 1 },
],
{ actor: 'operator', requestId: 'request-1' },
);
expect(result.outcome).toBe('succeeded');
expect(send).toHaveBeenCalledTimes(2);
for (const call of send.mock.calls) {
expect(call[1]?.content).toMatch(/^Status 2026-09-06 12:05:06 id [A-Za-z0-9]{12}$/u);
}
db.close();
});
it('executes restart targets independently and aggregates partial success', async () => {
const db = new Database(':memory:');
migrateDatabase(db);
@@ -69,6 +136,17 @@ describe('ScheduledOperationDispatcher', () => {
'2026-07-30T00:00:00.000Z',
'2026-07-30T00:00:00.000Z',
);
db.prepare(
'INSERT INTO instances (id,name,base_url,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
).run(
'b',
'Beta',
'http://10.0.0.2',
1,
1,
'2026-07-30T00:00:00.000Z',
'2026-07-30T00:00:00.000Z',
);
const repository = new ScheduledTaskRepository(db);
const task = repository.create({
id: 'task-sms',
@@ -1,4 +1,4 @@
import { createHash, randomUUID } from 'node:crypto';
import { createHash, randomInt, randomUUID } from 'node:crypto';
import type { Job, PrepareOperationRequest, ScheduledTask } from '@multi-simadmin/contracts';
import type { InstanceMessageService } from '../messages/instance-message-service.js';
@@ -56,6 +56,35 @@ const operationNames: Record<string, string> = {
'restart-baseband': 'postBasebandRestart',
};
const SMS_RANDOM_LENGTH = 12;
const SMS_RANDOM_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
/**
* Hub-compatible SMS macros are filled in just before delivery. Rendering per target keeps
* `${time}` meaningful when a large batch spans seconds and gives every message its own nonce.
*/
export function renderSmsTemplate(
content: string,
now: Date,
random: () => string = () =>
Array.from({ length: SMS_RANDOM_LENGTH }, () =>
SMS_RANDOM_ALPHABET.charAt(randomInt(SMS_RANDOM_ALPHABET.length)),
).join(''),
): string {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const timestamp = formatter.format(now).replace(', ', ' ');
return content.replaceAll('${time}', timestamp).replaceAll('${random}', random());
}
function aggregate(successes: number, failures: number): DispatchResult['outcome'] {
if (successes > 0 && failures > 0) return 'partially-succeeded';
return failures > 0 ? 'failed' : 'succeeded';
@@ -159,7 +188,12 @@ export class ScheduledOperationDispatcher {
let successes = 0;
let failures = 0;
for (const target of targets) {
const result = await this.recordSmsJob(target.id, payload, task.retryPolicy, context);
const result = await this.recordSmsJob(
target.id,
{ ...payload, content: renderSmsTemplate(payload.content, this.now()) },
task.retryPolicy,
context,
);
jobIds.push(result.id);
if (result.succeeded) successes += 1;
else failures += 1;
@@ -64,7 +64,7 @@ describe('resolveTargets', () => {
db.close();
});
it('resolves the Hub all-devices and single-group selectors', () => {
it('resolves the Hub all-devices and group selectors', () => {
const db = fixture();
const now = '2026-07-30T00:00:00.000Z';
db.prepare(
@@ -78,6 +78,9 @@ describe('resolveTargets', () => {
expect(resolveTargets(db, { mode: 'group', groupId: 'g-lab' })).toEqual([
{ id: 'a', revision: 3 },
]);
expect(resolveTargets(db, { mode: 'groups', groupIds: ['g-lab', 'g-office'] })).toEqual([
{ id: 'a', revision: 3 },
]);
db.close();
});
@@ -64,6 +64,18 @@ export function resolveTargets(
return rows.map((row) => ({ id: row.id, revision: row.config_revision }));
}
if (selector.mode === 'groups') {
const placeholders = selector.groupIds.map(() => '?').join(',');
const rows = db
.prepare(
`SELECT id, config_revision FROM instances
WHERE enabled = 1 AND group_id IN (${placeholders})
ORDER BY id ASC`,
)
.all(...selector.groupIds) as TargetRow[];
return rows.map((row) => ({ id: row.id, revision: row.config_revision }));
}
const placeholders = selector.tags.map(() => '?').join(',');
const comparison = selector.match === 'all' ? '= ?' : '> 0';
const parameters: Array<string | number> = [...selector.tags];