From f77ab5ae4bce19034f0f6891bd1baab5c750aac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 17 Sep 2026 18:18:06 +0800 Subject: [PATCH] feat(contracts): outbox event contract types Co-Authored-By: Claude Code --- docs/plans/2026-09-17-P1a-后端核心链路.md | 4 +- .../contracts/src/__tests__/events.test.ts | 95 +++++++++++++++++++ packages/contracts/src/events.ts | 41 ++++++++ packages/contracts/src/index.ts | 1 + 4 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 packages/contracts/src/__tests__/events.test.ts create mode 100644 packages/contracts/src/events.ts diff --git a/docs/plans/2026-09-17-P1a-后端核心链路.md b/docs/plans/2026-09-17-P1a-后端核心链路.md index b6142c6..0a26681 100644 --- a/docs/plans/2026-09-17-P1a-后端核心链路.md +++ b/docs/plans/2026-09-17-P1a-后端核心链路.md @@ -264,8 +264,8 @@ Expected: `src/generated/openapi.d.ts` 生成;redocly 0 错误。 - `GET /public/albums/{token}`( translations 按请求 locale 回退、categories、assets、`viewCount: null` 占位、timezone、startAt/endAt ISO) - `GET /public/albums/{token}/photos`(query: category、locale、cursor、limit≤60;item 含 `photoId,width,height,thumb{url,expiresAt},browse{url,expiresAt},likeCount,publishedAt`) - `GET /public/albums/{token}/photos/{photoId}`(单图定位;同时作为签名 URL 过期后的刷新入口) -- [ ] **Step 2: 生成 + lint 通过**(同 Task 4 命令) -- [ ] **Step 3: Commit** `feat(contracts): p1a endpoint contracts` +- [x] **Step 2: 生成 + lint 通过**(同 Task 4 命令)(完成:22 操作;质量审查 3 Important + 7 Minor 修复于 f6448bd/b4ee92d,复核通过) +- [x] **Step 3: Commit** `feat(contracts): p1a endpoint contracts`(完成:1dae00e + f6448bd + b4ee92d) ### Task 6: 事件契约 + 用户确认点 diff --git a/packages/contracts/src/__tests__/events.test.ts b/packages/contracts/src/__tests__/events.test.ts new file mode 100644 index 0000000..b506768 --- /dev/null +++ b/packages/contracts/src/__tests__/events.test.ts @@ -0,0 +1,95 @@ +// packages/contracts/src/__tests__/events.test.ts +// 事件契约测试:合法事件编译通过、非法 type 编译期拒绝、OUTBOX_EVENT_TYPES +// 与接口 union 1:1,以及 openapi Error.code enum 与 errors.ts ErrorCodes 1:1 +// (Task 4 审查遗留的枚举同步守护)。ts-jest 为全类型检查模式,@ts-expect-error +// 与 Equal 断言在编译期生效。 +import { ErrorCodes, type ErrorCode } from '../errors.js'; +import type { components } from '../generated/openapi.js'; +import { + OUTBOX_EVENT_TYPES, + type OutboxEvent, + type PhotoPublishedPayload, +} from '../events.js'; + +// 类型恒等辅助:仅当 X 与 Y 是完全相同的类型(双向兼容,多/少一个成员都为 false)。 +type Equal = + (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 + ? true + : false; + +// openapi.yaml 生成的 Error.code enum(src/generated/openapi.d.ts)。 +type ApiErrorCode = components['schemas']['Error']['code']; + +const publishedEvent: OutboxEvent = { + eventId: 'cxxxxxxxxxxxxxxxxxxxxxx', + type: 'photo.published', + albumId: 'album-1', + photoId: 'photo-1', + entityVersion: 1, + payload: { + photoId: 'photo-1', + albumId: 'album-1', + objectKey: 'albums/album-1/photo-1.webp', + thumbKey: 'thumbs/album-1/photo-1.webp', + browseKey: 'browse/album-1/photo-1.webp', + width: 4032, + height: 3024, + publishedAt: '2026-09-17T08:00:00.000Z', + entityVersion: 1, + }, + occurredAt: '2026-09-17T08:00:00.000Z', +}; + +describe('OutboxEvent', () => { + it('合法的 photo.published 事件通过类型检查且字段完整', () => { + expect(publishedEvent.type).toBe('photo.published'); + expect(publishedEvent.payload).toMatchObject({ + photoId: 'photo-1', + albumId: 'album-1', + width: 4032, + height: 3024, + }); + }); + + it('非法 type 在编译期被拒绝', () => { + // @ts-expect-error 事件类型必须严格属于 §8.2 定义的 5 种之一 + const invalidType: OutboxEvent = { ...publishedEvent, type: 'photo.uploaded' }; + expect(invalidType.type).toBe('photo.uploaded'); + }); +}); + +describe('OUTBOX_EVENT_TYPES', () => { + it('恰好包含全部 5 个事件类型字符串', () => { + expect([...OUTBOX_EVENT_TYPES]).toEqual([ + 'photo.published', + 'photo.hidden', + 'photo.deleted', + 'photo.likes.updated', + 'album.updated', + ]); + }); + + it('数组与接口 union 类型层 1:1(satisfies 管数组⊆union,Equal 管 union⊆数组)', () => { + const arrayEqualsUnion: Equal< + (typeof OUTBOX_EVENT_TYPES)[number], + OutboxEvent['type'] + > = true; + expect(arrayEqualsUnion).toBe(true); + }); +}); + +describe('openapi Error.code enum ↔ errors.ts ErrorCodes 同步', () => { + it('两侧 code 联合类型双向恒等(1:1)', () => { + const apiEqualsErrors: Equal = true; + const errorsEqualsApi: Equal = true; + expect(apiEqualsErrors && errorsEqualsApi).toBe(true); + }); + + it('运行时兜底:ErrorCodes 值唯一且与 key 同名', () => { + const entries = Object.entries(ErrorCodes); + expect(new Set(entries.map(([, code]) => code)).size).toBe(entries.length); + for (const [key, code] of entries) { + expect(key).toBe(code); + } + }); +}); diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts new file mode 100644 index 0000000..befee62 --- /dev/null +++ b/packages/contracts/src/events.ts @@ -0,0 +1,41 @@ +// packages/contracts/src/events.ts +// Outbox 事件契约(docs/03-技术实施方案.md §8.2):P1a 在业务事务内写 outbox, +// P2 海外副本按 eventId 去重、按 entityVersion 单调递增消费。payload 由各事件 +// 类型自行约定,此处仅落 P2 展示一张照片所需的 photo.published 载荷(YAGNI)。 +export interface OutboxEvent { + eventId: string; // cuid,持久唯一 + type: + | 'photo.published' + | 'photo.hidden' + | 'photo.deleted' + | 'photo.likes.updated' + | 'album.updated'; + albumId: string; + photoId?: string; + entityVersion: number; // 单调递增,P2 inbox 消费依据 + payload: T; + occurredAt: string; // ISO UTC +} + +// photo.published 载荷:P2 海外副本展示一张照片所需的全部字段。 +export interface PhotoPublishedPayload { + photoId: string; + albumId: string; + objectKey: string; // 原图对象键 + thumbKey: string; // 缩略图对象键 + browseKey: string; // 浏览图对象键 + width: number; + height: number; + publishedAt: string; // ISO UTC + entityVersion: number; +} + +// 运行时校验用:全部合法事件类型字符串。satisfies 保证 数组 ⊆ 接口 union; +// __tests__/events.test.ts 的 Equal 断言反向锁定 union ⊆ 数组,两侧永远 1:1。 +export const OUTBOX_EVENT_TYPES = [ + 'photo.published', + 'photo.hidden', + 'photo.deleted', + 'photo.likes.updated', + 'album.updated', +] as const satisfies readonly OutboxEvent['type'][]; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index b3832d3..214733c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -3,4 +3,5 @@ // 消费方不跑 gen;本包改动后执行 pnpm gen 并提交生成文件 diff)。 // NodeNext + ESM:相对导入必须带 .js 扩展名;生成文件仅含类型,用 export type * 在运行时擦除。 export * from './errors.js'; +export * from './events.js'; export type * from './generated/openapi.js';