feat(contracts): openapi base, error codes, type generation
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
/** @type {import('jest').Config} */
|
||||||
|
module.exports = {
|
||||||
|
testEnvironment: 'node',
|
||||||
|
// 包为 ESM("type": "module"),Jest 以 CJS 运行测试:
|
||||||
|
// 用内联 tsconfig 覆盖 module/moduleResolution(NodeNext 会按包类型产出 ESM)。
|
||||||
|
// Task 6 的类型测试沿用此配置。
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
tsconfig: {
|
||||||
|
module: 'commonjs',
|
||||||
|
moduleResolution: 'node10',
|
||||||
|
target: 'es2022',
|
||||||
|
strict: true,
|
||||||
|
esModuleInterop: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// TS 源码使用 NodeNext 风格的 .js 扩展导入,运行时映射回 .ts。
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||||
|
},
|
||||||
|
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: photo-live
|
||||||
|
version: 0.1.0
|
||||||
|
license:
|
||||||
|
name: UNLICENSED
|
||||||
|
identifier: UNLICENSED
|
||||||
|
description: |
|
||||||
|
photo-live 直播相册系统 API 契约(单一事实来源)。
|
||||||
|
|
||||||
|
- 类型经 `openapi-typescript` 生成并提交入库(`src/generated/openapi.d.ts`),
|
||||||
|
三端(services/api、apps/admin、apps/viewer)直接消费生成类型,不自行跑 gen。
|
||||||
|
- 错误响应使用稳定 `code`(见 `components.schemas.Error` 的枚举,与
|
||||||
|
`src/errors.ts` 的 `ErrorCodes` 一一对应),客户端按 code 映射多语言文案,
|
||||||
|
`message` 仅供诊断。
|
||||||
|
- 列表接口统一游标分页,元信息见 `components.schemas.CursorPageMeta`。
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: /v1
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: auth
|
||||||
|
description: 密码登录会话(签发、滑动续期、退出)
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/auth/password:
|
||||||
|
post:
|
||||||
|
operationId: loginWithPassword
|
||||||
|
tags: [auth]
|
||||||
|
summary: 密码登录
|
||||||
|
description: 校验账号密码,签发会话令牌。
|
||||||
|
security: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LoginRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 登录成功,返回会话令牌与过期时间。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LoginResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/ErrorResponse'
|
||||||
|
'500':
|
||||||
|
$ref: '#/components/responses/ErrorResponse'
|
||||||
|
|
||||||
|
/auth/refresh:
|
||||||
|
post:
|
||||||
|
operationId: refreshSession
|
||||||
|
tags: [auth]
|
||||||
|
summary: 会话滑动续期
|
||||||
|
description: 携带有效 Bearer 令牌滑动续期,返回新的过期时间。
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 续期成功。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RefreshResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/ErrorResponse'
|
||||||
|
'500':
|
||||||
|
$ref: '#/components/responses/ErrorResponse'
|
||||||
|
|
||||||
|
/auth/logout:
|
||||||
|
post:
|
||||||
|
operationId: logout
|
||||||
|
tags: [auth]
|
||||||
|
summary: 退出登录
|
||||||
|
description: 使当前 Bearer 令牌失效。
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: 已退出,无响应体。
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/ErrorResponse'
|
||||||
|
'500':
|
||||||
|
$ref: '#/components/responses/ErrorResponse'
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
|
||||||
|
responses:
|
||||||
|
ErrorResponse:
|
||||||
|
description: 错误响应(稳定 code;客户端按 code 映射文案,message 仅供诊断)。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
Error:
|
||||||
|
type: object
|
||||||
|
required: [code, message, requestId]
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
readOnly: true
|
||||||
|
description: 稳定错误码,与 src/errors.ts 的 ErrorCodes 一一对应。
|
||||||
|
enum:
|
||||||
|
- AUTH_INVALID_CREDENTIALS
|
||||||
|
- AUTH_TOKEN_EXPIRED
|
||||||
|
- AUTH_FORBIDDEN
|
||||||
|
- ALBUM_NOT_FOUND
|
||||||
|
- ALBUM_NOT_PUBLIC
|
||||||
|
- ALBUM_UPLOAD_DISABLED
|
||||||
|
- MEMBER_CONFLICT
|
||||||
|
- UPLOAD_SESSION_CONFLICT
|
||||||
|
- UPLOAD_VERIFY_FAILED
|
||||||
|
- PHOTO_NOT_FOUND
|
||||||
|
- PHOTO_NOT_PUBLIC
|
||||||
|
- VALIDATION_FAILED
|
||||||
|
- INTERNAL_ERROR
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
readOnly: true
|
||||||
|
description: 服务器生成的可读消息(诊断用,不作为客户端展示文案依据)。
|
||||||
|
requestId:
|
||||||
|
type: string
|
||||||
|
readOnly: true
|
||||||
|
description: 请求追踪 ID。
|
||||||
|
|
||||||
|
CursorPageMeta:
|
||||||
|
type: object
|
||||||
|
required: [nextCursor, hasMore]
|
||||||
|
description: 游标分页元信息;各列表响应以 allOf 内嵌本结构(条目 schema 在业务端点中定义)。
|
||||||
|
properties:
|
||||||
|
nextCursor:
|
||||||
|
type: [string, 'null']
|
||||||
|
readOnly: true
|
||||||
|
description: 下一页游标;null 表示没有更多数据。
|
||||||
|
hasMore:
|
||||||
|
type: boolean
|
||||||
|
readOnly: true
|
||||||
|
description: 是否还有下一页。
|
||||||
|
|
||||||
|
LoginRequest:
|
||||||
|
type: object
|
||||||
|
required: [account, password]
|
||||||
|
properties:
|
||||||
|
account:
|
||||||
|
type: string
|
||||||
|
description: 账号(用户名或邮箱)。
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
description: 密码。
|
||||||
|
LoginResponse:
|
||||||
|
type: object
|
||||||
|
required: [sessionToken, expiresAt]
|
||||||
|
properties:
|
||||||
|
sessionToken:
|
||||||
|
type: string
|
||||||
|
readOnly: true
|
||||||
|
description: 会话令牌(Bearer)。
|
||||||
|
expiresAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
readOnly: true
|
||||||
|
description: 会话过期时间。
|
||||||
|
RefreshResponse:
|
||||||
|
type: object
|
||||||
|
required: [expiresAt]
|
||||||
|
properties:
|
||||||
|
expiresAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
readOnly: true
|
||||||
|
description: 续期后的过期时间。
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@photo/contracts",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "API contract single source of truth (OpenAPI 3.1 + error codes); types generated via openapi-typescript and committed under src/generated/",
|
||||||
|
"//": "lint script is a placeholder (exit 0): eslint flat config arrives in Task 7. test is a minimal ts-jest scaffold: real type tests arrive in Task 6.",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./openapi.yaml": "./openapi.yaml"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"gen": "openapi-typescript openapi.yaml -o src/generated/openapi.d.ts",
|
||||||
|
"lint:api": "redocly lint openapi.yaml",
|
||||||
|
"build": "tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/generated', 'dist/generated', { recursive: true })\"",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "jest",
|
||||||
|
"lint": "exit 0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@redocly/cli": "^1.0.0",
|
||||||
|
"@types/jest": "^29.5.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"openapi-typescript": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
extends:
|
||||||
|
- recommended
|
||||||
|
rules:
|
||||||
|
# 基座阶段 CursorPageMeta 为前瞻定义(Task 5 的列表端点消费),暂时无引用。
|
||||||
|
no-unused-components: off
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// 占位冒烟测试:仅验证 jest + ts-jest 工具链可用(Task 6 落地真正的类型测试)。
|
||||||
|
import { ErrorCodes, type ErrorCode } from '../errors.js';
|
||||||
|
|
||||||
|
describe('ErrorCodes', () => {
|
||||||
|
it('每个 key 映射到同名的稳定 code 字符串', () => {
|
||||||
|
for (const [key, code] of Object.entries(ErrorCodes)) {
|
||||||
|
expect(key).toBe(code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ErrorCode 联合类型可收窄具体 code', () => {
|
||||||
|
const code: ErrorCode = ErrorCodes.ALBUM_NOT_FOUND;
|
||||||
|
expect(code).toBe('ALBUM_NOT_FOUND');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// packages/contracts/src/errors.ts
|
||||||
|
export const ErrorCodes = {
|
||||||
|
AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS',
|
||||||
|
AUTH_TOKEN_EXPIRED: 'AUTH_TOKEN_EXPIRED',
|
||||||
|
AUTH_FORBIDDEN: 'AUTH_FORBIDDEN',
|
||||||
|
ALBUM_NOT_FOUND: 'ALBUM_NOT_FOUND',
|
||||||
|
ALBUM_NOT_PUBLIC: 'ALBUM_NOT_PUBLIC',
|
||||||
|
ALBUM_UPLOAD_DISABLED: 'ALBUM_UPLOAD_DISABLED',
|
||||||
|
MEMBER_CONFLICT: 'MEMBER_CONFLICT',
|
||||||
|
UPLOAD_SESSION_CONFLICT: 'UPLOAD_SESSION_CONFLICT',
|
||||||
|
UPLOAD_VERIFY_FAILED: 'UPLOAD_VERIFY_FAILED',
|
||||||
|
PHOTO_NOT_FOUND: 'PHOTO_NOT_FOUND',
|
||||||
|
PHOTO_NOT_PUBLIC: 'PHOTO_NOT_PUBLIC',
|
||||||
|
VALIDATION_FAILED: 'VALIDATION_FAILED',
|
||||||
|
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
||||||
|
} as const;
|
||||||
|
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* This file was auto-generated by openapi-typescript.
|
||||||
|
* Do not make direct changes to the file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface paths {
|
||||||
|
"/auth/password": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* 密码登录
|
||||||
|
* @description 校验账号密码,签发会话令牌。
|
||||||
|
*/
|
||||||
|
post: operations["loginWithPassword"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/auth/refresh": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* 会话滑动续期
|
||||||
|
* @description 携带有效 Bearer 令牌滑动续期,返回新的过期时间。
|
||||||
|
*/
|
||||||
|
post: operations["refreshSession"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/auth/logout": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* 退出登录
|
||||||
|
* @description 使当前 Bearer 令牌失效。
|
||||||
|
*/
|
||||||
|
post: operations["logout"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type webhooks = Record<string, never>;
|
||||||
|
export interface components {
|
||||||
|
schemas: {
|
||||||
|
Error: {
|
||||||
|
/**
|
||||||
|
* @description 稳定错误码,与 src/errors.ts 的 ErrorCodes 一一对应。
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
readonly code: "AUTH_INVALID_CREDENTIALS" | "AUTH_TOKEN_EXPIRED" | "AUTH_FORBIDDEN" | "ALBUM_NOT_FOUND" | "ALBUM_NOT_PUBLIC" | "ALBUM_UPLOAD_DISABLED" | "MEMBER_CONFLICT" | "UPLOAD_SESSION_CONFLICT" | "UPLOAD_VERIFY_FAILED" | "PHOTO_NOT_FOUND" | "PHOTO_NOT_PUBLIC" | "VALIDATION_FAILED" | "INTERNAL_ERROR";
|
||||||
|
/** @description 服务器生成的可读消息(诊断用,不作为客户端展示文案依据)。 */
|
||||||
|
readonly message: string;
|
||||||
|
/** @description 请求追踪 ID。 */
|
||||||
|
readonly requestId: string;
|
||||||
|
};
|
||||||
|
/** @description 游标分页元信息;各列表响应以 allOf 内嵌本结构(条目 schema 在业务端点中定义)。 */
|
||||||
|
CursorPageMeta: {
|
||||||
|
/** @description 下一页游标;null 表示没有更多数据。 */
|
||||||
|
readonly nextCursor: string | null;
|
||||||
|
/** @description 是否还有下一页。 */
|
||||||
|
readonly hasMore: boolean;
|
||||||
|
};
|
||||||
|
LoginRequest: {
|
||||||
|
/** @description 账号(用户名或邮箱)。 */
|
||||||
|
account: string;
|
||||||
|
/** @description 密码。 */
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
LoginResponse: {
|
||||||
|
/** @description 会话令牌(Bearer)。 */
|
||||||
|
readonly sessionToken: string;
|
||||||
|
/**
|
||||||
|
* Format: date-time
|
||||||
|
* @description 会话过期时间。
|
||||||
|
*/
|
||||||
|
readonly expiresAt: string;
|
||||||
|
};
|
||||||
|
RefreshResponse: {
|
||||||
|
/**
|
||||||
|
* Format: date-time
|
||||||
|
* @description 续期后的过期时间。
|
||||||
|
*/
|
||||||
|
readonly expiresAt: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description 错误响应(稳定 code;客户端按 code 映射文案,message 仅供诊断)。 */
|
||||||
|
ErrorResponse: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
parameters: never;
|
||||||
|
requestBodies: never;
|
||||||
|
headers: never;
|
||||||
|
pathItems: never;
|
||||||
|
}
|
||||||
|
export type $defs = Record<string, never>;
|
||||||
|
export interface operations {
|
||||||
|
loginWithPassword: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["LoginRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description 登录成功,返回会话令牌与过期时间。 */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["LoginResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
401: components["responses"]["ErrorResponse"];
|
||||||
|
500: components["responses"]["ErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
refreshSession: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description 续期成功。 */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RefreshResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
401: components["responses"]["ErrorResponse"];
|
||||||
|
500: components["responses"]["ErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
logout: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description 已退出,无响应体。 */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
401: components["responses"]["ErrorResponse"];
|
||||||
|
500: components["responses"]["ErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// packages/contracts/src/index.ts
|
||||||
|
// 契约统一出口:错误码 + OpenAPI 生成类型(src/generated/openapi.d.ts 提交入库,
|
||||||
|
// 消费方不跑 gen;本包改动后执行 pnpm gen 并提交生成文件 diff)。
|
||||||
|
// NodeNext + ESM:相对导入必须带 .js 扩展名;生成文件仅含类型,用 export type * 在运行时擦除。
|
||||||
|
export * from './errors.js';
|
||||||
|
export type * from './generated/openapi.js';
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Generated
+3868
-8
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user