个人随笔
目录
AI生成的:微信小程序景区签到技术方案分析(附完整落地代码)
2026-05-06 11:14:01
在智慧景区建设中,签到功能是提升用户互动性、统计景区流量、发放权益(如积分、集章)的核心功能之一。但微信小程序场景下,景区签到面临一个核心痛点:前端定位易被篡改,多层代理导致真实IP难以获取,传统签到方案易被作弊,无法满足景区“低成本、高防作弊、易落地”的核心需求。
本文结合实际开发场景,从痛点分析、方案演进、最终落地实现三个维度,详细拆解微信小程序景区签到的技术方案,提供可直接复制使用的代码,同时解答开发中常见的坑点(如多层代理IP获取、虚拟定位防御),适合前端、后端开发人员参考。

纯线上所有参数(定位、时间戳、Nonce、设备信息、签名)均可被抓包篡改或虚拟定位伪造,只能通过行为轨迹拉高作弊门槛,无法从根源杜绝;唯有依托线下现场硬件生成一次性有效凭证,签到强制校验该凭证,才能真正做到「人不到现场绝对无法签到」,是唯一从物理层面根治虚拟定位和抓包改包的终极方案。

所有的技术防作弊、架构设计、校验逻辑,从来不是越复杂越好,而是必须和业务场景强绑定、按需设计。
  1. 办公考勤场景:作弊收益极高(不上班也打卡),必须上全套:定位校验、行为风控、设备绑定、严格防改包、轨迹分析,拉满防御。
  2. 普通线上积分签到场景:有小额权益,需要做基础防御:签名、Nonce、时间戳、简单限流,把小白作弊挡掉就行。
  3. 景区线下集章打卡场景:线上打卡只是记录,真正权益在线下现场领取。作弊没有实际收益,纯属白费功夫。所以完全不用过度设计,只做最简单的经纬度距离比对就完全够用

一句话道透本质:技术方案不是堆砌复杂度,而是先评估「作弊收益 vs 作弊成本」,再决定做到什么防护级别。


业务场景决定风险等级,风险等级决定技术复杂度,绝不盲目叠防御、过度设计

一、景区签到核心痛点(区别于办公打卡)

很多开发者会混淆景区签到与办公打卡的需求,导致方案设计冗余或防作弊不足。两者核心差异如下,也是我们设计方案的核心依据:
维度
办公打卡
景区签到(本次场景)
作弊动机
极高(迟到扣工资、影响考勤)
极低(仅为积分、集章等轻权益)
精度要求
极高(公司楼下50米内)
较低(景区方圆500-1000米均可)
用户行为
固定时间、固定地点
随机时间、移动状态(游客游览中)
作弊容忍度
零容忍(必须本人到岗)
容忍少量作弊(无需投入高成本防专业黑产)
基于以上差异,我们的方案核心目标的是:防住99%普通作弊者(虚拟定位App、抓包改包新手),放弃防专业Root/越狱玩家(成本高于收益),同时保证方案易落地、无第三方付费依赖

二、方案演进:从“无效防御”到“落地可行”

在实际开发中,我们尝试了多种方案,逐步排查无效逻辑,最终形成适配景区场景的最优解,过程如下:

2.1 初期无效方案(避坑指南)

初期方案因忽略小程序特性和作弊逻辑,均被验证无法防作弊,具体如下:
  1. 前端判断距离后上报:前端调用微信定位API获取经纬度,自行计算与景区的距离,符合条件再调用后端接口。弊端:前端逻辑可被轻易篡改(如修改距离计算代码),作弊者可直接绕过前端校验,调用后端接口签到。
  2. 后端生成Token/签名防作弊:前端先请求后端获取签到Token,再携带Token+经纬度上报。弊端:作弊者可先合法获取Token,再伪造经纬度提交,Token仅能防抓包重放,无法防虚拟定位。
  3. 百度服务端地理围栏:依赖百度鹰眼服务,后端调用百度API判断经纬度是否在景区围栏内。弊端:百度围栏仅判断坐标范围,不验证坐标真实性,作弊者伪造景区内坐标即可绕过,且需额外付费、多一层依赖,性价比低。

2.2 核心问题拆解

无效方案的核心问题的是:前端不可信、IP获取困难、过度依赖单一防御
  • 前端不可信:微信小程序的wx.getLocation()返回的经纬度,可通过虚拟定位App、Root/越狱设备篡改,无法从源头保证坐标真实性。
  • IP获取困难:线上系统多采用多层代理(CDN→WAF→Nginx→应用服务器),后端直接获取的IP多为代理IP,无法拿到用户真实IP,导致IP属地校验失效。
  • 单一防御不足:仅靠某一种校验(如签名、距离),无法覆盖所有作弊场景,作弊成本极低。

2.3 最终方案思路

放弃“验证坐标真实性”(小程序场景下无解),转向“验证行为合理性”——作弊者可伪造单一坐标,但难以伪造一整套合理的行为轨迹、设备信息,通过多层防御,大幅提升作弊成本,实现“低成本、高防作弊”。
最终方案:无IP版四层防御体系(接口防重放+后端距离校验+强化版位移速度风控+设备指纹+账号行为画像),无需第三方付费服务,纯后端逻辑实现,适配多层代理场景。

三、最终落地方案(附完整代码)

方案整体流程:用户点击签到→前端获取经纬度+设备信息→组装参数上报→后端四层校验→校验通过记录签到,全程无IP依赖,防作弊效果满足景区需求。

3.1 整体架构

采用“小程序前端-后端服务-数据层”三层架构,深度适配微信生态特性,无需额外引入第三方服务,降低开发和维护成本:
  • 前端:基于微信原生小程序框架,负责获取用户位置、设备信息,组装参数并调用后端接口,处理用户交互和权限提示。
  • 后端:采用Spring Boot框架,实现四层校验逻辑、数据存储和业务处理,核心依赖MySQL(存储签到记录)和Redis(防重放、限流)。
  • 数据层:MySQL存储用户签到记录、签到尝试记录;Redis缓存Nonce、限流信息,提升接口响应效率。

3.2 前端代码(微信小程序端,可直接复制)

核心功能:获取用户位置(处理权限)、获取设备信息、组装参数、调用后端签到接口,无任何前端校验逻辑(全部交给后端)。
// pages/checkin/checkin.js
Page({
  data: {
    scenicId: 1001, // 当前景区ID,从页面参数传入(如跳转时携带)
    scenicName: "广州白云山", // 景区名称(可选,用于页面展示)
    // 景区中心坐标(后端也会存储,前端仅用于展示,不参与校验)
    scenicLat: 23.1858,
    scenicLng: 113.3088,
    radius: 800, // 允许签到半径(米,后端统一配置,前端仅展示)
    isChecking: false // 防止重复点击
  },

  // 点击签到按钮(核心入口)
  async onCheckinClick() {
    if (this.data.isChecking) return;
    this.setData({ isChecking: true });

    try {
      // 1. 获取用户当前位置(微信官方API,坐标类型统一为gcj02)
      const location = await this.getUserLocation();
      console.log("获取到用户位置:", location);

      // 2. 获取设备信息(用于后端生成设备指纹,防多账号作弊)
      const deviceInfo = this.getDeviceInfo();

      // 3. 生成防重放参数(Nonce+时间戳)
      const timestamp = Math.floor(Date.now() / 1000); // 秒级时间戳
      const nonce = Math.random().toString(36).substring(2, 8); // 6位随机字符串

      // 4. 调用后端签到接口(替换为你的真实接口地址)
      const res = await wx.request({
        url: "https://your-domain/api/scenic/checkin",
        method: "POST",
        header: {
          "Content-Type": "application/json",
          "Authorization": "Bearer " + wx.getStorageSync("token") // 用户登录态(根据你的登录逻辑调整)
        },
        data: {
          scenicId: this.data.scenicId,
          lat: location.latitude, // 纬度
          lng: location.longitude, // 经度
          timestamp: timestamp,
          nonce: nonce,
          deviceInfo: deviceInfo // 设备信息,用于后端生成指纹
        }
      });

      // 处理接口响应
      if (res.data.code === 200) {
        wx.showToast({ title: "签到成功!", icon: "success" });
        // 可添加跳转逻辑(如跳转到签到记录页)
      } else {
        wx.showModal({
          title: "签到失败",
          content: res.data.msg || "位置异常,请勿使用虚拟定位",
          showCancel: false
        });
      }
    } catch (err) {
      console.error("签到失败:", err);
      wx.showModal({
        title: "签到失败",
        content: err.message || "请检查网络和位置权限",
        showCancel: false
      });
    } finally {
      this.setData({ isChecking: false });
    }
  },

  // 封装:获取用户位置(处理权限拒绝场景)
  getUserLocation() {
    return new Promise((resolve, reject) => {
      wx.getLocation({
        type: "gcj02", // 微信小程序默认国测局坐标,后端统一用此类型
        success: resolve,
        fail: (err) => {
          // 权限错误处理
          if (err.errCode === 2) {
            reject(new Error("请开启手机定位服务"));
          } else if (err.errCode === 11) {
            // 用户拒绝授权,引导前往设置
            wx.showModal({
              title: "需要位置权限",
              content: "请在设置中允许小程序获取您的位置,否则无法完成签到",
              confirmText: "去设置",
              success: (res) => {
                if (res.confirm) {
                  wx.openSetting(); // 跳转微信设置页
                }
              }
            });
            reject(new Error("未授权位置权限"));
          } else {
            reject(new Error("获取位置失败,请重试"));
          }
        }
      });
    });
  },

  // 封装:获取设备信息(用于后端生成设备指纹)
  getDeviceInfo() {
    const systemInfo = wx.getSystemInfoSync();
    // 提取关键设备信息(避免敏感信息,仅用于生成指纹)
    return {
      platform: systemInfo.platform, // 系统平台:ios/android/devtools
      system: systemInfo.system, // 系统版本:iOS 16.0/Android 13
      model: systemInfo.model, // 设备型号:iPhone 14 Pro/Huawei Mate 50
      brand: systemInfo.brand, // 设备品牌:Apple/Huawei
      version: systemInfo.version, // 微信版本号
      SDKVersion: systemInfo.SDKVersion, // 小程序基础库版本
      screenWidth: systemInfo.screenWidth, // 屏幕宽度
      screenHeight: systemInfo.screenHeight // 屏幕高度
    };
  }
});

3.3 后端代码(Spring Boot,可直接复制)

核心功能:实现四层校验逻辑,处理签到请求,记录签到数据,核心依赖Redis(防重放、限流)、MySQL(存储数据),无需IP参与校验。

3.3.1 核心工具类:球面距离计算(Haversine公式)

用于后端计算用户坐标与景区中心的距离,替代百度围栏,无需第三方依赖,计算精度满足景区需求。Haversine公式是一种用于计算球面上两个点之间最短距离(大圆距离)的数学公式,广泛应用于地理信息系统中,适用于地球表面的距离计算,优点是适用于小距离计算且不涉及复杂三角函数。
import org.springframework.util.DigestUtils;
import java.nio.charset.StandardCharsets;

/**
 * 地理坐标工具类(核心:球面距离计算)
 */
public class GeoUtils {
    // 地球平均半径(米)
    private static final double EARTH_RADIUS = 6378137;

    /**
     * 计算两个经纬度之间的直线距离(米)
     * @param lat1 点1纬度
     * @param lng1 点1经度
     * @param lat2 点2纬度
     * @param lng2 点2经度
     * @return 距离(米,四舍五入取整)
     */
    public static double calculateDistance(double lat1, double lng1, double lat2, double lng2) {
        // 将经纬度转换为弧度
        double radLat1 = Math.toRadians(lat1);
        double radLat2 = Math.toRadians(lat2);
        double a = radLat1 - radLat2;
        double b = Math.toRadians(lng1) - Math.toRadians(lng2);
        
        // 应用Haversine公式计算距离
        double s = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(a / 2), 2) +
                Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2)));
        return Math.round(s * EARTH_RADIUS);
    }

    /**
     * 生成设备指纹(基于前端传入的设备信息,MD5哈希加密)
     * @param deviceInfo 前端传入的设备信息
     * @return 唯一设备指纹(32位MD5)
     */
    public static String generateDeviceFingerprint(DeviceInfo deviceInfo) {
        // 拼接设备关键信息,生成原始字符串
        String raw = deviceInfo.getPlatform() + "|" + deviceInfo.getSystem() + "|" +
                deviceInfo.getModel() + "|" + deviceInfo.getBrand() + "|" +
                deviceInfo.getVersion() + "|" + deviceInfo.getSDKVersion();
        // MD5哈希生成唯一指纹
        return DigestUtils.md5DigestAsHex(raw.getBytes(StandardCharsets.UTF_8));
    }
}

3.3.2 实体类与DTO(请求/响应模型)

import lombok.Data;

// 1. 签到请求DTO(接收前端传入的参数)
@Data
public class ScenicCheckinRequest {
    private Long scenicId; // 景区ID
    private Double lat; // 用户纬度
    private Double lng; // 用户经度
    private Long timestamp; // 时间戳(秒)
    private String nonce; // 随机字符串(防重放)
    private DeviceInfo deviceInfo; // 设备信息
}

// 2. 设备信息DTO(接收前端传入的设备参数)
@Data
public class DeviceInfo {
    private String platform;
    private String system;
    private String model;
    private String brand;
    private String version;
    private String SDKVersion;
    private Integer screenWidth;
    private Integer screenHeight;
}

// 3. 签到尝试记录表实体(用于轨迹校验)
@Data
@TableName("user_checkin_attempt")
public class UserCheckinAttempt {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long userId; // 用户ID
    private Long scenicId; // 景区ID
    private Double latitude; // 尝试签到的纬度
    private Double longitude; // 尝试签到的经度
    private Long attemptTime; // 尝试时间戳(秒)
    private Boolean isSuccess; // 是否签到成功
    private String abnormalType; // 异常类型(如“瞬移”“距离突变”)
}

// 4. 签到记录表实体(存储成功签到记录)
@Data
@TableName("user_checkin_record")
public class UserCheckinRecord {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long userId; // 用户ID
    private Long scenicId; // 景区ID
    private Double latitude; // 签到纬度
    private Double longitude; // 签到经度
    private Long checkinTime; // 签到时间戳(秒)
    private String deviceFingerprint; // 设备指纹
}

3.3.3 核心接口:签到接口(四层校验逻辑)

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.concurrent.TimeUnit;

@RestController
@RequestMapping("/api/scenic")
public class ScenicCheckinController {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private ScenicService scenicService; // 景区服务(查询景区坐标、半径)

    @Autowired
    private UserCheckinService userCheckinService; // 签到服务(记录、查询)

    // 常量配置(可提取到配置文件)
    private static final long TIMESTAMP_TOLERANCE = 120; // 时间戳误差(2分钟)
    private static final double MAX_ALLOWED_SPEED = 300; // 最大允许速度(km/h,适配高铁场景)
    private static final long NONCE_EXPIRE = 120; // Nonce过期时间(2分钟)
    private static final long RATE_LIMIT_EXPIRE = 60; // 限流时间(1分钟)
    private static final double DISTANCE突变_THRESHOLD = 5; // 距离突变阈值(5公里)
    private static final double TIME突变_THRESHOLD = 1.0/60; // 时间突变阈值(1分钟)

    /**
     * 景区签到接口(核心接口)
     */
    @PostMapping("/checkin")
    public ResponseEntity<?> checkin(@RequestBody ScenicCheckinRequest request,
                                     @RequestHeader("Authorization") String token) {
        // 1. 解析用户ID(从登录Token中获取,根据你的登录逻辑实现)
        Long userId = getUserIdFromToken(token);
        if (userId == null) {
            return ResponseEntity.status(401).body("未登录,请先登录");
        }

        // 第一层校验:防重放 + 时间戳 + 限流(防脚本批量刷签到)
        long now = System.currentTimeMillis() / 1000;
        // 1.1 时间戳校验(防止请求被篡改、过期)
        if (Math.abs(now - request.getTimestamp()) > TIMESTAMP_TOLERANCE) {
            return ResponseEntity.badRequest().body("请求超时,请重试");
        }
        // 1.2 Nonce防重放(同一用户的Nonce只能用一次)
        String nonceKey = "checkin:nonce:" + userId + ":" + request.getNonce();
        if (Boolean.TRUE.equals(redisTemplate.hasKey(nonceKey))) {
            return ResponseEntity.badRequest().body("请勿重复提交签到请求");
        }
        redisTemplate.opsForValue().set(nonceKey, 1, NONCE_EXPIRE, TimeUnit.SECONDS);
        // 1.3 接口限流(同一用户1分钟只能签到1次)
        String rateLimitKey = "checkin:rate:" + userId;
        if (Boolean.TRUE.equals(redisTemplate.hasKey(rateLimitKey))) {
            return ResponseEntity.badRequest().body("操作太频繁,请1分钟后再试");
        }
        redisTemplate.opsForValue().set(rateLimitKey, 1, RATE_LIMIT_EXPIRE, TimeUnit.SECONDS);

        // 第二层校验:后端强制距离校验(核心,防乱填坐标)
        Scenic scenic = scenicService.getById(request.getScenicId());
        if (scenic == null) {
            return ResponseEntity.badRequest().body("景区不存在");
        }
        // 计算用户坐标与景区中心的距离(米)
        double distance = GeoUtils.calculateDistance(
                request.getLat(), request.getLng(),
                scenic.getLatitude(), scenic.getLongitude()
        );
        // 超过景区允许的签到半径,直接拒绝
        if (distance > scenic.getCheckinRadius()) {
            // 记录失败尝试
            userCheckinService.recordCheckinAttempt(userId, request.getScenicId(),
                    request.getLat(), request.getLng(), now, false, "不在景区范围内");
            return ResponseEntity.badRequest().body("您不在景区范围内,无法签到");
        }

        // 第三层校验:强化版位移速度风控(核心防虚拟定位,无IP依赖)
        // 获取用户最近1小时内所有签到尝试(包括失败的,用于轨迹校验)
        List<UserCheckinAttempt> recentAttempts = userCheckinService.getRecentAttempts(userId, 3600);
        if (!recentAttempts.isEmpty()) {
            // 按时间倒序排列(最新的尝试在前面)
            recentAttempts.sort((a, b) -> Long.compare(b.getAttemptTime(), a.getAttemptTime()));
            UserCheckinAttempt lastAttempt = recentAttempts.get(0);

            // 3.1 计算本次与上一次尝试的速度(km/h)
            double timeDiffHours = (now - lastAttempt.getAttemptTime()) / 3600.0;
            double distanceKm = GeoUtils.calculateDistance(
                    request.getLat(), request.getLng(),
                    lastAttempt.getLatitude(), lastAttempt.getLongitude()
            ) / 1000.0;
            double speed = distanceKm / timeDiffHours;

            // 3.2 距离突变检测(1分钟内移动超过5公里,判定为作弊)
            if (timeDiffHours < TIME突变_THRESHOLD && distanceKm > DISTANCE突变_THRESHOLD) {
                userCheckinService.recordCheckinAttempt(userId, request.getScenicId(),
                        request.getLat(), request.getLng(), now, false, "距离突变");
                return ResponseEntity.badRequest().body("位置异常,请勿使用虚拟定位");
            }

            // 3.3 连续3次尝试平均速度检测(超过300km/h,判定为作弊)
            if (recentAttempts.size() >= 3) {
                double totalDistance = 0;
                double totalTime = 0;
                for (int i = 0; i < 2; i++) {
                    UserCheckinAttempt current = recentAttempts.get(i);
                    UserCheckinAttempt previous = recentAttempts.get(i + 1);
                    totalDistance += GeoUtils.calculateDistance(
                            current.getLatitude(), current.getLongitude(),
                            previous.getLatitude(), previous.getLongitude()
                    ) / 1000.0;
                    totalTime += (current.getAttemptTime() - previous.getAttemptTime()) / 3600.0;
                }
                double avgSpeed = totalDistance / totalTime;
                if (avgSpeed > MAX_ALLOWED_SPEED) {
                    userCheckinService.recordCheckinAttempt(userId, request.getScenicId(),
                            request.getLat(), request.getLng(), now, false, "连续高速移动");
                    return ResponseEntity.badRequest().body("位置异常,请勿使用虚拟定位");
                }
            }

            // 3.4 单次速度检测(超过500km/h,判定为瞬移作弊)
            if (speed > 500) {
                userCheckinService.recordCheckinAttempt(userId, request.getScenicId(),
                        request.getLat(), request.getLng(), now, false, "疑似瞬移");
                return ResponseEntity.badRequest().body("位置异常,请勿使用虚拟定位");
            }
        }

        // 第四层校验:设备指纹 + 账号行为画像(防多账号、模拟器作弊)
        // 4.1 生成设备指纹
        String deviceFingerprint = GeoUtils.generateDeviceFingerprint(request.getDeviceInfo());
        // 4.2 设备行为风控(可根据业务扩展)
        // ① 模拟器检测(platform=devtools,直接拒绝)
        if ("devtools".equals(request.getDeviceInfo().getPlatform())) {
            userCheckinService.recordCheckinAttempt(userId, request.getScenicId(),
                    request.getLat(), request.getLng(), now, false, "模拟器作弊");
            return ResponseEntity.badRequest().body("请勿使用模拟器签到");
        }
        // ② 同一设备多账号校验(查询该设备指纹绑定的用户数,超过3个判定异常)
        long userCountByDevice = userCheckinService.countUserByDeviceFingerprint(deviceFingerprint);
        if (userCountByDevice > 3) {
            userCheckinService.recordCheckinAttempt(userId, request.getScenicId(),
                    request.getLat(), request.getLng(), now, false, "同一设备多账号作弊");
            return ResponseEntity.badRequest().body("同一设备不可使用多个账号签到");
        }
        // ③ 新账号风控(注册时间<24小时,可增加额外校验,如滑动验证码)
        boolean isNewUser = userCheckinService.isNewUser(userId);
        if (isNewUser) {
            // 此处可添加滑动验证码、短信验证等额外校验(可选)
        }

        // 所有校验通过,记录签到成功
        // 1. 记录签到尝试(成功)
        userCheckinService.recordCheckinAttempt(userId, request.getScenicId(),
                request.getLat(), request.getLng(), now, true, null);
        // 2. 记录正式签到记录
        UserCheckinRecord checkinRecord = new UserCheckinRecord();
        checkinRecord.setUserId(userId);
        checkinRecord.setScenicId(request.getScenicId());
        checkinRecord.setLatitude(request.getLat());
        checkinRecord.setLongitude(request.getLng());
        checkinRecord.setCheckinTime(now);
        checkinRecord.setDeviceFingerprint(deviceFingerprint);
        userCheckinService.saveCheckinRecord(checkinRecord);

        return ResponseEntity.ok("签到成功");
    }

    /**
     * 辅助方法:从Token中解析用户ID(根据你的登录逻辑实现,如JWT解析)
     */
    private Long getUserIdFromToken(String token) {
        // 示例:替换为你的JWT解析逻辑
        // 这里简化处理,实际开发中需解析Token中的用户信息
        return 1L;
    }
}

3.3.4 数据库表设计(MySQL)

核心表:签到尝试表(用于轨迹校验)、签到记录表(用于存储成功签到数据),支持后续行为分析和异常排查。
-- 景区表(存储景区信息,如中心坐标、签到半径)
CREATE TABLE `scenic` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '景区ID',
  `name` varchar(100) NOT NULL COMMENT '景区名称',
  `latitude` double NOT NULL COMMENT '景区中心纬度',
  `longitude` double NOT NULL COMMENT '景区中心经度',
  `checkin_radius` int NOT NULL DEFAULT 800 COMMENT '签到半径(米)',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='景区信息表';

-- 用户签到尝试记录表(用于位移轨迹校验,无论成功失败都记录)
CREATE TABLE `user_checkin_attempt` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `scenic_id` bigint NOT NULL COMMENT '景区ID',
  `latitude` double NOT NULL COMMENT '尝试签到纬度',
  `longitude` double NOT NULL COMMENT '尝试签到经度',
  `attempt_time` bigint NOT NULL COMMENT '尝试时间戳(秒)',
  `is_success` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否成功(0-失败,1-成功)',
  `abnormal_type` varchar(50) DEFAULT NULL COMMENT '异常类型(如“瞬移”“距离突变”)',
  PRIMARY KEY (`id`),
  KEY `idx_user_time` (`user_id`, `attempt_time`) COMMENT '用户+时间索引,提升查询效率'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户签到尝试记录表';

-- 用户签到记录表(存储成功签到记录)
CREATE TABLE `user_checkin_record` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `scenic_id` bigint NOT NULL COMMENT '景区ID',
  `latitude` double NOT NULL COMMENT '签到纬度',
  `longitude` double NOT NULL COMMENT '签到经度',
  `checkin_time` bigint NOT NULL COMMENT '签到时间戳(秒)',
  `device_fingerprint` varchar(32) NOT NULL COMMENT '设备指纹(MD5)',
  PRIMARY KEY (`id`),
  KEY `idx_user_scenic` (`user_id`, `scenic_id`) COMMENT '用户+景区索引,防止重复签到'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户签到记录表';

四、防作弊效果评估与常见问题

4.1 防作弊效果评估

本方案针对景区签到场景优化,无需高成本投入,防作弊效果可覆盖99%的常见场景:
用户类型
是否能绕过
说明
普通小白用户
❌ 完全防住
不了解虚拟定位,无法绕过任何一层校验
会用虚拟定位App的用户
❌ 基本防住
无法伪造连续轨迹和设备信息,会被位移风控、设备指纹拦死
会抓包改包的新手
❌ 防住
无法绕过Nonce防重放和时间戳校验,无法构造合法请求
懂技术的程序员
⚠️ 极难绕过
需同时伪造合理坐标、连续轨迹、真实设备信息,作弊成本极高
专业黑产/越狱玩家
✅ 可以绕过
但景区签到无高价值权益,作弊收益低于成本,无需专门防御

4.2 常见问题与解决方案

  1. 多层代理下,如何尝试获取真实IP(可选):如果运维可配合,可在每一层代理(CDN、WAF、Nginx)配置IP透传,后端通过可信代理白名单解析真实IP,配置示例如下(Nginx): # Nginx配置(透传真实IP)location / { proxy_pass http://backend; # 后端服务地址 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 多层代理时,配置可信代理IP段 set_real_ip_from 10.0.0.0/8; # 内网代理IP段 set_real_ip_from 192.168.0.0/16; real_ip_header X-Forwarded-For; real_ip_recursive on;}
  2. 误杀正常用户怎么办:景区场景下,可适当放宽速度阈值(如高铁场景放宽到250km/h),IP属地校验(若能获取)仅作为标记,不直接拒绝;新用户额外校验可选择滑动验证码,避免误杀。
  3. 如何优化接口性能:Redis缓存Nonce、限流信息,提升接口响应速度;签到尝试表可按时间分区(如按月份),减少查询压力;球面距离计算为纯数学运算,性能消耗极低,无需优化。

五、方案总结

本文提出的微信小程序景区签到方案,核心优势在于“低成本、易落地、高防作弊”,适配多层代理无IP场景,无需依赖任何第三方付费服务,完全基于原生技术实现。
核心逻辑总结:放弃“验证坐标真实性”,转向“验证行为合理性”,通过四层防御体系,大幅提升作弊成本,同时兼顾用户体验(无复杂操作)和开发成本(代码可直接复制使用)。
对于景区签到场景而言,无需追求“100%防作弊”,只要把作弊成本提高到“比亲自去景区签到还麻烦”,就是最优方案。本方案可直接落地使用,也可根据景区具体需求(如积分兑换、多景点连续签到)灵活扩展。
 8

啊!这个可能是世界上最丑的留言输入框功能~


当然,也是最丑的留言列表

有疑问发邮件到 : suibibk@qq.com 侵权立删
Copyright : 个人随笔   备案号 : 粤ICP备18099399号-2