修复:CI 部署环境与 ref 错配、迁移碎片化、图片意图 source_span、章节物化脏版式、会话历史与本地语音不一致
新增:TTS 上传 COS 与分片、章节 reading_segments 物化与快照、markdown 清洗、会话消息 repository、语音 store 重构与相关测试
This commit is contained in:
197
app-expo/src/features/voice/voice-segment-store.ts
Normal file
197
app-expo/src/features/voice/voice-segment-store.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { executeSql, querySql } from '@/core/storage/sqlite';
|
||||
|
||||
import type { SegmentOutboxEntry, SegmentOutboxStatus } from './types';
|
||||
|
||||
const CREATE_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS segment_outbox (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id TEXT NOT NULL,
|
||||
voice_session_id TEXT NOT NULL,
|
||||
segment_index INTEGER NOT NULL,
|
||||
file_uri TEXT NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
let initialized = false;
|
||||
|
||||
async function migrateLegacyVoiceMessageLocal(): Promise<void> {
|
||||
const rows = await querySql<{ name: string }>(
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name='voice_message_local'`,
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
const now = Date.now();
|
||||
await executeSql(
|
||||
`INSERT OR IGNORE INTO segment_outbox (conversation_id, voice_session_id, segment_index, file_uri, duration_ms, status, retry_count, created_at)
|
||||
SELECT conversation_id, voice_session_id, 0, file_uri, duration_ms, 'sent', 0, ?
|
||||
FROM voice_message_local`,
|
||||
[now],
|
||||
);
|
||||
await executeSql(`DROP TABLE IF EXISTS voice_message_local`);
|
||||
}
|
||||
|
||||
async function ensureTable(): Promise<void> {
|
||||
if (initialized) return;
|
||||
await executeSql(CREATE_TABLE_SQL);
|
||||
await executeSql(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS uq_segment_outbox_voice_session_segment
|
||||
ON segment_outbox(voice_session_id, segment_index)`,
|
||||
);
|
||||
await migrateLegacyVoiceMessageLocal();
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
function mapRow(row: Record<string, unknown>): SegmentOutboxEntry {
|
||||
return {
|
||||
id: row.id as number,
|
||||
conversationId: row.conversation_id as string,
|
||||
voiceSessionId: row.voice_session_id as string,
|
||||
segmentIndex: row.segment_index as number,
|
||||
fileUri: row.file_uri as string,
|
||||
durationMs: row.duration_ms as number,
|
||||
status: row.status as SegmentOutboxStatus,
|
||||
retryCount: row.retry_count as number,
|
||||
createdAt: row.created_at as number,
|
||||
};
|
||||
}
|
||||
|
||||
export interface VoicePlaybackRow {
|
||||
voiceSessionId: string;
|
||||
fileUri: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地语音分段:outbox(pending→sent)与可回放元数据共用同一张表。
|
||||
* 音频文件在文件系统;`status=sent` 行保留用于按 voice_session_id 关联 REST 历史。
|
||||
*/
|
||||
export const voiceSegmentStore = {
|
||||
async enqueue(
|
||||
entry: Omit<
|
||||
SegmentOutboxEntry,
|
||||
'id' | 'status' | 'retryCount' | 'createdAt'
|
||||
>,
|
||||
): Promise<number> {
|
||||
await ensureTable();
|
||||
const result = await executeSql(
|
||||
`INSERT INTO segment_outbox (conversation_id, voice_session_id, segment_index, file_uri, duration_ms, status, retry_count, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?)`,
|
||||
[
|
||||
entry.conversationId,
|
||||
entry.voiceSessionId,
|
||||
entry.segmentIndex,
|
||||
entry.fileUri,
|
||||
entry.durationMs,
|
||||
Date.now(),
|
||||
],
|
||||
);
|
||||
return result.lastInsertRowId;
|
||||
},
|
||||
|
||||
/** 发送成功后写入(或覆盖)同一条 voice+segment,用于回放与 outbox 终态统一 */
|
||||
async recordSentSegment(entry: {
|
||||
conversationId: string;
|
||||
voiceSessionId: string;
|
||||
segmentIndex?: number;
|
||||
fileUri: string;
|
||||
durationMs: number;
|
||||
}): Promise<void> {
|
||||
await ensureTable();
|
||||
const segmentIndex = entry.segmentIndex ?? 0;
|
||||
const now = Date.now();
|
||||
await executeSql(
|
||||
`INSERT INTO segment_outbox (conversation_id, voice_session_id, segment_index, file_uri, duration_ms, status, retry_count, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'sent', 0, ?)
|
||||
ON CONFLICT(voice_session_id, segment_index) DO UPDATE SET
|
||||
conversation_id = excluded.conversation_id,
|
||||
file_uri = excluded.file_uri,
|
||||
duration_ms = excluded.duration_ms,
|
||||
status = 'sent',
|
||||
retry_count = 0`,
|
||||
[
|
||||
entry.conversationId,
|
||||
entry.voiceSessionId,
|
||||
segmentIndex,
|
||||
entry.fileUri,
|
||||
entry.durationMs,
|
||||
now,
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
async listPlaybackForConversation(
|
||||
conversationId: string,
|
||||
): Promise<VoicePlaybackRow[]> {
|
||||
await ensureTable();
|
||||
const rows = await querySql<Record<string, unknown>>(
|
||||
`SELECT voice_session_id AS voiceSessionId, file_uri AS fileUri, duration_ms AS durationMs
|
||||
FROM segment_outbox WHERE conversation_id = ? AND status = 'sent'`,
|
||||
[conversationId],
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
voiceSessionId: r.voiceSessionId as string,
|
||||
fileUri: r.fileUri as string,
|
||||
durationMs: r.durationMs as number,
|
||||
}));
|
||||
},
|
||||
|
||||
async getPending(conversationId?: string): Promise<SegmentOutboxEntry[]> {
|
||||
await ensureTable();
|
||||
const sql = conversationId
|
||||
? `SELECT * FROM segment_outbox WHERE status = 'pending' AND conversation_id = ? ORDER BY created_at ASC`
|
||||
: `SELECT * FROM segment_outbox WHERE status = 'pending' ORDER BY created_at ASC`;
|
||||
const params = conversationId ? [conversationId] : [];
|
||||
const rows = await querySql<Record<string, unknown>>(sql, params);
|
||||
return rows.map(mapRow);
|
||||
},
|
||||
|
||||
async markSending(id: number): Promise<void> {
|
||||
await ensureTable();
|
||||
await executeSql(
|
||||
`UPDATE segment_outbox SET status = 'sending' WHERE id = ?`,
|
||||
[id],
|
||||
);
|
||||
},
|
||||
|
||||
async markSent(id: number): Promise<void> {
|
||||
await ensureTable();
|
||||
await executeSql(`UPDATE segment_outbox SET status = 'sent' WHERE id = ?`, [
|
||||
id,
|
||||
]);
|
||||
},
|
||||
|
||||
async markFailed(id: number): Promise<void> {
|
||||
await ensureTable();
|
||||
await executeSql(
|
||||
`UPDATE segment_outbox SET status = 'failed', retry_count = retry_count + 1 WHERE id = ?`,
|
||||
[id],
|
||||
);
|
||||
},
|
||||
|
||||
async resetFailed(conversationId?: string): Promise<void> {
|
||||
await ensureTable();
|
||||
const sql = conversationId
|
||||
? `UPDATE segment_outbox SET status = 'pending' WHERE status = 'failed' AND conversation_id = ?`
|
||||
: `UPDATE segment_outbox SET status = 'pending' WHERE status = 'failed'`;
|
||||
const params = conversationId ? [conversationId] : [];
|
||||
await executeSql(sql, params);
|
||||
},
|
||||
|
||||
async clearConversation(conversationId: string): Promise<void> {
|
||||
await ensureTable();
|
||||
await executeSql(`DELETE FROM segment_outbox WHERE conversation_id = ?`, [
|
||||
conversationId,
|
||||
]);
|
||||
},
|
||||
|
||||
/** @internal 测试用 */
|
||||
_resetForTest(): void {
|
||||
initialized = false;
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** @deprecated 使用 voiceSegmentStore */
|
||||
export const segmentOutbox = voiceSegmentStore;
|
||||
Reference in New Issue
Block a user