3 天搓一个 AI 小程序:uniapp + Spring Boot + LLM 架构与踩坑实录

0 阅读11分钟

一个独立开发者视角,记录用 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 张表:birthdayanniversarycountdown。但 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 ?")  // 每年 1101: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 件事

  1. 一张表打天下:省了 60% 的后端代码,现在加"倒数日"只是多一个 type=4
  2. LLM + 置信度兜底:没有把命运完全交给 AI,低置信走正则 + 用户确认
  3. 订阅消息"次数银行" :把 7 天留存从 12% 拉到 31%

做错的 3 件事

  1. 第一天没做埋点:前两周的用户行为数据全丢了
  2. 语音入口藏得太深:引导不够,用户占比只有 23%(预期 50%)
  3. 微信登录晚加了 2 天:最初用手机号 + 验证码,流失一大批

8. 写在最后

3 天做一个小程序不是什么了不起的事。真正难的是你愿意把周末"浪费"在一个可能没人用的东西上。

独立开发者的时间不能用"投入产出比"来衡量,只能用"你是不是真的想做"来衡量。

如果这篇文章对你有帮助,点个赞让更多独立开发者看到。有问题欢迎评论区交流。

横版.png