diff --git a/cmd/enums/generator.go b/cmd/enums/generator.go index 2eb51e6..7cf1cb7 100644 --- a/cmd/enums/generator.go +++ b/cmd/enums/generator.go @@ -17,7 +17,7 @@ import ( const ( enumsPkgName = "enums" enumsDir = "internal/pkg/enums" - outputPath = "dashboard/lib/generated/enums.ts" + outputPath = "web/lib/generated/enums.ts" ) type enumValueType string diff --git a/internal/controllers/api/channel_controller.go b/internal/controllers/api/channel_controller.go index bffda2a..cb41508 100644 --- a/internal/controllers/api/channel_controller.go +++ b/internal/controllers/api/channel_controller.go @@ -2,10 +2,13 @@ package api import ( "cs-agent/internal/pkg/dto/response" + "cs-agent/internal/pkg/enums" + "cs-agent/internal/pkg/errorsx" "cs-agent/internal/services" "github.com/kataras/iris/v12" "github.com/mlogclub/simple/web" + "github.com/mlogclub/simple/web/params" ) type ChannelController struct { @@ -17,18 +20,81 @@ func (c *ChannelController) AnyConfig() *web.JsonResult { if channel == nil { return web.JsonErrorMsg("接入渠道未初始化") } - cfg, err := services.ChannelService.ParseWebChannelConfig(channel.ConfigJSON) + cfg, externalSource, err := resolveWidgetConfig(channel.ChannelType, channel.ConfigJSON) if err != nil { - return web.JsonErrorMsg("Web渠道配置不合法") + return web.JsonError(err) } ret := response.WidgetConfigResponse{ - ChannelID: channel.ChannelID, - Title: cfg.Title, - Subtitle: cfg.Subtitle, - ThemeColor: cfg.ThemeColor, - Position: cfg.Position, - Width: cfg.Width, + ChannelID: channel.ChannelID, + ChannelType: channel.ChannelType, + ExternalSource: externalSource, + Title: cfg.Title, + Subtitle: cfg.Subtitle, + ThemeColor: cfg.ThemeColor, + Position: cfg.Position, + Width: cfg.Width, } return web.JsonData(ret) } + +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) + 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 + ThemeColor string + Position string + Width string +} + +func resolveWidgetConfig(channelType, rawConfig string) (*webLikeWidgetConfig, string, error) { + switch channelType { + case enums.ChannelTypeWeb: + cfg, err := services.ChannelService.ParseWebChannelConfig(rawConfig) + if err != nil { + return nil, "", err + } + return &webLikeWidgetConfig{ + Title: cfg.Title, + Subtitle: cfg.Subtitle, + ThemeColor: cfg.ThemeColor, + Position: cfg.Position, + Width: cfg.Width, + }, string(enums.ExternalSourceWebChat), nil + case enums.ChannelTypeWechatMP: + cfg, err := services.ChannelService.ParseWechatMPChannelConfig(rawConfig) + if err != nil { + return nil, "", err + } + return &webLikeWidgetConfig{ + Title: cfg.Title, + Subtitle: cfg.Subtitle, + ThemeColor: cfg.ThemeColor, + Position: cfg.Position, + Width: cfg.Width, + }, string(enums.ExternalSourceWechatMP), nil + default: + return nil, "", errorsx.InvalidParam("该渠道不支持开放客服配置") + } +} diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go index cc14d13..068b03d 100644 --- a/internal/pkg/dto/dto.go +++ b/internal/pkg/dto/dto.go @@ -23,3 +23,15 @@ type WebChannelConfig struct { Position string `json:"position"` Width string `json:"width"` } + +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"` +} diff --git a/internal/pkg/dto/response/widget_response.go b/internal/pkg/dto/response/widget_response.go index 99b7338..e6f4ba4 100644 --- a/internal/pkg/dto/response/widget_response.go +++ b/internal/pkg/dto/response/widget_response.go @@ -1,10 +1,12 @@ package response type WidgetConfigResponse struct { - ChannelID string `json:"channelId"` - Title string `json:"title"` - Subtitle string `json:"subtitle"` - ThemeColor string `json:"themeColor"` - Position string `json:"position"` - Width string `json:"width"` + ChannelID string `json:"channelId"` + ChannelType string `json:"channelType"` + ExternalSource string `json:"externalSource"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + ThemeColor string `json:"themeColor"` + Position string `json:"position"` + Width string `json:"width"` } diff --git a/internal/pkg/enums/external_identity.go b/internal/pkg/enums/external_identity.go index 6d9c236..203ff3c 100644 --- a/internal/pkg/enums/external_identity.go +++ b/internal/pkg/enums/external_identity.go @@ -7,11 +7,13 @@ type ExternalSource string const ( ExternalSourceWebChat ExternalSource = "web_chat" + ExternalSourceWechatMP ExternalSource = "wechat_mp" ExternalSourceWxWorkKF ExternalSource = "wxwork_kf" ) var externalSourceLabelMap = map[ExternalSource]string{ ExternalSourceWebChat: "网页客服", + ExternalSourceWechatMP: "微信公众号", ExternalSourceWxWorkKF: "企业微信客服", } @@ -25,7 +27,7 @@ func GetExternalSourceLabel(v ExternalSource) string { // IsAllowedOpenImExternalSource 开放 IM 入口允许的外部来源(闭集校验)。 func IsAllowedOpenImExternalSource(s ExternalSource) bool { switch s { - case ExternalSourceWebChat: + case ExternalSourceWebChat, ExternalSourceWechatMP: return true default: return false diff --git a/internal/pkg/enums/wxwork_kf.go b/internal/pkg/enums/wxwork_kf.go index 97eaccd..76c3550 100644 --- a/internal/pkg/enums/wxwork_kf.go +++ b/internal/pkg/enums/wxwork_kf.go @@ -18,6 +18,7 @@ const ( const ( ChannelTypeWeb = "web" + ChannelTypeWechatMP = "wechat_mp" ChannelTypeWxWorkKF = "wxwork_kf" ) diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go index b2494bf..2badba4 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -1,6 +1,9 @@ package services import ( + "context" + "crypto/hmac" + "crypto/sha256" "cs-agent/internal/models" "cs-agent/internal/pkg/dto" "cs-agent/internal/pkg/dto/request" @@ -11,7 +14,12 @@ 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" @@ -19,6 +27,8 @@ 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" ) @@ -31,6 +41,19 @@ func newChannelService() *channelService { type channelService struct { } +type wechatMPOAuthState struct { + ChannelID string `json:"channelId"` + ReturnPath string `json:"returnPath"` + ExpiresAt int64 `json:"expiresAt"` +} + +type WechatMPOAuthResult struct { + ChannelID string + ExternalID string + ExternalName string + ReturnPath string +} + func (s *channelService) Get(id int64) *models.Channel { return repositories.ChannelRepository.Get(sqls.DB(), id) } @@ -239,6 +262,140 @@ func (s *channelService) ParseWebChannelConfig(raw string) (*dto.WebChannelConfi return cfg, nil } +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, + } + if raw != "" { + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, err + } + } + cfg.Title = strings.TrimSpace(cfg.Title) + if cfg.Title == "" { + cfg.Title = "公众号客服" + } + cfg.Subtitle = strings.TrimSpace(cfg.Subtitle) + cfg.ThemeColor = strings.TrimSpace(cfg.ThemeColor) + 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) + 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, returnPath 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.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(), + }, 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, + ReturnPath: normalizeWechatMPReturnPath(payload.ReturnPath), + }, nil +} + func (s *channelService) GetEnabledWxWorkKFChannelByOpenKfID(openKfID string) *models.Channel { openKfID = strings.TrimSpace(openKfID) if openKfID == "" { @@ -274,7 +431,7 @@ func (s *channelService) GetEnabledChannel(ctx iris.Context) *models.Channel { func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRequest) (*models.Channel, error) { channelType := strings.TrimSpace(req.ChannelType) - if channelType != enums.ChannelTypeWeb && channelType != enums.ChannelTypeWxWorkKF { + if channelType != enums.ChannelTypeWeb && channelType != enums.ChannelTypeWechatMP && channelType != enums.ChannelTypeWxWorkKF { return nil, errorsx.InvalidParam("渠道类型不合法") } name := strings.TrimSpace(req.Name) @@ -322,6 +479,25 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe return nil, err } configJSON = string(configBytes) + case enums.ChannelTypeWechatMP: + if channelID == "" { + channelID = strs.UUID() + } + if exists := s.Take("channel_id = ? AND status <> ? AND id <> ?", channelID, enums.StatusDeleted, id); exists != nil { + return nil, errorsx.InvalidParam("渠道标识已存在") + } + cfg, err := s.ParseWechatMPChannelConfig(configJSON) + 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 + } + configJSON = string(configBytes) case enums.ChannelTypeWxWorkKF: if channelID == "" { channelID = strs.UUID() @@ -351,3 +527,127 @@ 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") + } + 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), + )) + 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.SplitN(string(data), "|", 3) + if len(stateParts) != 3 { + 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]), + ReturnPath: strings.TrimSpace(stateParts[2]), + 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 == "" { + 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, normalizeWechatMPReturnPath(result.ReturnPath), values) +} diff --git a/web/app/dashboard/channels/_components/edit.tsx b/web/app/dashboard/channels/_components/edit.tsx index 70f1e61..a1e591c 100644 --- a/web/app/dashboard/channels/_components/edit.tsx +++ b/web/app/dashboard/channels/_components/edit.tsx @@ -38,9 +38,15 @@ type ChannelFormDialogProps = { const channelTypeOptions = [ { value: "web", label: "Web 站点" }, + { value: "wechat_mp", label: "微信公众号" }, { 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: "左下角" }, @@ -54,6 +60,13 @@ type WebChannelConfig = { width?: string } +type WechatMPChannelConfig = WebChannelConfig & { + appId?: string + appSecret?: string + oauthScope?: "snsapi_base" | "snsapi_userinfo" + oauthEnabled?: boolean +} + const defaultWebChannelConfig: Required = { title: "在线客服", subtitle: "欢迎咨询", @@ -64,7 +77,7 @@ const defaultWebChannelConfig: Required = { const schema = z .object({ - channelType: z.enum(["web", "wxwork_kf"], "请选择渠道类型"), + channelType: z.enum(["web", "wechat_mp", "wxwork_kf"], "请选择渠道类型"), aiAgentId: z.string().trim().regex(/^\d+$/, "请选择 AI Agent"), name: z.string().trim().min(1, "渠道名称不能为空"), openKfId: z.string().trim(), @@ -73,6 +86,9 @@ 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) => { @@ -83,6 +99,22 @@ 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 @@ -103,6 +135,9 @@ const emptyForm: EditForm = { widgetThemeColor: defaultWebChannelConfig.themeColor, widgetPosition: defaultWebChannelConfig.position, widgetWidth: defaultWebChannelConfig.width, + wechatAppId: "", + wechatAppSecret: "", + wechatOAuthScope: "snsapi_base", remark: "", } @@ -138,13 +173,53 @@ function parseWebChannelConfig(configJson: string): Required { } } +function parseWechatMPChannelConfig(configJson: string): Required { + const fallback = { + ...defaultWebChannelConfig, + title: "公众号客服", + 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, + appId: parsed.appId?.trim() || "", + appSecret: parsed.appSecret?.trim() || "", + oauthScope, + oauthEnabled: parsed.oauthEnabled ?? true, + } + } catch { + return fallback + } +} + function buildForm(item: AdminChannel | null): EditForm { if (!item) { return emptyForm } - const widgetConfig = parseWebChannelConfig(item.configJson) + const isWechatMP = item.channelType === "wechat_mp" + const webConfig = parseWebChannelConfig(item.configJson) + const wechatConfig = isWechatMP + ? parseWechatMPChannelConfig(item.configJson) + : null + const widgetConfig = wechatConfig ?? webConfig return { - channelType: item.channelType === "wxwork_kf" ? "wxwork_kf" : "web", + channelType: + item.channelType === "wxwork_kf" + ? "wxwork_kf" + : item.channelType === "wechat_mp" + ? "wechat_mp" + : "web", aiAgentId: item.aiAgentId > 0 ? String(item.aiAgentId) : "", name: item.name, openKfId: parseOpenKfId(item.configJson), @@ -153,23 +228,37 @@ function buildForm(item: AdminChannel | null): EditForm { widgetThemeColor: widgetConfig.themeColor, widgetPosition: widgetConfig.position, widgetWidth: widgetConfig.width, + wechatAppId: wechatConfig?.appId ?? "", + wechatAppSecret: wechatConfig?.appSecret ?? "", + wechatOAuthScope: wechatConfig?.oauthScope ?? "snsapi_base", remark: item.remark || "", } } function buildPayload(form: EditForm, status: number): CreateAdminChannelPayload { const channelType = form.channelType + const webLikeConfig = { + title: + form.widgetTitle.trim() || + (channelType === "wechat_mp" ? "公众号客服" : defaultWebChannelConfig.title), + 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" ? JSON.stringify({ openKfId: form.openKfId.trim() }) - : JSON.stringify({ - title: form.widgetTitle.trim() || defaultWebChannelConfig.title, - subtitle: form.widgetSubtitle.trim(), - themeColor: - form.widgetThemeColor.trim() || defaultWebChannelConfig.themeColor, - position: form.widgetPosition || defaultWebChannelConfig.position, - width: form.widgetWidth.trim() || defaultWebChannelConfig.width, - }) + : channelType === "wechat_mp" + ? JSON.stringify({ + ...webLikeConfig, + appId: form.wechatAppId.trim(), + appSecret: form.wechatAppSecret.trim(), + oauthScope: form.wechatOAuthScope || "snsapi_base", + oauthEnabled: true, + }) + : JSON.stringify(webLikeConfig) return { channelType, aiAgentId: Number(form.aiAgentId), @@ -235,6 +324,7 @@ 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() { @@ -408,7 +498,9 @@ function ChannelFormBody({
{channelType === "wxwork_kf" ? "配置企业微信客服账号,用于匹配回调消息和对外发送消息。" - : "配置 Web 站点客服窗口的展示参数。"} + : channelType === "wechat_mp" + ? "配置公众号网页授权和客服窗口展示参数。" + : "配置 Web 站点客服窗口的展示参数。"}
@@ -440,9 +532,62 @@ function ChannelFormBody({ ) : null} - {channelType === "web" ? ( + {isWebLikeChannel ? ( <>
+ {channelType === "wechat_mp" ? ( + <> + + + 公众号 AppID + + + + + + + + + + 公众号 AppSecret + + + + + + + + + 网页授权方式 + + ( + + )} + /> + + + + + ) : null} + 窗口标题 @@ -507,7 +652,10 @@ function ChannelFormBody({
- + ) : null} @@ -525,8 +673,15 @@ function ChannelFormBody({ ) } -function WebAccessGuide({ channelId }: { channelId: string }) { +function WebAccessGuide({ + channelId, + channelType, +}: { + channelId: string + channelType: "web" | "wechat_mp" +}) { const [origin, setOrigin] = useState("") + const isWechatMP = channelType === "wechat_mp" useEffect(() => { setOrigin(window.location.origin) @@ -536,10 +691,16 @@ function WebAccessGuide({ channelId }: { channelId: string }) { if (!origin || !channelId) { return "" } - const url = new URL("/kefu/chat/", origin) + const url = new URL( + isWechatMP ? "/api/channel/wechat_mp/oauth/authorize" : "/kefu/chat/", + origin + ) url.searchParams.set("channelId", channelId) + if (isWechatMP) { + url.searchParams.set("returnPath", "/kefu/chat/") + } return url.toString() - }, [channelId, origin]) + }, [channelId, isWechatMP, origin]) const testUrl = useMemo(() => { if (!origin || !channelId) { @@ -556,11 +717,11 @@ function WebAccessGuide({ channelId }: { channelId: string }) { } return ` ` - }, [channelId, origin]) + }, [channelId, isWechatMP, origin]) async function copyText(text: string, successMessage: string) { if (!text) { @@ -577,11 +738,15 @@ function WebAccessGuide({ channelId }: { channelId: string }) { return (
-
Web 接入信息
+
+ {isWechatMP ? "微信公众号接入信息" : "Web 接入信息"} +
{channelId - ? "复制链接或嵌入代码即可接入当前 Web 渠道。" - : "保存渠道后生成接入链接和 SDK 代码。"} + ? isWechatMP + ? "将授权链接配置到公众号菜单,用户授权后会进入客服窗口。" + : "复制链接或嵌入代码即可接入当前 Web 渠道。" + : "保存渠道后生成接入链接。"}
@@ -592,7 +757,9 @@ function WebAccessGuide({ channelId }: { channelId: string }) { ) : (
-
直接访问链接
+
+ {isWechatMP ? "公众号菜单授权链接" : "直接访问链接"} +
@@ -618,43 +785,55 @@ function WebAccessGuide({ channelId }: { channelId: string }) {
-
-
-
- 嵌入式接入代码 + {!isWechatMP ? ( +
+
+
+ 嵌入式接入代码 +
+
- +
+                {snippet}
+              
-
-              {snippet}
-            
-
+ ) : null}
接入教程
-
1. 确认该渠道已启用。
-
2. 将嵌入代码粘贴到目标网站 HTML 的 body 结束标签前。
-
3. 发布网站后刷新页面,客服入口会按渠道配置展示。
-
4. 独立页面或二维码场景可直接使用访问链接。
-
- -
+ {isWechatMP ? ( + <> +
1. 确认该渠道已启用,并在微信公众平台配置网页授权域名。
+
2. 将公众号菜单跳转地址设置为上方授权链接。
+
3. 用户点击菜单并授权后,会以 openid 作为稳定客户身份进入客服窗口。
+ + ) : ( + <> +
1. 确认该渠道已启用。
+
2. 将嵌入代码粘贴到目标网站 HTML 的 body 结束标签前。
+
3. 发布网站后刷新页面,客服入口会按渠道配置展示。
+
4. 独立页面或二维码场景可直接使用访问链接。
+
+ +
+ + )}
)} diff --git a/web/app/dashboard/channels/page.tsx b/web/app/dashboard/channels/page.tsx index 1b56867..23d8cd0 100644 --- a/web/app/dashboard/channels/page.tsx +++ b/web/app/dashboard/channels/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react" import { Building2Icon, + MessagesSquareIcon, MessageSquareMoreIcon, MoreHorizontalIcon, PlusIcon, @@ -58,10 +59,14 @@ const statusOptions = [ const channelTypeOptions = [ { value: "all", label: "全部类型" }, { value: "web", label: "Web 站点" }, + { value: "wechat_mp", label: "微信公众号" }, { value: "wxwork_kf", label: "企业微信客服" }, ] as const function getChannelTypeLabel(channelType: string) { + if (channelType === "wechat_mp") { + return "微信公众号" + } if (channelType === "wxwork_kf") { return "企业微信客服" } @@ -69,6 +74,9 @@ function getChannelTypeLabel(channelType: string) { } function ChannelIcon({ channelType }: { channelType: string }) { + if (channelType === "wechat_mp") { + return + } if (channelType === "wxwork_kf") { return } diff --git a/web/lib/api/im.ts b/web/lib/api/im.ts index 639c4ec..4730648 100644 --- a/web/lib/api/im.ts +++ b/web/lib/api/im.ts @@ -104,6 +104,8 @@ export type ImAsset = { export type ImWidgetConfig = { channelId?: string + channelType?: string + externalSource?: string title?: string subtitle?: string themeColor?: string @@ -146,6 +148,7 @@ function getRuntimeImConfig() { channelId: widgetConfig.channelId || OPEN_IM_CHANNEL_ID, externalSource: (widgetConfig.externalSource || OPEN_IM_EXTERNAL_SOURCE).trim() || "web_chat", + externalId: (widgetConfig.externalId || "").trim(), externalName: (widgetConfig.subject || "").trim(), } } @@ -154,7 +157,7 @@ function createImHeaders() { const config = getRuntimeImConfig() const headers: Record = { "X-External-Source": config.externalSource, - "X-External-Id": getImVisitorId(), + "X-External-Id": config.externalId || getImVisitorId(), "X-Channel-Id": config.channelId, } if (config.externalName) { diff --git a/web/lib/generated/enums.ts b/web/lib/generated/enums.ts index d45ed3a..03de15e 100644 --- a/web/lib/generated/enums.ts +++ b/web/lib/generated/enums.ts @@ -73,10 +73,12 @@ export const ContactTypeLabels: Record = { export enum ExternalSource { WebChat = "web_chat", + WechatMP = "wechat_mp", WxWorkKF = "wxwork_kf", } export const ExternalSourceLabels: Record = { [ExternalSource.WebChat]: "网页客服", + [ExternalSource.WechatMP]: "微信公众号", [ExternalSource.WxWorkKF]: "企业微信客服", } diff --git a/web/lib/im-realtime.ts b/web/lib/im-realtime.ts index 871f894..203b921 100644 --- a/web/lib/im-realtime.ts +++ b/web/lib/im-realtime.ts @@ -19,7 +19,9 @@ export function createImRealtimeConnection() { const baseUrl = apiBaseUrl ? apiBaseUrl.replace(/^http/, "ws").replace(/\/$/, "") : createWebSocketBaseUrl() - const externalId = encodeURIComponent(getImVisitorId()) + const resolvedExternalId = encodeURIComponent( + (config.externalId ?? "").trim() || getImVisitorId() + ) const externalSource = encodeURIComponent( (config.externalSource ?? "web_chat").trim() || "web_chat" ) @@ -30,6 +32,6 @@ export function createImRealtimeConnection() { ? `&externalName=${encodeURIComponent(externalName)}` : "" return new WebSocket( - `${baseUrl}/api/ws/open?externalId=${externalId}&externalSource=${externalSource}&channelId=${channelId}${nameQuery}` + `${baseUrl}/api/ws/open?externalId=${resolvedExternalId}&externalSource=${externalSource}&channelId=${channelId}${nameQuery}` ) } diff --git a/web/lib/kefu-widget-config.ts b/web/lib/kefu-widget-config.ts index f80a057..cd94b21 100644 --- a/web/lib/kefu-widget-config.ts +++ b/web/lib/kefu-widget-config.ts @@ -4,6 +4,8 @@ export type KefuWidgetHostConfig = { apiBaseUrl?: string /** 与后端 enums.ExternalSource 一致,默认 web_chat */ externalSource?: string + /** 外部访客稳定标识;微信公众号 OAuth 场景使用 openid */ + externalId?: string title?: string subtitle?: string position?: "left" | "right" @@ -48,6 +50,7 @@ export function readKefuWidgetConfig(): KefuWidgetHostConfig { query.get("externalSource") ?? process.env.NEXT_PUBLIC_OPEN_IM_EXTERNAL_SOURCE?.trim() ?? undefined, + externalId: query.get("externalId") ?? undefined, title: query.get("title") ?? undefined, subtitle: query.get("subtitle") ?? undefined, position: (query.get("position") as "left" | "right" | null) ?? undefined, diff --git a/web/lib/stores/kefu-chat.ts b/web/lib/stores/kefu-chat.ts index 813abf4..519ea1b 100644 --- a/web/lib/stores/kefu-chat.ts +++ b/web/lib/stores/kefu-chat.ts @@ -35,6 +35,7 @@ import { import { summarizeIMMessage } from "@/lib/im-message" import { createRealtimeConnectionManager } from "@/lib/realtime-connection" import { generateUUID } from "@/lib/utils" +import { readKefuWidgetConfig, setKefuWidgetConfig } from "@/lib/kefu-widget-config" type ChatStatus = "connecting" | "connected" | "disconnected" @@ -267,6 +268,15 @@ export const useKefuChatStore = create((set, get) => { return } + if (widgetConfig.channelId || widgetConfig.externalSource) { + setKefuWidgetConfig({ + ...readKefuWidgetConfig(), + channelId: widgetConfig.channelId || readKefuWidgetConfig().channelId, + externalSource: + widgetConfig.externalSource || readKefuWidgetConfig().externalSource, + }) + } + set({ title: widgetConfig.title || "在线客服", subtitle: widgetConfig.subtitle || "",