diff --git a/internal/controllers/api/channel_controller.go b/internal/controllers/api/channel_controller.go index 50dfa82..8c59302 100644 --- a/internal/controllers/api/channel_controller.go +++ b/internal/controllers/api/channel_controller.go @@ -8,7 +8,6 @@ import ( "github.com/kataras/iris/v12" "github.com/mlogclub/simple/web" - "github.com/mlogclub/simple/web/params" ) type ChannelController struct { @@ -38,27 +37,6 @@ func (c *ChannelController) AnyConfig() *web.JsonResult { return web.JsonData(ret) } -func (c *ChannelController) AnyWechat_mpOauthAuthorize() *web.JsonResult { - channelID, _ := params.Get(c.Ctx, "channelId") - redirectURL, err := services.ChannelService.BuildWechatMPOAuthURL(c.Ctx, channelID) - if err != nil { - return web.JsonError(err) - } - c.Ctx.Redirect(redirectURL) - return nil -} - -func (c *ChannelController) AnyWechat_mpOauthCallback() *web.JsonResult { - code, _ := params.Get(c.Ctx, "code") - state, _ := params.Get(c.Ctx, "state") - result, err := services.ChannelService.CompleteWechatMPOAuth(c.Ctx.Request().Context(), code, state) - if err != nil { - return web.JsonError(err) - } - c.Ctx.Redirect(services.BuildWechatMPChatRedirectURL(c.Ctx, result)) - return nil -} - type webLikeWidgetConfig struct { Title string Subtitle string diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go index ff43301..7b5c2ec 100644 --- a/internal/pkg/dto/dto.go +++ b/internal/pkg/dto/dto.go @@ -28,7 +28,4 @@ type WechatMPChannelConfig struct { Title string `json:"title"` Subtitle string `json:"subtitle"` ThemeColor string `json:"themeColor"` - AppID string `json:"appId"` - AppSecret string `json:"appSecret"` - OAuthScope string `json:"oauthScope"` } diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go index 13b7451..62a2500 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -1,9 +1,6 @@ package services import ( - "context" - "crypto/hmac" - "crypto/sha256" "cs-agent/internal/models" "cs-agent/internal/pkg/dto" "cs-agent/internal/pkg/dto/request" @@ -14,12 +11,7 @@ import ( "cs-agent/internal/pkg/utils" "cs-agent/internal/repositories" "cs-agent/internal/wxwork" - "encoding/base64" "encoding/json" - "errors" - "fmt" - "net/url" - "strconv" "strings" "time" @@ -27,8 +19,6 @@ import ( "github.com/mlogclub/simple/common/strs" "github.com/mlogclub/simple/sqls" "github.com/mlogclub/simple/web/params" - "github.com/silenceper/wechat/v2" - offConfig "github.com/silenceper/wechat/v2/officialaccount/config" "github.com/silenceper/wechat/v2/work/kf" ) @@ -41,17 +31,6 @@ func newChannelService() *channelService { type channelService struct { } -type wechatMPOAuthState struct { - ChannelID string `json:"channelId"` - ExpiresAt int64 `json:"expiresAt"` -} - -type WechatMPOAuthResult struct { - ChannelID string - ExternalID string - ExternalName string -} - func (s *channelService) Get(id int64) *models.Channel { return repositories.ChannelRepository.Get(sqls.DB(), id) } @@ -266,7 +245,6 @@ func (s *channelService) ParseWechatMPChannelConfig(raw string) (*dto.WechatMPCh Title: "公众号客服", Subtitle: "欢迎咨询", ThemeColor: "#2563eb", - OAuthScope: "snsapi_base", } if raw != "" { if err := json.Unmarshal([]byte(raw), cfg); err != nil { @@ -282,99 +260,9 @@ func (s *channelService) ParseWechatMPChannelConfig(raw string) (*dto.WechatMPCh if cfg.ThemeColor == "" { cfg.ThemeColor = "#2563eb" } - cfg.AppID = strings.TrimSpace(cfg.AppID) - cfg.AppSecret = strings.TrimSpace(cfg.AppSecret) - cfg.OAuthScope = strings.TrimSpace(cfg.OAuthScope) - if cfg.OAuthScope == "" { - cfg.OAuthScope = "snsapi_base" - } - if cfg.OAuthScope != "snsapi_base" && cfg.OAuthScope != "snsapi_userinfo" { - return nil, errorsx.InvalidParam("微信公众号渠道配置 oauthScope 只能为 snsapi_base 或 snsapi_userinfo") - } return cfg, nil } -func (s *channelService) BuildWechatMPOAuthURL(ctx iris.Context, channelID string) (string, error) { - channelID = strings.TrimSpace(channelID) - if channelID == "" { - return "", errorsx.InvalidParam("channelId不能为空") - } - channel := repositories.ChannelRepository.GetByChannelID(sqls.DB(), channelID) - if channel == nil || channel.Status != enums.StatusOk || channel.ChannelType != enums.ChannelTypeWechatMP { - return "", errorsx.InvalidParam("微信公众号渠道不存在或已停用") - } - cfg, err := s.ParseWechatMPChannelConfig(channel.ConfigJSON) - if err != nil { - return "", errorsx.InvalidParam("微信公众号渠道配置不合法") - } - if cfg.AppID == "" || cfg.AppSecret == "" { - return "", errorsx.InvalidParam("微信公众号渠道缺少 appId 或 appSecret") - } - - state, err := s.signWechatMPOAuthState(wechatMPOAuthState{ - ChannelID: channel.ChannelID, - ExpiresAt: time.Now().Add(10 * time.Minute).Unix(), - }, cfg.AppSecret) - if err != nil { - return "", err - } - redirectURI := buildAbsoluteURL(ctx, "/api/channel/wechat_mp/oauth/callback", nil) - oa := wechat.NewWechat().GetOfficialAccount(&offConfig.Config{ - AppID: cfg.AppID, - AppSecret: cfg.AppSecret, - }) - return oa.GetOauth().GetRedirectURL(redirectURI, cfg.OAuthScope, state) -} - -func (s *channelService) CompleteWechatMPOAuth(ctx context.Context, code, state string) (*WechatMPOAuthResult, error) { - code = strings.TrimSpace(code) - if code == "" { - return nil, errorsx.InvalidParam("code不能为空") - } - payload, err := decodeWechatMPOAuthStatePayload(state) - if err != nil { - return nil, errorsx.InvalidParam("OAuth state不合法") - } - channel := repositories.ChannelRepository.GetByChannelID(sqls.DB(), payload.ChannelID) - if channel == nil || channel.Status != enums.StatusOk || channel.ChannelType != enums.ChannelTypeWechatMP { - return nil, errorsx.InvalidParam("微信公众号渠道不存在或已停用") - } - cfg, err := s.ParseWechatMPChannelConfig(channel.ConfigJSON) - if err != nil { - return nil, errorsx.InvalidParam("微信公众号渠道配置不合法") - } - if cfg.AppID == "" || cfg.AppSecret == "" { - return nil, errorsx.InvalidParam("微信公众号渠道缺少 appId 或 appSecret") - } - if _, err := s.verifyWechatMPOAuthState(state, cfg.AppSecret); err != nil { - return nil, err - } - - oa := wechat.NewWechat().GetOfficialAccount(&offConfig.Config{ - AppID: cfg.AppID, - AppSecret: cfg.AppSecret, - }) - oauth := oa.GetOauth() - token, err := oauth.GetUserAccessTokenContext(ctx, code) - if err != nil { - return nil, err - } - externalName := "" - if strings.Contains(token.Scope, "snsapi_userinfo") { - if info, infoErr := oauth.GetUserInfoContext(ctx, token.AccessToken, token.OpenID, "zh_CN"); infoErr == nil { - externalName = strings.TrimSpace(info.Nickname) - } - } - if strings.TrimSpace(token.OpenID) == "" { - return nil, errorsx.InvalidParam("微信授权未返回 openid") - } - return &WechatMPOAuthResult{ - ChannelID: channel.ChannelID, - ExternalID: strings.TrimSpace(token.OpenID), - ExternalName: externalName, - }, nil -} - func (s *channelService) GetEnabledWxWorkKFChannelByOpenKfID(openKfID string) *models.Channel { openKfID = strings.TrimSpace(openKfID) if openKfID == "" { @@ -469,9 +357,6 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe if err != nil { return nil, errorsx.InvalidParam("微信公众号渠道配置不合法") } - if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" { - return nil, errorsx.InvalidParam("微信公众号渠道配置缺少 appId 或 appSecret") - } configBytes, err := json.Marshal(cfg) if err != nil { return nil, err @@ -506,107 +391,3 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe Remark: strings.TrimSpace(req.Remark), }, nil } - -func (s *channelService) signWechatMPOAuthState(payload wechatMPOAuthState, secret string) (string, error) { - payload.ChannelID = strings.TrimSpace(payload.ChannelID) - if payload.ChannelID == "" || payload.ExpiresAt <= 0 { - return "", errors.New("invalid oauth state payload") - } - encodedPayload := base64.RawURLEncoding.EncodeToString([]byte( - fmt.Sprintf("%s|%d", payload.ChannelID, payload.ExpiresAt), - )) - mac := hmac.New(sha256.New, []byte(secret)) - _, _ = mac.Write([]byte(encodedPayload)) - signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) - return encodedPayload + "." + signature, nil -} - -func (s *channelService) verifyWechatMPOAuthState(raw, secret string) (*wechatMPOAuthState, error) { - raw = strings.TrimSpace(raw) - parts := strings.Split(raw, ".") - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return nil, errorsx.InvalidParam("OAuth state不合法") - } - mac := hmac.New(sha256.New, []byte(secret)) - _, _ = mac.Write([]byte(parts[0])) - expected := mac.Sum(nil) - actual, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil || !hmac.Equal(actual, expected) { - return nil, errorsx.InvalidParam("OAuth state签名不合法") - } - payload, err := decodeWechatMPOAuthStatePayload(raw) - if err != nil { - return nil, errorsx.InvalidParam("OAuth state不合法") - } - if time.Now().Unix() > payload.ExpiresAt { - return nil, errorsx.InvalidParam("OAuth state已过期") - } - return payload, nil -} - -func decodeWechatMPOAuthStatePayload(raw string) (*wechatMPOAuthState, error) { - raw = strings.TrimSpace(raw) - parts := strings.Split(raw, ".") - if len(parts) != 2 || parts[0] == "" { - return nil, errors.New("invalid oauth state") - } - data, err := base64.RawURLEncoding.DecodeString(parts[0]) - if err != nil { - return nil, err - } - stateParts := strings.Split(string(data), "|") - if len(stateParts) != 2 { - return nil, errors.New("invalid oauth state payload") - } - expiresAt, err := strconv.ParseInt(stateParts[1], 10, 64) - if err != nil { - return nil, err - } - payload := &wechatMPOAuthState{ - ChannelID: strings.TrimSpace(stateParts[0]), - ExpiresAt: expiresAt, - } - payload.ChannelID = strings.TrimSpace(payload.ChannelID) - if payload.ChannelID == "" || payload.ExpiresAt <= 0 { - return nil, errors.New("invalid oauth state payload") - } - return payload, nil -} - -func buildAbsoluteURL(ctx iris.Context, path string, values url.Values) string { - scheme := ctx.GetHeader("X-Forwarded-Proto") - if scheme == "" { - scheme = ctx.GetHeader("X-Scheme") - } - if scheme == "" { - if ctx.Request().TLS != nil { - scheme = "https" - } else { - scheme = "http" - } - } - host := ctx.GetHeader("X-Forwarded-Host") - if host == "" { - host = ctx.Host() - } - u := url.URL{ - Scheme: scheme, - Host: host, - Path: path, - } - if values != nil { - u.RawQuery = values.Encode() - } - return u.String() -} - -func BuildWechatMPChatRedirectURL(ctx iris.Context, result *WechatMPOAuthResult) string { - values := url.Values{} - values.Set("channelId", result.ChannelID) - values.Set("externalSource", string(enums.ExternalSourceWechatMP)) - values.Set("externalId", result.ExternalID) - if result.ExternalName != "" { - values.Set("subject", result.ExternalName) - } - return buildAbsoluteURL(ctx, "/kefu/chat/", values) -} diff --git a/web/app/dashboard/channels/_components/edit.tsx b/web/app/dashboard/channels/_components/edit.tsx index 2db2840..f495182 100644 --- a/web/app/dashboard/channels/_components/edit.tsx +++ b/web/app/dashboard/channels/_components/edit.tsx @@ -42,11 +42,6 @@ const channelTypeOptions = [ { value: "wxwork_kf", label: "企业微信客服" }, ] as const -const oauthScopeOptions = [ - { value: "snsapi_base", label: "静默授权" }, - { value: "snsapi_userinfo", label: "用户信息授权" }, -] as const - const widgetPositionOptions = [ { value: "right", label: "右下角" }, { value: "left", label: "左下角" }, @@ -64,9 +59,6 @@ type WechatMPChannelConfig = { title?: string subtitle?: string themeColor?: string - appId?: string - appSecret?: string - oauthScope?: "snsapi_base" | "snsapi_userinfo" } const defaultWebChannelConfig: Required = { @@ -88,9 +80,6 @@ const schema = z widgetThemeColor: z.string().trim(), widgetPosition: z.enum(["left", "right"]), widgetWidth: z.string().trim(), - wechatAppId: z.string().trim(), - wechatAppSecret: z.string().trim(), - wechatOAuthScope: z.enum(["snsapi_base", "snsapi_userinfo"]), remark: z.string().trim(), }) .superRefine((values, ctx) => { @@ -101,22 +90,6 @@ const schema = z message: "请选择企业微信客服账号", }) } - if (values.channelType === "wechat_mp") { - if (!values.wechatAppId.trim()) { - ctx.addIssue({ - code: "custom", - path: ["wechatAppId"], - message: "请填写公众号 AppID", - }) - } - if (!values.wechatAppSecret.trim()) { - ctx.addIssue({ - code: "custom", - path: ["wechatAppSecret"], - message: "请填写公众号 AppSecret", - }) - } - } }) type EditForm = z.infer @@ -137,9 +110,6 @@ const emptyForm: EditForm = { widgetThemeColor: defaultWebChannelConfig.themeColor, widgetPosition: defaultWebChannelConfig.position, widgetWidth: defaultWebChannelConfig.width, - wechatAppId: "", - wechatAppSecret: "", - wechatOAuthScope: "snsapi_base", remark: "", } @@ -180,25 +150,17 @@ function parseWechatMPChannelConfig(configJson: string): Required @@ -536,59 +490,6 @@ function ChannelFormBody({ {channelType === "web" || channelType === "wechat_mp" ? ( <>
- {channelType === "wechat_mp" ? ( - <> - - - 公众号 AppID - - - - - - - - - - 公众号 AppSecret - - - - - - - - - 网页授权方式 - - ( - - )} - /> - - - - - ) : null} - 窗口标题 @@ -827,8 +728,9 @@ function WechatMPAccessGuide({ channelId }: { channelId: string }) { if (!origin || !channelId) { return "" } - const url = new URL("/api/channel/wechat_mp/oauth/authorize", origin) + const url = new URL("/kefu/chat/", origin) url.searchParams.set("channelId", channelId) + url.searchParams.set("externalSource", "wechat_mp") return url.toString() }, [channelId, origin]) @@ -850,7 +752,7 @@ function WechatMPAccessGuide({ channelId }: { channelId: string }) {
微信公众号接入信息
{channelId - ? "将该链接配置到微信公众号自定义菜单,用户点击菜单后进入客服聊天页。" + ? "将该链接配置到微信公众号自定义菜单,用户点击菜单后直接进入客服聊天页。" : "保存渠道后生成公众号菜单链接。"}
@@ -892,9 +794,9 @@ function WechatMPAccessGuide({ channelId }: { channelId: string }) {
接入教程
-
1. 确认该渠道已启用,并在微信公众平台配置网页授权域名。
+
1. 确认该渠道已启用。
2. 将公众号自定义菜单跳转地址设置为上方链接。
-
3. 用户点击菜单并授权后,会以 openid 作为稳定客户身份进入客服聊天页。
+
3. 用户点击菜单后会直接进入客服聊天页。
)} diff --git a/web/lib/kefu-widget-config.ts b/web/lib/kefu-widget-config.ts index cd94b21..858ea0a 100644 --- a/web/lib/kefu-widget-config.ts +++ b/web/lib/kefu-widget-config.ts @@ -4,7 +4,7 @@ export type KefuWidgetHostConfig = { apiBaseUrl?: string /** 与后端 enums.ExternalSource 一致,默认 web_chat */ externalSource?: string - /** 外部访客稳定标识;微信公众号 OAuth 场景使用 openid */ + /** 外部访客稳定标识;未传时使用浏览器本地访客 ID */ externalId?: string title?: string subtitle?: string