本文只描述企业微信环境下前后端如何完成联登和扫码,不绑定具体项目、模块名称、Token 名称、接口路径、网关前缀或业务对象。
任何前端框架、后端语言和部署架构都可以按本文拆分职责后实现。
1. 要解决的问题
用户在企业微信内打开一个 H5 页面时,系统需要完成两件事:
- 利用企业微信环境中已有的外部身份登录态,让用户进入本系统,不再重复输入账号密码。
- 用户点击扫码时,优先调用企业微信原生扫码;原生能力不可用时,仍能使用摄像头或相册识别。
核心原则是:
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 企业微信后台准备
前端编码前应先确认:
- 已获得企业 ID,即
corpId。 - 已确定当前应用类型。
- 如果目标能力需要应用身份,已获得
agentId。 - 已配置"网页授权及 JS-SDK"可信域名。
- 当前域名已经完成归属验证。
- 应用可见范围包含测试用户。
- 后端可以取得生成签名所需的凭证或访问上游签名服务。
自建应用使用企业身份能力时通常提供 getConfigSignature。第三方应用或需要应用身份的 API,还可能需要 agentId 和 getAgentConfigSignature。这两种签名不能混用,具体组合应按应用类型和目标 JSAPI 确认。
4.3 前端依赖
推荐使用新版企业微信 SDK:
bash
npm install @wecom/jssdk axios
如果还需要普通浏览器摄像头和相册降级,可以增加二维码识别库:
bash
npm install html5-qrcode
依赖职责:
| 依赖 | 用途 | 是否必需 |
|---|---|---|
@wecom/jssdk |
register、checkJsApi、scanQRCode |
企业微信原生扫码必需 |
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 不使用旧版 jweixin 的 wx.ready() 模式。
正确理解是:
- 先调用
ww.register()注册页面身份和所需 JSAPI。 - SDK 在真正调用接口时,内部通过
getConfigSignature(url)获取签名并完成config。 register后可以直接调用 Promise 化的 JSAPI,不需要等待wx.ready()。- 为了让页面有明确的"SDK 可用"状态,业务代码可以等待
ww.checkJsApi()。 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>
如果产品要求打开扫码页后自动弹出原生扫码,也应先等待 authReady 和 sdkReady。在兼容性要求较高的场景,仍推荐由明确的用户点击事件触发。
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: "",
});
}
前端拿到登录结果后:
- 保存本系统会话。
- 请求当前用户信息。
- 确认用户、角色和权限加载成功。
- 标记"企业微信联登成功"。
- 允许进入扫码或其他业务页面。
- 登录失败时清理本系统会话并展示明确错误。
只拿到一个会话字符串,不能直接认为登录成功;当前用户信息和权限确认成功才算完成联登。
6. 联登:后端怎么做
后端联登接口建议按下面的顺序实现:
- 从受控请求体读取外部凭证,兼容必须显式传参的集成方式。
- 如果请求体为空,从请求 Cookie 读取外部凭证。
- 凭证为空时返回明确的身份获取失败。
- 使用服务端保存的密钥调用企业微信或上游身份系统。
- 校验上游响应,并提取稳定的外部账号编码。
- 按"外部账号编码 + 项目/租户"查询本地用户。
- 用户不存在时按业务规则创建;存在时同步姓名、组织和状态。
- 校验本地用户是否启用、是否有当前项目权限。
- 签发本系统会话。
- 返回最小必要的用户信息,不返回外部凭证、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 也必须同步变化。
后端
后端负责:
- 校验本系统会话。
- 校验 URL 的协议、域名和端口是否在允许范围。
- 按统一规则规范化 URL。
- 使用服务端 Secret 获取或计算签名。
- 缓存可缓存的 access token、ticket 等中间凭证。
- 只返回前端需要的时间戳、随机串和签名。
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;
};
企业微信原生扫码流程:
- 在用户点击事件中开始调用。
- 调用
ensureWeComReady()。 - SDK 按当前页面 URL 请求签名并完成注册。
checkJsApi确认扫码能力可用。- 调用
scanQRCode。 - 处理成功、取消、权限失败、签名失败和超时。
- 将返回内容归一化后交给业务层。
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 上下文,第二次调用容易出现签名不一致。建议:
- 使用独立扫码页面或独立路由。
- 每次进入扫码页面生成唯一查询参数,让签名 URL 发生变化。
- iOS 重试时完整刷新页面,再重新请求签名和注册 SDK。
- Android 可以复用当前页面,但要阻止重复初始化和并发扫码。
- 原生扫码失败后停留在错误状态,提供"重新扫码"按钮。
- 用户取消扫码只关闭当前扫码会话,不提示系统错误。
- 同一时间只能有一个扫码会话。
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. 最小验收清单
- 企业微信内首次打开可以自动联登。
- 外部 Cookie 缺失时有明确提示。
- 外部账号可以正确映射本地用户和项目权限。
- 已禁用或无权限用户不能继续扫码和访问业务。
- 刷新页面后可以恢复会话或按预期重新联登。
- 普通浏览器不会误调用企业微信 SDK。
- Android 原生扫码成功。
- iOS 连续扫码、取消、失败后重试正常。
- 签名 URL 带查询参数时仍能成功。
- 原生扫码失败时可以切换摄像头或相册识别。
- 图片无二维码时有明确提示。
- 扫码成功但业务查询失败时不会误跳转到成功页面。
- 日志中没有外部凭证、本系统会话和 Secret。
16. 官方资料
企业微信客户端、JS-SDK 和接口规则会更新。新项目接入或升级依赖时,应重新核对对应版本的官方文档、类型定义和 Android/iOS 真机行为。