From 8406f14469e93e5a4737b4062cc1df6901f9a827 Mon Sep 17 00:00:00 2001 From: chick Date: Mon, 7 Sep 2026 00:44:33 +0800 Subject: [PATCH] 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. --- .../scheduled-operation-dispatcher.test.ts | 78 ++++++++++++++++ .../scheduled-operation-dispatcher.ts | 38 +++++++- .../automation/target-resolver.test.ts | 5 +- .../application/automation/target-resolver.ts | 12 +++ packages/contracts/src/automation.test.ts | 9 ++ packages/contracts/src/automation.ts | 5 + packages/contracts/src/index.ts | 1 + packages/contracts/src/operators.test.ts | 40 ++++++++ packages/contracts/src/operators.ts | 91 +++++++++++++++++++ 9 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 packages/contracts/src/operators.test.ts create mode 100644 packages/contracts/src/operators.ts diff --git a/apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts b/apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts index c564e5f..260b94d 100644 --- a/apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts +++ b/apps/api/src/application/automation/scheduled-operation-dispatcher.test.ts @@ -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', diff --git a/apps/api/src/application/automation/scheduled-operation-dispatcher.ts b/apps/api/src/application/automation/scheduled-operation-dispatcher.ts index fe3566b..ca559f5 100644 --- a/apps/api/src/application/automation/scheduled-operation-dispatcher.ts +++ b/apps/api/src/application/automation/scheduled-operation-dispatcher.ts @@ -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 = { '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; diff --git a/apps/api/src/application/automation/target-resolver.test.ts b/apps/api/src/application/automation/target-resolver.test.ts index 339069b..889ee0e 100644 --- a/apps/api/src/application/automation/target-resolver.test.ts +++ b/apps/api/src/application/automation/target-resolver.test.ts @@ -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(); }); diff --git a/apps/api/src/application/automation/target-resolver.ts b/apps/api/src/application/automation/target-resolver.ts index 029bb18..eb62e54 100644 --- a/apps/api/src/application/automation/target-resolver.ts +++ b/apps/api/src/application/automation/target-resolver.ts @@ -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 = [...selector.tags]; diff --git a/packages/contracts/src/automation.test.ts b/packages/contracts/src/automation.test.ts index 954a277..4cc7e90 100644 --- a/packages/contracts/src/automation.test.ts +++ b/packages/contracts/src/automation.test.ts @@ -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({ diff --git a/packages/contracts/src/automation.ts b/packages/contracts/src/automation.ts index 15cbfe2..943ac17 100644 --- a/packages/contracts/src/automation.ts +++ b/packages/contracts/src/automation.ts @@ -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' }; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 1abb843..df73350 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -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; diff --git a/packages/contracts/src/operators.test.ts b/packages/contracts/src/operators.test.ts new file mode 100644 index 0000000..6d89bc0 --- /dev/null +++ b/packages/contracts/src/operators.test.ts @@ -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('运营商未知'); + }); +}); diff --git a/packages/contracts/src/operators.ts b/packages/contracts/src/operators.ts new file mode 100644 index 0000000..50465f5 --- /dev/null +++ b/packages/contracts/src/operators.ts @@ -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 { + const entries = new Map(); + 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 ?? '未知运营商'; +}