Files
story/internal/onboarding/wechat_provider.go
T
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

132 lines
4.4 KiB
Go

package onboarding
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"sync"
"time"
)
type WeChatProvider interface {
OpenID(context.Context, string) (string, error)
Phone(context.Context, string) (string, error)
}
// AppSecret and provider tokens stay server-side. Provider errors must not
// include the request URL: WeChat places credentials in query parameters.
type weChatClient struct {
appID, secret, base string
http *http.Client
mu sync.Mutex
token string
expires time.Time
}
func newWeChatClient(appID, secret string) *weChatClient {
return &weChatClient{appID: appID, secret: secret, base: "https://api.weixin.qq.com", http: &http.Client{Timeout: 6 * time.Second}}
}
func (c *weChatClient) call(ctx context.Context, path string, query url.Values, body any, out any) error {
method := http.MethodGet
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(raw)
method = http.MethodPost
}
u := c.base + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, u, reader)
if err != nil {
return fail(503, "WECHAT_UNAVAILABLE", "微信服务暂不可用,请稍后重试")
}
req.Header.Set("Content-Type", "application/json")
res, err := c.http.Do(req)
if err != nil {
return fail(503, "WECHAT_UNAVAILABLE", "微信服务暂不可用,请稍后重试")
}
defer res.Body.Close()
if res.StatusCode != 200 || json.NewDecoder(io.LimitReader(res.Body, 64<<10)).Decode(out) != nil {
return fail(503, "WECHAT_UNAVAILABLE", "微信服务响应异常,请稍后重试")
}
return nil
}
func (c *weChatClient) OpenID(ctx context.Context, code string) (string, error) {
var out struct {
OpenID string `json:"openid"`
ErrCode int `json:"errcode"`
}
err := c.call(ctx, "/sns/jscode2session", url.Values{"appid": {c.appID}, "secret": {c.secret}, "js_code": {code}, "grant_type": {"authorization_code"}}, nil, &out)
if err != nil {
return "", err
}
if out.ErrCode != 0 || out.OpenID == "" || len(out.OpenID) > 128 {
return "", fail(401, "WECHAT_LOGIN_FAILED", "微信登录已失效,请重新点击登录")
}
return out.OpenID, nil
}
func (c *weChatClient) accessToken(ctx context.Context, invalid string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.token != "" && c.token != invalid && time.Now().Before(c.expires) {
return c.token, nil
}
var out struct {
Token string `json:"access_token"`
Expires int `json:"expires_in"`
ErrCode int `json:"errcode"`
}
err := c.call(ctx, "/cgi-bin/stable_token", nil, map[string]any{"grant_type": "client_credential", "appid": c.appID, "secret": c.secret, "force_refresh": invalid != ""}, &out)
if err != nil {
return "", err
}
if out.ErrCode != 0 || out.Token == "" || out.Expires <= 60 {
return "", fail(503, "WECHAT_CONFIG_ERROR", "微信登录尚未配置完成,请使用手机号登录")
}
c.token = out.Token
c.expires = time.Now().Add(time.Duration(out.Expires-60) * time.Second)
return c.token, nil
}
func (c *weChatClient) Phone(ctx context.Context, code string) (string, error) {
invalid := ""
for i := 0; i < 2; i++ {
token, err := c.accessToken(ctx, invalid)
if err != nil {
return "", err
}
var out struct {
ErrCode int `json:"errcode"`
Info struct {
Number string `json:"purePhoneNumber"`
Country string `json:"countryCode"`
Watermark struct {
AppID string `json:"appid"`
} `json:"watermark"`
} `json:"phone_info"`
}
if err = c.call(ctx, "/wxa/business/getuserphonenumber", url.Values{"access_token": {token}}, map[string]string{"code": code}, &out); err != nil {
return "", err
}
if out.ErrCode == 40001 || out.ErrCode == 40014 || out.ErrCode == 42001 {
invalid = token
continue
}
if out.ErrCode == 48001 || out.ErrCode == 48004 || out.ErrCode == 200003 {
return "", fail(503, "WECHAT_PHONE_UNAVAILABLE", "小程序尚未开通手机号授权,请使用手机号登录")
}
if out.ErrCode != 0 || out.Info.Watermark.AppID != c.appID || !phonePattern.MatchString("+"+out.Info.Country+out.Info.Number) {
return "", fail(400, "WECHAT_PHONE_INVALID", "手机号授权失败,请重新授权或使用密码登录")
}
return "+" + out.Info.Country + out.Info.Number, nil
}
return "", fail(503, "WECHAT_UNAVAILABLE", "微信服务暂不可用,请稍后重试")
}