diff --git a/apps/api/src/app/__tests__/route-registry.test.ts b/apps/api/src/app/__tests__/route-registry.test.ts index edfb790..3c43f7f 100644 --- a/apps/api/src/app/__tests__/route-registry.test.ts +++ b/apps/api/src/app/__tests__/route-registry.test.ts @@ -39,12 +39,32 @@ describe('路由注册表', () => { it('数组归属原样回', () => { expect(registry.lookup('GET', '/api/gov/staff')).toEqual(['design', 'ops']); }); - it('has 按注册时的模版查', () => { + it('has 认的是注册时的模版,lookup 认的是实际路径', () => { expect(registry.has('POST', '/api/gov/tasks/:taskId/control')).toBe(true); expect(registry.has('GET', '/api/gov/tasks/:taskId/control')).toBe(false); expect(registry.has('GET', '/api/health')).toBe(true); expect(registry.size).toBe(9); }); + it('参数名换一个:lookup 还是能匹配(参数段认任意值),has 就说没有', () => { + // 注册的模版是 `:taskId`,lookup('/api/gov/tasks/:otherName/control') 的 + // `:otherName` 满足参数段的 [^/]+ 正则,lookup 会命中; + // 但 has 只认注册时的模版名,两者语义不同 + expect(registry.lookup('POST', '/api/gov/tasks/:otherName/control')).toBe('ops'); + expect(registry.has('POST', '/api/gov/tasks/:otherName/control')).toBe(false); + }); +}); + +describe('字面优先于参数段 —— 同一个路径两边都能匹配时', () => { + const collide = buildRouteRegistry([ + { method: 'GET', path: '/api/gov/:zone', access: 'design' }, + { method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] }, + ]); + it('字面赢', () => { + expect(collide.lookup('GET', '/api/gov/staff')).toEqual(['design', 'ops']); + }); + it('没有字面可命中时才落到参数段', () => { + expect(collide.lookup('GET', '/api/gov/tools')).toBe('design'); + }); }); describe('routesOf —— 从契约命名空间挑路由', () => { @@ -61,3 +81,50 @@ describe('routesOf —— 从契约命名空间挑路由', () => { for (const r of all) expect(reg.has(r.method, r.path), `${r.method} ${r.path}`).toBe(true); }); }); + +describe('建表自检 —— 两条模版能撞就不许建', () => { + const reg = (routes: Parameters[0]) => () => buildRouteRegistry(routes); + it('同方法、同段数、逐位兼容 → 抛错', () => { + expect( + reg([ + { method: 'GET', path: '/api/a/:x', access: 'ops' }, + { method: 'GET', path: '/api/:y/b', access: 'design' }, + ]), + ).toThrow(/同一个具体路径/); + }); + it('方法不同不算撞', () => { + expect( + reg([ + { method: 'GET', path: '/api/a/:x', access: 'ops' }, + { method: 'POST', path: '/api/:y/b', access: 'design' }, + ]), + ).not.toThrow(); + }); + it('段数不同不算撞', () => { + expect( + reg([ + { method: 'GET', path: '/api/a/:x', access: 'ops' }, + { method: 'GET', path: '/api/:y/b/c', access: 'design' }, + ]), + ).not.toThrow(); + }); + it('字面段对不上不算撞', () => { + expect( + reg([ + { method: 'GET', path: '/api/a/:x', access: 'ops' }, + { method: 'GET', path: '/api/b/:y', access: 'design' }, + ]), + ).not.toThrow(); + }); + it('字面与参数并存不算撞 —— 那是有意的优先级', () => { + expect( + reg([ + { method: 'GET', path: '/api/gov/:zone', access: 'design' }, + { method: 'GET', path: '/api/gov/staff', access: 'ops' }, + ]), + ).not.toThrow(); + }); + it('真实 95 条契约路由建得起来(没有暗撞)', () => { + expect(() => buildRouteRegistry(routesOf(Object.values(contracts)))).not.toThrow(); + }); +}); diff --git a/apps/api/src/app/route-registry.ts b/apps/api/src/app/route-registry.ts index 9f333f2..625d772 100644 --- a/apps/api/src/app/route-registry.ts +++ b/apps/api/src/app/route-registry.ts @@ -53,6 +53,21 @@ function matcherOf(pattern: string): RegExp { return new RegExp(`^${body}$`); } +/** + * 两条模版会不会匹配到同一个具体路径:同段数,且每一位都兼容 + * (参数段与任何段兼容;字面段只与相同的字面兼容)。 + */ +function canCollide(a: string, b: string): boolean { + const sa = a.split('/'); + const sb = b.split('/'); + if (sa.length !== sb.length) return false; + return sa.every((seg, i) => { + const other = sb[i] ?? ''; + if (seg.startsWith(':') || other.startsWith(':')) return true; + return seg === other; + }); +} + export function buildRouteRegistry(routes: readonly RegisteredRoute[]): RouteRegistry { const literal = new Map(); const patterns: Array<{ method: string; pattern: string; re: RegExp; access: RouteAccess }> = []; @@ -60,6 +75,26 @@ export function buildRouteRegistry(routes: readonly RegisteredRoute[]): RouteReg if (r.path.includes(':')) patterns.push({ method: r.method, pattern: r.path, re: matcherOf(r.path), access: r.access }); else literal.set(`${r.method} ${r.path}`, r.access); } + + /* + * 两条模版能匹配到同一个具体路径 = 查它时命中谁全看声明顺序 —— 那是**静默判错权限**, + * 比启动失败严重得多。契约是我们自己写的,撞了就是写错了:开发期就掀桌子。 + * 只检模版之间;字面与参数并存是**有意的**优先级(字面赢),不算冲突。 + */ + for (let i = 0; i < patterns.length; i += 1) { + for (let j = i + 1; j < patterns.length; j += 1) { + const a = patterns[i]; + const b = patterns[j]; + if (a === undefined || b === undefined) continue; + if (a.method === b.method && canCollide(a.pattern, b.pattern)) { + throw new Error( + `路由归属表冲突:${a.method} ${a.pattern} 与 ${a.method} ${b.pattern} 会匹配到同一个具体路径 —— ` + + '命中谁只看声明顺序,等于静默判错权限。改掉其中一条的路径。', + ); + } + } + } + return { size: routes.length, lookup(method, path) {