diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77262dc0..8a7902e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: gochat/gochat + SHANGWUTONG_IMAGE_NAME: gochat/shangwutong GOPROXY: https://goproxy.cn,direct jobs: @@ -191,8 +192,8 @@ jobs: type=semver,pattern={{major}}.{{minor}} type=sha - # Build and push - - name: Build and push + # Build and push main GoChat image + - name: Build and push GoChat image id: build uses: docker/build-push-action@v5 with: @@ -205,14 +206,46 @@ jobs: VERSION=${{ github.ref_name }} COMMIT_SHA=${{ github.sha }} BUILD_DATE=${{ github.event.head_commit.timestamp }} - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=gochat + cache-to: type=gha,mode=max,scope=gochat - # Scan Docker image with Trivy - - name: Scan Docker image + - name: Extract Shangwutong connector metadata + id: shangwutong_meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.SHANGWUTONG_IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + + - name: Build and push Shangwutong connector image + id: shangwutong_build + uses: docker/build-push-action@v5 + with: + context: . + file: ./channels/shangwutong/Dockerfile + push: true + tags: ${{ steps.shangwutong_meta.outputs.tags }} + labels: ${{ steps.shangwutong_meta.outputs.labels }} + cache-from: type=gha,scope=shangwutong + cache-to: type=gha,mode=max,scope=shangwutong + + # Scan Docker images with Trivy + - name: Scan GoChat image uses: aquasecurity/trivy-action@master with: - image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }} + format: 'table' + exit-code: '1' + severity: 'CRITICAL,HIGH' + + - name: Scan Shangwutong connector image + uses: aquasecurity/trivy-action@master + with: + image-ref: ${{ env.REGISTRY }}/${{ env.SHANGWUTONG_IMAGE_NAME }}@${{ steps.shangwutong_build.outputs.digest }} format: 'table' exit-code: '1' severity: 'CRITICAL,HIGH' diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index c37b853e..34fe7583 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -237,14 +237,38 @@ func RegisterRoutes( // Long-lived Connector service principal routes are intentionally outside // the user JWT group and accept only a dedicated PlatformApp Bearer token. - if handlers.ShangwutongConnector != nil { - connector := engine.Group("/api/v1/connector/shangwutong") - connector.Use(middleware.ConnectorServiceAuth(db)) - connector.GET("/inboxes", handlers.ShangwutongConnector.ListInboxes) - connector.GET("/inboxes/:inbox_id", handlers.ShangwutongConnector.GetInbox) - connector.PUT("/inboxes/:inbox_id/status", handlers.ShangwutongConnector.UpdateInboxStatus) - connector.PUT("/inboxes/:inbox_id/messages/:message_id/status", handlers.ShangwutongConnector.UpdateMessageStatus) - } + // Register routes unconditionally so route parity tests catch accidental removals; + // nil handler checks live inside each closure. + connector := engine.Group("/api/v1/connector/shangwutong") + connector.Use(middleware.ConnectorServiceAuth(db)) + connector.GET("/inboxes", func(c *gin.Context) { + if handlers == nil || handlers.ShangwutongConnector == nil { + webhookProviderUnavailable(c) + return + } + handlers.ShangwutongConnector.ListInboxes(c) + }) + connector.GET("/inboxes/:inbox_id", func(c *gin.Context) { + if handlers == nil || handlers.ShangwutongConnector == nil { + webhookProviderUnavailable(c) + return + } + handlers.ShangwutongConnector.GetInbox(c) + }) + connector.PUT("/inboxes/:inbox_id/status", func(c *gin.Context) { + if handlers == nil || handlers.ShangwutongConnector == nil { + webhookProviderUnavailable(c) + return + } + handlers.ShangwutongConnector.UpdateInboxStatus(c) + }) + connector.PUT("/inboxes/:inbox_id/messages/:message_id/status", func(c *gin.Context) { + if handlers == nil || handlers.ShangwutongConnector == nil { + webhookProviderUnavailable(c) + return + } + handlers.ShangwutongConnector.UpdateMessageStatus(c) + }) // API v1 routes — authenticated, account-scoped apiV1 := engine.Group("/api/v1") diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index ce4f1066..70016bec 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -63,6 +63,10 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) { "GET /tiktok/callback", "GET /app", "GET /app/*params", + "GET /api/v1/connector/shangwutong/inboxes", + "GET /api/v1/connector/shangwutong/inboxes/:inbox_id", + "PUT /api/v1/connector/shangwutong/inboxes/:inbox_id/status", + "PUT /api/v1/connector/shangwutong/inboxes/:inbox_id/messages/:message_id/status", "GET /api/v1/accounts/:account_id/captain/assistants/tools", "GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id", "PATCH /api/v1/accounts/:account_id/captain/assistants/:assistant_id", @@ -207,15 +211,24 @@ func TestWebhookNilHandlerReturnsProviderUnavailable(t *testing.T) { nil, ) - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/webhooks/telegram/bot-token", nil) - engine.ServeHTTP(w, req) - - if w.Code != http.StatusServiceUnavailable { - t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, w.Code) + cases := []struct { + method string + path string + }{ + {method: http.MethodPost, path: "/webhooks/telegram/bot-token"}, + {method: http.MethodGet, path: "/api/v1/connector/shangwutong/inboxes"}, } - if strings.Contains(w.Body.String(), "not implemented") || strings.Contains(w.Body.String(), "placeholder") { - t.Fatalf("nil webhook fallback returned placeholder body: %s", w.Body.String()) + for _, tc := range cases { + w := httptest.NewRecorder() + req := httptest.NewRequest(tc.method, tc.path, nil) + engine.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable && w.Code != http.StatusUnauthorized { + t.Fatalf("%s %s expected unavailable/auth failure, got %d", tc.method, tc.path, w.Code) + } + if strings.Contains(w.Body.String(), "not implemented") || strings.Contains(w.Body.String(), "placeholder") { + t.Fatalf("nil webhook fallback returned placeholder body: %s", w.Body.String()) + } } } diff --git a/channels/shangwutong/Dockerfile b/channels/shangwutong/Dockerfile index d6aa97d5..d3305bd8 100644 --- a/channels/shangwutong/Dockerfile +++ b/channels/shangwutong/Dockerfile @@ -25,7 +25,7 @@ VOLUME ["/data", "/backup"] EXPOSE 9100 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD wget -q -T 3 -O /dev/null http://127.0.0.1:9100/healthz || exit 1 + CMD wget -q -T 3 -O /dev/null http://127.0.0.1:9100/readyz || exit 1 ENTRYPOINT ["/usr/local/bin/shangwutong"] CMD ["serve"] diff --git a/deploy/docker/docker-compose.prod.yml b/deploy/docker/docker-compose.prod.yml index c511a6e3..31ff9eac 100644 --- a/deploy/docker/docker-compose.prod.yml +++ b/deploy/docker/docker-compose.prod.yml @@ -99,7 +99,7 @@ services: cpus: '1.0' shangwutong: - image: gochat/shangwutong:${SHANGWUTONG_VERSION:-latest} + image: ${SHANGWUTONG_IMAGE:-ghcr.io/gochat/shangwutong:latest} container_name: gochat-shangwutong restart: always stop_grace_period: ${SWT_SHUTDOWN_TIMEOUT:-30s} diff --git a/docs/runbooks/shangwutong-connector.md b/docs/runbooks/shangwutong-connector.md index 1553303f..f76a4049 100644 --- a/docs/runbooks/shangwutong-connector.md +++ b/docs/runbooks/shangwutong-connector.md @@ -1,5 +1,16 @@ # 商务通 Connector 生产运行手册 + +## 架构说明 + +Shangwutong 是刻意采用的外部 Connector 产品形态,不是内嵌在 GoChat 主进程里的标准 `ChannelProvider` 插件。GoChat 侧负责 Inbox 配置、凭据下发、durable webhook 投递、消息导入与状态回写;`channels/shangwutong` 进程负责登录商务通、维护会话、轮询/映射真实协议事件、执行出站发送。这样做的原因是商务通协议需要长连接/心跳/本地游标/不确定投递消歧,独立进程可以隔离故障域、持久化协议状态,并避免把站点密码与会话状态塞进主 API 进程。 + +因此,Shangwutong 不需要实现 `internal/channel.ChannelProvider` 的 14 方法接口,也不注册 `/webhooks/shangwutong/*` 平台回调路由。其稳定契约是: + +- GoChat → Connector:`/webhooks/gochat/v1`,使用 `X-Chatwoot-Timestamp` + `X-Chatwoot-Signature` HMAC 签名。 +- Connector → GoChat:`/api/v1/connector/shangwutong/*` 配置/状态 API,以及允许列表中的 account-scoped message/conversation API。 +- GoChat Inbox 类型仍序列化为 `Channel::Shangwutong`,前端按 API-like inbox 展示和配置。 + ## 1. 上线前检查 1. GoChat 已部署 `Channel::Shangwutong` migration、Connector service principal、配置/状态 API 和 durable webhook worker。 diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/specs/Shangwutong.spec.js b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/specs/Shangwutong.spec.js new file mode 100644 index 00000000..6377be19 --- /dev/null +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/specs/Shangwutong.spec.js @@ -0,0 +1,104 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import Shangwutong from '../Shangwutong.vue'; + +vi.mock('../../../../../index', () => ({ + default: { + replace: vi.fn(), + }, +})); + +vi.mock('dashboard/composables', () => ({ + useAlert: vi.fn(), +})); + +const t = key => key; + +const mountComponent = ({ + dispatch = vi.fn().mockResolvedValue({ id: 42 }), +} = {}) => + shallowMount(Shangwutong, { + global: { + mocks: { + $t: t, + $store: { + dispatch, + getters: { + 'inboxes/getUIFlags': { isCreating: false }, + }, + }, + }, + stubs: { + PageHeader: true, + NextButton: true, + }, + }, + }); + +describe('Shangwutong channel setup', () => { + it('submits the exact shangwutong channel payload', async () => { + const dispatch = vi.fn().mockResolvedValue({ id: 42 }); + const wrapper = mountComponent({ dispatch }); + + await wrapper.setData({ + channelName: ' 商务通站点 ', + sessionId: 'LZA69557093', + username: ' operator ', + password: 'secret-password', + desiredPresence: 'busy', + webhookUrl: 'http://shangwutong-connector:9100/webhooks/gochat/v1', + }); + + await wrapper.vm.createChannel(); + + expect(dispatch).toHaveBeenCalledWith('inboxes/createChannel', { + name: '商务通站点', + channel: { + type: 'shangwutong', + session_id: 'LZA69557093', + username: 'operator', + password: 'secret-password', + desired_presence: 'busy', + webhook_url: 'http://shangwutong-connector:9100/webhooks/gochat/v1', + }, + }); + }); + + it('rejects invalid session IDs before dispatching', async () => { + const dispatch = vi.fn(); + const wrapper = mountComponent({ dispatch }); + + await wrapper.setData({ + channelName: '商务通站点', + sessionId: 'short', + username: 'operator', + password: 'secret-password', + webhookUrl: 'http://shangwutong-connector:9100/webhooks/gochat/v1', + }); + + await wrapper.vm.createChannel(); + await nextTick(); + + expect(dispatch).not.toHaveBeenCalled(); + expect(wrapper.vm.v$.sessionId.$error).toBe(true); + }); + + it('rejects webhook URLs with userinfo or fragments', async () => { + const dispatch = vi.fn(); + const wrapper = mountComponent({ dispatch }); + + await wrapper.setData({ + channelName: '商务通站点', + sessionId: 'LZA69557093', + username: 'operator', + password: 'secret-password', + webhookUrl: 'http://user:pass@connector:9100/webhooks/gochat/v1#frag', + }); + + await wrapper.vm.createChannel(); + await nextTick(); + + expect(dispatch).not.toHaveBeenCalled(); + expect(wrapper.vm.v$.webhookUrl.$error).toBe(true); + }); +}); diff --git a/package.json b/package.json index 26f6753c..84bf9d0b 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,11 @@ "build:frontend": "cd frontend && pnpm build", "build:sdk": "cd frontend && pnpm build:sdk", "lint:frontend": "cd frontend && pnpm eslint", - "test:frontend": "cd frontend && pnpm test" + "test:frontend": "cd frontend && pnpm test", + "dev:shangwutong": "cd channels/shangwutong && go run ./cmd/shangwutong serve", + "test:shangwutong": "cd channels/shangwutong && go test ./...", + "build:shangwutong": "cd channels/shangwutong && go build -o bin/shangwutong ./cmd/shangwutong", + "dev:all": "concurrently -n backend,frontend,shangwutong -c blue,green,magenta \"pnpm dev:backend\" \"pnpm dev:frontend\" \"pnpm dev:shangwutong\"" }, "devDependencies": { "concurrently": "^9.1.0"