Files
Mycontent/soul-api/internal/wechat/jssdk_open_launch.go
2026-04-15 19:07:02 +08:00

183 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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&timestamp=%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
}