feat(contracts): outbox event contract types
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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}`( 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`(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 过期后的刷新入口)
|
- `GET /public/albums/{token}/photos/{photoId}`(单图定位;同时作为签名 URL 过期后的刷新入口)
|
||||||
- [ ] **Step 2: 生成 + lint 通过**(同 Task 4 命令)
|
- [x] **Step 2: 生成 + lint 通过**(同 Task 4 命令)(完成:22 操作;质量审查 3 Important + 7 Minor 修复于 f6448bd/b4ee92d,复核通过)
|
||||||
- [ ] **Step 3: Commit** `feat(contracts): p1a endpoint contracts`
|
- [x] **Step 3: Commit** `feat(contracts): p1a endpoint contracts`(完成:1dae00e + f6448bd + b4ee92d)
|
||||||
|
|
||||||
### Task 6: 事件契约 + 用户确认点
|
### 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'][];
|
||||||
@@ -3,4 +3,5 @@
|
|||||||
// 消费方不跑 gen;本包改动后执行 pnpm gen 并提交生成文件 diff)。
|
// 消费方不跑 gen;本包改动后执行 pnpm gen 并提交生成文件 diff)。
|
||||||
// NodeNext + ESM:相对导入必须带 .js 扩展名;生成文件仅含类型,用 export type * 在运行时擦除。
|
// NodeNext + ESM:相对导入必须带 .js 扩展名;生成文件仅含类型,用 export type * 在运行时擦除。
|
||||||
export * from './errors.js';
|
export * from './errors.js';
|
||||||
|
export * from './events.js';
|
||||||
export type * from './generated/openapi.js';
|
export type * from './generated/openapi.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user