@autional/shared 0.1.0-rc.33 → 0.1.0-rc.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autional/shared",
3
- "version": "0.1.0-rc.33",
3
+ "version": "0.1.0-rc.35",
4
4
  "description": "Autional shared layer — API client, auth, types, hooks, utils",
5
5
  "license": "AGPL-3.0",
6
6
  "main": "./src/index.ts",
@@ -5,10 +5,11 @@
5
5
  * (不复制 4 份,违反 DRY)。
6
6
  *
7
7
  * - usePublicTenantSlugs: 调公开 tenants API(无认证端点),staleTime 5m 缓存,
8
- * retry 1,任何失败返回空数组(放行语义由 TenantIndexGuard 处理)。
9
- * - TenantIndexGuard: index 路由守卫(P0-2),防止未知路径被贪婪渲染为
10
- * Dashboard/首页。404 页由各 portal 通过 notFound prop 注入
11
- * (shared 组件不能 import 具体 app 的 not-found/page)。
8
+ * retry 1 + 8s 超时;失败上抛(不吞成空数组成功)——放行语义由消费方
9
+ * (TenantIndexGuard / TenantRootRedirect)在失败态自行 fail-open(rc.35)。
10
+ * - TenantIndexGuard: 租户 slug 白名单守卫(P0-2),防止未知 slug 被贪婪渲染为
11
+ * 租户内容(index 首页与 /:slug/... 子路由通用)。404 页由各 portal 通过
12
+ * notFound prop 注入(shared 组件不能 import 具体 app 的 not-found/page)。
12
13
  *
13
14
  * ⚠️ 依赖约束: shared 包 dependencies 无 @autional/ui,禁止 import @autional/ui
14
15
  * (会引入未声明依赖/循环依赖)。loading 默认值用内联 div 骨架,
@@ -30,11 +31,20 @@ export interface PublicTenantInfo {
30
31
  id?: string;
31
32
  }
32
33
 
34
+ /** 公开租户名单请求超时(ms)。跨境链路(Vercel edge → 源站)实测新连接丢 ~45%,
35
+ * 失败连接 edge 端要 ~31s 才吐 502;正常成功 ≤2s,8s 留 4x 余量,失败快速交给 retry。
36
+ * (rc.35:此前无超时 + catch 吞错 → retry 死码、失败被当"空名单"成功。) */
37
+ export const PUBLIC_TENANTS_FETCH_TIMEOUT_MS = 8000;
38
+
33
39
  /**
34
40
  * 公开租户列表(无认证端点,用于校验 URL slug 有效性)。
35
41
  * 从 end-user-portal App.tsx 原样抽取。
36
42
  * - staleTime 5m(plan §5 资源列:public tenants API 有缓存)
37
- * - retry 1;失败返回 [](白名单空数组放行,避免 API 挂掉时全站 404)
43
+ * - retry 1;非 2xx / 网络错 / 超时一律上抛(不吞成空数组成功)——
44
+ * error 态不落缓存,下次调用自然重试
45
+ * - 消费方 fail-open:名单缺失(error 态 data=undefined 或空数组)→ 不拦截
46
+ * (TenantIndexGuard 渲染 children;TenantRootRedirect 漏斗 brand;
47
+ * resolveSlugAgainstList 信任传入 slug)
38
48
  *
39
49
  * ⚠️ 响应契约:tenant-service ListPublicTenants 返回 dto_base.ListResponse
40
50
  * (`{code, message, items, total, pagination, timestamp}`,items 才是数组)。
@@ -44,14 +54,18 @@ export function usePublicTenantSlugs() {
44
54
  return useQuery<Array<PublicTenantInfo>>({
45
55
  queryKey: ['public-tenants'],
46
56
  queryFn: async () => {
57
+ const controller = new AbortController();
58
+ const timer = setTimeout(() => controller.abort(), PUBLIC_TENANTS_FETCH_TIMEOUT_MS);
47
59
  try {
48
- const res = await fetch('/bff/tenant/api/v1/tenant/public/tenants');
49
- if (!res.ok) return [];
60
+ const res = await fetch('/bff/tenant/api/v1/tenant/public/tenants', {
61
+ signal: controller.signal,
62
+ });
63
+ if (!res.ok) throw new Error(`public tenants API HTTP ${res.status}`);
50
64
  const json = await res.json();
51
65
  const list = json?.items ?? json?.data ?? json ?? [];
52
66
  return Array.isArray(list) ? list : [];
53
- } catch {
54
- return [];
67
+ } finally {
68
+ clearTimeout(timer);
55
69
  }
56
70
  },
57
71
  staleTime: 5 * 60 * 1000,
@@ -105,22 +119,19 @@ function DefaultLoadingSkeleton() {
105
119
  }
106
120
 
107
121
  /**
108
- * index 路由守卫(P0-2):防止未知路径被贪婪渲染为 Dashboard/首页。
109
- * 逻辑与 end-user-portal 现状一致(L94-111):
110
- * - URL 多段(如 /acme-corp/xyz):basename 已剥离 slug,内部单段必为未知路由段 → notFound
111
- * - URL 单段(如 /not-found):校验 slug 是否在公开租户列表 → 无效 → notFound
112
- * - URL 单段且 slug 有效(如 /acme-corp)→ children
122
+ * 租户 slug 白名单守卫(P0-2):防止未知 slug 被贪婪渲染为租户内容。
123
+ * - URL 首段经 extractSlugFromPath 取为 slug(保留段/已注册业务段返回 undefined,放行)。
124
+ * - slug 命中公开租户列表 → children;未命中 → notFound(/nonexistent-tenant[/...] 均 404)。
125
+ * - 只校验 slug 一段,不看路径深度 —— 子路径合法性(/acme-corp/terms 等)由路由表决定。
126
+ * 2026-10-09 移除「URL 段数 > 1 → notFound」检查:它出自已废弃的动态 basename 方案
127
+ * (那时 basename 剥掉 slug,内部多段 = 未知路由);basename 改为 '/' 后它对 index
128
+ * 路由从未生效,却在包裹子路由处误杀 —— auth 站 /:tenantSlug/terms|privacy 曾因此恒 404。
113
129
  * - 白名单为空数组时放行(public tenants API 挂掉时退化为不拦截,与 end-user 现状一致)
114
130
  */
115
131
  export function TenantIndexGuard({ children, notFound, loading }: TenantIndexGuardProps) {
116
132
  const { data: tenants, isLoading } = usePublicTenantSlugs();
117
133
  const slug = useTenantSlugFromUrl();
118
134
 
119
- if (typeof window !== 'undefined') {
120
- const fullSegs = window.location.pathname.split('/').filter(Boolean);
121
- if (fullSegs.length > 1) return <>{notFound}</>;
122
- }
123
-
124
135
  if (isLoading && !tenants) return <>{loading ?? <DefaultLoadingSkeleton />}</>;
125
136
 
126
137
  if (slug && tenants && tenants.length > 0 && !tenants.some((t) => (t.name || t.slug) === slug)) {
@@ -18,7 +18,11 @@ import { usePublicTenantSlugs } from './TenantIndexGuard';
18
18
  */
19
19
  export function TenantRootRedirect() {
20
20
  const currentTenantId = useCurrentTenantId();
21
- const { data: knownTenants, isSuccess: tenantsLoaded } = usePublicTenantSlugs();
21
+ const {
22
+ data: knownTenants,
23
+ isSuccess: tenantsLoaded,
24
+ isError: tenantsError,
25
+ } = usePublicTenantSlugs();
22
26
 
23
27
  const slug = useMemo(() => {
24
28
  if (!currentTenantId || !tenantsLoaded) return undefined;
@@ -35,7 +39,8 @@ export function TenantRootRedirect() {
35
39
  }
36
40
  // 有会话但公开名单尚未返回:等待(名单是 slug 唯一权威,不得抢跑漏斗 brand)。
37
41
  // 无会话(currentTenantId 空)不受此闸,立即漏斗。
38
- if (currentTenantId && !tenantsLoaded) return;
42
+ // 名单接口彻底失败(rc.35:重试耗尽后 error 态)不等待:按无匹配处理漏斗 brand,不卡死。
43
+ if (currentTenantId && !tenantsLoaded && !tenantsError) return;
39
44
  const brand = getPortalUrl('brand');
40
45
  // 门户不可派生(如经 IP 主机访问):保持当前页,不导航(与 EntryRouter 同约定)
41
46
  if (!brand) return;
@@ -43,7 +48,7 @@ export function TenantRootRedirect() {
43
48
  window.location.origin + window.location.pathname,
44
49
  )}`;
45
50
  traceRedirect(target, { reason: 'funnel-brand' });
46
- }, [slug, currentTenantId, tenantsLoaded]);
51
+ }, [slug, currentTenantId, tenantsLoaded, tenantsError]);
47
52
 
48
53
  return null;
49
54
  }
@@ -9,7 +9,9 @@ import { TenantIndexGuard, usePublicTenantSlugs } from '../TenantIndexGuard';
9
9
  function createQueryClient() {
10
10
  return new QueryClient({
11
11
  defaultOptions: {
12
- queries: { retry: false, gcTime: Infinity },
12
+ // retryDelay 0:hook 内部 retry:1 覆盖 retry,但重试退避仍走 defaultOptions,
13
+ // 不设 0 时失败用例要白等默认 1s 退避
14
+ queries: { retry: false, retryDelay: 0, gcTime: Infinity },
13
15
  },
14
16
  });
15
17
  }
@@ -106,20 +108,34 @@ describe('usePublicTenantSlugs(响应契约解析)', () => {
106
108
  await waitFor(() => expect(result).toEqual([{ id: '1', name: 'acme-corp' }]));
107
109
  });
108
110
 
109
- it('API 非 200 → 返回空数组(放行语义)', async () => {
111
+ it('API 非 200 → 上抛进入 error 态,不吞成空数组成功(rc.35)', async () => {
110
112
  stubFetchNonOk();
111
- let result: unknown[] | undefined;
113
+ let state: { isError: boolean; data: unknown[] | undefined } | undefined;
112
114
  function Harness() {
113
115
  const q = usePublicTenantSlugs();
114
- result = q.data;
116
+ state = { isError: q.isError, data: q.data };
115
117
  return null;
116
118
  }
117
119
  render(wrapper(<Harness />));
118
- await waitFor(() => expect(result).toEqual([]));
120
+ await waitFor(() => expect(state?.isError).toBe(true));
121
+ expect(state?.data).toBeUndefined();
119
122
  });
120
123
 
121
- it('网络异常 → 返回空数组', async () => {
124
+ it('网络异常 → 上抛进入 error 态(retry 不再被吞错架空,rc.35)', async () => {
122
125
  stubFetchReject();
126
+ let state: { isError: boolean; data: unknown[] | undefined } | undefined;
127
+ function Harness() {
128
+ const q = usePublicTenantSlugs();
129
+ state = { isError: q.isError, data: q.data };
130
+ return null;
131
+ }
132
+ render(wrapper(<Harness />));
133
+ await waitFor(() => expect(state?.isError).toBe(true));
134
+ expect(state?.data).toBeUndefined();
135
+ });
136
+
137
+ it('fetch 携带 AbortSignal(8s 超时接线,rc.35)', async () => {
138
+ stubFetch([{ id: '1', name: 'acme-corp' }]);
123
139
  let result: unknown[] | undefined;
124
140
  function Harness() {
125
141
  const q = usePublicTenantSlugs();
@@ -127,7 +143,39 @@ describe('usePublicTenantSlugs(响应契约解析)', () => {
127
143
  return null;
128
144
  }
129
145
  render(wrapper(<Harness />));
130
- await waitFor(() => expect(result).toEqual([]));
146
+ await waitFor(() => expect(result).toEqual([{ id: '1', name: 'acme-corp' }]));
147
+ const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>;
148
+ const init = fetchMock.mock.calls[0][1] as RequestInit;
149
+ expect(init.signal).toBeInstanceOf(AbortSignal);
150
+ });
151
+
152
+ it('超时 abort(AbortError)→ 上抛进入 error 态,不吞(rc.35)', async () => {
153
+ const signals: AbortSignal[] = [];
154
+ vi.stubGlobal(
155
+ 'fetch',
156
+ vi.fn((_url: string, init?: RequestInit) => {
157
+ return new Promise((_resolve, reject) => {
158
+ signals.push(init!.signal!);
159
+ init!.signal!.addEventListener('abort', () =>
160
+ reject(new DOMException('The operation was aborted.', 'AbortError')),
161
+ );
162
+ });
163
+ }),
164
+ );
165
+ let isError = false;
166
+ function Harness() {
167
+ const q = usePublicTenantSlugs();
168
+ isError = q.isError;
169
+ return null;
170
+ }
171
+ render(wrapper(<Harness />));
172
+ // 模拟计时器到点 abort(计时器-到点→abort 是标准库行为,此处验 abort 错误的上抛路径);
173
+ // hook retry:1 → 两跳都要 abort 才耗尽重试
174
+ await waitFor(() => expect(signals.length).toBeGreaterThan(0));
175
+ signals[0].dispatchEvent(new Event('abort'));
176
+ await waitFor(() => expect(signals.length).toBeGreaterThan(1));
177
+ signals[1].dispatchEvent(new Event('abort'));
178
+ await waitFor(() => expect(isError).toBe(true));
131
179
  });
132
180
 
133
181
  it('响应无 items/data 且非数组 → 返回空数组', async () => {
@@ -178,10 +226,27 @@ describe('TenantIndexGuard(P0-2 slug 白名单守卫)', () => {
178
226
  await waitFor(() => expect(screen.getByTestId('children')).toBeInTheDocument());
179
227
  });
180
228
 
181
- it('URL 多段路径 → 直接 notFound(basename 已剥离 slug,内部必为未知段)', () => {
182
- setPath('/acme-corp/xyz');
229
+ it('名单接口失败(重试耗尽 error)→ fail-open 渲染 children,不白屏不 404(rc.35)', async () => {
230
+ stubFetchReject();
231
+ setPath('/any-slug');
232
+ render(wrapper(<TenantIndexGuard notFound={notFound}>{children}</TenantIndexGuard>));
233
+ await waitFor(() => expect(screen.getByTestId('children')).toBeInTheDocument());
234
+ expect(screen.queryByTestId('notfound')).toBeNull();
235
+ });
236
+
237
+ it('slug 有效 + 子路径(/acme-corp/terms)→ 渲染 children(回归:子路由此前被段数检查误杀)', async () => {
238
+ stubFetch([{ id: '1', name: 'acme-corp' }]);
239
+ setPath('/acme-corp/terms');
183
240
  render(wrapper(<TenantIndexGuard notFound={notFound}>{children}</TenantIndexGuard>));
184
- expect(screen.getByTestId('notfound')).toBeInTheDocument();
241
+ await waitFor(() => expect(screen.getByTestId('children')).toBeInTheDocument());
242
+ expect(screen.queryByTestId('notfound')).toBeNull();
243
+ });
244
+
245
+ it('slug 无效 + 子路径(/nonexistent-tenant/terms)→ 渲染 notFound', async () => {
246
+ stubFetch([{ id: '1', name: 'acme-corp' }]);
247
+ setPath('/nonexistent-tenant/terms');
248
+ render(wrapper(<TenantIndexGuard notFound={notFound}>{children}</TenantIndexGuard>));
249
+ await waitFor(() => expect(screen.getByTestId('notfound')).toBeInTheDocument());
185
250
  expect(screen.queryByTestId('children')).toBeNull();
186
251
  });
187
252
 
@@ -34,7 +34,12 @@ import { getPortalUrl } from '../../config';
34
34
  function wrapper(children: ReactNode) {
35
35
  return (
36
36
  <QueryClientProvider
37
- client={new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } })}
37
+ client={
38
+ new QueryClient({
39
+ // retryDelay 0:hook 内部 retry:1 覆盖 retry,重试退避仍走 defaultOptions
40
+ defaultOptions: { queries: { retry: false, retryDelay: 0, gcTime: Infinity } },
41
+ })
42
+ }
38
43
  >
39
44
  {children}
40
45
  </QueryClientProvider>
@@ -154,7 +159,7 @@ describe('TenantRootRedirect 裸根漏斗', () => {
154
159
  expect(withoutRt(String(mockState.replace.mock.calls[0][0]))).toBe(withoutRt(expectedBrandFunnel()));
155
160
  });
156
161
 
157
- it('名单接口失败(返回空数组)→ 有会话按无匹配处理,漏斗 brand(不卡死)', async () => {
162
+ it('名单接口失败(error 态,rc.35 上抛)→ 有会话按无匹配处理,漏斗 brand(不卡死)', async () => {
158
163
  mockState.currentTenantId = 't1';
159
164
  vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
160
165