简介:本资源是一套面向Java开发者的企业微信审批系统集成方案,聚焦解决审批表单中动态加载外部数据的典型需求,如通过API对接公司内部小区信息库实现下拉选项自动填充。适用于具备Spring Boot与企业微信JS-SDK开发经验的中高级工程师,尤其适合正在落地审批流程数字化改造的ToB应用团队。压缩包共20个文件,含10个核心Java业务类(覆盖Controller、Service及回调处理)、6个MyBatis XML映射文件、1个application.yml配置文件、1个HTML前端页面及必要工程配置文件(.gitignore等),整体仅20KB,轻量易集成。目前已有694人学习下载,提供开箱即用的完整调用链路:从前端JS签名注入、后端API鉴权与外部数据透传,到审批控件渲染与回调事件处理,目录结构清晰,模块职责分明,可直接嵌入现有企业微信应用项目快速验证与二次开发。
1. 企业微信审批流里嵌外部选项:不是加个 URL 就能用,得过 JS SDK、后端鉴权、字段映射三道关
某公司做 OA 升级时,想在企业微信「审批模板」里把“供应商选择”这个环节从静态下拉改成调用自有主数据系统接口动态加载——结果发现后台配置页里那个灰掉的「关联外部选项」开关根本点不动。翻遍官方文档才明白:这功能压根不是前端配个链接就能跑通的黑匣子,而是需要 JS SDK 初始化、后端生成有效 signature、审批控件字段名与返回 JSON 结构严格对齐的三段式链路。它解决的不是“能不能显示”,而是“如何让企业微信信任你的外部服务、并把用户选中的值原样回传给审批流”。适合正在对接企业微信审批 API 的 Java 后端 + 前端联调工程师,尤其当你已经卡在「点击加载无反应」「选中后提交报错字段不存在」「回调地址 403」这类典型现场时,这篇笔记就是你打开控制台前该先看的 checklist。
2. 外部选项功能原理与企业微信侧约束:为什么必须用 JS SDK + 回调地址 + 审批模板字段绑定
2.1 企业微信审批控件的「外部选项」本质是受控 iframe 加载模式
企业微信不直接渲染你的页面,而是以 iframe 方式加载你提供的 URL,并通过window.postMessage与你的页面通信。整个流程分三步:
- 用户进入审批单 → 企业微信向你配置的「外部选项 URL」发起 GET 请求(带
code、state参数); - 你的页面初始化 JS SDK(
wx.config),调用wx.invoke('openSelectExternalOption', {...})触发弹窗; - 用户选择后,企业微信通过
wx.onMenuShareAppMessage类似机制将选中项value和label回传到审批表单字段。
关键约束有三点:
- URL 必须是 HTTPS,且域名需提前在「企业微信管理后台 → 应用管理 → 自建应用 → 可信域名」中备案;
- JS SDK 签名必须由后端生成,前端不能硬编码
jsapi_ticket或nonceStr; - 返回的 JSON 数据结构必须符合企业微信强 schema:必须是
{"items": [{"value": "xxx", "label": "yyy"}]},多一层包装或字段名错一个字母都会导致空白页。
2.2 为什么 Java 后端必须参与签名生成?JS SDK 初始化失败的根源在这里
企业微信 JS SDK 要求每次调用前执行wx.config,其参数signature是对以下字符串 SHA1 加密:
jsapi_ticket=xxx&noncestr=yyy×tamp=zzz&url=https://your-domain.com/external-option其中jsapi_ticket是企业微信颁发的临时票据(2小时过期),需用corpId+corpSecret调用/cgi-bin/get_jsapi_ticket接口获取。前端无法安全存储corpSecret,所以签名逻辑必须放在 Java 后端。常见错误是前端试图用固定时间戳拼接,结果timestamp与企业微信服务器时间偏差超 7200 秒直接拒绝。
提示:Java 后端建议封装
WxJsSdkSignService,缓存jsapi_ticket并自动刷新,避免每次请求都调用接口。我一般会用Caffeine做本地缓存,过期时间设为 110 分钟,留 10 分钟缓冲。
2.3 审批模板字段与外部选项的映射关系:字段名大小写、类型、必填性全要对齐
在企业微信管理后台创建审批模板时,添加「外部选项」控件后,必须手动指定「字段名」(如supplier_id)。这个字段名会作为value的 key 回传到审批数据中。而你的外部页面返回的items数组里每个对象的value字段,必须是字符串类型(即使后端是 Long,也要String.valueOf(id)),且长度不能超过 128 字符。若你在模板里把该字段设为「必填」,但用户未选择就点提交,企业微信会拦截并提示「请选择供应商」,不会走到你后端。
3. Java 后端签名服务实现:Spring Boot 下生成有效 wx.config 参数
3.1 企业微信凭证配置与票据缓存
在application.yml中配置基础参数:
wechat: corp-id: wwxxxxxxxxxxxxxx corp-secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx agent-id: 1000001定义票据响应实体:
@Data public class JsApiTicketResponse { private int errcode; private String errmsg; private String ticket; private int expires_in; // 单位秒 }使用RestTemplate获取票据并缓存(关键:加锁防并发重复刷新):
@Service public class WxJsSdkSignService { private final RestTemplate restTemplate = new RestTemplate(); private final CaffeineCache jsapiTicketCache; public WxJsSdkSignService() { this.jsapiTicketCache = Caffeine.newBuilder() .maximumSize(1) .expireAfterWrite(110, TimeUnit.MINUTES) .build(); } public String getJsApiTicket(String corpId, String corpSecret) { String cacheKey = corpId; return jsapiTicketCache.get(cacheKey, key -> { String url = "https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token=" + getAccessToken(corpId, corpSecret); JsApiTicketResponse resp = restTemplate.getForObject(url, JsApiTicketResponse.class); if (resp == null || resp.getErrcode() != 0) { throw new RuntimeException("获取 jsapi_ticket 失败: " + resp.getErrmsg()); } return resp.getTicket(); }); } private String getAccessToken(String corpId, String corpSecret) { String url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + corpId + "&corpsecret=" + corpSecret; // 此处省略 AccessToken 响应解析,同理用 Caffeine 缓存,有效期 2h return "xxx_access_token_xxx"; } }3.2 签名生成 Controller:接收前端传来的 URL,返回完整 config 参数
前端页面加载时,会先请求/api/wx/sign?url=https%3A%2F%2Fxxx.com%2Foption获取签名:
@RestController @RequestMapping("/api/wx") public class WxSignController { @Autowired private WxJsSdkSignService signService; @GetMapping("/sign") public ResponseEntity<Map<String, String>> sign(@RequestParam String url) { String nonceStr = UUID.randomUUID().toString().replace("-", ""); long timestamp = System.currentTimeMillis() / 1000; String jsapiTicket = signService.getJsApiTicket( "wwxxxxxxxxxxxxxx", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ); // 拼接签名原文 String plain = "jsapi_ticket=" + jsapiTicket + "&noncestr=" + nonceStr + "×tamp=" + timestamp + "&url=" + url; String signature = DigestUtils.sha1Hex(plain); Map<String, String> result = new HashMap<>(); result.put("appId", "wwxxxxxxxxxxxxxx"); result.put("timestamp", String.valueOf(timestamp)); result.put("nonceStr", nonceStr); result.put("signature", signature); return ResponseEntity.ok(result); } }注意:
url参数必须是当前页面的完整 URL(含协议、域名、路径、查询参数),不能是相对路径或漏掉?code=xxx。企业微信校验时会严格比对,差一个字符就config:invalid url。
3.3 外部选项数据接口:返回符合 schema 的 JSON 列表
提供/external-option接口,接收企业微信带的code参数(用于换取用户身份),返回供应商列表:
@GetMapping("/external-option") public ResponseEntity<Map<String, Object>> externalOption(@RequestParam String code) { // 1. 用 code 换取 user_id(可选,若需权限控制) // 2. 查询主数据系统供应商列表 List<Supplier> suppliers = supplierService.listActive(); List<Map<String, String>> items = suppliers.stream() .map(s -> { Map<String, String> item = new HashMap<>(); item.put("value", String.valueOf(s.getId())); // 必须是字符串! item.put("label", s.getName()); return item; }) .collect(Collectors.toList()); Map<String, Object> result = new HashMap<>(); result.put("items", items); // 顶层必须是 items 字段,不能是 data/items 或 list return ResponseEntity.ok(result); }关键点:返回体必须是
{"items": [...]},不能包在{ "code": 0, "data": { "items": [...] } }里。企业微信解析失败时静默失败,只留白屏。
4. 前端页面 JS SDK 集成与外部选项调用:Vue/React 通用写法
4.1 页面加载后立即请求签名并初始化 SDK
以 Vue 3 Composition API 为例,在onMounted中执行:
import { onMounted, ref } from 'vue' import axios from 'axios' export default { setup() { const loading = ref(true) onMounted(async () => { try { // 1. 获取当前页面完整 URL(注意:必须 encodeURI) const currentUrl = encodeURI(window.location.href) // 2. 请求后端签名接口 const { data } = await axios.get(`/api/wx/sign?url=${currentUrl}`) // 3. 初始化 SDK wx.config({ debug: false, // 生产环境务必关闭 appId: data.appId, timestamp: data.timestamp, nonceStr: data.nonceStr, signature: data.signature, jsApiList: ['openSelectExternalOption'] // 必须显式声明 }) wx.ready(() => { console.log('wx sdk ready') loading.value = false }) wx.error((res) => { console.error('wx config error:', res) // 常见错误:invalid signature / invalid url / permission denied }) } catch (e) { console.error('init wx sdk failed', e) } }) return { loading } } }提示:
wx.config必须在页面 DOM 加载完成后调用,且jsApiList里必须包含'openSelectExternalOption',否则后续调用直接报not in jsApiList。
4.2 触发外部选项弹窗并处理选中结果
在用户点击「选择供应商」按钮时调用:
const openExternalOption = () => { wx.invoke('openSelectExternalOption', { url: 'https://your-domain.com/external-option', // 必须与后台配置一致 title: '请选择供应商', placeholder: '搜索供应商名称', multiple: false, // true 支持多选,但审批字段只存单值 search: true }, (res) => { if (res.err_msg === 'openSelectExternalOption:ok') { // res.data 格式:{ value: "1001", label: "XX科技有限公司" } console.log('选中:', res.data) // 将 value 写入表单隐藏域,供审批提交时读取 document.getElementById('supplier_id').value = res.data.value } else { console.warn('外部选项取消或失败:', res.err_msg) } }) }注意:
res.data是对象,不是数组。即使配置了multiple: true,企业微信在审批控件中也只支持单选回传(历史兼容限制),所以后端字段设计仍按字符串处理。
4.3 审批提交时字段值传递:隐藏域 + 表单序列化
在审批单 HTML 中,确保存在与模板字段名一致的<input>:
<!-- 企业微信模板中字段名为 supplier_id --> <input type="hidden" id="supplier_id" name="supplier_id" value="" /> <!-- 提交按钮触发企业微信默认提交 --> <button onclick="wx.invoke('submitApproval')">提交审批</button>企业微信 SDK 会自动扫描表单内name属性匹配的字段,并将value作为该字段的值提交。无需额外 JS 绑定。
5. 避坑指南:90% 的「外部选项不显示/选不了/提交失败」都出在这五处
5.1 现象:页面白屏,控制台无报错,network 里看不到external-option请求
原因:企业微信 iframe 加载你的页面时,若Content-Security-Policy头禁止了frame-ancestors 'self',或页面返回了X-Frame-Options: DENY,则 iframe 被浏览器拦截。
解决:后端响应头必须包含X-Frame-Options: ALLOW-FROM https://work.weixin.qq.com(注意是ALLOW-FROM,不是SAMEORIGIN),且 Spring Boot 中需禁用默认 HSTS 头。
@Configuration public class WebSecurityConfig { @Bean public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { return http .headers(headers -> headers .frameOptions(frameOptions -> frameOptions .disable())) // 关闭默认 X-Frame-Options .build(); } }再手动在 Controller 返回头中添加:
response.setHeader("X-Frame-Options", "ALLOW-FROM https://work.weixin.qq.com");5.2 现象:wx.config:invalid signature,但后端日志显示签名生成无异常
原因:前端传给/api/wx/sign?url=的url参数未做encodeURI,导致特殊字符(如?code=abc&state=def中的&)被截断,后端拼接的url字符串与实际页面 URL 不一致。
解决:前端必须严格encodeURI(window.location.href),后端接收后不要二次 decode,直接用于签名拼接。企业微信校验时用的是原始 URL 字符串。
5.3 现象:点击「选择供应商」无反应,控制台报not in jsApiList
原因:wx.config的jsApiList数组里漏写了'openSelectExternalOption',或wx.config调用时机过早(DOM 未加载完)、过晚(用户已点击按钮)。
解决:把wx.config放在onMounted或document.addEventListener('DOMContentLoaded')之后;检查jsApiList是否精确匹配,大小写敏感,无空格。
5.4 现象:外部选项弹窗能打开,列表也显示,但选中后审批单里字段为空
原因:企业微信要求value字段必须是字符串,而你的后端返回了数字(如"value": 1001),JSON 解析后变成 number 类型,企业微信拒绝识别。
解决:Java 返回时强制String.valueOf(id),JavaScript 返回时写"value": "" + id,确保 JSON 中value是字符串类型。可用curl -v抓包验证响应体。
5.5 现象:审批提交后,后台收到的数据里supplier_id字段是空字符串或undefined
原因:HTML 表单中input的name属性与企业微信审批模板中配置的字段名不一致(如模板写supplierId,页面写supplier_id),或该input被 JS 动态移除/重命名。
解决:打开企业微信审批模板编辑页,复制「字段名」精确粘贴到 HTML 的name属性;用浏览器开发者工具检查该input是否始终存在于 DOM 中,且value有值。
6. 审批数据回传验证与调试技巧:用企业微信「测试账号」+ 日志埋点定位真实链路
6.1 用企业微信「测试账号」绕过审核,快速验证全流程
生产环境需提交审批模板审核,但开发阶段可启用「测试账号」功能:
- 进入「企业微信管理后台 → 应用管理 → 自建应用 → 测试账号」;
- 添加你自己的微信账号(需是企业成员);
- 在测试账号下创建审批单,此时所有外部选项配置实时生效,无需等待审核。
这是血泪经验:曾因等审核 2 天,反复改代码却不知是签名问题还是字段名问题。开启测试账号后,从改代码到看到弹窗,10 分钟闭环。
6.2 在关键节点埋点日志,区分「前端失败」还是「后端失败」
在 Java 后端external-option接口开头加日志:
log.info("【外部选项】收到请求,code={}, userAgent={}", code, request.getHeader("User-Agent"));在前端wx.invoke回调里打印:
wx.invoke('openSelectExternalOption', {...}, (res) => { log.info('【外部选项】invoke callback', { err_msg: res.err_msg, data: res.data }) })然后对比:
- 若后端日志没打印 → 企业微信没发起请求 → 检查 iframe 加载、CSP 头;
- 若后端有日志但前端
err_msg是config:fail→ SDK 初始化失败 → 查签名; - 若前端
err_msg是ok但data为空 → 后端返回 JSON 结构错误 → 抓包看响应体。
6.3 用 Postman 模拟企业微信回调,验证签名服务健壮性
企业微信在用户提交审批后,会向你配置的「审批回调 URL」发送 POST 请求。虽然本篇聚焦外部选项,但回调验证是联调必过环节。用 Postman 构造请求:
- Method: POST
- URL:
https://your-domain.com/api/wx/callback - Body (raw JSON):
{ "ToUserName": "wwxxxxxxxxxxxxxx", "FromUserName": "USERID", "CreateTime": 1712345678, "MsgType": "event", "Event": "approval_approval", "ApprovalInfo": { "sp_no": "APPROVAL-123456789", "status": "approved", "approver_list": [{"userid": "USERID", "status": "approved"}] } }- Headers:
Content-Type: application/json
后端解析时,必须校验msg_signature(企业微信用token+timestamp+nonce+body生成),否则拒收。这部分逻辑建议抽成WxCallbackValidator工具类,避免每次手写。
从那以后我每次对接企业微信新能力,都强制走一遍「测试账号创建审批单 → 打开开发者工具看 network → 抓包确认每一步响应体 → 对照日志定位断点」四步闭环。哪怕只是改了个字段名,也重新走一遍——因为企业微信的报错永远藏在最深的那层 silent fail 里,不亲眼看见 response,你永远不知道是哪行代码在假装工作。希望帮到你。
本文还有配套的精品资源,点击获取