2026-02-18 23:23:34 +08:00

456 lines
13 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 (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/httpclient"
redispkg "bindbox-game/internal/pkg/redis"
)
// AccessTokenRequest 获取 access_token 请求参数
type AccessTokenRequest struct {
AppID string `json:"appid"`
AppSecret string `json:"secret"`
GrantType string `json:"grant_type"`
}
// AccessTokenResponse 获取 access_token 响应
type AccessTokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
}
// TokenCache access_token 缓存结构
type TokenCache struct {
Token string
ExpiresAt time.Time
mutex sync.RWMutex
}
// 全局 token 缓存
var globalTokenCache = &TokenCache{}
// WechatConfig 微信配置
type WechatConfig struct {
AppID string
AppSecret string
}
// QRCodeRequest 获取小程序码请求参数
type QRCodeRequest struct {
Path string `json:"path"`
Width int `json:"width,omitempty"`
AutoColor bool `json:"auto_color,omitempty"`
LineColor *LineColor `json:"line_color,omitempty"`
IsHyaline bool `json:"is_hyaline,omitempty"`
EnvVersion string `json:"release,omitempty"`
}
type LineColor struct {
R int `json:"r"`
G int `json:"g"`
B int `json:"b"`
}
// QRCodeResponse 获取小程序码响应
type QRCodeResponse struct {
Buffer []byte `json:"-"`
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
}
// QRCodeError 微信API错误响应
type QRCodeError struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
func (e *QRCodeError) Error() string {
return fmt.Sprintf("微信API错误: errcode=%d, errmsg=%s", e.ErrCode, e.ErrMsg)
}
// GetAccessToken 获取微信 access_token
func GetAccessToken(ctx core.Context, config *WechatConfig) (string, error) {
if config == nil {
return "", fmt.Errorf("微信配置不能为空")
}
if config.AppID == "" {
return "", fmt.Errorf("AppID 不能为空")
}
if config.AppSecret == "" {
return "", fmt.Errorf("AppSecret 不能为空")
}
// 1. 先检查缓存是否有效
globalTokenCache.mutex.RLock()
if globalTokenCache.Token != "" && time.Now().Before(globalTokenCache.ExpiresAt) {
token := globalTokenCache.Token
globalTokenCache.mutex.RUnlock()
return token, nil
}
globalTokenCache.mutex.RUnlock()
// 2. 缓存失效,需要获取新 token使用写锁防止并发重复请求
globalTokenCache.mutex.Lock()
defer globalTokenCache.mutex.Unlock()
// 双重检查:可能在等待锁期间已被其他协程更新
if globalTokenCache.Token != "" && time.Now().Before(globalTokenCache.ExpiresAt) {
return globalTokenCache.Token, nil
}
// 3. 调用微信 API 获取新 token (使用 stable_token 接口)
url := "https://api.weixin.qq.com/cgi-bin/stable_token"
requestBody := map[string]any{
"grant_type": "client_credential",
"appid": config.AppID,
"secret": config.AppSecret,
"force_refresh": false,
}
client := httpclient.GetHttpClientWithContext(ctx.RequestContext())
resp, err := client.R().
SetBody(requestBody).
Post(url)
if err != nil {
return "", fmt.Errorf("获取stable_access_token失败: %v", err)
}
if resp.StatusCode() != http.StatusOK {
return "", fmt.Errorf("HTTP请求失败状态码: %d", resp.StatusCode())
}
var tokenResp AccessTokenResponse
if err := json.Unmarshal(resp.Body(), &tokenResp); err != nil {
return "", fmt.Errorf("解析access_token响应失败: %v", err)
}
if tokenResp.ErrCode != 0 {
return "", fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
}
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("获取到的access_token为空")
}
// 4. 更新缓存提前5分钟过期以留出刷新余地
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second)
globalTokenCache.Token = tokenResp.AccessToken
globalTokenCache.ExpiresAt = expiresAt
return tokenResp.AccessToken, nil
}
// GetAccessTokenWithContext 获取微信 access_token使用 context.Context
// 用于后台任务等无 core.Context 的场景
// 优先使用 Redis 缓存实现跨实例共享,Redis 不可用时降级到内存缓存
func GetAccessTokenWithContext(ctx context.Context, config *WechatConfig) (string, error) {
if config == nil {
return "", fmt.Errorf("微信配置不能为空")
}
if config.AppID == "" {
return "", fmt.Errorf("AppID 不能为空")
}
if config.AppSecret == "" {
return "", fmt.Errorf("AppSecret 不能为空")
}
// 1. 尝试从 Redis 获取 token
redisKey := fmt.Sprintf("wechat:access_token:%s", config.AppID)
if token, err := getTokenFromRedis(ctx, redisKey); err == nil && token != "" {
return token, nil
}
// 2. Redis 中没有,使用分布式锁获取新 token
lockKey := fmt.Sprintf("lock:wechat:access_token:%s", config.AppID)
locked, err := acquireDistributedLock(ctx, lockKey, 10*time.Second)
if err != nil || !locked {
// 如果获取锁失败,等待一小段时间后重试读取(可能其他实例正在获取)
time.Sleep(100 * time.Millisecond)
if token, err := getTokenFromRedis(ctx, redisKey); err == nil && token != "" {
return token, nil
}
// 如果还是没有,降级到内存缓存逻辑
return getTokenFromMemoryOrAPI(ctx, config)
}
defer releaseDistributedLock(ctx, lockKey)
// 3. 双重检查:获取锁后再次检查 Redis
if token, err := getTokenFromRedis(ctx, redisKey); err == nil && token != "" {
return token, nil
}
// 4. 调用微信 API 获取新 token (使用 stable_token 接口)
url := "https://api.weixin.qq.com/cgi-bin/stable_token"
requestBody := map[string]any{
"grant_type": "client_credential",
"appid": config.AppID,
"secret": config.AppSecret,
"force_refresh": false,
}
client := httpclient.GetHttpClient()
resp, err := client.R().
SetBody(requestBody).
Post(url)
if err != nil {
return "", fmt.Errorf("获取stable_access_token失败: %v", err)
}
if resp.StatusCode() != http.StatusOK {
return "", fmt.Errorf("HTTP请求失败,状态码: %d", resp.StatusCode())
}
var tokenResp AccessTokenResponse
if err := json.Unmarshal(resp.Body(), &tokenResp); err != nil {
return "", fmt.Errorf("解析access_token响应失败: %v", err)
}
if tokenResp.ErrCode != 0 {
return "", fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
}
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("获取到的access_token为空")
}
// 5. 存储到 Redis (提前5分钟过期以留出刷新余地)
expiresIn := tokenResp.ExpiresIn - 300
if expiresIn < 60 {
expiresIn = 60 // 最少缓存1分钟
}
saveTokenToRedis(ctx, redisKey, tokenResp.AccessToken, expiresIn)
// 6. 同时更新内存缓存作为降级备份
globalTokenCache.mutex.Lock()
globalTokenCache.Token = tokenResp.AccessToken
globalTokenCache.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
globalTokenCache.mutex.Unlock()
return tokenResp.AccessToken, nil
}
// GetQRCodeWithConfig 通过微信配置获取小程序码自动获取access_token
// config: 微信配置AppID 和 AppSecret
// request: 请求参数
// 返回: 成功时返回图片二进制数据,失败时返回错误
func GetQRCodeWithConfig(ctx core.Context, config *WechatConfig, request *QRCodeRequest) (*QRCodeResponse, error) {
// 自动获取 access_token
accessToken, err := GetAccessToken(ctx, config)
if err != nil {
return nil, fmt.Errorf("获取access_token失败: %v", err)
}
return GetQRCode(ctx, accessToken, request)
}
// GetQRCode 获取小程序码
// accessToken: 接口调用凭证
// request: 请求参数
// 返回: 成功时返回图片二进制数据,失败时返回错误
func GetQRCode(ctx core.Context, accessToken string, request *QRCodeRequest) (*QRCodeResponse, error) {
if accessToken == "" {
return nil, fmt.Errorf("access_token 不能为空")
}
if request == nil {
return nil, fmt.Errorf("请求参数不能为空")
}
if request.Path == "" {
return nil, fmt.Errorf("path 参数不能为空")
}
if len(request.Path) > 1024 {
return nil, fmt.Errorf("path 参数长度不能超过1024个字符")
}
// 设置默认值
if request.Width == 0 {
request.Width = 430
}
if request.Width < 280 {
request.Width = 280
}
if request.Width > 1280 {
request.Width = 1280
}
url := fmt.Sprintf("https://api.weixin.qq.com/wxa/getwxacode?access_token=%s", accessToken)
requestBody, err := json.Marshal(request)
if err != nil {
return nil, fmt.Errorf("序列化请求参数失败: %v", err)
}
client := httpclient.GetHttpClientWithContext(ctx.RequestContext())
resp, err := client.R().
SetHeader("Content-Type", "application/json").
SetBody(requestBody).
Post(url)
if err != nil {
return nil, fmt.Errorf("发送HTTP请求失败: %v", err)
}
if resp.StatusCode() != http.StatusOK {
return nil, fmt.Errorf("HTTP请求失败状态码: %d", resp.StatusCode())
}
responseBody := resp.Body()
if isJSONResponse(responseBody) {
var qrError QRCodeError
if err := json.Unmarshal(responseBody, &qrError); err != nil {
return nil, fmt.Errorf("解析错误响应失败: %v", err)
}
return nil, &qrError
}
return &QRCodeResponse{
Buffer: responseBody,
}, nil
}
func isJSONResponse(data []byte) bool {
if len(data) == 0 {
return false
}
trimmed := bytes.TrimSpace(data)
return len(trimmed) > 0 && trimmed[0] == '{'
}
// GenerateQRCode 最简化的小程序码生成接口
func GenerateQRCode(ctx core.Context, appID, appSecret, path string) ([]byte, error) {
config := &WechatConfig{
AppID: appID,
AppSecret: appSecret,
}
request := &QRCodeRequest{
Path: path,
}
response, err := GetQRCodeWithConfig(ctx, config, request)
if err != nil {
return nil, err
}
return response.Buffer, nil
}
// ========== Redis 缓存辅助函数 ==========
// getTokenFromRedis 从 Redis 获取 access_token
func getTokenFromRedis(ctx context.Context, key string) (string, error) {
client := redispkg.GetClient()
if client == nil {
return "", fmt.Errorf("Redis client not available")
}
token, err := client.Get(ctx, key).Result()
if err != nil {
return "", err
}
return token, nil
}
// saveTokenToRedis 保存 access_token 到 Redis
func saveTokenToRedis(ctx context.Context, key, token string, expiresIn int) {
client := redispkg.GetClient()
if client == nil {
return // Redis 不可用,静默失败
}
_ = client.Set(ctx, key, token, time.Duration(expiresIn)*time.Second).Err()
}
// acquireDistributedLock 获取分布式锁
func acquireDistributedLock(ctx context.Context, lockKey string, ttl time.Duration) (bool, error) {
client := redispkg.GetClient()
if client == nil {
return false, fmt.Errorf("Redis client not available")
}
return client.SetNX(ctx, lockKey, "1", ttl).Result()
}
// releaseDistributedLock 释放分布式锁
func releaseDistributedLock(ctx context.Context, lockKey string) {
client := redispkg.GetClient()
if client == nil {
return
}
_ = client.Del(ctx, lockKey).Err()
}
// getTokenFromMemoryOrAPI 降级方案:从内存缓存获取或调用API
func getTokenFromMemoryOrAPI(ctx context.Context, config *WechatConfig) (string, error) {
// 1. 先检查内存缓存
globalTokenCache.mutex.RLock()
if globalTokenCache.Token != "" && time.Now().Before(globalTokenCache.ExpiresAt) {
token := globalTokenCache.Token
globalTokenCache.mutex.RUnlock()
return token, nil
}
globalTokenCache.mutex.RUnlock()
// 2. 内存缓存也失效,使用写锁防止并发
globalTokenCache.mutex.Lock()
defer globalTokenCache.mutex.Unlock()
// 双重检查
if globalTokenCache.Token != "" && time.Now().Before(globalTokenCache.ExpiresAt) {
return globalTokenCache.Token, nil
}
// 3. 调用微信 API (使用 stable_token 接口)
url := "https://api.weixin.qq.com/cgi-bin/stable_token"
requestBody := map[string]any{
"grant_type": "client_credential",
"appid": config.AppID,
"secret": config.AppSecret,
"force_refresh": false,
}
client := httpclient.GetHttpClient()
resp, err := client.R().
SetBody(requestBody).
Post(url)
if err != nil {
return "", fmt.Errorf("获取stable_access_token失败: %v", err)
}
if resp.StatusCode() != http.StatusOK {
return "", fmt.Errorf("HTTP请求失败,状态码: %d", resp.StatusCode())
}
var tokenResp AccessTokenResponse
if err := json.Unmarshal(resp.Body(), &tokenResp); err != nil {
return "", fmt.Errorf("解析access_token响应失败: %v", err)
}
if tokenResp.ErrCode != 0 {
return "", fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
}
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("获取到的access_token为空")
}
// 4. 更新内存缓存
expiresIn := tokenResp.ExpiresIn - 300
if expiresIn < 60 {
expiresIn = 60
}
globalTokenCache.Token = tokenResp.AccessToken
globalTokenCache.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
return tokenResp.AccessToken, nil
}