架构契约(冻结版 v1)
本文件是并行开发的唯一接口依据。任何模块之间的调用都必须遵守这里的签名与语义;
需要变更时先改本文件 +packages/core/src/types.ts,再改实现。
0. 目录布局
QQ/
├── packages/core/ @qqarchive/core 领域类型、配置、日志、键生成、错误
├── packages/db/ @qqarchive/db SQLite 存储层(迁移/仓储/FTS/备份恢复)
├── packages/import/ @qqarchive/import 导入器(QCE JSON/JSONL、TXT、通用)+ 媒体归档
├── packages/collect/ @qqarchive/collect 采集适配器(OneBot/NapCat)+ 调度与告警
├── packages/server/ @qqarchive/server HTTP API + 静态前端托管 + 认证
├── apps/cli/ CLI 入口(serve / import / collect / backup / restore / doctor)
├── web/ 前端静态资源(原生 JS,无构建步骤)
├── deploy/ systemd unit、示例配置、安装脚本
├── tests/ node:test 测试与测试数据生成器
└── docs/ 文档
运行方式:Node 24 直接运行 TypeScript(--experimental-strip-types),无编译产物、无打包步骤。
模块内部相对导入必须带 .ts 后缀(NodeNext + type stripping 要求)。
1. 技术选型与理由(已定,不再讨论)
| 关注点 | 选择 | 理由 |
|---|---|---|
| 运行时 | Node.js ≥ 22.5(本机 24.21) | 原生 node:sqlite、原生 TS 剥离 |
| 数据库 | 内置 node:sqlite(SQLite 3.53,WAL + FTS5 + JSON1) |
零原生编译依赖,Linux/Windows/ARM 都能装;已实测 FTS5/JSON1/WAL 可用 |
| 中文全文检索 | FTS5 unicode61 + 应用层 CJK 二元切分(buildSearchText) |
不依赖 trigram(老版本 SQLite 没有),中文子串可搜 |
| HTTP | node:http 自研极简路由 + node:crypto |
无第三方框架依赖,安全面小,便于审计 |
| 前端 | 原生 ES 模块 + 手写 CSS | 无构建步骤,node:test 可直接测前端纯函数 |
| 采集 | OneBot v11 HTTP(NapCat / LLOneBot 等实现) | 只读接口、可断点续传、不注入不 hook |
| 测试 | node:test + node:assert/strict |
内置,零依赖 |
依赖策略:生产依赖为 0。 所有功能用 Node 内置模块实现。这是可审计、可长期运行的最优解;
唯一外部程序依赖是可选的 ffmpeg(视频缩略图 / 转码),缺失时功能降级而不是报错。
2. @qqarchive/core 公共 API
// 类型(packages/core/src/types.ts):见文件,全部为契约
// 工具(packages/core/src/ids.ts)
sha256Hex(input: string | Uint8Array): string
sessionKeyOf(chatType: string, peerUid: string): string // "<chatType>:<peerUid>"
messageKeyOf(input: {msgId?, ts, senderUid?, senderUin?, text?, resources?, seq?}): string
safeFilename(name: string, fallbackExt?: string): string
formatTime(ts: number): string
parseTimeString(s: string | null | undefined): number | null
buildSearchText(text: string): string // CJK 二元切分
bigrams(seg: string): string
escapeFtsQuery(query: string): string
escapeHtml(s: string): string
// 配置(packages/core/src/config.ts)
loadConfig(configPath?: string): LoadedConfig
validateConfig(cfg: AppConfig): string[]
DEFAULT_CONFIG: AppConfig
// 日志(packages/core/src/logger.ts)
createLogger(opts?: LoggerOptions): Logger
new Logger(opts).child(scope): ScopedLogger // debug/info/warn/error/fail
// 错误(packages/core/src/errors.ts)
class AppError extends Error { code: AppErrorCode; status: number; details?: unknown }
badRequest / unauthorized / forbidden / notFound / unsupportedFormat /
mediaMissing / mediaCorrupt / collectorUnavailable / internal
3. @qqarchive/db 公共 API(仓储层)
openDatabase(opts: { path: string; logger?: ScopedLogger }): Db
class Db {
readonly raw: DatabaseSync; // 逃生舱,仅供本包内部与迁移使用
readonly path: string;
close(): void;
schemaVersion(): number;
health(): { ok: boolean; schemaVersion: number };
// 账号 / 会话 / 联系人
ensureAccount(a: {uid: string; uin?: string; displayName?: string}): void;
ensureSession(s: NormalizedSession): SessionRecord; // 不存在则建,存在则补充 name/uin
listSessions(q: {accountUid?: string; chatType?: ChatType; keyword?: string; page?: number; pageSize?: number; sort?: 'recent'|'messages'|'name'}): Page<SessionRecord>;
getSession(id: number): SessionRecord | null;
getSessionByKey(accountUid: string, sessionKey: string): SessionRecord | null;
touchSessionStats(sessionId: number): void;
// 消息写入(幂等)
insertMessages(sessionId: number, accountUid: string, sessionKey: string,
msgs: NormalizedMessage[], opts: { batchId?: number; source: string }): InsertResult;
// InsertResult = { inserted: number; duplicate: number; failed: number; messageIds: number[]; errors: string[] }
// 查询
listMessages(q: {sessionId: number; from?: number; to?: number; senderUid?: string; msgType?: MessageType;
keyword?: string; direction?: Direction; before?: number; after?: number;
limit?: number; order?: 'asc'|'desc'; includeRecalled?: boolean}): { items: MessageRecord[]; hasMore: boolean };
getMessage(id: number): MessageRecord | null;
searchMessages(q: {keyword: string; accountUid?: string; sessionId?: number; chatType?: ChatType;
from?: number; to?: number; msgType?: MessageType; senderUid?: string;
page?: number; pageSize?: number}): Page<SearchHit>;
// 媒体
upsertMedia(input: MediaInput): { id: number; created: boolean };
linkMessageMedia(messageId: number, mediaId: number, ordinal: number, filename?: string | null, declaredSize?: number | null): void;
getMedia(id: number): MediaRow | null;
getMediaBySha(sha256: string): MediaRow | null;
// keyword 同时匹配文件名与**转写文本**;transcribed 过滤「是否已有转写」
listMedia(q: {kind?: MediaKind; status?: MediaStatus; sessionId?: number; from?: number; to?: number; keyword?: string;
transcribed?: boolean; page?: number; pageSize?: number}): Page<MediaRow & {sessionName: string | null;
messageId: number | null; transcript: string | null}>;
markMediaStatus(id: number, status: MediaStatus, failReason?: string | null): void;
mediaStats(): {total: number; present: number; missing: number; corrupt: number; bytes: number};
// 语音转写(media_transcripts,与 media 一对一,随 media 级联删除)
upsertTranscript(input: {mediaId: number; text: string; language?: string | null; engine: string;
model?: string | null; segments?: number | null; elapsedMs?: number | null}): void;
getTranscript(mediaId: number): TranscriptRow | null;
getTranscripts(mediaIds: number[]): Map<number, TranscriptRow>;
listUntranscribedAudio(limit: number): Array<{id: number; relPath: string; sha256: string; duration: number | null}>;
deleteTranscript(mediaId: number): number;
transcriptStats(): {total: number; chars: number; engines: Array<{engine: string; count: number}>};
/** 删除 ref_count=0 且文件存在的孤儿媒体,返回删除数 */
gcOrphanMedia(mediaDir: string, dryRun: boolean): { deleted: number; bytes: number };
// 任务 / 采集状态 / 备份
createJob(j: {kind: 'import'|'collect'|'backup'; format?: string; accountUid?: string; sourcePath?: string; options?: unknown}): number;
updateJob(id: number, patch: Partial<JobRecord>): void;
getJob(id: number): JobRecord | null;
listJobs(q?: {status?: string; kind?: string; limit?: number; page?: number}): Page<JobRecord>;
getCollectState(source: string, accountUid: string, sessionKey: string): CollectStateRow | null;
upsertCollectState(row: {source: string; accountUid: string; sessionKey: string; cursorTs?: number; cursorSeq?: string | null;
lastError?: string | null; ok?: boolean}): void;
listCollectStates(): CollectStateRow[];
createBackup(row: {path: string; kind: string}): number;
finishBackup(id: number, patch: Partial<BackupRecord>): void;
listBackups(limit?: number): BackupRecord[];
// 日志 / 统计 / 设置
appendLog(e: {level: LogLevel; scope: string; message: string; data?: unknown}): void;
listLogs(q?: {level?: LogLevel; scope?: string; limit?: number; page?: number; since?: string}): Page<LogRecord>;
pruneLogs(keepDays: number): number;
stats(): ArchiveStats;
getSetting(key: string): string | null;
setSetting(key: string, value: string): void;
allSettings(): Record<string, string>;
}
关键 DB 语义(写入方必须遵守)
insertMessages幂等:以(account_uid, session_key, msg_key)唯一约束判重;重复走duplicate++,
且不覆盖已有行(避免导出文件里的旧快照覆盖更完整的归档)。- FTS 在同一事务内同步写入:
messages_fts与messages_fts_bigram的 rowid 都等于messages.id。 session.message_count / first_message_at / last_message_at在批量插入后重算一次(touchSessionStats)。- 任何写操作都在事务里;失败整体回滚,部分失败记入
InsertResult.errors。 - 时间一律毫秒整数;
ts<=0视为无效,拒绝插入(计入 failed 并写日志)。
4. @qqarchive/import 公共 API
// 格式探测
detectFormat(filePath: string, head?: string): 'qce-json' | 'qce-jsonl' | 'qce-txt' | 'generic-jsonl' | 'unknown';
interface Importer {
readonly name: string;
readonly formats: string[];
/** 流式解析,逐批回调;返回统计。 */
run(ctx: ImportContext): Promise<ImportStats>;
}
interface ImportContext {
filePath: string;
accountUid: string; // 默认 'default'
onBatch(batch: ImportBatch): Promise<void>; // 由调用方写库
logger: ScopedLogger;
signal?: AbortSignal;
limit?: number; // 仅导入前 N 条(测试用)
mediaDir?: string; // 提供时同步归档媒体
mediaResolver?: MediaResolver; // 自定义媒体来源
}
interface ImportStats {
format: string; totalItems: number; batches: number; inserted: number; duplicate: number;
failed: number; mediaSaved: number; mediaMissing: number; durationMs: number; errors: string[];
}
// 入口
importFile(ctx: ImportContext): Promise<ImportStats> // 自动探测格式
listImporters(): Array<{name: string; formats: string[]}>
支持格式与来源:
| 格式 | 来源 | 说明 |
|---|---|---|
qce-json |
qq-chat-exporter JSON 导出(metadata/chatInfo/statistics/messages) |
主目标格式,字段见 docs/format-mapping.md |
qce-jsonl |
QCE chunked JSONL(每行一条消息,或包一层) | 大文件流式,逐行解析 |
qce-txt |
QCE TXT 导出 | 尽力解析;无法解析的行计入 errors 并保留原文 |
generic-jsonl |
自带规范:每行 NormalizedMessage 风格对象 |
供用户自行转换/其他工具接入 |
html |
聊天记录导出为网页(QCE viewer / 一般聊天页 HTML) | 手写扫描器,零 HTML 依赖;兼容 data-*、内嵌 JSON 与通用消息容器。探测保守:不像聊天页就不认,需 --format html 显式指定 |
媒体归档(packages/import/src/media.ts):
class MediaArchiver {
constructor(opts: {root: string; maxBytes: number; logger: ScopedLogger; ffmpegPath?: string});
/** 从候选来源归档一个媒体;返回 MediaInput(或失败标记)。 */
archive(candidates: {localPath?: string; url?: string; filename?: string; kind: MediaKind; declaredSize?: number}): Promise<ArchiveOutcome>;
/** 校验已存在的文件(sha256 比对),返回 present/corrupt/missing。 */
verify(relPath: string, expectedSha256: string): 'present'|'corrupt'|'missing';
ensureThumb(media: ThumbSource): Promise<string | null>; // 图片缩略图 / 视频抽帧
}
// ThumbSource = {status; kind; sha256; relPath}(MediaRow 与尚未入库的 MediaInput 都满足)
// commit() 落盘后**立即**调用 ensureThumb(),把 thumbRel 一起放进 MediaInput.thumbPath;
// 于是 import(runImport)与 collect(MediaArchiverLike.archive)两条链路都会生成缩略图,
// 无需任何调用方额外接线。ffmpeg 缺失或抽帧失败只是没有 thumbPath,不影响归档结果。
// ArchiveOutcome = {status:'present'|'missing'|'corrupt'; media?: MediaInput; reason?: string}
媒体规则(硬性)
- 落盘路径:
<mediaDir>/<kind>/<yyyy>/<mm>/<sha256前2位>/<sha256>.<ext>,内容寻址、天然去重、不覆盖。 - 先写
*.part再rename,保证不会出现半个文件被当成完整文件。 - 断点续传(HTTP Range):临时路径按
(url, dest)确定性派生,旁边写<file>.part.meta.json记录{url, finalUrl, etag, lastModified, totalBytes, written}。重跑时若残片与 sidecar 都在且 url 一致,
就发Range: bytes=<written>-;有强校验器(etag 优先于 last-modified)时带If-Range。206追加并要求Content-Range.start === offset;200说明服务端忽略了 Range → 截断重下;416→ 丢掉残片重下。maxBytes按总大小(不是本次增量)判断。
成功的 sha256 对完整重组后的文件计算,落盘成功后先删 sidecar 再原子rename。 - 必须计算并保存 sha256;
size与校验值一起入库。 - 原始文件不存在 / URL 404 →
status='missing'+ 明确fail_reason,绝不生成占位文件。 - 同 sha256 只存一份,
media.ref_count增加。
5. @qqarchive/collect 公共 API
interface Collector {
readonly kind: string;
/** 探活:/get_login_info + /get_status */
probe(): Promise<{ok: boolean; uin?: string; nickname?: string; error?: string}>;
/** 列出可见会话(群 + 好友)。 */
listSessions(): Promise<Array<{chatType: 'group'|'private'; peerUid: string; peerUin: string; name: string}>>;
/** 增量拉取一个会话自 since 起的消息,返回按时间升序的归一化消息。 */
fetchSince(session: CollectSession, sinceTs: number, opts: {pageSize: number; maxPages: number}): Promise<NormalizedMessage[]>;
/** 下载媒体(OneBot /get_image、/get_record、/get_file 或直链)。 */
fetchMedia(ref: ResourceRef): Promise<{data: Uint8Array; filename?: string} | null>;
/** 可选:展开一条合并转发(OneBot /get_forward_msg)。不实现则转发只留 [合并转发] 占位。 */
fetchForward?(id: string): Promise<ForwardNode[]>;
}
// ForwardNode = {senderUin?; senderName?; ts; text; msgType; elements?; resources: ResourceRef[]; forwardId?}
class OneBotCollector implements Collector { constructor(opts: {baseUrl: string; token?: string; fetchImpl?: typeof fetch}) }
class CollectRunner {
constructor(opts: {db: Db; collector: Collector; archiver?: MediaArchiver; config: CollectorConfig;
logger: ScopedLogger; now?: () => number});
/** 跑一轮采集(可并发多会话,但每会话串行)。返回本轮统计。 */
runOnce(): Promise<CollectRound>;
/** 启动定时循环;返回 stop()。 */
start(): () => Promise<void>;
/** 健康状态(供 /api/health 使用)。 */
health(): {ok: boolean; lastOkAt: string | null; lastError: string | null; consecutiveFailures: number};
}
// CollectRound = {sessions: number; fetched: number; inserted: number; duplicate: number; failed: number;
// mediaSaved: number; mediaMissing: number; errors: Array<{session: string; error: string}>;
// forwardMessages?: number; forwardFailed?: number} // 只在真有值时出现
采集规则
- 断点续传:游标 =
collect_state.cursor_ts,每成功写入一批后立即持久化(upsertCollectState)。 - 每轮先把
since = max(cursorTs - lookbackSec*1000, now - initialBackfillDays*86400_000)。 - 失败重试:指数退避(1s/2s/4s,最多 3 次),仍失败则记
collect_state.last_error与consecutive_failures,
超过alert.collectFailureThreshold触发告警。 - 合并转发展开:每条带
forwardId的消息调collector.fetchForward,递归展开(默认collector.forwardDepth=3层、collector.forwardMaxNodes=200个节点)成一个StoredForward树存进messages.extra.forward,转发内的图片/视频/文件会被一起归档并
按resources下标写message_media.ordinal。触顶或超预算时保留节点自身的forwardId
并置forwardTruncated=true(截断标记会冒泡到顶层,避免静默少存)。
拉取失败不影响消息本身落库,只丢弃展开结果、保留[合并转发]占位并记forwardFailed。forwardDepth=0可关闭展开。 - 采集不得因为单个会话失败而中断整轮。
- OneBot HTTP 只使用只读接口,不使用任何写消息接口。
6. @qqarchive/server 公共 API 与 HTTP 契约
createServer(opts: {config: AppConfig; db: Db; logger: ScopedLogger; collect?: CollectRunner;
repoRoot: string; version: string; startedAt: number}): {server: http.Server; close(): Promise<void>}
统一响应:成功 200 {"data": ...};错误 4xx/5xx {"error": "...", "code": "..."}。
所有 /api/* 除 /api/health、/api/auth/login 外都要求认证。
| 方法 | 路径 | 说明 | 认证 |
|---|---|---|---|
| GET | /api/health |
健康检查(含 db/media/collector/disk) | 否 |
| POST | /api/auth/login |
body {token} → {accessToken, expiresAt} |
否 |
| POST | /api/auth/logout |
注销当前会话 | 是 |
| GET | /api/auth/session |
当前会话信息 | 是 |
| GET | /api/stats |
ArchiveStats |
是 |
| GET | /api/sessions?chatType=&keyword=&page=&pageSize=&sort= |
Page<SessionRecord>(附每条 lastMessageText) |
是 |
| GET | /api/sessions/:id |
SessionRecord |
是 |
| GET | /api/sessions/:id/messages?before=&after=&limit=&keyword=&msgType=&direction= |
{items: MessageRecord[]; hasMore} |
是 |
| GET | /api/messages/:id |
MessageRecord(含 reply 预览与媒体) |
是 |
| GET | /api/search?q=&sessionId=&chatType=&msgType=&from=&to=&page=&pageSize= |
Page<SearchHit> |
是 |
| GET | /api/media?kind=&status=&sessionId=&from=&to=&page=&pageSize= |
Page<MediaListItem> |
是 |
| GET | /api/media/:id/raw |
原始文件流(支持 Range;Content-Disposition 依 allowDownload) |
是 |
| GET | /api/media/:id/thumb |
缩略图(不存在则 404,前端回退原图) | 是 |
| POST | /api/media/:id/verify |
重新校验 sha256 | 是 |
| GET | /api/jobs?kind=&status=&page= |
Page<JobRecord> |
是 |
| GET | /api/jobs/:id |
JobRecord |
是 |
| POST | /api/import |
body {path, format?, accountUid?} → 创建并异步执行导入任务 |
是 |
| GET | /api/jobs/:id/events |
SSE 进度流(text/event-stream) |
是 |
| POST | /api/collect/run |
立即跑一轮采集 | 是 |
| GET | /api/collect/health |
采集健康状态 | 是 |
| POST | /api/backup |
body {verify?} → 同步执行备份,返回备份记录 |
是 |
| GET | /api/backups |
备份列表 | 是 |
| POST | /api/backups/restore |
body {path, dryRun?} → 恢复演练(默认 dryRun) |
是 |
| GET | /api/logs?level=&scope=&page= |
Page<LogRecord> |
是 |
| GET | `/api/export/session/:id?format=json | jsonl | txt&from=&to=` |
| GET | /api/napcat/status |
body {} → {configured, webuiUrl, connected, baseUrl?, isLogin, uin, error?}(不含 token/凭据) |
是 |
| POST | /api/napcat/qrcode |
取登录二维码 → {isLogin, uin, png},png 是 data:image/png;base64,...,二维码 URL 本身绝不出现在响应里 |
是 |
| GET | /api/napcat/accounts |
本机可快捷登录账号 {accounts: NapCatQuickAccount[]} |
是 |
| POST | /api/napcat/quick-login |
body {uin} → {ok, uin, message?, alreadyLoggedIn?};「该账号已登录」也是 200,由 ok/alreadyLoggedIn 表达 |
是 |
NapCat 登录接口的额外约定:二维码是登录凭据,因此只以 PNG 下发(data: URL),
不进 JSON 明文、不写日志、不走 queryAuth(浏览器无法给 <img> 加请求头,若把二维码放 URL 里会泄漏凭据);/api/napcat/* 未配置 collector.napcat.token 时返回 503 NAPCAT_UNAVAILABLE 并说明要改哪个配置项。
认证语义
POST /api/auth/login用配置里的server.token(常量时间比较)换取一个 随机 refresh token;
DB 只保存其 sha256(settings表或内存会话表),浏览器拿accessToken(短时 JWT 风格:base64url(payload).sig,
用server.token派生的 HMAC 密钥签名)。- 媒体与导出接口必须校验会话;未认证一律 401,不泄露文件是否存在。
安全硬性要求
- 默认只听
127.0.0.1;非本机监听时若 token 强度不足直接拒绝启动(validateConfig)。 - 媒体路径必须经过
path.resolve前缀校验,禁止..穿越;只允许从 DB 取rel_path,不接受用户传路径。 - 导入接口只接受服务端可读的绝对路径,且路径必须存在且是普通文件;不使用用户输入拼 shell。
- 响应头带
X-Content-Type-Options: nosniff、X-Frame-Options: DENY、Referrer-Policy: no-referrer、CSP(前端同源)。
7. 前端契约(web/)
- 纯静态:
web/index.html、web/app.js、web/style.css,由 server 从repoRoot/web托管。 - 所有数据来自上表 API,不允许任何硬编码假数据;未登录时显示登录页。
- 页面:登录 / 总览 / 聊天记录(会话列表 + 时间线 + 搜索)/ 媒体管理 / 归档管理 / 日志。
- 纯函数(时间格式化、类型标签、URL 构造)放在
web/lib.js,用node:test直接测。
8. 测试契约(tests/)
- 全部使用
node:test;node --test --experimental-strip-types "tests/**/*.test.ts"。 - 测试数据由
tests/fixtures/generate.ts生成到临时目录,不使用真实聊天记录。 - 覆盖
docs/acceptance.md里的 15 项;每项都要有可复现命令与断言。
9. @qqarchive/stt 公共 API(可选后处理:语音转文字)
独立的一层,不参与归档正确性:转写失败不影响消息与媒体已经安全落盘。默认关闭。
const WHISPER_ENGINE = 'whisper-cli';
const WHISPER_SAMPLE_RATE = 16_000;
interface SttLikeConfig {
enabled?: boolean; engine?: string; binary?: string; ffmpeg?: string;
model?: string; language?: string; vadModel?: string;
threads?: number; timeoutMs?: number; maxDurationSec?: number;
}
class WhisperCliTranscriber {
readonly engine: string;
constructor(cfg: SttLikeConfig, deps?: {exec?: ExecFn; tmpDir?: string; fileExists?: (p: string) => boolean;
readText?: (p: string) => string});
/** 探活:二进制 / ffmpeg / 模型文件是否都在;结果缓存,force 可强制重探。 */
probe(force?: boolean): Promise<SttAvailability>;
/** 单条转写:ffmpeg 转 16k 单声道 wav → whisper-cli → normalizeTranscript。 */
transcribe(req: {path: string; language?: string; signal?: AbortSignal}): Promise<TranscriptResult>;
}
transcriberFromConfig(cfg: AppConfig): WhisperCliTranscriber | null
normalizeTranscript(raw: string): {text: string; segments: number} // 去时间戳、丢 [BLANK_AUDIO]/[ Silence ] 等占位
isEnglishOnlyModel(path: string): boolean // *.en.bin → 转中文几乎空输出,探测阶段就警告
const DEFAULT_TRANSCRIBE_LIMIT = 50;
const MAX_TRANSCRIBE_LIMIT = 1000;
async function transcribePending(transcriber, db: TranscriptSink, opts: TranscribeBatchOptions): Promise<TranscribeBatchStats>
// TranscribeBatchStats = {candidates; transcribed; empty; failed; skipped; elapsedMs; errors: Array<{mediaId; relPath; error}>}
硬性约束
- 引擎只有 whisper.cpp 官方 CLI(
whisper-cli),不引入任何 npm 依赖,不自己实现推理。 - 转中文必须用多语言模型(
ggml-large-v3-turbo.bin等)。*.en.bin是英文专用模型,
用它转中文会得到近乎空的输出 ——probe()会给出警告,但不阻止(用户可能就是要转英文)。 - 只能读
mediaDir内的文件:relPath经path.resolve前缀校验,..穿越一律跳过并告警,
绝不把任意路径送进ffmpeg/whisper-cli。 - 静音/纯噪声转出空文本也要落一行
media_transcripts,否则每轮重跑都会重新烧一遍 GPU。 - 一条失败不中断整批(
continueOnError,默认 true),失败条目留在待办队列等下次重试。 - 语音文件不离开本机:全部是本地进程调用,不调用任何在线 API。
真实边界(必须如实告知)
- 转写质量取决于模型与音质;本项目的真机验证只覆盖 3 个真实
.amr(见最终报告),
没有做 CER/WER 基准,也没有覆盖方言、强噪声、多人重叠说话。 media_transcripts.text是机器转写,可能有错字,不能当作原始消息内容的权威副本;
原始语音文件本身始终保留(内容寻址,不会被转写替换或删除)。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 emoeem!
评论







