夜雨聆风学习资料网

ARTICLE · 1029249

AI 语音记账 App:iOS 客户端与 Qwen Omni 服务端

AI 语音记账 App:iOS 客户端与 Qwen Omni 服务端

AI 语音记账 App:iOS 客户端与 Qwen Omni 服务端

语音记账的核心不是录音按钮,而是把“我中午花了 35 块吃饭”可靠地变成可编辑账目,并在网络失败、识别错误和重复提交时保持一致。

本文以「iOS 应用开发:开发一款 AI 语音记账软件(基于 Qwen Omni)」实战课为蓝本,零手写代码走完完整链路:Cursor 生成原型图 → SwiftUI 客户端(SwiftData 本地存储)→ Flask + Supabase 服务端(录音存储)→ 接入阿里云百炼 Qwen Omni 全模态模型解析账目。开发 iOS 应用需要 Mac 电脑和 Xcode,如果你只有 Windows,这期项目无法运行,请先准备环境再继续。

开头先问你一个问题

一个完整项目的难点不在“把页面做出来”,而在用户中断、数据错误、权限越界时,系统是否还能给出可恢复的结果。

先把基础概念说清楚

产品闭环指用户从输入到获得结果、再次回来仍可找到历史记录的完整路径。数据模型、状态机、权限与失败处理决定这条路径是否可靠。

本项目的完整数据链路:

iOS 录音(.m4a)→ POST /upload-audio → 存入 Supabase Storage(user-audio 桶)→ 返回音频 URL→ 服务端带 URL + 客户端分类数据调 Qwen Omni → 返回金额/标题/分类 JSON 数组→ 客户端渲染候选账目 → 用户编辑确认 → SwiftData 本地落库

关键点:模型只负责“提出候选账目”,正式入账必须经过用户确认。金额、类别、日期错一个,用户就会失去对产品的信任。

一、准备:环境与账号清单

项目
要求
电脑
Mac(iOS 开发必须)
IDE
App Store 安装 Xcode
AI 编辑器
Cursor(Agent 模式)
后端平台
Supabase 账号(Storage 存录音)
模型平台
阿里云百炼(通义千问-Omni-Turbo)

建议先完成《出海必备-Supabase 详细教程》再开始本课,否则存储桶、服务端密钥这些概念会卡住后面的步骤。

二、阶段 1:用 Cursor 生成原型图

新建空文件夹 prototype,拖入 Cursor,Agent 模式 + Claude 模型,输入需求提示词(节选核心要求):

项目描述:开发一款语音记账iOS应用,项目总共有4个tab:记账/统计/历史/设置。1. 记账:主界面为记账页面,顶部显示本月支出,下方有两个按钮(语音输入/手动输入),下方显示今日的记录历史。用户长按语音输入后能录音。点击手动输入后弹出popup要求输入金额、标题、时间(默认当前时间)和选择分类2. 统计:统计页面可以切换月/季度/年,显示对应的支出情况。并且显示两个柱状图:趋势图(花销按时间顺序)和分布图(分类花销从高到低)3. 历史:按天显示历史记录4. 设置:用户可以选择货币单位(人民币、美元、欧元)、对分类进行管理(增删改查)、对数据实现管理(导出CSV、清除所有数据)请按照以下步骤完成所有界面的原型设计:1. 用户体验(UX)分析:分析核心功能与用户需求,确定主要交互逻辑2. 产品界面规划:定义核心界面,构建合理的信息架构3. 高保真UI设计:严格遵循 iOS Human Interface Guidelines,使用真实图片(Unsplash、Pexels)避免占位图4. HTML原型实现:使用 HTML + Tailwind CSS 开发所有界面,每个界面独立HTML文件(home.html、statistics.html、history.html、settings.html),index.html 通过iframe平铺展示所有页面,避免链接跳转5. 真实感增强:界面尺寸模拟 iPhone 15 Pro,圆角设计贴近真实设备

验证结果:浏览器打开 prototype/index.html,应看到 4 个手机框(记账/统计/历史/设置)平铺展示,可交互。与原课程生成图有差异时,把“现状 + 预期”描述给 Cursor 反复调整,或直接使用课程附带的 prototype.zip 跳到下一步。

三、阶段 2:用 Cursor 开发 SwiftUI 客户端

3.1 新建 Xcode 项目

  1. 打开 Xcode → 【Create New Project】→ 平台选 iOS → 应用选 App。
  2. Product Name 填产品名,Storage 选 SwiftData(SwiftData 负责本地存储记账记录,支持自动存储/查询/删除/更新,@Query 可实时绑定界面数据)。

3.2 基于原型图生成前端代码

把 Xcode 项目文件夹拖进 Cursor,把 prototype/ 文件夹也放进项目根目录,然后输入:

请你根据 @/prototype 下面的原型图,创建对应的iOS页面,在 @/VoiceAccount 文件夹下,创建对应的app页面。目前分别有4个页面,要求尽可能的还原样式。注意 @index.html 不用还原,它是一个概览页面,只需要还原其他4个页面就好。

提示:@文件夹 需要在 Cursor 里手动选择文件再引用,直接复制粘贴路径可能失效。

3.3 处理编译错误(本项目最大的坑)

Swift 是强类型 + 编译型语言,容错率低,而 Cursor 对 SwiftUI 语法、作用域、结构的掌握不如 JavaScript/Python,所以第一次生成后大概率有几十个编译错误。处理原则:

1. 有报错 → 把报错信息复制给 Cursor(右键复制,或直接截图);2. 和预期不一致 → 描述清楚“现在的样子”和“期望的样子”,越精准越好。

把 Xcode 里的报错一条条喂给 Cursor,修完后点击 ContentView 页面预览,项目能跑起来即通过。

3.4 迭代优化提示词(按需使用)

优化:1. 现在有很多的假数据,请你修改成为真实的本地存储的数据2. 分类管理处添加新分类的时候,没有办法编辑分类名称和对应的icon,我们选择icon和icon的背景色

目前这两个页面的统计图的柱状图有显示异常:1. 支出趋势有假数据2. 分类分布会超出高度,并且x轴的柱子需要对齐最底边

有几个问题:1. 季度现在统计柱状图显示异常2. 柱状图的柱子上方增加对应的数字,最好能增加用户的交互3. 在设置页面切换货币单位后,所有的货币单位都应该实现修改

设置页面的导出CSV数据功能没有完成,我希望点击后能够将所有的本地数据导出。

验证结果:模拟器(点击 Xcode 编译按钮打开)里 4 个 tab 可切换;手动记账弹窗可填金额/标题/时间/分类;切换货币单位后所有页面金额单位同步变化;统计页柱状图显示真实数据。

四、阶段 3:初始化 Flask + Supabase

后端技术选型:Flask(Python)+ Supabase Storage(存录音)+ Qwen Omni(AI 解析)

4.1 初始化 Flask

python3 -m venv venvsource venv/bin/activate        # 注意命令行前缀出现 (venv)pip install flaskpython app.py                    # 在虚拟环境中运行

访问 http://localhost:5000/(本课项目把端口改成了 9001,以实际代码为准)看到 hello 输出即成功。用虚拟环境的目的是让每个项目的 Python 包互相隔离,不污染系统环境。

特有失败分支:5000 端口被占用python app.py 报“5000 端口已经被使用了”,改端口 9001 重新运行即可。新增依赖后启动报错,就在虚拟环境中执行 pip install -r requirements.txt 再重启。

4.2 接入 Supabase

  1. 新建 Supabase 项目,拿到 Project URL 和服务端私钥(service_role key)
  2. 在 VoiceAccountServer/ 下创建 .env

# VoiceAccountServer/.envSUPABASE_URL=your_project_urlSUPABASE_SERVICE_ROLE_KEY=your_service_role_key

  1. 创建 .gitignore 把 .env 排除在版本管理外。
  2. 特有失败分支:AI 重写 .env 导致 invalid url
    。Cursor 改代码时可能顺手重写 .env,导致启动报 invalid url。对策:在项目里新建 Cursor 规则文件 .cursor/rules/env-rules.mdc,内容写三条并让 AI 始终遵守:

永远不要重写.env如果需要新增环境变量直接告诉我,我来进行操作并 always 遵守规则

  1. 让 Cursor 在 app.py 中读取环境变量并初始化 Supabase:

在 `app.py` 中获取 `.env` 中的环境变量,并初始化supabase。`SUPABASE_URL` 和 `SUPABASE_SERVICE_ROLE_KEY` 为supabase的Project URL和服务端私钥,使用服务端私钥初始化。并且新建一个 `/supabase-test` 接口,该接口通过 `supabase.auth.admin.list_users` 获取用户数量。来测试是否接通supabase

  1. 在虚拟环境中执行 pip install -r requirements.txt 安装依赖,然后 python app.py 重启。若启动报错且与 supabase 库有关,可能是版本与 Python 环境不兼容,可把 supabase 降级到 2.0.3(课程实测方案);具体依赖版本以 PyPI 当前版本为准。

验证结果:先在 Supabase 控制台【Add user】添加一个用户,再访问 http://localhost:9001/supabase-test,返回 user count 0 → 创建第 1 个用户后变 1 → 再创建 1 个变 2,即表示 Flask 已成功连接 Supabase。记得提交 Git,养成版本管理习惯。

⚠️ 安全边界:SUPABASE_SERVICE_ROLE_KEY 绕过行级安全(RLS),只能放在服务端,绝不能进 iOS 客户端代码或提交到 GitHub。

五、阶段 4:录音上传服务

把客户端与服务端放进同一个父文件夹(VoiceAccount/VoiceAccountClient + VoiceAccount/VoiceAccountServer),用 Cursor 同时打开父文件夹,让 AI 同时拥有前后端上下文;两个子文件夹分别管理各自的 Git 仓库,方便后期单独部署。

  1. 在 Supabase Storage 点击【New Bucket】新建桶 user-audio,设置为 Public bucket
  2. 输入开发提示词:

1. 用户点击录音按钮后,需要实现录音,结束录音的时候,保存录音文件为.m4a的文件格式并发送给后端接口2. @app.py 中新增一个接口,用于接受录音文件,然后将录音文件保存到supabase的文件存储桶中,对应的bucket名称为user-audio。并且返回对应的url

  1. 重启后端:

cd VoiceAccountServersource venv/bin/activatepip install -r requirements.txtpython app.py

验证结果:模拟器里点击录音按钮录一段话,到 Supabase Storage 的 user-audio 桶里能看到刚才上传的 .m4a 文件,说明上传链路通了。这一步要重点盯住后端控制台日志排查报错。

六、阶段 5:接入 Qwen Omni AI 解析

6.1 选模型 + 拿 API Key

在阿里云百炼开通【通义千问-Omni-Turbo】——全模态模型,支持文本/图像/音频/视频解析。创建 API Key 后填入 .env

# VoiceAccountServer/.envSUPABASE_URL=your_supabase_urlSUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_keyDASHSCOPE_API_KEY=your_dashscope_api_key

6.2 参考官方音频示例

Qwen Omni 走 OpenAI 兼容协议,官方音频 + 文本输入示例(这是让 Cursor 接入 AI 的关键参考):

import osfrom openai import OpenAIclient = OpenAI(    # 若没有配置环境变量,请用阿里云百炼API Key将下行替换为:api_key="sk-xxx",    api_key=os.getenv("DASHSCOPE_API_KEY"),    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",)completion = client.chat.completions.create(    model="qwen-omni-turbo-0119",    messages=[        {            "role": "user",            "content": [                {                    "type": "input_audio",                    "input_audio": {                        "data": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250211/tixcef/cherry.wav",                        "format": "wav",                    },                },                {"type": "text", "text": "这段音频在说什么"},            ],        },    ],    # 设置输出数据的模态,当前支持两种:["text","audio"]、["text"]    modalities=["text", "audio"],    audio={"voice": "Cherry", "format": "wav"},    # stream 必须设置为 True,否则会报错    stream=True,    stream_options={"include_usage": True},)for chunk in completion:    print(chunk)

6.3 开发 AI 解析接口

1. 请你参考上面的代码,在python中增加一个新的接口,用于将/upload-audio接口上传的语音url地址通过该接口的方式解析我们的记账明细,要求AI返回金额/标题/分类的json数组的形式,如果有多条需要返回多条。接口中需要上传语音url地址和客户端中保存的分类数据,方便AI自动选择对应的分类。2. 客户端需要新增该接口的调用逻辑,当/upload-audio接收到语音url后立即调用该接口,并开始AI解析的状态提示,移除掉现有的url地址的显示,要求显示一个好看的AI解析中的动画效果。在接受到AI的返回值后呢,能够进行渲染我们记账的条目,并且用户能够自己编辑金额/标题/分类/时间(时间默认客户端当前时间),并实现统一保存

AI 解析接口(视频中 AI 命名为 parse-audio 之类)接收“语音 URL + 客户端分类数据”,返回金额/标题/分类的 JSON 数组:

[  { "amount": 35, "title": "午饭", "category": "餐饮" },  { "amount": 12, "title": "地铁", "category": "交通" }]

验证结果:模拟器里长按录音说“中午吃饭花了三十五块”,松开后应看到 AI 解析动画(AILoadingView),随后弹出候选账目列表(AIParsingResultView):金额 35、标题、分类,每项可编辑,点保存后写入 SwiftData 本地存储。

课程实测的完整验收数据:

语音:早上吃了一碗牛肉面花了12块钱,买了一杯蜜雪冰城花了5块钱,买了1本书是48块钱,     买了一个PS游戏光碟花了220块钱解析:牛肉面 12(餐饮)/ 蜜雪冰城 5(餐饮)/ 书 48(书籍)/ PS游戏光碟 220(娱乐),     4 笔记账全部解析正确,保存后本月支出自动累加

整个 debug 过程会比较长,务必打开日志:前端日志看 Xcode 的 Debug Area → Active Console,后端日志看终端——如果没日志,就让 AI 给代码补上日志输出。

七、接口契约与数据结构

接口
方法/入参
返回
/upload-audio
POST,录音文件(.m4a)
音频 URL
/supabase-test
GET
Supabase 用户数(连通性测试)
AI 解析接口
音频 URL + 客户端分类数据
账目 JSON 数组

AI 解析返回的核心结构(多条记账就是数组多个元素):

[  { "amount": 35, "title": "午饭", "category": "餐饮" },  { "amount": 12, "title": "地铁", "category": "交通" }]

服务端处理要点(把原课程的边界约定落进代码):

1. 上传接口先校验登录态、文件大小、格式(仅 m4a)、任务频率,再生成任务 ID;2. 金额解析为最小货币单位(分)或明确小数规则,不用浮点数随意累加(0.1 + 0.2 在账本里是真实错误);3. 分类必须落在客户端传入的分类集合内,模型乱编的分类标记为待确认;4. 任务状态机:uploaded → transcribing → ready_for_review → saved / failed,用户退出页面后仍能回来查看状态;5. 客户端每次提交带 idempotency key,服务端对同一用户同一 key 只创建一笔,网络重试不会出现两条“午饭 35 元”。

八、特有失败分支与排查

现象
原因
处理
生成后几十个编译错误
Swift 强类型 + Cursor 对 SwiftUI 掌握弱
复制/截图报错给 Cursor,逐个修;Cursor 面板里的报错不用管,以 Xcode 报错为准
调用 Qwen Omni 报错
stream
 未设置为 True(该模型只支持流式输出)
按官方示例补 stream=True, stream_options={"include_usage": True},并把内测版模型名换成稳定版
启动报 invalid url
Cursor 顺手重写了 .env
建 .cursor/rules/env-rules.mdc 规则禁止重写 .env,重新粘贴正确密钥
录音后 Storage 里没有文件
桶权限/依赖缺失/接口报错
看后端控制台日志;确认 user-audio 桶已建、pip install -r requirements.txt 已执行
Preview 直接崩溃
录音功能缺少麦克风权限描述
把崩溃信息给 Cursor,让它补 Info.plist 权限描述;录音必须在模拟器实测,Preview 不具备真机能力
后端日志显示 Json 解析失败
模型返回格式与解析代码不匹配
先让 Cursor “打印完整的返回格式”到日志,看到真实返回后修正解析逻辑
已有“书籍”分类却选中“购物”
分类匹配提示词不精确
优化提示词:从客户端上传的分类列表中匹配,不凭空生成
导出 CSV 没反应
原实现只有一个 print(假功能)
让 Cursor 实现真实导出;模拟器只能唤起保存操作,真机才能落盘
模拟器正常、真机打不开 App
未开启开发者模式/证书未信任
iPhone【设置→隐私与安全性】打开开发者模式;【VPN 与设备管理】信任开发者证书
真机请求失败
手机访问 localhost 不通
终端执行 ipconfig getifaddr en0 拿到局域网 IP,替换代码里的 http://localhost,手机与电脑同一 Wi-Fi
识别金额错误
模型误听/口音/噪声
让用户强确认:确认页默认高亮“金额/分类/日期”,必须手动确认才保存;保留原始转写供编辑

九、安全与隐私边界

  • 录音是敏感数据:上传到 Supabase 私有或受控桶,明确“保留多久、何时删除”;日志不记录完整音频 URL 和账目明细。
  • DASHSCOPE_API_KEY
    SUPABASE_SERVICE_ROLE_KEY 只存在于服务端 .env;模型报错时给客户端返回“暂时无法识别,请手动录入或稍后重试”,不要把供应商原始错误透出。
  • 埋点只记录“转写完成率、用户修改金额比例、确认保存率、平均等待时间”,不要把完整音频和账目文本送进分析系统。
  • 模型名称 qwen-omni-turbo-0119、百炼接口地址与免费额度以当前官方文档为准。

十、一次完整的人工验收

1. 模拟器里录“中午吃饭花了三十五块”,确认候选账目金额/分类正确;2. 修改金额为 35.5 再保存,确认保存的是改后的值;3. 录“买咖啡12块,打车20块”,确认返回两条账目且日期都为今天;4. 网络断掉时点保存,恢复网络后重试,确认只有一条账目(幂等生效);5. 设置页导出 CSV,确认包含全部本地数据;切换货币单位全局生效;6. 用 iPhone 真机跑一遍授权拒绝(拒麦克风权限)场景,确认可手动输入兜底。

深度实战:33 AI语音记账App iOS与QwenOmni服务端

概念与边界

语音模型只出候选账目,确认后才入账;服务端保管密钥且音频默认短期保存。全模态模型能直接理解音频,但“理解”不等于“记账事实”:金额、分类、日期都必须经过服务端校验 + 用户确认两条闸门。

从零实现

iOS 录 m4a multipart 到 parse(本项目为 Flask /upload-audio → Supabase Storage user-audio 桶);服务端输出 entries(amount_cents, currency, category, occurred_at, merchant, confidence),确认时带 client_confirmation_id 幂等入账。客户端 SwiftData 本地落库,AI 解析中展示 AILoadingView 动画,返回后进入 AIParsingResultView 可编辑列表。

失败分支与处理

拒绝麦克风→手输兜底;0 字节→不传;金额误听→强确认;相对日期→按用户时区解析;一句多账→返回数组逐条确认;超时→幂等键重试;后台失败→受控重试不重复入账;模型乱编分类→只允许客户端传入的分类集合。

可验证验收

真机测授权拒绝来电;一句两笔应两项日期正确;改金额后保存改值;重复确认一组;TTL 后音频清除。模拟器验证:录音上传后 Storage 可见 .m4a/supabase-test 返回用户数;AI 解析返回可编辑候选列表;保存后重启 App 记录仍在(SwiftData 持久化)。

发布前检查清单

[ ] Mac + Xcode 环境就绪,模拟器可编译运行[ ] SwiftData 本地数据增删改查可用,重启 App 数据不丢[ ] VoiceAccountServer/.env 含 SUPABASE_URL、SUPABASE_SERVICE_ROLE_KEY、DASHSCOPE_API_KEY[ ] .env 在 .gitignore 中,仓库中搜索不到密钥[ ] user-audio 存储桶已创建,录音上传后可见 .m4a[ ] Qwen Omni 调用带 stream=True,AI 解析返回金额/标题/分类 JSON 数组[ ] 候选账目可编辑,确认后才写入本地存储(不直接落账)[ ] 幂等键生效:断网重试不会产生两条账目[ ] 金额用分/明确小数规则,浮点累计已规避[ ] 真机调试:开发者模式开启、证书已信任、localhost 已换局域网 IP[ ] 埋点不含完整音频与账目文本,模型报错不透出供应商原始信息[ ] 课后作业已评估:记账数据从 SwiftData 迁移到 Supabase 数据库

关注 「斌哥聊技术」,分享更多 AI 实践干货。

觉得有帮助的话,点个 「赞」 让更多人看到 👇

有技术问题欢迎留言交流,我们一起进步 💬

相关学习资料

返回首页浏览学习资料