admin 管理员组

文章数量: 887021

配套视频:https://www.bilibili/video/BV1oA411B7gv/


背景

今天鼓捣了一下手机投屏到笔记本,就想录个视频展示一下学习成果,正好就想起了很早之前实现的这个功能。
H5文件下载是一个很简单的功能,但是把这个H5放在安卓版微信打开,功能就不能用了,因为安卓端的微信内置浏览器拦截了所有下载文件的请求。
即使微信的sdk也没有提供直接保存文件的接口,所以出路只有一条,就是跳到第三方应用进行下载,比如跳到手机浏览器、跳到微信小程序。如果是上架了应用宝的app,可以跳转应用宝下载。
之所以屏蔽,应该是H5无法监管的原因,但是不能理解的是,ios端的微信是可以下载的,难道苹果手机高人一等?

解决方案收集

  • 1.微信公众号sdk(无法实现)

    • 可能以前有这个功能,但是现在确实是没有了,找不到这种接口
    • 微信公众号sdk官方文档:附录2-所有 JS 接口列表
  • 2.跳转第三方应用

    • 2.1.跳转小程序(没有实践过,但是跳转小程序,还不如跳转手机浏览器呢)
      • 参考:在微信浏览器打开 H5,居然无法一键下载图片?
    • 2.2.第三方应用生成的链接可以直接触发跳转外部浏览器选择窗口(骗人的吧)
      • 参考:成功解决微信跳转到手机默认浏览器下载

      • 这些网站都打不开了,不靠谱

    • 2.3.如果是app,可以跳腾讯出品的应用宝下载
      • 参考:H5在微信下载app
    • 2.4.前端写弹窗提示或是遮罩提示,引导用户在右上角通过浏览器打开
      • 参考:2022-12-06 uniApp H5端实现下载文件(包含微信浏览器内处理)
      • 参考:微信H5保存或下载视频到本地,将视频直接分享视频给好友
      • 参考:微信跳转手机默认浏览器提示 微信h5页面中下载第三方app的方法

最终选择的解决方案

  • 想到这个方案,是一个意外。

  • 一开始我只测试了zip的下载,确实不能下载,以至于我以偏概全地以为所有格式都不能下载,所以就转到百度上找答案。

  • 然后测试跟我说,ios的文件有些也不能预览,不能下载。

  • 所以我就丢下这个坑,先去解决ios的问题。

  • 百度发现ios也是伪下载,它是先以预览的方式打开文件,需要用户点击右上角手动保存。

  • 而且文件后缀和响应头 content-type要严格对应,不对应就会报错,预览不了

  • 参考:解决移动端H5下载文件提示文件类型无法识别或非法文件的问题

  • 改完ios的问题,我传了各种格式的文件测试了一遍,确认修复之后,又转回安卓端。

  • 随手点击了几下,就是这么几下让我看到了希望。

  • 并不是所有类型的文件都不能下载,针对docx、pdf、xlsx、txt等格式,微信会主动唤起跳转其他浏览器的选择弹窗。

  • 这比起前端写提示窗无疑要友好许多。

  • 所以只要发挥偷蒙拐骗的优良品质,让微信对所有文件一视同仁,都唤起跳转窗口就行了。

  • 到此,安卓端H5下载文件的问题完美解决。

  • 欺骗的手段也很简单,反正微信也不能下载,就所有的下载请求,都给它一个假文件,比如123456.xlsx。

java实现

  • 注意,如果接口使用cookie鉴权,跳转外部浏览器,cookie是带不过去的。
  • 需要提供一个不需要鉴权的接口,换一种方式鉴权,比如时效分享码或者直接携带sessionId之类的
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;

import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@RestController
public class ApiController {

    // 获取日志对象 Spring Boot 中内置了日志框架 Slf4j
    private static Logger log = LoggerFactory.getLogger(ApiController.class);

    /**
     * 处理微信文件下载
     * 欺骗安卓微信唤起打开外部浏览器的选择框
     * ios微信可以预览每种格式的文件,但是不支持直接下载,需要用户在预览页点右上角手动保存
     * 另外,ios对content_type要求严格,如果文件后缀和content_type对不上,连预览页都进不了
     * 企业微信不用做任何处理
     */
    @GetMapping("downloadFileWx")
    public void downloadFileWx(@RequestParam String path, HttpServletRequest request, HttpServletResponse response) throws Exception {
        responseOutputFileWx(path, null, request, response);
    }

    /**
     * 响应文件流
     * @param path 文件路径
     * @param outputFileName 文件名称,赋值给响应头Content-Disposition
     * @param request
     * @param response
     */
    public void responseOutputFileWx(String path, String outputFileName,
                                   HttpServletRequest request, HttpServletResponse response)
            throws Exception {
        File file = new File(path);
        if (file == null || !file.exists() || !file.isFile()) {
            log.error("文件不存在");
            // 重定向到当前页面,相当于刷新页面
            String contextPath = request.getContextPath();
            response.sendRedirect(contextPath + "/downFile");
            return;
        }

        if (outputFileName == null || outputFileName.trim().length() == 0) {
            // 假如下载文件名参数为空,则设置为原始文件名
            outputFileName = file.getName();
        }
        ServletContext context = request.getServletContext();
        // 文件绝对路径
        String absolutePath = file.getAbsolutePath();
        // 获取文件的MIME type
        String mimeType = context.getMimeType(absolutePath);
        if (mimeType == null) {
            // 没有发现则设为二进制流
            mimeType = "application/octet-stream";
        }

        response.setContentType(mimeType);
        // 设置文件下载响应头
        String headerKey = "Content-Disposition";
        String headerValue = null;

        if (isWx(request)) {
            // 微信浏览器,打开手机默认浏览器下载文件
            // 注意排除企业微信
            try {
                if (isAndroidWx(request)) {
                    // 安卓端,xlsx文件类型会触发微信弹出跳转外部浏览器窗口,欺骗一下
                    response.setContentType("application/octet-stream");
                    outputFileName = "123456.xlsx";
                } else {
                    // ios 微信对contentType要求比较严格
                    // https://juejin/post/6844904086463053837
                    if (absolutePath.endsWith("xlsx")) {
                        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
                    } else if (absolutePath.endsWith("xls")) {
                        response.setContentType("application/vnd.ms-excel");
                    } else if (absolutePath.endsWith("doc")) {
                        response.setContentType("application/msword");
                    } else if (absolutePath.endsWith("docx")) {
                        response.setContentType("application/application/vnd.openxmlformats-officedocument.wordprocessingml.document");
                    }
                }
                headerValue = String.format("attachment; filename=\"%s\"", URLEncoder.encode(outputFileName, "UTF-8"));
            } catch (Exception e) {
                headerValue = String.format("attachment; filename=\"%s\"", outputFileName);
                log.error(e.getMessage(), e);
            }
        } else {
            try {
                // 解决Firefox浏览器中文件名中文乱码
                // https://blog.csdn/Jon_Smoke/article/details/53699400
                headerValue = String.format("attachment; filename* = UTF-8''%s",
                        URLEncoder.encode(outputFileName, "UTF-8")
                );
            } catch (Exception e) {
                headerValue = String.format("attachment; filename=\"%s\"", outputFileName);
                log.error(e.getMessage(), e);
            }
        }
        response.setHeader(headerKey, headerValue);

        String fileName = file.getName();
        try (OutputStream outputStream = response.getOutputStream()) {
            response.setCharacterEncoding("utf-8");

            // 将下面2行放开,可以测试微信最原始反应
            // 设置返回类型
            // response.setContentType("multipart/form-data");
            // // 文件名转码一下,不然会出现中文乱码
            // response.setHeader("Content-Disposition", "attachment;fileName=" + encodeStr(fileName));

            byte[] bytes = readBytes(file);
            if (bytes == null) {
                log.error("文件不存在");
            }
            outputStream.write(bytes);
            log.info("文件下载成功!" + fileName);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 对字符串(文件名或路径)进行url编码
     */
    private String encodeStr(String str) throws Exception {
        return URLEncoder.encode(str, "UTF-8");
    }

    /**
     * 将文件转为byte数组
     */
    public byte[] readBytes(File file) throws Exception {
        long len = file.length();
        // 无论数组的类型如何,数组中的最大元素数为Integer.MAX_VALUE,大约20亿
        if (len >= 2147483647L) {
            return null;
        } else {
            byte[] bytes = new byte[(int) len];

            try (FileInputStream in = new FileInputStream(file)) {
                int readLength = in.read(bytes);
                if ((long) readLength < len) {
                    log.error("文件未读取完全");
                    return null;
                }
            } catch (Exception var10) {
                return null;
            }

            return bytes;
        }
    }

    /**
     * 是否从安卓端微信请求,需要排除企业微信
     */
    private static boolean isAndroidWx(HttpServletRequest request) {
        String userAgent = request.getHeader("user-agent");
        return userAgent != null && userAgent.toLowerCase().indexOf("micromessenger") > -1
                && userAgent.toLowerCase().indexOf("wxwork") < 0
                && userAgent.toLowerCase().indexOf("android") > -1;
    }

    /**
     * 是否从微信请求,需要排除企业微信
     * 安卓或ios
     */
    private static boolean isWx(HttpServletRequest request) {
        String userAgent = request.getHeader("user-agent");
        return userAgent != null && userAgent.toLowerCase().indexOf("micromessenger") > -1
                && userAgent.toLowerCase().indexOf("wxwork") < 0;
    }

}

题外话:手机如何投屏笔记本

方式1:win10自带投屏

  • 按 “Windows 徽标键+I” 打开设置,设置–>系统–>投影到此电脑

  • 投影到此电脑中显示灰色不可选,或显示“我们正在确认这项功能”

  • 第一次需要安装 无线显示器

  • 手机使用电脑自带功能进行投屏

  • 手机投屏到笔记本之后,笔记本会被劫持,就是只能操作手机画面,鼠标移不出来,可以在电脑上用鼠标直接操作手机。

  • 这一点,有点不方便,比如想一边写代码,一边预览手机效果,就不能实现。

  • 另外,建议选择 仅第一次 需要验证,我第一次投成功了,关闭之后,就死活投不上去,主要是笔记本不能弹出确认对话框

  • 之后,重启电脑才能第二次投屏成功。

方式2:幕享 软件

  • 官网下载页
  • 官方使用教程:如何使用幕享Windows版
  • 我是在这个分享视频里面找到的这个软件:需要手机投屏电脑?这五款软件就够了!
  • 这是纯投屏软件,不能在笔记本上操作手机。对手机录屏,然后传输到笔记本上,局域网下延迟不高。

本文标签: 跳转 浏览器 窗口 文件 安卓端微信