WeChat JSSDKを活用したWebサイト共有時のサムネイル表示実装

WeChat(微信)でWebサイトをモーメンツ(朋友圈)に共有する際、サムネイル画像付きで表示させるには、WeChat公式のJSSDKを導入し、認証済みの公式アカウントが必要です。WeChat 6.5.5以降、JSSDKを使用しない共有は公式にサポートされていません。

前提条件

  • 共有対象のWebサイトドメインがICP登録(中国国内サーバー登録)されていること
  • WeChat公式アカウント管理画面でAppIDとAppSecretを取得済み
  • サーバーIPがホワイトリストに登録されていること

フロントエンド実装

HTMLページにWeChat JSSDKを組み込み、シグネチャ検証とシェア設定を行います。

<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script>
(function() {
    const shareConfig = {
        appId: window.WX_APP_ID,
        timestamp: window.WX_TIMESTAMP,
        nonceStr: window.WX_NONCE_STR,
        signature: window.WX_SIGNATURE,
        shareTitle: '共有タイトル',
        shareImageUrl: 'https://example.com/share-thumbnail.jpg'
    };

    wx.config({
        debug: false,
        appId: shareConfig.appId,
        timestamp: shareConfig.timestamp,
        nonceStr: shareConfig.nonceStr,
        signature: shareConfig.signature,
        jsApiList: [
            'updateAppMessageShareData',
            'updateTimelineShareData',
            'onMenuShareTimeline',
            'onMenuShareAppMessage'
        ]
    });

    wx.ready(function() {
        // モーメンツへの共有設定
        wx.updateTimelineShareData({
            title: shareConfig.shareTitle,
            link: window.location.href,
            imgUrl: shareConfig.shareImageUrl,
            success: function() {}
        });

        // チャットへの共有設定
        wx.updateAppMessageShareData({
            title: shareConfig.shareTitle,
            desc: 'シェアの説明文',
            link: window.location.href,
            imgUrl: shareConfig.shareImageUrl,
            success: function() {}
        });
    });
})();
</script>

また、ページ読み込み時にサムネイル画像を認識させるため、非表示エリアに画像を配置します。

<div style="width:0;height:0;overflow:hidden;position:absolute;">
    <img src="https://example.com/share-thumbnail.jpg" alt="share" />
</div>

バックエンド実装(C#)

シグネチャパッケージ生成クラス

public class WeChatSignatureService
{
    private readonly string _appId;
    private readonly string _appSecret;
    private readonly ITokenRepository _tokenRepository;

    public WeChatSignatureService(string appId, string appSecret, ITokenRepository tokenRepository)
    {
        _appId = appId;
        _appSecret = appSecret;
        _tokenRepository = tokenRepository;
    }

    public Dictionary<string, object> GenerateSignaturePackage(string currentUrl)
    {
        var jsApiTicket = GetOrRefreshJsApiTicket();
        var timestamp = GenerateTimestamp();
        var nonceStr = GenerateNonceString();
        var rawString = $"jsapi_ticket={jsApiTicket}&noncestr={nonceStr}×tamp={timestamp}&url={currentUrl}";
        var signature = ComputeSha1Hash(rawString);

        return new Dictionary<string, object>
        {
            { "appId", _appId },
            { "timestamp", timestamp },
            { "nonceStr", nonceStr },
            { "signature", signature },
            { "url", currentUrl }
        };
    }

    private string GenerateNonceString(int length = 16)
    {
        const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
        var random = new Random();
        return new string(Enumerable.Repeat(chars, length)
            .Select(s => s[random.Next(s.Length)]).ToArray());
    }

    private int GenerateTimestamp()
    {
        var epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
        return (int)(DateTime.UtcNow - epoch).TotalSeconds;
    }

    private string ComputeSha1Hash(string input)
    {
        using (var sha1 = SHA1.Create())
        {
            var bytes = Encoding.UTF8.GetBytes(input);
            var hash = sha1.ComputeHash(bytes);
            return BitConverter.ToString(hash).Replace("-", "").ToLower();
        }
    }

    private string GetOrRefreshJsApiTicket()
    {
        var stored = _tokenRepository.GetJsApiTicket();
        
        if (stored != null && stored.IsValid)
        {
            return stored.Ticket;
        }

        var accessToken = GetOrRefreshAccessToken();
        var ticketJson = HttpGet($"https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token={accessToken}&type=jsapi");
        var ticket = JsonSerializer.Deserialize<JsApiTicketResponse>(ticketJson);
        
        _tokenRepository.SaveJsApiTicket(new JsApiTicketData
        {
            Ticket = ticket.Ticket,
            ExpiresAt = DateTime.Now.AddSeconds(7000)
        });

        return ticket.Ticket;
    }

    private string GetOrRefreshAccessToken()
    {
        var stored = _tokenRepository.GetAccessToken();
        
        if (stored != null && stored.IsValid)
        {
            return stored.Token;
        }

        var json = HttpGet($"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={_appId}&secret={_appSecret}");
        var response = JsonSerializer.Deserialize<AccessTokenResponse>(json);
        
        _tokenRepository.SaveAccessToken(new AccessTokenData
        {
            Token = response.AccessToken,
            ExpiresAt = DateTime.Now.AddSeconds(7000)
        });

        return response.AccessToken;
    }

    private string HttpGet(string url)
    {
        using (var client = new WebClient())
        {
            return client.DownloadString(url);
        }
    }
}

トークン管理リポジトリ

public interface ITokenRepository
{
    AccessTokenData GetAccessToken();
    void SaveAccessToken(AccessTokenData data);
    JsApiTicketData GetJsApiTicket();
    void SaveJsApiTicket(JsApiTicketData data);
}

public class DatabaseTokenRepository : ITokenRepository
{
    private readonly IDbConnection _connection;

    public DatabaseTokenRepository(IDbConnection connection)
    {
        _connection = connection;
    }

    public AccessTokenData GetAccessToken()
    {
        const string sql = "SELECT access_token, expires_at FROM weixin_tokens WHERE id = 1";
        using (var cmd = _connection.CreateCommand())
        {
            cmd.CommandText = sql;
            using (var reader = cmd.ExecuteReader())
            {
                if (reader.Read())
                {
                    return new AccessTokenData
                    {
                        Token = reader.GetString(0),
                        ExpiresAt = reader.GetDateTime(1)
                    };
                }
            }
        }
        return null;
    }

    public void SaveAccessToken(AccessTokenData data)
    {
        const string sql = @"INSERT INTO weixin_tokens (id, access_token, expires_at) 
                            VALUES (1, @Token, @ExpiresAt)
                            ON DUPLICATE KEY UPDATE access_token = @Token, expires_at = @ExpiresAt";
        _connection.Execute(sql, new { data.Token, data.ExpiresAt });
    }

    public JsApiTicketData GetJsApiTicket()
    {
        const string sql = "SELECT jsapi_ticket, expires_at FROM weixin_jsapi_tickets WHERE id = 1";
        using (var cmd = _connection.CreateCommand())
        {
            cmd.CommandText = sql;
            using (var reader = cmd.ExecuteReader())
            {
                if (reader.Read())
                {
                    return new JsApiTicketData
                    {
                        Ticket = reader.GetString(0),
                        ExpiresAt = reader.GetDateTime(1)
                    };
                }
            }
        }
        return null;
    }

    public void SaveJsApiTicket(JsApiTicketData data)
    {
        const string sql = @"INSERT INTO weixin_jsapi_tickets (id, jsapi_ticket, expires_at) 
                            VALUES (1, @Ticket, @ExpiresAt)
                            ON DUPLICATE KEY UPDATE jsapi_ticket = @Ticket, expires_at = @ExpiresAt";
        _connection.Execute(sql, new { data.Ticket, data.ExpiresAt });
    }
}

public class AccessTokenData
{
    public string Token { get; set; }
    public DateTime ExpiresAt { get; set; }
    public bool IsValid => !string.IsNullOrEmpty(Token) && DateTime.Now < ExpiresAt;
}

public class JsApiTicketData
{
    public string Ticket { get; set; }
    public DateTime ExpiresAt { get; set; }
    public bool IsValid => !string.IsNullOrEmpty(Ticket) && DateTime.Now < ExpiresAt;
}

コントローラーでの使用例

public class ShareController : Controller
{
    private readonly WeChatSignatureService _signatureService;

    public ShareController(WeChatSignatureService signatureService)
    {
        _signatureService = signatureService;
    }

    public ActionResult Index()
    {
        var currentUrl = Request.Url.AbsoluteUri;
        var signaturePackage = _signatureService.GenerateSignaturePackage(currentUrl);

        ViewBag.WxAppId = signaturePackage["appId"];
        ViewBag.WxTimestamp = signaturePackage["timestamp"];
        ViewBag.WxNonceStr = signaturePackage["nonceStr"];
        ViewBag.WxSignature = signaturePackage["signature"];

        return View();
    }
}

データベース設計

トークンとチケットを永続化するためのテーブル構造です。

CREATE TABLE weixin_accounts (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100),
    appid VARCHAR(100) NOT NULL,
    appsecret VARCHAR(150) NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE weixin_tokens (
    id INT PRIMARY KEY,
    access_token VARCHAR(500) NOT NULL,
    expires_at DATETIME NOT NULL,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

CREATE TABLE weixin_jsapi_tickets (
    id INT PRIMARY KEY,
    jsapi_ticket VARCHAR(500) NOT NULL,
    expires_at DATETIME NOT NULL,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

注意事項

  • access_tokenは1日の取得回数制限があるため、キャッシュとデータベースで管理し、有効期限内は再利用する
  • jsapi_ticketも同様に取得回数制限があるため、適切にキャッシュする
  • シグネチャ生成時、URLパラメータはエンコードせずそのまま使用する
  • 本番環境ではHTTPSが必須

実装完了後、WeChat開発者ツールを使用してデバッグし、各パラメータが正しく取得できているか確認してください。正常に設定されれば、共有時に指定したサムネイル画像が表示されるようになります。

タグ: WeChat JSSDK C# サムネイル共有 アクセストークン管理

10月24日 09:10 投稿