feat(contracts): outbox event contract types

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 18:18:06 +08:00
parent b4ee92de96
commit f77ab5ae4b
4 changed files with 139 additions and 2 deletions
@@ -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: 事件契约 + 用户确认点
@@ -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<X, Y> =
(<T>() => T extends X ? 1 : 2) extends <T>() => 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<PhotoPublishedPayload> = {
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<PhotoPublishedPayload> = { ...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<ApiErrorCode, ErrorCode> = true;
const errorsEqualsApi: Equal<ErrorCode, ApiErrorCode> = 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);
}
});
});
+41
View File
@@ -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<T = unknown> {
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'][];
+1
View File
@@ -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';