plan-v1.md 45 KB

许愿树应用开发计划 — Plan B (H5)

一、项目背景与需求分析

核心痛点

传统物理许愿树的问题:

  • 愿望挂在树上,风吹雨淋后损坏丢失
  • 无法长期保存和回顾
  • 无法记录许愿者的位置信息
  • 无法与他人分享互动

解决方案

开发线上许愿树 H5 应用,结合 LBS(基于位置的服务):

  • 用户在许愿树附近开启定位
  • 拍照上传 + 文字记录愿望
  • 永久保存许愿记录
  • 支持查看历史愿望和位置

目标用户

  • 景区游客
  • 寺庙/公园访客
  • 任何有许愿树场景的场所

二、方案对比:Plan A vs Plan B

维度 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元

决策:推荐 Plan B(H5)

理由:

  1. 跨平台:微信内、浏览器、QQ、微博皆可打开,流量渠道更广
  2. 无审核:迭代更新无需等待微信审核,bug 修复即时生效
  3. 低成本:无微信认证年费,标准前端技术栈,团队更好招聘
  4. 灵活运营:不受微信诱导分享等规则限制
  5. 景区场景适配:景区可在票根印二维码、指示牌贴二维码,用户扫码即用

三、系统架构设计

技术栈

层级 技术 说明
前端 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/缓存)   │        │
│  └──────────────┘ └──────────────┘ └──────────────┘        │
└─────────────────────────────────────────────────────────────┘

四、数据存储设计

4.1 存储分层架构

存储 角色 数据内容
MySQL 持久化 用户、许愿树、愿望、点赞
Redis GEO 地理位置计算 许愿树经纬度、附近查询
Redis Hash 热点缓存 许愿树详情、愿望列表
COS 图片存储 许愿树封面、用户上传图片

4.2 Redis Key 设计

# 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

4.3 数据同步策略

启动初始化:
  ApplicationRunner → 从 MySQL 全量加载许愿树 → Redis GEO + Hash

增量写入:
  创建/更新许愿树 → MySQL 写入 → 同步更新 Redis GEO + Hash
  创建愿望 → MySQL 写入 → 更新 wish:count 计数

定时补偿:
  每 5 分钟 → 对比 MySQL 和 Redis 的许愿树数据 → 修复差异
  每小时   → 将 wish:count 回写 MySQL total_wishes 字段

4.4 MySQL 表结构

许愿树表 (wishing_tree)

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='许愿树表';

愿望表 (wish)

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='愿望表';

用户表 (user)

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='用户表';

五、Redis GEO 核心服务

5.1 Redis 配置

@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);
    }
}

5.2 GEO 服务完整实现

@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));
    }
}

六、API 接口设计

6.1 接口总览

用户模块

方法 路径 说明
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)

6.2 核心接口详解

GET /api/trees/nearby — 附近许愿树

请求:
  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公园"
    }
  ]
}

POST /api/wishes — 许愿

请求:
{
  "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 计算)
  - 用户当日许愿次数是否超限(可选)

七、H5 前端设计

7.1 工程化配置

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

7.2 核心技术依赖

{
  "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"
  }
}

7.3 页面设计与交互

首页 — HomeView.vue

┌──────────────────────────────────┐
│        ✨ 许愿树                │
│    🌸 找到你身边的许愿树        │
├──────────────────────────────────┤
│  ┌────────────────────────────┐  │
│  │ 📍 正在获取位置...         │  │
│  │ 或者                       │  │
│  │ [手动选择城市 ▾]          │  │
│  └────────────────────────────┘  │
├──────────────────────────────────┤
│  附近的许愿树 (3公里内)         │
│                                  │
│  ┌───────────┐                   │
│  │           │ 千年银杏许愿树    │
│  │  封面图   │ 📍 150m  ✅可许愿│
│  │           │ 💬 9999个愿望    │
│  └───────────┘                   │
│                                  │
│  ┌───────────┐                   │
│  │           │ 祈福樱花树        │
│  │  封面图   │ 📍 2.5km ❌太远  │
│  │           │ 💬 888个愿望     │
│  └───────────┘                   │
│                                  │
│  没有找到附近的许愿树?          │
│  [ 查看全部许愿树 → ]            │
├──────────────────────────────────┤
│   🏠首页  🗺️地图  👤我的       │
└──────────────────────────────────┘

许愿页 — MakeWishView.vue

┌──────────────────────────────────┐
│  ← 返回    许下心愿             │
├──────────────────────────────────┤
│  🌳 千年银杏许愿树               │
│  📍 您距许愿树 50 米 ✅ 在范围内 │
├──────────────────────────────────┤
│  上传心愿照片(可选)            │
│                                  │
│  ┌────┐ ┌────┐ ┌────┐          │
│  │ +  │ │ 📷 │ │ 📷 │          │
│  │拍照│ │图片│ │图片│          │
│  └────┘ └────┘ └────┘          │
│  最多上传 9 张                  │
├──────────────────────────────────┤
│  ✏️ 写下你的愿望                │
│  ┌────────────────────────────┐  │
│  │                            │  │
│  │ 希望家人身体健康...        │  │
│  │                            │  │
│  └────────────────────────────┘  │
│  0/200                           │
├──────────────────────────────────┤
│  🏷️ 选择标签(可选)             │
│  ┌────┐ ┌────┐ ┌────┐ ┌────┐   │
│  │爱情│ │事业│ │健康│ │学业│   │
│  └────┘ └────┘ └────┘ └────┘   │
│  ┌────┐ ┌────┐ ┌────┐          │
│  │财富│ │平安│ │祈福│ ...      │
│  └────┘ └────┘ └────┘          │
├──────────────────────────────────┤
│  👁️ 公开愿望   [切换开关 ON]    │
│     公开后其他游客也能看到       │
├──────────────────────────────────┤
│  ┌────────────────────────────┐  │
│  │        🙏 许愿            │  │
│  └────────────────────────────┘  │
└──────────────────────────────────┘

7.4 核心业务流

用户打开 H5 页面 (扫码或链接)
         │
         ▼
   请求定位权限
    navigator.geolocation
    ┌──────┴──────┐
    ▼             ▼
  允许          拒绝
    │             │
    ▼             ▼
  获取GPS     手动选择城市/默认位置
    │             │
    └────┬────────┘
         ▼
   POST /api/trees/nearby
   携带 lng, lat, maxDistance
         │
         ▼
  后端调用 Redis GEOSEARCH
  计算距离 + 判断是否在范围
         │
         ▼
  返回许愿树列表
  标记 distance / isInRange
         │
         ▼
  用户点击许愿树
    ┌────┴────┐
    ▼         ▼
 isInRange  !isInRange
    │         │
    ▼         ▼
 进入许愿页   提示"需靠近",仅可浏览
    │
    ▼
 拍照/选图 → 写愿望 → 选标签 → 设公开
    │
    ▼
 POST /api/wishes
 后端校验距离 + 保存 + Redis计数+1
    │
    ▼
 许愿成功 → 可分享/可查看

八、H5 定位与拍照实现

8.1 定位工具 (utils/geo.ts)

// 获取用户位置
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分钟内缓存
      }
    );
  });
}

8.2 拍照工具 (utils/camera.ts)

// 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);
  });
}

8.3 高德地图 JS SDK 集成 (utils/amap.ts)

初始化

// 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;
  });
}

地图组件 (MapView.vue)

<!-- 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 和安全密钥。

8.4 微信环境下的 JS-SDK 增强(可选)

在微信浏览器中打开时,可接入微信 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
    }
    

九、Spring Boot 后端目录结构

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

十、开发阶段规划

第一阶段:MVP(3-4周)

后端(第 1-2 周):

  • Spring Boot 项目初始化 + pom.xml 依赖
  • MySQL 表结构创建(3 张核心表)
  • Redis GEO 服务实现(核心)
  • 许愿树 CRUD + 附近查询接口
  • 愿望 CRUD 接口
  • 手机号验证码登录接口
  • COS 图片上传接口
  • JWT 鉴权拦截器
  • 全局异常处理 + 统一返回格式

前端(第 2-4 周):

  • Vue 3 + Vite + Vant 项目脚手架
  • Axios 封装 + Token 管理
  • 定位模块(getUserLocation)
  • 拍照/选图/压缩模块
  • 首页 — 附近许愿树列表
  • 许愿页 — 拍照 + 表单提交
  • 我的愿望列表
  • 登录页

第二阶段:增强功能(1-2周)

  • 地图页 — 高德地图 JS SDK 集成
  • 许愿树详情页 — 展示公开愿望瀑布流
  • 愿望点赞
  • 标签筛选
  • 分享卡片生成(Canvas 合成)
  • 微信 JS-SDK 接入(微信环境增强)

第三阶段:运营功能(1-2周)

  • 管理后台(Vue 3 + vite-admin)
  • 许愿树管理(新增/编辑/禁用)
  • 数据看板(用户数、许愿数、日活)
  • 内容审核(违规内容屏蔽)
  • PWA 配置(离线访问能力)

十一、成本预估

项目 费用(月)
云服务器(2核4G) ¥100-200
对象存储 COS 按量计费(初期极低)
MySQL 云数据库 ¥50-150
Redis 云缓存 ¥30-80
短信服务 ¥0.04/条(按量)
域名 + HTTPS ¥50-100/年
H5 无需认证费 ¥0
总计 约 ¥200-500/月

H5 方案比小程序方案每月节省约 ¥200(无认证费、部署更轻量)


十二、H5 方案优势总结

优势 说明
跨平台 微信、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