diff --git a/frontend/.env.example b/frontend/.env.example index 591fdf0..e646a25 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,4 +1,12 @@ -# Android emulator: 10.0.2.2 reaches the host machine. +# Android emulator talks to the host machine via 10.0.2.2 EXPO_PUBLIC_API_URL=http://10.0.2.2:8000/api/v1 -# Baidu Maps browser-side AK with JavaScript API v4 enabled. + +# Baidu Maps browser-side AK with JavaScript API v4 enabled EXPO_PUBLIC_BAIDU_MAP_AK=your-baidu-map-browser-ak + +# Real backend WebSocket URL. Leave empty to use the in-process fake transport in development. +# Example: ws://10.0.2.2:8000/ws/v1 +EXPO_PUBLIC_WS_URL= + +# Force the fake WebSocket even if EXPO_PUBLIC_WS_URL is set (dev only). +# EXPO_PUBLIC_USE_FAKE_WS=true \ No newline at end of file diff --git a/frontend/__tests__/app/session/SessionProvider.test.tsx b/frontend/__tests__/app/session/SessionProvider.test.tsx new file mode 100644 index 0000000..7217def --- /dev/null +++ b/frontend/__tests__/app/session/SessionProvider.test.tsx @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { act, render } from '@testing-library/react-native'; +import { View } from 'react-native'; + +import { SessionProvider } from '@/app/session/SessionProvider'; +import type { DeviceIdStore } from '@/infrastructure/storage/deviceIdStore'; + +class TestWebSocket { + static readonly OPEN = 1; + static instances: TestWebSocket[] = []; + + binaryType = ''; + readyState = 0; + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + onclose: (() => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + + constructor(_url: string) { + TestWebSocket.instances.push(this); + } + + send(_data: string) {} + + close() { + this.readyState = 3; + this.onclose?.(); + } + + open() { + this.readyState = TestWebSocket.OPEN; + this.onopen?.(); + } +} + +const deviceIdStore: DeviceIdStore = { + get: async () => 'device_test', + set: async () => undefined, +}; + +describe('SessionProvider reconnect lifecycle', () => { + const originalWebSocket = globalThis.WebSocket; + const originalUrl = process.env.EXPO_PUBLIC_WS_URL; + + beforeEach(() => { + jest.useFakeTimers(); + TestWebSocket.instances = []; + process.env.EXPO_PUBLIC_WS_URL = 'ws://test.invalid/ws'; + Object.defineProperty(globalThis, 'WebSocket', { + configurable: true, + value: TestWebSocket, + writable: true, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + if (originalUrl === undefined) delete process.env.EXPO_PUBLIC_WS_URL; + else process.env.EXPO_PUBLIC_WS_URL = originalUrl; + Object.defineProperty(globalThis, 'WebSocket', { + configurable: true, + value: originalWebSocket, + writable: true, + }); + }); + + it('schedules only one first retry after the session handshake times out', async () => { + const view = render( + + + , + ); + + await act(async () => undefined); + expect(TestWebSocket.instances).toHaveLength(1); + + await act(async () => { + TestWebSocket.instances[0]?.open(); + await Promise.resolve(); + }); + + act(() => { + jest.advanceTimersByTime(10_000); + jest.advanceTimersByTime(999); + }); + expect(TestWebSocket.instances).toHaveLength(1); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(TestWebSocket.instances).toHaveLength(2); + + view.unmount(); + }); +}); diff --git a/frontend/__tests__/app/session/sessionEndpoint.test.ts b/frontend/__tests__/app/session/sessionEndpoint.test.ts new file mode 100644 index 0000000..457c936 --- /dev/null +++ b/frontend/__tests__/app/session/sessionEndpoint.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from '@jest/globals'; + +import { buildSessionWebSocketUrl, resolveSessionUserId } from '@/app/session/sessionEndpoint'; + +describe('session endpoint compatibility', () => { + it('adds the persisted device id to the backend WebSocket URL', () => { + expect(buildSessionWebSocketUrl('ws://127.0.0.1:8000/ws', 'device 1')).toBe( + 'ws://127.0.0.1:8000/ws?device_id=device+1', + ); + }); + + it('replaces a stale device id while preserving other query parameters', () => { + expect( + buildSessionWebSocketUrl('wss://api.example.com/ws?token=test&device_id=stale', 'current'), + ).toBe('wss://api.example.com/ws?token=test&device_id=current'); + }); + + it('rejects non-WebSocket URLs', () => { + expect(() => buildSessionWebSocketUrl('http://127.0.0.1:8000/ws', 'device_1')).toThrow( + '必须使用 ws:// 或 wss://', + ); + }); + + it('uses the MVP backend user when session.ready omits user_id', () => { + expect(resolveSessionUserId(undefined)).toBe('default_user'); + expect(resolveSessionUserId(' user_1 ')).toBe('user_1'); + }); +}); diff --git a/frontend/__tests__/dev/fakes/schedule/scheduleConflicts.test.ts b/frontend/__tests__/dev/fakes/schedule/scheduleConflicts.test.ts new file mode 100644 index 0000000..1a66b56 --- /dev/null +++ b/frontend/__tests__/dev/fakes/schedule/scheduleConflicts.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from '@jest/globals'; + +import type { Schedule, ScheduleUpsertCommand } from '@/contracts'; + +import { upsertSchedule } from '@/dev/fakes/schedule/scheduleConflicts'; + +function makeSchedule(overrides: Partial = {}): Schedule { + return { + id: 'schedule_existing', + user_id: 'default_user', + source_mode: 'manual', + schedule_type: 'time', + status: 'scheduled', + title: '已有日程', + notes: null, + start_time: new Date(2026, 6, 29, 9, 0).toISOString(), + end_time: new Date(2026, 6, 29, 10, 0).toISOString(), + timezone: 'Asia/Shanghai', + location_name: null, + location_address: null, + latitude: null, + longitude: null, + geofence_radius_meters: 100, + geofence_armed: false, + time_remind_offset_minutes: 15, + time_triggered_at: null, + geo_triggered_at: null, + system_schedule_ref_id: null, + system_alarm_ref_id: null, + created_at: new Date(2026, 6, 20, 10, 0).toISOString(), + updated_at: new Date(2026, 6, 20, 10, 0).toISOString(), + ...overrides, + }; +} + +function makeCommand(startHour: number, endHour: number | null): ScheduleUpsertCommand { + return { + type: 'schedule.upsert.command', + request_id: 'req_test', + payload: { + source_mode: 'manual', + schedule_type: 'time', + title: '新日程', + start_time: new Date(2026, 6, 29, startHour, 0).toISOString(), + end_time: endHour === null ? null : new Date(2026, 6, 29, endHour, 0).toISOString(), + }, + }; +} + +describe('upsertSchedule conflict detection', () => { + const existing = [makeSchedule()]; + + it('flags a schedule that overlaps an existing one', () => { + const result = upsertSchedule(makeCommand(9, 10), existing, 'schedule_new'); + expect(result.payload.conflicts.map((conflict) => conflict.schedule_id)).toEqual([ + 'schedule_existing', + ]); + }); + + it('flags a partial overlap', () => { + const result = upsertSchedule(makeCommand(9, 11), existing, 'schedule_new'); + expect(result.payload.conflicts).toHaveLength(1); + }); + + it('treats touching boundaries as a conflict', () => { + const result = upsertSchedule(makeCommand(10, 11), existing, 'schedule_new'); + expect(result.payload.conflicts).toHaveLength(1); + }); + + it('reports nothing for a non-overlapping slot', () => { + const result = upsertSchedule(makeCommand(11, 12), existing, 'schedule_new'); + expect(result.payload.conflicts).toHaveLength(0); + }); + + it('does not conflict a schedule with itself while editing', () => { + const result = upsertSchedule(makeCommand(9, 10), existing, 'schedule_existing'); + expect(result.payload.conflicts).toHaveLength(0); + }); + + it('ignores deleted schedules', () => { + const deleted = [makeSchedule({ status: 'deleted' })]; + const result = upsertSchedule(makeCommand(9, 10), deleted, 'schedule_new'); + expect(result.payload.conflicts).toHaveLength(0); + }); + + it('reports nothing when the new schedule has no start time', () => { + const command: ScheduleUpsertCommand = { + type: 'schedule.upsert.command', + request_id: 'req_test', + payload: { + source_mode: 'manual', + schedule_type: 'location', + title: '地点日程', + start_time: null, + }, + }; + const result = upsertSchedule(command, existing, 'schedule_new'); + expect(result.payload.conflicts).toHaveLength(0); + }); + + it('echoes the request id and schedule id back', () => { + const result = upsertSchedule(makeCommand(14, 15), existing, 'schedule_new'); + expect(result.request_id).toBe('req_test'); + expect(result.payload.schedule_id).toBe('schedule_new'); + }); + + it('ignores unparseable start times on the new command', () => { + const command: ScheduleUpsertCommand = { + type: 'schedule.upsert.command', + request_id: 'req_test', + payload: { + source_mode: 'manual', + schedule_type: 'time', + title: '坏时间', + start_time: 'not-a-date', + end_time: null, + }, + }; + expect(upsertSchedule(command, existing, 'schedule_new').payload.conflicts).toHaveLength(0); + }); + + it('ignores existing items whose times do not parse', () => { + const broken = [makeSchedule({ id: 'broken', start_time: 'bad', end_time: 'also-bad' })]; + expect( + upsertSchedule(makeCommand(9, 10), broken, 'schedule_new').payload.conflicts, + ).toHaveLength(0); + }); + + it('inherits geofence_armed from the existing schedule when omitted', () => { + const armed = [makeSchedule({ id: 'schedule_existing', geofence_armed: true })]; + const command = makeCommand(14, 15); + delete (command.payload as { geofence_armed?: boolean }).geofence_armed; + expect(upsertSchedule(command, armed, 'schedule_existing').payload.geofence_armed).toBe(true); + }); +}); diff --git a/frontend/__tests__/fixtures.ts b/frontend/__tests__/fixtures.ts new file mode 100644 index 0000000..4195d90 --- /dev/null +++ b/frontend/__tests__/fixtures.ts @@ -0,0 +1,30 @@ +import type { Schedule } from '@/contracts'; + +export function makeSchedule(overrides: Partial = {}): Schedule { + return { + id: 'schedule_test', + user_id: 'default_user', + source_mode: 'manual', + schedule_type: 'time', + status: 'scheduled', + title: '测试日程', + notes: null, + start_time: new Date(2026, 6, 29, 9, 5).toISOString(), + end_time: null, + timezone: 'Asia/Shanghai', + location_name: null, + location_address: null, + latitude: null, + longitude: null, + geofence_radius_meters: 100, + geofence_armed: false, + time_remind_offset_minutes: 15, + time_triggered_at: null, + geo_triggered_at: null, + system_schedule_ref_id: null, + system_alarm_ref_id: null, + created_at: new Date(2026, 6, 20, 10, 0).toISOString(), + updated_at: new Date(2026, 6, 20, 10, 0).toISOString(), + ...overrides, + }; +} diff --git a/frontend/__tests__/infrastructure/storage/deviceIdStore.test.ts b/frontend/__tests__/infrastructure/storage/deviceIdStore.test.ts new file mode 100644 index 0000000..6da89a4 --- /dev/null +++ b/frontend/__tests__/infrastructure/storage/deviceIdStore.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; + +const mockGetInfoAsync = jest.fn(async () => ({ exists: true })); +const mockReadAsStringAsync = jest.fn(async () => ' device_existing '); +const mockWriteAsStringAsync = jest.fn(async () => undefined); +const mockFileSystem = { + documentDirectory: 'file:///documents/', + getInfoAsync: mockGetInfoAsync, + readAsStringAsync: mockReadAsStringAsync, + writeAsStringAsync: mockWriteAsStringAsync, +}; + +jest.mock('react-native', () => ({ + Platform: { OS: 'android' }, +})); + +jest.mock('expo', () => ({ + requireOptionalNativeModule: jest.fn(() => mockFileSystem), +})); + +import { createDeviceIdStore } from '@/infrastructure/storage/deviceIdStore'; + +describe('native deviceIdStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetInfoAsync.mockResolvedValue({ exists: true }); + mockReadAsStringAsync.mockResolvedValue(' device_existing '); + }); + + it('passes the options arguments required by Expo SDK 57 native methods', async () => { + const store = await createDeviceIdStore(); + + await expect(store.get()).resolves.toBe('device_existing'); + await store.set('device_updated'); + + const path = 'file:///documents/.timeflow-device-id'; + expect(mockGetInfoAsync).toHaveBeenCalledWith(path, {}); + expect(mockReadAsStringAsync).toHaveBeenCalledWith(path, {}); + expect(mockWriteAsStringAsync).toHaveBeenCalledWith(path, 'device_updated', {}); + }); +}); diff --git a/frontend/__tests__/infrastructure/ws/WsClient.test.ts b/frontend/__tests__/infrastructure/ws/WsClient.test.ts new file mode 100644 index 0000000..0de8916 --- /dev/null +++ b/frontend/__tests__/infrastructure/ws/WsClient.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from '@jest/globals'; + +import { FakeWsServer } from '@/dev/fakes/FakeWsServer'; +import { WsClient } from '@/infrastructure/ws/WsClient'; +import { makeSchedule } from '@test/fixtures'; + +describe('WsClient + FakeWsServer', () => { + it('completes session hello and lists schedules', async () => { + const server = new FakeWsServer({ userId: 'user_test' }); + const client = new WsClient({ fakeHandler: server.handleMessage }); + server.attach(client); + await client.connect(); + + const ready = new Promise((resolve) => { + client.onMessage((message) => { + if (!(message instanceof ArrayBuffer) && message.type === 'session.ready') { + resolve(); + } + }); + }); + client.sendJson({ + type: 'session.hello', + device_id: 'device_1', + app_version: '1.0.0', + }); + await ready; + + const list = await client.request({ + type: 'schedule.list.query', + request_id: 'req_list_1', + payload: { status: null, include_deleted: false }, + }); + expect(list.type).toBe('schedule.list.result'); + expect(list.ok).toBe(true); + client.close(); + }); + + it('upserts a schedule through fake WS', async () => { + const server = new FakeWsServer({ userId: 'user_test' }); + const client = new WsClient({ fakeHandler: server.handleMessage }); + server.attach(client); + await client.connect(); + + const response = await client.request({ + type: 'schedule.upsert.command', + request_id: 'req_up_1', + payload: { + source_mode: 'manual', + schedule_type: 'time', + title: '测试', + start_time: new Date(Date.now() + 3_600_000).toISOString(), + }, + }); + expect(response.ok).toBe(true); + expect(server.getSchedules()).toHaveLength(1); + client.close(); + }); + + it('acks delete without losing synchronous fake replies', async () => { + const server = new FakeWsServer({ + userId: 'user_test', + seedSchedules: [makeSchedule({ id: 'del_sync', user_id: 'user_test' })], + }); + const client = new WsClient({ fakeHandler: server.handleMessage }); + server.attach(client); + await client.connect(); + + const ack = await client.request({ + type: 'schedule.deleted', + request_id: 'req_del_sync', + schedule_id: 'del_sync', + deleted: true, + timestamp: new Date().toISOString(), + }); + expect(ack.type).toBe('schedule.deleted.ack'); + expect(ack.ok).toBe(true); + expect(server.getSchedules()[0]?.status).toBe('deleted'); + client.close(); + }); + + it('updates status to done without marking deleted', async () => { + const server = new FakeWsServer({ + userId: 'user_test', + seedSchedules: [makeSchedule({ id: 'status_1', user_id: 'user_test' })], + }); + const client = new WsClient({ fakeHandler: server.handleMessage }); + server.attach(client); + await client.connect(); + + const response = await client.request({ + type: 'schedule.status.command', + request_id: 'req_status_1', + payload: { schedule_id: 'status_1', status: 'done' }, + }); + expect(response.ok).toBe(true); + expect(server.getSchedules()[0]?.status).toBe('done'); + client.close(); + }); + + it('uses the production location report and uncorrelated ack shapes', async () => { + const server = new FakeWsServer({ userId: 'user_test' }); + const client = new WsClient({ fakeHandler: server.handleMessage }); + server.attach(client); + await client.connect(); + + const ack = new Promise>((resolve) => { + client.onMessage((message) => { + if (!(message instanceof ArrayBuffer) && message.type === 'location.report.ack') { + resolve(message); + } + }); + }); + client.sendJson({ + type: 'location.report', + schedule_scope: 'current', + latitude: 31.236305, + longitude: 121.480237, + accuracy: 12, + timestamp: '2026-07-31T10:00:00Z', + }); + + await expect(ack).resolves.toEqual({ type: 'location.report.ack', ok: true }); + client.close(); + }); + + it('rejects pending requests immediately when the remote socket closes unexpectedly', async () => { + class TestWebSocket { + static readonly OPEN = 1; + static instance: TestWebSocket | null = null; + + binaryType = ''; + readyState = 0; + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + onclose: (() => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + + constructor(_url: string) { + TestWebSocket.instance = this; + } + + send(_data: string | ArrayBuffer) {} + + close() { + this.readyState = 3; + this.onclose?.(); + } + + open() { + this.readyState = TestWebSocket.OPEN; + this.onopen?.(); + } + + closeUnexpectedly() { + this.readyState = 3; + this.onclose?.(); + } + } + + const originalWebSocket = globalThis.WebSocket; + Object.defineProperty(globalThis, 'WebSocket', { + configurable: true, + value: TestWebSocket, + writable: true, + }); + + try { + const client = new WsClient({ url: 'ws://test.invalid', requestTimeoutMs: 60_000 }); + const connecting = client.connect(); + TestWebSocket.instance?.open(); + await connecting; + + const pending = client.request({ + type: 'schedule.list.query', + request_id: 'req_disconnect', + payload: { status: null, include_deleted: false }, + }); + TestWebSocket.instance?.closeUnexpectedly(); + + await expect(pending).rejects.toThrow('WebSocket closed unexpectedly'); + expect(client.getConnectionStatus()).toBe('closed'); + } finally { + Object.defineProperty(globalThis, 'WebSocket', { + configurable: true, + value: originalWebSocket, + writable: true, + }); + } + }); +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 5220552..d9e20b2 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -8,18 +8,148 @@ module.exports = defineConfig([ expoConfig, prettierConfig, { - ignores: ['dist/**', '.expo/**', 'web-build/**', 'node_modules/**'], + ignores: [ + 'dist/**', + '.expo/**', + 'web-build/**', + 'node_modules/**', + '_backup_*/**', + '_shots/**', + '**/*.apk', + 'android/**', + 'ios/**', + 'modules/**', + ], }, { rules: { 'no-console': ['warn', { allow: ['warn', 'error'] }], }, }, + { + files: ['src/features/**/*.ts', 'src/features/**/*.tsx'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@/app', + '@/app/*', + '@/features/*', + '@/features/*/*', + '@/infrastructure', + '@/infrastructure/*', + ], + message: + 'Features may depend only on contracts/shared and their own relative modules. Compose adapters in app.', + }, + ], + }, + ], + }, + }, + { + files: [ + 'src/contracts/**/*.ts', + 'src/contracts/**/*.tsx', + 'src/shared/**/*.ts', + 'src/shared/**/*.tsx', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@/app', + '@/app/*', + '@/dev', + '@/dev/*', + '@/features/*', + '@/features/*/*', + '@/infrastructure', + '@/infrastructure/*', + ], + message: 'Contracts/shared must not depend on app, dev, features, or infrastructure.', + }, + ], + }, + ], + }, + }, + { + files: ['src/infrastructure/**/*.ts', 'src/infrastructure/**/*.tsx'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@/app', '@/app/*', '@/dev', '@/dev/*', '@/features/*', '@/features/*/*'], + message: 'Infrastructure must not depend on app, dev, or feature implementations.', + }, + ], + }, + ], + }, + }, + { + files: ['src/dev/**/*.ts', 'src/dev/**/*.tsx'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@/features/*', '@/features/*/*'], + message: 'Development fakes must not depend on feature-private implementations.', + }, + ], + }, + ], + }, + }, + { + files: ['src/app/**/*.ts', 'src/app/**/*.tsx', 'App.tsx'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@/features/*/hooks/*', + '@/features/*/components/*', + '@/features/*/data/*', + '@/features/*/domain/*', + '@/features/*/application/*', + '@/features/*/calendar/*', + '@/features/*/editor/*', + '@/features/*/detail/*', + '@/features/*/screens/*', + '@/features/*/model/*', + '@/features/*/location/*', + '@/features/*/native/*', + '@/features/*/presentation/*', + '@/features/*/services/*', + '@/features/*/utils/*', + ], + message: 'Import from @/features/ public entry instead of deep paths.', + }, + ], + }, + ], + }, + }, { files: ['__tests__/**/*.ts', '__tests__/**/*.tsx'], rules: { '@typescript-eslint/no-require-imports': 'off', 'import/first': 'off', + 'no-restricted-imports': 'off', }, }, { @@ -30,4 +160,12 @@ module.exports = defineConfig([ }, }, }, + { + files: ['react-native.config.js'], + languageOptions: { + globals: { + __dirname: 'readonly', + }, + }, + }, ]); diff --git a/frontend/src/app/session/SessionProvider.tsx b/frontend/src/app/session/SessionProvider.tsx new file mode 100644 index 0000000..027ed40 --- /dev/null +++ b/frontend/src/app/session/SessionProvider.tsx @@ -0,0 +1,288 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; + +import type { ConnectionStatus, SessionHello, SessionReady, WsJsonMessage } from '@/contracts'; + +import { FakeWsServer } from '@/dev/fakes/FakeWsServer'; +import { getOrCreateDeviceId, type DeviceIdStore } from '@/infrastructure/storage/deviceIdStore'; +import { WsClient } from '@/infrastructure/ws/WsClient'; + +import { buildSessionWebSocketUrl, resolveSessionUserId } from './sessionEndpoint'; + +export type SessionTransportMode = 'remote' | 'fake' | 'unavailable'; + +export type SessionContextValue = { + deviceId: string | null; + userId: string | null; + connectionStatus: ConnectionStatus; + transportMode: SessionTransportMode; + /** 每次成功 session.ready 递增,供 schedule 重连后 resync。 */ + sessionEpoch: number; + client: WsClient | null; + fakeServer: FakeWsServer | null; + connectionError: string | null; +}; + +const SessionContext = createContext(null); + +function resolveWsUrl(): string | null { + const fromEnv = + typeof process !== 'undefined' ? process.env.EXPO_PUBLIC_WS_URL?.trim() : undefined; + return fromEnv || null; +} + +function resolveAllowFake(): boolean { + const flag = + typeof process !== 'undefined' ? process.env.EXPO_PUBLIC_USE_FAKE_WS?.trim() : undefined; + const isDevBuild = typeof __DEV__ !== 'undefined' && __DEV__; + // Fake 只能进入开发/调试构建;release 即使误带变量也必须拒绝。 + if (!isDevBuild) return false; + if (flag === '0' || flag === 'false') return false; + return flag === '1' || flag === 'true' || flag == null; +} + +const RECONNECT_BASE_MS = 1000; +const RECONNECT_MAX_MS = 30_000; +const SESSION_READY_TIMEOUT_MS = 10_000; +const UNAVAILABLE_CONNECTION_ERROR = + '缺少 EXPO_PUBLIC_WS_URL。开发环境可设置 EXPO_PUBLIC_USE_FAKE_WS=true 使用进程内 Fake。'; + +function isSessionReady(message: WsJsonMessage, deviceId: string): message is SessionReady { + return ( + message.type === 'session.ready' && + message.device_id === deviceId && + (message.user_id == null || + (typeof message.user_id === 'string' && message.user_id.trim().length > 0)) && + typeof message.server_time === 'string' + ); +} + +export function SessionProvider({ + children, + deviceIdStore, +}: { + children: ReactNode; + deviceIdStore?: DeviceIdStore; +}) { + const [deviceId, setDeviceId] = useState(null); + const [userId, setUserId] = useState(null); + const [connectionStatus, setConnectionStatus] = useState('idle'); + const [sessionEpoch, setSessionEpoch] = useState(0); + const [connectionError, setConnectionError] = useState(null); + + const url = useMemo(() => resolveWsUrl(), []); + const allowFake = useMemo(() => resolveAllowFake(), []); + const transportMode: SessionTransportMode = url ? 'remote' : allowFake ? 'fake' : 'unavailable'; + + const remoteEndpoint = useMemo(() => { + if (transportMode !== 'remote' || !url || !deviceId) { + return { url: null, error: null }; + } + try { + return { url: buildSessionWebSocketUrl(url, deviceId), error: null }; + } catch (error) { + return { + url: null, + error: error instanceof Error ? error.message : 'WebSocket 地址不合法', + }; + } + }, [deviceId, transportMode, url]); + + const { client, fakeServer } = useMemo(() => { + if (transportMode === 'unavailable') { + return { client: null as WsClient | null, fakeServer: null as FakeWsServer | null }; + } + if (transportMode === 'remote' && !remoteEndpoint.url) { + return { client: null as WsClient | null, fakeServer: null as FakeWsServer | null }; + } + const server = transportMode === 'fake' ? new FakeWsServer() : null; + const ws = new WsClient({ + url: transportMode === 'remote' ? remoteEndpoint.url : null, + fakeHandler: server ? server.handleMessage : undefined, + }); + server?.attach(ws); + return { client: ws, fakeServer: server }; + }, [remoteEndpoint.url, transportMode]); + + const reconnectAttempt = useRef(0); + const reconnectTimer = useRef | null>(null); + + useEffect(() => { + let cancelled = false; + void getOrCreateDeviceId(deviceIdStore) + .then((id) => { + if (!cancelled) setDeviceId(id); + }) + .catch((error: unknown) => { + if (cancelled) return; + setConnectionStatus('error'); + setConnectionError( + error instanceof Error ? error.message : '无法初始化设备身份,请检查原生存储配置', + ); + }); + return () => { + cancelled = true; + }; + }, [deviceIdStore]); + + useEffect(() => { + if (!client) return; + if (!deviceId) return; + + let cancelled = false; + let sessionReadyTimer: ReturnType | null = null; + + const clearReconnect = () => { + if (reconnectTimer.current) { + clearTimeout(reconnectTimer.current); + reconnectTimer.current = null; + } + }; + + const clearSessionReadyTimer = () => { + if (!sessionReadyTimer) return; + clearTimeout(sessionReadyTimer); + sessionReadyTimer = null; + }; + + const sendHello = () => { + const hello: SessionHello = { + type: 'session.hello', + device_id: deviceId, + app_version: '1.0.0', + }; + clearSessionReadyTimer(); + sessionReadyTimer = setTimeout(() => { + if (cancelled) return; + setConnectionStatus('error'); + setConnectionError('会话握手超时,请检查服务连接'); + client.close(); + }, SESSION_READY_TIMEOUT_MS); + client.sendJson(hello); + }; + + const connectOnce = async () => { + try { + setConnectionError(null); + await client.connect(); + if (cancelled) return; + reconnectAttempt.current = 0; + sendHello(); + } catch (error) { + if (cancelled) return; + clearSessionReadyTimer(); + client.close(); + setConnectionStatus('error'); + setConnectionError(error instanceof Error ? error.message : 'WebSocket 连接失败'); + } + }; + + const scheduleReconnect = () => { + if (cancelled || transportMode === 'fake') return; + clearReconnect(); + const attempt = reconnectAttempt.current; + const delay = Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS); + reconnectAttempt.current = attempt + 1; + setConnectionStatus('reconnecting'); + reconnectTimer.current = setTimeout(() => { + void connectOnce(); + }, delay); + }; + + const unsubscribeStatus = client.onStatus((status) => { + if (cancelled) return; + // WebSocket open 只代表 socket 可用;session.ready 才代表身份握手完成。 + setConnectionStatus(status === 'ready' ? 'connecting' : status); + if (status === 'closed') { + clearSessionReadyTimer(); + scheduleReconnect(); + } + }); + + const unsubscribeMessage = client.onMessage((message) => { + if (message instanceof ArrayBuffer) return; + if (isSessionReady(message, deviceId)) { + clearSessionReadyTimer(); + setUserId(resolveSessionUserId(message.user_id)); + setSessionEpoch((value) => value + 1); + setConnectionStatus('ready'); + setConnectionError(null); + return; + } + if (message.type === 'session.error') { + clearSessionReadyTimer(); + setConnectionStatus('error'); + setConnectionError( + typeof message.error === 'object' && + message.error !== null && + 'message' in message.error && + typeof message.error.message === 'string' + ? message.error.message + : '会话握手失败', + ); + } + }); + + void connectOnce(); + + return () => { + cancelled = true; + clearReconnect(); + clearSessionReadyTimer(); + unsubscribeStatus(); + unsubscribeMessage(); + client.close(); + }; + }, [client, deviceId, transportMode]); + + const effectiveConnectionStatus: ConnectionStatus = + transportMode === 'unavailable' || remoteEndpoint.error || (connectionError && !client) + ? 'error' + : client + ? connectionStatus + : 'connecting'; + const effectiveConnectionError = + transportMode === 'unavailable' + ? UNAVAILABLE_CONNECTION_ERROR + : (remoteEndpoint.error ?? connectionError); + + const value = useMemo( + () => ({ + deviceId, + userId, + connectionStatus: effectiveConnectionStatus, + transportMode, + sessionEpoch, + client, + fakeServer, + connectionError: effectiveConnectionError, + }), + [ + client, + deviceId, + effectiveConnectionError, + effectiveConnectionStatus, + fakeServer, + sessionEpoch, + transportMode, + userId, + ], + ); + + return {children}; +} + +export function useSession(): SessionContextValue { + const value = useContext(SessionContext); + if (!value) { + throw new Error('useSession must be used within SessionProvider'); + } + return value; +} diff --git a/frontend/src/app/session/sessionEndpoint.ts b/frontend/src/app/session/sessionEndpoint.ts new file mode 100644 index 0000000..6b49fd8 --- /dev/null +++ b/frontend/src/app/session/sessionEndpoint.ts @@ -0,0 +1,15 @@ +const LEGACY_BACKEND_USER_ID = 'default_user'; + +export function buildSessionWebSocketUrl(baseUrl: string, deviceId: string): string { + const url = new URL(baseUrl); + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + throw new Error('EXPO_PUBLIC_WS_URL 必须使用 ws:// 或 wss://'); + } + url.searchParams.set('device_id', deviceId); + return url.toString(); +} + +/** Current MVP backend owns a single default user but omits it from session.ready. */ +export function resolveSessionUserId(userId: unknown): string { + return typeof userId === 'string' && userId.trim() ? userId.trim() : LEGACY_BACKEND_USER_ID; +} diff --git a/frontend/src/contracts/envelope.ts b/frontend/src/contracts/envelope.ts new file mode 100644 index 0000000..f7ff041 --- /dev/null +++ b/frontend/src/contracts/envelope.ts @@ -0,0 +1,25 @@ +export type ApiError = { + code: string; + message: string; + details: Record | null; +}; + +export type WsRequest = { + type: TType; + request_id: string; + payload: TPayload; +}; + +export type WsSuccess = { + type: TType; + request_id: string; + ok: true; + payload: TPayload; +}; + +export type WsFailure = { + type: TType; + request_id: string; + ok: false; + error: ApiError; +}; diff --git a/frontend/src/contracts/index.ts b/frontend/src/contracts/index.ts new file mode 100644 index 0000000..5b1d678 --- /dev/null +++ b/frontend/src/contracts/index.ts @@ -0,0 +1,6 @@ +export type * from './envelope'; +export type * from './reminder'; +export type * from './schedule'; +export type * from './session'; +export type * from './transport'; +export type * from './voice'; diff --git a/frontend/src/contracts/reminder.ts b/frontend/src/contracts/reminder.ts new file mode 100644 index 0000000..7936d25 --- /dev/null +++ b/frontend/src/contracts/reminder.ts @@ -0,0 +1,48 @@ +import type { ApiError } from './envelope'; + +/** + * 提醒通道协议(预留)。 + * 当前前端尚未接入 WS 提醒控制 / TTS 音频流;类型仅作与后端对齐的权威契约文档。 + * 接入客户端前请勿在业务层依赖这些消息。 + */ + +/** 服务端下发提醒控制;具体展示通道由客户端根据前后台自行决定。 */ +export type ReminderControl = { + type: 'reminder.control'; + schedule_id: string; + reason: string; + action: 'show'; +}; + +export type ReminderControlAck = + | { type: 'reminder.control.ack'; schedule_id: string; ok: true } + | { type: 'reminder.control.ack'; schedule_id: string; ok: false; error: ApiError }; + +/** 提醒 TTS 音频流开始;随后通过同一 WebSocket 连接发送 Binary Frame。 */ +export type ReminderAudioStart = { + type: 'reminder.audio.start'; + schedule_id: string; + stream_id: string; + audio_format: 'mp3'; +}; + +export type ReminderAudioEnd = { + type: 'reminder.audio.end'; + schedule_id: string; + stream_id: string; +}; + +export type ReminderAudioAck = + | { + type: 'reminder.audio.ack'; + schedule_id: string; + stream_id: string; + ok: true; + } + | { + type: 'reminder.audio.ack'; + schedule_id: string; + stream_id: string; + ok: false; + error: ApiError; + }; diff --git a/frontend/src/contracts/schedule.ts b/frontend/src/contracts/schedule.ts new file mode 100644 index 0000000..9978407 --- /dev/null +++ b/frontend/src/contracts/schedule.ts @@ -0,0 +1,134 @@ +import type { ApiError, WsFailure, WsRequest, WsSuccess } from './envelope'; + +export type ScheduleSourceMode = 'manual' | 'voice'; +export type ScheduleType = 'time' | 'location'; +export type ScheduleStatus = 'scheduled' | 'done' | 'deleted'; + +export type Schedule = { + id: string; + user_id: string; + source_mode: ScheduleSourceMode; + schedule_type: ScheduleType; + status: ScheduleStatus; + title: string; + notes: string | null; + start_time: string | null; + end_time: string | null; + timezone: string | null; + location_name: string | null; + location_address: string | null; + latitude: number | null; + longitude: number | null; + geofence_radius_meters: number; + geofence_armed: boolean; + time_remind_offset_minutes: number; + time_triggered_at: string | null; + geo_triggered_at: string | null; + system_schedule_ref_id: string | null; + system_alarm_ref_id: string | null; + created_at: string; + updated_at: string; +}; + +export type ScheduleListQueryPayload = { + status: ScheduleStatus | null; + include_deleted: boolean; +}; + +export type ScheduleListQuery = WsRequest<'schedule.list.query', ScheduleListQueryPayload>; + +export type ScheduleListResultPayload = { + schedules: Schedule[]; +}; + +export type ScheduleListResult = WsSuccess<'schedule.list.result', ScheduleListResultPayload>; + +export type ScheduleListError = WsFailure<'schedule.list.error'>; +export type ScheduleListResponse = ScheduleListResult | ScheduleListError; + +export type ScheduleConflict = { + schedule_id: string; + title: string; + start_time: string; + end_time: string | null; +}; + +/** 草稿业务字段(创建/语音解析共用,不含 schedule_id / source_mode)。 */ +export type ScheduleDraftFields = { + schedule_type: ScheduleType; + title: string; + notes?: string | null; + start_time?: string | null; + end_time?: string | null; + timezone?: string | null; + location_name?: string | null; + location_address?: string | null; + latitude?: number | null; + longitude?: number | null; + geofence_radius_meters?: number | null; + geofence_armed?: boolean | null; + time_remind_offset_minutes?: number | null; +}; + +export type ScheduleUpsertPayload = ScheduleDraftFields & { + schedule_id?: string | null; + source_mode: ScheduleSourceMode; +}; + +export type ScheduleUpsertCommand = WsRequest<'schedule.upsert.command', ScheduleUpsertPayload>; + +export type ScheduleUpsertResultPayload = { + schedule_id: string; + schedule_type: ScheduleType; + status: ScheduleStatus; + conflicts: ScheduleConflict[]; + geofence_armed: boolean; +}; + +export type ScheduleUpsertResult = WsSuccess<'schedule.upsert.result', ScheduleUpsertResultPayload>; + +export type ScheduleUpsertError = WsFailure<'schedule.upsert.error'>; +export type ScheduleUpsertResponse = ScheduleUpsertResult | ScheduleUpsertError; + +/** 完成 / 恢复为已安排;与删除语义分离。 */ +export type ScheduleStatusUpdatePayload = { + schedule_id: string; + status: Extract; +}; + +export type ScheduleStatusUpdateCommand = WsRequest< + 'schedule.status.command', + ScheduleStatusUpdatePayload +>; + +export type ScheduleStatusUpdateResultPayload = { + schedule_id: string; + status: ScheduleStatus; +}; + +export type ScheduleStatusUpdateResult = WsSuccess< + 'schedule.status.result', + ScheduleStatusUpdateResultPayload +>; + +export type ScheduleStatusUpdateError = WsFailure<'schedule.status.error'>; +export type ScheduleStatusUpdateResponse = ScheduleStatusUpdateResult | ScheduleStatusUpdateError; + +/** 客户端确认删除后,通知服务端取消监听与提醒(仅删除,不含完成)。 */ +export type ScheduleDeleted = { + type: 'schedule.deleted'; + request_id: string; + schedule_id: string; + deleted: true; + timestamp: string; +}; + +export type ScheduleDeletedAck = + | { type: 'schedule.deleted.ack'; request_id?: string; schedule_id: string; ok: true } + | { + type: 'schedule.deleted.ack'; + request_id?: string; + schedule_id: string; + ok: false; + error: ApiError; + }; diff --git a/frontend/src/contracts/session.ts b/frontend/src/contracts/session.ts new file mode 100644 index 0000000..98bdf44 --- /dev/null +++ b/frontend/src/contracts/session.ts @@ -0,0 +1,34 @@ +import type { ApiError } from './envelope'; + +export type SessionHello = { + type: 'session.hello'; + device_id: string; + app_version: string; +}; + +export type SessionReady = { + type: 'session.ready'; + device_id: string; + /** Newer servers return this; the current single-user MVP server omits it. */ + user_id?: string; + server_time: string; +}; + +export type SessionError = { + type: 'session.error'; + ok: false; + error: ApiError; +}; + +export type LocationReport = { + type: 'location.report'; + schedule_scope: 'current'; + latitude: number; + longitude: number; + accuracy: number; + timestamp: string; +}; + +export type LocationReportAck = + | { type: 'location.report.ack'; ok: true } + | { type: 'location.report.ack'; ok: false; error: ApiError }; diff --git a/frontend/src/contracts/transport.ts b/frontend/src/contracts/transport.ts new file mode 100644 index 0000000..9530433 --- /dev/null +++ b/frontend/src/contracts/transport.ts @@ -0,0 +1,8 @@ +export type ConnectionStatus = + 'idle' | 'connecting' | 'ready' | 'reconnecting' | 'closed' | 'error'; + +export type WsJsonMessage = { + type: string; + request_id?: string; + [key: string]: unknown; +}; diff --git a/frontend/src/contracts/voice.ts b/frontend/src/contracts/voice.ts new file mode 100644 index 0000000..07c8ad1 --- /dev/null +++ b/frontend/src/contracts/voice.ts @@ -0,0 +1,54 @@ +import type { ApiError, WsFailure, WsRequest, WsSuccess } from './envelope'; +import type { ScheduleDraftFields } from './schedule'; + +export type VoiceStreamStartPayload = { + audio_format: 'pcm_s16le'; + sample_rate_hz: number; + channels: number; +}; + +export type VoiceStreamStartCommand = WsRequest<'voice.stream.start', VoiceStreamStartPayload>; + +export type VoiceStreamEndPayload = { + stream_id: string; +}; + +export type VoiceStreamEndCommand = WsRequest<'voice.stream.end', VoiceStreamEndPayload>; + +export type VoiceStreamError = WsFailure<'voice.stream.error'>; + +export type VoiceStreamStarted = WsSuccess< + 'voice.stream.started', + { stream_id: string; job_id: string } +>; + +export type VoiceStreamEnded = WsSuccess< + 'voice.stream.ended', + { stream_id: string; job_id: string; status: 'processing' } +>; + +export type VoiceStreamStartResponse = VoiceStreamStarted | VoiceStreamError; +export type VoiceStreamEndResponse = VoiceStreamEnded | VoiceStreamError; + +export type VoiceParseDraft = Omit; + +export type VoiceParseReadyResult = { + type: 'voice.parse.result'; + request_id: string; + job_id: string; + status: 'ready_for_confirmation'; + draft: VoiceParseDraft; + missing_fields: string[]; + ambiguous_fields: string[]; + needs_confirmation: true; +}; + +export type VoiceParseFailedResult = { + type: 'voice.parse.result'; + request_id: string; + job_id: string; + status: 'failed'; + error: ApiError; +}; + +export type VoiceParseResultMessage = VoiceParseReadyResult | VoiceParseFailedResult; diff --git a/frontend/src/dev/fakes/FakeWsServer.ts b/frontend/src/dev/fakes/FakeWsServer.ts new file mode 100644 index 0000000..f3d1c85 --- /dev/null +++ b/frontend/src/dev/fakes/FakeWsServer.ts @@ -0,0 +1,255 @@ +import type { + LocationReport, + LocationReportAck, + Schedule, + ScheduleDeleted, + ScheduleDeletedAck, + ScheduleListQuery, + ScheduleListResponse, + ScheduleStatusUpdateCommand, + ScheduleStatusUpdateResponse, + ScheduleUpsertCommand, + ScheduleUpsertResponse, + SessionHello, + SessionReady, + VoiceParseResultMessage, + VoiceStreamEndCommand, + VoiceStreamStartCommand, + VoiceStreamStartResponse, + VoiceStreamEndResponse, + WsJsonMessage, +} from '@/contracts'; +import type { WsClient } from '@/infrastructure/ws/WsClient'; + +import { upsertSchedule } from './schedule/scheduleConflicts'; +import { createFakeSchedule } from './schedule/scheduleFactory'; + +type FakeWsServerOptions = { + userId?: string; + seedSchedules?: Schedule[]; +}; + +/** + * 进程内 Fake WS:只依赖 contracts + WsClient,供本地与测试使用。 + */ +export class FakeWsServer { + private readonly schedules = new Map(); + private readonly userId: string; + private client: WsClient | null = null; + private voiceJobCounter = 0; + + constructor(options: FakeWsServerOptions = {}) { + this.userId = options.userId ?? 'user_fake_1'; + for (const schedule of options.seedSchedules ?? []) { + this.schedules.set(schedule.id, schedule); + } + } + + attach(client: WsClient): void { + this.client = client; + } + + getUserId(): string { + return this.userId; + } + + getSchedules(): Schedule[] { + return [...this.schedules.values()]; + } + + handleMessage = async (message: WsJsonMessage | ArrayBuffer): Promise => { + if (message instanceof ArrayBuffer) { + return; + } + + switch (message.type) { + case 'session.hello': + this.handleSessionHello(message as SessionHello); + return; + case 'schedule.list.query': + this.handleList(message as ScheduleListQuery); + return; + case 'schedule.upsert.command': + this.handleUpsert(message as ScheduleUpsertCommand); + return; + case 'schedule.status.command': + this.handleStatusUpdate(message as ScheduleStatusUpdateCommand); + return; + case 'schedule.deleted': + this.handleDeleted(message as ScheduleDeleted); + return; + case 'location.report': + this.handleLocationReport(message as LocationReport); + return; + case 'voice.stream.start': + this.handleVoiceStart(message as VoiceStreamStartCommand); + return; + case 'voice.stream.end': + this.handleVoiceEnd(message as VoiceStreamEndCommand); + return; + default: + return; + } + }; + + private reply(message: WsJsonMessage): void { + this.client?.emitFromServer(message); + } + + private handleSessionHello(message: SessionHello): void { + const ready: SessionReady = { + type: 'session.ready', + device_id: message.device_id, + user_id: this.userId, + server_time: new Date().toISOString(), + }; + this.reply(ready); + } + + private handleList(message: ScheduleListQuery): void { + const includeDeleted = message.payload.include_deleted; + const statusFilter = message.payload.status; + const schedules = [...this.schedules.values()].filter((item) => { + if (!includeDeleted && item.status === 'deleted') return false; + if (statusFilter && item.status !== statusFilter) return false; + return true; + }); + const response: ScheduleListResponse = { + type: 'schedule.list.result', + request_id: message.request_id, + ok: true, + payload: { schedules }, + }; + this.reply(response); + } + + private handleUpsert(message: ScheduleUpsertCommand): void { + const scheduleId = message.payload.schedule_id ?? `schedule_${Date.now()}`; + const current = [...this.schedules.values()]; + const existing = this.schedules.get(scheduleId) ?? null; + const result = upsertSchedule(message, current, scheduleId); + const entity = createFakeSchedule({ + draft: { ...message.payload, schedule_id: scheduleId }, + scheduleId, + userId: this.userId, + status: result.payload.status, + geofenceArmed: result.payload.geofence_armed, + existing, + }); + this.schedules.set(scheduleId, entity); + const response: ScheduleUpsertResponse = result; + this.reply(response); + this.reply({ + type: 'schedule.updated', + schedule: entity, + }); + } + + private handleStatusUpdate(message: ScheduleStatusUpdateCommand): void { + const existing = this.schedules.get(message.payload.schedule_id); + if (!existing || existing.status === 'deleted') { + const response: ScheduleStatusUpdateResponse = { + type: 'schedule.status.error', + request_id: message.request_id, + ok: false, + error: { + code: 'schedule_not_found', + message: '日程不存在或已删除', + details: null, + }, + }; + this.reply(response); + return; + } + + const next: Schedule = { + ...existing, + status: message.payload.status, + updated_at: new Date().toISOString(), + }; + this.schedules.set(next.id, next); + const response: ScheduleStatusUpdateResponse = { + type: 'schedule.status.result', + request_id: message.request_id, + ok: true, + payload: { schedule_id: next.id, status: next.status }, + }; + this.reply(response); + this.reply({ type: 'schedule.updated', schedule: next }); + } + + private handleDeleted(message: ScheduleDeleted): void { + const existing = this.schedules.get(message.schedule_id); + if (existing) { + const next: Schedule = { + ...existing, + status: 'deleted', + updated_at: new Date().toISOString(), + }; + this.schedules.set(message.schedule_id, next); + this.reply({ type: 'schedule.updated', schedule: next }); + } + const ack: ScheduleDeletedAck = { + type: 'schedule.deleted.ack', + request_id: message.request_id, + schedule_id: message.schedule_id, + ok: true, + }; + this.reply(ack); + } + + private handleLocationReport(_message: LocationReport): void { + const ack: LocationReportAck = { + type: 'location.report.ack', + ok: true, + }; + this.reply(ack); + } + + private handleVoiceStart(message: VoiceStreamStartCommand): void { + this.voiceJobCounter += 1; + const streamId = `stream_${this.voiceJobCounter}`; + const jobId = `job_${this.voiceJobCounter}`; + const response: VoiceStreamStartResponse = { + type: 'voice.stream.started', + request_id: message.request_id, + ok: true, + payload: { stream_id: streamId, job_id: jobId }, + }; + this.reply(response); + } + + private handleVoiceEnd(message: VoiceStreamEndCommand): void { + const jobId = `job_${this.voiceJobCounter || 1}`; + const response: VoiceStreamEndResponse = { + type: 'voice.stream.ended', + request_id: message.request_id, + ok: true, + payload: { + stream_id: message.payload.stream_id, + job_id: jobId, + status: 'processing', + }, + }; + this.reply(response); + + const parseResult: VoiceParseResultMessage = { + type: 'voice.parse.result', + request_id: message.request_id, + job_id: jobId, + status: 'ready_for_confirmation', + draft: { + schedule_type: 'time', + title: '语音创建的日程', + start_time: new Date(Date.now() + 3_600_000).toISOString(), + end_time: null, + timezone: 'Asia/Shanghai', + time_remind_offset_minutes: 0, + }, + missing_fields: [], + ambiguous_fields: [], + needs_confirmation: true, + }; + setTimeout(() => this.reply(parseResult), 0); + } +} diff --git a/frontend/src/dev/fakes/schedule/scheduleConflicts.ts b/frontend/src/dev/fakes/schedule/scheduleConflicts.ts new file mode 100644 index 0000000..d2612b5 --- /dev/null +++ b/frontend/src/dev/fakes/schedule/scheduleConflicts.ts @@ -0,0 +1,61 @@ +import type { + Schedule, + ScheduleConflict, + ScheduleUpsertCommand, + ScheduleUpsertResult, +} from '@/contracts'; + +/** 检测与现有日程的时间重叠冲突(排除自身与已删除项)。 */ +function findScheduleConflicts( + command: ScheduleUpsertCommand, + schedules: Schedule[], + currentScheduleId: string, +): ScheduleConflict[] { + const { end_time: endTime, start_time: startTime } = command.payload; + if (!startTime) return []; + + const start = new Date(startTime).getTime(); + const end = endTime ? new Date(endTime).getTime() : start; + if (!Number.isFinite(start) || !Number.isFinite(end)) return []; + + return schedules + .filter((item) => item.id !== currentScheduleId && item.status !== 'deleted' && item.start_time) + .filter((item) => { + const itemStart = new Date(item.start_time!).getTime(); + const itemEnd = item.end_time ? new Date(item.end_time).getTime() : itemStart; + return ( + Number.isFinite(itemStart) && + Number.isFinite(itemEnd) && + start <= itemEnd && + itemStart <= end + ); + }) + .map((item) => ({ + schedule_id: item.id, + title: item.title, + start_time: item.start_time!, + end_time: item.end_time, + })); +} + +/** 拼装本地 upsert 结果(含冲突列表与 geofence 默认值)。 */ +export function upsertSchedule( + command: ScheduleUpsertCommand, + current: Schedule[], + scheduleId: string, +): ScheduleUpsertResult { + const existingSchedule = current.find((item) => item.id === scheduleId); + + return { + type: 'schedule.upsert.result', + request_id: command.request_id, + ok: true, + payload: { + schedule_id: scheduleId, + schedule_type: command.payload.schedule_type, + status: 'scheduled', + conflicts: findScheduleConflicts(command, current, scheduleId), + geofence_armed: command.payload.geofence_armed ?? existingSchedule?.geofence_armed ?? true, + }, + }; +} diff --git a/frontend/src/dev/fakes/schedule/scheduleFactory.ts b/frontend/src/dev/fakes/schedule/scheduleFactory.ts new file mode 100644 index 0000000..89863e8 --- /dev/null +++ b/frontend/src/dev/fakes/schedule/scheduleFactory.ts @@ -0,0 +1,39 @@ +import type { Schedule, ScheduleStatus, ScheduleUpsertPayload } from '@/contracts'; + +export function createFakeSchedule(input: { + draft: ScheduleUpsertPayload; + scheduleId: string; + userId: string; + status: ScheduleStatus; + geofenceArmed: boolean; + existing?: Schedule | null; +}): Schedule { + const { draft, existing } = input; + const now = new Date().toISOString(); + + return { + id: input.scheduleId, + user_id: existing?.user_id ?? input.userId, + source_mode: draft.source_mode, + schedule_type: draft.schedule_type, + status: input.status, + title: draft.title, + notes: draft.notes ?? null, + start_time: draft.start_time ?? null, + end_time: draft.end_time ?? null, + timezone: draft.timezone ?? null, + location_name: draft.location_name ?? null, + location_address: draft.location_address ?? null, + latitude: draft.latitude ?? null, + longitude: draft.longitude ?? null, + geofence_radius_meters: draft.geofence_radius_meters ?? existing?.geofence_radius_meters ?? 100, + geofence_armed: input.geofenceArmed, + time_remind_offset_minutes: draft.time_remind_offset_minutes ?? 0, + time_triggered_at: existing?.time_triggered_at ?? null, + geo_triggered_at: existing?.geo_triggered_at ?? null, + system_schedule_ref_id: existing?.system_schedule_ref_id ?? null, + system_alarm_ref_id: existing?.system_alarm_ref_id ?? null, + created_at: existing?.created_at ?? now, + updated_at: now, + }; +} diff --git a/frontend/src/infrastructure/storage/deviceIdStore.ts b/frontend/src/infrastructure/storage/deviceIdStore.ts new file mode 100644 index 0000000..21d998e --- /dev/null +++ b/frontend/src/infrastructure/storage/deviceIdStore.ts @@ -0,0 +1,150 @@ +import { Platform } from 'react-native'; +import { requireOptionalNativeModule } from 'expo'; + +const DEVICE_ID_KEY = 'timeflow.device_id'; + +export type DeviceIdStore = { + get(): Promise; + set(value: string): Promise; +}; + +type ExpoFileSystemLike = { + documentDirectory: string | null; + getInfoAsync: (path: string, options: Record) => Promise<{ exists: boolean }>; + readAsStringAsync: (path: string, options: Record) => Promise; + writeAsStringAsync: ( + path: string, + contents: string, + options: Record, + ) => Promise; +}; + +/** + * Native storage is deliberately a hard dependency at runtime. Falling back + * to an in-process map makes the device identity change on every cold start, + * which is worse than refusing to establish a session. + */ +export class DeviceIdPersistenceUnavailableError extends Error { + constructor(message = '原生设备存储不可用,无法持久化 device_id') { + super(message); + this.name = 'DeviceIdPersistenceUnavailableError'; + } +} + +function webStore(): DeviceIdStore { + return { + async get() { + if (typeof localStorage === 'undefined') { + throw new DeviceIdPersistenceUnavailableError('浏览器 localStorage 不可用'); + } + return localStorage.getItem(DEVICE_ID_KEY); + }, + async set(value) { + if (typeof localStorage === 'undefined') { + throw new DeviceIdPersistenceUnavailableError('浏览器 localStorage 不可用'); + } + localStorage.setItem(DEVICE_ID_KEY, value); + }, + }; +} + +/** Test/host adapter. Production code must inject a persistent implementation. */ +export function memoryStore(seed: Map = new Map()): DeviceIdStore { + return { + async get() { + return seed.get(DEVICE_ID_KEY) ?? null; + }, + async set(value) { + seed.set(DEVICE_ID_KEY, value); + }, + }; +} + +let nativeFileStore: DeviceIdStore | null = null; +let nativeFileStorePromise: Promise | null = null; + +function loadExpoFileSystem(): ExpoFileSystemLike | null { + // Expo Go and a custom Expo runtime expose the legacy module under this + // name. The lookup is static and Metro-visible; there is no hidden import + // or optional JS package that can silently disappear from a release bundle. + return requireOptionalNativeModule('ExponentFileSystem'); +} + +function createNativeFileStore(FileSystem = loadExpoFileSystem()): DeviceIdStore { + const base = FileSystem?.documentDirectory; + if ( + !FileSystem || + !base || + typeof FileSystem.getInfoAsync !== 'function' || + typeof FileSystem.readAsStringAsync !== 'function' || + typeof FileSystem.writeAsStringAsync !== 'function' + ) { + throw new DeviceIdPersistenceUnavailableError( + 'ExpoFileSystem 原生模块未链接;请在构建中声明 expo-file-system 或注入 DeviceIdStore', + ); + } + const path = `${base}.timeflow-device-id`; + return { + async get() { + let info: { exists: boolean }; + try { + info = await FileSystem.getInfoAsync(path, {}); + } catch (error) { + throw new DeviceIdPersistenceUnavailableError( + `读取 device_id 失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (!info.exists) return null; + try { + const value = await FileSystem.readAsStringAsync(path, {}); + return value.trim() || null; + } catch (error) { + throw new DeviceIdPersistenceUnavailableError( + `读取 device_id 失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, + async set(value) { + try { + await FileSystem.writeAsStringAsync(path, value, {}); + } catch (error) { + throw new DeviceIdPersistenceUnavailableError( + `写入 device_id 失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, + }; +} + +export async function createDeviceIdStore(): Promise { + if (Platform.OS === 'web') { + return webStore(); + } + if (!nativeFileStore) { + nativeFileStorePromise ??= Promise.resolve() + .then(() => createNativeFileStore()) + .catch((error) => { + // A transient host/module setup failure should not poison all later + // attempts during the same app lifetime. + nativeFileStorePromise = null; + throw error; + }); + nativeFileStore = await nativeFileStorePromise; + } + return nativeFileStore; +} + +export async function getOrCreateDeviceId(store?: DeviceIdStore): Promise { + const backend = store ?? (await createDeviceIdStore()); + const existing = await backend.get(); + if (existing) return existing; + const next = `device_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + await backend.set(next); + // Read back once so a best-effort native implementation cannot report a + // successful write while losing the value (for example, a denied file URI). + const persisted = await backend.get(); + if (persisted !== next) { + throw new DeviceIdPersistenceUnavailableError('device_id 写入后校验失败'); + } + return next; +} diff --git a/frontend/src/infrastructure/ws/WsClient.ts b/frontend/src/infrastructure/ws/WsClient.ts new file mode 100644 index 0000000..45c9b88 --- /dev/null +++ b/frontend/src/infrastructure/ws/WsClient.ts @@ -0,0 +1,250 @@ +import type { ConnectionStatus, WsJsonMessage } from '@/contracts'; + +export type { ConnectionStatus, WsJsonMessage } from '@/contracts'; + +export type WsClientOptions = { + /** 真实后端地址;为空则走 fakeHandler 进程内通道。 */ + url?: string | null; + /** 无 URL 时的进程内消息处理器(由 SessionProvider 在显式 Fake 模式下注入)。 */ + fakeHandler?: (message: WsJsonMessage | ArrayBuffer) => void | Promise; + requestTimeoutMs?: number; +}; + +type PendingRequest = { + resolve: (value: WsJsonMessage) => void; + reject: (error: Error) => void; + timer: ReturnType; + isMatch: (response: WsJsonMessage) => boolean; +}; + +/** + * 契约对齐的 WS 客户端:按 request_id 等待响应,支持订阅推送与二进制帧。 + * 无 URL 时走进程内 Fake 通道,便于本地与单测。 + */ +export class WsClient { + private socket: WebSocket | null = null; + private readonly pending = new Map(); + private readonly listeners = new Set<(message: WsJsonMessage | ArrayBuffer) => void>(); + private readonly statusListeners = new Set<(status: ConnectionStatus) => void>(); + private status: ConnectionStatus = 'idle'; + private readonly requestTimeoutMs: number; + private readonly url: string | null; + private readonly fakeHandler?: (message: WsJsonMessage | ArrayBuffer) => void | Promise; + private fakeReply: ((message: WsJsonMessage | ArrayBuffer) => void) | null = null; + private intentionallyClosed = false; + /** Invalidates callbacks belonging to a socket that has been replaced. */ + private socketGeneration = 0; + private connecting: { generation: number; reject: (error: Error) => void } | null = null; + + constructor(options: WsClientOptions = {}) { + this.url = options.url?.trim() || null; + this.fakeHandler = options.fakeHandler; + this.requestTimeoutMs = options.requestTimeoutMs ?? 15_000; + } + + getConnectionStatus(): ConnectionStatus { + return this.status; + } + + onStatus(listener: (status: ConnectionStatus) => void): () => void { + this.statusListeners.add(listener); + listener(this.status); + return () => this.statusListeners.delete(listener); + } + + onMessage(listener: (message: WsJsonMessage | ArrayBuffer) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async connect(): Promise { + this.intentionallyClosed = false; + if (!this.url) { + this.setStatus('connecting'); + this.fakeReply = (message) => this.dispatch(message); + this.setStatus('ready'); + return; + } + + this.setStatus(this.status === 'ready' ? 'reconnecting' : 'connecting'); + await new Promise((resolve, reject) => { + const generation = ++this.socketGeneration; + const socket = new WebSocket(this.url!); + this.socket = socket; + this.connecting = { generation, reject }; + socket.binaryType = 'arraybuffer'; + socket.onopen = () => { + if (this.socket !== socket || this.socketGeneration !== generation) return; + this.connecting = null; + this.setStatus('ready'); + resolve(); + }; + socket.onerror = () => { + if (this.socket !== socket || this.socketGeneration !== generation) return; + const error = new Error('WebSocket connection failed'); + this.setStatus('error'); + this.rejectPending(error); + this.rejectConnecting(generation, error); + }; + socket.onclose = () => { + const isCurrent = this.socket === socket && this.socketGeneration === generation; + if (!isCurrent) return; + this.socket = null; + if (this.intentionallyClosed) return; + const error = new Error('WebSocket closed unexpectedly'); + this.rejectPending(error); + this.setStatus('closed'); + this.rejectConnecting(generation, new Error('WebSocket closed before becoming ready')); + }; + socket.onmessage = (event) => { + if (this.socket !== socket || this.socketGeneration !== generation) return; + if (typeof event.data === 'string') { + try { + this.dispatch(JSON.parse(event.data) as WsJsonMessage); + } catch { + // ignore malformed JSON + } + return; + } + if (event.data instanceof ArrayBuffer) { + this.dispatch(event.data); + } + }; + }); + } + + close(): void { + this.intentionallyClosed = true; + this.rejectConnecting(this.socketGeneration, new Error('WebSocket closed')); + this.socketGeneration += 1; + const socket = this.socket; + this.socket = null; + socket?.close(); + this.fakeReply = null; + this.rejectPending(new Error('WebSocket closed')); + this.setStatus('closed'); + } + + sendJson(message: WsJsonMessage): void { + if (!this.url) { + void Promise.resolve(this.fakeHandler?.(message)).catch((error) => { + if (message.request_id) { + this.rejectRequest( + message.request_id, + error instanceof Error ? error : new Error(String(error)), + ); + } + }); + return; + } + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket is not open'); + } + this.socket.send(JSON.stringify(message)); + } + + sendBinary(data: ArrayBuffer): void { + if (!this.url) { + void Promise.resolve(this.fakeHandler?.(data)).catch(() => undefined); + return; + } + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket is not open'); + } + this.socket.send(data); + } + + /** Fake 服务端向客户端推送。 */ + emitFromServer(message: WsJsonMessage | ArrayBuffer): void { + this.fakeReply?.(message); + } + + request( + message: WsJsonMessage & { request_id: string }, + isMatch: (response: WsJsonMessage) => boolean = (response) => + response.request_id === message.request_id, + ): Promise { + return new Promise((resolve, reject) => { + if (!message.request_id) { + reject(new Error(`Request id is required: ${message.type}`)); + return; + } + if (this.pending.has(message.request_id)) { + reject(new Error(`Duplicate request id: ${message.request_id}`)); + return; + } + const timer = setTimeout(() => { + this.pending.delete(message.request_id); + reject(new Error(`Request timed out: ${message.type}`)); + }, this.requestTimeoutMs); + + this.pending.set(message.request_id, { + isMatch, + resolve: (value) => { + clearTimeout(timer); + this.pending.delete(message.request_id); + resolve(value as T); + }, + reject: (error) => { + clearTimeout(timer); + this.pending.delete(message.request_id); + reject(error); + }, + timer, + }); + + try { + this.sendJson(message); + } catch (error) { + clearTimeout(timer); + this.pending.delete(message.request_id); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + private dispatch(message: WsJsonMessage | ArrayBuffer): void { + if (!(message instanceof ArrayBuffer)) { + for (const pending of this.pending.values()) { + if (pending.isMatch(message)) { + pending.resolve(message); + break; + } + } + } + for (const listener of this.listeners) { + listener(message); + } + } + + private rejectPending(error: Error): void { + const pending = [...this.pending.values()]; + this.pending.clear(); + for (const request of pending) { + clearTimeout(request.timer); + request.reject(error); + } + } + + private rejectRequest(requestId: string, error: Error): void { + const request = this.pending.get(requestId); + if (!request) return; + clearTimeout(request.timer); + this.pending.delete(requestId); + request.reject(error); + } + + private rejectConnecting(generation: number, error: Error): void { + const connecting = this.connecting; + if (!connecting || connecting.generation !== generation) return; + this.connecting = null; + connecting.reject(error); + } + + private setStatus(status: ConnectionStatus): void { + this.status = status; + for (const listener of this.statusListeners) { + listener(status); + } + } +} diff --git a/frontend/src/shared/types/geo.ts b/frontend/src/shared/types/geo.ts new file mode 100644 index 0000000..9bb1e6e --- /dev/null +++ b/frontend/src/shared/types/geo.ts @@ -0,0 +1,7 @@ +/** 中立地理点类型:feature 与地图 adapter 共用,不归属具体供应商。 */ +export type MapLocation = { + address: string; + latitude: number; + longitude: number; + name?: string; +}; diff --git a/frontend/src/shared/utils/requestId.ts b/frontend/src/shared/utils/requestId.ts new file mode 100644 index 0000000..f5aca22 --- /dev/null +++ b/frontend/src/shared/utils/requestId.ts @@ -0,0 +1,4 @@ +/** 生成 WS 请求 ID:`prefix_timestamp_random`。 */ +export function nextRequestId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +}