From 0112e3320a5eff27343d06648a5cf74e7b560f74 Mon Sep 17 00:00:00 2001 From: chick Date: Sun, 19 Jul 2026 02:12:57 +0800 Subject: [PATCH] feat(sms): add real instance message workflow --- .../connections/upstream-session-client.ts | 2 + .../messages/instance-message-service.test.ts | 124 +++++++ .../messages/instance-message-service.ts | 191 ++++++++++ apps/api/src/control-plane.ts | 7 + .../transport/safe-instance-transport.ts | 1 - .../transport/safe-upstream-gateway.test.ts | 47 +++ .../transport/safe-upstream-gateway.ts | 38 +- .../api/src/interface/http/instance-routes.ts | 69 ++++ apps/web/src/app-shell.integration.test.tsx | 36 +- apps/web/src/app-shell.tsx | 6 +- apps/web/src/fleet/fleet-page.tsx | 12 - .../src/instances/instance-detail.test.tsx | 8 +- apps/web/src/instances/instance-detail.tsx | 19 +- .../src/instances/messages-api-data-source.ts | 59 +++ .../src/instances/messages-module.test.tsx | 241 +++---------- apps/web/src/instances/messages-module.tsx | 336 +++++++----------- apps/web/src/styles.css | 35 +- 17 files changed, 781 insertions(+), 450 deletions(-) create mode 100644 apps/api/src/application/messages/instance-message-service.test.ts create mode 100644 apps/api/src/application/messages/instance-message-service.ts create mode 100644 apps/web/src/instances/messages-api-data-source.ts diff --git a/apps/api/src/application/connections/upstream-session-client.ts b/apps/api/src/application/connections/upstream-session-client.ts index 396fdd1..ab517b0 100644 --- a/apps/api/src/application/connections/upstream-session-client.ts +++ b/apps/api/src/application/connections/upstream-session-client.ts @@ -6,6 +6,8 @@ export interface UpstreamRequest { readonly secret?: string; /** Redacted representation suitable for observability, never the password. */ readonly body?: '[REDACTED]'; + /** Explicit one-shot SMS action payload. Implementations must not log or persist it. */ + readonly sms?: { readonly phoneNumber: string; readonly content: string }; } export interface UpstreamResponse { readonly status: number; diff --git a/apps/api/src/application/messages/instance-message-service.test.ts b/apps/api/src/application/messages/instance-message-service.test.ts new file mode 100644 index 0000000..950d27b --- /dev/null +++ b/apps/api/src/application/messages/instance-message-service.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest'; +import { InstanceSessionStore } from '../connections/upstream-session-client.js'; +import { + InstanceMessageService, + MessageServiceError, + parseMessageList, +} from './instance-message-service.js'; + +const instance = { id: 'alpha', origin: 'http://192.168.1.10:8080' }; +const instances = { get: vi.fn(async (id: string) => (id === 'alpha' ? instance : undefined)) }; + +describe('InstanceMessageService', () => { + it('parses a bounded explicit message allowlist and never exposes pdu or excess fields', () => { + const messages = parseMessageList( + { + status: 200, + headers: {}, + body: JSON.stringify({ + status: 'success', + data: { + messages: [ + { + id: 'm1', + direction: 'received', + phone_number: '+15550199', + content: 'hello', + timestamp: '2026-07-19T12:00:00Z', + status: 'delivered', + pdu: 'SECRET-PDU', + transport: 'modem', + password: 'secret', + }, + ], + }, + }), + }, + 10, + ); + expect(messages).toEqual([ + { + id: 'm1', + direction: 'received', + phoneNumber: '+15550199', + content: 'hello', + timestamp: '2026-07-19T12:00:00Z', + status: 'delivered', + transport: 'modem', + }, + ]); + expect(JSON.stringify(messages)).not.toContain('PDU'); + expect(JSON.stringify(messages)).not.toContain('secret'); + }); + + it('uses exact owner, bounded query and matching optional session cookie', async () => { + const sessions = new InstanceSessionStore(); + sessions.set('alpha', instance.origin, 'simadmin_session=opaque'); + const request = vi.fn(async () => ({ + status: 200, + headers: {}, + body: '{"status":"success","data":{"messages":[]}}', + })); + const service = new InstanceMessageService({ + instances: instances as never, + sessions, + request, + }); + await expect( + service.list('alpha', { limit: 20, offset: 2, direction: 'outgoing' }), + ).resolves.toEqual({ messages: [] }); + expect(request).toHaveBeenCalledWith({ + url: 'http://192.168.1.10:8080/api/sms/list?limit=20&offset=2&direction=outgoing', + method: 'GET', + headers: { accept: 'application/json', cookie: 'simadmin_session=opaque' }, + }); + }); + + it('sends once with an explicit payload and returns only safe success', async () => { + const request = vi.fn(async () => ({ + status: 200, + headers: {}, + body: '{"status":"success","message":"queued","data":{"token":"secret"}}', + })); + const service = new InstanceMessageService({ + instances: instances as never, + sessions: new InstanceSessionStore(), + request, + }); + await expect( + service.send('alpha', { phoneNumber: '+15550199', content: 'hello' }), + ).resolves.toEqual({ sent: true }); + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith({ + url: 'http://192.168.1.10:8080/api/sms/send', + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + sms: { phoneNumber: '+15550199', content: 'hello' }, + }); + }); + + it('fails closed for missing owners, stale sessions and upstream status:error', async () => { + const sessions = new InstanceSessionStore(); + sessions.set('alpha', 'http://192.168.1.99', 'simadmin_session=opaque'); + const request = vi.fn(async () => ({ + status: 200, + headers: {}, + body: '{"status":"error","message":"recipient secret"}', + })); + const service = new InstanceMessageService({ + instances: instances as never, + sessions, + request, + }); + await expect(service.list('alpha', { limit: 10, offset: 0 })).rejects.toBeInstanceOf( + MessageServiceError, + ); + await expect(service.list('missing', { limit: 10, offset: 0 })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + sessions.clear('alpha'); + await expect( + service.send('alpha', { phoneNumber: '+15550199', content: 'hello' }), + ).rejects.toMatchObject({ code: 'UPSTREAM_FAILED' }); + }); +}); diff --git a/apps/api/src/application/messages/instance-message-service.ts b/apps/api/src/application/messages/instance-message-service.ts new file mode 100644 index 0000000..f3f684d --- /dev/null +++ b/apps/api/src/application/messages/instance-message-service.ts @@ -0,0 +1,191 @@ +import type { InstanceService } from '../instances/instance-service.js'; +import type { + InstanceSessionStore, + UpstreamResponse, + UpstreamSessionClientOptions, +} from '../connections/upstream-session-client.js'; + +export type MessageDirection = 'received' | 'sent' | 'incoming' | 'outgoing' | 'unknown'; +export interface InstanceMessage { + readonly id: string; + readonly direction: MessageDirection; + readonly phoneNumber: string; + readonly content: string; + readonly timestamp: string; + readonly status: string; + readonly transport: string; +} +export interface MessageListQuery { + readonly limit: number; + readonly offset: number; + readonly direction?: Exclude; +} +export interface SendMessageInput { + readonly phoneNumber: string; + readonly content: string; +} +export type MessageServiceErrorCode = + | 'NOT_FOUND' + | 'VALIDATION_FAILED' + | 'SESSION_INVALID' + | 'UPSTREAM_FAILED'; +export class MessageServiceError extends Error { + constructor(readonly code: MessageServiceErrorCode) { + super(code); + this.name = 'MessageServiceError'; + } +} + +const MAX_RESPONSE_BYTES = 262_144; +const DIRECTIONS = new Set(['received', 'sent', 'incoming', 'outgoing']); +const record = (value: unknown): Record | undefined => + value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +const bounded = (value: unknown, maximum: number): string | undefined => + (typeof value === 'string' || typeof value === 'number') && + String(value).length <= maximum && + !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(String(value)) + ? String(value) + : undefined; + +export function validPhoneNumber(value: string): boolean { + return value.length >= 3 && value.length <= 32 && /^\+?[0-9][0-9 ()-]*$/u.test(value); +} +export function validMessageContent(value: string): boolean { + return ( + value.length >= 1 && + value.length <= 1600 && + Buffer.byteLength(value, 'utf8') <= 6400 && + !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value) + ); +} + +export function parseMessageList( + response: UpstreamResponse, + limit: number, +): readonly InstanceMessage[] { + if ( + response.status < 200 || + response.status >= 300 || + Buffer.byteLength(response.body, 'utf8') > MAX_RESPONSE_BYTES + ) + throw new MessageServiceError('UPSTREAM_FAILED'); + let root: Record | undefined; + try { + root = record(JSON.parse(response.body)); + } catch { + throw new MessageServiceError('UPSTREAM_FAILED'); + } + if (root?.status !== 'success' && root?.status !== 'ok') + throw new MessageServiceError('UPSTREAM_FAILED'); + const source = record(root.data)?.messages; + if (!Array.isArray(source) || source.length > limit) + throw new MessageServiceError('UPSTREAM_FAILED'); + const output: InstanceMessage[] = []; + for (const item of source) { + const value = record(item); + const id = bounded(value?.id, 128); + const phoneNumber = bounded(value?.phone_number, 32); + const content = bounded(value?.content, 1600); + const timestamp = bounded(value?.timestamp, 64); + const status = bounded(value?.status, 32); + const transport = bounded(value?.transport, 32); + const rawDirection = bounded(value?.direction, 16); + if ( + !id || + !phoneNumber || + !validPhoneNumber(phoneNumber) || + content === undefined || + timestamp === undefined || + status === undefined || + transport === undefined + ) + continue; + const direction: MessageDirection = + rawDirection && DIRECTIONS.has(rawDirection as MessageDirection) + ? (rawDirection as MessageDirection) + : 'unknown'; + output.push({ id, direction, phoneNumber, content, timestamp, status, transport }); + } + return output; +} + +function parseSendSuccess(response: UpstreamResponse): void { + if ( + response.status < 200 || + response.status >= 300 || + Buffer.byteLength(response.body, 'utf8') > 32_768 + ) + throw new MessageServiceError('UPSTREAM_FAILED'); + try { + if (record(JSON.parse(response.body))?.status !== 'success') + throw new MessageServiceError('UPSTREAM_FAILED'); + } catch (error) { + if (error instanceof MessageServiceError) throw error; + throw new MessageServiceError('UPSTREAM_FAILED'); + } +} + +export class InstanceMessageService { + constructor( + private readonly options: { + readonly instances: InstanceService; + readonly sessions: InstanceSessionStore; + readonly request: UpstreamSessionClientOptions['request']; + }, + ) {} + + private async owner(instanceId: string) { + const instance = await this.options.instances.get(instanceId); + if (!instance) throw new MessageServiceError('NOT_FOUND'); + const session = this.options.sessions.sessionFor(instanceId); + if (session && session.origin !== instance.origin) + throw new MessageServiceError('SESSION_INVALID'); + return { instance, session }; + } + + async list( + instanceId: string, + query: MessageListQuery, + ): Promise<{ readonly messages: readonly InstanceMessage[] }> { + if ( + !Number.isSafeInteger(query.limit) || + query.limit < 1 || + query.limit > 100 || + !Number.isSafeInteger(query.offset) || + query.offset < 0 || + query.offset > 10_000 || + (query.direction !== undefined && + query.direction !== 'incoming' && + query.direction !== 'outgoing') + ) + throw new MessageServiceError('VALIDATION_FAILED'); + const { instance, session } = await this.owner(instanceId); + const direction = query.direction ? `&direction=${query.direction}` : ''; + const response = await this.options.request({ + url: `${instance.origin}/api/sms/list?limit=${query.limit}&offset=${query.offset}${direction}`, + method: 'GET', + headers: { accept: 'application/json', ...(session ? { cookie: session.cookie } : {}) }, + }); + return { messages: parseMessageList(response, query.limit) }; + } + + async send(instanceId: string, input: SendMessageInput): Promise<{ readonly sent: true }> { + if (!validPhoneNumber(input.phoneNumber) || !validMessageContent(input.content)) + throw new MessageServiceError('VALIDATION_FAILED'); + const { instance, session } = await this.owner(instanceId); + const response = await this.options.request({ + url: `${instance.origin}/api/sms/send`, + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + ...(session ? { cookie: session.cookie } : {}), + }, + sms: { phoneNumber: input.phoneNumber, content: input.content }, + }); + parseSendSuccess(response); + return { sent: true }; + } +} diff --git a/apps/api/src/control-plane.ts b/apps/api/src/control-plane.ts index f4a8af2..e12daf1 100644 --- a/apps/api/src/control-plane.ts +++ b/apps/api/src/control-plane.ts @@ -29,6 +29,7 @@ import { registerJobRoutes } from './interface/http/job-routes.js'; import { AuditQueryService } from './application/audit/audit-query-service.js'; import { registerAuditRoutes } from './interface/http/audit-routes.js'; import { InstanceResourceService } from './application/resources/instance-resource-service.js'; +import { InstanceMessageService } from './application/messages/instance-message-service.js'; export interface SafeControlPlaneUpstream extends ConnectionTransport { request: UpstreamSessionClientOptions['request']; @@ -67,6 +68,11 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane sessions, request: options.upstream.request, }); + const messages = new InstanceMessageService({ + instances, + sessions, + request: options.upstream.request, + }); const resolver = new InstanceCredentialResolver({ db: options.db, store: options.store }); const login = options.now ? new InstanceLoginService({ db: options.db, client, resolver, now: options.now }) @@ -96,6 +102,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane login, deletion, resources, + messages, registerDeletionPreparationRoute: false, }); registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion); diff --git a/apps/api/src/infrastructure/transport/safe-instance-transport.ts b/apps/api/src/infrastructure/transport/safe-instance-transport.ts index c860e87..d031919 100644 --- a/apps/api/src/infrastructure/transport/safe-instance-transport.ts +++ b/apps/api/src/infrastructure/transport/safe-instance-transport.ts @@ -59,7 +59,6 @@ const origin = (raw: string): URL => { (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password || - parsed.search || parsed.hash ) throw new TransportError('UNSAFE_ORIGIN'); diff --git a/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts b/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts index b8f5918..a56cc78 100644 --- a/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts +++ b/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts @@ -98,6 +98,53 @@ describe('SafeUpstreamGateway', () => { }); }); + it('allows only a bounded SMS list query and serializes an explicit SMS payload', async () => { + const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); + const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); + const gateway = new SafeUpstreamGateway({ transport: { get, post } }); + await gateway.request({ + url: 'http://192.168.1.20:8080/api/sms/list?limit=20&offset=0&direction=outgoing', + method: 'GET', + headers: { accept: 'application/json' }, + }); + await gateway.request({ + url: 'http://192.168.1.20:8080/api/sms/send', + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + sms: { phoneNumber: '+15550199', content: 'hello' }, + }); + expect(get).toHaveBeenCalledOnce(); + expect(post).toHaveBeenCalledWith( + 'http://192.168.1.20:8080/api/sms/send', + { accept: 'application/json', 'content-type': 'application/json' }, + '{"phone_number":"+15550199","content":"hello"}', + ); + }); + + it('rejects unallowlisted SMS queries and malformed send payloads before transport', async () => { + const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); + const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' })); + const gateway = new SafeUpstreamGateway({ transport: { get, post } }); + for (const url of [ + 'http://192.168.1.20/api/sms/list?limit=101&offset=0', + 'http://192.168.1.20/api/sms/list?limit=10&offset=0&pdu=true', + 'http://192.168.1.20/api/sms/list?offset=0&limit=10', + ]) + await expect(gateway.request({ url, method: 'GET', headers: {} })).rejects.toThrow( + 'UPSTREAM_REQUEST_INVALID', + ); + await expect( + gateway.request({ + url: 'http://192.168.1.20/api/sms/send', + method: 'POST', + headers: {}, + sms: { phoneNumber: 'bad\nnumber', content: 'hello' }, + }), + ).rejects.toThrow('UPSTREAM_REQUEST_INVALID'); + expect(get).not.toHaveBeenCalled(); + expect(post).not.toHaveBeenCalled(); + }); + it('does not allow a supplied redacted body marker to become a network request body', async () => { const gateway = new SafeUpstreamGateway({ transport: { diff --git a/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts b/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts index 6718f85..045bd78 100644 --- a/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts +++ b/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts @@ -46,12 +46,21 @@ export class SafeUpstreamGateway { } async request(request: UpstreamRequest): Promise { const url = new URL(request.url); + const headerKeys = Object.keys(request.headers).sort().join(','); if (request.method === 'GET') { + const smsList = url.pathname === '/api/sms/list'; + const smsQuery = smsList + ? /^(?:limit=([1-9]\d?)|limit=100)&offset=(0|[1-9]\d{0,4})(?:&direction=(incoming|outgoing))?$/u.exec( + url.search.slice(1), + ) + : null; if ( request.secret !== undefined || request.body !== undefined || - (url.pathname !== '/api/stats' && url.pathname !== '/api/sim') || - url.search || + request.sms !== undefined || + (headerKeys !== 'accept' && headerKeys !== 'accept,cookie') || + (url.pathname !== '/api/stats' && url.pathname !== '/api/sim' && !smsQuery) || + (!smsList && url.search) || url.hash || url.username || url.password || @@ -62,6 +71,31 @@ export class SafeUpstreamGateway { throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); return this.options.transport.get(request.url, request.headers); } + if (url.pathname === '/api/sms/send') { + const sms = request.sms; + if ( + request.method !== 'POST' || + request.secret !== undefined || + request.body !== undefined || + !sms || + (headerKeys !== 'accept,content-type' && headerKeys !== 'accept,content-type,cookie') || + !/^\+?[0-9][0-9 ()-]{2,31}$/u.test(sms.phoneNumber) || + sms.content.length < 1 || + sms.content.length > 1600 || + Buffer.byteLength(sms.content, 'utf8') > 6400 || + /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(sms.content) || + url.search || + url.hash || + url.username || + url.password + ) + throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); + return this.options.transport.post( + request.url, + request.headers, + JSON.stringify({ phone_number: sms.phoneNumber, content: sms.content }), + ); + } if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH'); if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID'); if (request.url.endsWith('/api/auth/login')) { diff --git a/apps/api/src/interface/http/instance-routes.ts b/apps/api/src/interface/http/instance-routes.ts index 90bb701..a6de158 100644 --- a/apps/api/src/interface/http/instance-routes.ts +++ b/apps/api/src/interface/http/instance-routes.ts @@ -20,6 +20,10 @@ import { DeleteInstanceOperationError, } from '../../application/operations/delete-instance-operation.js'; import type { InstanceResourceService } from '../../application/resources/instance-resource-service.js'; +import { + MessageServiceError, + type InstanceMessageService, +} from '../../application/messages/instance-message-service.js'; export interface InstanceRoutesOptions { readonly instances: InstanceService; @@ -27,6 +31,7 @@ export interface InstanceRoutesOptions { readonly login?: InstanceLoginService; readonly deletion?: DeleteInstanceOperation; readonly resources?: InstanceResourceService; + readonly messages?: InstanceMessageService; readonly registerDeletionPreparationRoute?: boolean; } const problem = ( @@ -303,6 +308,21 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo 'The requested session operation could not be completed.', ), ); + if (error instanceof MessageServiceError) { + const status = + error.code === 'NOT_FOUND' ? 404 : error.code === 'VALIDATION_FAILED' ? 400 : 502; + return reply + .code(status) + .type('application/problem+json') + .send( + problem( + request, + status, + error.code, + 'The requested message operation could not be completed.', + ), + ); + } throw error; } }; @@ -345,6 +365,55 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo options.resources!.get((request.params as { instanceId: string }).instanceId), ), ); + if (options.messages) { + app.get( + '/api/v1/instances/:instanceId/messages', + wrap(async (request) => { + const query = request.query as Record; + if (Object.keys(query).some((key) => !['limit', 'offset', 'direction'].includes(key))) + throw new MessageServiceError('VALIDATION_FAILED'); + const integer = (key: 'limit' | 'offset', fallback: number): number => { + const raw = query[key]; + if (raw === undefined) return fallback; + if (typeof raw !== 'string' || !/^\d+$/u.test(raw)) + throw new MessageServiceError('VALIDATION_FAILED'); + return Number(raw); + }; + const direction = query.direction; + if (direction !== undefined && direction !== 'incoming' && direction !== 'outgoing') + throw new MessageServiceError('VALIDATION_FAILED'); + return options.messages!.list((request.params as { instanceId: string }).instanceId, { + limit: integer('limit', 50), + offset: integer('offset', 0), + ...(direction ? { direction } : {}), + }); + }), + ); + app.post( + '/api/v1/instances/:instanceId/messages/send', + { + bodyLimit: 8_192, + schema: { + body: { + type: 'object', + additionalProperties: false, + required: ['phoneNumber', 'content'], + properties: { + phoneNumber: { type: 'string', minLength: 3, maxLength: 32 }, + content: { type: 'string', minLength: 1, maxLength: 1600 }, + }, + }, + }, + }, + wrap(async (request) => { + const value = record(request.body); + return options.messages!.send((request.params as { instanceId: string }).instanceId, { + phoneNumber: typeof value.phoneNumber === 'string' ? value.phoneNumber : '', + content: typeof value.content === 'string' ? value.content : '', + }); + }), + ); + } app.patch( '/api/v1/instances/:instanceId', { schema: { body: instancePatchSchema } }, diff --git a/apps/web/src/app-shell.integration.test.tsx b/apps/web/src/app-shell.integration.test.tsx index 4aaf315..774fece 100644 --- a/apps/web/src/app-shell.integration.test.tsx +++ b/apps/web/src/app-shell.integration.test.tsx @@ -129,8 +129,12 @@ describe('React AppShell and Fleet vertical slice', () => { '/instances/new', ); const card = screen.getByRole('article', { name: 'Bravo 实例概览' }); - expect(within(card).getByText('40 ms')).toBeTruthy(); - expect(within(card).getByText('2.0')).toBeTruthy(); + expect(within(card).queryByText('延迟')).toBeNull(); + expect(within(card).queryByText('版本')).toBeNull(); + expect(within(card).queryByText('新鲜度')).toBeNull(); + expect(within(card).queryByText('40 ms')).toBeNull(); + expect(within(card).queryByText('2.0')).toBeNull(); + expect(within(card).queryByText('可能过期')).toBeNull(); expect(within(card).getByText('18.4%')).toBeTruthy(); expect(within(card).getByText('63.2%')).toBeTruthy(); expect(within(card).getByText('46.7 °C')).toBeTruthy(); @@ -150,6 +154,34 @@ describe('React AppShell and Fleet vertical slice', () => { expect(within(card).getByRole('status').textContent).toContain('删除请求已提交'); }); + it('keeps Jobs and Audit routes compatible without advertising them in global navigation', async () => { + const jobsDataSource: JobsDataSource = { load: vi.fn().mockResolvedValue(emptyPage) }; + const { rerender } = render( + , + ); + + expect(await screen.findByText(/没有任务符合当前查询/i)).toBeTruthy(); + const navigation = screen.getByRole('navigation', { name: '全局导航' }); + expect(within(navigation).queryByRole('link', { name: '任务' })).toBeNull(); + expect(within(navigation).queryByRole('link', { name: '审计' })).toBeNull(); + expect(within(navigation).getByRole('link', { name: '实例总览' })).toBeTruthy(); + expect(within(navigation).getByRole('link', { name: '设置' })).toBeTruthy(); + + const auditDataSource: AuditDataSource = { load: vi.fn().mockResolvedValue(emptyPage) }; + rerender( + , + ); + expect(await screen.findByText(/没有审计事件符合当前查询/i)).toBeTruthy(); + }); + it('loads route-owned instance context so overview-card navigation opens detail management', async () => { const instanceDataSource: InstanceDataSource = { get: vi.fn().mockResolvedValue({ diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index b5c56f7..e8a01cc 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -22,6 +22,7 @@ import { createInstanceApiDataSource } from './instances/instance-api-data-sourc import { JobsPage, type JobsDataSource } from './jobs/jobs-page.js'; import { createJobsApiDataSource } from './jobs/jobs-api-data-source.js'; import { MessagesModule, type MessagesDataSource } from './instances/messages-module.js'; +import { createMessagesApiDataSource } from './instances/messages-api-data-source.js'; import { NotificationsModule, type NotificationsDataSource, @@ -410,6 +411,7 @@ export function AppShell({ const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []); const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []); const defaultInstanceDataSource = useMemo(() => createInstanceApiDataSource(), []); + const defaultMessagesDataSource = useMemo(() => createMessagesApiDataSource(), []); const resolvedJobsDataSource = useMemo( () => jobsDataSource ?? createJobsApiDataSource(), [jobsDataSource], @@ -488,8 +490,6 @@ export function AppShell({ {( [ ['fleet', '/fleet', '实例总览'], - ['jobs', '/jobs', '任务'], - ['audit', '/audit', '审计'], ['settings', '/settings/instances', '设置'], ] as const ).map(([key, href, label]) => ( @@ -520,7 +520,7 @@ export function AppShell({ overviewDataSource={overviewDataSource} cellularDataSource={cellularDataSource} deviceNetworkDataSource={deviceNetworkDataSource} - messagesDataSource={messagesDataSource} + messagesDataSource={messagesDataSource ?? defaultMessagesDataSource} callsDataSource={callsDataSource} esimDataSource={esimDataSource} notificationsDataSource={notificationsDataSource} diff --git a/apps/web/src/fleet/fleet-page.tsx b/apps/web/src/fleet/fleet-page.tsx index 9072325..e4b4ee4 100644 --- a/apps/web/src/fleet/fleet-page.tsx +++ b/apps/web/src/fleet/fleet-page.tsx @@ -444,18 +444,6 @@ export function FleetPage({
最高温度
{temperature(row.status?.summary?.resources?.maxTemperatureCelsius)}
-
-
延迟
-
{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}
-
-
-
版本
-
{row.version ?? '—'}
-
-
-
新鲜度
-
{FRESHNESS_LABELS[row.freshness] ?? row.freshness}
-
{row.capabilities.length > 0 ? ( diff --git a/apps/web/src/instances/instance-detail.test.tsx b/apps/web/src/instances/instance-detail.test.tsx index a43793f..5c42c77 100644 --- a/apps/web/src/instances/instance-detail.test.tsx +++ b/apps/web/src/instances/instance-detail.test.tsx @@ -28,7 +28,7 @@ const capabilities: InstanceCapabilityMap = { }; describe('InstanceDetail', () => { - it('makes only supported modules actionable and explains every other capability state', () => { + it('keeps core overview and message modules actionable while explaining other capability states', () => { render( { expect(screen.getByRole('link', { name: '概览' }).getAttribute('href')).toBe( '/instances/owner/overview', ); - expect(screen.queryByRole('link', { name: '消息' })).toBeNull(); - expect(screen.getByText('消息历史记录为只读。')).toBeTruthy(); + expect(screen.getByRole('link', { name: '消息' }).getAttribute('href')).toBe( + '/instances/owner/messages', + ); + expect(screen.queryByText('消息历史记录为只读。')).toBeNull(); expect(screen.getByText('此调制解调器不支持语音功能。')).toBeTruthy(); expect(screen.getByText('能力探测结果未包含 eSIM。')).toBeTruthy(); expect(screen.getAllByText('能力状态未知。').length).toBeGreaterThan(0); diff --git a/apps/web/src/instances/instance-detail.tsx b/apps/web/src/instances/instance-detail.tsx index f1a2c72..b256db6 100644 --- a/apps/web/src/instances/instance-detail.tsx +++ b/apps/web/src/instances/instance-detail.tsx @@ -55,6 +55,11 @@ function canRender(capability: InstanceCapability): boolean { return capability.state === 'supported' || capability.state === 'degraded'; } +const CORE_MODULES = new Set(['overview', 'messages']); +function canOpen(module: InstanceModule, capability: InstanceCapability): boolean { + return CORE_MODULES.has(module) || canRender(capability); +} + function explanation(capability: InstanceCapability): string | null { if (capability.state === 'supported') return null; return capability.explanation?.trim() || DEFAULT_EXPLANATIONS[capability.state]; @@ -131,14 +136,6 @@ export function InstanceDetail({
状态
{displayStatus(instance.status)}
-
-
认证
-
{displayStatus(instance.authentication)}
-
-
-
数据新鲜度
-
{displayStatus(instance.freshness)}
-
{origin ? ( @@ -153,7 +150,7 @@ export function InstanceDetail({ const reason = explanation(capability); return (
  • - {capability.state === 'supported' ? ( + {canOpen(item, capability) ? ( {INSTANCE_MODULE_LABELS[module]} {loading ?

    正在加载能力…

    : null} {loadError ?

    能力不可用:{loadError}

    : null} - {!loading && canRender(activeCapability) ? ( + {!loading && canOpen(module, activeCapability) ? ( <> {activeCapability.state === 'degraded' ? (

    {explanation(activeCapability)}

    @@ -184,7 +181,7 @@ export function InstanceDetail({ {moduleContent ??

    查看{INSTANCE_MODULE_LABELS[module]}数据和可用操作。

    } ) : null} - {!loading && !canRender(activeCapability) ? ( + {!loading && !canOpen(module, activeCapability) ? (

    {explanation(activeCapability)}

    ) : null} diff --git a/apps/web/src/instances/messages-api-data-source.ts b/apps/web/src/instances/messages-api-data-source.ts new file mode 100644 index 0000000..8f0a898 --- /dev/null +++ b/apps/web/src/instances/messages-api-data-source.ts @@ -0,0 +1,59 @@ +import type { MessagesDataSource, MessagesSnapshot, SendMessageInput } from './messages-module.js'; + +interface Options { + readonly fetcher?: typeof fetch; +} + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +export function createMessagesApiDataSource(options: Options = {}): MessagesDataSource { + const fetcher = options.fetcher ?? fetch; + return { + async load(instanceId, signal): Promise { + const response = await fetcher( + `/api/v1/instances/${encodeURIComponent(instanceId)}/messages?limit=50&offset=0`, + { headers: { accept: 'application/json' }, signal }, + ); + if (!response.ok) throw new Error('message load failed'); + const root = record(await response.json()); + if (!Array.isArray(root?.messages)) throw new Error('message load failed'); + return { + messages: root.messages.flatMap((item) => { + const value = record(item); + return typeof value?.id === 'string' && + typeof value.phoneNumber === 'string' && + typeof value.content === 'string' && + typeof value.timestamp === 'string' && + typeof value.status === 'string' && + typeof value.direction === 'string' + ? [ + { + id: value.id, + phoneNumber: value.phoneNumber, + content: value.content, + timestamp: value.timestamp, + status: value.status, + direction: value.direction, + }, + ] + : []; + }), + }; + }, + async send(instanceId: string, input: SendMessageInput): Promise { + const response = await fetcher( + `/api/v1/instances/${encodeURIComponent(instanceId)}/messages/send`, + { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify(input), + }, + ); + if (!response.ok) throw new Error('message send failed'); + }, + }; +} diff --git a/apps/web/src/instances/messages-module.test.tsx b/apps/web/src/instances/messages-module.test.tsx index f2b3228..758cfd2 100644 --- a/apps/web/src/instances/messages-module.test.tsx +++ b/apps/web/src/instances/messages-module.test.tsx @@ -1,215 +1,58 @@ // @vitest-environment jsdom -import { cleanup, render, screen, within } from '@testing-library/react'; +import { cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { InstanceContext } from '../app-shell.js'; -import { - MessagesModule, - type MessagesDataSource, - type MessagesSnapshot, -} from './messages-module.js'; +import { MessagesModule, type MessagesDataSource } from './messages-module.js'; -afterEach(cleanup); - -const owner: InstanceContext = { +const instance: InstanceContext = { id: 'alpha', name: 'Alpha', - origin: 'https://alpha.example', - status: 'online', - authentication: 'authenticated', - freshness: 'fresh', + origin: 'http://192.168.1.2', + status: 'unknown', + authentication: 'unknown', + freshness: 'unknown', }; +afterEach(cleanup); -const snapshot: MessagesSnapshot = { - observedAt: '2026-07-17T12:00:00Z', - sms: { - total: 42, - inbound: 25, - outbound: 17, - unread: 3, - failed: 2, - queued: 1, - lastActivityAt: '2026-07-17T11:55:00Z', - // Deliberate excess fields: payloads and recipient data must never reach the DOM. - body: 'private message body', - content: 'private message content', - recipient: '+15550199', - recipientCredential: 'secret-recipient-token', - }, - devices: [ - { - deviceId: 'modem-1', - label: 'Primary modem', - state: 'online', - total: 30, - inbound: 18, - outbound: 12, - unread: 2, - failed: 1, - queued: 0, - lastActivityAt: '2026-07-17T11:54:00Z', - body: 'device body must not render', - phoneNumber: '+15550123', - password: 'device credential', - }, - ], -}; - -function deferredSource() { - let resolve!: (value: MessagesSnapshot) => void; - let reject!: (reason: unknown) => void; - const load = vi.fn( - (_instanceId, _signal) => - new Promise((done, fail) => { - void _instanceId; - void _signal; - resolve = done; - reject = fail; +describe('MessagesModule', () => { + it('shows the real message list including content and never exposes PDU', async () => { + const dataSource: MessagesDataSource = { + load: vi.fn().mockResolvedValue({ + messages: [ + { + id: '32', + direction: 'incoming', + phoneNumber: '10086', + content: '余额提醒', + timestamp: '2026-07-18 09:09:20', + status: 'received', + pdu: 'secret-pdu', + }, + ], }), - ); - return { - source: { load }, - load, - resolve: (value: MessagesSnapshot) => resolve(value), - reject: (reason: unknown) => reject(reason), - }; -} - -describe('Messages isolated read module', () => { - it('loads only through the injected exact-owner source and renders aggregate SMS/device metadata', async () => { - const pending = deferredSource(); - render(); - - expect(screen.getByRole('status', { name: '消息加载状态' })).toBeTruthy(); - expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal)); - pending.resolve(snapshot); - - const sms = await screen.findByRole('region', { name: '短信汇总' }); - expect(within(sms).getByText('42')).toBeTruthy(); - expect(within(sms).getByText('2026-07-17T11:55:00Z')).toBeTruthy(); - const devices = screen.getByRole('region', { name: '设备消息汇总' }); - expect(within(devices).getByText('modem-1')).toBeTruthy(); - expect(within(devices).getByText('Primary modem')).toBeTruthy(); - expect(within(devices).getByText('在线')).toBeTruthy(); - expect(within(devices).queryByText('online')).toBeNull(); - expect(screen.getByText(/观测时间:2026-07-17T12:00:00Z/)).toBeTruthy(); + send: vi.fn(), + }; + render(); + expect(await screen.findByText('余额提醒')).toBeTruthy(); + expect(screen.getByText(/收到 · 10086/)).toBeTruthy(); + expect(document.body.textContent).not.toContain('secret-pdu'); }); - it('uses an explicit safe allowlist and exposes no bodies, content, recipients, credentials, or write actions', async () => { - render( snapshot }} />); - expect(await screen.findByText('Primary modem')).toBeTruthy(); - const text = document.body.textContent ?? ''; - for (const secret of [ - 'private message body', - 'private message content', - '+15550199', - 'secret-recipient-token', - 'device body must not render', - '+15550123', - 'device credential', - ]) { - expect(text).not.toContain(secret); - } - expect(screen.queryByText(/recipient|phone number|password/i)).toBeNull(); - expect(screen.queryByRole('button')).toBeNull(); - expect(screen.getByText(/仅显示汇总元数据/i)).toBeTruthy(); - expect(screen.getByText(/此只读模块不支持发送、删除或修改消息/)).toBeTruthy(); - }); - - it('does not invent an endpoint when no source is injected', () => { - render(); - expect(screen.getByRole('status', { name: '消息不可用' }).textContent).toMatch( - /没有可用的安全消息只读数据源.*未签订契约的生产端点/i, - ); - }); - - it('requires the exact owner to remain authenticated and clears prior owner data', async () => { - const load = vi.fn().mockResolvedValue(snapshot); - const { rerender } = render(); - expect(await screen.findByText('Primary modem')).toBeTruthy(); - - rerender( - , - ); - expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i); - expect(screen.queryByText('Primary modem')).toBeNull(); - expect(load).toHaveBeenCalledTimes(1); - }); - - it('uses a fixed safe error and retries without exposing rejection details', async () => { + it('provides an intentional send form and submits exactly once', async () => { const user = userEvent.setup(); - const load = vi - .fn() - .mockRejectedValueOnce(new Error('secret URL and recipient credential')) - .mockResolvedValueOnce(snapshot); - render(); - - const alert = await screen.findByRole('alert'); - expect(alert.textContent).toContain('无法加载消息数据。'); - expect(alert.textContent).not.toContain('secret URL'); - await user.click(screen.getByRole('button', { name: '重试加载消息' })); - expect(await screen.findByText('Primary modem')).toBeTruthy(); - expect(load).toHaveBeenCalledTimes(2); - }); - - it('retains only the same owner last-good snapshot during refresh and after refresh failure', async () => { - const user = userEvent.setup(); - const load = vi - .fn() - .mockResolvedValueOnce(snapshot) - .mockRejectedValueOnce(new Error('unsafe detail')) - .mockResolvedValueOnce({ ...snapshot, devices: [{ label: 'Replacement modem', total: 4 }] }); - const source = { load }; - const { rerender } = render( - , - ); - expect(await screen.findByText('Primary modem')).toBeTruthy(); - - rerender(); - expect(screen.getByText('Primary modem')).toBeTruthy(); - expect((await screen.findByRole('alert')).textContent).toMatch(/正在显示上次已知/i); - expect(screen.getByText('Primary modem')).toBeTruthy(); - await user.click(screen.getByRole('button', { name: '重试加载消息' })); - expect(await screen.findByText('Replacement modem')).toBeTruthy(); - }); - - it('aborts replaced reads and fences late responses from another owner', async () => { - const alpha = deferredSource(); - const bravo = deferredSource(); - const load = vi.fn((instanceId, signal) => - instanceId === 'alpha' - ? alpha.source.load(instanceId, signal) - : bravo.source.load(instanceId, signal), - ); - const source = { load }; - const { rerender } = render(); - const alphaSignal = load.mock.calls[0]?.[1]; - - rerender( - , - ); - expect(alphaSignal?.aborted).toBe(true); - expect(screen.queryByText('Primary modem')).toBeNull(); - alpha.resolve(snapshot); - bravo.resolve({ ...snapshot, devices: [{ label: 'Bravo modem', total: 9 }] }); - expect(await screen.findByText('Bravo modem')).toBeTruthy(); - expect(screen.queryByText('Primary modem')).toBeNull(); - }); - - it('marks owner-declared stale data while preserving aggregates', async () => { - render( - snapshot }} - />, - ); - expect((await screen.findByRole('status', { name: '消息数据新鲜度' })).textContent).toMatch( - /可能已过期/i, - ); - expect(screen.getByText('Primary modem')).toBeTruthy(); + const send = vi.fn().mockResolvedValue(undefined); + const dataSource: MessagesDataSource = { + load: vi.fn().mockResolvedValue({ messages: [] }), + send, + }; + render(); + await user.type(screen.getByRole('textbox', { name: '手机号' }), '10086'); + await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'CXLL'); + await user.click(screen.getByRole('button', { name: '发送短信' })); + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith('alpha', { phoneNumber: '10086', content: 'CXLL' }); + expect((await screen.findByRole('status')).textContent).toContain('短信已提交发送'); }); }); diff --git a/apps/web/src/instances/messages-module.tsx b/apps/web/src/instances/messages-module.tsx index c1548f5..c9d74ef 100644 --- a/apps/web/src/instances/messages-module.tsx +++ b/apps/web/src/instances/messages-module.tsx @@ -1,251 +1,159 @@ -import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { InstanceContext } from '../app-shell.js'; -import { displayValue } from '../ui/locale.js'; -/** Bounded primitives permitted at the Messages presentation boundary. */ -export type MessageMetadataValue = string | number | boolean | null; - -/** Aggregate SMS metadata only. Message bodies, content, recipients, and credentials are absent. */ -export interface SmsAggregate { - readonly total?: number | null; - readonly inbound?: number | null; - readonly outbound?: number | null; - readonly unread?: number | null; - readonly failed?: number | null; - readonly queued?: number | null; - readonly lastActivityAt?: string | null; +export interface SmsMessage { + readonly id: string; + readonly direction: string; + readonly phoneNumber: string; + readonly content: string; + readonly timestamp: string; + readonly status: string; } - -/** Non-sensitive device identity/status and aggregate counts only. */ -export interface DeviceMessageAggregate extends SmsAggregate { - readonly deviceId?: string | null; - readonly label?: string | null; - readonly state?: string | null; -} - export interface MessagesSnapshot { - readonly observedAt?: string; - readonly sms: SmsAggregate; - readonly devices: readonly DeviceMessageAggregate[]; + readonly messages: readonly SmsMessage[]; +} +export interface SendMessageInput { + readonly phoneNumber: string; + readonly content: string; } - export interface MessagesDataSource { - /** Supplied by the authenticated owner; this isolated module defines no network endpoint. */ load(instanceId: string, signal: AbortSignal): Promise; + send(instanceId: string, input: SendMessageInput): Promise; } - export interface MessagesModuleProps { readonly instance: InstanceContext; readonly dataSource?: MessagesDataSource; - /** Change this owner-provided value to request another read. */ readonly refreshSignal?: unknown; } -type ReadState = - | { kind: 'idle'; ownerId: string } - | { kind: 'loading'; ownerId: string; snapshot?: MessagesSnapshot } - | { kind: 'ready'; ownerId: string; snapshot: MessagesSnapshot } - | { kind: 'error'; ownerId: string; snapshot?: MessagesSnapshot }; - -const SAFE_LOAD_ERROR = '无法加载消息数据。'; - -const AGGREGATE_FIELDS = [ - ['总计', 'total'], - ['接收', 'inbound'], - ['发送', 'outbound'], - ['未读', 'unread'], - ['失败', 'failed'], - ['排队中', 'queued'], - ['最近活动', 'lastActivityAt'], -] as const; - -function displayRaw(value: MessageMetadataValue | undefined): string { - return value == null ? '不可用' : String(value); -} - -function Fields({ - values, -}: { - values: readonly (readonly [ - label: string, - value: MessageMetadataValue | undefined, - localized?: boolean, - ])[]; -}) { - return ( -
    - {values.map(([label, value, localized]) => ( -
    -
    {label}
    -
    {localized ? displayValue(value) : displayRaw(value)}
    -
    - ))} -
    - ); -} - -function Section({ label, children }: { label: string; children: ReactNode }) { - return ( -
    -

    {label}

    - {children} -
    - ); -} - -function aggregateFields( - aggregate: SmsAggregate, -): readonly (readonly [string, MessageMetadataValue | undefined])[] { - // This constant-key projection is the presentation allowlist. Never enumerate source objects. - return AGGREGATE_FIELDS.map(([label, key]) => [label, aggregate[key]] as const); -} - -function SnapshotView({ snapshot }: { snapshot: MessagesSnapshot }) { - return ( - <> - {snapshot.observedAt ?

    观测时间:{snapshot.observedAt}

    : null} -

    仅显示汇总元数据;敏感内容和寻址详情已排除。

    -
    -
    - -
    -
    - {snapshot.devices.length ? ( - snapshot.devices.map((device, index) => ( -
    - -
    - )) - ) : ( -

    未提供设备消息汇总。

    - )} -
    -
    -
    -

    消息操作

    -

    此只读模块不支持发送、删除或修改消息。

    -
    - - ); -} +const directionLabel = (value: string): string => + value === 'incoming' || value === 'received' + ? '收到' + : value === 'outgoing' || value === 'sent' + ? '发送' + : '未知'; export function MessagesModule({ instance, dataSource, refreshSignal }: MessagesModuleProps) { - const requestOwner = useRef(0); + const owner = useRef(0); + const [messages, setMessages] = useState(); + const [loadError, setLoadError] = useState(false); const [retry, setRetry] = useState(0); - const [state, setState] = useState({ kind: 'idle', ownerId: instance.id }); + const [phoneNumber, setPhoneNumber] = useState(''); + const [content, setContent] = useState(''); + const [sending, setSending] = useState(false); + const [sendState, setSendState] = useState<'success' | 'error'>(); useEffect(() => { - const request = ++requestOwner.current; - const ownerId = instance.id; + const request = ++owner.current; const controller = new AbortController(); - - if (instance.authentication !== 'authenticated' || !dataSource) { - setState({ kind: 'idle', ownerId }); - return () => controller.abort(); - } - - setState((current) => ({ - kind: 'loading', - ownerId, - ...(current.ownerId === ownerId && - (current.kind === 'ready' || current.kind === 'error') && - current.snapshot - ? { snapshot: current.snapshot } - : {}), - })); - - void dataSource.load(ownerId, controller.signal).then( + setMessages(undefined); + setLoadError(false); + if (!dataSource) return () => controller.abort(); + void dataSource.load(instance.id, controller.signal).then( (snapshot) => { - if (request === requestOwner.current && !controller.signal.aborted) { - setState({ kind: 'ready', ownerId, snapshot }); - } + if (request === owner.current && !controller.signal.aborted) setMessages(snapshot.messages); }, - (_reason: unknown) => { - void _reason; - if (request === requestOwner.current && !controller.signal.aborted) { - setState((current) => ({ - kind: 'error', - ownerId, - ...(current.ownerId === ownerId && current.kind === 'loading' && current.snapshot - ? { snapshot: current.snapshot } - : {}), - })); - } + () => { + if (request === owner.current && !controller.signal.aborted) setLoadError(true); }, ); - return () => controller.abort(); - }, [dataSource, instance.authentication, instance.id, refreshSignal, retry]); + }, [dataSource, instance.id, refreshSignal, retry]); - if (instance.authentication !== 'authenticated') { + async function send(): Promise { + if (!dataSource || sending) return; + setSending(true); + setSendState(undefined); + try { + await dataSource.send(instance.id, { phoneNumber, content }); + setContent(''); + setSendState('success'); + setRetry((value) => value + 1); + } catch { + setSendState('error'); + } finally { + setSending(false); + } + } + + if (!dataSource) return ( -
    - 需要先完成认证,才能读取消息数据。 +
    + 短信功能暂不可用。
    ); - } - - if (!dataSource) { - return ( -
    - 没有可用的安全消息只读数据源。此控制台不会臆造或调用未签订契约的生产端点。 -
    - ); - } - - const retainedSnapshot = - state.ownerId === instance.id && (state.kind === 'loading' || state.kind === 'error') - ? state.snapshot - : undefined; - - if ((state.kind === 'idle' || state.kind === 'loading') && !retainedSnapshot) { - return ( -

    - 正在加载消息… -

    - ); - } - - if (state.kind === 'error' && !retainedSnapshot) { - return ( -
    -

    {SAFE_LOAD_ERROR}

    - -
    - ); - } - - const currentSnapshot = - state.kind === 'ready' && state.ownerId === instance.id ? state.snapshot : retainedSnapshot; - if (!currentSnapshot) return null; return (
    - {state.kind === 'loading' ?

    正在刷新消息…

    : null} - {state.kind === 'error' ? ( -
    -

    刷新失败,正在显示上次已知的消息数据。

    -