传统物理许愿树的问题:
开发线上许愿树 H5 应用,结合 LBS(基于位置的服务):
| 维度 | Plan A:微信小程序 | Plan B:H5(推荐) |
|---|---|---|
| 访问方式 | 微信内扫码/搜索 | 扫码/链接/浏览器打开 |
| 平台限制 | 仅微信内 | 微信、浏览器、其他App均可 |
| 审核 | 需微信审核(1-7天) | 无审核,上线自由 |
| 用户登录 | wx.login 静默登录 | 手机号/微信OAuth授权 |
| 定位 | wx.getLocation | navigator.geolocation |
| 拍照 | wx.chooseImage | input[type=file] capture |
| 地图 | 腾讯地图小程序SDK | 高德地图 JS SDK |
| 分享 | 微信转发 | Web Share / 复制链接 |
| 离线 | 小程序缓存 | PWA Service Worker |
| 开发成本 | 中(需学小程序) | 低(标准前端技能) |
| 运营灵活性 | 低(受微信约束) | 高(完全自主) |
| iOS/Android | 统一体验 | 统一体验 |
| 年费 | 300元认证费 | 0元 |
理由:
| 层级 | 技术 | 说明 |
|---|---|---|
| 前端 | Vue 3 + Vant UI + TypeScript | 移动端 H5 框架 |
| 构建 | Vite | 极速开发体验 |
| 路由 | Vue Router 4 | Hash 模式(兼容性好) |
| 状态管理 | Pinia | Vue 3 官方推荐 |
| HTTP | Axios | 请求封装 |
| 地图 | 高德地图 JS SDK 2.0 | H5 地图展示 |
| 后端 | Java 17 + Spring Boot 3.x | RESTful API |
| 数据库 | MySQL 8.0 | 持久化存储 |
| 缓存 | Redis 6.0+ | GEO 地理位置查询 |
| ORM | MyBatis-Plus | 数据访问层 |
| 文件存储 | 腾讯云 COS | 图片存储 |
| 部署 | Nginx + Docker | H5 静态资源 + 后端服务 |
┌─────────────────────────────────────────────────────────────┐
│ H5 前端 (Vue 3 + Vant) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 首页 │ │ 地图页 │ │ 许愿页 │ │ 我的 │ │
│ │ (许愿树) │ │ (LBS) │ │ (拍照) │ │ (历史) │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────┬───────────────────────────────────┘
│ HTTPS
┌─────────────────────────▼───────────────────────────────────┐
│ Nginx (静态资源 + 反向代理) │
└─────────────────────────┬───────────────────────────────────┘
│
┌─────────────────────────▼───────────────────────────────────┐
│ Spring Boot 后端服务 (Docker) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Controller │ │ Service │ │ Mapper │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────┬───────────────────────────────────┘
│
┌─────────────────────────▼───────────────────────────────────┐
│ 数据层 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ MySQL │ │ COS │ │ Redis │ │
│ │ (业务数据) │ │ (图片存储) │ │ (GEO/缓存) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
| 存储 | 角色 | 数据内容 |
|---|---|---|
| MySQL | 持久化 | 用户、许愿树、愿望、点赞 |
| Redis GEO | 地理位置计算 | 许愿树经纬度、附近查询 |
| Redis Hash | 热点缓存 | 许愿树详情、愿望列表 |
| COS | 图片存储 | 许愿树封面、用户上传图片 |
# GEO 数据结构 — 存储所有许愿树位置
geo:wishing:trees → GEO { member:treeId, lng, lat }
# Hash — 缓存许愿树详情(TTL 24h)
wishing:tree:{treeId} → Hash { name, coverImage, radius, totalWishes, ... }
# String — 热点计数(TTL 永久,定时同步到 MySQL)
wish:count:{treeId} → 许愿树愿望总数
wish:count:daily:{treeId}:{date} → 当日许愿数
# String — 愿望详情缓存(TTL 1h)
wish:detail:{wishId} → JSON
启动初始化:
ApplicationRunner → 从 MySQL 全量加载许愿树 → Redis GEO + Hash
增量写入:
创建/更新许愿树 → MySQL 写入 → 同步更新 Redis GEO + Hash
创建愿望 → MySQL 写入 → 更新 wish:count 计数
定时补偿:
每 5 分钟 → 对比 MySQL 和 Redis 的许愿树数据 → 修复差异
每小时 → 将 wish:count 回写 MySQL total_wishes 字段
CREATE TABLE wishing_tree (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL COMMENT '许愿树名称',
description TEXT COMMENT '描述',
longitude DECIMAL(10, 7) NOT NULL COMMENT '经度',
latitude DECIMAL(10, 7) NOT NULL COMMENT '纬度',
address VARCHAR(255) COMMENT '详细地址',
radius INT DEFAULT 100 COMMENT '有效范围(米)',
cover_image VARCHAR(500) COMMENT '封面图URL',
total_wishes INT DEFAULT 0 COMMENT '总许愿数(Redis定时同步)',
is_active TINYINT DEFAULT 1 COMMENT '是否启用',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许愿树表';
CREATE TABLE wish (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tree_id BIGINT NOT NULL COMMENT '许愿树ID',
user_id BIGINT NOT NULL COMMENT '用户ID',
content TEXT NOT NULL COMMENT '愿望内容',
images JSON COMMENT '图片URL数组',
longitude DECIMAL(10, 7) COMMENT '许愿时经度',
latitude DECIMAL(10, 7) COMMENT '许愿时纬度',
address VARCHAR(255) COMMENT '地址描述',
is_public TINYINT DEFAULT 1 COMMENT '是否公开',
tags JSON COMMENT '标签JSON数组',
likes INT DEFAULT 0 COMMENT '点赞数',
status TINYINT DEFAULT 0 COMMENT '0生效 1已实现',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_tree (tree_id),
INDEX idx_user (user_id),
INDEX idx_created (created_at),
FOREIGN KEY (tree_id) REFERENCES wishing_tree(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='愿望表';
CREATE TABLE user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
phone VARCHAR(20) COMMENT '手机号',
open_id VARCHAR(64) COMMENT '微信OpenID(可选)',
nickname VARCHAR(100) COMMENT '昵称',
avatar_url VARCHAR(500) COMMENT '头像URL',
total_wishes INT DEFAULT 0 COMMENT '总许愿数',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_phone (phone),
UNIQUE KEY uk_open_id (open_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
GenericJackson2JsonRedisSerializer jsonSerializer =
new GenericJackson2JsonRedisSerializer();
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
return new StringRedisTemplate(factory);
}
}
@Service
@Slf4j
public class WishingTreeGeoService {
private static final String GEO_KEY = "geo:wishing:trees";
private static final String TREE_CACHE_PREFIX = "wishing:tree:";
private static final String WISH_COUNT_PREFIX = "wish:count:";
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private WishingTreeMapper treeMapper;
@Autowired
private ObjectMapper objectMapper;
// ==================== 初始化加载 ====================
@EventListener(ApplicationReadyEvent.class)
public void initGeoData() {
log.info("开始加载许愿树到 Redis GEO...");
redisTemplate.delete(GEO_KEY);
List<WishingTree> trees = treeMapper.selectList(
new LambdaQueryWrapper<WishingTree>().eq(WishingTree::getIsActive, true)
);
if (!trees.isEmpty()) {
batchAdd(trees);
log.info("已加载 {} 棵许愿树到 Redis", trees.size());
}
}
// ==================== 许愿树管理 ====================
/** 批量添加许愿树到 GEO + Hash */
public void batchAdd(List<WishingTree> trees) {
// 写入 GEO
Map<String, Point> points = new HashMap<>();
for (WishingTree t : trees) {
points.put(t.getId().toString(),
new Point(t.getLongitude().doubleValue(), t.getLatitude().doubleValue()));
}
redisTemplate.opsForGeo().add(GEO_KEY, points);
// 写入 Hash 缓存
Map<String, Object> pipe = redisTemplate.opsForHash().getOperations()
.executePipelined((RedisCallback<Object>) connection -> {
for (WishingTree t : trees) {
String key = TREE_CACHE_PREFIX + t.getId();
Map<String, String> map = treeToHash(t);
redisTemplate.opsForHash().putAll(key, map);
redisTemplate.expire(key, Duration.ofHours(24));
}
return null;
});
}
/** 新增许愿树 */
public void addTree(WishingTree tree) {
// MySQL
treeMapper.insert(tree);
// Redis: GEO + Hash
redisTemplate.opsForGeo().add(GEO_KEY,
new Point(tree.getLongitude().doubleValue(), tree.getLatitude().doubleValue()),
tree.getId().toString());
cacheTree(tree);
}
/** 更新许愿树 */
public void updateTree(WishingTree tree) {
treeMapper.updateById(tree);
// 移除旧位置 + 添加新位置
redisTemplate.opsForGeo().remove(GEO_KEY, tree.getId().toString());
redisTemplate.opsForGeo().add(GEO_KEY,
new Point(tree.getLongitude().doubleValue(), tree.getLatitude().doubleValue()),
tree.getId().toString());
cacheTree(tree);
}
/** 删除许愿树 */
public void removeTree(Long treeId) {
treeMapper.update(new LambdaUpdateWrapper<WishingTree>()
.eq(WishingTree::getId, treeId)
.set(WishingTree::getIsActive, false));
redisTemplate.opsForGeo().remove(GEO_KEY, treeId.toString());
redisTemplate.delete(TREE_CACHE_PREFIX + treeId);
}
// ==================== 附近查询 ====================
/**
* 查询附近的许愿树 — 核心接口
*
* @param lng 用户经度
* @param lat 用户纬度
* @param maxDistance 最大距离(米) 默认5000
* @param limit 最大返回数
*/
public List<NearbyTreeVO> findNearby(double lng, double lat,
double maxDistance, int limit) {
GeoResults<GeoLocation<String>> results = redisTemplate.opsForGeo()
.search(GEO_KEY,
new GeoReference.Point(lng, lat),
new Distance(maxDistance, Metrics.METERS),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
.includeDistance()
.sortAscending()
.limit(limit));
if (results == null) {
return Collections.emptyList();
}
List<NearbyTreeVO> list = new ArrayList<>();
for (GeoResult<GeoLocation<String>> r : results) {
Long treeId = Long.valueOf(r.getContent().getName());
int distance = (int) r.getDistance().getValue();
// 从 Hash 缓存读详情
WishingTree tree = getTreeCache(treeId);
if (tree == null || !tree.getIsActive()) continue;
NearbyTreeVO vo = new NearbyTreeVO();
BeanUtils.copyProperties(tree, vo);
vo.setId(treeId);
vo.setDistance(distance);
vo.setIsInRange(distance <= tree.getRadius());
list.add(vo);
}
return list;
}
/**
* 计算用户到指定许愿树的距离(用于许愿前置校验)
*/
public int distanceToTree(Long treeId, double userLng, double userLat) {
// 方法1:Redis 内置(Haversine,误差 < 1%)
Distance distance = redisTemplate.opsForGeo().distance(GEO_KEY,
treeId.toString(),
userLng + ":" + userLat, // 其他 member 或转换
Metrics.METERS);
if (distance != null) {
return (int) distance.getValue();
}
// 方法2:Java 计算(兜底)
WishingTree tree = getTreeCache(treeId);
if (tree == null) return Integer.MAX_VALUE;
return (int) haversine(userLng, userLat,
tree.getLongitude().doubleValue(), tree.getLatitude().doubleValue());
}
// ==================== 缓存操作 ====================
public void cacheTree(WishingTree tree) {
String key = TREE_CACHE_PREFIX + tree.getId();
Map<String, String> map = treeToHash(tree);
redisTemplate.opsForHash().putAll(key, map);
redisTemplate.expire(key, Duration.ofHours(24));
}
public WishingTree getTreeCache(Long treeId) {
String key = TREE_CACHE_PREFIX + treeId;
Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
if (entries.isEmpty()) {
// 缓存未命中,查 MySQL 后回填
WishingTree tree = treeMapper.selectById(treeId);
if (tree != null) cacheTree(tree);
return tree;
}
return hashToTree(entries, treeId);
}
// ==================== 愿望计数 ====================
/** 许愿成功后递增计数 */
public void incrWishCount(Long treeId) {
String key = WISH_COUNT_PREFIX + treeId;
redisTemplate.opsForValue().increment(key);
}
/** 定时任务:回写计数到 MySQL */
@Scheduled(cron = "0 0 * * * ?") // 每小时
public void syncWishCount() {
Set<String> keys = redisTemplate.keys(WISH_COUNT_PREFIX + "*");
if (keys == null) return;
for (String key : keys) {
Long treeId = Long.valueOf(key.replace(WISH_COUNT_PREFIX, ""));
String count = redisTemplate.opsForValue().get(key);
if (count != null) {
treeMapper.update(null, new LambdaUpdateWrapper<WishingTree>()
.eq(WishingTree::getId, treeId)
.set(WishingTree::getTotalWishes, Integer.valueOf(count)));
}
}
}
// ==================== 工具方法 ====================
private Map<String, String> treeToHash(WishingTree tree) {
Map<String, String> map = new HashMap<>();
map.put("id", tree.getId().toString());
map.put("name", tree.getName());
map.put("description", tree.getDescription() == null ? "" : tree.getDescription());
map.put("longitude", tree.getLongitude().toString());
map.put("latitude", tree.getLatitude().toString());
map.put("address", tree.getAddress() == null ? "" : tree.getAddress());
map.put("radius", tree.getRadius().toString());
map.put("coverImage", tree.getCoverImage() == null ? "" : tree.getCoverImage());
map.put("totalWishes", tree.getTotalWishes().toString());
map.put("isActive", tree.getIsActive().toString());
return map;
}
private WishingTree hashToTree(Map<Object, Object> map, Long treeId) {
WishingTree t = new WishingTree();
t.setId(treeId);
t.setName((String) map.get("name"));
t.setDescription((String) map.get("description"));
t.setLongitude(new BigDecimal((String) map.get("longitude")));
t.setLatitude(new BigDecimal((String) map.get("latitude")));
t.setAddress((String) map.get("address"));
t.setRadius(Integer.valueOf((String) map.get("radius")));
t.setCoverImage((String) map.get("coverImage"));
t.setTotalWishes(Integer.valueOf((String) map.get("totalWishes")));
t.setIsActive(Boolean.valueOf((String) map.get("isActive")));
return t;
}
/** Haversine 公式 — 计算球面距离 */
public static double haversine(double lng1, double lat1, double lng2, double lat2) {
final double R = 6371000; // 地球半径(米)
double dLat = Math.toRadians(lat2 - lat1);
double dLng = Math.toRadians(lng2 - lng1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(dLng / 2) * Math.sin(dLng / 2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
}
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/auth/send-sms | 发送短信验证码 |
| POST | /api/auth/login | 手机号验证码登录 |
| GET | /api/user/profile | 获取用户信息 |
| PUT | /api/user/profile | 更新用户信息 |
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/trees/nearby | 附近许愿树(Redis GEO) |
| GET | /api/trees/{id} | 许愿树详情 |
| GET | /api/trees/{id}/wishes | 树下公开愿望(分页) |
| POST | /api/trees | 新增许愿树(管理) |
| PUT | /api/trees/{id} | 更新许愿树(管理) |
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/wishes | 创建愿望 |
| GET | /api/wishes/my | 我的愿望列表(分页) |
| GET | /api/wishes/{id} | 愿望详情 |
| DELETE | /api/wishes/{id} | 删除愿望 |
| POST | /api/wishes/{id}/like | 点赞 |
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/upload/image | 上传图片(返回URL) |
请求:
GET /api/trees/nearby?lng=116.4074&lat=39.9042&maxDistance=5000&limit=20
响应:
{
"code": 0,
"data": [
{
"id": 1,
"name": "千年银杏许愿树",
"distance": 150, // 距离(米) — Redis GEOSEARCH 返回
"isInRange": true, // 是否在可许愿范围内 — distance <= radius
"radius": 100,
"coverImage": "https://cos.xxx/tree/xxx.jpg",
"totalWishes": 9999,
"address": "北京市东城区XX公园"
}
]
}
请求:
{
"treeId": 1,
"content": "希望家人身体健康,工作顺利",
"images": ["https://cos.xxx/wish/img1.jpg", "https://cos.xxx/wish/img2.jpg"],
"lng": 116.4074,
"lat": 39.9042,
"isPublic": true,
"tags": ["健康", "事业"]
}
响应:
{
"code": 0,
"data": { "wishId": 123, "message": "许愿成功!" }
}
业务校验:
- treeId 对应的许愿树是否存在且启用
- 用户与许愿树的距离是否在 radius 范围内(Redis 计算)
- 用户当日许愿次数是否超限(可选)
wishing-tree-h5/
├── package.json
├── vite.config.ts
├── tsconfig.json
├── index.html # SPA 入口
├── public/
│ └── favicon.ico
└── src/
├── main.ts # 入口
├── App.vue # 根组件
├── router/
│ └── index.ts # Vue Router (hash)
├── stores/
│ ├── user.ts # Pinia 用户状态
│ └── location.ts # 定位状态
├── api/
│ ├── request.ts # Axios 封装 + 拦截器
│ ├── auth.ts # 登录接口
│ ├── tree.ts # 许愿树接口
│ └── wish.ts # 愿望接口
├── utils/
│ ├── geo.ts # 定位工具
│ ├── camera.ts # 拍照工具
│ └── storage.ts # localStorage 封装
├── components/
│ ├── TreeCard.vue # 许愿树卡片
│ ├── WishCard.vue # 愿望卡片
│ ├── ImageUploader.vue # 图片上传组件
│ ├── TagSelector.vue # 标签选择器
│ └── BottomNav.vue # 底部导航
├── views/
│ ├── HomeView.vue # 首页 — 许愿树列表
│ ├── MapView.vue # 地图页 — 许愿树分布
│ ├── TreeDetailView.vue # 许愿树详情 + 公开愿望
│ ├── MakeWishView.vue # 许愿页 — 拍照 + 输入
│ ├── MyWishesView.vue # 我的愿望
│ ├── WishDetailView.vue # 愿望详情
│ └── UserView.vue # 个人中心
└── assets/
└── styles/
└── global.css
{
"dependencies": {
"vue": "^3.4",
"vue-router": "^4.3",
"pinia": "^2.1",
"axios": "^1.6",
"vant": "^4.8",
"@vant/use": "^1.6",
"@amap/amap-jsapi-loader": "^1.0"
},
"devDependencies": {
"vite": "^5.2",
"typescript": "^5.4",
"@vitejs/plugin-vue": "^5.0",
"unplugin-vue-components": "^0.26",
"@vant/auto-import-resolver": "^1.2"
}
}
┌──────────────────────────────────┐
│ ✨ 许愿树 │
│ 🌸 找到你身边的许愿树 │
├──────────────────────────────────┤
│ ┌────────────────────────────┐ │
│ │ 📍 正在获取位置... │ │
│ │ 或者 │ │
│ │ [手动选择城市 ▾] │ │
│ └────────────────────────────┘ │
├──────────────────────────────────┤
│ 附近的许愿树 (3公里内) │
│ │
│ ┌───────────┐ │
│ │ │ 千年银杏许愿树 │
│ │ 封面图 │ 📍 150m ✅可许愿│
│ │ │ 💬 9999个愿望 │
│ └───────────┘ │
│ │
│ ┌───────────┐ │
│ │ │ 祈福樱花树 │
│ │ 封面图 │ 📍 2.5km ❌太远 │
│ │ │ 💬 888个愿望 │
│ └───────────┘ │
│ │
│ 没有找到附近的许愿树? │
│ [ 查看全部许愿树 → ] │
├──────────────────────────────────┤
│ 🏠首页 🗺️地图 👤我的 │
└──────────────────────────────────┘
┌──────────────────────────────────┐
│ ← 返回 许下心愿 │
├──────────────────────────────────┤
│ 🌳 千年银杏许愿树 │
│ 📍 您距许愿树 50 米 ✅ 在范围内 │
├──────────────────────────────────┤
│ 上传心愿照片(可选) │
│ │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │ + │ │ 📷 │ │ 📷 │ │
│ │拍照│ │图片│ │图片│ │
│ └────┘ └────┘ └────┘ │
│ 最多上传 9 张 │
├──────────────────────────────────┤
│ ✏️ 写下你的愿望 │
│ ┌────────────────────────────┐ │
│ │ │ │
│ │ 希望家人身体健康... │ │
│ │ │ │
│ └────────────────────────────┘ │
│ 0/200 │
├──────────────────────────────────┤
│ 🏷️ 选择标签(可选) │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │爱情│ │事业│ │健康│ │学业│ │
│ └────┘ └────┘ └────┘ └────┘ │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │财富│ │平安│ │祈福│ ... │
│ └────┘ └────┘ └────┘ │
├──────────────────────────────────┤
│ 👁️ 公开愿望 [切换开关 ON] │
│ 公开后其他游客也能看到 │
├──────────────────────────────────┤
│ ┌────────────────────────────┐ │
│ │ 🙏 许愿 │ │
│ └────────────────────────────┘ │
└──────────────────────────────────┘
用户打开 H5 页面 (扫码或链接)
│
▼
请求定位权限
navigator.geolocation
┌──────┴──────┐
▼ ▼
允许 拒绝
│ │
▼ ▼
获取GPS 手动选择城市/默认位置
│ │
└────┬────────┘
▼
POST /api/trees/nearby
携带 lng, lat, maxDistance
│
▼
后端调用 Redis GEOSEARCH
计算距离 + 判断是否在范围
│
▼
返回许愿树列表
标记 distance / isInRange
│
▼
用户点击许愿树
┌────┴────┐
▼ ▼
isInRange !isInRange
│ │
▼ ▼
进入许愿页 提示"需靠近",仅可浏览
│
▼
拍照/选图 → 写愿望 → 选标签 → 设公开
│
▼
POST /api/wishes
后端校验距离 + 保存 + Redis计数+1
│
▼
许愿成功 → 可分享/可查看
// 获取用户位置
export function getUserLocation(): Promise<{ lng: number; lat: number }> {
return new Promise((resolve, reject) => {
// 优先使用浏览器 Geolocation API
if (!navigator.geolocation) {
reject(new Error('浏览器不支持定位'));
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
resolve({
lng: position.coords.longitude,
lat: position.coords.latitude,
});
},
(error) => {
switch (error.code) {
case error.PERMISSION_DENIED:
reject(new Error('用户拒绝了定位请求'));
break;
case error.TIMEOUT:
reject(new Error('定位超时'));
break;
default:
reject(new Error('定位失败'));
}
},
{
enableHighAccuracy: true, // 高精度
timeout: 10000, // 10秒超时
maximumAge: 300000, // 5分钟内缓存
}
);
});
}
// H5 拍照 — 使用 input[type=file] capture="environment"
export function takePhoto(): Promise<File> {
return new Promise((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.capture = 'environment'; // 调用后置摄像头
input.onchange = (e: Event) => {
const files = (e.target as HTMLInputElement).files;
if (files && files.length > 0) {
const file = files[0];
// 压缩图片(前端压缩后再上传)
compressImage(file).then(resolve).catch(reject);
} else {
reject(new Error('未选择图片'));
}
};
input.click();
});
}
// 从相册选择
export function pickFromAlbum(multiple = false): Promise<File[]> {
return new Promise((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.multiple = multiple;
input.onchange = (e: Event) => {
const files = (e.target as HTMLInputElement).files;
if (files && files.length > 0) {
const compressTasks = Array.from(files).map(compressImage);
Promise.all(compressTasks).then(resolve).catch(reject);
} else {
reject(new Error('未选择图片'));
}
};
input.click();
});
}
// Canvas 压缩图片(限制 1080px + 质量 0.8)
export function compressImage(file: File): Promise<File> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const maxSize = 1080;
let { width, height } = img;
if (width > maxSize || height > maxSize) {
if (width > height) {
height = (height / width) * maxSize;
width = maxSize;
} else {
width = (width / height) * maxSize;
height = maxSize;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
resolve(new File([blob!], file.name, { type: 'image/jpeg' }));
},
'image/jpeg',
0.8
);
};
img.src = e.target!.result as string;
};
reader.readAsDataURL(file);
});
}
// utils/amap.ts
import AMapLoader from '@amap/amap-jsapi-loader';
// 高德地图 Key(从环境变量获取)
const AMAP_KEY = import.meta.env.VITE_AMAP_KEY;
// 安全密钥(2.0 版本需要)
const AMAP_SECRET = import.meta.env.VITE_AMAP_SECRET;
let AMapInstance: any = null;
/** 加载高德地图 JS SDK(单例) */
export function loadAMap(): Promise<any> {
if (AMapInstance) return Promise.resolve(AMapInstance);
// 2.0 版本需要配置安全密钥
(window as any)._AMapSecurityConfig = {
securityJsCode: AMAP_SECRET,
};
return AMapLoader.load({
key: AMAP_KEY,
version: '2.0',
plugins: [
'AMap.Geolocation', // 定位
'AMap.Scale', // 比例尺
'AMap.ToolBar', // 工具条
'AMap.Marker', // 标记点
'AMap.InfoWindow', // 信息窗体
],
}).then((AMap: any) => {
AMapInstance = AMap;
return AMap;
});
}
<!-- views/MapView.vue -->
<template>
<div class="map-page">
<!-- 地图容器 -->
<div ref="mapContainer" class="map-container"></div>
<!-- 定位按钮 -->
<van-button
class="locate-btn"
icon="aim"
round
type="primary"
@click="locateUser"
>
重新定位
</van-button>
<!-- 许愿树信息卡片(点击标记弹出) -->
<van-popup
v-model:show="showTreePopup"
position="bottom"
round
:style="{ maxHeight: '40%' }"
>
<div class="tree-popup" v-if="selectedTree">
<img :src="selectedTree.coverImage" class="tree-cover" />
<h3>{{ selectedTree.name }}</h3>
<p class="tree-address">{{ selectedTree.address }}</p>
<p class="tree-distance">
距离你 {{ selectedTree.distance }} 米
<van-tag :type="selectedTree.isInRange ? 'success' : 'warning'">
{{ selectedTree.isInRange ? '可许愿' : '太远了' }}
</van-tag>
</p>
<van-button
type="primary"
block
:disabled="!selectedTree.isInRange"
@click="goMakeWish"
>
{{ selectedTree.isInRange ? '去许愿' : '需要靠近许愿树' }}
</van-button>
</div>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { showToast } from 'vant';
import { loadAMap } from '@/utils/amap';
import { getUserLocation } from '@/utils/geo';
import { getNearbyTrees } from '@/api/tree';
const router = useRouter();
const mapContainer = ref<HTMLDivElement>();
const showTreePopup = ref(false);
const selectedTree = ref<any>(null);
let map: any = null;
let markers: any[] = [];
onMounted(async () => {
await nextTick();
await initMap();
});
onUnmounted(() => {
if (map) map.destroy();
});
/** 初始化地图 + 加载许愿树 */
async function initMap() {
const AMap = await loadAMap();
// 创建地图
map = new AMap.Map(mapContainer.value!, {
zoom: 14,
center: [116.397428, 39.90923], // 默认中心(北京)
});
// 添加控件
map.addControl(new AMap.Scale());
map.addControl(new AMap.ToolBar({ position: 'RT' }));
// 定位用户位置
await locateUser();
}
/** 定位用户并加载附近许愿树 */
async function locateUser() {
try {
const { lng, lat } = await getUserLocation();
const AMap = await loadAMap();
// 移动地图中心
map.setCenter([lng, lat]);
// 添加用户位置标记
const userMarker = new AMap.Marker({
position: [lng, lat],
icon: new AMap.Icon({
size: new AMap.Size(24, 24),
image: '/marker-user.png',
imageSize: new AMap.Size(24, 24),
}),
zIndex: 100,
});
map.add(userMarker);
// 加载附近许愿树
const trees = await getNearbyTrees(lng, lat, 5000, 50);
renderTreeMarkers(AMap, trees);
// 自适应显示所有标记
map.setFitView(null, true, [100, 100, 100, 100]);
} catch {
showToast('定位失败,请开启位置权限');
}
}
/** 渲染许愿树标记 */
function renderTreeMarkers(AMap: any, trees: any[]) {
// 清除旧标记
markers.forEach((m) => map.remove(m));
markers = [];
trees.forEach((tree) => {
const content = `
<div class="tree-marker" style="
text-align:center;
background:${tree.isInRange ? '#07c160' : '#ff976a'};
color:#fff;
padding:4px 8px;
border-radius:12px;
font-size:12px;
white-space:nowrap;
box-shadow:0 2px 6px rgba(0,0,0,0.3);
">
${tree.name}<br/>
<span style="font-size:10px">${tree.distance}m</span>
</div>`;
const marker = new AMap.Marker({
position: [tree.longitude, tree.latitude],
content: content,
anchor: 'bottom-center',
});
// 点击弹出详情
marker.on('click', () => {
selectedTree.value = tree;
showTreePopup.value = true;
});
marker.setMap(map);
markers.push(marker);
});
}
function goMakeWish() {
showTreePopup.value = false;
router.push({ path: '/make-wish', query: { treeId: selectedTree.value.id } });
}
</script>
<style scoped>
.map-page {
position: relative;
width: 100%;
height: 100vh;
}
.map-container {
width: 100%;
height: 100%;
}
.locate-btn {
position: absolute;
bottom: 80px;
right: 16px;
z-index: 10;
}
.tree-popup {
padding: 20px;
}
.tree-cover {
width: 100%;
height: 160px;
object-fit: cover;
border-radius: 8px;
}
.tree-address {
color: #999;
font-size: 14px;
}
.tree-distance {
margin: 12px 0;
}
</style>
# .env
VITE_AMAP_KEY=your_amap_js_api_key
VITE_AMAP_SECRET=your_amap_security_code
高德地图 Key 申请:前往 高德开放平台 创建应用 → 添加 Key → 选择「Web端(JS API)」→ 获取 Key 和安全密钥。
在微信浏览器中打开时,可接入微信 JS-SDK 获得更好的体验:
wx.getLocation() — 更精确的微信定位wx.chooseImage() — 直接调起相机wx.onMenuShareTimeline() — 自定义分享到朋友圈
// 判断是否在微信中
export function isWechat(): boolean {
return /micromessenger/i.test(navigator.userAgent);
}
// 微信环境使用 JS-SDK
if (isWechat()) {
// 注入 wx.config,获取微信特有 API
}
wishing-tree-server/
├── pom.xml
├── src/main/java/com/wishingtree/
│ ├── WishingTreeApplication.java # 启动类
│ ├── config/
│ │ ├── RedisConfig.java # Redis 配置
│ │ ├── CosConfig.java # COS 配置
│ │ ├── WebConfig.java # CORS / 拦截器
│ │ └── SwaggerConfig.java # 接口文档
│ ├── controller/
│ │ ├── AuthController.java # 登录
│ │ ├── UserController.java # 用户
│ │ ├── TreeController.java # 许愿树
│ │ ├── WishController.java # 愿望
│ │ └── UploadController.java # 上传
│ ├── service/
│ │ ├── WishingTreeGeoService.java # Redis GEO 服务
│ │ ├── WishingTreeService.java
│ │ ├── WishService.java
│ │ ├── UserService.java
│ │ ├── AuthService.java
│ │ └── CosService.java
│ ├── mapper/
│ │ ├── WishingTreeMapper.java
│ │ ├── WishMapper.java
│ │ └── UserMapper.java
│ ├── entity/
│ │ ├── WishingTree.java
│ │ ├── Wish.java
│ │ └── User.java
│ ├── dto/ # 请求体
│ │ ├── LoginDTO.java
│ │ ├── CreateWishDTO.java
│ │ └── NearbyQueryDTO.java
│ ├── vo/ # 响应体
│ │ ├── NearbyTreeVO.java
│ │ ├── WishVO.java
│ │ └── Result.java
│ ├── handler/
│ │ └── GlobalExceptionHandler.java
│ └── util/
│ ├── JwtUtil.java
│ └── SmsUtil.java
├── src/main/resources/
│ ├── application.yml
│ ├── application-dev.yml
│ └── application-prod.yml
└── Dockerfile
后端(第 1-2 周):
前端(第 2-4 周):
| 项目 | 费用(月) |
|---|---|
| 云服务器(2核4G) | ¥100-200 |
| 对象存储 COS | 按量计费(初期极低) |
| MySQL 云数据库 | ¥50-150 |
| Redis 云缓存 | ¥30-80 |
| 短信服务 | ¥0.04/条(按量) |
| 域名 + HTTPS | ¥50-100/年 |
| H5 无需认证费 | ¥0 |
| 总计 | 约 ¥200-500/月 |
H5 方案比小程序方案每月节省约 ¥200(无认证费、部署更轻量)
| 优势 | 说明 |
|---|---|
| 跨平台 | 微信、QQ、浏览器、微博等所有 App 内均可打开 |
| 免审核 | 更新即上线,bug 修复秒级生效 |
| 低成本 | 无微信认证年费,标准前端技术栈 |
| 灵活运营 | 不受微信规则约束,可自由设计分享/活动 |
| SEO 友好 | 页面可被搜索引擎收录(非封闭生态) |
| 多入口 | 景区二维码、公众号菜单、朋友圈链接均可引流 |
| PWA | 可添加桌面图标、离线访问,体验接近原生 App |
| 风险 | 影响 | 应对 |
|---|---|---|
| HTTPS 定位限制 | iOS Safari 仅 HTTPS 下允许定位 | 全站 HTTPS + 证书自动续期 |
| 浏览器兼容 | 不同浏览器相机/定位 API 差异 | 多套降级方案 + 微信环境走 JS-SDK |
| 用户拒绝定位 | 无法获取精确位置 | 提供手动选择城市 + 查看全部许愿树 |
| 图片过大 | 上传慢、流量消耗 | 前端 Canvas 压缩至 1080px + 质量 0.8 |
| 高并发 | 节假日景区流量洪峰 | Redis 缓存扛读 + Nginx 限流 |
计划创建时间:2026-05-17 当前采用方案:Plan B(H5) 版本:v2.0