微信强制外跳浏览器方案(Nginx 实现)

一、目标与效果说明

目标

在微信里点击链接时:

  • Android 微信: 自动调起系统默认浏览器,并在外部浏览器打开真实业务页面
  • iOS 微信: 弹出遮罩,用户手动点击在浏览器打开

实现方式

利用微信内置浏览器对 "附件下载(Content-Disposition: attachment)" 的处理逻辑: 当返回头声明为附件下载时,Android 微信会交给系统浏览器或外部下载器处理,该请求在外部浏览器重新发起后,Nginx 返回一段包含 JavaScript 的 HTML,在客户端进行 URL 解码并跳转。


二、整体流程概览

  1. 用户在微信中点击形如:

    https://www.jump.com/open?url=https%3A%2F%2Fwww.baidu.com
    

    说明: ?url= 后面的参数就是最终要跳转到的完整业务链接(需经过 URL 编码)。

  2. 请求打到 www.jump.com 上的 Nginx:

    • User-Agent 中包含 MicroMessenger,识别为微信内置浏览器。
    • Nginx 返回:
      • Content-Type: application/pdf
      • Content-Disposition: attachment; filename="open.pdf"
      • 一个伪装的 PDF 内容。
  3. Android 微信把这个请求交给系统浏览器/外部浏览器处理,外部浏览器再次访问同一个 URL。

  4. 第二次请求中不再带 MicroMessenger User-Agent,Nginx 判断为"外部浏览器"。

    • 由于 URL 参数是编码过的(如 https%3A%2F%2F...),Nginx 若直接 302 跳转会导致路径错误。
    • Nginx 返回一段 HTML,内含 JavaScript 脚本
  5. 外部浏览器执行这段 JS 脚本:

    • 使用 decodeURIComponent() 还原真实 URL。
    • 执行 window.location.replace() 完成跳转,打开真实业务页面。

三、前置条件

  1. 跳转域名未被微信拦截: 指 www.jump.com 这个跳转专用域名能在微信内正常打开(?url= 后面的目标业务域名无此要求)。

  2. 已有一台可以部署 Nginx 的服务器,并且:

    • 可绑定跳转专用域名(如 www.jump.com)。
    • 能为其配置 HTTPS 证书(Let's Encrypt / 云厂商证书均可)。
  3. Nginx 版本支持 mapifreturn 等基础指令(常见发行版默认满足)。


四、步骤 1: 准备跳转子域名

为强制外跳单独准备一个域名,例如:

https://www.jump.com

在域名 DNS 控制台中新增一条解析:

  • 记录类型: A(或 CNAME 至负载均衡)
  • 主机记录: www
  • 记录值: Nginx 服务器 IP

解析生效后,访问 https://www.jump.com 应能到这台 Nginx。

五、完整OpenResty配置(openresty/1.27.1.2)

# user  nginx;
worker_processes  auto;
worker_rlimit_nofile 65535;
worker_shutdown_timeout 10s;

error_log  logs/error.log  warn;
pid        logs/nginx.pid;

events {
    worker_connections 65535;
    multi_accept on;
    use epoll;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    # -------- Gzip --------
    gzip  on;
    gzip_min_length 1k;
    gzip_comp_level 6;
    gzip_vary  on;
    gzip_disable "MSIE [1-6]\\.";
    gzip_types
        text/plain text/css
        application/javascript application/json
        application/xml text/javascript;

    sendfile        on;
    keepalive_timeout 65;
    server_tokens off;
    client_max_body_size 30m;
    client_body_timeout 300s;

    # -------- WebSocket/Keep-Alive --------
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      "";
    }

    # -------- HTTP → HTTPS 强制跳转 --------
    server {
        listen       80;
        listen       [::]:80;
        server_name  www.jump.com;
        return 301 https://$host$request_uri;
    }

  
    # ============================================================
    # 微信跳转服务 (jump) - 使用 Lua 逻辑
    # ============================================================
    server {
        listen       443 ssl;
        listen       [::]:443 ssl;
        server_name  www.jump.com;
        
        # 开启 http2
        http2 on;

        # 证书配置
        ssl_certificate      /home/nginxcert/_.jump.com.crt;
        ssl_certificate_key  /home/nginxcert/_.jump.com.key;
        
        ssl_session_cache    shared:SSL:10m;
        ssl_session_timeout  10m;
        ssl_protocols        TLSv1.2 TLSv1.3;
        ssl_ciphers          HIGH:!aNULL:!MD5;
        ssl_prefer_server_ciphers on;

        # --------------------------------------------------------
        # 入口逻辑:使用 Lua 处理所有逻辑 (输出更可靠)
        # --------------------------------------------------------
        location = /open {
            content_by_lua_block {
                -- 1. 获取参数和 UA
                local ua = string.lower(ngx.var.http_user_agent or "")
                -- 这里的 arg_url 会自动被 Nginx 解码 (例如 %3A 变成 :)
                -- 为了保证 JS 能正确处理,我们重新获取原始 Query String 可能更安全,
                -- 但通常 arg_url 也是可以的。为了保险,我们下面直接拼入 JS 字符串。
                local target_url = ngx.var.arg_url

                -- 2. 检查参数
                if not target_url or target_url == "" then
                    ngx.status = 400
                    ngx.say("Missing url parameter")
                    return
                end

                -- 3. 判断环境
                local is_wechat = string.find(ua, "micromessenger")
                local is_android = string.find(ua, "android")

                if is_wechat then
                    if is_android then
                        -- A. [Android 微信] -> 走伪装 PDF 强制外跳
                        -- 注意:这里需要 exec 到另一个 location 是为了利用 Nginx 的 default_type 机制
                        -- 此时 target_url 不重要了,因为跳出去后浏览器会重发请求
                        ngx.exec("/_android_handler")
                    else
                        -- B. [iOS 微信] -> 直接输出遮罩 HTML
                        ngx.header.content_type = "text/html;charset=utf-8"
                        ngx.say('<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>请在浏览器打开</title><style>body{margin:0;padding:0;}.mask{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:9999;text-align:center;color:#fff;padding-top:20px;}.guide-img{width:80%;max-width:400px;margin:20px auto;border:1px dashed #666;padding:10px;border-radius:8px;background:rgba(255,255,255,0.1);}.text{font-size:18px;line-height:1.6;margin-top:20px;}</style></head><body><div class="mask"><div class="text"><p>由于微信限制,请按照以下步骤操作:</p><p>1. 点击右上角的 <span style="font-size:24px;font-weight:bold;">...</span></p><p>2. 选择 <span style="color:#4caf50;font-weight:bold;">在浏览器打开</span></p></div><div class="guide-img">Safari / 浏览器</div></div></body></html>')
                        return
                    end
                else
                    -- C. [外部浏览器] -> 直接输出 JS 跳转 HTML
                    -- 此时已经在浏览器里了,直接用 target_url 生成页面
                    ngx.header.content_type = "text/html;charset=utf-8"
                    
                    -- 构建 HTML 字符串,把 target_url 嵌入进去
                    -- 注意:encodeURIComponent 对应的解码是 decodeURIComponent
                    -- 如果 ngx.var.arg_url 已经被 nginx 解码过了(例如变成 https://...), JS 再 decode 一次也没事
                    -- 关键是防止 Lua 里的 nil 导致拼接报错(前面已检查)
                    
                    local html = '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>正在跳转...</title></head><body>' ..
                                 '<script>' ..
                                 'var target = "' .. target_url .. '";' ..
                                 'if(target.indexOf("%") > -1) { target = decodeURIComponent(target); }' .. 
                                 'window.location.replace(target);' ..
                                 '</script></body></html>'
                    
                    ngx.say(html)
                    return
                end
            }
        }

        # --------------------------------------------------------
        # 内部处理块 (只保留 Android PDF 逻辑)
        # --------------------------------------------------------
        
        # [Android 处理] 伪装 PDF 强制外跳
        location = /_android_handler {
            internal;
            default_type application/pdf;
            add_header Content-Disposition 'attachment; filename="open.pdf"';
            add_header Cache-Control 'no-store, no-cache, must-revalidate, max-age=0';
            add_header Pragma 'no-cache';
            return 200 "%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Resources<<>>>>endobj\nxref\n0 4\n0000000000 65535 f\n0000000009 00000 n\n0000000058 00000 n\n0000000115 00000 n\ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n190\n%%EOF";
        }
        
        # 默认首页
        location / {
            add_header Content-Type "text/html;charset=utf-8";
            return 200 "Wechat Jump Service is Running.";
        }
    }
}