一个独立开发者视角,记录用 3 天时间从 0 搭建一个 AI 小程序的完整技术链路。
技术栈:uniapp (Vue 3) + Spring Boot 3.2 + MyBatis-Plus + MySQL 8 + Redis + LLM。
项目背景
这次要做的是一个微信小程序:用自然语言 / 语音 录入"生日、纪念日、倒数日",并通过微信订阅消息按规则推送提醒。
这个项目来自之前一个偶然的想法,因为觉得自己经常记不住亲人的生日,想找个地方记录一下,搜了一下微信下面的小程序,普遍都偏老,或者已经停止维护,或者体验不好,或者各种bug。那在现在大模型应用的场景下,是不是我们有更快捷的方式来记录呢?
说干就干,刚开始规划的功能点很单一,就是单纯的语音录入,然后用LLM解析成生日的各个要素。后面的很多想法都是在实践过程中和ai交流得出来的,比如:生成祝福,礼物建议等等。
1. 技术栈选型:不浪漫,只讲理由
1.1 前端:为什么是 uniapp 而不是原生 / Taro
纠结过 3 个方案:
| 方案 | 开发速度 | 多端能力 | 性能 | 技术栈熟悉度 | 结论 |
|---|---|---|---|---|---|
| 微信原生(WXML) | 中 | ❌ 只能跑微信 | ✅ 最好 | 中 | ❌ 未来要扩 H5 |
| Taro(React) | 中 | ✅ 多端 | 良 | ❌ React 不熟 | ❌ 学习成本 |
| uniapp(Vue 3) | 快 | ✅ 多端 | 良 | ✅ 熟 | ✅ 选它 |
Vue 3 + Composition API 的写法,和我做管理后台的技术栈复用度 100%,人力复用是独立开发者最稀缺的资源。
1.2 后端:为什么是 Spring Boot 3 而不是 Node
很多人建议"个人项目用 Node 更轻"。我尝试过,对我来说反而更慢:
- 定时任务:Spring 的
@Scheduled+ cron 表达式开箱即用,Node 要引三方库还要踩时区 - ORM:MyBatis-Plus 一行
extends ServiceImpl<Mapper, Entity>就把 CRUD 全写完了 - 事务:
@Transactional一个注解搞定,Node 的事务管理写起来冗长 - 线程模型:订阅消息推送是 IO 密集 + CPU 轻量,Java 的线程池 + CompletableFuture 写得爽
再加上 Java 17 的 Record、Text Block,代码简洁度已经和 Kotlin 差不多了。
1.3 完整技术栈
┌─ 前端 ────────────────────────────────────────┐
│ uniapp 3.0 + Vue 3.4 │
│ Pinia 2 (状态管理) │
│ uni.request (HTTP) │
│ 条件编译:#ifdef MP-WEIXIN │
└───────────────────────────────────────────────┘
│ HTTPS / JSON
┌─ 后端 ────────────────────────────────────────┐
│ Spring Boot 3.2.0 + Java 17 │
│ MyBatis-Plus 3.5.5 │
│ Lombok + Hutool │
│ @Scheduled 定时任务 │
│ RestTemplate / OkHttp 调 LLM │
└───────────────────────────────────────────────┘
│
┌─ 存储 ────────────────────────────────────────┐
│ MySQL 8.0 (主数据) │
│ Redis 7 (access_token / AI 结果缓存) │
└───────────────────────────────────────────────┘
│
┌─ 外部服务 ────────────────────────────────────┐
│ LLM API (DeepSeek / Qwen,OpenAI 兼容接口) │
│ 腾讯云 ASR (语音转文字) │
│ 微信订阅消息 API │
└───────────────────────────────────────────────┘
2. 3 天时间表(真实版)
| 时间 | 任务 | 难点 |
|---|---|---|
| Day 1 上午 | 需求 + 数据库设计 + 项目脚手架 | 表结构一次想清楚,后面很难改 |
| Day 1 下午 | 日期 CRUD + Tab 首页 | 无 |
| Day 1 晚上 | 接入 LLM,实现"文本 → JSON" | Prompt 工程 |
| Day 2 上午 | 语音录入(录音 → ASR → 文本 → LLM) | 录音格式跨平台兼容 |
| Day 2 下午 | 阴历 / 阳历转换 + "今年的阳历日"计算 | 闰月、跨年边界 |
| Day 2 晚上 | AI 祝福语(流式输出) | SSE 在小程序里的兼容处理 |
| Day 3 上午 | 微信订阅消息 + 定时任务 | 订阅次数累积策略 |
| Day 3 下午 | UI 打磨 + 真机调试 | iOS 录音权限 |
| Day 3 晚上 | 提审 | 类目选 "工具-效率" |
真正卡时间的不是功能开发,而是3 个看起来小但其实吃时间的坑,下面重点讲。
3. 核心功能一:LLM 把一句话变成结构化日期
这是整个产品的灵魂。传统录入要让用户填 6 个字段:姓名、日期、阴阳历、类型、提醒天数、备注。6 个字段,用户填到第 3 个就跑了。
我的做法:让用户说一句话。
3.1 用户输入示例
输入: "我老婆的生日,农历八月十五,提前一周提醒我"
输出(结构化 JSON):
{
"person": "老婆",
"type": 1, // 1=生日 2=纪念日 3=其他
"calendar_type": "lunar",
"lunar_date": "2026-08-15",
"remind_days": [7],
"confidence": 0.96
}
3.2 Prompt 工程:从 70% 到 95% 的三个技巧
初版 Prompt 随便写的,准确率只有 70%,迭代后稳定在 95%,关键做了三件事。
技巧 1:强制 JSON 输出(不要裸跑)
DeepSeek / OpenAI 兼容接口都支持 response_format,别省这一行:
Map<String, Object> body = Map.of(
"model", "deepseek-chat",
"messages", messages,
"response_format", Map.of("type", "json_object"),
"temperature", 0.3
);
temperature 必须调低(0~0.3),解析类任务不需要创造性。
技巧 2:Few-shot 比长篇大论管用
别在 System Prompt 里写一大堆"你要注意 XXX、不要 YYY",不如直接给 3 个示例:
你是一个日期解析助手。只返回 JSON,不要任何解释。
示例 1:
输入: "我妈生日是 5 月 20 号"
输出: {"person":"妈妈","type":1,"calendar_type":"solar","date":"2026-05-20","remind_days":[1],"confidence":0.95}
示例 2:
输入: "结婚纪念日 2020 年 10 月 1 日,提前三天提醒"
输出: {"event":"结婚纪念日","type":2,"calendar_type":"solar","date":"2020-10-01","remind_days":[3],"confidence":0.98}
示例 3:
输入: "外婆忌日农历七月初七"
输出: {"person":"外婆","event":"忌日","type":3,"calendar_type":"lunar","lunar_date":"2026-07-07","remind_days":[1,7],"confidence":0.92}
现在解析: "{用户输入}"
Few-shot 的效果立竿见影——模型对着抄作业是它最擅长的事。
技巧 3:confidence 字段 + 兜底逻辑
不要无脑信任 LLM。让它自己打个置信分,后端根据分数走不同分支:
if (parsed.getConfidence() < 0.6) {
// 低置信:走关键词正则兜底 + 让用户二次确认
return fallbackParseWithRegex(rawText);
}
if (parsed.getConfidence() < 0.85) {
// 中等置信:AI 解析 + 前端弹窗让用户校对
return AiResult.ofNeedConfirm(parsed);
}
// 高置信:直接返回
return AiResult.ofSuccess(parsed);
3.3 服务端核心代码(片段)
@Service
@RequiredArgsConstructor
@Slf4j
public class DateParseService {
private final LlmClient llmClient;
private final StringRedisTemplate redis;
private static final String CACHE_PREFIX = "date:parse:";
public ParseResult parse(String rawText, Long userId) {
// 1. 结果缓存(同样的话不花第二次钱)
String cacheKey = CACHE_PREFIX + DigestUtil.md5Hex(rawText);
String cached = redis.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, ParseResult.class);
}
// 2. 构造 Prompt(Few-shot 已内联在 PromptTemplate)
String prompt = PromptTemplate.DATE_PARSE.render(Map.of("input", rawText));
// 3. 调 LLM
String json = llmClient.chatJson(prompt);
ParseResult result = JSON.parseObject(json, ParseResult.class);
// 4. 兜底校验(日期合法性、confidence 阈值)
result = validateAndEnrich(result, rawText);
// 5. 高置信结果缓存 24h
if (result.getConfidence() >= 0.85) {
redis.opsForValue().set(cacheKey, JSON.toJSONString(result), Duration.ofDays(1));
}
return result;
}
}
一个省钱的细节:同样的输入走 Redis 缓存,省 LLM 调用费。对于"我妈生日是 5 月 20 号"这种高频输入,命中率非常可观。
4. 核心功能二:语音录入的 3 个真机坑
用户按住麦克风说话 → 自动解析成日期。整个链路:
wx.getRecorderManager().start()
↓ 录音(mp3)
上传后端(multipart/form-data)
↓
腾讯云 ASR(语音转文字)
↓
复用上面的 LLM 解析
↓
返回结构化结果
听起来简单,实际在真机上每一步都踩了坑。
坑 1:iOS 和安卓默认的录音格式不一样
微信官方文档写 RecorderManager 支持 mp3 / aac / wav,但是:
- iOS 默认是
aac - 安卓 默认是
mp3
后端统一用 FFmpeg 处理麻烦,必须在调用时显式指定格式:
const recorder = uni.getRecorderManager()
recorder.start({
format: 'mp3', // 强制 mp3,iOS 安卓一致
sampleRate: 16000, // ASR 最友好的采样率
numberOfChannels: 1,
encodeBitRate: 48000,
frameSize: 50
})
sampleRate: 16000 是关键——ASR 服务大部分原生支持 16kHz,高采样率反而会被降采样浪费流量。
坑 2:iOS 首次必须 authorize,否则静默失败
iOS 对麦克风权限特别严。第一次调用录音前不弹权限请求,会直接静默失败,连 error 回调都不进。正确姿势:
async function startRecord() {
// #ifdef MP-WEIXIN
try {
await uni.authorize({ scope: 'scope.record' })
} catch (e) {
// 用户拒绝过,引导去设置页
const res = await uni.showModal({
title: '需要麦克风权限',
content: '请在设置中打开麦克风权限'
})
if (res.confirm) {
uni.openSetting()
}
return
}
// #endif
recorder.start({ /* ... */ })
}
坑 3:短按(< 300ms)会报 operateRecorder:fail
用户手抖点一下就松开,录音还没真正开始就停了。需要前端做最小录音时长兜底:
let startTime = 0
recorder.onStart(() => { startTime = Date.now() })
function stopRecord() {
const duration = Date.now() - startTime
if (duration < 500) {
uni.showToast({ title: '请按住说话', icon: 'none' })
return
}
recorder.stop()
}
5. 核心功能三:微信订阅消息的"次数银行"策略
这是决定产品留存生死的功能。
5.1 订阅消息的硬规则
微信订阅消息是一次性的:
- 用户订阅 1 次 = 服务端只能推送 1 条
- 用户关闭小程序后,之前累积的订阅次数依然有效
- 想持续推送,必须反复让用户订阅
5.2 次数累积策略
我的做法是所有关键路径都静默触发订阅弹窗:
| 触发时机 | 累积次数 |
|---|---|
| 用户添加新日期成功后 | +3 |
| 用户打开"详情页" | +1 |
| 用户打开"我的"页面 | +1 |
| 节日前一天主动弹"是否要祝福提醒" | +3 |
代码片段(前端):
function requestSubscribe(count = 1) {
const tmplIds = Array(count).fill('your_template_id_xxx')
uni.requestSubscribeMessage({
tmplIds,
success: (res) => {
const accepted = tmplIds.filter(id => res[id] === 'accept').length
if (accepted > 0) {
// 同步到后端:这个用户账户 +accepted 次推送额度
api.addSubscribeQuota({ count: accepted })
}
}
})
}
后端维护一张"订阅额度表":
CREATE TABLE subscribe_quota (
user_id BIGINT PRIMARY KEY,
remaining INT NOT NULL DEFAULT 0, -- 剩余推送次数
total_accepted INT NOT NULL DEFAULT 0,
updated_at BIGINT
);
每次推送前 UPDATE ... SET remaining = remaining - 1 WHERE user_id = ? AND remaining > 0 扣减。
5.3 定时任务:每天凌晨扫描
@Component
@RequiredArgsConstructor
@Slf4j
public class RemindJob {
private final DateMapper dateMapper;
private final SubscribeQuotaService quotaService;
private final WxMpService wxMpService;
/** 每天凌晨 3 点扫描今日需推送的提醒 */
@Scheduled(cron = "0 0 3 * * ?")
public void dailyRemind() {
LocalDate today = LocalDate.now();
// 查询所有"今天需要提醒"的日期(提前 0/1/3/7/30 天都会命中)
List<RemindItem> items = dateMapper.selectTodayReminds(today);
log.info("今日需推送提醒 {} 条", items.size());
// 按用户聚合
Map<Long, List<RemindItem>> byUser = items.stream()
.collect(Collectors.groupingBy(RemindItem::getUserId));
byUser.forEach((userId, userItems) -> {
try {
pushToUser(userId, userItems);
} catch (Exception e) {
log.error("推送失败 userId={}", userId, e);
}
});
}
private void pushToUser(Long userId, List<RemindItem> items) {
// 检查额度
if (!quotaService.consume(userId)) {
log.info("用户 {} 无剩余推送额度", userId);
return;
}
// 调微信订阅消息接口
wxMpService.sendSubscribeMessage(buildTemplate(userId, items));
}
}
5.4 模板字符限制的暗坑
微信订阅消息的每个字段上限 20 字符。超了整条推送失败,且不返回错误,只能在微信后台推送记录里看到"失败"。调试这个我浪费了 40 分钟。
兜底代码:
private String truncate(String s, int max) {
if (s == null) return "";
return s.length() <= max ? s : s.substring(0, max - 1) + "…";
}
Map<String, Object> data = Map.of(
"thing1", Map.of("value", truncate(personOrEvent, 20)),
"time2", Map.of("value", truncate(dateStr, 20)),
"thing3", Map.of("value", truncate(note, 20))
);
6. 数据库:一张表解决生日/纪念日/倒数日
最初设计时很自然地想拆 3 张表:birthday、anniversary、countdown。但 Day 1 中午我推翻了这个设计——它们的字段重合度 95%,拆表等于写 3 套 CRUD。
最终只有一张核心表:
CREATE TABLE `date` (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
person VARCHAR(100) COMMENT '人物(生日场景用)',
event VARCHAR(100) COMMENT '事件(纪念日/其他场景用)',
`date` DATE NOT NULL COMMENT '阳历日期',
type TINYINT NOT NULL COMMENT '1=生日 2=纪念日 3=其他',
calendar_type VARCHAR(10) NOT NULL DEFAULT 'solar' COMMENT 'solar/lunar',
lunar_date VARCHAR(20) COMMENT '阴历原始字符串,如 2026-08-15',
remind_days VARCHAR(100) COMMENT '提醒天数,逗号分隔如 "0,3,7"',
`group` VARCHAR(20) COMMENT '分组:家人/朋友/同事',
hide_age TINYINT DEFAULT 0,
note VARCHAR(500),
create_time BIGINT,
update_time BIGINT,
deleted TINYINT DEFAULT 0,
KEY idx_user_date (user_id, `date`),
KEY idx_user_type (user_id, type, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
两个关键索引
idx_user_date(user_id, date) :
首页 SQL 是"查某用户未来 30 天内的所有日期":
SELECT * FROM `date`
WHERE user_id = ?
AND `date` BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 30 DAY)
AND deleted = 0
ORDER BY `date` ASC
这个复合索引完美覆盖 WHERE + ORDER BY,EXPLAIN 走 range 扫描。
idx_user_type(user_id, type, deleted) :
为分 Tab 展示(生日/纪念日/倒数日)设计。把 deleted 放在索引尾部,能让 WHERE deleted=0 走索引下推,避免回表。
阴历日期的存储方案
很多人会想"把阴历转成阳历存 date 字段不就行了"。不行:
- 阴历日期在每一年对应不同的阳历日期
- 闰月年的阴历"闰四月"需要特殊标记
我的方案:
date字段永远存"今年对应的阳历日"(供查询用)lunar_date存阴历原始字符串(供计算用)- 定时任务每年 1 月 1 日凌晨,把所有
calendar_type='lunar'的记录重算一次date字段
@Scheduled(cron = "0 0 1 1 1 ?") // 每年 1 月 1 日 01:00
public void refreshLunarDates() {
List<DateEntity> lunarList = dateMapper.selectLunarList();
for (DateEntity d : lunarList) {
LocalDate solarOfThisYear = LunarUtil.toSolar(d.getLunarDate(), Year.now().getValue());
d.setDate(solarOfThisYear);
dateMapper.updateById(d);
}
}
阴历转换用 Hutool 自带的 ChineseDate,够用。复杂场景(闰月)可以用 cn.6tail:lunar-java。
7. 复盘:做对的、做错的
做对的 3 件事
- 一张表打天下:省了 60% 的后端代码,现在加"倒数日"只是多一个
type=4 - LLM + 置信度兜底:没有把命运完全交给 AI,低置信走正则 + 用户确认
- 订阅消息"次数银行" :把 7 天留存从 12% 拉到 31%
做错的 3 件事
- 第一天没做埋点:前两周的用户行为数据全丢了
- 语音入口藏得太深:引导不够,用户占比只有 23%(预期 50%)
- 微信登录晚加了 2 天:最初用手机号 + 验证码,流失一大批
8. 写在最后
3 天做一个小程序不是什么了不起的事。真正难的是你愿意把周末"浪费"在一个可能没人用的东西上。
独立开发者的时间不能用"投入产出比"来衡量,只能用"你是不是真的想做"来衡量。
如果这篇文章对你有帮助,点个赞让更多独立开发者看到。有问题欢迎评论区交流。