package service import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "sync" "time" ) // 小程序码(wxacode)。身份卡右下角那个码,扫了能进小程序。 // // 两步:先拿 access_token(有 2h 有效期,缓存复用),再调 getwxacodeunlimit // 换一张 PNG,存到 MinIO 返回 URL。同一只宠物的码存一次就够——微信侧有 // 日调用配额,object 名按宠物 id 定死,存在就直接返回不重新生成。 // // ⚠️ getwxacodeunlimit 要求 page 存在于**已发布**的小程序版本里。开发版/未发布 // 时会返回 errcode 41030(page 不存在),这时身份卡照常出,只是没有码。 // 发布之后自动就有了,不用改代码。 // accessToken 缓存。进程级,够用——多实例各自缓存不影响正确性 var ( atMu sync.Mutex atValue string atExpires time.Time ) // wechatAccessToken 取 access_token,带缓存。提前 5 分钟过期,避免边界上用到失效的 func (s *Service) wechatAccessToken() (string, error) { atMu.Lock() defer atMu.Unlock() if atValue != "" && time.Now().Before(atExpires) { return atValue, nil } if s.cfg.WeChat.AppID == "" || s.cfg.WeChat.AppSecret == "" { return "", errors.New("微信 app_id / app_secret 未配置") } q := url.Values{} q.Set("grant_type", "client_credential") q.Set("appid", s.cfg.WeChat.AppID) q.Set("secret", s.cfg.WeChat.AppSecret) ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.weixin.qq.com/cgi-bin/token?"+q.Encode(), nil) resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() var out struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", err } if out.AccessToken == "" { return "", fmt.Errorf("拿 access_token 失败(%d): %s", out.ErrCode, out.ErrMsg) } atValue = out.AccessToken atExpires = time.Now().Add(time.Duration(out.ExpiresIn-300) * time.Second) return atValue, nil } // PetQRCode 取某只宠物的小程序码 URL。存在就直接返回,不存在才向微信换。 // scene 里带宠物 id,扫码进来时小程序能拿到(暂时只用来打开小程序) func (s *Service) PetQRCode(petID string) (string, error) { if s.storage == nil { return "", errors.New("对象存储未配置") } obj := "wxacode/" + petID + ".png" if s.storage.Exists(obj) { return s.storage.PublicURL(obj), nil } token, err := s.wechatAccessToken() if err != nil { return "", err } body, _ := json.Marshal(map[string]any{ "scene": "pet=" + petID, // 最长 32 字符,够放一个雪花 id "page": "pages/home/home", "check_path": true, "width": 280, }) ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) defer cancel() req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token="+token, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return "", err } // 成功返回图片二进制;失败返回一段 JSON。用 Content-Type 分辨最稳 if ct := resp.Header.Get("Content-Type"); len(ct) >= 5 && ct[:5] == "image" { if _, err := s.storage.Upload(obj, bytes.NewReader(data), int64(len(data)), "image/png"); err != nil { return "", err } return s.storage.PublicURL(obj), nil } var e struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } _ = json.Unmarshal(data, &e) // 41030 = page 不在已发布版本里。开发期常见,往上层透传让它降级 return "", fmt.Errorf("微信生成小程序码失败(%d): %s", e.ErrCode, e.ErrMsg) }