使用 Spring Boot 快速构建企业微信 JS-SDK 权限签名后端服务
本篇文章将介绍如何使用Spring Boot快速构建一个用于支持企业微信JS-SDK权限校验的后端接口,并提供一个简单的HTML页面进行功能测试。
使用 Spring Boot 快速构建企业微信 JS-SDK 权限签名后端服务
本篇文章将介绍如何使用 Spring Boot 快速构建一个用于支持企业微信 JS-SDK 权限校验的后端接口,并提供一个简单的 HTML 页面进行功能测试。适用于需要在企业微信网页端使用扫一扫、定位、录音等接口的场景。
一、项目目标
我们希望实现一个包含以下功能的服务:
- 提供获取企业微信
access_token
的接口 - 提供获取部门成员信息的接口(需要带 token)
- 提供 JS-SDK 前端初始化所需签名参数的接口(
wx.config()
配置) - 提供一个前端页面用于测试扫码、定位、数据表格展示等功能
二、开发环境与依赖
- JDK 17
- IDEA
- Spring Boot 3.2.5
- Maven 3.x
三、项目结构
DemoAPI
├── pom.xml // 项目依赖配置
├── src
│ └── main
│ ├── java
│ │ └── org.example
│ │ ├── Main.java // 项目启动类
│ │ ├── WeComController.java // 控制器:处理请求
│ │ └── WeComService.java // 服务类:处理逻辑
│ └── resources
│ └── static
│ └── index.html // 测试前端页面
说明: 本项目未配置 application.yml
,Spring Boot 默认即可运行。
四、完整功能实现
第一步:修改 pom.xml,添加 Spring Boot 配置
pom.xml
中我们引入了:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
可能遇到的问题:
- 依赖下载失败,可通过加速器优化下载速度。
- 注意 Spring Boot 3.x 要使用 JDK 17+。
第二步:刷新依赖
你可以点击 IntelliJ 右侧 “Maven” 工具窗口的刷新按钮(🔄),或者右键 pom.xml → Add as Maven Project,IDE 会自动下载 Spring Boot 依赖。
第三步:修改你的 Main.java,变成 Spring Boot 启动类
package org.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Main {
public static void main(String[] args) {
SpringApplication.run(Main.class, args);
}
}
问题回顾: 如果你忘记添加 @SpringBootApplication
,将导致 ApplicationContext
启动失败,同时控制台可能提示找不到 Web 容器类(如 Tomcat
)或无法创建 Controller Bean。解决办法:确保注解已加。
第四步:创建一个服务类 WeComService.java
提供 access_token 缓存获取、jsapi_ticket 缓存、JS-SDK 签名生成逻辑:
String raw = String.format(
"jsapi_ticket=%s&noncestr=%s×tamp=%d&url=%s",
jsapiTicket, nonceStr, timestamp, url);
MessageDigest md = MessageDigest.getInstance("SHA-1");
md.update(raw.getBytes(StandardCharsets.UTF_8));
注意:
- 签名计算必须严格按参数顺序和格式
access_token
和jsapi_ticket
建议缓存,避免频繁请求- 返回格式需包括
appId
、timestamp
、nonceStr
、signature
JS-SDK 参数生成
- 参数组成:
jsapi_ticket
、nonceStr
、timestamp
、url
- 算法:
SHA-1(raw字符串)
生成签名 - 返回结构:包含
appId
、timestamp
、nonceStr
、signature
第五步:控制器类 WeComController.java
提供如下接口:
接口地址 | 请求方法 | 功能描述 |
---|---|---|
/wecom/token |
GET | 获取 access_token |
/wecom/department/users |
GET | 获取指定部门的成员列表 |
/wecom/js-sdk-config |
GET | 获取 JS-SDK 初始化配置信息 |
常见问题:
- 若自动注入失败,请确认
@Service
和@RestController
注解是否添加 - 如果依赖注入失败,控制台会提示
UnsatisfiedDependencyException
第六步:创建前端测试页面 index.html
功能:
- 获取 Token 并展示
- 获取部门成员并展示表格(含滚动条)
- 初始化 JS SDK,支持扫码、定位等测试按钮
wx.config({
appId: config.appId,
timestamp: config.timestamp,
nonceStr: config.nonceStr,
signature: config.signature,
jsApiList: ["scanQRCode", "getLocation"]
});
wx.ready(function() {
alert("✅ 企业微信 JS SDK 初始化成功");
});
失败处理:
wx.error(function (err) {
alert("❌ SDK 初始化失败: " + JSON.stringify(err));
});
页面结构清晰,所有逻辑通过 window.onload
初始化即可。
第七步:运行你的 Spring Boot 应用
在 IntelliJ 中右键 Main.java → Run ‘Main’,或点击绿色的 ▶ 按钮。
看到类似:
Tomcat started on port(s): 8080
Started Main in x.xxx seconds
说明服务已成功启动。
第八步:界面展示
http://localhost:8080/index.html
运行 & 测试(可选)
启动 Spring Boot 项目后,浏览器访问可访问下面的接口:
http://localhost:8080/wecom/token
http://localhost:8080/wecom/department/users?id=1
六、常见问题总结
问题 | 说明 | 解决办法 |
---|---|---|
SDK 初始化失败 | 签名无效、时间戳不一致等 | 保证 URL 不带 # ,参数顺序正确 |
Bean 注入失败 | 启动报错找不到 Controller Bean | 检查是否缺少 @SpringBootApplication 或 @Service 注解 |
依赖无法拉取 | Maven 仓库连接慢 | 配置阿里云镜像源,提高稳定性 |
HTML 无法访问 | 资源路径未设置正确 | 放到 resources/static/ 下由 Spring Boot 自动映射 |
❌ 错误核心提示:
APPLICATION FAILED TO START
Web application could not be started as there was no
org.springframework.boot.web.servlet.server.ServletWebServerFactory bean defined in the context.
原因解释:Spring Boot 应用是一个 Web 项目,但 缺少内嵌 Servlet 容器(比如 Tomcat)依赖,也就是没有 ServletWebServerFactory,Spring Boot 启动 Web 服务失败。
最常见的原因:
pom.xml
中 缺失或拼错了spring-boot-starter-web
依赖- Maven 没有下载成功依赖(网络或仓库问题)
- 没有添加
@SpringBootApplication
七、后续可扩展方向
- 接入企业微信身份认证(OAuth2)
- 支持更多 JS API(如录音、语音识别、打开地图)
- 使用 Redis 缓存 token,提升性能与健壮性
- 前后端分离,使用 Vue、React 等框架
八、结语
通过本项目我们实现了从零搭建一个企业微信 JS-SDK 权限校验服务,具备了完整的后端支持和前端测试页面。如果想正常使用企业微信的扫描等功能需要在企业微信内部访问,那么就需要设置 IP 白名单、域名、网页授权及JS-SDK、企业微信授权登录和应用主页等。
九、推荐
Maven Central(Maven 中央仓库 Web 版)
这是最常用、几乎所有 Java 开发者都会用的网站 —— 一个图形化的 Maven 中央仓库检索平台:
👉 网站地址:
🌐 https://mvnrepository.com
使用 Spring Initializr 官网 创建项目(图形化窗口版)
这个网站会自动帮你生成一个可运行的 Spring Boot 项目,并打包成一个 zip 文件。解压 zip,然后用 IDEA 打开即可。
👉 地址:
🌐 https://start.spring.io
附录:完整文件(可自行补全代码)
pom.xml ✅
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>DemoAPI</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<!-- Spring Boot 父项目 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- Spring Boot Web 模块(包含内嵌 Tomcat) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 开发工具(自动重启,非必须) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
index.html ✅
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>企业微信接口测试</title>
<style>
body {
font-family: "微软雅黑", sans-serif;
margin: 20px;
}
table {
border-collapse: collapse;
width: 100%;
margin-top: 10px;
}
th, td {
border: 1px solid #ccc;
padding: 6px 12px;
text-align: center;
}
th {
background-color: #f5f5f5;
}
pre {
background-color: #eee;
padding: 10px;
}
.scroll-box {
max-height: 160px;
overflow-y: auto;
border: 1px solid #ccc;
}
#scanModal {
display: none; /* 初始隐藏 */
position: fixed;
z-index: 1000;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
/* 居中弹窗 */
display: flex;
justify-content: center;
align-items: center;
}
#scanModal.hidden {
display: none !important;
}
#scanBox {
background: white;
width: 320px;
max-width: 90vw;
padding: 16px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
text-align: center;
position: relative;
box-sizing: border-box;
}
#scanBox #reader {
width: 100%;
height: auto;
aspect-ratio: 1;
border: 1px solid #ccc;
margin-bottom: 10px;
}
#scanBox button {
background-color: #36eef4;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
}
</style>
<!-- 引入企业微信 JS SDK -->
<script src="https://res.wx.qq.com/open/js/jweixin-1.2.0.js"></script>
<!-- html5-qrcode 扫码库 -->
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
<script>
// 判断企业微信环境
function isWeComBrowser() {
return /wxwork/i.test(navigator.userAgent);
}
// 判断设备类型
function isMobileDevice() {
return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
}
// 初始化企业微信 JS SDK
async function initWeComJsSdk() {
const url = window.location.href.split('#')[0];
const res = await fetch('/wecom/js-sdk-config?url=' + encodeURIComponent(url));
const config = await res.json();
wx.config({
beta: true,
debug: false,
appId: config.appId,
timestamp: config.timestamp,
nonceStr: config.nonceStr,
signature: config.signature,
jsApiList: ["scanQRCode", "getLocation"]
});
wx.ready(function () {
console.log("企业微信 JS SDK 就绪");
alert("✅ 企业微信 JS SDK 初始化成功!");
document.getElementById('scanBtn').onclick = function () {
wx.scanQRCode({
needResult: 1,
scanType: ["qrCode", "barCode"],
success: function (res) {
alert("扫码结果:" + res.resultStr);
}
});
};
document.getElementById('locBtn').onclick = function () {
wx.getLocation({
type: 'wgs84',
success: function (res) {
alert("当前位置:经度 " + res.longitude + ",纬度 " + res.latitude);
}
});
};
});
wx.error(function (err) {
console.error("JS SDK 初始化失败:", err);
alert("❌ 企业微信 JS SDK 初始化失败!\n" + JSON.stringify(err));
});
}
async function getToken() {
const res = await fetch('/wecom/token');
const token = await res.text();
document.getElementById('token').innerText = token;
}
async function getUsers() {
const deptId = document.getElementById('dept').value || '1';
const res = await fetch(`/wecom/department/users?id=${deptId}`);
const json = await res.json();
document.getElementById('result').innerText = JSON.stringify(json, null, 2);
if (json.userlist) {
renderTable(json.userlist);
} else {
document.getElementById('userTableBody').innerHTML = "<tr><td colspan='6'>无成员数据</td></tr>";
}
}
function renderTable(users) {
const tbody = document.getElementById("userTableBody");
tbody.innerHTML = "";
users.forEach(user => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${user.name}</td>
<td>${user.userid}</td>
<td>${(user.department || []).join(',')}</td>
<td>${user.isleader === 1 ? '是' : '否'}</td>
<td>${translateStatus(user.status)}</td>
<td>${user.telephone || ''}</td>
`;
tbody.appendChild(row);
});
}
function translateStatus(status) {
switch (status) {
case 1: return "正常";
case 2: return "已禁用";
case 4: return "未激活";
default: return "未知";
}
}
// 全局变量:摄像头列表和当前索引等
let html5QrCode = null;
let cameras = [];
let currentCameraIndex = 0;
// 启动 HTML5 扫码窗口
function startHtml5Scan() {
const modal = document.getElementById("scanModal");
modal.classList.remove("hidden"); // 显示弹窗
try {
html5QrCode = new Html5Qrcode("reader");
} catch (err) {
alert("❌ 初始化扫码对象失败:" + err);
return;
}
Html5Qrcode.getCameras().then(deviceCameras => {
if (!deviceCameras || deviceCameras.length === 0) {
alert("⚠️ 没有检测到摄像头!");
return;
}
cameras = deviceCameras;
currentCameraIndex = 0;
startCameraByIndex(currentCameraIndex);
}).catch(err => {
alert("⚠️ 获取摄像头失败:" + err);
});
}
// 根据索引启动摄像头
function startCameraByIndex(index) {
const cameraId = cameras[index].id;
html5QrCode.start(
cameraId,
{ fps: 10, qrbox: 250 },
(decodedText) => {
alert("✅ 扫码成功:" + decodedText);
closeScanModal();
},
(errMsg) => {
console.log("识别中...", errMsg);
}
).catch(err => {
alert("❌ 启动摄像头失败:" + err);
});
}
// 切换摄像头
function switchCamera() {
if (!cameras || cameras.length <= 1) {
alert("没有其他摄像头可切换!");
return;
}
html5QrCode.stop().then(() => {
html5QrCode.clear();
currentCameraIndex = (currentCameraIndex + 1) % cameras.length;
startCameraByIndex(currentCameraIndex);
}).catch(err => {
alert("切换失败:" + err);
});
}
// 停止 HTML5 扫码
function stopHtml5Scan() {
if (html5QrCode) {
html5QrCode.stop().then(() => {
html5QrCode.clear();
document.getElementById("reader").style.display = "none";
}).catch(err => {
console.error("停止失败", err);
});
}
}
// 关闭 HTML5 扫码窗口
function closeScanModal() {
if (html5QrCode) {
html5QrCode.stop().then(() => {
html5QrCode.clear();
html5QrCode = null;
}).catch(err => {
console.error("停止失败", err);
});
}
const modal = document.getElementById("scanModal");
modal.classList.add("hidden");
document.getElementById("reader").innerHTML = ""; // 清除内容,避免重复初始化
}
// 非企业微信环境绑定 html5 扫码逻辑
function bindHtml5Fallback() {
console.warn("非企业微信环境,使用 html5-qrcode");
document.getElementById("scanBtn").onclick = startHtml5Scan;
document.getElementById("locBtn").onclick = useBrowserLocation;
}
function useBrowserLocation() {
if (!navigator.geolocation) {
alert("❌ 当前浏览器不支持地理定位!");
return;
}
navigator.geolocation.getCurrentPosition(
function (position) {
const lat = position.coords.latitude.toFixed(6);
const lon = position.coords.longitude.toFixed(6);
alert("📍 当前定位成功:纬度 " + lat + ",经度 " + lon);
},
function (err) {
console.error("定位失败", err);
switch (err.code) {
case 1:
alert("❌ 用户拒绝授权定位");
break;
case 2:
alert("❌ 位置不可用");
break;
case 3:
alert("❌ 获取位置超时");
break;
default:
alert("❌ 定位失败:" + err.message);
}
},
{
enableHighAccuracy: true, // 请求高精度定位
timeout: 10000,
maximumAge: 0
}
);
}
window.onload = function () {
if (isWeComBrowser() && isMobileDevice()) {
initWeComJsSdk();
} else {
bindHtml5Fallback();
}
};
</script>
</head>
<body>
<h1>企业微信接口测试</h1>
<!-- 获取 Token -->
<button onclick="getToken()">获取 Token</button>
<p>Token:<code id="token">(点击上面按钮)</code></p>
<!-- 获取部门成员 -->
<hr>
<label>部门 ID:</label>
<input type="text" id="dept" value="1">
<button onclick="getUsers()">获取部门成员</button>
<!-- 显示返回数据 -->
<h3>接口返回数据:</h3>
<pre id="result">(点击按钮查看 JSON)</pre>
<!-- 成员列表表格 -->
<h3>成员列表表格:</h3>
<div class="scroll-box">
<table>
<thead>
<tr>
<th>姓名</th>
<th>用户ID</th>
<th>部门</th>
<th>是否领导</th>
<th>状态</th>
<th>座机</th>
</tr>
</thead>
<tbody id="userTableBody"></tbody>
</table>
</div>
<!-- 功能测试 -->
<h3>功能测试:</h3>
<button id="scanBtn">扫一扫</button>
<button id="locBtn">获取当前位置</button>
<!-- 扫码模态窗口(初始隐藏) -->
<div id="scanModal" class="hidden">
<div id="scanBox">
<h3>📷 请对准二维码</h3>
<div id="reader"></div>
<div style="margin-top: 8px;">
<button onclick="switchCamera()">🔁</button>
<button onclick="closeScanModal()">❌</button>
</div>
</div>
</div>
</body>
</html>
Main.java ✅
package org.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* ==================================================
* This class ${NAME} is responsible for [功能描述].
*
* @author Darker
* @version 1.0
* ==================================================
*/
@SpringBootApplication
public class Main {
public static void main(String[] args) {
SpringApplication.run(Main.class, args);
}
}
WeComService.java ✅
package org.example;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.http.ResponseEntity;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Formatter;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.time.Instant;
/**
* ==================================================
* This class WeComService is responsible for [功能描述].
*
* @author Darker
* @version 1.0
* ==================================================
*/
@Service
public class WeComService {
private static final String CORP_ID = "你的企业微信ID";
private static final String SECRET = "你的自建应用的Secret";
private static final String TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken";
private String accessToken;
private long expireTime = 0;
// jsapi_ticket(缓存 2 小时)
private String jsapiTicket;
private long ticketExpire = 0;
public String getAccessToken() {
long now = Instant.now().getEpochSecond();
if (accessToken != null && now < expireTime) {
return accessToken;
}
// 请求新的 token
RestTemplate restTemplate = new RestTemplate();
UriComponentsBuilder builder = UriComponentsBuilder
.fromHttpUrl(TOKEN_URL)
.queryParam("corpid", CORP_ID)
.queryParam("corpsecret", SECRET);
ResponseEntity<WeComTokenResponse> response = restTemplate.getForEntity(
builder.toUriString(), WeComTokenResponse.class);
WeComTokenResponse body = response.getBody();
if (body != null && body.getAccess_token() != null) {
this.accessToken = body.getAccess_token();
this.expireTime = now + body.getExpires_in() - 60; // 提前60秒过期
return accessToken;
}
throw new RuntimeException("无法获取 access_token");
}
public Map<String, Object> getJsSdkConfig(String url) {
String jsapiTicket = getJsApiTicket(); // 用下面方法实现
String nonceStr = UUID.randomUUID().toString().replace("-", "");
long timestamp = System.currentTimeMillis() / 1000;
String raw = String.format(
"jsapi_ticket=%s&noncestr=%s×tamp=%d&url=%s",
jsapiTicket, nonceStr, timestamp, url
);
String signature;
try {
MessageDigest md = MessageDigest.getInstance("SHA-1");
md.update(raw.getBytes(StandardCharsets.UTF_8));
signature = bytesToHex(md.digest());
} catch (Exception e) {
throw new RuntimeException("签名失败", e);
}
Map<String, Object> result = new HashMap<>();
result.put("appId", CORP_ID);
result.put("timestamp", timestamp);
result.put("nonceStr", nonceStr);
result.put("signature", signature);
return result;
}
private String bytesToHex(byte[] bytes) {
Formatter formatter = new Formatter();
for (byte b : bytes) {
formatter.format("%02x", b);
}
String result = formatter.toString();
formatter.close();
return result;
}
public String getJsApiTicket() {
long now = System.currentTimeMillis() / 1000;
if (jsapiTicket != null && now < ticketExpire) {
return jsapiTicket;
}
String token = getAccessToken();
String url = "https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token=" + token;
RestTemplate restTemplate = new RestTemplate();
Map<String, Object> res = restTemplate.getForObject(url, Map.class);
if (res != null && res.get("ticket") != null) {
jsapiTicket = (String) res.get("ticket");
ticketExpire = now + ((Integer) res.get("expires_in")) - 60;
return jsapiTicket;
}
throw new RuntimeException("获取 jsapi_ticket 失败");
}
// 内部类用于接收 JSON 响应
public static class WeComTokenResponse {
private String access_token;
private int expires_in;
public String getAccess_token() {
return access_token;
}
public void setAccess_token(String access_token) {
this.access_token = access_token;
}
public int getExpires_in() {
return expires_in;
}
public void setExpires_in(int expires_in) {
this.expires_in = expires_in;
}
}
}
WeComController.java ✅
package org.example;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.web.util.UriComponentsBuilder;
/**
* ==================================================
* This class WeComController is responsible for [功能描述].
*
* @author Darker
* @version 1.0
* ==================================================
*/
@RestController
@RequestMapping("/wecom")
public class WeComController {
@Autowired
private WeComService weComService;
// GET 接口:/wecom/token
@GetMapping("/token")
public String getToken() {
return weComService.getAccessToken();
}
@GetMapping("/department/users")
public Object getDepartmentUsers(@RequestParam("id") String departmentId) {
String token = weComService.getAccessToken();
String url = "https://qyapi.weixin.qq.com/cgi-bin/user/list";
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url)
.queryParam("access_token", token)
.queryParam("department_id", departmentId);
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<Object> response = restTemplate.getForEntity(
builder.toUriString(), Object.class
);
return response.getBody();
}
// GET 接口:/wecom/js-sdk-config?url=xxx
@GetMapping("/js-sdk-config")
public Object getJsSdkConfig(@RequestParam("url") String url) {
return weComService.getJsSdkConfig(url);
}
}
更多推荐
所有评论(0)