# 登山节微信网页实现思路简述 ｜ 实现定位和数据处理

原文：https://blog.grtsinry43.com/posts/csu-dynamic-youth/

作者：grtsinry43

发布时间：2025-03-23T08:01:50Z

内容更新时间：2025-03-23T08:01:50Z

去年 10 月份左右，学校组织活动需要，要求 7 天内实现一个登山节打卡用微信网页，包含定位打卡，排行榜，抽奖等等功能，其实当时一周内我就已经完成并迭代至稳定，~~但是当时这个博客还没有重构完故没有记录~~，如今新一期的活动准备开始，新功能和管理面板的开发需求也已经接近完成。但还没完成 ~~（硬控了我好久）~~，分享一下核心功能的实现思路。

## 定位区域和有效范围实现

![image](https://blog.grtsinry43.com/uploads/2025/03/23/image.png_50f80355-c202-447f-8fe6-fe9375a5202b.png)

首先最重要的功能就是确定打卡范围，我的想法是最低成本实现且对精度没有高要求，就确认一个中心点，然后用得到的坐标和这个坐标计算距离，小于要求距离即可，实现大概是这样：

```java
private boolean isWithinCheckinRange(BigDecimal latitude, BigDecimal longitude, int type) {
        // 这里从 checkpoint 表中获取签到点的经纬度
        CheckPoint checkPoint = checkPointService.getById(type);

        BigDecimal checkinLatitude = checkPoint.getLatitude();
        BigDecimal checkinLongitude = checkPoint.getLongitude();

        // 计算提供的坐标和签到点之间的距离
        double distance = calculateDistance(latitude, longitude, checkinLatitude, checkinLongitude);

        // 检查距离是否在 50 米以内
        return distance <= 50;
    }

    private double calculateDistance(BigDecimal lat1, BigDecimal lon1, BigDecimal lat2, BigDecimal lon2) {
        final int R = 6371000; // 地球半径，单位为米

        double latDistance = Math.toRadians(lat2.subtract(lat1).doubleValue());
        double lonDistance = Math.toRadians(lon2.subtract(lon1).doubleValue());

        double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2)
                + Math.cos(Math.toRadians(lat1.doubleValue())) * Math.cos(Math.toRadians(lat2.doubleValue()))
                * Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2);

        double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

        return R * c; // 距离，单位为米
    }
```

问题来了，国内为了隐私考虑，采用了 `gcj02` 坐标体系，而只有通用的 `wgs84` 坐标才能进行计算得到距离，好在这方面已有很多现成开源的算法实现，我们可以这样：

```typescript
const a = 6378245.0;
const ee = 0.00669342162296594323;

function outOfChina(lng: number, lat: number): boolean {
    return (lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271);
}

function transformLat(x: number, y: number): number {
    let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
    ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0;
    ret += (20.0 * Math.sin(y * Math.PI) + 40.0 * Math.sin(y / 3.0 * Math.PI)) * 2.0 / 3.0;
    ret += (160.0 * Math.sin(y / 12.0 * Math.PI) + 320 * Math.sin(y * Math.PI / 30.0)) * 2.0 / 3.0;
    return ret;
}

function transformLng(x: number, y: number): number {
    let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
    ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0;
    ret += (20.0 * Math.sin(x * Math.PI) + 40.0 * Math.sin(x / 3.0 * Math.PI)) * 2.0 / 3.0;
    ret += (150.0 * Math.sin(x / 12.0 * Math.PI) + 300.0 * Math.sin(x / 30.0 * Math.PI)) * 2.0 / 3.0;
    return ret;
}

export function wgs84ToGcj02(lng: number, lat: number): [number, number] {
    if (outOfChina(lng, lat)) {
        return [lng, lat];
    }
    let dLat = transformLat(lng - 105.0, lat - 35.0);
    let dLng = transformLng(lng - 105.0, lat - 35.0);
    const radLat = lat / 180.0 * Math.PI;
    let magic = Math.sin(radLat);
    magic = 1 - ee * magic * magic;
    const sqrtMagic = Math.sqrt(magic);
    dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * Math.PI);
    dLng = (dLng * 180.0) / (a / sqrtMagic * Math.cos(radLat) * Math.PI);
    const mgLat = lat + dLat;
    const mgLng = lng + dLng;
    return [mgLng, mgLat];
}
```

到了这一步，我们只需要遍历每个点和提交数据的范围就好了，但是遍历...，为了防止给后端造成计算压力，我们这里的计算是分两个方面的。

首先为了地图上范围的标注，前端本身就会拉取签到点信息，那我们就可以利用这个，在前端就做好一次距离计算，仅在范围内才会允许发起签到，并且这里还会指定哪个签到点，这样就分散了计算压力，也就是这样：

```typescript
matchedPoint.value = checkPoints.value.find(point => {
        const distance = AMap.GeometryUtil.distance([res.longitude, res.latitude], [point.longitude, point.latitude]);
        return distance <= 50;
      });

      if (matchedPoint.value) {
        if (currentStep.value === 0 || !matchedPoint.value.isEnd) {
          form.value.type = matchedPoint.value.id;
        }
        if (currentStep.value === 1 && !matchedPoint.value.isEnd) {
          showNotify({type: 'warning', message: '不在终点打卡点范围内，请移动到终点打卡点附近'});
        }
        if (currentStep.value === 0 && matchedPoint.value.isEnd) {
          showNotify({type: 'warning', message: '不在起点打卡点范围内，请移动到起点打卡点附近'});
        }
        canCheckIn.value = true;
      } else {
        canCheckIn.value = false;
        showNotify({type: 'warning', message: '不在打卡点范围内，请移动到打卡点附近'});
      }
```

而后端最后准备要计算的大概是这样的，这样只需要判断是否符合签到进度和签到范围就可以啦：

```java
package com.csu54sher.csudynamicyouth.dto;

@Data
public class CheckInInfo {
    private BigDecimal latitude;
    private BigDecimal longitude;
    // 这个 type 要对应 checkpoint 的 id
    private int type;
}
```

## 简单的加密和防修改手段

这里不能说得太详细，毕竟这一次活动还没开始呢，但是实际活动中...emm 大部分都在玩虚拟定位，这个从根本上数据就不可信，并且我们的软件载体还是微信网页，没有防范的方法（其实也有，这次灰度了几个，我看看效果，用打 tag 的方式）

首先是为了防止重放攻击，我们的核心经纬度数据是经过算法加密的，同时也算了一个 state 带了一个时间戳：

```java
package com.csu54sher.csudynamicyouth.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

/**
 * @author grtsinry43
 * @date 2024/10/19 10:29
 * @description 热爱可抵岁月漫长
 */
@Data
public class CheckInRequest {
    @NotNull
    @NotBlank
    private String data;
    @NotNull
    @NotBlank
    private String state;
    @NotNull
    @NotBlank
    private String timestamp;
}
```

## 面对面组队的简单实现

***emm...这功能被砍掉了啊啊啊啊啊啊啊啊啊啊啊！！！***

![image](https://blog.grtsinry43.com/uploads/2025/03/23/image.png_ea0f4760-a7e7-4e49-bc10-ad626249c5b0.png)

这个的核心功能就是发起组队会生成一个组队码，规定时间内，其他人输入这个相同的号码就可以进去，有点类似面对面建群。

### 生成和存储

组队码是随机生成的四位数，在判断不重复之后插入数据库和 Redis 中，有效期五分钟，在过期之前，收到请求时都会从 redis 取出，然后将用户团队对象插入/删除。

具体实现是这样的：

```java
public void joinTeam(String teamCode, Long userId) {
        // 这里没有就是错误
        TempTeam team = lambdaQuery().eq(TempTeam::getPwd, teamCode).one();
        if (team == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND);
        }

        // 这里没了就是过期
        TempTeam redisTeam = (TempTeam) redisService.get("temp_team_" + team.getId());
        if (redisTeam == null) {
            throw new BusinessException(ErrorCode.CODE_HAS_EXPIRED);
        }

        // 避免重复插入
        boolean isMember = userTempTeamService.lambdaQuery()
                .eq(UserTempTeam::getUserId, userId)
                .eq(UserTempTeam::getTempTeamId, redisTeam.getId())
                .count() > 0;
        if (isMember) {
            throw new BusinessException(ErrorCode.ALREADY_IN_TEAM);
        }

        UserTempTeam userTempTeam = new UserTempTeam();
        userTempTeam.setTempTeamId(redisTeam.getId());
        userTempTeam.setUserId(userId);
        userTempTeamService.save(userTempTeam);
        // 这里的通知用于实时刷新
        notifyTeamUpdate(redisTeam.getId());
    }
```

### 实时列表刷新

组队列表还要求实时性，于是我们需要一个服务器端事件（SSE）来实现：

首先是事件触发的部分：

```java
public void addEmitter(Long teamId, SseEmitter emitter) {
        emitters.put(teamId, emitter);
    }

    public void removeEmitter(Long teamId) {
        emitters.remove(teamId);
    }

    public void notifyTeamUpdate(Long teamId) {
        SseEmitter emitter = emitters.get(teamId);
        if (emitter != null) {
            try {
                TeamInfo teamInfo = getTeamInfoById(teamId, null);
                emitter.send(SseEmitter.event().name("teamUpdate").data(teamInfo));
            } catch (Exception e) {
                emitters.remove(teamId);
            }
        }
    }
```

然后提供一个服务器事件的 api 端点：

```java
@GetMapping(value = "/stream/{id}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public ResponseEntity<SseEmitter> streamTeamUpdates(@PathVariable Long id) {
        TempTeam team = (TempTeam) redisService.get("temp_team_" + id);
        if (team == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND);
        }

        SseEmitter emitter = new SseEmitter();
        tempTeamService.addEmitter(id, emitter);

        emitter.onCompletion(() -> tempTeamService.removeEmitter(id));
        emitter.onTimeout(() -> tempTeamService.removeEmitter(id));
        emitter.onError((ex) -> tempTeamService.removeEmitter(id));

        // 心跳保持连接
        Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
            try {
                emitter.send(SseEmitter.event().name("heartbeat").data("keep-alive"));
            } catch (Exception e) {
                tempTeamService.removeEmitter(id);
            }
        }, 0, 3, TimeUnit.SECONDS); 

        // 当过期时主动断开和标记结束
        Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
            if (redisService.get("temp_team_" + id) == null) {
                try {
                    emitter.send(SseEmitter.event().name("finish").data("Team expired"));
                    emitter.complete();
                } catch (Exception e) {
                    tempTeamService.removeEmitter(id);
                }
            }
        }, 0, 1, TimeUnit.SECONDS);

        return ResponseEntity.ok(emitter);
    }
```

## 简单总结

大概就是这样，在经过网络安全评测之后我们也终于迁入了校内，有了统一的身份管理和鉴权措施，另外管理端也从无到有...希望这个项目能继续用下去吧，不出问题就是最大的幸运了，短时间写 crud 感觉要吐了
