Spring Boot を使用した企業 WeChat JS-SDK 権限署名バックエンドサービスの迅速な構築

Spring Boot を使用した企業 WeChat JS-SDK 権限署名バックエンドサービスの迅速な構築

本記事では、Spring Boot を使用して企業 WeChat JS-SDK 権限検証のためのバックエンドインターフェースを迅速に構築する方法を紹介します。また、機能テスト用のシンプルな HTML ページも提供します。企業 WeChat のウェブページでスキャン、位置情報取得、録音などのインターフェースを使用する必要があるシナリオに適用されます。

プロジェクトの目標

以下の機能を含むサービスを実装します:

  • 企業 WeChat access_token を取得するインターフェースを提供
  • トークンを含む部門メンバー情報を取得するインターフェースを提供
  • JS-SDK フロントエンド初期化に必要な署名パラメータのインターフェースを提供(wx.config() 構成)
  • スキャン、位置情報取得、データテーブル表示などの機能をテストするためのフロントエンドページを提供

開発環境と依存関係

  • JDK 17
  • IDEA
  • Spring Boot 3.2.5
  • Maven 3.x

プロジェクト構造

WeComDemo
├── pom.xml                       		  // プロジェクト依存関係設定
├── src
│   └── main
│       ├── java
│       │   └── com.example
│       │       ├── Application.java             // プロジェクト起動クラス
│       │       ├── WeComController.java         // コントローラ:リクエスト処理
│       │       └── WeComService.java            // サービスクラス:ロジック処理
│       └── resources
│           └── static
│               └── test.html                   // テストフロントエンドページ

説明: このプロジェクトでは application.yml を設定していません。Spring Boot のデフォルト設定で実行可能です。

完全な機能の実装

ステップ1: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+ を使用する必要があります。

ステップ2:依存関係を更新

IntelliJ の右側にある「Maven」ツールウィンドウの更新ボタン(🔄)をクリックするか、pom.xml を右クリック → Add as Maven Project を選択すると、IDE が自動的に Spring Boot 依存関係をダウンロードします。

ステップ3:Main.java を Spring Boot 起動クラスに修正

package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

問題の確認: @SpringBootApplication を追加し忘れると、ApplicationContext の起動に失敗し、コンソールに Web コンテナクラス(例:Tomcat)が見つからない、または Controller Bean を作成できないというメッセージが表示される可能性があります。解決策:注釈が正しく追加されていることを確認してください。

ステップ4:サービスクラス 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_tokenjsapi_ticket はキャッシュすることをお勧めし、頻繁なリクエストを避けてください
  • 返却形式には appIdtimestampnonceStrsignature を含める必要があります

JS-SDK パラメータ生成

  • パラメータ構成:jsapi_ticketnonceStrtimestampurl
  • アルゴリズム:SHA-1(raw文字列) で署名を生成
  • 返却構造: appIdtimestampnonceStrsignature を含む

ステップ5:コントローラークラス WeComController.java

以下のインターフェースを提供:

インターフェースアドレス リクエストメソッド 機能の説明
/wecom/token GET access_token を取得
/wecom/department/users GET 指定された部門のメンバーリストを取得
/wecom/js-sdk-config GET JS-SDK 初期化構成情報を取得

よくある問題:

  • 自動注入に失敗した場合は、@Service@RestController の注釈が追加されているか確認してください
  • 依存関係の注入に失敗した場合、コンソールに UnsatisfiedDependencyException が表示される可能性があります

ステップ6:フロントエンドテストページ test.html を作成

機能:

  • トークンを取得して表示
  • 部門メンバーを取得してテーブルで表示(スクロールバー付き)
  • JS SDK を初期化し、スキャン、位置情報取得などのテストボタンをサポート
wx.config({
  appId: config.appId,
  timestamp: config.timestamp,
  nonceStr: config.nonceStr,
  signature: config.signature,
  jsApiList: ["scanQRCode", "getLocation"]
});

wx.ready(function() {
  alert("✅ 企業 WeChat JS SDK 初期化成功");
});

失敗処理:

wx.error(function (err) {
    alert("❌ SDK 初期化失敗: " + JSON.stringify(err));
});

ページ構造は明確で、すべてのロジックは window.onload で初期化できます。

ステップ7:Spring Boot アプリケーションを実行

IntelliJ で Main.java を右クリック → Run 'Main'、または緑色の ▶ ボタンをクリックします。

以下のようなメッセージが表示されます:

Tomcat started on port(s): 8080
Started Main in x.xxx seconds

サービスが正常に起動したことを示します。

ステップ8:インターフェースの表示

http://localhost:8080/test.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)の依存関係が欠けているため、Spring Boot が Web サービスの起動に失敗します。

最も一般的な原因:

  • pom.xmlspring-boot-starter-web 依存関係が欠けている、またはスペルミス
  • Maven が依存関係を正常にダウンロードできなかった(ネットワークまたはリポジトリの問題)
  • @SpringBootApplication が追加されていない

今後の拡張方向

  • 企業 WeChat 認証(OAuth2)を統合
  • より多くの JS API(録音、音声認識、地図を開くなど)をサポート
  • Redis を使用して token をキャッシュし、パフォーマンスと堅牢性を向上させる
  • フロントエンドとバックエンドを分離し、Vue、React などのフレームワークを使用する

推奨

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>com.example</groupId>
    <artifactId>WeComDemo</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>

test.html ✅

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>企業 WeChat インターフェーステスト</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;
        }
    </style>

    <!-- 企業 WeChat JS SDK を導入 -->
    <script src="https://res.wx.qq.com/open/js/jweixin-1.2.0.js"></script>

    <script>
        // 企業 WeChat 環境を判断
        function isWeComBrowser() {
            return /wxwork/i.test(navigator.userAgent);
        }

        // デバイスタイプを判断
        function isMobileDevice() {
            return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
        }

        // 企業 WeChat 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("企業 WeChat JS SDK が準備完了");
                alert("✅ 企業 WeChat 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("❌ 企業 WeChat 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 "不明";
            }
        }

        window.onload = function () {
            if (isWeComBrowser() && isMobileDevice()) {
                initWeComJsSdk();
            } else {
                console.warn("非企業 WeChat 環境、機能制限あり");
            }
        };
    </script>
</head>
<body>

<h1>企業 WeChat インターフェーステスト</h1>

<!-- トークンを取得 -->
<button onclick="getToken()">トークンを取得</button>
<p>トークン:(上のボタンをクリック)</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>

</body>
</html>

Application.java ✅

package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

WeComService.java ✅

package com.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;

@Service
public class WeComService {
    private static final String CORP_ID = "あなたの企業 WeChat 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;
        }

        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 com.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;

@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);
    }
}

タグ: Spring Boot WeChat JS-SDK 企业微信 Java

7月17日 16:06 投稿