package wechat import ( "context" "github.com/gogf/gf/v2/encoding/gjson" "io" "os" "server/internal/consts" "server/utility/ecode" "sync" "time" "github.com/go-resty/resty/v2" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/glog" "github.com/google/uuid" ) type weChatClient struct { AppId string AppSecret string TicketExpire int Token string accessToken string expiresIn int lastUpdated time.Time mu sync.RWMutex } var ( instance *weChatClient once sync.Once ) func (c *weChatClient) loadCachedToken(ctx context.Context) bool { val, err := g.Redis().Get(ctx, consts.WechatAccessTokenKey) if err != nil || val.IsEmpty() { return false } var data struct { Token string `json:"token"` ExpiresAt int64 `json:"expires_at"` } if err := gjson.DecodeTo(val.String(), &data); err != nil { return false } // 如果当前时间超过过期时间 - 5分钟,则视为即将过期 if time.Now().Unix() >= data.ExpiresAt-300 { return false } c.mu.Lock() c.accessToken = data.Token c.expiresIn = int(data.ExpiresAt - time.Now().Unix()) c.lastUpdated = time.Now() c.mu.Unlock() glog.Infof(ctx, "[loadCachedToken] 成功加载 Redis 中的 access_token: %s", data.Token) return true } func GetWeChatClient() *weChatClient { return instance } func init() { once.Do(func() { ctx := context.Background() instance = &weChatClient{ AppId: g.Config().MustGet(ctx, "wechat.appId").String(), AppSecret: g.Config().MustGet(ctx, "wechat.appSecret").String(), TicketExpire: g.Config().MustGet(ctx, "wechat.ticketExpire").Int(), Token: g.Config().MustGet(ctx, "wechat.token").String(), } go instance.autoRefreshToken(ctx) }) } func (c *weChatClient) getAccessToken() error { ctx := context.Background() result := struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` // 一般为 7200 秒 }{} url := "https://api.weixin.qq.com/cgi-bin/token" resp, err := resty.New().R(). SetQueryParams(g.MapStrStr{ "grant_type": "client_credential", "appid": c.AppId, "secret": c.AppSecret, }). SetResult(&result). Get(url) if err != nil { glog.Errorf(ctx, "[getAccessToken] 请求异常, URL: %s, err: %+v", url, err) return ecode.Fail.Sub("发起 get access_token 请求出现异常") } if resp.IsError() { glog.Errorf(ctx, "[getAccessToken] 响应错误, 状态: %s, 内容: %s", resp.Status(), resp.String()) return ecode.Fail.Sub("获取 access_token 失败") } // 写入内存 c.mu.Lock() c.accessToken = result.AccessToken c.expiresIn = result.ExpiresIn c.lastUpdated = time.Now() c.mu.Unlock() // 写入 Redis expiresAt := time.Now().Unix() + int64(result.ExpiresIn) cacheData := g.Map{ "token": result.AccessToken, "expires_at": expiresAt, } jsonStr, _ := gjson.Encode(cacheData) ttl := result.ExpiresIn - 300 if ttl < 60 { ttl = 60 } err = g.Redis().SetEX(ctx, consts.WechatAccessTokenKey, jsonStr, int64(ttl)) if err != nil { glog.Warningf(ctx, "[getAccessToken] Redis 缓存失败: %+v", err) } else { glog.Infof(ctx, "[getAccessToken] 成功写入 Redis,expires_in: %ds", result.ExpiresIn) } glog.Infof(ctx, "[getAccessToken] 成功获取 access_token: %s,expires_in: %ds", result.AccessToken, result.ExpiresIn) return nil } func (c *weChatClient) autoRefreshToken(ctx context.Context) { if c.loadCachedToken(ctx) { glog.Infof(ctx, "[autoRefreshToken] 成功加载缓存 token") } else { if err := c.getAccessToken(); err != nil { glog.Errorf(ctx, "[autoRefreshToken] 初次获取 token 失败: %+v", err) time.Sleep(1 * time.Minute) } } for { c.mu.RLock() expiresIn := c.expiresIn c.mu.RUnlock() refreshAfter := time.Duration(expiresIn-300) * time.Second if refreshAfter <= 0 { refreshAfter = 5 * time.Minute } time.Sleep(refreshAfter) if err := c.getAccessToken(); err != nil { glog.Errorf(ctx, "[autoRefreshToken] 刷新 token 失败: %+v", err) time.Sleep(1 * time.Minute) } } } func (c *weChatClient) GetTicket(sceneId string) (string, error) { ctx := context.Background() body := map[string]interface{}{ "expire_seconds": c.TicketExpire, "action_name": "QR_STR_SCENE", "action_info": map[string]interface{}{ "scene": map[string]string{ "scene_str": sceneId, }, }, } result := struct { Errcode int `json:"errcode"` Ticket string `json:"ticket"` ExpireSeconds int `json:"expire_seconds"` Url string `json:"url"` }{} resp, err := resty.New().R(). SetQueryParams(g.MapStrStr{"access_token": c.accessToken}). SetBody(body). SetResult(&result). Post("https://api.weixin.qq.com/cgi-bin/qrcode/create") if err != nil { glog.Errorf(ctx, "[GetTicket] 请求异常: %+v", err) return "", ecode.Fail.Sub("发起 get ticket 请求出现异常") } if resp.IsError() { glog.Errorf(ctx, "[GetTicket] 响应错误,状态: %s,内容: %s", resp.Status(), resp.String()) return "", ecode.Fail.Sub("获取微信 ticket 失败") } if result.Errcode == 40001 { if err := c.getAccessToken(); err != nil { glog.Errorf(ctx, "[GetTicket] 刷新 token 失败: %+v", err) return "", ecode.Fail.Sub("刷新 access_token 失败") } return c.GetTicket(sceneId) } glog.Infof(ctx, "[GetTicket] 成功获取 ticket: %s, 过期时间: %ds", result.Ticket, result.ExpireSeconds) return result.Ticket, nil } func (c *weChatClient) GetQrCode(ticket string) (string, error) { ctx := context.Background() url := "https://mp.weixin.qq.com/cgi-bin/showqrcode" resp, err := resty.New().R(). SetDoNotParseResponse(true). SetQueryParams(g.MapStrStr{"ticket": ticket}). Get(url) if err != nil { glog.Errorf(ctx, "[GetQrCode] 请求失败: %+v", err) return "", ecode.Fail.Sub("获取二维码图片请求失败") } if resp.IsError() { glog.Errorf(ctx, "[GetQrCode] 响应异常, 状态: %s", resp.Status()) return "", ecode.Fail.Sub("获取二维码图片失败") } imagePath := uuid.New().String() + ".jpg" data, readErr := io.ReadAll(resp.RawBody()) defer resp.RawBody().Close() if readErr != nil { glog.Errorf(ctx, "[GetQrCode] 读取图片失败: %+v", readErr) return "", ecode.Fail.Sub("读取二维码图片失败") } writeErr := os.WriteFile(imagePath, data, 0644) if writeErr != nil { glog.Errorf(ctx, "[GetQrCode] 保存图片失败: %+v", writeErr) return "", ecode.Fail.Sub("保存二维码图片失败") } glog.Infof(ctx, "[GetQrCode] 二维码保存成功: %s", imagePath) return imagePath, nil } func (c *weChatClient) GetToken() string { return c.Token } func (c *weChatClient) GetUserUnionId(ctx context.Context, openid string) (unionId string, err error) { // TODO 获取唯一UnionId //result := struct { // UnionId string `json:"unionid"` //}{} // //resp, err := resty.New().R(). // SetQueryParams(g.MapStrStr{"access_token": c.accessToken}).SetQueryParam("openid", openid). // SetResult(&result). // Get("https://api.weixin.qq.com/cgi-bin/user/info") // //if err != nil { // glog.Errorf(ctx, "发起 get ticket 请求出现异常: %+v", err) // return "", ecode.Fail.Sub("发起 get ticket 请求出现异常") //} //if resp.StatusCode() != 200 { // glog.Errorf(ctx, "获取微信 ticket 响应异常: %+v", resp.Status()) // return "", ecode.Fail.Sub("获取微信 ticket 失败") //} return openid, nil }