From 2220dcb0cba35b572f7b05c6c74d395b9873dbd0 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Mon, 27 Apr 2026 16:01:00 +0800 Subject: [PATCH] refactor: simplify WeChat MP OAuth handling and update channel configuration structure --- .../controllers/api/channel_controller.go | 7 +- internal/pkg/dto/dto.go | 15 +- internal/services/channel_service.go | 71 +--- .../dashboard/channels/_components/edit.tsx | 309 +++++++++++------- 4 files changed, 209 insertions(+), 193 deletions(-) diff --git a/internal/controllers/api/channel_controller.go b/internal/controllers/api/channel_controller.go index cb41508..50dfa82 100644 --- a/internal/controllers/api/channel_controller.go +++ b/internal/controllers/api/channel_controller.go @@ -40,8 +40,7 @@ func (c *ChannelController) AnyConfig() *web.JsonResult { func (c *ChannelController) AnyWechat_mpOauthAuthorize() *web.JsonResult { channelID, _ := params.Get(c.Ctx, "channelId") - returnPath, _ := params.Get(c.Ctx, "returnPath") - redirectURL, err := services.ChannelService.BuildWechatMPOAuthURL(c.Ctx, channelID, returnPath) + redirectURL, err := services.ChannelService.BuildWechatMPOAuthURL(c.Ctx, channelID) if err != nil { return web.JsonError(err) } @@ -91,8 +90,8 @@ func resolveWidgetConfig(channelType, rawConfig string) (*webLikeWidgetConfig, s Title: cfg.Title, Subtitle: cfg.Subtitle, ThemeColor: cfg.ThemeColor, - Position: cfg.Position, - Width: cfg.Width, + Position: "right", + Width: "100%", }, string(enums.ExternalSourceWechatMP), nil default: return nil, "", errorsx.InvalidParam("该渠道不支持开放客服配置") diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go index 068b03d..ff43301 100644 --- a/internal/pkg/dto/dto.go +++ b/internal/pkg/dto/dto.go @@ -25,13 +25,10 @@ type WebChannelConfig struct { } type WechatMPChannelConfig struct { - Title string `json:"title"` - Subtitle string `json:"subtitle"` - ThemeColor string `json:"themeColor"` - Position string `json:"position"` - Width string `json:"width"` - AppID string `json:"appId"` - AppSecret string `json:"appSecret"` - OAuthScope string `json:"oauthScope"` - OAuthEnabled bool `json:"oauthEnabled"` + 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 2badba4..13b7451 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -42,16 +42,14 @@ type channelService struct { } type wechatMPOAuthState struct { - ChannelID string `json:"channelId"` - ReturnPath string `json:"returnPath"` - ExpiresAt int64 `json:"expiresAt"` + ChannelID string `json:"channelId"` + ExpiresAt int64 `json:"expiresAt"` } type WechatMPOAuthResult struct { ChannelID string ExternalID string ExternalName string - ReturnPath string } func (s *channelService) Get(id int64) *models.Channel { @@ -265,13 +263,10 @@ func (s *channelService) ParseWebChannelConfig(raw string) (*dto.WebChannelConfi func (s *channelService) ParseWechatMPChannelConfig(raw string) (*dto.WechatMPChannelConfig, error) { raw = strings.TrimSpace(raw) cfg := &dto.WechatMPChannelConfig{ - Title: "公众号客服", - Subtitle: "欢迎咨询", - ThemeColor: "#2563eb", - Position: "right", - Width: "380px", - OAuthScope: "snsapi_base", - OAuthEnabled: true, + Title: "公众号客服", + Subtitle: "欢迎咨询", + ThemeColor: "#2563eb", + OAuthScope: "snsapi_base", } if raw != "" { if err := json.Unmarshal([]byte(raw), cfg); err != nil { @@ -287,17 +282,6 @@ func (s *channelService) ParseWechatMPChannelConfig(raw string) (*dto.WechatMPCh if cfg.ThemeColor == "" { cfg.ThemeColor = "#2563eb" } - cfg.Position = strings.TrimSpace(cfg.Position) - if cfg.Position == "" { - cfg.Position = "right" - } - if cfg.Position != "left" && cfg.Position != "right" { - return nil, errorsx.InvalidParam("微信公众号渠道配置 position 只能为 left 或 right") - } - cfg.Width = strings.TrimSpace(cfg.Width) - if cfg.Width == "" { - cfg.Width = "380px" - } cfg.AppID = strings.TrimSpace(cfg.AppID) cfg.AppSecret = strings.TrimSpace(cfg.AppSecret) cfg.OAuthScope = strings.TrimSpace(cfg.OAuthScope) @@ -310,7 +294,7 @@ func (s *channelService) ParseWechatMPChannelConfig(raw string) (*dto.WechatMPCh return cfg, nil } -func (s *channelService) BuildWechatMPOAuthURL(ctx iris.Context, channelID, returnPath string) (string, error) { +func (s *channelService) BuildWechatMPOAuthURL(ctx iris.Context, channelID string) (string, error) { channelID = strings.TrimSpace(channelID) if channelID == "" { return "", errorsx.InvalidParam("channelId不能为空") @@ -323,17 +307,13 @@ func (s *channelService) BuildWechatMPOAuthURL(ctx iris.Context, channelID, retu if err != nil { return "", errorsx.InvalidParam("微信公众号渠道配置不合法") } - if !cfg.OAuthEnabled { - return "", errorsx.InvalidParam("微信公众号渠道未启用 OAuth") - } if cfg.AppID == "" || cfg.AppSecret == "" { return "", errorsx.InvalidParam("微信公众号渠道缺少 appId 或 appSecret") } state, err := s.signWechatMPOAuthState(wechatMPOAuthState{ - ChannelID: channel.ChannelID, - ReturnPath: normalizeWechatMPReturnPath(returnPath), - ExpiresAt: time.Now().Add(10 * time.Minute).Unix(), + ChannelID: channel.ChannelID, + ExpiresAt: time.Now().Add(10 * time.Minute).Unix(), }, cfg.AppSecret) if err != nil { return "", err @@ -392,7 +372,6 @@ func (s *channelService) CompleteWechatMPOAuth(ctx context.Context, code, state ChannelID: channel.ChannelID, ExternalID: strings.TrimSpace(token.OpenID), ExternalName: externalName, - ReturnPath: normalizeWechatMPReturnPath(payload.ReturnPath), }, nil } @@ -533,12 +512,8 @@ func (s *channelService) signWechatMPOAuthState(payload wechatMPOAuthState, secr if payload.ChannelID == "" || payload.ExpiresAt <= 0 { return "", errors.New("invalid oauth state payload") } - payload.ReturnPath = normalizeWechatMPReturnPath(payload.ReturnPath) - if len(payload.ReturnPath) > 14 { - payload.ReturnPath = "/kefu/chat/" - } encodedPayload := base64.RawURLEncoding.EncodeToString([]byte( - fmt.Sprintf("%s|%d|%s", payload.ChannelID, payload.ExpiresAt, payload.ReturnPath), + fmt.Sprintf("%s|%d", payload.ChannelID, payload.ExpiresAt), )) mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(encodedPayload)) @@ -579,8 +554,8 @@ func decodeWechatMPOAuthStatePayload(raw string) (*wechatMPOAuthState, error) { if err != nil { return nil, err } - stateParts := strings.SplitN(string(data), "|", 3) - if len(stateParts) != 3 { + 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) @@ -588,32 +563,16 @@ func decodeWechatMPOAuthStatePayload(raw string) (*wechatMPOAuthState, error) { return nil, err } payload := &wechatMPOAuthState{ - ChannelID: strings.TrimSpace(stateParts[0]), - ReturnPath: strings.TrimSpace(stateParts[2]), - ExpiresAt: expiresAt, + ChannelID: strings.TrimSpace(stateParts[0]), + ExpiresAt: expiresAt, } payload.ChannelID = strings.TrimSpace(payload.ChannelID) - payload.ReturnPath = normalizeWechatMPReturnPath(payload.ReturnPath) if payload.ChannelID == "" || payload.ExpiresAt <= 0 { return nil, errors.New("invalid oauth state payload") } return payload, nil } -func normalizeWechatMPReturnPath(returnPath string) string { - returnPath = strings.TrimSpace(returnPath) - if returnPath == "" { - return "/kefu/chat/" - } - if !strings.HasPrefix(returnPath, "/") || strings.HasPrefix(returnPath, "//") { - return "/kefu/chat/" - } - if strings.Contains(returnPath, "\n") || strings.Contains(returnPath, "\r") { - return "/kefu/chat/" - } - return returnPath -} - func buildAbsoluteURL(ctx iris.Context, path string, values url.Values) string { scheme := ctx.GetHeader("X-Forwarded-Proto") if scheme == "" { @@ -649,5 +608,5 @@ func BuildWechatMPChatRedirectURL(ctx iris.Context, result *WechatMPOAuthResult) if result.ExternalName != "" { values.Set("subject", result.ExternalName) } - return buildAbsoluteURL(ctx, normalizeWechatMPReturnPath(result.ReturnPath), values) + 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 a1e591c..2db2840 100644 --- a/web/app/dashboard/channels/_components/edit.tsx +++ b/web/app/dashboard/channels/_components/edit.tsx @@ -60,11 +60,13 @@ type WebChannelConfig = { width?: string } -type WechatMPChannelConfig = WebChannelConfig & { +type WechatMPChannelConfig = { + title?: string + subtitle?: string + themeColor?: string appId?: string appSecret?: string oauthScope?: "snsapi_base" | "snsapi_userinfo" - oauthEnabled?: boolean } const defaultWebChannelConfig: Required = { @@ -175,28 +177,28 @@ function parseWebChannelConfig(configJson: string): Required { function parseWechatMPChannelConfig(configJson: string): Required { const fallback = { - ...defaultWebChannelConfig, title: "公众号客服", + subtitle: defaultWebChannelConfig.subtitle, + themeColor: defaultWebChannelConfig.themeColor, appId: "", appSecret: "", oauthScope: "snsapi_base" as const, - oauthEnabled: true, } if (!configJson.trim()) { return fallback } try { const parsed = JSON.parse(configJson) as WechatMPChannelConfig - const base = parseWebChannelConfig(configJson) const oauthScope = parsed.oauthScope === "snsapi_userinfo" ? "snsapi_userinfo" : "snsapi_base" return { - ...base, title: parsed.title?.trim() || fallback.title, + subtitle: parsed.subtitle?.trim() ?? fallback.subtitle, + themeColor: + parsed.themeColor?.trim() || defaultWebChannelConfig.themeColor, appId: parsed.appId?.trim() || "", appSecret: parsed.appSecret?.trim() || "", oauthScope, - oauthEnabled: parsed.oauthEnabled ?? true, } } catch { return fallback @@ -212,7 +214,6 @@ function buildForm(item: AdminChannel | null): EditForm { const wechatConfig = isWechatMP ? parseWechatMPChannelConfig(item.configJson) : null - const widgetConfig = wechatConfig ?? webConfig return { channelType: item.channelType === "wxwork_kf" @@ -223,11 +224,11 @@ function buildForm(item: AdminChannel | null): EditForm { aiAgentId: item.aiAgentId > 0 ? String(item.aiAgentId) : "", name: item.name, openKfId: parseOpenKfId(item.configJson), - widgetTitle: widgetConfig.title, - widgetSubtitle: widgetConfig.subtitle, - widgetThemeColor: widgetConfig.themeColor, - widgetPosition: widgetConfig.position, - widgetWidth: widgetConfig.width, + widgetTitle: wechatConfig?.title ?? webConfig.title, + widgetSubtitle: wechatConfig?.subtitle ?? webConfig.subtitle, + widgetThemeColor: wechatConfig?.themeColor ?? webConfig.themeColor, + widgetPosition: webConfig.position, + widgetWidth: webConfig.width, wechatAppId: wechatConfig?.appId ?? "", wechatAppSecret: wechatConfig?.appSecret ?? "", wechatOAuthScope: wechatConfig?.oauthScope ?? "snsapi_base", @@ -244,8 +245,6 @@ function buildPayload(form: EditForm, status: number): CreateAdminChannelPayload subtitle: form.widgetSubtitle.trim(), themeColor: form.widgetThemeColor.trim() || defaultWebChannelConfig.themeColor, - position: form.widgetPosition || defaultWebChannelConfig.position, - width: form.widgetWidth.trim() || defaultWebChannelConfig.width, } const configJson = channelType === "wxwork_kf" @@ -256,9 +255,12 @@ function buildPayload(form: EditForm, status: number): CreateAdminChannelPayload appId: form.wechatAppId.trim(), appSecret: form.wechatAppSecret.trim(), oauthScope: form.wechatOAuthScope || "snsapi_base", - oauthEnabled: true, }) - : JSON.stringify(webLikeConfig) + : JSON.stringify({ + ...webLikeConfig, + position: form.widgetPosition || defaultWebChannelConfig.position, + width: form.widgetWidth.trim() || defaultWebChannelConfig.width, + }) return { channelType, aiAgentId: Number(form.aiAgentId), @@ -324,7 +326,6 @@ function ChannelFormBody({ } = form const channelType = useWatch({ control, name: "channelType" }) const openKfId = useWatch({ control, name: "openKfId" }) - const isWebLikeChannel = channelType === "web" || channelType === "wechat_mp" useEffect(() => { async function loadAIAgents() { @@ -532,7 +533,7 @@ function ChannelFormBody({ ) : null} - {isWebLikeChannel ? ( + {channelType === "web" || channelType === "wechat_mp" ? ( <>
{channelType === "wechat_mp" ? ( @@ -619,43 +620,48 @@ function ChannelFormBody({ - - 挂载位置 - - ( - + + 挂载位置 + + ( + + )} /> - )} - /> - - - + + + - - 窗口宽度 - - - - - + + 窗口宽度 + + + + + + + ) : null}
- + {channelType === "wechat_mp" ? ( + + ) : ( + + )} ) : null} @@ -673,15 +679,8 @@ function ChannelFormBody({ ) } -function WebAccessGuide({ - channelId, - channelType, -}: { - channelId: string - channelType: "web" | "wechat_mp" -}) { +function WebAccessGuide({ channelId }: { channelId: string }) { const [origin, setOrigin] = useState("") - const isWechatMP = channelType === "wechat_mp" useEffect(() => { setOrigin(window.location.origin) @@ -691,16 +690,10 @@ function WebAccessGuide({ if (!origin || !channelId) { return "" } - const url = new URL( - isWechatMP ? "/api/channel/wechat_mp/oauth/authorize" : "/kefu/chat/", - origin - ) + const url = new URL("/kefu/chat/", origin) url.searchParams.set("channelId", channelId) - if (isWechatMP) { - url.searchParams.set("returnPath", "/kefu/chat/") - } return url.toString() - }, [channelId, isWechatMP, origin]) + }, [channelId, origin]) const testUrl = useMemo(() => { if (!origin || !channelId) { @@ -717,11 +710,11 @@ function WebAccessGuide({ } return ` ` - }, [channelId, isWechatMP, origin]) + }, [channelId, origin]) async function copyText(text: string, successMessage: string) { if (!text) { @@ -738,14 +731,10 @@ function WebAccessGuide({ return (
-
- {isWechatMP ? "微信公众号接入信息" : "Web 接入信息"} -
+
Web 接入信息
{channelId - ? isWechatMP - ? "将授权链接配置到公众号菜单,用户授权后会进入客服窗口。" - : "复制链接或嵌入代码即可接入当前 Web 渠道。" + ? "复制链接或嵌入代码即可接入当前 Web 渠道。" : "保存渠道后生成接入链接。"}
@@ -757,9 +746,7 @@ function WebAccessGuide({ ) : (
-
- {isWechatMP ? "公众号菜单授权链接" : "直接访问链接"} -
+
直接访问链接
@@ -785,55 +772,129 @@ function WebAccessGuide({
- {!isWechatMP ? ( -
-
-
- 嵌入式接入代码 -
- +
+
+
+ 嵌入式接入代码
-
-                {snippet}
-              
+
- ) : null} +
+              {snippet}
+            
+
接入教程
- {isWechatMP ? ( - <> -
1. 确认该渠道已启用,并在微信公众平台配置网页授权域名。
-
2. 将公众号菜单跳转地址设置为上方授权链接。
-
3. 用户点击菜单并授权后,会以 openid 作为稳定客户身份进入客服窗口。
- - ) : ( - <> -
1. 确认该渠道已启用。
-
2. 将嵌入代码粘贴到目标网站 HTML 的 body 结束标签前。
-
3. 发布网站后刷新页面,客服入口会按渠道配置展示。
-
4. 独立页面或二维码场景可直接使用访问链接。
-
- -
- - )} +
1. 确认该渠道已启用。
+
2. 将嵌入代码粘贴到目标网站 HTML 的 body 结束标签前。
+
3. 发布网站后刷新页面,客服入口会按渠道配置展示。
+
4. 独立页面或二维码场景可直接使用访问链接。
+
+ +
+
+
+ )} +
+ ) +} + +function WechatMPAccessGuide({ channelId }: { channelId: string }) { + const [origin, setOrigin] = useState("") + + useEffect(() => { + setOrigin(window.location.origin) + }, []) + + const menuUrl = useMemo(() => { + if (!origin || !channelId) { + return "" + } + const url = new URL("/api/channel/wechat_mp/oauth/authorize", origin) + url.searchParams.set("channelId", channelId) + return url.toString() + }, [channelId, origin]) + + async function copyText(text: string) { + if (!text) { + return + } + try { + await navigator.clipboard.writeText(text) + toast.success("已复制公众号菜单链接") + } catch { + toast.error("复制失败") + } + } + + return ( +
+
+
微信公众号接入信息
+
+ {channelId + ? "将该链接配置到微信公众号自定义菜单,用户点击菜单后进入客服聊天页。" + : "保存渠道后生成公众号菜单链接。"} +
+
+ + {!channelId ? ( +
+ 当前为新建渠道,保存后会生成 channelId。 +
+ ) : ( +
+
+
+ 公众号菜单链接 +
+
+ +
+ + +
+
+
+ +
+
接入教程
+
1. 确认该渠道已启用,并在微信公众平台配置网页授权域名。
+
2. 将公众号自定义菜单跳转地址设置为上方链接。
+
3. 用户点击菜单并授权后,会以 openid 作为稳定客户身份进入客服聊天页。
)}