给全家做了个药箱管家:Go 单文件塞进路由器,拍张照就能录入
家里药箱翻出来一堆过期药,想找退烧药的时候又翻不到。市面上的 App 要么要注册要上云,要么把全家的用药数据传给不知道谁——干脆自己写一个。
最后的成品是一个 Go 单文件 Web 应用,编译出来一个可执行文件,直接塞进家里的 OpenWrt 路由器常驻运行。录入一盒药最快只要拍张照。
这篇记录完整的技术选型和踩过的坑。代码已开源:zhangwenqiang0214/medbox
一、约束先行:为什么是”单文件塞路由器”
先定运行环境,再定技术栈——这个顺序很重要。
我不想为了一个药箱应用专门开一台机器。家里 24 小时不关的只有路由器,所以目标很明确:跑在 OpenWrt 上。
但路由器的资源是真紧张。我那台 RAX3000M 上已经背着 OpenClash、softflowd、Grafana 一堆东西,内存本来就不宽裕,overlay 分区也就那么点。这直接砍掉了一堆选项:
- ❌ Docker —— 路由器上根本不现实
- ❌ Node/Python —— 拖一整个运行时进去,overlay 放不下
- ❌ 任何需要单独装数据库的方案
剩下的答案只有一个:编译成一个静态可执行文件,扔进去就能跑。Go 天生适合干这个。
于是整个项目最后就是 main.go 一个文件 + 三个前端静态文件。没有 node_modules,没有构建流水线,没有框架。
二、坑 1:SQLite 的 CGO 之痛
要存数据,SQLite 是最自然的选择——单文件数据库,跟”单文件应用”的气质完全一致。
Go 里用 SQLite,第一反应是 mattn/go-sqlite3。装上、本地跑通,一切正常。直到我要交叉编译到路由器:
1 | GOOS=linux GOARCH=arm64 go build -o medbox-arm64 . |
炸了。
原因是 mattn/go-sqlite3 是对 C 版 SQLite 的绑定,依赖 CGO。而一旦开了 CGO,交叉编译就不再是改个环境变量的事——你得给目标架构准备一整套 C 交叉编译工具链。为了一个药箱应用去折腾 ARM64 的 gcc 工具链,属实不值。
解法是换成 modernc.org/sqlite:
1 | require modernc.org/sqlite v1.34.5 |
这个库把 SQLite 的 C 代码机器翻译成了纯 Go。没有 CGO,没有 C 编译器,交叉编译回归到最朴素的形态:
1 | GOOS=linux GOARCH=arm64 go build -o deploy/medbox-arm64 . |
一条命令出货。换 mipsle 给老路由器也是改个字母的事。
代价:纯 Go 的 SQLite 性能比 C 版差一截,二进制也胖(15MB 左右)。但对一个家庭药箱——几百条记录、个位数并发——这点性能差异完全感知不到。在这个场景下,可移植性的价值远高于性能。
三、go:embed:把前端塞进二进制
前端是原生 HTML/CSS/JS,没上框架。用 go:embed 直接内嵌:
1 | //go:embed web |
这样最终产物真的就是一个文件。部署 = scp 一个文件过去。不用传前端资源,不用配 nginx,不用担心静态文件路径。
同时留了个后门参数,方便本地调前端时不用反复编译:
1 | go run . -web ./web # 留空则用内嵌资源 |
四、坑 2:药品有效期,包装上根本没有”日”
写过期提醒的时候才发现一个现实问题:药盒上印的有效期,绝大多数只到月份。
有效期至 2026 年 03 月
没有具体哪一天。那 2026-03 到底算哪天过期?3 月 1 日还是 3 月 31 日?
差着 30 天,而这是药,宁可保守:
1 | // 统一按「当月第 1 天 0 点」计算(有效期到月,进入该月即视为过期,偏保守) |
一进入该月就标记过期。 宁可提前一个月提醒你扔掉,也不要让人在最后一天吃下可能已经失效的药。
这类”业务上偏保守”的决策,在健康相关的应用里我觉得比技术选型更重要。
五、坑 3:药箱里不只有药
录入到一半发现,药箱里一大半东西根本不是药:体温计、血压计、创可贴、棉签、口罩、纱布。
这些东西的过期逻辑完全不同:
- 体温计、血压计 —— 能反复用,压根不该算”过期”,也不该算”库存还剩几个”
- 创可贴、棉签 —— 一次性,用一个少一个,要计数
- 药品 —— 既要算过期,也要算库存
最后建了个二维模型:item_type(药品/器械)× reusable(可复用/一次性)。可复用器械直接短路掉过期计算:
1 | if m.ItemType == "器械" && m.Reusable == "可复用" { |
顺带一提,这个字段是后加的——数据库里已经有几十条记录了。所以写了个 migrateV2:重建带 CHECK 约束的新表,旧数据边拷贝边纠正,跑一次就完事。自己用的小项目也别图省事直接改表,数据丢了没地方哭。
六、AI 部分:让录入这件事别那么烦
手动录一盒药要填十几个字段:药名、规格、剂型、适应症、用法用量、有效期、批准文号…… 录三盒就放弃了。录入成本是这类应用的头号杀手。
所以接了 DeepSeek,做了四种录入方式:
| 方式 | 交互 |
|---|---|
| 📷 拍照 | 拍药盒,视觉模型逐字识别,自动填好所有字段 |
| 💬 一句话 | 「布洛芬两盒 明年3月过期」→ 解析 + 常识补全 |
| 🔤 药名 | 只输药名和有效期,模型补全剂型/适应症/分类 |
| ✍️ 手动 | 兜底,什么都不依赖 |
防幻觉:把”不要编造”写死进 prompt
视觉识别最大的风险是模型自作聪明。你拍一张模糊的药盒,它能给你编出一个看起来特别合理的批准文号。
这在别的场景可能只是错,在用药场景是危险。所以 prompt 里把约束写得非常死:
请【逐字识别】包装上印刷的文字,据实提取信息,只输出一个 JSON 对象。看不清或包装上没有的字段留空字符串,【绝对不要编造】。
并且给每个字段都写死了枚举和格式要求,比如:
is_rx(是否处方药):包装印「OTC/甲类/乙类非处方」→ 否;印「凭医师处方购买使用」→ 是expiry_date:只输出YYYY-MM;若包装只印生产日期 + 「有效期24个月」,据此推算
给模型留的自由度越小,幻觉越少。
对症分诊:只推荐你家里真有的药
这个功能我自己用得最多:描述症状,它从当前库存里挑药。
关键在于把药箱库存注入上下文,并且限定它只能在库存里选:
【家里可用】:从药箱里挑出对症的药,指名道姓写清楚吃哪个、每次多少、一天几次、饭前后等;若该药已过期或临近过期要提醒。若服药人是儿童,务必按其年龄/体重折算儿童剂量而不是成人量;老人适当减量提醒。
儿童剂量那句是特意加的。成人量给孩子吃是真会出事的,而模型默认给的基本都是成人量。
另外还有个「药箱体检」,自动分析成分冲突(比如两种药都含对乙酰氨基酚,叠着吃会过量)和品类缺口(家里缺哪类常备药)。这个结果扔进 ai_cache 表缓存,不然每次打开页面都烧一遍 token。
七、几个小设计
API Key 的掩码往返。 设置页要能改 Key,但不能把 Key 明文吐给前端。做法是 GET 时替换成哨兵值,POST 时看到哨兵值就跳过不覆盖:
1 | // GET:不回传原文 |
简单,但比”前端存一份明文再传回来”干净得多。
部署脚本带自检。 一条命令:建目录 → 传二进制 → 装 init.d 服务 → 开机自启 → curl 健康检查。最后一步很关键,不然你永远不知道服务到底起没起来——退出码 0 不等于服务活着,这个教训我在别的项目上吃过。
八、开源前的脱敏:扫出来的东西比想象中多
准备开源的时候顺手做了一遍敏感信息扫描,结果有点意外:
| 发现 | 位置 |
|---|---|
| 🔴 DeepSeek API Key 明文硬编码 | main.go 默认设置块 |
| 🟠 家人的真实昵称当默认成员 | main.go + 前端 app.js 三处 |
| ⚫ 编译好的二进制里面焊着旧 Key | deploy/ |
第二条特别典型:我以为改掉后端默认值就完事了,结果一复扫,前端 app.js 里还硬编码了一份当 fallback,连输入框的 placeholder 都写着家人的名字。
教训是:脱敏不能只改你记得的那一处,必须全仓 grep 到零命中为止。而且要在 git add 之后、push 之前,再扫一遍 git diff --cached 的实际内容——因为最终进仓库的是暂存区,不是你的工作目录。
那个编译产物更阴险:二进制是 Key 被抹掉之前编译的,Key 就焊在里面。直接 .gitignore 排除掉,仓库只留源码。
尾巴
整个项目大概断断续续折腾了几个晚上。现在家里人手机上存个书签,进门连上 WiFi 就能查”家里还有没有退烧药”、”这盒药过期没”。
数据全部躺在自家路由器的 SQLite 文件里,不上云,不遥测,不注册。
代码在这:github.com/zhangwenqiang0214/medbox,MIT 协议,随便拿去改。
⚠️ 免责声明:本项目的 AI 用药建议仅供参考,不构成医疗意见,不能替代执业医师、药师的专业判断。用药前请阅读说明书,身体不适请及时就医。





