家里药箱翻出来一堆过期药,想找退烧药的时候又翻不到。市面上的 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
2
//go:embed web
var webFS embed.FS

这样最终产物真的就是一个文件。部署 = scp 一个文件过去。不用传前端资源,不用配 nginx,不用担心静态文件路径。

同时留了个后门参数,方便本地调前端时不用反复编译:

1
go run . -web ./web   # 留空则用内嵌资源

四、坑 2:药品有效期,包装上根本没有”日”

写过期提醒的时候才发现一个现实问题:药盒上印的有效期,绝大多数只到月份。

有效期至 2026 年 03 月

没有具体哪一天。那 2026-03 到底算哪天过期?3 月 1 日还是 3 月 31 日?

差着 30 天,而这是药,宁可保守:

1
2
// 统一按「当月第 1 天 0 点」计算(有效期到月,进入该月即视为过期,偏保守)
return time.Date(y, time.Month(m), 1, 0, 0, 0, 0, time.Local), true

一进入该月就标记过期。 宁可提前一个月提醒你扔掉,也不要让人在最后一天吃下可能已经失效的药。

这类”业务上偏保守”的决策,在健康相关的应用里我觉得比技术选型更重要。


五、坑 3:药箱里不只有药

录入到一半发现,药箱里一大半东西根本不是药:体温计、血压计、创可贴、棉签、口罩、纱布。

这些东西的过期逻辑完全不同:

  • 体温计、血压计 —— 能反复用,压根不该算”过期”,也不该算”库存还剩几个”
  • 创可贴、棉签 —— 一次性,用一个少一个,要计数
  • 药品 —— 既要算过期,也要算库存

最后建了个二维模型:item_type(药品/器械)× reusable(可复用/一次性)。可复用器械直接短路掉过期计算:

1
2
3
if m.ItemType == "器械" && m.Reusable == "可复用" {
m.Status, m.DaysLeft, m.Durable = "ok", 0, true
}

顺带一提,这个字段是后加的——数据库里已经有几十条记录了。所以写了个 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
2
3
4
5
6
7
8
9
10
// GET:不回传原文
if m["deepseek_api_key"] != "" {
m["deepseek_api_key"] = "__SET__"
}
delete(m, "access_pin") // 访问密码从不下发

// POST:哨兵值 = 用户没改,跳过
if k == "deepseek_api_key" && v == "__SET__" {
continue
}

简单,但比”前端存一份明文再传回来”干净得多。

部署脚本带自检。 一条命令:建目录 → 传二进制 → 装 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 用药建议仅供参考,不构成医疗意见,不能替代执业医师、药师的专业判断。用药前请阅读说明书,身体不适请及时就医。