ARTICLE · 1029249
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 本地落库
关键点:模型只负责“提出候选账目”,正式入账必须经过用户确认。金额、类别、日期错一个,用户就会失去对产品的信任。
一、准备:环境与账号清单
建议先完成《出海必备-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 项目
打开 Xcode → 【Create New Project】→ 平台选 iOS → 应用选 App。 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
新建 Supabase 项目,拿到 Project URL 和服务端私钥(service_role key)。 在 VoiceAccountServer/下创建.env:
# VoiceAccountServer/.envSUPABASE_URL=your_project_urlSUPABASE_SERVICE_ROLE_KEY=your_service_role_key
创建 .gitignore把.env排除在版本管理外。- 特有失败分支:AI 重写 .env 导致 invalid url
。Cursor 改代码时可能顺手重写 .env,导致启动报invalid url。对策:在项目里新建 Cursor 规则文件.cursor/rules/env-rules.mdc,内容写三条并让 AI 始终遵守:
永远不要重写.env如果需要新增环境变量直接告诉我,我来进行操作并 always 遵守规则
让 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
在虚拟环境中执行 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 仓库,方便后期单独部署。
在 Supabase Storage 点击【New Bucket】新建桶 user-audio,设置为 Public bucket。输入开发提示词:
1. 用户点击录音按钮后,需要实现录音,结束录音的时候,保存录音文件为.m4a的文件格式并发送给后端接口2. @app.py 中新增一个接口,用于接受录音文件,然后将录音文件保存到supabase的文件存储桶中,对应的bucket名称为user-audio。并且返回对应的url
重启后端:
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 | ||
/supabase-test | ||
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 元”。
八、特有失败分支与排查
stream | stream=True, stream_options={"include_usage": True},并把内测版模型名换成稳定版 | |
invalid url | .env | .cursor/rules/env-rules.mdc 规则禁止重写 .env,重新粘贴正确密钥 |
user-audio 桶已建、pip install -r requirements.txt 已执行 | ||
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 实践干货。
觉得有帮助的话,点个 「赞」 让更多人看到 👇
有技术问题欢迎留言交流,我们一起进步 💬