diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 2ffc857..92b9fea 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -89,10 +89,10 @@ jobs: umask 077 { printf "OLONS_IM_INTERNAL_SECRET=%s\n" "$internal_secret" - printf "OLONS_IM_BASE_URL=https://im-api.yuexuan.shop\n" - printf "OLONS_IM_HTTP_API_URL=https://im-api.yuexuan.shop/v1/im\n" - printf "OLONS_IM_WS_URL=wss://im-api.yuexuan.shop/v0/channels\n" - printf "OLONS_IM_FILE_URL=https://im-api.yuexuan.shop/v0/file\n" + printf "OLONS_IM_BASE_URL=http://im-server:6060\n" + printf "OLONS_IM_HTTP_API_URL=http://im-server:6060/v1/im\n" + printf "OLONS_IM_WS_URL=ws://im-server:6060/v0/channels\n" + printf "OLONS_IM_FILE_URL=http://im-server:6060/v0/file\n" printf "OLONS_IM_TENANT_CODE=default\n" printf "OLONS_IM_API_KEY=AQAAAAABAAAnbYhUP5GAkexs5qlwQ8fR\n" printf "OLONS_IM_INTERNAL_SERVICE=olons-server\n" @@ -118,7 +118,7 @@ jobs: docker compose -f "$base_file" -f compose.olons-server-secret.yaml up -d --no-deps --wait olons-server docker compose -f "$base_file" -f compose.olons-server-secret.yaml exec -T olons-server sh -c '"'"' test -n "$OLONS_IM_INTERNAL_SECRET" - test "$OLONS_IM_BASE_URL" = "https://im-api.yuexuan.shop" - test "$OLONS_IM_HTTP_API_URL" = "https://im-api.yuexuan.shop/v1/im" + test "$OLONS_IM_BASE_URL" = "http://im-server:6060" + test "$OLONS_IM_HTTP_API_URL" = "http://im-server:6060/v1/im" '"'"' ' diff --git a/internal/support/service.go b/internal/support/service.go index 0d037a6..fc232e8 100644 --- a/internal/support/service.go +++ b/internal/support/service.go @@ -70,6 +70,12 @@ type Session struct { Agent Agent `json:"agent"` } +const ( + publicIMAPIPath = "/v1/im" + publicIMWebSocketPath = "/v0/channels" + publicIMFilePath = "/v0/file" +) + func New(repository Repository, im IM, authConfig config.AuthConfig) *Service { return &Service{repository: repository, im: im, aead: credentialAEAD(authConfig)} } @@ -158,8 +164,8 @@ func (s *Service) OpenSession(ctx context.Context, tenantID, memberID, serviceID return nil, ErrRelationDenied } return &Session{ - TenantCode: cfg.TenantCode, APIBaseURL: cfg.HTTPAPIURL, WebSocketURL: cfg.WebSocketURL, - FileURL: cfg.FileURL, APIKey: cfg.APIKey, Token: imSession.Token, ExpiresAt: imSession.ExpiresAt, + TenantCode: cfg.TenantCode, APIBaseURL: publicIMAPIPath, WebSocketURL: publicIMWebSocketPath, + FileURL: publicIMFilePath, APIKey: cfg.APIKey, Token: imSession.Token, ExpiresAt: imSession.ExpiresAt, CurrentUserID: imSession.UserID, Topic: conversation.Topic, Relationship: "friend", Agent: Agent{ID: agent.ID, IMUserID: agent.IMUserID, Name: agent.Name, AvatarURL: agent.AvatarURL, OnlineStatus: customerServiceStatus(agent.OnlineStatus)}, diff --git a/internal/support/service_test.go b/internal/support/service_test.go index ed803b7..ccd0f6e 100644 --- a/internal/support/service_test.go +++ b/internal/support/service_test.go @@ -91,6 +91,24 @@ func TestOpenSessionAutoAcceptsAndReturnsTopic(t *testing.T) { } } +func TestOpenSessionReturnsSameOriginEndpoints(t *testing.T) { + repository := &repositoryStub{ + agent: &store.CustomerService{ID: 3, Name: "客服01", IMTenantCode: "allrich", IMUserID: "usr_agent", OnlineStatus: 1}, + identity: &store.MemberIMIdentity{TenantID: 0, MemberID: 9, IMTenantCode: "allrich", IMUserID: "usr_member", IMUsername: "member01"}, + } + im := &imStub{} + service := New(repository, im, config.AuthConfig{FinanceMasterKey: strings.Repeat("k", 32)}) + repository.identity.IMPasswordCipher, _ = service.encryptCredential("password") + + session, err := service.OpenSession(context.Background(), 0, 9, 3, SessionRequest{DeviceID: "web-1", Platform: "web"}) + if err != nil { + t.Fatal(err) + } + if session.APIBaseURL != "/v1/im" || session.WebSocketURL != "/v0/channels" || session.FileURL != "/v0/file" { + t.Fatalf("browser endpoints must be same-origin paths: %+v", session) + } +} + func TestOpenSessionRepairsDriftedIMCredential(t *testing.T) { repository := &repositoryStub{ agent: &store.CustomerService{ID: 3, Name: "客服01", IMTenantCode: "allrich", IMUserID: "usr_agent", OnlineStatus: 1}, diff --git a/tests/test_actions_workflow.py b/tests/test_actions_workflow.py index b0dc223..22b950d 100644 --- a/tests/test_actions_workflow.py +++ b/tests/test_actions_workflow.py @@ -26,14 +26,21 @@ class DeployWorkflowContractTest(unittest.TestCase): self.assertIn("DEPLOY_SSH_KEY", self.workflow) self.assertIn("DEPLOY_KNOWN_HOSTS", self.workflow) self.assertIn("StrictHostKeyChecking=yes", self.workflow) - self.assertIn("docker compose pull olons-server", self.workflow) - self.assertIn("docker compose up -d --no-deps --wait olons-server", self.workflow) + self.assertIn("pull olons-server", self.workflow) + self.assertIn("up -d --no-deps --wait olons-server", self.workflow) def test_removes_old_kubernetes_and_registry_targets(self): self.assertNotIn("kubectl", self.workflow) self.assertNotIn("KUBECONFIG", self.workflow) self.assertNotIn("git.118im.com", self.workflow) + def test_uses_internal_im_endpoints_for_server_calls(self): + self.assertIn("OLONS_IM_BASE_URL=http://im-server:6060", self.workflow) + self.assertIn("OLONS_IM_HTTP_API_URL=http://im-server:6060/v1/im", self.workflow) + self.assertIn("OLONS_IM_WS_URL=ws://im-server:6060/v0/channels", self.workflow) + self.assertIn("OLONS_IM_FILE_URL=http://im-server:6060/v0/file", self.workflow) + self.assertNotIn("OLONS_IM_BASE_URL=https://im-api.yuexuan.shop", self.workflow) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_deployment_contract.py b/tests/test_deployment_contract.py index 2b45295..ef39059 100644 --- a/tests/test_deployment_contract.py +++ b/tests/test_deployment_contract.py @@ -11,8 +11,8 @@ class DeploymentContractTest(unittest.TestCase): self.assertIn("Deploy olons-server production", workflow) self.assertIn("git.olns.shop/pro/olons_server", workflow) - self.assertIn("docker compose pull olons-server", workflow) - self.assertIn("docker compose up -d --no-deps --wait olons-server", workflow) + self.assertIn("pull olons-server", workflow) + self.assertIn("up -d --no-deps --wait olons-server", workflow) self.assertNotIn("im-workbench", workflow) self.assertNotIn("workbench-system", workflow) diff --git a/web/package.json b/web/package.json index a5d1e31..cdf6ef3 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@vicons/ionicons5": "^0.12.0", @@ -20,7 +21,8 @@ "@types/node": "^22.5.2", "@vitejs/plugin-vue": "^5.1.3", "typescript": "^5.5.4", - "vite": "^5.4.2" + "vite": "^5.4.2", + "vitest": "^2.1.9" }, "engines": { "node": ">=18" diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index e50c698..a00b6b7 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -13,7 +13,7 @@ importers: version: 0.12.0 axios: specifier: ^1.7.7 - version: 1.7.7 + version: 1.7.7(debug@4.4.3) naive-ui: specifier: ^2.44.1 version: 2.45.3(vue@3.4.38(typescript@5.5.4)) @@ -39,6 +39,9 @@ importers: vite: specifier: ^5.4.2 version: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6) + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6) packages: @@ -225,9 +228,6 @@ packages: '@jridgewell/source-map@0.3.6': resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} - '@jridgewell/sourcemap-codec@1.5.0': - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} - '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -348,6 +348,35 @@ packages: vite: ^5.0.0 vue: ^3.2.25 + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vue/compiler-core@3.4.38': resolution: {integrity: sha512-8IQOTCWnLFqfHzOGm9+P8OPSEDukgg3Huc92qSG49if/xI2SAwLHQO2qaPQbjCWPBcQoO1WYfXfTACUrWV3c5A==} @@ -385,6 +414,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async-validator@4.2.5: resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} @@ -397,6 +430,18 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -424,6 +469,19 @@ packages: date-fns@4.4.0: resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -436,6 +494,9 @@ packages: resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} hasBin: true + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -444,9 +505,16 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + evtd@0.2.4: resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + follow-redirects@1.15.6: resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} engines: {node: '>=4.0'} @@ -495,9 +563,15 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + magic-string@0.30.11: resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-dir@2.1.0: resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} engines: {node: '>=6'} @@ -515,6 +589,9 @@ packages: engines: {node: '>=4'} hasBin: true + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + naive-ui@2.45.3: resolution: {integrity: sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw==} engines: {node: '>=20'} @@ -535,6 +612,13 @@ packages: resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} engines: {node: '>= 0.10'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.0: resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} @@ -570,6 +654,9 @@ packages: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.0: resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} engines: {node: '>=0.10.0'} @@ -581,6 +668,12 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + terser@5.31.6: resolution: {integrity: sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==} engines: {node: '>=10'} @@ -589,6 +682,24 @@ packages: tinode-sdk@0.25.4: resolution: {integrity: sha512-EFWWR3JDFH1oognJT3K8vH6l7TmcOqblvqx3FwiRY1RAsSUypxOzGfWMZZo0nfc7EmjK3SogRgqNWH6NxLS5+w==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} @@ -612,6 +723,11 @@ packages: peerDependencies: vue: ^3.0.11 + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite@5.4.2: resolution: {integrity: sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -643,6 +759,31 @@ packages: terser: optional: true + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vooks@0.2.12: resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==} peerDependencies: @@ -666,6 +807,11 @@ packages: peerDependencies: vue: ^3.0.11 + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + snapshots: '@babel/helper-string-parser@7.24.8': {} @@ -780,10 +926,7 @@ snapshots: '@jridgewell/trace-mapping': 0.3.25 optional: true - '@jridgewell/sourcemap-codec@1.5.0': {} - - '@jridgewell/sourcemap-codec@1.5.5': - optional: true + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.25': dependencies: @@ -860,6 +1003,46 @@ snapshots: vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6) vue: 3.4.38(typescript@5.5.4) + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + '@vue/compiler-core@3.4.38': dependencies: '@babel/parser': 7.25.6 @@ -919,13 +1102,15 @@ snapshots: acorn@8.18.0: optional: true + assertion-error@2.0.1: {} + async-validator@4.2.5: {} asynckit@0.4.0: {} - axios@1.7.7: + axios@1.7.7(debug@4.4.3): dependencies: - follow-redirects: 1.15.6 + follow-redirects: 1.15.6(debug@4.4.3) form-data: 4.0.0 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -934,6 +1119,18 @@ snapshots: buffer-from@1.1.2: optional: true + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -961,6 +1158,12 @@ snapshots: date-fns@4.4.0: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + delayed-stream@1.0.0: {} entities@4.5.0: {} @@ -970,6 +1173,8 @@ snapshots: prr: 1.0.1 optional: true + es-module-lexer@1.7.0: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -998,9 +1203,17 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.5 + evtd@0.2.4: {} - follow-redirects@1.15.6: {} + expect-type@1.4.0: {} + + follow-redirects@1.15.6(debug@4.4.3): + optionalDependencies: + debug: 4.4.3 form-data@4.0.0: dependencies: @@ -1046,9 +1259,15 @@ snapshots: lodash@4.18.1: {} + loupe@3.2.1: {} + magic-string@0.30.11: dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 make-dir@2.1.0: dependencies: @@ -1065,6 +1284,8 @@ snapshots: mime@1.6.0: optional: true + ms@2.1.3: {} + naive-ui@2.45.3(vue@3.4.38(typescript@5.5.4)): dependencies: '@css-render/plugin-bem': 0.15.14(css-render@0.15.14) @@ -1098,6 +1319,10 @@ snapshots: parse-node-version@1.0.1: optional: true + pathe@1.1.2: {} + + pathval@2.0.1: {} + picocolors@1.1.0: {} pify@4.0.1: @@ -1147,6 +1372,8 @@ snapshots: semver@5.7.2: optional: true + siginfo@2.0.0: {} + source-map-js@1.2.0: {} source-map-support@0.5.21: @@ -1158,6 +1385,10 @@ snapshots: source-map@0.6.1: optional: true + stackback@0.0.2: {} + + std-env@3.10.0: {} + terser@5.31.6: dependencies: '@jridgewell/source-map': 0.3.6 @@ -1168,6 +1399,16 @@ snapshots: tinode-sdk@0.25.4: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + to-fast-properties@2.0.0: {} treemate@0.3.11: {} @@ -1184,6 +1425,24 @@ snapshots: evtd: 0.2.4 vue: 3.4.38(typescript@5.5.4) + vite-node@2.1.9(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6): dependencies: esbuild: 0.21.5 @@ -1195,6 +1454,41 @@ snapshots: less: 4.2.0 terser: 5.31.6 + vitest@2.1.9(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6) + vite-node: 2.1.9(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.5.2 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vooks@0.2.12(vue@3.4.38(typescript@5.5.4)): dependencies: evtd: 0.2.4 @@ -1225,3 +1519,8 @@ snapshots: vdirs: 0.1.8(vue@3.4.38(typescript@5.5.4)) vooks: 0.2.12(vue@3.4.38(typescript@5.5.4)) vue: 3.4.38(typescript@5.5.4) + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/web/src/mobile/im.test.ts b/web/src/mobile/im.test.ts new file mode 100644 index 0000000..274d95d --- /dev/null +++ b/web/src/mobile/im.test.ts @@ -0,0 +1,268 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ instances: [] as any[] })); + +vi.mock('tinode-sdk', () => { + class QueryBuilder { + private kind = ''; + private since?: number; + private limit?: number; + + constructor(private topic?: { maxMsgSeq?: () => number }) {} + + withData(_since?: number, _before?: number, limit?: number) { + this.kind = 'initial'; + this.limit = limit; + return this; + } + + withLaterData(limit?: number) { + this.kind = 'later'; + this.since = (this.topic?.maxMsgSeq?.() || 0) + 1; + this.limit = limit; + return this; + } + + build() { + return { kind: this.kind, since: this.since, limit: this.limit }; + } + } + + class MockTinode { + onConnect?: () => Promise | void; + onDisconnect?: () => void; + onAutoreconnectIteration?: (timeoutMs: number) => void; + _connection = { sendText: vi.fn() }; + connect = vi.fn(async () => undefined); + loginToken = vi.fn(async () => ({ code: 200 })); + disconnect = vi.fn(); + authorizeURL = vi.fn((value: string) => value); + topic = { + onData: undefined as (() => void) | undefined, + onAllMessagesReceived: undefined as ((count: number) => void) | undefined, + maxSeq: 0, + maxMsgSeq: vi.fn(() => this.topic.maxSeq), + startMetaQuery: () => new QueryBuilder(this.topic), + subscribe: vi.fn(async () => { + setTimeout(() => this.topic.onAllMessagesReceived?.(0), 0); + return { code: 200, params: { what: 'data', count: 0 } }; + }), + getMeta: vi.fn(async () => ({ code: 200, params: { what: 'data', count: 0 } })), + leave: vi.fn(), + messages: vi.fn(), + createMessage: vi.fn(), + publishDraft: vi.fn(), + }; + + constructor(public options: Record) { + mocks.instances.push(this); + } + + getTopic() { + return this.topic; + } + } + + return { + Tinode: MockTinode, + Drafty: { appendImage: vi.fn(), insertVideo: vi.fn() }, + }; +}); + +import { + CustomerServiceChat, + resolveCustomerServiceEndpoints, + type CustomerServiceSession, +} from './im'; + +const session = (overrides: Partial = {}): CustomerServiceSession => ({ + tenant_code: 'default', + api_base_url: '/v1/im', + file_url: '/v0/file', + websocket_url: '/v0/channels', + api_key: 'public-key', + token: 'token-1', + current_user_id: 'usr_member', + topic: 'p2p-topic', + ...overrides, +}); + +describe('customer service IM endpoints', () => { + it('resolves API, file and websocket paths against the current page origin', () => { + expect(resolveCustomerServiceEndpoints(session(), 'https://www.yuexuan2.shop')).toEqual({ + apiBaseURL: 'https://www.yuexuan2.shop/v1/im', + fileURL: 'https://www.yuexuan2.shop/v0/file', + websocketURL: 'wss://www.yuexuan2.shop/v0/channels', + }); + }); + + it('does not follow legacy absolute IM hosts from a cached session response', () => { + expect( + resolveCustomerServiceEndpoints( + session({ + api_base_url: 'https://im-api.yuexuan.shop/v1/im', + file_url: 'https://im-api.yuexuan.shop/v0/file', + websocket_url: 'wss://im-api.yuexuan.shop/v0/channels', + }), + 'https://www.yuexuan3.shop', + ), + ).toEqual({ + apiBaseURL: 'https://www.yuexuan3.shop/v1/im', + fileURL: 'https://www.yuexuan3.shop/v0/file', + websocketURL: 'wss://www.yuexuan3.shop/v0/channels', + }); + }); +}); + +describe('customer service reconnect', () => { + beforeEach(() => { + mocks.instances.length = 0; + }); + + it('reauthenticates and resubscribes with later data after reconnect', async () => { + const states: string[] = []; + const chat = new CustomerServiceChat(session(), vi.fn(), { + origin: 'https://www.yuexuan1.shop', + onConnectionState: (state) => states.push(state), + }); + const tinode = mocks.instances[0]; + + await chat.connect('token-1'); + tinode.onDisconnect?.(); + await tinode.onConnect?.(); + + expect(tinode.loginToken).toHaveBeenCalledTimes(2); + expect(tinode.topic.subscribe).toHaveBeenCalledTimes(2); + expect(tinode.topic.subscribe.mock.calls[0][0]).toEqual({ kind: 'initial', limit: 50, since: undefined }); + expect(tinode.topic.subscribe.mock.calls[1][0]).toEqual({ kind: 'later', limit: 50, since: 1 }); + expect(states).toEqual(expect.arrayContaining(['connecting', 'online', 'reconnecting'])); + expect(states.at(-1)).toBe('online'); + }); + + it('does not restore a deliberately disconnected chat', async () => { + const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://olons.yuexuan.shop' }); + const tinode = mocks.instances[0]; + + await chat.connect('token-1'); + chat.disconnect(); + await tinode.onConnect?.(); + + expect(tinode.loginToken).toHaveBeenCalledTimes(1); + expect(tinode.topic.subscribe).toHaveBeenCalledTimes(1); + }); + + it('reports offline without leaking a rejected reconnect callback', async () => { + const states: string[] = []; + const chat = new CustomerServiceChat(session(), vi.fn(), { + origin: 'https://www.yuexuan3.shop', + onConnectionState: (state) => states.push(state), + }); + const tinode = mocks.instances[0]; + + await chat.connect('token-1'); + tinode.loginToken.mockRejectedValueOnce(new Error('network unavailable')); + tinode.onDisconnect?.(); + + await expect(tinode.onConnect?.()).resolves.toBeUndefined(); + expect(states.at(-1)).toBe('offline'); + }); + + it('uses rotated login tokens for reconnect and media upload', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ ctrl: { params: { url: '/v0/file/s/file-1' } } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' }); + const tinode = mocks.instances[0]; + tinode.loginToken + .mockResolvedValueOnce({ code: 200, params: { token: 'token-2' } }) + .mockResolvedValueOnce({ code: 200, params: { token: 'token-3' } }); + + await chat.connect('token-1'); + await tinode.onConnect?.(); + await (chat as any).upload(new File(['image'], 'test.png', { type: 'image/png' })); + + expect(tinode.loginToken.mock.calls.map((call: unknown[]) => call[0])).toEqual(['token-1', 'token-2']); + expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ Authorization: 'Token token-3' }); + fetchMock.mockRestore(); + }); + + it('paginates all messages accumulated while disconnected', async () => { + const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' }); + const tinode = mocks.instances[0]; + await chat.connect('token-1'); + + tinode.topic.subscribe.mockImplementationOnce(async () => { + setTimeout(() => { + tinode.topic.maxSeq = 50; + tinode.topic.onAllMessagesReceived?.(50); + }, 10); + return { code: 200, params: { what: 'data', count: 50 } }; + }); + tinode.topic.getMeta + .mockImplementationOnce(async () => { + setTimeout(() => { + tinode.topic.maxSeq = 100; + tinode.topic.onAllMessagesReceived?.(50); + }, 10); + return { code: 200, params: { what: 'data', count: 50 } }; + }) + .mockImplementationOnce(async () => { + setTimeout(() => { + tinode.topic.maxSeq = 120; + tinode.topic.onAllMessagesReceived?.(20); + }, 10); + return { code: 200, params: { what: 'data', count: 20 } }; + }); + + await tinode.onConnect?.(); + + expect(tinode.topic.getMeta).toHaveBeenCalledTimes(2); + expect(tinode.topic.getMeta.mock.calls.map((call: unknown[]) => call[0])).toEqual([ + { kind: 'later', limit: 50, since: 51 }, + { kind: 'later', limit: 50, since: 101 }, + ]); + }); +}); + +describe('customer service media upload', () => { + beforeEach(() => { + mocks.instances.length = 0; + }); + + it('keeps credentials in headers and out of the upload URL', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ ctrl: { params: { url: '/v0/file/s/file-1' } } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' }); + + await (chat as any).upload(new File(['image'], 'test.png', { type: 'image/png' })); + + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('https://www.yuexuan1.shop/v0/file/u/'); + expect(String(url)).not.toContain('secret='); + expect(init?.headers).toEqual({ + 'X-MeetingIM-APIKey': 'public-key', + Authorization: 'Token token-1', + }); + fetchMock.mockRestore(); + }); + + it('rewrites historical absolute IM file references to the current frontend origin', () => { + const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan3.shop' }); + const tinode = mocks.instances[0]; + tinode.authorizeURL.mockImplementation((value: string) => `${value}?auth=token-1`); + + expect((chat as any).mediaURL('https://im-api.yuexuan.shop/v0/file/s/legacy-image')).toBe( + 'https://www.yuexuan3.shop/v0/file/s/legacy-image?auth=token-1', + ); + expect((chat as any).mediaURL('https://cdn.example.com/public/image.png')).toBe( + 'https://cdn.example.com/public/image.png', + ); + }); +}); diff --git a/web/src/mobile/im.ts b/web/src/mobile/im.ts index ab5ac79..def8cb6 100644 --- a/web/src/mobile/im.ts +++ b/web/src/mobile/im.ts @@ -25,18 +25,57 @@ export interface ChatMessage { file_name?: string; } +export type CustomerServiceConnectionState = 'connecting' | 'online' | 'reconnecting' | 'offline'; + +interface CustomerServiceChatOptions { + origin?: string; + onConnectionState?: (state: CustomerServiceConnectionState) => void; +} + +export function resolveCustomerServiceEndpoints(session: CustomerServiceSession, origin: string) { + const pageOrigin = new URL(origin); + const sameOriginURL = (value: string, fallback: string) => { + const configured = new URL(value || fallback, pageOrigin); + const resolved = new URL(`${configured.pathname}${configured.search}`, pageOrigin); + return resolved.toString().replace(/\/$/, ''); + }; + const websocket = new URL(sameOriginURL(session.websocket_url, '/v0/channels')); + websocket.protocol = pageOrigin.protocol === 'https:' ? 'wss:' : 'ws:'; + return { + apiBaseURL: sameOriginURL(session.api_base_url, '/v1/im'), + fileURL: sameOriginURL(session.file_url, '/v0/file'), + websocketURL: websocket.toString().replace(/\/$/, ''), + }; +} + export class CustomerServiceChat { + private static readonly reconnectPageSize = 50; + private static readonly maxReconnectPages = 100; private tinode: any; private topic: any; private currentUserID: string; private onMessages: (messages: ChatMessage[]) => void; private session: CustomerServiceSession; + private endpoints: ReturnType; + private token = ''; + private sessionActive = false; + private intentionalDisconnect = false; + private recovery: Promise | null = null; + private onConnectionState: (state: CustomerServiceConnectionState) => void; - constructor(session: CustomerServiceSession, onMessages: (messages: ChatMessage[]) => void) { - const endpoint = new URL(session.websocket_url); + constructor( + session: CustomerServiceSession, + onMessages: (messages: ChatMessage[]) => void, + options: CustomerServiceChatOptions = {}, + ) { + const origin = options.origin || window.location.origin; + this.endpoints = resolveCustomerServiceEndpoints(session, origin); + const endpoint = new URL(this.endpoints.websocketURL); this.session = session; + this.token = session.token; this.currentUserID = session.current_user_id; this.onMessages = onMessages; + this.onConnectionState = options.onConnectionState || (() => undefined); this.tinode = new Tinode({ appName: '悦选', host: endpoint.host, @@ -48,15 +87,35 @@ export class CustomerServiceChat { this.installTenantPatch(session.tenant_code); this.topic = this.tinode.getTopic(session.topic); this.topic.onData = () => this.refreshMessages(); + this.tinode.onDisconnect = () => { + if (!this.intentionalDisconnect && this.sessionActive) { + this.onConnectionState('reconnecting'); + } + }; + this.tinode.onAutoreconnectIteration = (timeoutMs: number) => { + if (!this.intentionalDisconnect && this.sessionActive) { + this.onConnectionState(timeoutMs >= 0 ? 'reconnecting' : 'offline'); + } + }; + this.tinode.onConnect = () => { + if (!this.intentionalDisconnect && this.sessionActive) { + return this.restoreAfterReconnect(); + } + }; } async connect(token: string) { + this.intentionalDisconnect = false; + this.token = token; + this.onConnectionState('connecting'); await this.tinode.connect(); - const login = await this.tinode.loginToken(token); + const login = await this.tinode.loginToken(this.token); if (login?.code >= 300) throw new Error('客服会话登录失败'); + this.acceptRotatedToken(login); const query = this.topic.startMetaQuery().withData(undefined, undefined, 50).build(); - const subscribed = await this.topic.subscribe(query); - if (subscribed?.code >= 300) throw new Error('客服会话订阅失败'); + await this.requestMessagePage(() => this.topic.subscribe(query), '客服会话订阅失败'); + this.sessionActive = true; + this.onConnectionState('online'); this.refreshMessages(); } @@ -101,6 +160,9 @@ export class CustomerServiceChat { } disconnect() { + this.intentionalDisconnect = true; + this.sessionActive = false; + this.onConnectionState('offline'); try { this.topic?.leave?.(false); } finally { @@ -108,6 +170,85 @@ export class CustomerServiceChat { } } + private restoreAfterReconnect() { + if (this.recovery) return this.recovery; + this.recovery = (async () => { + this.onConnectionState('reconnecting'); + const login = await this.tinode.loginToken(this.token); + if (login?.code >= 300) throw new Error('客服会话重新登录失败'); + this.acceptRotatedToken(login); + await this.restoreSubscriptionAndMessages(); + this.refreshMessages(); + this.onConnectionState('online'); + })() + .catch(() => { + this.onConnectionState('offline'); + }) + .finally(() => { + this.recovery = null; + }); + return this.recovery; + } + + private acceptRotatedToken(login: any) { + const rotated = String(login?.params?.token || ''); + if (!rotated) return; + this.token = rotated; + this.session.token = rotated; + } + + private async restoreSubscriptionAndMessages() { + const pageSize = CustomerServiceChat.reconnectPageSize; + const firstQuery = this.topic.startMetaQuery().withLaterData(pageSize).build(); + let count = await this.requestMessagePage(() => this.topic.subscribe(firstQuery), '客服会话重新订阅失败'); + + for (let page = 1; count >= pageSize; page += 1) { + if (page >= CustomerServiceChat.maxReconnectPages) { + throw new Error('客服消息积压过多,请重新进入客服会话'); + } + const previousMax = Number(this.topic.maxMsgSeq?.() || 0); + const query = this.topic.startMetaQuery().withLaterData(pageSize).build(); + count = await this.requestMessagePage(() => this.topic.getMeta(query), '客服历史消息补拉失败'); + const currentMax = Number(this.topic.maxMsgSeq?.() || 0); + if (count > 0 && currentMax <= previousMax) { + throw new Error('客服历史消息补拉未推进'); + } + } + } + + private async requestMessagePage(request: () => Promise, errorMessage: string) { + let observedCount: number | undefined; + let resolveObserved: ((count: number) => void) | undefined; + const observed = new Promise((resolve) => { + resolveObserved = resolve; + }); + const previousHandler = this.topic.onAllMessagesReceived; + this.topic.onAllMessagesReceived = (count: number) => { + previousHandler?.(count); + observedCount = Number(count || 0); + resolveObserved?.(observedCount); + }; + + let timeout: ReturnType | undefined; + try { + const response = await request(); + if (response?.code >= 300) throw new Error(errorMessage); + if (observedCount !== undefined) return observedCount; + return await Promise.race([ + observed, + new Promise((_, reject) => { + // A Tinode request promise resolves before the SDK finishes routing + // its data packets. Only this completion callback guarantees that + // maxMsgSeq and the local message cache belong to the finished page. + timeout = setTimeout(() => reject(new Error(errorMessage)), 5_000); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + this.topic.onAllMessagesReceived = previousHandler; + } + } + private installTenantPatch(tenantCode: string) { const connection = this.tinode._connection; if (!connection || connection.__olonsTenantPatched) return; @@ -152,21 +293,24 @@ export class CustomerServiceChat { } private async upload(file: File, onProgress?: (progress: number) => void) { - const uploadURL = new URL(this.session.file_url || '/v0/file', this.session.api_base_url); + const uploadURL = new URL(this.endpoints.fileURL); uploadURL.pathname = `${uploadURL.pathname.replace(/\/$/, '').replace(/\/u$/, '')}/u/`; - uploadURL.searchParams.set('apikey', this.session.api_key); - uploadURL.searchParams.set('auth', 'token'); - uploadURL.searchParams.set('secret', this.session.token); const form = new FormData(); form.set('id', clientMID()); form.set('file', file, file.name); onProgress?.(0.1); let response: Response; try { - // Do not attach XMLHttpRequest upload progress listeners here. For a - // cross-origin IM endpoint they trigger a CORS preflight which the file - // service does not expose, while multipart/form-data itself is accepted. - response = await fetch(uploadURL.toString(), { method: 'POST', body: form }); + // Keep uploads on fetch so API and token credentials stay in headers and + // never leak into the URL, browser history, or proxy access logs. + response = await fetch(uploadURL.toString(), { + method: 'POST', + headers: { + 'X-MeetingIM-APIKey': this.session.api_key, + Authorization: `Token ${this.token}`, + }, + body: form, + }); } catch { throw new Error('媒体上传失败,请检查网络后重试'); } @@ -186,15 +330,15 @@ export class CustomerServiceChat { private mediaURL(ref?: string) { if (!ref) return ''; - const apiBase = new URL(this.session.api_base_url); + const apiBase = new URL(this.endpoints.apiBaseURL); const resolved = new URL(ref, apiBase); - // The SDK signs relative file URLs. Also tolerate historical absolute IM - // references by converting same-origin URLs back to a signable path. - const signable = resolved.origin === apiBase.origin - ? `${resolved.pathname}${resolved.search}` - : ref; + // Historical messages may contain the old absolute IM host. Route only + // known IM file paths through the current frontend origin; leave unrelated + // external media untouched. + if (!resolved.pathname.startsWith('/v0/file/') && resolved.origin !== apiBase.origin) return ref; + const signable = `${resolved.pathname}${resolved.search}`; const authorized = this.tinode.authorizeURL(signable); - return new URL(authorized, this.session.api_base_url).toString(); + return new URL(authorized, this.endpoints.apiBaseURL).toString(); } } diff --git a/web/src/mobile/styles.css b/web/src/mobile/styles.css index e02f496..508a9db 100644 --- a/web/src/mobile/styles.css +++ b/web/src/mobile/styles.css @@ -1493,6 +1493,48 @@ body { padding: 9px 16px; font-size: 13px; } + +.m-chat-connection { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 36px; + padding: 8px 14px; + color: #8a5a00; + background: #fff3cd; + font-size: 13px; +} + +.m-chat-connection.is-offline { + color: #9f2d2d; + background: #fde7e7; +} + +.m-chat-connection button, +.m-chat-media-error button { + border: 0; + padding: 0; + color: currentColor; + background: transparent; + font: inherit; + text-decoration: underline; + cursor: pointer; +} + +.m-chat-media-error { + display: flex; + min-width: 160px; + min-height: 96px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: #8b3a3a; + background: #f8eaea; + border-radius: 12px; + font-size: 13px; +} .m-chat-empty { min-height: 530px; padding: 100px 30px 20px; diff --git a/web/src/mobile/views/SupportView.vue b/web/src/mobile/views/SupportView.vue index 16ae040..bfe399a 100644 --- a/web/src/mobile/views/SupportView.vue +++ b/web/src/mobile/views/SupportView.vue @@ -38,6 +38,10 @@
会话内容已安全加密,请勿发送密码或验证码
+
+ {{ connectionStatusText }} + +

正在连接客服

@@ -59,8 +63,11 @@
- - +
+ 媒体加载失败 +
+ +

{{ message.text }}

@@ -90,8 +97,11 @@
- - +
+ 媒体加载失败 +
+ +

{{ message.text }}

@@ -103,12 +113,12 @@
- +