博客

  • 第五章:部署监控与持续迭代 — 从单次修复到可维护体系

    部署路径管理

    服务器上存在两套代码目录,这是一个容易踩的坑:

    目录用途
    /opt/english-api/生产环境,systemd 服务 WorkingDirectory
    /root/workspace/english-api/开发副本,用于调试和测试

    关键规则:systemd 管理的生产服务只读 /opt 下的代码。在 /root/workspace 下改了代码不生效,是因为改错目录了。部署更新时,需要把改动同步到 /opt 下,清缓存,再重启服务。

    另外,路由文件的位置也有讲究——生产环境的路由直接放在 /opt/english-api/routers/ 下,不是在 server/ 子目录里。这和本地 git 仓库的结构不一样,第一次部署时很容易搞混。

    Python 缓存陷阱

    改完 .py 文件后重启服务,发现修改没生效——这是 Python 的 __pycache__ 在作怪。旧的 .pyc 字节码缓存被继续使用,新的源码被忽略了。

    # 每次部署的标准操作
    find /opt/english-api -name '__pycache__' -type d -exec rm -rf {} +
    systemctl restart english-api

    把这个操作写进部署脚本,避免”改了代码但不生效”的诡异现象。

    systemd 服务管理

    后端通过 systemd 管理,服务名 english-api。几个常用操作:

    systemctl status english-api    # 查看状态
    systemctl restart english-api   # 重启(改完代码后)
    journalctl -u english-api -f    # 实时日志
    
    # 偶尔遇到旧进程占端口的情况
    ss -tlnp | grep 8000           # 查看谁在占 8000 端口
    fuser -k 8000/tcp              # 强制释放端口
    systemctl restart english-api  # 再重启

    注意区分:systemctl restart 和手动 python -m uvicorn 是两个独立进程。手动启动的 uvicorn 不受 systemd 管理,kill 不掉会导致端口冲突。

    前端构建与验证

    前端每次改动要过两道验证:

    cd frontend
    npx tsc --noEmit          # TypeScript 类型检查
    npm run build:weapp        # 生产构建

    两个都通过才算合格。开发时用 npm run dev:weapp 进入 watch 模式,配合微信开发者工具实时预览。

    接口监控

    经历了 STT 卡死整个 API 的教训后,建立了简单的持续检查机制。每次改动部署后,手动验证四个核心接口:

    接口验证方式
    /api/healthcurl 检查返回 {"status":"ok"}
    /api/stt用 TTS 生成的标准英文语音测试转写准确性
    /api/tts发送 500 字符英文文本,检查返回音频大小 > 100KB
    /api/chat/send发送一条对话消息,检查 AI 正常回复

    Git 分支规范

    项目采用双分支策略,简单但有效:

    • 功能类改动(逻辑修复、API、store、hooks、工具函数)→ 推送到 master
    • UI 类改动(样式、布局、颜色、间距、视觉调整)→ 推送到 ui_change

    判断规则很简单:改 .scss/.wxss/.css 或组件内联样式就是 UI,改业务逻辑就是功能。混合改动时按主要目的判断,紧密耦合就拆成两次提交分别推送。

    WordPress 博客环境

    服务器上还跑着一个 WordPress 个人博客(就是你正在读的这篇文章所在的地方),技术细节:

    • Nginx 监听 127.0.0.1:8080,仅本地环回,不对外暴露
    • PHP-FPM 通过 unix socket (/run/php-fpm/www.sock) 处理 PHP
    • WordPress 文件在 /root/workspace/wordpress/
    • 数据库和 CleanLearn 共用同一台 MariaDB,库名 wordpress
    • 对外通过 Nginx 反向代理暴露(admintest.xiaoyinxia.com

    后续计划

    • HTTPS 证书:当前 API 走 HTTP,真机访问时微信会拦截。需要给 admintest.xiaoyinxia.com 配置 SSL 证书
    • 自动化部署脚本:把”同步代码 → 清缓存 → 重启服务 → 验证接口”串成一个脚本,一键完成
    • 接口监控自动化:用 cron 定时跑四个接口的健康检查,失败时告警
    • 前端 CI:每次 push 自动跑 tsc --noEmit + build:weapp

    ← 返回目录

  • 第四章:数据库运维实录 — 密码更换、故障恢复与经验教训

    背景

    CleanLearn 的 MariaDB 数据库有一个应用用户 english_app,原始密码是 english_app_pass——太弱了,而且是明文写在多个配置文件里的。出于安全考虑,决定把密码换掉,同时把忘了的 root 密码也重置。

    看起来很简单:改 config.py → 改 MySQL 用户密码 → 重启服务。实际上踩了一串坑。

    踩坑全记录

    坑 1:改了配置文件但忘了改数据库

    第一步很自然地用 sed/opt/english-api/config.py 里的 english_app_pass 替换成了 mypwd。改完重启服务——所有 API 返回 500。

    原因很简单:MySQL 里 english_app 用户的密码还是旧的。配置文件改了,但数据库用户密码没同步,连接当然失败。

    坑 2:不知道 root 密码

    要改 english_app 的密码,需要 root 权限。但 root 密码是什么?翻遍了服务器上的配置文件、脚本、环境变量,都没有。尝试无密码登录:

    mysql -u root           # 不行
    mysql -u root -p        # 不知道密码
    mariadb -u root         # 也不行

    MariaDB 10.11 默认没有开启 unix_socket 认证,root 必须用密码。唯一的办法:跳过权限验证启动

    坑 3:skip-grant-tables 下的 ALTER USER 不生效

    跳过权限验证的标准操作:

    systemctl stop mariadb
    mariadbd --skip-grant-tables --user=mysql &
    mariadb -u root -e "ALTER USER 'english_app'@'localhost' IDENTIFIED BY 'mypwd';"

    结果报错:

    ERROR 1290 (HY000): The MariaDB server is running with the
    --skip-grant-tables option so it cannot execute this statement

    --skip-grant-tables 模式下,权限表完全不加载,所以 ALTER USER 这类 DCL 语句不能直接执行。必须先 FLUSH PRIVILEGES 加载权限表,再执行 ALTER

    mariadb -u root -e "
      FLUSH PRIVILEGES;
      ALTER USER 'english_app'@'localhost' IDENTIFIED BY 'mypwd';
      ALTER USER 'root'@'localhost' IDENTIFIED BY 'mypwd';
      FLUSH PRIVILEGES;
    "

    坑 4:临时进程没杀掉,服务起不来

    密码改完后,用 kill %1 杀临时 mariadbd 进程,然后 systemctl start mariadb。结果服务启动失败——因为临时进程是 nohup 启动的,kill %1 在非交互式 SSH 中无效,旧进程还占着 3306 端口。

    正确做法是 pkill -9 mariadbd 彻底清场再重启

    pkill -9 mariadbd
    sleep 2
    systemctl start mariadb
    # 验证
    mysql -u root -pmypwd -e "SELECT 1 AS ok;"

    后续完善

    创建免密配置文件

    改完密码后,每次敲 mysql -u root -pmypwd 很麻烦,而且密码会留在 shell 历史里。创建 /root/.my.cnf

    [client]
    user=root
    password=mypwd
    chmod 600 /root/.my.cnf
    # 之后直接 mysql -e "..." 即可

    同步所有配置文件

    密码改了,但服务器上还有多个脚本硬编码了旧密码:

    /opt/english-api/fix_phonetics.py
    /opt/english-api/fix_worker.py
    /opt/english-api/extract_answers.py
    /opt/english-api/link_audio.py
    /opt/english-api/link_audio2.py
    # 全部 sed 替换 english_app_pass → mypwd

    本地也一样——CLAUDE.md 和知识库 memory 文件里的连接串也要更新。

    完整操作 checklist

    总结一个”安全更换数据库密码”的步骤,下次照着做就不会踩坑:

    1. 确认知道 root 密码(不知道就先 reset)
    2. 改 MySQL 用户密码ALTER USER 'xxx'@'localhost' IDENTIFIED BY 'newpass'; FLUSH PRIVILEGES;
    3. 改应用配置文件config.py 里的连接串
    4. 改所有硬编码脚本:grep 全局搜索旧密码,逐一替换
    5. 重启应用服务systemctl restart english-api
    6. 验证连接mysql -u xxx -pnewpass -e "SELECT 1"
    7. 更新文档:CLAUDE.md、知识库、团队共享的配置说明
    8. 清理 shell 历史history -c 避免密码残留

    经验教训

    • skip-grant-tables 模式下要先 FLUSH PRIVILEGES 再 ALTER USER,否则语法报错
    • 非交互 shell 不认 kill %1,用 pkill 杀进程更可靠
    • 密码要全局搜索替换:配置文件、脚本、文档、知识库,少改一处就是一颗定时炸弹
    • 创建 ~/.my.cnf 不仅能免密登录,还能避免密码留在命令历史里

    ← 返回目录

  • 第三章:AI 对话 UI 重构 — 打字与语音模式合二为一

    需求背景

    CleanLearn 最早有两个独立的 AI 对话入口:“打字对话”(纯文字聊天)和“口语录音”(按住录音、松开发送)。用户需要在一个页面里切换输入方式,而不是跳来跳去。目标是把两个页面合并成一个,像微信那样——点击图标在键盘和麦克风之间切换。

    合并方案

    底部导航简化

    原来的底部 Tab 有 4 个:对话 / 口语 / 场景 / 记录。合并后变为 3 个:对话 / 场景 / 记录,”口语”融入”对话”的语音模式里。

    // 合并前
    const TABS = [
      { key: 'chat', label: '对话' },
      { key: 'speak', label: '口语' },  // ← 去掉
      { key: 'scenario', label: '场景' },
      { key: 'history', label: '记录' },
    ]
    
    // 合并后
    const TABS = [
      { key: 'chat', label: '对话' },     // 文字+语音都在这里
      { key: 'scenario', label: '场景' },
      { key: 'history', label: '记录' },
    ]

    输入模式一键切换

    输入栏左侧放一个切换图标:文字模式下显示麦克风 🎤(点它切到语音),语音模式下显示键盘 ⌨(点它切回文字)。状态由一个简单的 useState 管理:

    const [inputMode, setInputMode] = useState('text' | 'voice')('text')
    
    // 文字模式 → 显示 ChatInput 输入框
    // 语音模式 → 显示"按住说话"长条按钮

    语音交互:按住说话,松开发送

    类微信交互——一个满宽的胶囊形按钮,onTouchStart 开始录音,onTouchEnd 停止录音并自动 STT→发送:

    // 按下 → 开始录音
    const handleTouchStart = () => {
      recorderRef.current?.start({
        duration: 60000,        // 最长60秒
        sampleRate: 16000,      // 16kHz
        numberOfChannels: 1,    // 单声道
        format: 'wav',          // WAV格式
      })
      setIsRecording(true)
    }
    
    // 松开 → 停止、转写、发送
    const handleTouchEnd = () => {
      recorderRef.current?.stop()
      // onStop 回调中自动调 transcribeAudio() → sendMessage()
    }

    录音中显示红色脉冲动画 + 计时器(00:05…00:30…),按钮文案变为”松开 发送”,视觉反馈清晰。

    UI 微调迭代

    合并完成后,用户又提了几轮 UI 调整。这个过程挺有意思的——它展示了”用户要的往往比开发者理解的要少”:

    第一轮:”删掉这个提示条”

    用户说”修改 AI 语音提问你录音回答这一行”,我第一次理解成了要删掉整个”口语训练”场景卡片,结果用户说”其他场景还是会有”——原来用户只想删黄色的”AI 朗读已开启”提示条。我只删了提示条但没恢复场景卡片,用户纠正说”我只是让你删除这个提示条而已没让你删除功能”。赶紧恢复场景卡片,只删提示条,功能照旧

    第二轮:场景提示条和会话切换条

    删掉”AI 朗读已开启”后,用户注意到还有黄色场景描述条(进入咖啡店/机场等场景时显示”在咖啡店用英语点单”)和顶部会话切换条(多个会话时横向滚动的标签)。场景描述条直接删掉;会话切换条因为和底部导航功能重复,也删掉,换成新的场景提示条——显示当前场景名称和描述,样式简洁。

    第三轮:状态栏留白

    聊天页用了 position: fixed; top: 0,导致顶部紧贴屏幕边缘,和真题模拟页面对比有明显的视觉差异。参考真题模拟页面的间距,加了 paddingTop: calc(44px + env(safe-area-inset-top)),给状态栏留出空间。

    Git 分支管理

    这个项目有明确的分支规范:

    • master — 功能类改动(逻辑修复、API、store、hooks、工具函数)
    • ui_change — UI 类改动(样式、布局、颜色、间距、视觉调整)

    这轮 UI 改动都在 ui_change 分支上完成,每个 commit 粒度很小(一个提示条删除就是一个 commit),方便回溯。

    经验教训

    • 先确认用户意图再动手:用户说”删掉这一行”可能只是删一个提示条,不是删整个功能模块
    • 小步提交:每个 UI 调整单独 commit,出了问题可以用 git revert 精准回滚
    • UI 和逻辑分分支ui_changemaster 分开,互不干扰
    • 微信小程序 fixed 布局要手动处理安全区env(safe-area-inset-top) 在固定定位下不会自动生效

    ← 返回目录

  • 第二章:AI 语音朗读(TTS)— 长文本分段播放与分片上限调整

    问题现象

    STT 修复后,用户开始用口语模式正常对话。但很快发现新问题:AI 的回复稍微长一点,朗读就只读开头几个字,然后就停了。比如 AI 回复了一段 300 字的英文建议,小程序只播放了前两句,后面全没了。用户以为朗读功能坏了,反复点击重试也没用。

    对比生成音频文件大小也印证了这一点:一条 480 字符的 AI 回复,生成的音频只有 17KB(约 2 秒),而正常完整朗读应该是 180KB+(约 20 秒)。

    排查过程

    这个问题是典型的”截断”而非”中断”——不是播放器中途挂掉,而是根本没生成完整的音频。顺着调用链从上往下排查:

    第一处截断:前端聊天页

    // frontend/src/pages/chat/index.tsx — 旧代码
    speak(lastMsg.content.slice(0, 200))  // ← 硬截 200 字符

    聊天页在调用 speak() 之前,自己先截了一刀。

    第二处截断:speech 模块

    // frontend/src/lib/speech.ts — 旧代码
    export function speak(text: string): void {
      const trimmed = text.slice(0, 200)  // ← 又截一刀
      // ...请求 TTS 接口
    }

    即使聊天页传了完整文本,speech.ts 内部还有一道截断。

    第三处截断:后端 TTS

    # /opt/english-api/routers/tts_router.py — 旧代码
    text = text[:200]  # ← 第三刀

    后端也有硬截断。三层截断叠加,无论前端传多长,最终送到 edge-tts 的文本最多 200 字符

    根因分析

    根本原因不是某个单一的 bug,而是前后端各自加了”安全截断”但没有协调。200 字符这个限制的来源是 edge-tts 单次合成的一个经验值——太长的文本会导致合成超时或内存溢出。但正确的做法不是暴力截断然后只读个开头,而是分段合成、队列播放

    修复方案

    1. 新增分段函数 splitTextForTTS

    在前端 speech.ts 中新增智能分段逻辑,按句子边界切分长文本,每段不超过 500 字符:

    export function splitTextForTTS(text: string, maxChars = 500): string[] {
      const segments: string[] = []
      let remaining = text.trim()
      while (remaining.length > 0) {
        if (remaining.length <= maxChars) {
          segments.push(remaining); break
        }
        // 在 maxChars 范围内找最后一个句子结束符
        let cutAt = maxChars
        for (const sep of ['. ', '! ', '? ', '\n', '.', '!', '?']) {
          const pos = remaining.lastIndexOf(sep, maxChars)
          if (pos > maxChars * 0.6) { cutAt = pos + sep.length; break }
        }
        segments.push(remaining.slice(0, cutAt).trim())
        remaining = remaining.slice(cutAt).trim()
      }
      return segments.filter(s => s.length > 0)
    }

    2. 队列式逐段播放

    分段后依次请求 TTS 接口,每段播放完毕再播下一段。单段合成失败自动跳过,不中断整段朗读:

    export function speak(text: string): void {
      const segments = splitTextForTTS(text)
      // 清空旧队列,加入新分段(每个分段作为独立任务)
      audioQueue.push(...segments.map(seg => ({ text: seg })))
      if (!isPlaying) playNext()
    }

    3. 统一上限为 500

    • 前端 splitTextForTTS 每段 ≤500 字符
    • 后端 TTS 路由上限从 200 提到 500
    • 聊天页不再截断,传完整文本给 speak()

    验证结果

    • 一条 480 字符的 AI 回复,生成 183KB 音频(原来 17KB),完整播放约 20 秒
    • 更长的回复会被切成 2~3 段,段间无感知衔接
    • 单段 TTS 超时或失败,自动跳过该段继续播后续

    经验教训

    • 截断不等于降级:暴力截断然后让用户以为功能坏了,不如老实做分段播放
    • 前后端的”安全限制”要对齐:三处各自为政的 200 字符限制是最典型的反面教材
    • 按句子边界切分体验好很多:在句号/问号处断句,用户听不出是分段播放
    • 队列模式天然容错:单段失败不影响整段朗读,比”全成功或全失败”的模式稳健得多

    ← 返回目录

  • 第一章:AI 语音识别(STT)— 从语音识别失败到全链路跑通

    问题现象

    2026年7月底,用户反馈:在 CleanLearn 小程序的口语训练模式下,按住录音按钮说完话松手后,页面提示“语音识别失败”。有时候等很久然后弹出一个红色提示条,有时候干脆没有任何反应。

    更严重的是,当语音识别触发后,整个小程序的聊天功能都会卡住——发送文字消息也会超时,页面像冻住了一样。

    排查过程

    第一步:直测后端接口

    不通过小程序,直接用 curl 模拟请求打后端 /api/stt 接口:

    curl -X POST http://193.112.130.163/api/stt \
      -H "Authorization: Bearer <token>" \
      -F "audio=@test.wav"

    返回 500 Internal Server Error,有时候甚至是连接超时。这说明问题不在小程序端,而在服务器上。

    第二步:检查健康接口

    接着测 /api/health——这个接口最简单,只返回 {"status":"ok"}。结果也超时了。一个健康检查都超时,说明整个 FastAPI 服务被某次请求卡死了。

    第三步:检查 STT 代码

    登录服务器查看 /opt/english-api/routers/stt_router.py,发现了两个致命问题:

    • 模型文件 faster-whisper-tiny 根本不存在于本地——每次启动时都会尝试从 HuggingFace 下载,但服务器访问不了 HuggingFace(国内网络限制)
    • 模型加载和转写逻辑写在同步函数里,没有放到后台线程。FastAPI 的 async event loop 被同步 I/O 阻塞,一次请求就冻住整个服务

    第四步:检查录音格式

    前端录音用的是 mp3 格式,后端解码依赖 PyAV(FFmpeg 的 Python 绑定)。但服务器上没有安装 FFmpeg,PyAV 也是缺的。即使模型能加载,解码这一步也会挂。

    根因分析

    三个根因叠加导致了一个”死锁”级故障:

    1. 模型从未下载成功:faster-whisper 初始化时调 download_model(),但 HuggingFace CDN 在国内不可达,每次请求都在等一个永远不会完成的下载
    2. 事件循环被阻塞:同步的模型下载和转写操作跑在 FastAPI 的 async event loop 里,一个请求卡住,所有后续请求(包括 health)全部排队等死
    3. 音频格式链路不匹配:前端产 mp3,后端期望 pcm/wav,中间依赖一个不存在的 PyAV/FFmpeg

    修复方案

    1. 模型本地化

    hf-mirror.com(HuggingFace 国内镜像)下载 faster-whisper-tiny 模型,放到服务器本地路径:

    /opt/english-api/models/faster-whisper-tiny/

    代码中指定 local_files_only=True,彻底绕开外网依赖。

    2. 异步化改造

    重写 STT 路由,所有耗时操作(模型加载、语音转写)全部包进 asyncio.to_thread()

    # 模型加载 — 启动时后台完成,不阻塞请求
    model = await asyncio.to_thread(load_model)
    
    # 语音转写 — 每次请求在独立线程执行
    result = await asyncio.to_thread(model.transcribe, audio_data)

    这样即使转写耗时 5 秒,其他请求(health、chat)完全不受影响。模型未就绪时返回明确的 503 Service Unavailable,而不是无限挂起。

    3. 音频格式统一为 WAV

    前端录音参数从 mp3 改为 WAV:

    // frontend/src/pages/chat/index.tsx
    recorderRef.current?.start({
      duration: 60000,
      sampleRate: 16000,
      numberOfChannels: 1,
      encodeBitRate: 48000,
      format: 'wav',  // ← 从 mp3 改为 wav
    })

    后端用 Python 内置的 wave 模块解码,零外部依赖

    import wave
    import io
    
    with wave.open(io.BytesIO(audio_bytes), 'rb') as wf:
        frames = wf.readframes(wf.getnframes())
        audio_data = np.frombuffer(frames, dtype=np.int16)

    验证结果

    • 用 TTS 生成一段英文语音,发送到 /api/stt,1~5 秒内返回正确转写文本
    • /api/health 始终正常响应,不再受 STT 请求影响
    • 多次连续录音→转写→发送,全链路稳定

    经验教训

    • AI 相关服务必须有”外网不可用”的降级方案:模型必须预下载到本地,不能假设运行时能访问外部 CDN
    • FastAPI 路由里绝对不能放同步阻塞操作:哪怕只是”可能慢一点”的 I/O,都要进 asyncio.to_thread
    • 依赖越少越好:用 Python 内置库替代 PyAV/FFmpeg,部署复杂度直接归零
    • 排查线上问题先直测后端接口:curl 一把梭,能快速排除前端干扰,定位到真正的故障层

    ← 返回目录

  • CleanLearn 小程序 AI 对话全链路修复实录 — 目录索引

    项目简介

    CleanLearn 是一个英语学习微信小程序(AppID: wx861f66f8f805b5f2),前端使用 Taro 3.6.32 + React + Zustand,后端是 Python FastAPI + MariaDB 10.11,部署在腾讯云服务器上。核心功能包括单词学习、真题模拟和 AI 对话(口语陪练)。

    这篇文章是系列修复实录的目录索引,记录了从用户反馈”AI 语音识别不了”开始,经历 STT 重写、TTS 分段播放、UI 合并重构、数据库密码更换,到最后建立部署监控体系的完整过程。每个问题拆成独立章节,方便按需跳转。

    目录

    1. AI 语音识别(STT) — 录音后提示”语音识别失败”,接口超时/500,从模型下载到格式兼容全链路排查
    2. AI 语音朗读(TTS) — AI 回复一长就只读开头,三处硬截断 200 字符的根因与分段队列播放方案
    3. AI 对话 UI 重构 — 打字与语音模式合二为一,提示条清理、状态栏留白、分支管理
    4. 数据库运维实录 — 密码更换导致服务 500,skip-grant-tables 踩坑与恢复全过程
    5. 部署监控与持续迭代 — 路径管理、缓存陷阱、接口监控、构建流程规范化

    技术栈速览

    层级技术
    前端框架Taro 3.6.32 (React + TypeScript)
    状态管理Zustand 4.5
    后端框架Python FastAPI
    数据库MariaDB 10.11 (SQLAlchemy ORM)
    AI 模型DeepSeek Chat + faster-whisper-tiny (STT) + edge-tts (TTS)
    部署腾讯云 CentOS, Nginx + systemd, Docker

    快速跳转

    如果你只关心某个具体问题:

    本系列所有内容基于实际项目操作记录,代码路径和配置文件均可在项目仓库中找到。

  • CleanLearn 小程序 AI 对话修复实录:从语音识别不了到全链路跑通

    项目简介

    CleanLearn 是一个英语学习微信小程序,前端使用 Taro 3.6 + React + Zustand,后端是 Python FastAPI + MariaDB,部署在一台腾讯云服务器上。核心功能包括单词学习、真题模拟和 AI 对话(口语陪练)。

    这篇文章记录一次完整的线上问题排查与迭代过程:从用户反馈”AI 语音识别不了”,到对话页合并、数据库密码更换,再到部署监控,最终全部链路跑通。

    问题一:AI 语音识别不了

    现象:口语模式录音后,小程序提示”语音识别失败”。

    排查过程:先用 curl 直接测后端 /api/stt 接口,发现返回 500/400 而不是正常转写结果;进一步发现服务器的健康检查也会超时——说明整个 API 被卡住了。最终定位到两个根因:

    • 服务器无法访问 HuggingFace(网络不通),而 faster-whisper 每次启动或首次请求都要去下载模型,导致模型从未加载成功;
    • 旧版 stt_router.py 在事件循环里同步加载模型和转写,一次请求就把整个 API 冻住。

    修复

    • 从 hf-mirror.com 下载 faster-whisper tiny 模型到服务器本地(/opt/english-api/models/faster-whisper-tiny),彻底绕开外网依赖;
    • 重写 STT 路由:模型加载与转录都放进 asyncio.to_thread,事件循环不再被阻塞,失败时返回明确的 503;
    • 前端录音格式从 mp3 改为 wav,后端用 Python 内置 wave 模块解码,不再依赖 PyAV/ffmpeg。

    修复后实测:TTS 生成的英文语音能被正确转写,接口从”卡死超时”变成 1~5 秒返回结果。

    问题二:长文本 AI 朗读中断

    现象:AI 回复一长,朗读只读开头就停了。

    根因:文本在三个地方被硬截断到 200 字符——聊天页调用 speakslice(0, 200)speech.ts 里再次截断、后端 TTS 也 text[:200]。超过 200 字符的回复只朗读开头,看起来就像”中断”。

    修复

    • 前端新增 splitTextForTTS:按句子边界把长文本切成 ≤500 字符的分段,队列式逐段播放,单段失败自动跳过;
    • 聊天页改为传完整文本,不再截断;
    • 后端 TTS 上限提高到 500 字符,edge-tts 全量合成。

    验证:480 字符的文本返回 183KB 音频(原来只有 17KB),朗读完整连贯。

    问题三:AI 对话页合并

    需求:把”打字对话”和”口语录音”两个页面合并成一个,像微信一样点击语音按钮切换输入模式,按住说话、松开发送。

    实现

    • 底部 Tab 从 4 个(对话/口语/场景/记录)合并为 3 个(对话/场景/记录);
    • 输入栏左侧 麦克风/键盘 图标一键切换文字与语音模式;
    • 语音模式按住大麦克风录音(WAV、16kHz、单声道、最长 60 秒),松开自动 STT 转写并发送;
    • AI 回复自动朗读常开,配合分句播放。

    期间用户多次调整需求(”只删提示条,不删功能”),最终版本:删除冗余提示条、顶部状态栏按真题模拟页的间距预留安全区、场景提示条保留。

    数据库密码更换的教训

    为了安全把 english_app 的数据库密码换掉,过程中踩了一个坑:用 skip-grant-tables 模式改密码时,脚本里的 kill %1 在非交互 shell 中无效,导致临时实例没被清理、MariaDB 服务停在 failed 状态,数据库接口全部 500。

    正确处理:先 systemctl stop mariadb,用 --skip-grant-tables 启动临时实例执行 ALTER USER,然后杀掉临时进程systemctl start mariadb 正常启动,最后重启应用加载新配置。改完密码记得同步所有配置文件(生产 + 开发副本),并验证新旧密码的登录结果。

    部署与监控

    • 后端部署路径注意:生产代码在 /opt/english-api/routers/,不是 /opt/english-api/server/
    • 服务用 systemd 管理,改完代码清 __pycache__ 再重启;
    • 建立了持续监控:每轮检查 health / STT / TTS / chat 四个接口,STT 用真实语音样本验证转写结果;
    • 前端用 Taro watch + 微信开发者工具实时预览,确认无误后再通过 CLI 上传新版本。

    收获与后续

    这次迭代最大的收获是:线上问题要先用最小复现(curl 直测接口)定位,再顺着调用链逐层排查;AI 相关服务一定要考虑”外网不可用”的降级方案(本地模型、明确报错而不是挂死)。

    后续计划:为服务器配置 HTTPS 证书(当前真机访问会被微信拦截 http),以及完善 WordPress 博客的部署。

  • 世界,您好!

    欢迎使用 WordPress。这是您的第一篇文章。编辑或删除它,然后开始写作吧!