五一假期 5 天,我做了一个微信小程序——叫《会稽群聊》。
需求一句话:游客走到绍兴某个景点,扫码后能围观那个景点对应的本地古人在微信群里吵架。 鲁迅故里入口解锁鲁迅家族群、沈园解锁陆游唐婉群、兰亭解锁王羲之群——总共 6 个群。
这篇文章只讲怎么做出来的——剧本数据结构、围观模式状态机、LBS 半径判定、AI 调用、Day-by-Day 节奏、踩过的坑。idea 怎么来的、为什么是绍兴这些不展开。
技术栈三件套
| 层 | 选择 | 为什么 |
|---|---|---|
| 前端 | 微信小程序 | LBS 权限走原生最丝滑,免下载,审核比 App 快 |
| 后端 | 腾讯云开发 CloudBase(云函数 + NoSQL + 云存储) | 一体化,不用自己运维 |
| AI | CloudBase 内置 Hunyuan / DeepSeek | 云函数里 cloud.extend.AI 直接调,零对接 |
| 开发工具 | 微信开发者工具(内置 AI 编程,后文简称 IDE)+ Claude Code | 写小程序本体在 IDE,写剧本和云函数在 Claude Code |
为什么不用 Next.js + Supabase + Vercel:海外栈在国内有延迟、备案、微信生态接入三道坎,5 天根本跑不通。详细对比之前那篇《2026 一人公司技术架构》写过,这里不展开。
数据结构:把"群聊"变成数据库文档
整个产品的数据模型只有 4 个集合:
// 1. groups: 6 个群的元数据
{
_id: "lu-xun-family",
name: "鲁迅家族のChat",
spotId: "luxun-guli", // 关联打卡景点
avatarUrl: "...",
memberIds: ["lu-xun", "zhou-zuoren", "zhu-an", "xu-guangping", "run-tu"],
unlockOrder: 1 // 默认开放给所有用户
}
// 2. members: 所有人物角色卡
{
_id: "lu-xun",
displayName: "鲁迅",
age: 48,
personality: "刻薄敏感爱反讽,写作时抽烟",
avatarUrl: "...",
artifactNotes: ["《呐喊》", "线装日记本"] // 关联可点击的文物
}
// 3. scripts: 群聊剧本(每群 8-15 条,分段解锁)
{
_id: "lu-xun-family-act1",
groupId: "lu-xun-family",
unlockSpotId: "luxun-guli-entrance", // 在哪个 LBS 点解锁
messages: [
{ from: "zhou-zuoren", text: "大哥,请你以后不要再到后边院子里来。", type: "text" },
{ from: "zhou-zuoren", text: "[图片]", type: "image", imageUrl: "..." },
{ from: "lu-xun", text: "[已撤回一条消息]", type: "system" },
{ from: "zhu-an", text: "大先生,今天的茴香豆刚买好。", type: "text" },
// ...
]
}
// 4. user_progress: 用户解锁进度
{
_id: "openid-xxx",
unlockedScripts: ["lu-xun-family-act1", "shen-yuan-act1"],
stamps: ["luxun-guli", "shen-yuan"], // 集章
reactions: { "lu-xun-family-act1": ["🍶", "🥢"] }
}
为什么用 NoSQL 不用 MySQL:剧本结构嵌套深、字段不固定(图片消息和文本消息字段不一样),关系型表会很丑。CloudBase 的 云数据库支持嵌套查询,写起来直接:
// 云函数里取一个群所有已解锁的剧本
const db = cloud.database();
const scripts = await db.collection('scripts')
.where({
groupId: 'lu-xun-family',
unlockSpotId: db.command.in(userProgress.stamps)
})
.orderBy('createdAt', 'asc')
.get();
LBS 半径判定:100m 解锁
这是核心交互——用户必须人到了景点,才能解锁下一段对话。
前端拿定位,后端验距离。两段必须都在,前端单独算很容易被改。
// 小程序 wx.getLocation
wx.getLocation({
type: 'gcj02', // 国测局坐标系,微信小程序原生
isHighAccuracy: true, // 高精度模式,5-10m
success: (res) => {
wx.cloud.callFunction({
name: 'unlockSpot',
data: {
spotId: 'luxun-guli-entrance',
userLat: res.latitude,
userLng: res.longitude
}
});
}
});
云函数里 Haversine 算距离(地球曲面真实距离,不是直线):
// cloudfunctions/unlockSpot/index.js
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
const SPOTS = {
'luxun-guli-entrance': { lat: 30.0021, lng: 120.5797, radius: 100 },
'shen-yuan': { lat: 29.9924, lng: 120.5871, radius: 100 },
'lan-ting': { lat: 29.9583, lng: 120.5419, radius: 150 }, // 兰亭范围大,半径放大
// ...
};
function haversine(lat1, lng1, lat2, lng2) {
const R = 6371000; // 地球半径,米
const toRad = (x) => x * Math.PI / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a = Math.sin(dLat/2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng/2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
exports.main = async (event) => {
const { spotId, userLat, userLng } = event;
const spot = SPOTS[spotId];
if (!spot) return { ok: false, reason: 'unknown_spot' };
const distance = haversine(userLat, userLng, spot.lat, spot.lng);
if (distance > spot.radius) {
return { ok: false, reason: 'too_far', distance: Math.round(distance) };
}
const { OPENID } = cloud.getWXContext();
const db = cloud.database();
await db.collection('user_progress').doc(OPENID).update({
data: {
stamps: db.command.addToSet(spotId),
[`unlockedScripts`]: db.command.addToSet(`${spotId}-act1`)
}
});
return { ok: true };
};
踩过的坑:
- 微信小程序坐标系是 gcj02(国测局加密),不是常见的 wgs84(GPS 原始)。一开始用百度地图查的景点经纬度算出来全部偏移 50-300m,全部解锁失败
- 山区景点 GPS 飘移大——兰亭周围有山,半径放到 150m 才稳定
- 苹果手机定位精度高于安卓——
isHighAccuracy: true在低端安卓上偶尔超时(默认 3 秒),加highAccuracyExpireTime: 5000兜底
围观模式:用户默认不能说话
整个产品最反直觉的一个决策——用户在群里只能发表情、投票、点击文物名,不能直接打字。
技术上是用一个简单的状态机控制:
// 前端 page/chat/chat.js
data: {
inputMode: 'spectator', // 'spectator' | 'reaction' | 'vote'
allowedReactions: ['🍶', '🥢', '🪶', '🌧️', '🐢', '🪨'] // 绍兴限定六件套
},
// 用户点击底部输入区
onTapInput() {
if (this.data.inputMode === 'spectator') {
wx.showToast({
title: '默认围观哟,发表情吧',
icon: 'none'
});
return; // 不弹键盘
}
}
为什么这么设计:
- 避免 AI 客服感——一旦用户能直接和 AI 角色对话,体验就退化成 ChatGPT 套壳
- 保护剧本完整性——预设剧本是经过史料考证 + 戏剧编排的,用户插话会破坏节奏
- 降低 AI 成本——所有对话都是离线生成、存在数据库里,运行时几乎不再调 AI(除了反应统计),单用户成本控制在 0.001 元以下
反应表情是有后端聚合的:
// cloudfunctions/sendReaction/index.js
exports.main = async (event) => {
const { scriptId, emoji } = event;
const db = cloud.database();
await db.collection('reactions').add({
data: { scriptId, emoji, createdAt: db.serverDate() }
});
// 同步给所有看这条剧本的人(实时推送用 CloudBase Realtime)
return { ok: true };
};
CloudBase 的实时数据推送(Realtime)让"围观"有了集体氛围感——你在沈园看陆游和唐婉那段,能看到 30 秒前另一个游客刚发的破防 emoji 飞过。这是单机玩不出来的爽点。
CloudBase 内置 AI 写剧本
剧本不是手写的,是 AI 生成的——但所有 AI 调用都在线下完成,运行时不调。
云函数里调 Hunyuan:
// cloudfunctions/generateScript/index.js
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
exports.main = async (event) => {
const { groupId, conflictPrompt } = event;
const ai = cloud.extend.AI;
const result = await ai.generateText({
model: 'hunyuan-2.0-instruct-20251111',
messages: [
{
role: 'system',
content: `你是历史群聊编剧。按"微信群对话"格式输出 10-15 条消息,
每条不超过 30 字,必须包含 [图片] [已撤回] [正在输入] 等微信原生标记,
节奏要有沉默和留白。严格按时间线,不能让人物说出他死后才出现的话。`
},
{ role: 'user', content: conflictPrompt }
]
});
return { messages: parseMessages(result.text) };
};
调用时的 prompt 是「人物性格卡 + 冲突核心 + 文物锚点」三件套,举例:
【人物性格卡】
鲁迅:48 岁,刻薄敏感爱反讽
周作人:43 岁,温和记仇字斟句酌
朱安:49 岁,文盲,绍兴话
许广平:35 岁,新女性,住外面
闰土:50 岁,乡下渔民,木讷
【冲突核心】
1923 年 7 月 19 日早晨,周作人递给鲁迅一封绝交信
【文物锚点】(必须出现)
- 八道湾胡同 11 号后院门
- 鲁迅日记本(线装)
- 朱安从绍兴带的茴香豆
【输出】
微信群对话 10-15 条,要有节奏,要有沉默
我做的事就两件:
- 校时间线——AI 经常让 1923 年的鲁迅说 1936 年才出现的话,必须人工兜底
- 加方言——绍兴话彩蛋(朱安那句"我是大先生的太太")AI 写不出来
成本:6 个群一共 12 段对话(每群 2 段),Hunyuan 总调用约 30 次(含调试),加起来不到 1 元。
Day-by-Day:5 天怎么排的
| 日期 | 任务 | 工具 | 实际耗时 |
|---|---|---|---|
| 4/30 | 数据建模 + 6 群人物清单 + 6 段冲突大纲 | 飞书文档 | 4h |
| 5/1 Day1 | 小程序前端骨架(聊天 UI + 进度页 + 地图页) | 微信开发者工具 + 内置 AI | 8h |
| 5/2 Day2 | 6 群剧本生成 + 校对 | Hunyuan + 人工 | 6h |
| 5/3 Day3 | 4 个云函数(unlockSpot / sendReaction / getProgress / sealAchievement) | Claude Code + CloudBase MCP | 5h |
| 5/4 Day4 | LBS 接入 + 文物弹层 + 打卡相册 | 云存储 + wx.getLocation | 7h |
| 5/5 Day5 | 联调 + 修 bug + 提交审核 | 真机扫景点(杭州→绍兴→杭州) | 9h |
总计约 39 小时纯开发。两件事让节奏跑得通:
1. 用 CloudBase MCP 让 Claude Code 直接管后端
在 ~/.workbuddy/mcp.json 里挂 @cloudbase/cloudbase-mcp@latest,Claude Code 能直接:
- 创建云函数
- 改云函数代码 + 部署
- 查云数据库内容
- 上传文件到云存储
写云函数的 prompt 直接是:"给我写个 unlockSpot 函数,输入 spotId/userLat/userLng,用 Haversine 算距离,距离小于 100m 就把对应剧本加入用户解锁列表"。Claude Code 写完直接调 MCP 部署上去,不用复制粘贴。
2. 微信开发者工具内置的 AI 编程能力,写小程序前端比 Cursor 顺
聊天 UI 那种气泡布局、列表渲染、滚动到底部,原本是最磨叽的,IDE 内置的 AI 一句"做一个微信群聊样式的对话列表"直接生成可用的 wxml + wxss。这个之前那篇《微信开发者工具+AI,从写代码到部署上线没离开过这个窗口》详细写过工作流。
几个性能/成本数字
| 项 | 实测 |
|---|---|
| 单用户首屏冷启动 | 800ms(含云函数冷启动) |
| LBS 解锁单次延迟 | 平均 320ms(GPS 200ms + 云函数 120ms) |
| 单用户全程访问云函数次数 | ~25 次(6 个解锁 + 6 个集章 + 反应/进度若干) |
| 6 个群剧本总字数 | 约 4500 字 + 30 张文物图 |
| 云存储占用 | 12MB(图片)+ 1MB(数据库) |
| 日活 1000 人预估月成本 | 约 8 元(CloudBase 免费额度内基本不用付费) |
| 微信小程序审核耗时 | 提交到通过 18 小时(无内容驳回) |
最后一行是惊喜——鲁迅、王阳明、徐渭这种近代/近古名人 AI 生成对话居然过审了。猜测是因为对话节选都在公开史料范围内,且人物已离世足够久。但不建议复制时加在世人物或政治敏感人物,那是另一回事。
踩过的几个坑
- gcj02 vs wgs84 坐标系——前面讲过,景点经纬度必须用国测局加密后的 gcj02,否则全部解锁失败
- CloudBase Realtime 推送数量限制——免费额度是 100 个并发连接,超出就要付费。MVP 阶段够用,上量需要自己算账
- AI 生成的"图片"占位符——AI 输出
[图片]后我得手动配图,否则前端渲染就是个空块。后续考虑接 CloudBase 内置文生图模型自动配 - 审核敏感词预检——提交前用微信官方的内容安全 API(
security.msgSecCheck)扫一遍所有剧本文本,避免"色情""暴力"等词触发驳回。Claude Code 一键写脚本批量扫 - iOS 静默定位限制——小程序在后台不能持续拿定位,必须用户主动点"扫码解锁"按钮触发一次定位,不能做"自动检测到达"
最后
整个工程量不是创意而是工具链成熟度。
5 天能做完,是因为:
- CloudBase 把前端、 后端、数据库、AI、存储收成一个 SDK
- 微信开发者工具把小程序写代码 + 调试 + 预览 + 上传收成一个窗口
- Claude Code + MCP 把"想"和"做"之间的搬运成本压到接近零
三年前同样的需求,光搞定登录态 + LBS 权限 + 推送鉴权就要两天。现在你的瓶颈从"工具会不会用"变成了"想做什么"。
仓库地址正在脱敏,下周公开。小程序码在评论区。