五一假期 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兜底