企业微信联登与扫码通用业务方案

本文只描述企业微信环境下前后端如何完成联登和扫码,不绑定具体项目、模块名称、Token 名称、接口路径、网关前缀或业务对象。

任何前端框架、后端语言和部署架构都可以按本文拆分职责后实现。

1. 要解决的问题

用户在企业微信内打开一个 H5 页面时,系统需要完成两件事:

  1. 利用企业微信环境中已有的外部身份登录态,让用户进入本系统,不再重复输入账号密码。
  2. 用户点击扫码时,优先调用企业微信原生扫码;原生能力不可用时,仍能使用摄像头或相册识别。

核心原则是:

text 复制代码
企业微信外部身份
    -> 后端验证并映射本地用户
    -> 签发本系统会话
    -> 前端携带本系统会话访问业务接口
    -> 后端签名,前端调用企业微信原生扫码

外部身份凭证和本系统会话必须是两套东西。外部凭证由企业微信或上游统一身份系统管理,本系统只在服务端使用它完成一次身份交换,不能把它直接作为业务接口凭证。

2. 四类参与方

参与方 主要职责
企业微信 提供 WebView、外部登录态和原生扫码能力
前端 识别环境、发起联登、保存本系统会话、调用 SDK、展示状态
后端 读取外部凭证、查询上游身份、映射本地用户、签发会话、生成签名
业务服务 校验本系统会话和权限,处理扫码内容对应的业务数据

模块怎么拆分并不重要,可以是单体、微服务、BFF 或网关架构。重要的是以上职责不能混在一起。

3. 完整时序

业务后端 企业微信 JS-SDK 本地用户与会话存储 上游身份系统 认证后端 前端 企业微信 WebView 用户 业务后端 企业微信 JS-SDK 本地用户与会话存储 上游身份系统 认证后端 前端 企业微信 WebView 用户 #mermaid-svg-H796LS3R9xfs3OLP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-H796LS3R9xfs3OLP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-H796LS3R9xfs3OLP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-H796LS3R9xfs3OLP .error-icon{fill:#552222;}#mermaid-svg-H796LS3R9xfs3OLP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-H796LS3R9xfs3OLP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-H796LS3R9xfs3OLP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-H796LS3R9xfs3OLP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-H796LS3R9xfs3OLP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-H796LS3R9xfs3OLP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-H796LS3R9xfs3OLP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-H796LS3R9xfs3OLP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-H796LS3R9xfs3OLP .marker.cross{stroke:#333333;}#mermaid-svg-H796LS3R9xfs3OLP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-H796LS3R9xfs3OLP p{margin:0;}#mermaid-svg-H796LS3R9xfs3OLP .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-H796LS3R9xfs3OLP text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-H796LS3R9xfs3OLP .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-H796LS3R9xfs3OLP .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-H796LS3R9xfs3OLP .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-H796LS3R9xfs3OLP .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-H796LS3R9xfs3OLP #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-H796LS3R9xfs3OLP .sequenceNumber{fill:white;}#mermaid-svg-H796LS3R9xfs3OLP #sequencenumber{fill:#333;}#mermaid-svg-H796LS3R9xfs3OLP #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-H796LS3R9xfs3OLP .messageText{fill:#333;stroke:none;}#mermaid-svg-H796LS3R9xfs3OLP .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-H796LS3R9xfs3OLP .labelText,#mermaid-svg-H796LS3R9xfs3OLP .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-H796LS3R9xfs3OLP .loopText,#mermaid-svg-H796LS3R9xfs3OLP .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-H796LS3R9xfs3OLP .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-H796LS3R9xfs3OLP .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-H796LS3R9xfs3OLP .noteText,#mermaid-svg-H796LS3R9xfs3OLP .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-H796LS3R9xfs3OLP .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-H796LS3R9xfs3OLP .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-H796LS3R9xfs3OLP .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-H796LS3R9xfs3OLP .actorPopupMenu{position:absolute;}#mermaid-svg-H796LS3R9xfs3OLP .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-H796LS3R9xfs3OLP .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-H796LS3R9xfs3OLP .actor-man circle,#mermaid-svg-H796LS3R9xfs3OLP line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-H796LS3R9xfs3OLP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 在企业微信内打开页面 加载页面并自动携带外部身份 Cookie 发起外部身份联登 从 Cookie 或受控请求体读取外部凭证 校验凭证并查询外部账号 返回账号、姓名、组织和状态 查询或创建本地用户 签发本系统会话 返回会话和基础用户信息 获取当前用户、角色和权限 校验会话并构造用户上下文 返回用户权限 点击扫码 请求当前页面的 SDK 签名 使用服务端密钥获取或计算签名 返回签名数据 返回时间戳、随机串和签名 注册 SDK、检查能力、调用原生扫码 返回扫码内容或取消/失败 使用扫码内容请求业务数据 返回业务结果

联登、获取当前用户、获取签名、原生扫码和业务查询是独立阶段,每一阶段都应有单独的状态和错误处理。

4. 企业微信环境判断

User-Agent 只用于选择前端适配分支,不能作为安全认证依据:

js 复制代码
function getEnvironment() {
    const ua = navigator.userAgent || "";
    if (/wxwork/i.test(ua)) return "wecom";
    if (/micromessenger/i.test(ua)) return "wechat";
    return "browser";
}

企业微信判断应优先于普通微信判断。环境判断只决定是否尝试企业微信 SDK,真正的登录权限必须由后端验证外部凭证。

4.1 前端运行环境

企业微信原生扫码至少需要满足以下条件:

条件 说明
运行容器 页面在企业微信内置 WebView 中打开
应用类型 已明确是企业自建应用、代开发应用还是第三方应用
可信域名 当前页面域名已配置为企业微信应用可信域名
页面地址 企业微信实际打开的 URL 可以被签名服务准确还原
网络 手机可以访问 H5、联登接口、签名接口和企业微信相关服务
HTTPS 正式环境使用受信任的 HTTPS;H5 摄像头降级也要求安全上下文
客户端 目标 Android、iOS 企业微信版本已真机验证

本地电脑使用 localhost 或局域网 IP 启动页面,只能证明普通浏览器页面可访问,不能代表企业微信 JS-SDK 一定可用。企业微信通常还会校验可信域名、证书和签名 URL,因此正式联调应使用已配置的 HTTPS 域名。

4.2 企业微信后台准备

前端编码前应先确认:

  1. 已获得企业 ID,即 corpId
  2. 已确定当前应用类型。
  3. 如果目标能力需要应用身份,已获得 agentId
  4. 已配置"网页授权及 JS-SDK"可信域名。
  5. 当前域名已经完成归属验证。
  6. 应用可见范围包含测试用户。
  7. 后端可以取得生成签名所需的凭证或访问上游签名服务。

自建应用使用企业身份能力时通常提供 getConfigSignature。第三方应用或需要应用身份的 API,还可能需要 agentIdgetAgentConfigSignature。这两种签名不能混用,具体组合应按应用类型和目标 JSAPI 确认。

4.3 前端依赖

推荐使用新版企业微信 SDK:

bash 复制代码
npm install @wecom/jssdk axios

如果还需要普通浏览器摄像头和相册降级,可以增加二维码识别库:

bash 复制代码
npm install html5-qrcode

依赖职责:

依赖 用途 是否必需
@wecom/jssdk registercheckJsApiscanQRCode 企业微信原生扫码必需
axios 或其他请求库 调用联登、当前用户和签名接口 推荐
html5-qrcode 浏览器摄像头和相册识别 需要降级时使用

正式项目应锁定 SDK 版本,并根据该版本的 TypeScript 定义和真机结果开发。本文示例按 @wecom/jssdk 2.4.x 的 API 形态编写。

如果不能使用 npm,也可以使用企业微信官方 CDN 脚本。使用 npm 和 CDN 二选一,不要在同一页面重复加载两个不同版本。

4.4 前端公开配置

前端只保存可以公开的应用标识和服务地址:

bash 复制代码
VITE_WECOM_CORP_ID=企业ID
VITE_WECOM_AGENT_ID=可选的应用ID
VITE_SERVICE_BASE_URL=https://api.example.com

以下内容绝对不能放入前端环境变量、源码或浏览器存储:

  • 企业微信应用 Secret。
  • access token。
  • jsapi ticket。
  • 上游签名服务密钥。
  • 外部身份 Cookie 的完整值。

Vite 中的环境变量会进入浏览器构建产物,不属于安全存储。

4.5 新版 SDK 的 ready 机制

新版 @wecom/jssdk 不使用旧版 jweixinwx.ready() 模式。

正确理解是:

  1. 先调用 ww.register() 注册页面身份和所需 JSAPI。
  2. SDK 在真正调用接口时,内部通过 getConfigSignature(url) 获取签名并完成 config
  3. register 后可以直接调用 Promise 化的 JSAPI,不需要等待 wx.ready()
  4. 为了让页面有明确的"SDK 可用"状态,业务代码可以等待 ww.checkJsApi()
  5. checkJsApi 成功并且 checkResult.scanQRCode === true,即可把项目状态标记为 sdkReady

因此建议把状态拆开:

text 复制代码
authReady:企业微信联登、当前用户和权限加载完成
sdkReady:register 已完成,checkJsApi 已确认 scanQRCode 可用
scanning:已经发起扫码,防止重复点击

只有 authReady && sdkReady && !scanning 时才调用原生扫码。

如果项目确实需要监听底层 config 生命周期,register 还支持:

js 复制代码
ww.register({
    corpId,
    jsApiList: ["scanQRCode"],
    getConfigSignature,
    onConfigSuccess(result) {
        console.info("WeCom config ready", result);
    },
    onConfigFail(error) {
        console.error("WeCom config failed", error);
    },
});

普通扫码场景优先使用 checkJsApi Promise 作为业务 ready 判据,避免同时维护回调和 Promise 两套状态。

4.6 可复用的 SDK 初始化代码

js 复制代码
let sdkPromise;
let readyPromise;

function loadWeComSdk() {
    if (!sdkPromise) sdkPromise = import("@wecom/jssdk");
    return sdkPromise;
}

function validateSignature(data) {
    if (!data?.timestamp || !data?.nonceStr || !data?.signature) {
        throw new Error("企业微信签名数据不完整");
    }
    return data;
}

async function getConfigSignature(url) {
    // 必须使用 SDK 传入的 url 请求后端,不要固定为应用启动时的旧地址。
    const response = await requestSignature({ url });
    return validateSignature(response.data);
}

export function ensureWeComReady() {
    if (readyPromise) return readyPromise;

    readyPromise = (async () => {
        if (getEnvironment() !== "wecom") {
            throw new Error("当前环境不是企业微信");
        }

        const corpId = import.meta.env.VITE_WECOM_CORP_ID;
        if (!corpId) {
            throw new Error("未配置企业微信 corpId");
        }

        const ww = await loadWeComSdk();
        ww.register({
            corpId,
            jsApiList: ["scanQRCode"],
            getConfigSignature,
        });

        const result = await ww.checkJsApi({
            jsApiList: ["scanQRCode"],
        });
        if (result?.checkResult?.scanQRCode !== true) {
            throw new Error("当前企业微信客户端不支持扫码");
        }

        return ww;
    })().catch((error) => {
        // 允许配置、网络或签名问题修复后重新初始化。
        readyPromise = undefined;
        throw error;
    });

    return readyPromise;
}

如果初始化过程可能受弱网影响,应在外层增加 8 至 10 秒超时,并把超时转换成用户能理解的提示。

4.7 Vue 页面启动顺序

应用启动时只需要先完成联登和当前用户恢复。企业微信 SDK 可以在进入扫码页时初始化,也可以在联登成功后预热。

推荐顺序:

text 复制代码
应用挂载
    -> 判断是否企业微信
    -> 企业微信环境:发起联登
    -> 保存本系统会话
    -> 获取当前用户和权限
    -> authReady = true
    -> 进入扫码页
    -> ensureWeComReady()
    -> sdkReady = true
    -> 用户点击扫码

Vue 3 示例:

html 复制代码
<script setup>
import { computed, onMounted, ref } from "vue";

const authReady = ref(false);
const sdkReady = ref(false);
const initializing = ref(false);
const scanning = ref(false);
const errorMessage = ref("");

const canScan = computed(
    () => authReady.value && sdkReady.value && !scanning.value
);

onMounted(async () => {
    initializing.value = true;
    try {
        await loginAndLoadCurrentUser();
        authReady.value = true;

        if (getEnvironment() === "wecom") {
            await ensureWeComReady();
            sdkReady.value = true;
        }
    } catch (error) {
        errorMessage.value = error.message || "初始化失败";
    } finally {
        initializing.value = false;
    }
});

async function handleScanClick() {
    if (!canScan.value) return;
    scanning.value = true;
    errorMessage.value = "";
    try {
        const ww = await ensureWeComReady();
        const result = await ww.scanQRCode({
            needResult: true,
            scanType: ["qrCode", "barCode"],
        });
        await handleBusinessResult(result.resultStr);
    } catch (error) {
        if (!isUserCancelled(error)) {
            errorMessage.value = error.message || "扫码失败";
        }
    } finally {
        scanning.value = false;
    }
}
</script>

如果产品要求打开扫码页后自动弹出原生扫码,也应先等待 authReadysdkReady。在兼容性要求较高的场景,仍推荐由明确的用户点击事件触发。

5. 联登:前端怎么做

企业微信内打开页面后,前端直接请求后端的"外部身份联登接口"。不要先在前端判断外部 Cookie 是否存在,也不要尝试读取 HttpOnly Cookie。

js 复制代码
const authClient = axios.create({
    baseURL: SERVICE_BASE_URL,
    withCredentials: true,
    timeout: 10000,
});

async function loginByWeCom() {
    return authClient.post(EXTERNAL_LOGIN_API, {
        // 外部凭证由 WebView Cookie 携带时,这里可以为空。
        token: "",
    });
}

前端拿到登录结果后:

  1. 保存本系统会话。
  2. 请求当前用户信息。
  3. 确认用户、角色和权限加载成功。
  4. 标记"企业微信联登成功"。
  5. 允许进入扫码或其他业务页面。
  6. 登录失败时清理本系统会话并展示明确错误。

只拿到一个会话字符串,不能直接认为登录成功;当前用户信息和权限确认成功才算完成联登。

6. 联登:后端怎么做

后端联登接口建议按下面的顺序实现:

  1. 从受控请求体读取外部凭证,兼容必须显式传参的集成方式。
  2. 如果请求体为空,从请求 Cookie 读取外部凭证。
  3. 凭证为空时返回明确的身份获取失败。
  4. 使用服务端保存的密钥调用企业微信或上游身份系统。
  5. 校验上游响应,并提取稳定的外部账号编码。
  6. 按"外部账号编码 + 项目/租户"查询本地用户。
  7. 用户不存在时按业务规则创建;存在时同步姓名、组织和状态。
  8. 校验本地用户是否启用、是否有当前项目权限。
  9. 签发本系统会话。
  10. 返回最小必要的用户信息,不返回外部凭证、Secret 或内部账号数据。

后端伪代码:

java 复制代码
public LoginResult loginByExternalIdentity(
        LoginRequest request,
        HttpServletRequest httpRequest,
        String projectCode,
        String clientCode) {

    String credential = request.getToken();
    if (isBlank(credential)) {
        credential = readCookie(httpRequest, EXTERNAL_IDENTITY_COOKIE);
    }
    if (isBlank(credential)) {
        throw new BusinessException("未获取到企业微信身份");
    }

    ExternalAccount account = identityClient.login(credential);
    validateExternalAccount(account);

    LocalUser user = userService.findByExternalCode(
            account.getCode(), projectCode);
    if (user == null) {
        user = userService.createFromExternalAccount(account, projectCode);
    } else {
        userService.syncBasicProfile(user, account);
    }

    validateLocalUser(user, projectCode);
    String session = sessionService.issue(clientCode, user.getId(), projectCode);
    return LoginResult.success(session, sanitizeUser(user));
}

联登失败分支

情况 处理
外部 Cookie 不存在 提示在企业微信内打开,或进入普通登录页
外部凭证无效或过期 清理本系统会话,提示重新联登
上游接口超时 提示服务暂时不可用,允许重试
本地账号不存在且禁止自动创建 提示账号未开通权限
本地用户被禁用 禁止进入业务和扫码页面
当前项目无权限 进入无权限页,不要继续调用业务接口

7. 本系统会话和统一鉴权

外部联登成功后,项目需要签发自己的会话。后续所有业务请求都使用本系统会话,不再把外部凭证向下游传播。

统一鉴权链路:

text 复制代码
前端保存本系统会话
    -> 业务请求携带本系统会话
    -> 网关或认证中间件解码和校验
    -> 校验项目、用户状态、有效期和权限
    -> 注入 userId、projectCode 等上下文
    -> 业务服务执行查询或写入

认证层至少校验:

  • 会话是否为空、格式是否正确。
  • 签发方、客户端和项目是否匹配。
  • 用户是否存在、启用且未删除。
  • 会话是否过期或已撤销。
  • 当前用户是否有目标业务权限。

联登接口可以免本系统会话,但必须使用精确白名单。不要使用"路径中包含 login 就全部放行"的模糊规则。

8. JS-SDK 签名:前端和后端的分工

前端

前端在调用原生扫码前,把企业微信实际打开的当前页面 URL 交给后端:

js 复制代码
async function getSdkSignature() {
    const pageUrl = `${location.origin}${location.pathname}${location.search}`;
    return authClient.get(SIGNATURE_API, {
        params: { url: pageUrl },
    });
}

签名 URL 必须统一规范化,通常不包含 # 片段。页面查询参数发生变化时,签名 URL 也必须同步变化。

后端

后端负责:

  1. 校验本系统会话。
  2. 校验 URL 的协议、域名和端口是否在允许范围。
  3. 按统一规则规范化 URL。
  4. 使用服务端 Secret 获取或计算签名。
  5. 缓存可缓存的 access token、ticket 等中间凭证。
  6. 只返回前端需要的时间戳、随机串和签名。
java 复制代码
public SignatureData sign(String pageUrl, UserContext context) {
    URI url = urlPolicy.normalizeAndValidate(pageUrl);
    Ticket ticket = ticketService.getValidTicket(context.getClientCode());
    return signatureService.sign(url, ticket);
}

Secret、access token、ticket 和上游响应不能放到前端,也不能写入普通日志。

签名不一致的常见原因

  • 后端签名的 URL 和当前页面 URL 不一致。
  • 页面刷新后查询参数变化,但仍使用旧签名。
  • iOS WebView 复用旧的 SDK 页面上下文。
  • 企业微信可信域名或 JS-SDK 权限未配置。
  • 使用了错误的应用凭证。
  • 签名请求没有携带本系统会话。
  • 代理改写了协议、域名、端口或路径。

9. 企业微信原生扫码

扫码组件只负责调用适配层,不负责业务跳转。适配层应统一返回以下结果:

ts 复制代码
type ScanResult = {
    decodedText: string;
    source: "wecom" | "camera" | "image";
    raw?: unknown;
};

企业微信原生扫码流程:

  1. 在用户点击事件中开始调用。
  2. 调用 ensureWeComReady()
  3. SDK 按当前页面 URL 请求签名并完成注册。
  4. checkJsApi 确认扫码能力可用。
  5. 调用 scanQRCode
  6. 处理成功、取消、权限失败、签名失败和超时。
  7. 将返回内容归一化后交给业务层。
js 复制代码
async function scanByWeCom() {
    const ww = await ensureWeComReady();
    const result = await ww.scanQRCode({
        needResult: true,
        scanType: ["qrCode", "barCode"],
    });

    return {
        decodedText: normalizeScanContent(result.resultStr),
        source: "wecom",
        raw: result,
    };
}

不同版本 SDK 的参数类型和返回值可能不同,必须锁定依赖版本并以对应官方文档和真机结果为准。

扫码适配层的总入口可以这样设计:

js 复制代码
async function scan() {
    if (isWeComEnvironment() && isAuthenticated()) {
        try {
            return await scanByWeCom();
        } catch (error) {
            if (isUserCancelled(error)) throw error;
            // 原生能力失败,继续走浏览器降级。
        }
    }

    return scanByCameraOrImage();
}

10. 摄像头和相册降级

以下情况必须保留 H5 降级:

  • 用户不在企业微信内。
  • 企业微信联登未成功。
  • JS-SDK 初始化失败。
  • 签名失败或签名不一致。
  • 当前客户端不支持原生扫码。
  • 用户拒绝摄像头权限。
  • 当前 WebView 或桌面浏览器没有企业微信能力。

摄像头识别通常依赖安全上下文,正式环境应使用 HTTPS;相册识别不依赖摄像头权限,可作为权限失败后的备用方案。

相册图片没有识别到二维码时,必须提示"未识别到二维码或条形码"等可理解的信息,不能只在控制台输出底层 SDK 异常。

11. 扫码结果和业务处理

扫码成功不等于业务成功。推荐的业务编排是:

text 复制代码
SDK 返回内容
    -> 去除 SDK 特有前缀
    -> trim 和格式校验
    -> 防止重复提交
    -> 请求业务接口
    -> 校验业务响应和权限
    -> 成功后展示或跳转

业务层至少处理:

  • 空结果。
  • 非法二维码或条形码格式。
  • 不是本系统对象。
  • 对象不存在或已失效。
  • 用户无权查看对象。
  • 业务接口返回失败或超时。
  • 查询成功但数据不完整。

只有业务接口确认对象有效并返回数据后,才进入成功页面。

12. iOS 重复扫码和重试

iOS 企业微信可能复用 WebView 和 JS-SDK 上下文,第二次调用容易出现签名不一致。建议:

  1. 使用独立扫码页面或独立路由。
  2. 每次进入扫码页面生成唯一查询参数,让签名 URL 发生变化。
  3. iOS 重试时完整刷新页面,再重新请求签名和注册 SDK。
  4. Android 可以复用当前页面,但要阻止重复初始化和并发扫码。
  5. 原生扫码失败后停留在错误状态,提供"重新扫码"按钮。
  6. 用户取消扫码只关闭当前扫码会话,不提示系统错误。
  7. 同一时间只能有一个扫码会话。

13. 统一错误状态

建议由适配层把底层错误转换成业务可识别的错误码:

text 复制代码
AUTH_MISSING           未获取到外部身份
AUTH_INVALID           外部身份无效
AUTH_NO_PERMISSION     本地账号无权限
SIGNATURE_FAILED       SDK 签名失败
SDK_UNSUPPORTED        当前客户端不支持原生扫码
SCAN_CANCELLED         用户取消扫码
SCAN_FAILED            原生扫码调用失败
IMAGE_CODE_NOT_FOUND   图片中未识别到二维码
BUSINESS_QUERY_FAILED  扫码内容对应业务查询失败

用户界面显示易懂提示,日志保留错误码和必要上下文,但不得记录完整外部凭证、本系统会话、Secret、ticket 或敏感扫码内容。

14. 配置与安全要求

企业微信侧

  • 配置可信域名。
  • 开放目标 JS-SDK 能力。
  • 确认自建应用或第三方应用所需的应用标识。
  • 确认 Android、iOS 客户端版本范围。
  • 确认正式环境使用受信任 HTTPS 域名。

后端侧

  • 外部凭证只在后端读取和校验。
  • Secret、ticket、上游接口凭据放入配置中心或密钥管理系统。
  • 联登接口使用精确免会话白名单。
  • 签名接口要求本系统会话并限制 URL 白名单。
  • 本地用户映射包含项目或租户隔离。
  • 用户禁用、删除和权限变化能阻止后续访问。
  • 上游登录和签名调用设置超时、重试、限流和熔断。
  • 日志脱敏,不打印 Cookie、会话和密钥。

前端侧

  • 跨域请求正确携带凭证。
  • 不读取或保存 HttpOnly 外部凭证。
  • 不把 Secret、ticket 或 access token 放进前端构建变量和浏览器存储。
  • 原生扫码、摄像头扫码和相册识别使用统一结果契约。
  • 完整处理失败、取消、重试和重复点击状态。
  • 摄像头正式环境使用 HTTPS。

15. 最小验收清单

  1. 企业微信内首次打开可以自动联登。
  2. 外部 Cookie 缺失时有明确提示。
  3. 外部账号可以正确映射本地用户和项目权限。
  4. 已禁用或无权限用户不能继续扫码和访问业务。
  5. 刷新页面后可以恢复会话或按预期重新联登。
  6. 普通浏览器不会误调用企业微信 SDK。
  7. Android 原生扫码成功。
  8. iOS 连续扫码、取消、失败后重试正常。
  9. 签名 URL 带查询参数时仍能成功。
  10. 原生扫码失败时可以切换摄像头或相册识别。
  11. 图片无二维码时有明确提示。
  12. 扫码成功但业务查询失败时不会误跳转到成功页面。
  13. 日志中没有外部凭证、本系统会话和 Secret。

16. 官方资料

企业微信客户端、JS-SDK 和接口规则会更新。新项目接入或升级依赖时,应重新核对对应版本的官方文档、类型定义和 Android/iOS 真机行为。