refactor(data): establish API contract guardrails
CI / Quality and Bundle Budgets (push) Has been cancelled

This commit is contained in:
2026-07-16 20:09:30 +08:00
parent cc06ed034d
commit 19d36ed5bf
24 changed files with 498 additions and 230 deletions
+6 -1
View File
@@ -56,7 +56,12 @@ export default function RootLayout({
<SessionProvider>
<RootProviders>
{/* SerwistProvider registers the service worker built from app/sw.ts. */}
<SerwistProvider swUrl="/serwist/sw.js">{children}</SerwistProvider>
<SerwistProvider
swUrl="/serwist/sw.js"
disable={process.env.E2E_DISABLE_SERVICE_WORKER === "1"}
>
{children}
</SerwistProvider>
</RootProviders>
</SessionProvider>
</body>
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { createSchemaDto, type SchemaDto } from "../schema_dto";
const ExampleSchema = z.object({
id: z.string(),
traceId: z.string().default("generated-trace"),
});
type ExampleDto = SchemaDto<typeof ExampleSchema, "id">;
const ExampleDto = createSchemaDto<typeof ExampleSchema, "id">(
ExampleSchema,
);
describe("createSchemaDto", () => {
it("parses, freezes and serializes through the source schema", () => {
const dto: ExampleDto = ExampleDto.from({ id: "item-1" });
expect(dto).toBeInstanceOf(ExampleDto);
expect(Object.isFrozen(dto)).toBe(true);
expect(dto.id).toBe("item-1");
expect(dto.toJson()).toEqual({
id: "item-1",
traceId: "generated-trace",
});
});
it("keeps non-public schema fields out of the declared DTO interface", () => {
const dto = ExampleDto.fromJson({ id: "item-1", traceId: "trace-1" });
// @ts-expect-error traceId is serialized but not part of the public DTO API.
expect(dto.traceId).toBe("trace-1");
});
it("preserves schema validation errors", () => {
expect(() => ExampleDto.fromJson({ id: 1 })).toThrow(z.ZodError);
});
});
@@ -3,26 +3,15 @@
*/
import {
FacebookIdentityRequestSchema,
type FacebookIdentityRequestInput,
type FacebookIdentityRequestData,
} from "@/data/schemas/auth/request/facebook_identity_request";
import {
createSchemaDto,
type SchemaDto,
} from "@/data/dto/schema_dto";
export class FacebookIdentityRequest {
private constructor(input: FacebookIdentityRequestInput) {
const data = FacebookIdentityRequestSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: FacebookIdentityRequestInput): FacebookIdentityRequest {
return new FacebookIdentityRequest(input);
}
static fromJson(json: unknown): FacebookIdentityRequest {
return FacebookIdentityRequest.from(json as FacebookIdentityRequestInput);
}
toJson(): FacebookIdentityRequestData {
return FacebookIdentityRequestSchema.parse(this);
}
}
export type FacebookIdentityRequest = SchemaDto<
typeof FacebookIdentityRequestSchema
>;
export const FacebookIdentityRequest = createSchemaDto(
FacebookIdentityRequestSchema,
);
@@ -3,28 +3,15 @@
*/
import {
FacebookPsidLoginRequestSchema,
type FacebookPsidLoginRequestInput,
type FacebookPsidLoginRequestData,
} from "@/data/schemas/auth/request/facebook_psid_login_request";
import {
createSchemaDto,
type SchemaDto,
} from "@/data/dto/schema_dto";
export class FacebookPsidLoginRequest {
private constructor(input: FacebookPsidLoginRequestInput) {
const data = FacebookPsidLoginRequestSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: FacebookPsidLoginRequestInput): FacebookPsidLoginRequest {
return new FacebookPsidLoginRequest(input);
}
static fromJson(json: unknown): FacebookPsidLoginRequest {
return FacebookPsidLoginRequest.from(
json as FacebookPsidLoginRequestInput,
);
}
toJson(): FacebookPsidLoginRequestData {
return FacebookPsidLoginRequestSchema.parse(this);
}
}
export type FacebookPsidLoginRequest = SchemaDto<
typeof FacebookPsidLoginRequestSchema
>;
export const FacebookPsidLoginRequest = createSchemaDto(
FacebookPsidLoginRequestSchema,
);
@@ -3,28 +3,17 @@
*/
import {
RefreshTokenRequestSchema,
type RefreshTokenRequestInput,
type RefreshTokenRequestData,
} from "@/data/schemas/auth/request/refresh_token_request";
import {
createSchemaDto,
type SchemaDto,
} from "@/data/dto/schema_dto";
export class RefreshTokenRequest {
declare readonly refreshToken: string;
private constructor(input: RefreshTokenRequestInput) {
const data = RefreshTokenRequestSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: RefreshTokenRequestInput): RefreshTokenRequest {
return new RefreshTokenRequest(input);
}
static fromJson(json: unknown): RefreshTokenRequest {
return RefreshTokenRequest.from(json as RefreshTokenRequestInput);
}
toJson(): RefreshTokenRequestData {
return RefreshTokenRequestSchema.parse(this);
}
}
export type RefreshTokenRequest = SchemaDto<
typeof RefreshTokenRequestSchema,
"refreshToken"
>;
export const RefreshTokenRequest = createSchemaDto<
typeof RefreshTokenRequestSchema,
"refreshToken"
>(RefreshTokenRequestSchema);
@@ -3,28 +3,15 @@
*/
import {
FacebookIdentityResponseSchema,
type FacebookIdentityResponseInput,
type FacebookIdentityResponseData,
} from "@/data/schemas/auth/response/facebook_identity_response";
import {
createSchemaDto,
type SchemaDto,
} from "@/data/dto/schema_dto";
export class FacebookIdentityResponse {
private constructor(input: FacebookIdentityResponseInput) {
const data = FacebookIdentityResponseSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: FacebookIdentityResponseInput): FacebookIdentityResponse {
return new FacebookIdentityResponse(input);
}
static fromJson(json: unknown): FacebookIdentityResponse {
return FacebookIdentityResponse.from(
json as FacebookIdentityResponseInput,
);
}
toJson(): FacebookIdentityResponseData {
return FacebookIdentityResponseSchema.parse(this);
}
}
export type FacebookIdentityResponse = SchemaDto<
typeof FacebookIdentityResponseSchema
>;
export const FacebookIdentityResponse = createSchemaDto(
FacebookIdentityResponseSchema,
);
+12 -23
View File
@@ -3,28 +3,17 @@
*/
import {
LogoutResponseSchema,
type LogoutResponseInput,
type LogoutResponseData,
} from "@/data/schemas/auth/response/logout_response";
import {
createSchemaDto,
type SchemaDto,
} from "@/data/dto/schema_dto";
export class LogoutResponse {
declare readonly success: boolean;
private constructor(input: LogoutResponseInput) {
const data = LogoutResponseSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: LogoutResponseInput): LogoutResponse {
return new LogoutResponse(input);
}
static fromJson(json: unknown): LogoutResponse {
return LogoutResponse.from(json as LogoutResponseInput);
}
toJson(): LogoutResponseData {
return LogoutResponseSchema.parse(this);
}
}
export type LogoutResponse = SchemaDto<
typeof LogoutResponseSchema,
"success"
>;
export const LogoutResponse = createSchemaDto<
typeof LogoutResponseSchema,
"success"
>(LogoutResponseSchema);
@@ -1,26 +1,16 @@
import {
FeedbackSubmitResponseSchema,
type FeedbackSubmitResponseData,
type FeedbackSubmitResponseInput,
} from "@/data/schemas/feedback";
import {
createSchemaDto,
type SchemaDto,
} from "@/data/dto/schema_dto";
export class FeedbackSubmitResponse {
declare readonly feedbackId: string;
private constructor(input: FeedbackSubmitResponseInput) {
Object.assign(this, FeedbackSubmitResponseSchema.parse(input));
Object.freeze(this);
}
static from(input: FeedbackSubmitResponseInput): FeedbackSubmitResponse {
return new FeedbackSubmitResponse(input);
}
static fromJson(json: unknown): FeedbackSubmitResponse {
return FeedbackSubmitResponse.from(json as FeedbackSubmitResponseInput);
}
toJson(): FeedbackSubmitResponseData {
return FeedbackSubmitResponseSchema.parse(this);
}
}
export type FeedbackSubmitResponse = SchemaDto<
typeof FeedbackSubmitResponseSchema,
"feedbackId"
>;
export const FeedbackSubmitResponse = createSchemaDto<
typeof FeedbackSubmitResponseSchema,
"feedbackId"
>(FeedbackSubmitResponseSchema);
+10 -25
View File
@@ -3,30 +3,15 @@
*/
import {
AppEventSchema,
type AppEventInput,
type AppEventData,
} from "@/data/schemas/metrics/request/app_event";
import {
createSchemaDto,
type SchemaDto,
} from "@/data/dto/schema_dto";
export class AppEvent {
declare readonly userId: string;
declare readonly browser: string;
declare readonly userAgent: string;
private constructor(input: AppEventInput) {
const data = AppEventSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: AppEventInput): AppEvent {
return new AppEvent(input);
}
static fromJson(json: unknown): AppEvent {
return AppEvent.from(json as AppEventInput);
}
toJson(): AppEventData {
return AppEventSchema.parse(this);
}
}
type AppEventPublicKey = "userId" | "browser" | "userAgent";
export type AppEvent = SchemaDto<typeof AppEventSchema, AppEventPublicKey>;
export const AppEvent = createSchemaDto<
typeof AppEventSchema,
AppEventPublicKey
>(AppEventSchema);
+15 -27
View File
@@ -3,32 +3,20 @@
*/
import {
PwaEventSchema,
type PwaEventInput,
type PwaEventData,
} from "@/data/schemas/metrics/request/pwa_event";
import {
createSchemaDto,
type SchemaDto,
} from "@/data/dto/schema_dto";
export class PwaEvent {
declare readonly deviceId: string;
declare readonly deviceType: string;
declare readonly timestamp: number;
declare readonly pwaInstalled: boolean;
declare readonly pwaSupported: boolean;
private constructor(input: PwaEventInput) {
const data = PwaEventSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: PwaEventInput): PwaEvent {
return new PwaEvent(input);
}
static fromJson(json: unknown): PwaEvent {
return PwaEvent.from(json as PwaEventInput);
}
toJson(): PwaEventData {
return PwaEventSchema.parse(this);
}
}
type PwaEventPublicKey =
| "deviceId"
| "deviceType"
| "timestamp"
| "pwaInstalled"
| "pwaSupported";
export type PwaEvent = SchemaDto<typeof PwaEventSchema, PwaEventPublicKey>;
export const PwaEvent = createSchemaDto<
typeof PwaEventSchema,
PwaEventPublicKey
>(PwaEventSchema);
+59
View File
@@ -0,0 +1,59 @@
import type { z } from "zod";
export type SchemaDto<
TSchema extends z.ZodType,
TPublicKey extends keyof z.output<TSchema> = never,
> = Readonly<Pick<z.output<TSchema>, TPublicKey>> & {
toJson(): z.output<TSchema>;
};
export interface SchemaDtoFactory<
TSchema extends z.ZodType,
TPublicKey extends keyof z.output<TSchema> = never,
> {
readonly prototype: SchemaDto<TSchema, TPublicKey>;
[Symbol.hasInstance](value: unknown): boolean;
from(input: z.input<TSchema>): SchemaDto<TSchema, TPublicKey>;
fromJson(json: unknown): SchemaDto<TSchema, TPublicKey>;
}
/**
* 为没有自定义映射逻辑的对象 Schema 创建不可变 DTO。
*
* `TPublicKey` 只暴露业务代码实际读取的字段;完整解析结果仍会保留在实例中,
* 并由 `toJson` 按原 Schema 输出。包含嵌套 DTO 转换或领域方法的模型继续使用
* 显式 class,避免把业务逻辑隐藏进通用工厂。
*/
export function createSchemaDto<
TSchema extends z.ZodType,
TPublicKey extends keyof z.output<TSchema> = never,
>(schema: TSchema): SchemaDtoFactory<TSchema, TPublicKey> {
class GeneratedSchemaDto {
private constructor(input: z.input<TSchema>) {
Object.assign(this, schema.parse(input) as object);
Object.freeze(this);
}
static from(
input: z.input<TSchema>,
): SchemaDto<TSchema, TPublicKey> {
return new GeneratedSchemaDto(input) as SchemaDto<
TSchema,
TPublicKey
>;
}
static fromJson(json: unknown): SchemaDto<TSchema, TPublicKey> {
return GeneratedSchemaDto.from(json as z.input<TSchema>);
}
toJson(): z.output<TSchema> {
return schema.parse(this);
}
}
return GeneratedSchemaDto as unknown as SchemaDtoFactory<
TSchema,
TPublicKey
>;
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import apiContract from "../api_contract.json";
import { ApiPath } from "../api_path";
describe("ApiPath contract source", () => {
it("uses the shared contract path for every static operation", () => {
const staticOperations = Object.entries(apiContract).filter(
([operationId]) => operationId !== "privateRoomAlbumUnlock",
);
for (const [operationId, operation] of staticOperations) {
expect(ApiPath[operationId as keyof typeof ApiPath]).toBe(
operation.path,
);
}
});
it("fills and encodes the private album path parameter", () => {
expect(ApiPath.privateRoomAlbumUnlock("album/id 1")).toBe(
"/api/private-room/albums/album%2Fid%201/unlock",
);
});
});
+28
View File
@@ -0,0 +1,28 @@
{
"emailLogin": { "method": "post", "path": "/api/auth/login" },
"register": { "method": "post", "path": "/api/auth/register" },
"guestLogin": { "method": "post", "path": "/api/auth/guest" },
"googleLogin": { "method": "post", "path": "/api/auth/login/google" },
"facebookLogin": { "method": "post", "path": "/api/auth/login/facebook" },
"facebookIdLogin": { "method": "post", "path": "/api/auth/login/facebook/by-id" },
"facebookPsidLogin": { "method": "post", "path": "/api/auth/login/facebook/psid" },
"refresh": { "method": "post", "path": "/api/auth/refresh" },
"logout": { "method": "post", "path": "/api/auth/logout" },
"getCurrentUser": { "method": "get", "path": "/api/auth/me" },
"userProfile": { "method": "get", "path": "/api/user/profile" },
"userEntitlements": { "method": "get", "path": "/api/user/entitlements" },
"userFacebookIdentity": { "method": "post", "path": "/api/user/facebook/identity" },
"paymentCreateOrder": { "method": "post", "path": "/api/payment/create-order" },
"paymentOrderStatus": { "method": "get", "path": "/api/payment/order-status" },
"paymentPlans": { "method": "get", "path": "/api/payment/plans" },
"paymentTipPlans": { "method": "get", "path": "/api/payment/tip-plans" },
"chatSend": { "method": "post", "path": "/api/chat/send" },
"chatHistory": { "method": "get", "path": "/api/chat/history" },
"chatUnlockPrivate": { "method": "post", "path": "/api/chat/unlock-private" },
"chatUnlockHistory": { "method": "post", "path": "/api/chat/unlock-history" },
"privateRoomAlbums": { "method": "get", "path": "/api/private-room/albums" },
"privateRoomAlbumUnlock": { "method": "post", "path": "/api/private-room/albums/{albumId}/unlock" },
"metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" },
"reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" },
"feedback": { "method": "post", "path": "/api/feedback" }
}
+32 -40
View File
@@ -3,107 +3,99 @@
* 统一管理所有接口路径
*
*/
import apiContract from "./api_contract.json";
export class ApiPath {
private constructor() {}
// API 基础路径
private static readonly _baseUrl = "/api";
// ============ 功能模块分组 ============
private static readonly _auth = `${ApiPath._baseUrl}/auth`;
private static readonly _user = `${ApiPath._baseUrl}/user`;
private static readonly _payment = `${ApiPath._baseUrl}/payment`;
private static readonly _chat = `${ApiPath._baseUrl}/chat`;
private static readonly _privateRoom = `${ApiPath._baseUrl}/private-room`;
// ============ 认证相关 ============
/** 邮箱密码登录 */
static readonly emailLogin = `${ApiPath._auth}/login`;
static readonly emailLogin = apiContract.emailLogin.path;
/** 注册 */
static readonly register = `${ApiPath._auth}/register`;
static readonly register = apiContract.register.path;
/** 设备自动登录 */
static readonly guestLogin = `${ApiPath._auth}/guest`;
static readonly guestLogin = apiContract.guestLogin.path;
/** Google 登录 */
static readonly googleLogin = `${ApiPath._auth}/login/google`;
static readonly googleLogin = apiContract.googleLogin.path;
/** Facebook 登录 */
static readonly facebookLogin = `${ApiPath._auth}/login/facebook`;
static readonly facebookLogin = apiContract.facebookLogin.path;
/** Facebook ID 登录(v7.0 新增) */
static readonly facebookIdLogin = `${ApiPath._auth}/login/facebook/by-id`;
static readonly facebookIdLogin = apiContract.facebookIdLogin.path;
/** Facebook PSID 登录 */
static readonly facebookPsidLogin = `${ApiPath._auth}/login/facebook/psid`;
static readonly facebookPsidLogin = apiContract.facebookPsidLogin.path;
/** 刷新 Token */
static readonly refresh = `${ApiPath._auth}/refresh`;
static readonly refresh = apiContract.refresh.path;
/** 退出登录 */
static readonly logout = `${ApiPath._auth}/logout`;
static readonly logout = apiContract.logout.path;
/** 获取当前用户信息 */
static readonly getCurrentUser = `${ApiPath._auth}/me`;
static readonly getCurrentUser = apiContract.getCurrentUser.path;
// ============ 用户相关 ============
/** 获取个人信息(与 /auth/me 等价) */
static readonly userProfile = `${ApiPath._user}/profile`;
static readonly userProfile = apiContract.userProfile.path;
/** 获取当前用户权益快照 */
static readonly userEntitlements = `${ApiPath._user}/entitlements`;
static readonly userEntitlements = apiContract.userEntitlements.path;
/** 绑定 Facebook ASID / PSID */
static readonly userFacebookIdentity = `${ApiPath._user}/facebook/identity`;
static readonly userFacebookIdentity =
apiContract.userFacebookIdentity.path;
// ============ 支付相关 ============
/** 创建充值订单 */
static readonly paymentCreateOrder = `${ApiPath._payment}/create-order`;
static readonly paymentCreateOrder = apiContract.paymentCreateOrder.path;
/** 查询订单状态 */
static readonly paymentOrderStatus = `${ApiPath._payment}/order-status`;
static readonly paymentOrderStatus = apiContract.paymentOrderStatus.path;
/** 获取商品套餐列表 */
static readonly paymentPlans = `${ApiPath._payment}/plans`;
static readonly paymentPlans = apiContract.paymentPlans.path;
/** 获取咖啡打赏套餐列表 */
static readonly paymentTipPlans = `${ApiPath._payment}/tip-plans`;
static readonly paymentTipPlans = apiContract.paymentTipPlans.path;
// ============ 聊天相关 ============
/** 发送消息 */
static readonly chatSend = `${ApiPath._chat}/send`;
static readonly chatSend = apiContract.chatSend.path;
/** 获取聊天历史 */
static readonly chatHistory = `${ApiPath._chat}/history`;
static readonly chatHistory = apiContract.chatHistory.path;
/** 解锁私密消息 */
static readonly chatUnlockPrivate = `${ApiPath._chat}/unlock-private`;
static readonly chatUnlockPrivate = apiContract.chatUnlockPrivate.path;
/** 一键解锁历史锁定消息 */
static readonly chatUnlockHistory = `${ApiPath._chat}/unlock-history`;
static readonly chatUnlockHistory = apiContract.chatUnlockHistory.path;
// ============ 私密空间相关 ============
/** 获取私密图片包列表 */
static readonly privateRoomAlbums = `${ApiPath._privateRoom}/albums`;
static readonly privateRoomAlbums = apiContract.privateRoomAlbums.path;
/** 解锁私密图片包 */
static privateRoomAlbumUnlock(albumId: string): string {
return `${ApiPath.privateRoomAlbums}/${encodeURIComponent(albumId)}/unlock`;
return apiContract.privateRoomAlbumUnlock.path.replace(
"{albumId}",
encodeURIComponent(albumId),
);
}
// ============ 数据看板相关 ============
private static readonly _metrics = `${ApiPath._baseUrl}/metrics`;
/** 上报 PWA 事件 */
static readonly metricsPwaEvent = `${ApiPath._metrics}/pwa/event`;
static readonly metricsPwaEvent = apiContract.metricsPwaEvent.path;
// ============ 数据上报相关(v7.0 新增) ============
private static readonly _data = `${ApiPath._baseUrl}/data`;
/** 上报用户信息 */
static readonly reportUserInfo = `${ApiPath._data}/report-user-info`;
static readonly reportUserInfo = apiContract.reportUserInfo.path;
// ============ 用户反馈相关 ============
static readonly feedback = `${ApiPath._baseUrl}/feedback`;
static readonly feedback = apiContract.feedback.path;
}
+3
View File
@@ -21,6 +21,9 @@ type LogLevel = "debug" | "info" | "warn" | "error" | "fatal";
const rootLogger: PinoLogger = pino(createLoggerOptions());
function createLoggerOptions(): LoggerOptions {
if (process.env.NODE_ENV === "test") {
return { level: "silent" };
}
if (!AppEnvUtil.canOutputLogs()) {
return { level: "silent" };
}