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:
@@ -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];
|
||||
|
||||
@@ -46,6 +46,15 @@ describe('automation contracts', () => {
|
||||
).toThrow(/instance/i);
|
||||
});
|
||||
|
||||
it('parses Hub-compatible multi-group automation scopes', () => {
|
||||
expect(
|
||||
parseCreateScheduledTaskRequest({
|
||||
...restartRequest,
|
||||
targetSelector: { mode: 'groups', groupIds: ['field', 'office'] },
|
||||
}).targetSelector,
|
||||
).toEqual({ mode: 'groups', groupIds: ['field', 'office'] });
|
||||
});
|
||||
|
||||
it('requires bounded recipients and content only for SMS schedules', () => {
|
||||
expect(
|
||||
parseCreateScheduledTaskRequest({
|
||||
|
||||
@@ -34,6 +34,7 @@ export type ScheduleTargetSelector =
|
||||
| { readonly mode: 'fixed'; readonly instanceIds: readonly string[] }
|
||||
| { readonly mode: 'tags'; readonly match: 'any' | 'all'; readonly tags: readonly string[] }
|
||||
| { readonly mode: 'group'; readonly groupId: string }
|
||||
| { readonly mode: 'groups'; readonly groupIds: readonly string[] }
|
||||
| { readonly mode: 'all' };
|
||||
|
||||
/**
|
||||
@@ -244,6 +245,10 @@ function targetSelector(value: unknown): ScheduleTargetSelector {
|
||||
exactKeys(source, new Set(['mode', 'groupId']));
|
||||
return { mode: 'group', groupId: string(source.groupId, 'groupId', 120) };
|
||||
}
|
||||
if (source.mode === 'groups') {
|
||||
exactKeys(source, new Set(['mode', 'groupIds']));
|
||||
return { mode: 'groups', groupIds: strings(source.groupIds, 'groupIds', 200) };
|
||||
}
|
||||
if (source.mode === 'all') {
|
||||
exactKeys(source, new Set(['mode']));
|
||||
return { mode: 'all' };
|
||||
|
||||
@@ -6,4 +6,5 @@ export * from './jobs.js';
|
||||
export * from './operations.js';
|
||||
export * from './organization.js';
|
||||
export * from './notifications.js';
|
||||
export * from './operators.js';
|
||||
export const contractsWorkspaceReady = true;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { lookupOperator, operatorLabel } from './operators.js';
|
||||
|
||||
describe('lookupOperator', () => {
|
||||
it('resolves an assigned mainland PLMN', () => {
|
||||
expect(lookupOperator('460', '00')?.name).toBe('中国移动');
|
||||
expect(lookupOperator(460, 1)?.name).toBe('中国联通');
|
||||
expect(lookupOperator('460', '15')?.name).toBe('中国广电');
|
||||
expect(lookupOperator('460', '20')?.name).toBe('中国铁通');
|
||||
});
|
||||
|
||||
it('pads a single-digit MNC the way the network reports it', () => {
|
||||
expect(lookupOperator('460', '3')?.name).toBe('中国电信');
|
||||
});
|
||||
|
||||
it('leaves foreign and unassigned codes unresolved', () => {
|
||||
expect(lookupOperator('262', '01')).toBeNull();
|
||||
expect(lookupOperator('460', '77')).toBeNull();
|
||||
expect(lookupOperator(undefined, '01')).toBeNull();
|
||||
expect(lookupOperator('', '')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('operatorLabel', () => {
|
||||
it('prefers the registry over the string the device reported', () => {
|
||||
expect(operatorLabel({ mcc: '460', mnc: '00', operator: 'CHN-CMCC' })).toBe('中国移动');
|
||||
});
|
||||
|
||||
it('falls back to the reported name, then the bare PLMN, then the caller default', () => {
|
||||
expect(operatorLabel({ mcc: '262', mnc: '01', operator: 'Telekom' })).toBe('Telekom');
|
||||
expect(operatorLabel({ mcc: '262', mnc: '01' })).toBe('262-01');
|
||||
expect(operatorLabel({})).toBe('未知运营商');
|
||||
expect(operatorLabel({ operator: ' ' })).toBe('未知运营商');
|
||||
});
|
||||
|
||||
it('accepts a custom fallback for a table cell', () => {
|
||||
expect(operatorLabel({ fallback: '运营商未知' })).toBe('运营商未知');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Mainland-China PLMN registry.
|
||||
*
|
||||
* Devices report MCC and MNC as bare numbers and the operator string they read off the SIM,
|
||||
* which varies by firmware, language and roaming state. The Hub resolves the pair against this
|
||||
* table instead of trusting the string, so the console shows one stable brand name everywhere.
|
||||
*/
|
||||
export interface ChinaOperator {
|
||||
readonly mccMnc: string;
|
||||
readonly name: string;
|
||||
readonly nameEn: string;
|
||||
readonly technology: string;
|
||||
}
|
||||
|
||||
const MOBILE = '中国移动';
|
||||
const UNICOM = '中国联通';
|
||||
const TELECOM = '中国电信';
|
||||
|
||||
/** MNCs that belong to each brand, straight from the assigned mainland ranges. */
|
||||
const BRANDS: readonly (readonly [string, string, string, readonly string[]])[] = [
|
||||
[
|
||||
MOBILE,
|
||||
'China Mobile',
|
||||
'GSM 900 / GSM 1800 / TD-SCDMA 1880 / TD-SCDMA 2010 / TD-LTE 1800/2300/2600',
|
||||
['00', '02', '07', '08'],
|
||||
],
|
||||
[
|
||||
UNICOM,
|
||||
'China Unicom',
|
||||
'GSM 900 / GSM 1800 / UMTS 2100 / TD-LTE 2300/2600 / FDD-LTE 1800/2100',
|
||||
['01', '06', '09'],
|
||||
],
|
||||
[
|
||||
TELECOM,
|
||||
'China Telecom',
|
||||
'CDMA2000 800 / CDMA2000 2100 / TD-LTE 2300/2600 / FDD-LTE 1800/2100 / EV-DO / eHRPD',
|
||||
['03', '05', '11'],
|
||||
],
|
||||
[
|
||||
'中国广电',
|
||||
'China Broadnet',
|
||||
'LTE 1800 / LTE 900 / TD-LTE 1900 / TD-LTE 2300 / 5G 700 / 5G 2500',
|
||||
['15'],
|
||||
],
|
||||
['中国铁通', 'China Tietong', 'GSM-R', ['20']],
|
||||
];
|
||||
|
||||
function buildRegistry(): ReadonlyMap<string, ChinaOperator> {
|
||||
const entries = new Map<string, ChinaOperator>();
|
||||
for (const [name, nameEn, technology, mncs] of BRANDS) {
|
||||
for (const mnc of mncs)
|
||||
entries.set(`460${mnc}`, { mccMnc: `460${mnc}`, name, nameEn, technology });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
const REGISTRY = buildRegistry();
|
||||
|
||||
/** MCC and MNC arrive as numbers or strings of varying width; MNC is always two digits. */
|
||||
function digits(value: unknown, width: number): string {
|
||||
const raw = typeof value === 'number' ? String(value) : typeof value === 'string' ? value : '';
|
||||
const clean = raw.trim().replace(/\D/gu, '');
|
||||
return clean ? clean.padStart(width, '0') : '';
|
||||
}
|
||||
|
||||
export function lookupOperator(mcc: unknown, mnc: unknown): ChinaOperator | null {
|
||||
const country = digits(mcc, 3);
|
||||
const network = digits(mnc, 2);
|
||||
if (!country || !network) return null;
|
||||
return REGISTRY.get(`${country}${network}`) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best available operator name: the registry wins, then whatever the device reported, then the
|
||||
* bare PLMN. Never returns an empty string so a table cell always reads as an operator.
|
||||
*/
|
||||
export function operatorLabel(input: {
|
||||
readonly mcc?: unknown;
|
||||
readonly mnc?: unknown;
|
||||
readonly operator?: unknown;
|
||||
readonly fallback?: string | undefined;
|
||||
}): string {
|
||||
const known = lookupOperator(input.mcc, input.mnc);
|
||||
if (known) return known.name;
|
||||
const reported = typeof input.operator === 'string' ? input.operator.trim() : '';
|
||||
if (reported) return reported;
|
||||
const country = digits(input.mcc, 3);
|
||||
const network = digits(input.mnc, 2);
|
||||
if (country && network) return `${country}-${network}`;
|
||||
return input.fallback ?? '未知运营商';
|
||||
}
|
||||
Reference in New Issue
Block a user