mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-09-16 18:27:13 +00:00
d4fd38ab7e
- 同步 Plus v4.3.1 功能源并移除商业 License 闭环 - 更新开源镜像命名、Docker 部署版本和 geekai 数据库配置 - 补充前端 ESLint 检查配置并修复存量解析与模板问题 - 保留 JWT、管理员权限、API Key 和 OAuth 等正常鉴权机制
200 lines
6.0 KiB
Go
200 lines
6.0 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"geekai/core/types"
|
|
"geekai/utils"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const wxGzhTokenSkew = 300 * time.Second
|
|
|
|
// GetClientCredentialToken 获取公众号 client_credential access_token(带进程内缓存)。
|
|
func (s *WxGzhService) GetClientCredentialToken(ctx context.Context, appID, secret string) (string, error) {
|
|
appID, secret = strings.TrimSpace(appID), strings.TrimSpace(secret)
|
|
if appID == "" || secret == "" {
|
|
return "", fmt.Errorf("微信公众号 AppID 或 AppSecret 未配置")
|
|
}
|
|
|
|
now := time.Now()
|
|
s.ccMu.Lock()
|
|
if s.ccForApp == appID && s.ccToken != "" && now.Before(s.ccExpire) {
|
|
tok := s.ccToken
|
|
s.ccMu.Unlock()
|
|
return tok, nil
|
|
}
|
|
s.ccMu.Unlock()
|
|
|
|
raw := fmt.Sprintf(
|
|
"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
|
|
url.QueryEscape(appID), url.QueryEscape(secret),
|
|
)
|
|
body, status, err := utils.FetchURLBytes(ctx, raw, "", 30*time.Second, 2, 2<<20)
|
|
if err != nil {
|
|
return "", fmt.Errorf("获取 access_token 失败: status=%d: %w", status, err)
|
|
}
|
|
|
|
var parsed struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
ErrCode int `json:"errcode"`
|
|
ErrMsg string `json:"errmsg"`
|
|
}
|
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
|
return "", fmt.Errorf("解析 token 响应失败: %w", err)
|
|
}
|
|
if parsed.ErrCode != 0 {
|
|
return "", fmt.Errorf("微信 API 错误(%d): %s", parsed.ErrCode, parsed.ErrMsg)
|
|
}
|
|
if parsed.AccessToken == "" {
|
|
return "", fmt.Errorf("微信未返回 access_token: %s", string(body))
|
|
}
|
|
|
|
expireAt := now.Add(time.Duration(parsed.ExpiresIn) * time.Second).Add(-wxGzhTokenSkew)
|
|
if parsed.ExpiresIn <= 0 {
|
|
expireAt = now.Add(7000 * time.Second)
|
|
}
|
|
|
|
s.ccMu.Lock()
|
|
s.ccForApp = appID
|
|
s.ccToken = parsed.AccessToken
|
|
s.ccExpire = expireAt
|
|
s.ccMu.Unlock()
|
|
|
|
return parsed.AccessToken, nil
|
|
}
|
|
|
|
// PublishCustomMenu 调用 menu/create 全量覆盖公众号菜单。
|
|
func (s *WxGzhService) PublishCustomMenu(ctx context.Context, appID, secret string, menu types.WxGzhMenuConfig) error {
|
|
if err := ValidateWxGzhMenu(menu); err != nil {
|
|
return err
|
|
}
|
|
token, err := s.GetClientCredentialToken(ctx, appID, secret)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
payload, err := json.Marshal(menu)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
api := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/menu/create?access_token=%s", url.QueryEscape(token))
|
|
body, status, err := utils.PostURLBytes(ctx, api, "application/json; charset=utf-8", payload, "", 45*time.Second, 2, 2<<20)
|
|
if err != nil {
|
|
return fmt.Errorf("创建菜单请求失败: status=%d: %w", status, err)
|
|
}
|
|
var res struct {
|
|
ErrCode int `json:"errcode"`
|
|
ErrMsg string `json:"errmsg"`
|
|
}
|
|
if err := json.Unmarshal(body, &res); err != nil {
|
|
return fmt.Errorf("解析创建菜单响应失败: %w", err)
|
|
}
|
|
if res.ErrCode != 0 {
|
|
return fmt.Errorf("微信创建菜单失败(%d): %s", res.ErrCode, res.ErrMsg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetCustomMenuFromWechat 调用 menu/get 拉取线上菜单。
|
|
func (s *WxGzhService) GetCustomMenuFromWechat(ctx context.Context, appID, secret string) (types.WxGzhMenuConfig, error) {
|
|
var empty types.WxGzhMenuConfig
|
|
token, err := s.GetClientCredentialToken(ctx, appID, secret)
|
|
if err != nil {
|
|
return empty, err
|
|
}
|
|
api := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/menu/get?access_token=%s", url.QueryEscape(token))
|
|
body, status, err := utils.FetchURLBytes(ctx, api, "", 30*time.Second, 2, 2<<20)
|
|
if err != nil {
|
|
return empty, fmt.Errorf("获取菜单失败: status=%d: %w", status, err)
|
|
}
|
|
|
|
var envelope struct {
|
|
Menu *struct {
|
|
Button []types.WxGzhMenuButton `json:"button"`
|
|
} `json:"menu"`
|
|
ErrCode int `json:"errcode"`
|
|
ErrMsg string `json:"errmsg"`
|
|
}
|
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
|
return empty, fmt.Errorf("解析菜单响应失败: %w", err)
|
|
}
|
|
if envelope.ErrCode != 0 {
|
|
return empty, fmt.Errorf("微信获取菜单失败(%d): %s", envelope.ErrCode, envelope.ErrMsg)
|
|
}
|
|
if envelope.Menu == nil || len(envelope.Menu.Button) == 0 {
|
|
return types.WxGzhMenuConfig{Button: nil}, nil
|
|
}
|
|
return types.WxGzhMenuConfig{Button: envelope.Menu.Button}, nil
|
|
}
|
|
|
|
// ValidateWxGzhMenu 校验自定义菜单是否符合微信公众平台规则(view / click / 子菜单)。
|
|
func ValidateWxGzhMenu(m types.WxGzhMenuConfig) error {
|
|
if len(m.Button) == 0 {
|
|
return fmt.Errorf("请至少配置一个一级菜单")
|
|
}
|
|
if len(m.Button) > 3 {
|
|
return fmt.Errorf("一级菜单最多 3 个")
|
|
}
|
|
for _, b := range m.Button {
|
|
if err := validateTopButton(b); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateTopButton(b types.WxGzhMenuButton) error {
|
|
if strings.TrimSpace(b.Name) == "" {
|
|
return fmt.Errorf("菜单名称不能为空")
|
|
}
|
|
if len(b.SubButton) > 0 {
|
|
if strings.TrimSpace(b.Type) != "" {
|
|
return fmt.Errorf("含子菜单的一级项不应设置类型")
|
|
}
|
|
if len(b.SubButton) > 5 {
|
|
return fmt.Errorf("子菜单最多 5 项")
|
|
}
|
|
for _, sb := range b.SubButton {
|
|
if err := validateLeafButton(sb); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return validateLeafButton(b)
|
|
}
|
|
|
|
func validateLeafButton(b types.WxGzhMenuButton) error {
|
|
if strings.TrimSpace(b.Name) == "" {
|
|
return fmt.Errorf("菜单名称不能为空")
|
|
}
|
|
if len(b.SubButton) > 0 {
|
|
return fmt.Errorf("仅支持两级菜单,子菜单下不能再嵌套")
|
|
}
|
|
t := strings.ToLower(strings.TrimSpace(b.Type))
|
|
switch t {
|
|
case "view":
|
|
if strings.TrimSpace(b.URL) == "" {
|
|
return fmt.Errorf("跳转链接类型需要填写 URL")
|
|
}
|
|
case "click":
|
|
if strings.TrimSpace(b.Key) == "" {
|
|
return fmt.Errorf("点击事件类型需要填写 Key")
|
|
}
|
|
case "miniprogram":
|
|
if strings.TrimSpace(b.AppID) == "" || strings.TrimSpace(b.PagePath) == "" || strings.TrimSpace(b.URL) == "" {
|
|
return fmt.Errorf("小程序类型需要填写 appid、pagepath 与备用 url")
|
|
}
|
|
default:
|
|
if t == "" {
|
|
return fmt.Errorf("请选择菜单类型")
|
|
}
|
|
return fmt.Errorf("暂不支持的菜单类型: %s", b.Type)
|
|
}
|
|
return nil
|
|
}
|