Files
RockYang d4fd38ab7e feat: 迁移 geekai-plus v4.3.1 到开源版
- 同步 Plus v4.3.1 功能源并移除商业 License 闭环
- 更新开源镜像命名、Docker 部署版本和 geekai 数据库配置
- 补充前端 ESLint 检查配置并修复存量解析与模板问题
- 保留 JWT、管理员权限、API Key 和 OAuth 等正常鉴权机制
2026-09-13 14:37:12 +08:00

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
}