183 lines
4.9 KiB
Go
183 lines
4.9 KiB
Go
package wechat
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/sha1"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// 小程序 access_token + jsapi_ticket 缓存(用于 H5 wx-open-launch-weapp 的 wx.config)
|
||
var (
|
||
jssdkMu sync.Mutex
|
||
accessToken string
|
||
accessExpiry time.Time
|
||
jsapiTicket string
|
||
ticketExpiry time.Time
|
||
)
|
||
|
||
type wxTokenResp struct {
|
||
AccessToken string `json:"access_token"`
|
||
ExpiresIn int `json:"expires_in"`
|
||
ErrCode int `json:"errcode"`
|
||
ErrMsg string `json:"errmsg"`
|
||
}
|
||
|
||
type wxTicketResp struct {
|
||
Ticket string `json:"ticket"`
|
||
ExpiresIn int `json:"expires_in"`
|
||
ErrCode int `json:"errcode"`
|
||
ErrMsg string `json:"errmsg"`
|
||
}
|
||
|
||
func nonceStr16() string {
|
||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||
b := make([]byte, 16)
|
||
if _, err := rand.Read(b); err != nil {
|
||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||
}
|
||
for i := range b {
|
||
b[i] = chars[int(b[i])%len(chars)]
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
func fetchAccessToken(ctx context.Context) (string, time.Time, error) {
|
||
if cfg == nil || cfg.WechatAppID == "" || cfg.WechatAppSecret == "" {
|
||
return "", time.Time{}, fmt.Errorf("wechat not configured")
|
||
}
|
||
u := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
|
||
escapeQuery(cfg.WechatAppID), escapeQuery(cfg.WechatAppSecret))
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||
if err != nil {
|
||
return "", time.Time{}, err
|
||
}
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return "", time.Time{}, err
|
||
}
|
||
defer resp.Body.Close()
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return "", time.Time{}, err
|
||
}
|
||
var tr wxTokenResp
|
||
if err := json.Unmarshal(body, &tr); err != nil {
|
||
return "", time.Time{}, err
|
||
}
|
||
if tr.ErrCode != 0 {
|
||
return "", time.Time{}, fmt.Errorf("wechat token errcode=%d %s", tr.ErrCode, tr.ErrMsg)
|
||
}
|
||
if tr.AccessToken == "" {
|
||
return "", time.Time{}, fmt.Errorf("wechat token empty")
|
||
}
|
||
exp := time.Now().Add(time.Duration(tr.ExpiresIn-120) * time.Second)
|
||
if tr.ExpiresIn <= 120 {
|
||
exp = time.Now().Add(30 * time.Minute)
|
||
}
|
||
return tr.AccessToken, exp, nil
|
||
}
|
||
|
||
func escapeQuery(s string) string { return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") }
|
||
|
||
func fetchJSAPITicket(ctx context.Context, accessTok string) (string, time.Time, error) {
|
||
u := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=%s&type=jsapi", escapeQuery(accessTok))
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||
if err != nil {
|
||
return "", time.Time{}, err
|
||
}
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return "", time.Time{}, err
|
||
}
|
||
defer resp.Body.Close()
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return "", time.Time{}, err
|
||
}
|
||
var tr wxTicketResp
|
||
if err := json.Unmarshal(body, &tr); err != nil {
|
||
return "", time.Time{}, err
|
||
}
|
||
if tr.ErrCode != 0 {
|
||
return "", time.Time{}, fmt.Errorf("wechat ticket errcode=%d %s", tr.ErrCode, tr.ErrMsg)
|
||
}
|
||
if tr.Ticket == "" {
|
||
return "", time.Time{}, fmt.Errorf("wechat ticket empty")
|
||
}
|
||
exp := time.Now().Add(time.Duration(tr.ExpiresIn-120) * time.Second)
|
||
if tr.ExpiresIn <= 120 {
|
||
exp = time.Now().Add(30 * time.Minute)
|
||
}
|
||
return tr.Ticket, exp, nil
|
||
}
|
||
|
||
func ensureJSAPITicket(ctx context.Context) (string, error) {
|
||
jssdkMu.Lock()
|
||
defer jssdkMu.Unlock()
|
||
now := time.Now()
|
||
if jsapiTicket != "" && now.Before(ticketExpiry) {
|
||
return jsapiTicket, nil
|
||
}
|
||
if accessToken == "" || now.After(accessExpiry) {
|
||
tok, exp, err := fetchAccessToken(ctx)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
accessToken, accessExpiry = tok, exp
|
||
}
|
||
ticket, exp, err := fetchJSAPITicket(ctx, accessToken)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
jsapiTicket, ticketExpiry = ticket, exp
|
||
return jsapiTicket, nil
|
||
}
|
||
|
||
// JSSDKOpenLaunchPayload 供 H5 页 wx.config(须含 openTagList: wx-open-launch-weapp)
|
||
type JSSDKOpenLaunchPayload struct {
|
||
OK bool `json:"ok"`
|
||
AppID string `json:"appId,omitempty"`
|
||
Timestamp int64 `json:"timestamp,omitempty"`
|
||
NonceStr string `json:"nonceStr,omitempty"`
|
||
Signature string `json:"signature,omitempty"`
|
||
}
|
||
|
||
// BuildJSSDKOpenLaunchPayload 为当前页面 URL 生成签名;失败时 ok=false(页面仍可降级展示)
|
||
func BuildJSSDKOpenLaunchPayload(ctx context.Context, pageURL string) JSSDKOpenLaunchPayload {
|
||
out := JSSDKOpenLaunchPayload{OK: false}
|
||
if cfg == nil {
|
||
return out
|
||
}
|
||
pageURL = strings.TrimSpace(pageURL)
|
||
if pageURL == "" {
|
||
return out
|
||
}
|
||
if i := strings.IndexByte(pageURL, '#'); i >= 0 {
|
||
pageURL = pageURL[:i]
|
||
}
|
||
ticket, err := ensureJSAPITicket(ctx)
|
||
if err != nil {
|
||
return out
|
||
}
|
||
nonce := nonceStr16()
|
||
ts := time.Now().Unix()
|
||
plain := fmt.Sprintf("jsapi_ticket=%s&noncestr=%s×tamp=%d&url=%s", ticket, nonce, ts, pageURL)
|
||
sum := sha1.Sum([]byte(plain))
|
||
sig := hex.EncodeToString(sum[:])
|
||
out.OK = true
|
||
out.AppID = cfg.WechatAppID
|
||
out.Timestamp = ts
|
||
out.NonceStr = nonce
|
||
out.Signature = sig
|
||
return out
|
||
}
|