🤖
AI审核中

微信公众号API+Redis实现内容访问控制

  Java   72分钟   137浏览   1评论
AI
AI智能摘要
正在分析文章内容

功能概述

业务需求

为了实现博客内容的精细化运营,同时增加公众号粉丝关注量,开发了基于微信公众号验证码的文章解锁功能。该功能的核心流程如下:

  1. 内容展示控制:未解锁用户只能阅读文章的前1/3内容
  2. 解锁引导:在文章底部显示"查看全文"按钮,引导用户关注公众号
  3. 验证码获取:用户关注公众号后发送关键词"博客",自动获得6位数字验证码
  4. 全局解锁:验证成功后,用户可解锁所有文章,且状态持久化保存

功能特性

  • ✅ 前端内容截断与模糊遮罩效果
  • ✅ 微信公众号自动回复验证码
  • ✅ Redis存储验证码,支持5分钟有效期
  • ✅ 验证码单次使用机制
  • ✅ LocalStorage持久化解锁状态
  • ✅ 响应式设计,支持移动端和桌面端
  • ✅ 平滑的解锁动画效果

核心技术原理

1. 前端内容控制原理

采用CSS max-height 结合渐变遮罩实现内容截断效果:

.article-content-wrapper {
    max-height: 600px;  /* 根据内容动态计算 */
    overflow: hidden;
    position: relative;
}

.content-mask {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    height: 150px;
    background: linear-gradient(to bottom, transparent, rgba(255,255,255,0.9));
    pointer-events: none;
}

2. 微信公众号消息推送机制

微信公众平台采用服务器推送模式,当用户发送消息时,微信服务器会向开发者配置的URL发送POST请求,请求体为XML格式:

<xml>
    <ToUserName><![CDATA[gh_xxx]]></ToUserName>
    <FromUserName><![CDATA[openid_xxx]]></FromUserName>
    <CreateTime>123456789</CreateTime>
    <MsgType><![CDATA[text]]></MsgType>
    <Content><![CDATA[博客]]></Content>
</xml>

开发者需要解析XML消息,根据内容生成响应并返回给微信服务器。

3. 验证码安全机制

  • 存储方式:使用Redis存储,Key格式为 verify:code:article_unlock:{验证码}
  • 过期策略:设置5分钟TTL,过期自动删除
  • 单次有效:验证成功后立即删除Redis中的Key
  • 防暴力破解:验证码为6位纯数字,有效期短,且使用后失效

4. 全局状态管理

采用浏览器LocalStorage保存解锁状态,避免用户重复验证:

// 标记全局解锁状态
function markGlobalUnlocked() {
    localStorage.setItem('article_unlocked', 'true');
    localStorage.setItem('article_unlock_time', new Date().toISOString());
}

系统架构设计

整体架构图

┌─────────────────┐      ┌─────────────────┐      ┌─────────────────┐
│   用户浏览器     │      │   Spring Boot   │      │   Redis服务器   │
│                 │      │    后端服务      │      │                 │
│ ┌─────────────┐ │      │                 │      │                 │
│ │ 文章详情页面 │ │◄────►│ ┌─────────────┐ │      │ ┌─────────────┐ │
│ │ - 内容截断  │ │ AJAX │ │验证码Controller│ │◄────►│ │ 验证码存储  │ │
│ │ - 解锁按钮  │ │      │ └─────────────┘ │      │ │ - Key: Code │ │
│ │ - 验证弹窗  │ │      │                 │      │ │ - TTL: 5min │ │
│ └─────────────┘ │      │ ┌─────────────┐ │      │ └─────────────┘ │
│                 │      │ │微信消息Controller│ │      │                 │
└─────────────────┘      │ └─────────────┘ │      └─────────────────┘
         │               │                 │               ▲
         │               └─────────────────┘               │
         │                      ▲                          │
         │                      │                          │
         │               ┌──────┴──────┐                   │
         │               │  微信服务器  │                   │
         │               │             │───────────────────┘
         │               │ - 消息推送  │   存储验证码
         │               │ - 签名验证  │
         └──────────────►│             │
            扫码关注      └─────────────┘

核心组件说明

组件 职责 技术实现
前端页面 内容展示、解锁交互、状态管理 HTML + CSS + JavaScript
VerifyCodeController 验证码验证API Spring Boot REST API
WechatMpController 微信服务器接入与消息处理 Spring Boot Controller
VerifyCodeService 验证码生成与验证逻辑 Redis + 随机数生成
WechatMpService 微信消息解析与响应构建 XML解析 + SHA1签名验证

详细实现步骤

第一步:后端基础架构搭建

1.1 添加依赖配置

pom.xml 中添加Spring Data Redis依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

1.2 配置Redis连接

application.yaml 中添加Redis配置:

spring:
  data:
    redis:
      host: localhost
      port: 6379
      database: 0
      timeout: 60000ms
      lettuce:
        pool:
          max-active: 8
          max-idle: 8
          min-idle: 0

1.3 配置RedisTemplate

创建 RedisConfig.java 配置序列化方式:

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        StringRedisSerializer stringSerializer = new StringRedisSerializer();
        template.setKeySerializer(stringSerializer);
        template.setHashKeySerializer(stringSerializer);
        template.setValueSerializer(stringSerializer);
        template.setHashValueSerializer(stringSerializer);

        template.afterPropertiesSet();
        return template;
    }
}

第二步:验证码服务实现

2.1 定义服务接口

创建 VerifyCodeService.java

public interface VerifyCodeService {
    /**
     * 生成验证码
     * @param scene 使用场景
     * @return 生成的验证码
     */
    String generateCode(String scene);

    /**
     * 验证验证码(不删除)
     * @param code 验证码
     * @param scene 使用场景
     * @return 是否有效
     */
    boolean verifyCode(String code, String scene);

    /**
     * 验证并消费验证码(验证成功后删除)
     * @param code 验证码
     * @param scene 使用场景
     * @return 是否验证成功
     */
    boolean verifyAndConsumeCode(String code, String scene);
}

2.2 实现验证码服务

创建 VerifyCodeServiceImpl.java

@Service
public class VerifyCodeServiceImpl implements VerifyCodeService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    private static final long CODE_EXPIRE_MINUTES = 5;
    private static final int CODE_LENGTH = 6;
    private static final String CODE_KEY_PREFIX = "verify:code:";

    @Override
    public String generateCode(String scene) {
        String code = generateRandomNumericCode();
        String key = CODE_KEY_PREFIX + scene + ":" + code;

        // 存储到Redis,设置过期时间
        redisTemplate.opsForValue().set(key, scene, CODE_EXPIRE_MINUTES, TimeUnit.MINUTES);

        return code;
    }

    @Override
    public boolean verifyAndConsumeCode(String code, String scene) {
        if (code == null || code.trim().isEmpty()) {
            return false;
        }

        code = code.trim();
        String key = CODE_KEY_PREFIX + scene + ":" + code;
        Object storedScene = redisTemplate.opsForValue().get(key);

        // 使用toString()比较,确保类型安全
        if (storedScene != null && storedScene.toString().equals(scene.toString())) {
            // 验证成功,删除验证码(单次有效)
            redisTemplate.delete(key);
            return true;
        }

        return false;
    }

    private String generateRandomNumericCode() {
        Random random = new Random();
        StringBuilder code = new StringBuilder();
        for (int i = 0; i < CODE_LENGTH; i++) {
            code.append(random.nextInt(10));
        }
        return code.toString();
    }
}

第三步:微信公众号接入

3.1 配置微信参数

创建 WechatMpProperties.java

@Component
@ConfigurationProperties(prefix = "wechat.mp")
public class WechatMpProperties {
    private boolean enabled = false;
    private String appId;
    private String appSecret;
    private String token;
    private String encodingAesKey;

    // Getters and Setters
}

application.yaml 中配置:

wechat:
  mp:
    enabled: true
    app-id: your-app-id
    app-secret: your-app-secret
    token: your-token
    encoding-aes-key: your-encoding-aes-key

3.2 实现微信消息处理

创建 WechatMpServiceImpl.java

@Service
public class WechatMpServiceImpl implements WechatMpService {

    @Autowired
    private WechatMpProperties wechatMpProperties;

    @Autowired
    private VerifyCodeService verifyCodeService;

    private static final String TRIGGER_KEYWORD = "博客";
    private static final String SCENE_ARTICLE_UNLOCK = "article_unlock";

    @Override
    public String verifyServer(String signature, String timestamp, String nonce, String echostr) {
        if (checkSignature(signature, timestamp, nonce)) {
            return echostr;
        }
        return null;
    }

    @Override
    public String handleMessage(String requestBody) {
        if (!wechatMpProperties.isEnabled()) {
            return "";
        }

        try {
            String msgType = extractXmlValue(requestBody, "MsgType");
            String fromUserName = extractXmlValue(requestBody, "FromUserName");
            String toUserName = extractXmlValue(requestBody, "ToUserName");

            if ("text".equals(msgType)) {
                String content = extractXmlValue(requestBody, "Content");
                return handleTextMessage(fromUserName, toUserName, content);
            } else if ("event".equals(msgType)) {
                String event = extractXmlValue(requestBody, "Event");
                if ("subscribe".equals(event)) {
                    return buildTextResponse(fromUserName, toUserName,
                            "🎉 欢迎关注!\n\n发送「博客」获取验证码,解锁完整文章阅读权限。\n\n验证码5分钟内有效,仅限使用一次。");
                }
            }

            return "";
        } catch (Exception e) {
            return "";
        }
    }

    private String handleTextMessage(String fromUserName, String toUserName, String content) {
        if (content == null) {
            return "";
        }

        content = content.trim();

        if (TRIGGER_KEYWORD.equals(content)) {
            String code = verifyCodeService.generateCode(SCENE_ARTICLE_UNLOCK);

            String replyText = String.format(
                    "🔐 您的验证码是:%s\n\n" +
                            "⏰ 有效期:5分钟\n" +
                            "🔢 使用次数:仅限1次\n\n" +
                            "请在文章页面输入此验证码解锁全文阅读。",
                    code
            );

            return buildTextResponse(fromUserName, toUserName, replyText);
        }

        return buildTextResponse(fromUserName, toUserName,
                "💡 发送「博客」获取验证码,解锁完整文章阅读权限。");
    }

    private boolean checkSignature(String signature, String timestamp, String nonce) {
        String token = wechatMpProperties.getToken();
        if (token == null || token.isEmpty()) {
            return false;
        }

        String[] arr = new String[]{token, timestamp, nonce};
        Arrays.sort(arr);
        StringBuilder content = new StringBuilder();
        for (String s : arr) {
            content.append(s);
        }

        String sha1 = sha1(content.toString());
        return sha1 != null && sha1.equals(signature);
    }

    private String sha1(String str) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA1");
            byte[] digest = md.digest(str.getBytes());
            StringBuilder sb = new StringBuilder();
            for (byte b : digest) {
                sb.append(String.format("%02x", b));
            }
            return sb.toString();
        } catch (Exception e) {
            return null;
        }
    }

    private String extractXmlValue(String xml, String fieldName) {
        String pattern = "<" + fieldName + ">\\s*<!\\[CDATA\\[(.*?)\\]\\]>\\s*</" + fieldName + ">";
        Pattern r = Pattern.compile(pattern, Pattern.DOTALL);
        Matcher m = r.matcher(xml);
        if (m.find()) {
            return m.group(1).trim();
        }

        pattern = "<" + fieldName + ">(.*?)</" + fieldName + ">";
        r = Pattern.compile(pattern, Pattern.DOTALL);
        m = r.matcher(xml);
        if (m.find()) {
            return m.group(1).trim();
        }

        return null;
    }

    private String buildTextResponse(String toUserName, String fromUserName, String content) {
        long createTime = System.currentTimeMillis() / 1000;

        return String.format(
                "<xml>" +
                        "<ToUserName><![CDATA[%s]]></ToUserName>" +
                        "<FromUserName><![CDATA[%s]]></FromUserName>" +
                        "<CreateTime>%d</CreateTime>" +
                        "<MsgType><![CDATA[text]]></MsgType>" +
                        "<Content><![CDATA[%s]]></Content>" +
                        "</xml>",
                toUserName, fromUserName, createTime, content
        );
    }
}

第四步:前端页面实现

4.1 文章页面结构

post.html 中添加解锁功能相关HTML:

<!-- 文章内容包装器 -->
<div id="article-content-wrapper" class="article-content-wrapper">
    <div class="post-content" th:utext="${post.postContent}"></div>

    <!-- 内容遮罩(未解锁时显示) -->
    <div id="content-mask" class="content-mask" style="display: none;"></div>
</div>

<!-- 解锁按钮区域 -->
<div id="unlock-section" class="unlock-section" style="display: none;">
    <div class="unlock-bar" onclick="openVerifyModal()">
        <span class="unlock-icon">🔓</span>
        <span class="unlock-text">关注公众号解锁全文</span>
        <span class="unlock-arrow"></span>
    </div>
</div>

<!-- 验证码验证弹窗 -->
<div id="verify-modal" class="verify-modal" style="display: none;">
    <div class="verify-modal-content">
        <div class="verify-header">
            <h3>验证解锁</h3>
            <button class="verify-close" onclick="closeVerifyModal()">&times;</button>
        </div>
        <div class="verify-body">
            <div class="qrcode-section">
                <div class="qrcode-img">
                    <img src="your-qrcode-image-url" alt="公众号二维码">
                </div>
                <p class="qrcode-tip">扫描二维码关注公众号<br>发送「博客」获取验证码</p>
            </div>
            <div class="verify-form">
                <div class="input-group">
                    <input type="text" id="verify-code" class="verify-input"
                           placeholder="请输入验证码" maxlength="6">
                    <button class="verify-submit-btn" onclick="verifyCode()">验证</button>
                </div>
                <p class="verify-error" id="verify-error" style="display: none;"></p>
            </div>
        </div>
    </div>
</div>

4.2 CSS样式

添加解锁功能相关样式:

/* 文章内容包装器 */
.article-content-wrapper {
    position: relative;
    max-height: 600px;
    overflow: hidden;
    transition: max-height 0.8s ease-out;
}

.article-content-wrapper.unlocked {
    max-height: none !important;
}

/* 内容遮罩 */
.content-mask {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    height: 150px;
    background: linear-gradient(to bottom, 
        rgba(255, 255, 255, 0) 0%, 
        rgba(255, 255, 255, 0.8) 50%,
        rgba(255, 255, 255, 0.95) 100%);
    pointer-events: none;
    z-index: 10;
}

/* 解锁按钮 */
.unlock-section {
    margin: 30px 0;
    text-align: center;
}

.unlock-bar {
    display: inline-flex;
    align-items: center;
    gap: 10px;
    padding: 15px 30px;
    background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 100%);
    color: white;
    border-radius: 30px;
    cursor: pointer;
    box-shadow: 0 4px 15px rgba(238, 90, 90, 0.3);
    transition: all 0.3s ease;
}

.unlock-bar:hover {
    transform: translateY(-2px);
    box-shadow: 0 6px 20px rgba(238, 90, 90, 0.4);
}

.unlock-icon {
    font-size: 20px;
}

.unlock-text {
    font-size: 16px;
    font-weight: 500;
}

.unlock-arrow {
    font-size: 18px;
    transition: transform 0.3s ease;
}

.unlock-bar:hover .unlock-arrow {
    transform: translateX(5px);
}

/* 验证弹窗 */
.verify-modal {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(0, 0, 0, 0.6);
    backdrop-filter: blur(4px);
    z-index: 1000;
    display: flex;
    align-items: center;
    justify-content: center;
    opacity: 0;
    visibility: hidden;
    transition: all 0.3s ease;
}

.verify-modal.show {
    opacity: 1;
    visibility: visible;
}

.verify-modal-content {
    background: white;
    border-radius: 16px;
    width: 90%;
    max-width: 420px;
    overflow: hidden;
    transform: scale(0.9);
    transition: transform 0.3s ease;
}

.verify-modal.show .verify-modal-content {
    transform: scale(1);
}

.verify-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 20px 24px;
    border-bottom: 1px solid #f0f0f0;
}

.verify-header h3 {
    margin: 0;
    font-size: 18px;
    color: #333;
}

.verify-close {
    background: none;
    border: none;
    font-size: 24px;
    color: #999;
    cursor: pointer;
    padding: 0;
    width: 32px;
    height: 32px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    transition: all 0.2s ease;
}

.verify-close:hover {
    background: #f5f5f5;
    color: #666;
}

.verify-body {
    padding: 24px;
}

.qrcode-section {
    text-align: center;
    margin-bottom: 24px;
}

.qrcode-img {
    width: 180px;
    height: 180px;
    margin: 0 auto 16px;
    padding: 12px;
    border: 1px solid #eee;
    border-radius: 8px;
}

.qrcode-img img {
    width: 100%;
    height: 100%;
    object-fit: contain;
}

.qrcode-tip {
    color: #666;
    font-size: 14px;
    line-height: 1.6;
    margin: 0;
}

.verify-form .input-group {
    display: flex;
    gap: 12px;
}

.verify-input {
    flex: 1;
    padding: 12px 16px;
    border: 2px solid #e0e0e0;
    border-radius: 8px;
    font-size: 16px;
    text-align: center;
    letter-spacing: 4px;
    transition: border-color 0.2s ease;
}

.verify-input:focus {
    outline: none;
    border-color: #ff6b6b;
}

.verify-submit-btn {
    padding: 12px 24px;
    background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 100%);
    color: white;
    border: none;
    border-radius: 8px;
    font-size: 15px;
    font-weight: 500;
    cursor: pointer;
    transition: all 0.2s ease;
}

.verify-submit-btn:hover {
    transform: translateY(-1px);
    box-shadow: 0 4px 12px rgba(238, 90, 90, 0.3);
}

.verify-submit-btn:disabled {
    opacity: 0.6;
    cursor: not-allowed;
    transform: none;
}

.verify-error {
    color: #ee5a5a;
    font-size: 13px;
    margin-top: 12px;
    text-align: center;
}

4.3 JavaScript逻辑

实现解锁功能的核心JavaScript代码:

// 文章解锁功能
(function() {
    // 检查是否已全局解锁
    function isGlobalUnlocked() {
        return localStorage.getItem('article_unlocked') === 'true';
    }

    // 标记全局解锁
    function markGlobalUnlocked() {
        localStorage.setItem('article_unlocked', 'true');
        localStorage.setItem('article_unlock_time', new Date().toISOString());
    }

    // 解锁文章
    function unlockArticle() {
        const wrapper = document.getElementById('article-content-wrapper');
        const mask = document.getElementById('content-mask');
        const unlockSection = document.getElementById('unlock-section');

        if (wrapper) {
            wrapper.classList.add('unlocked');
        }
        if (mask) {
            mask.style.display = 'none';
        }
        if (unlockSection) {
            unlockSection.style.display = 'none';
        }
    }

    // 检查是否需要锁定
    function checkAndLockContent() {
        // 如果已全局解锁,直接解锁
        if (isGlobalUnlocked()) {
            unlockArticle();
            return;
        }

        const wrapper = document.getElementById('article-content-wrapper');
        const mask = document.getElementById('content-mask');
        const unlockSection = document.getElementById('unlock-section');

        if (!wrapper) return;

        // 获取内容高度
        const contentHeight = wrapper.scrollHeight;
        const maxHeight = 600; // 显示的最大高度

        // 如果内容超过最大高度,显示锁定UI
        if (contentHeight > maxHeight) {
            wrapper.style.maxHeight = maxHeight + 'px';
            if (mask) mask.style.display = 'block';
            if (unlockSection) unlockSection.style.display = 'block';
        } else {
            // 内容较短,不需要锁定
            if (mask) mask.style.display = 'none';
            if (unlockSection) unlockSection.style.display = 'none';
        }
    }

    // 打开验证弹窗
    window.openVerifyModal = function() {
        const modal = document.getElementById('verify-modal');
        if (modal) {
            modal.style.display = 'flex';
            setTimeout(() => modal.classList.add('show'), 10);
            document.getElementById('verify-code')?.focus();
        }
    };

    // 关闭验证弹窗
    window.closeVerifyModal = function() {
        const modal = document.getElementById('verify-modal');
        if (modal) {
            modal.classList.remove('show');
            setTimeout(() => {
                modal.style.display = 'none';
                document.getElementById('verify-code').value = '';
                document.getElementById('verify-error').style.display = 'none';
            }, 300);
        }
    };

    // 验证验证码
    window.verifyCode = function() {
        const input = document.getElementById('verify-code');
        const errorMsg = document.getElementById('verify-error');
        const submitBtn = document.querySelector('.verify-submit-btn');
        const code = input ? input.value.trim() : '';

        if (!code) {
            if (errorMsg) {
                errorMsg.textContent = '请输入验证码';
                errorMsg.style.display = 'block';
            }
            return;
        }

        // 禁用按钮,防止重复提交
        if (submitBtn) {
            submitBtn.disabled = true;
            submitBtn.textContent = '验证中...';
        }

        // 调用后端API验证验证码
        fetch('/api/verify/code', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded'
            },
            body: 'code=' + encodeURIComponent(code)
        })
        .then(function(response) {
            return response.json();
        })
        .then(function(data) {
            // JsonResult使用flag字段表示成功/失败
            if (data.flag) {
                // 验证成功 - 全局解锁所有文章
                markGlobalUnlocked();
                unlockArticle();
                closeVerifyModal();
                if (input) input.value = '';
                if (errorMsg) errorMsg.style.display = 'none';
                showSuccessToast();
            } else {
                // 验证失败
                if (errorMsg) {
                    errorMsg.textContent = data.msg || '验证码错误或已过期';
                    errorMsg.style.display = 'block';
                }
                // 输入框抖动效果
                if (input) {
                    input.style.animation = 'shake 0.5s';
                    setTimeout(() => {
                        input.style.animation = '';
                    }, 500);
                }
            }
        })
        .catch(function(error) {
            console.error('验证请求失败:', error);
            if (errorMsg) {
                errorMsg.textContent = '网络错误,请稍后重试';
                errorMsg.style.display = 'block';
            }
        })
        .finally(function() {
            // 恢复按钮状态
            if (submitBtn) {
                submitBtn.disabled = false;
                submitBtn.textContent = '验证';
            }
        });
    };

    // 显示成功提示
    function showSuccessToast() {
        // 实现成功提示逻辑
        alert('解锁成功!现在可以阅读所有文章了~');
    }

    // 页面加载完成后检查内容锁定状态
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', checkAndLockContent);
    } else {
        checkAndLockContent();
    }

    // 监听回车键
    document.addEventListener('keydown', function(e) {
        const modal = document.getElementById('verify-modal');
        if (e.key === 'Enter' && modal && modal.classList.contains('show')) {
            verifyCode();
        }
    });
})();

关键代码解析

1. 验证码生成算法

使用 Random 生成6位纯数字验证码,确保易读性和输入便捷性:

private String generateRandomNumericCode() {
    Random random = new Random();
    StringBuilder code = new StringBuilder();
    for (int i = 0; i < CODE_LENGTH; i++) {
        code.append(random.nextInt(10)); // 0-9随机数字
    }
    return code.toString();
}

2. 微信签名验证

微信服务器验证采用SHA1算法,将token、timestamp、nonce三个参数排序后拼接加密:

private boolean checkSignature(String signature, String timestamp, String nonce) {
    String token = wechatMpProperties.getToken();
    String[] arr = new String[]{token, timestamp, nonce};
    Arrays.sort(arr);  // 字典序排序
    StringBuilder content = new StringBuilder();
    for (String s : arr) {
        content.append(s);
    }

    String sha1 = sha1(content.toString());
    return sha1 != null && sha1.equals(signature);
}

3. 类型安全的验证码比较

由于Redis存储的value可能是String或其他类型,使用 toString() 方法确保比较安全:

if (storedScene != null && storedScene.toString().equals(scene.toString())) {
    redisTemplate.delete(key);  // 验证成功,立即删除
    return true;
}

4. 前端平滑动画

使用CSS transition实现解锁动画效果:

.article-content-wrapper {
    max-height: 600px;
    overflow: hidden;
    transition: max-height 0.8s ease-out;
}

.article-content-wrapper.unlocked {
    max-height: none !important;
}

测试验证方法

1. 单元测试

编写JUnit测试验证核心逻辑:

@SpringBootTest
public class VerifyCodeServiceTest {

    @Autowired
    private VerifyCodeService verifyCodeService;

    @Test
    public void testGenerateAndVerifyCode() {
        String scene = "test_scene";

        // 生成验证码
        String code = verifyCodeService.generateCode(scene);
        assertNotNull(code);
        assertEquals(6, code.length());

        // 验证验证码存在
        assertTrue(verifyCodeService.verifyCode(code, scene));

        // 消费验证码
        assertTrue(verifyCodeService.verifyAndConsumeCode(code, scene));

        // 再次验证应该失败(已删除)
        assertFalse(verifyCodeService.verifyCode(code, scene));
    }

    @Test
    public void testInvalidCode() {
        assertFalse(verifyCodeService.verifyAndConsumeCode("000000", "test_scene"));
    }
}

2. 接口测试

使用curl或Postman测试API:

# 测试验证码生成
curl http://localhost:8080/api/verify/generate

# 测试验证码验证
curl -X POST http://localhost:8080/api/verify/code \
  -d "code=123456" \
  -H "Content-Type: application/x-www-form-urlencoded"

3. 微信消息测试

使用微信官方调试工具或ngrok进行本地调试:

# 启动ngrok转发本地服务
ngrok http 8080

# 配置微信服务器URL为ngrok提供的地址
# URL: https://your-ngrok-url.ngrok.io/api/wechat/mp/message

4. 前端功能测试

  1. 清除浏览器LocalStorage,模拟首次访问
  2. 访问文章页面,确认只显示部分内容
  3. 点击"解锁"按钮,验证弹窗正常显示
  4. 输入错误验证码,验证错误提示
  5. 从公众号获取正确验证码并输入,验证解锁成功
  6. 刷新页面,验证解锁状态持久化

常见问题与解决方案

问题1:验证码验证失败但Redis中存在Key

现象:Redis中能看到验证码Key,但验证返回失败

原因

  • Redis存储的value类型与比较时的类型不一致
  • 可能是String和Object类型比较失败

解决:使用 toString() 进行类型安全比较:

if (storedScene != null && storedScene.toString().equals(scene.toString())) {
    // 验证成功
}

问题2:微信服务器验证失败

现象:微信后台配置服务器URL时提示验证失败

原因

  • Token配置不一致
  • 签名算法实现错误
  • 返回的echostr不正确

解决

  1. 检查application.yaml中的token与微信后台配置一致
  2. 确认签名算法正确实现字典序排序
  3. 确保GET请求时原样返回echostr参数

问题3:前端检查data.success但后端返回data.flag

现象:后端验证成功但前端显示验证失败

原因:JsonResult类使用 flag 字段表示成功/失败,但前端代码检查的是 data.success

解决:统一前端检查字段:

// 错误
if (data.success) { ... }

// 正确
if (data.flag) { ... }

问题4:验证码被提前删除

现象:第一次输错验证码后,第二次输入正确的也提示错误

原因:可能存在并发问题或代码逻辑错误导致验证码被意外删除

解决

  1. 添加详细日志追踪验证码生命周期
  2. 确保只在验证成功后才删除Redis Key
  3. 检查是否有其他代码路径会删除验证码

问题5:LocalStorage状态不同步

现象:用户在一篇文章解锁后,访问其他文章仍显示锁定状态

原因:解锁状态检查逻辑可能只在页面加载时执行一次

解决:确保每次页面加载都检查LocalStorage状态:

function checkAndLockContent() {
    if (isGlobalUnlocked()) {
        unlockArticle();
        return;
    }
    // ... 锁定逻辑
}

参考文档

总结

本文详细介绍了基于微信公众号验证码的博客文章解锁功能的完整实现过程。该方案结合了前端内容控制、后端API验证、Redis缓存和微信消息推送等多种技术,实现了安全、便捷的内容访问控制机制。

核心要点:

  1. 安全性:验证码5分钟有效期 + 单次使用机制
  2. 用户体验:平滑动画 + 全局解锁 + 状态持久化
  3. 可维护性:模块化设计 + 清晰的分层架构
  4. 扩展性:支持多种使用场景(scene参数)

通过本文的指导,开发者可以快速搭建类似的内容访问控制系统,并根据实际业务需求进行定制和扩展。

如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  1 条评论
伴我   湖南省衡阳市

博主太厉害了sixsixsix

AI助手
召田最帅boy的小助手
🤖
我是召田最帅boy的小助手
我已经阅读了这篇文章,可以帮您:
理解文章内容 · 解答细节问题 · 分析核心观点