From 19d36ed5bfe92dd1474dc428eace6c29afbb9bc0 Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 16 Jul 2026 20:09:30 +0800 Subject: [PATCH] refactor(data): establish API contract guardrails --- .gitea/workflows/ci.yml | 16 +++++ docs/backend/api-contract-check.md | 30 ++++++++ docs/docker-image-ci.md | 3 +- e2e/README.md | 5 ++ package.json | 5 +- playwright.config.ts | 3 + .../__tests__/openapi-contract.test.mjs | 68 ++++++++++++++++++ scripts/contracts/check-openapi.mjs | 37 ++++++++++ scripts/contracts/openapi-contract.mjs | 51 +++++++++++++ src/app/layout.tsx | 7 +- src/data/dto/__tests__/schema_dto.test.ts | 39 ++++++++++ .../auth/request/facebook_identity_request.ts | 31 +++----- .../request/facebook_psid_login_request.ts | 33 +++------ .../dto/auth/request/refresh_token_request.ts | 35 ++++----- .../response/facebook_identity_response.ts | 33 +++------ src/data/dto/auth/response/logout_response.ts | 35 ++++----- .../response/feedback_submit_response.ts | 34 ++++----- src/data/dto/metrics/request/app_event.ts | 35 +++------ src/data/dto/metrics/request/pwa_event.ts | 42 ++++------- src/data/dto/schema_dto.ts | 59 +++++++++++++++ .../services/api/__tests__/api_path.test.ts | 24 +++++++ src/data/services/api/api_contract.json | 28 ++++++++ src/data/services/api/api_path.ts | 72 +++++++++---------- src/utils/logger.ts | 3 + 24 files changed, 498 insertions(+), 230 deletions(-) create mode 100644 docs/backend/api-contract-check.md create mode 100644 scripts/contracts/__tests__/openapi-contract.test.mjs create mode 100644 scripts/contracts/check-openapi.mjs create mode 100644 scripts/contracts/openapi-contract.mjs create mode 100644 src/data/dto/__tests__/schema_dto.test.ts create mode 100644 src/data/dto/schema_dto.ts create mode 100644 src/data/services/api/__tests__/api_path.test.ts create mode 100644 src/data/services/api/api_contract.json diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8c55d095..775ead47 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -29,5 +29,21 @@ jobs: - name: Run quality checks run: pnpm quality + - name: Check Python backend API surface + env: + BACKEND_OPENAPI_SOURCE: ${{ secrets.BACKEND_OPENAPI_SOURCE }} + run: | + if [ -z "$BACKEND_OPENAPI_SOURCE" ]; then + echo "BACKEND_OPENAPI_SOURCE is not configured; skipping live OpenAPI comparison." + exit 0 + fi + pnpm contract:check + + - name: Install Chromium for mobile smoke tests + run: pnpm exec playwright install --with-deps chromium + + - name: Run critical mobile smoke tests + run: pnpm test:e2e:mobile-smoke + - name: Enforce bundle budgets run: pnpm perf:bundle:check diff --git a/docs/backend/api-contract-check.md b/docs/backend/api-contract-check.md new file mode 100644 index 00000000..23043761 --- /dev/null +++ b/docs/backend/api-contract-check.md @@ -0,0 +1,30 @@ +# 前端 API 契约检查 + +本仓库只包含 Next.js 前端,Python 后端仍在独立项目中维护。前端实际调用的 +HTTP method 与 path 统一记录在 +`src/data/services/api/api_contract.json`,`ApiPath` 与 OpenAPI 检查共同消费这份清单。 + +## 本地检查 + +传入 Python 后端生成的 OpenAPI JSON 文件或 URL: + +```bash +pnpm contract:check ../backend/openapi.json +pnpm contract:check https://backend.example.com/openapi.json +``` + +也可以使用环境变量: + +```bash +BACKEND_OPENAPI_SOURCE=https://backend.example.com/openapi.json pnpm contract:check +``` + +检查会验证前端依赖的每个 method/path 是否仍由后端公开。路径参数名称不同 +(例如 `{albumId}` 与 `{album_id}`)不会产生误报。字段级兼容仍由前端 Zod +Schema 和 DTO 测试负责,避免仅凭 OpenAPI 生成代码覆盖现有防御性解析规则。 + +## CI 配置 + +在 Gitea Actions Secrets 中配置 `BACKEND_OPENAPI_SOURCE` 后,CI 会比较目标 +Python 后端的实时 OpenAPI。未配置时 CI 会明确记录跳过原因,本地的比较器测试 +仍会随 `pnpm quality` 执行。 diff --git a/docs/docker-image-ci.md b/docs/docker-image-ci.md index 4f36acc5..cec6f006 100644 --- a/docs/docker-image-ci.md +++ b/docs/docker-image-ci.md @@ -8,7 +8,7 @@ | Workflow | 触发时机 | 职责 | | --- | --- | --- | -| `.gitea/workflows/ci.yml` | `dev`、`main`、`test` push / PR | 安装依赖、执行完整质量检查并校验 bundle 预算 | +| `.gitea/workflows/ci.yml` | `dev`、`main`、`test` push / PR | 安装依赖、执行完整质量检查、Python 后端 OpenAPI 差异检查、移动端 smoke,并校验 bundle 预算 | | `.gitea/workflows/docker-image.yml` | `main`、`test` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 | 这样可以避免所有开发分支都发布镜像,并确保生产镜像通过发布门禁。 @@ -33,6 +33,7 @@ | `REGISTRY_PACKAGE_NAME` | `cozsweet-web` | 可选;Registry 镜像名不含 owner 或包名不一致时用于指定 package name | | `TEST_ENV_FILE` | `.env.local` 的完整内容 | 测试环境构建期环境变量 | | `PRODUCTION_ENV_FILE` | `.env.production` 的完整内容 | 生产环境构建期环境变量 | +| `BACKEND_OPENAPI_SOURCE` | `https://api.example.com/openapi.json` | 可选;CI 用于检查 Python 后端是否仍公开前端依赖的 method/path | Next.js 的 `NEXT_PUBLIC_*` 会在构建期固化到产物中,因此测试环境和生产环境会分别构建镜像。 diff --git a/e2e/README.md b/e2e/README.md index eee4a7ae..13c88b11 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -13,6 +13,7 @@ This project uses Playwright for end-to-end tests. ```bash pnpm test:e2e pnpm test:e2e:chrome +pnpm test:e2e:mobile-smoke pnpm test:e2e:real pnpm test:e2e:prod ``` @@ -25,6 +26,10 @@ pnpm exec playwright install chromium For local development on macOS, `pnpm test:e2e:chrome` can reuse the installed Google Chrome. +`pnpm test:e2e:mobile-smoke` runs the critical authentication, logout, token +refresh and paid-image unlock paths with the Pixel 7 profile. CI runs this tier +on every pull request and `dev` / `test` / `main` push. + ## Environment overrides ```bash diff --git a/package.json b/package.json index 8156c1be..9bb016a0 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "lint": "eslint", "lint:fix": "eslint --fix", "typecheck": "tsc --noEmit", - "quality": "pnpm lint && pnpm typecheck && pnpm test", + "quality": "pnpm lint && pnpm typecheck && pnpm test && pnpm test:contracts", "test": "vitest run", "test:watch": "vitest", "test:ui": "vitest --ui", @@ -25,8 +25,11 @@ "test:e2e:chrome": "PLAYWRIGHT_USE_SYSTEM_CHROME=1 playwright test --project=mock-chromium", "test:e2e:headed": "playwright test --project=mock-chromium --headed", "test:e2e:ui": "playwright test --ui", + "test:e2e:mobile-smoke": "playwright test --project=mock-mobile-chrome e2e/specs/mock/auth/email-login-from-other-options.spec.ts e2e/specs/mock/auth/logout-from-sidebar.spec.ts e2e/specs/mock/chat/chat-send-token-refresh-retry.spec.ts e2e/specs/mock/unlock-message/image-unlock-insufficient-credits.spec.ts", "test:e2e:real": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://frontend-test.banlv-ai.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=real-backend", "test:e2e:prod": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://cozsweet.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=prod-smoke", + "contract:check": "node scripts/contracts/check-openapi.mjs", + "test:contracts": "node --test scripts/contracts/__tests__/*.test.mjs", "perf:bundle": "next experimental-analyze --output && node scripts/performance/report-bundle.mjs", "perf:bundle:check": "PERF_BUNDLE_ENFORCE=1 pnpm perf:bundle", "perf:vitals": "node scripts/performance/collect-web-vitals.mjs", diff --git a/playwright.config.ts b/playwright.config.ts index c6db36e9..9f01d9d4 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -27,6 +27,9 @@ export default defineConfig({ ? { command: "pnpm dev -H 127.0.0.1 -p 3000", url: baseURL, + env: { + E2E_DISABLE_SERVICE_WORKER: "1", + }, reuseExistingServer: !process.env.CI, timeout: 120_000, } diff --git a/scripts/contracts/__tests__/openapi-contract.test.mjs b/scripts/contracts/__tests__/openapi-contract.test.mjs new file mode 100644 index 00000000..9473e5de --- /dev/null +++ b/scripts/contracts/__tests__/openapi-contract.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + findMissingOperations, + normalizePath, +} from "../openapi-contract.mjs"; + +describe("OpenAPI contract comparison", () => { + it("accepts matching paths and methods", () => { + const missing = findMissingOperations( + { + chatHistory: { method: "get", path: "/api/chat/history" }, + unlockAlbum: { + method: "post", + path: "/api/private-room/albums/{albumId}/unlock", + }, + }, + { + paths: { + "/api/chat/history": { get: {} }, + "/api/private-room/albums/{album_id}/unlock/": { post: {} }, + }, + }, + ); + + assert.deepEqual(missing, []); + }); + + it("reports missing paths and methods with operation ids", () => { + const missing = findMissingOperations( + { + chatHistory: { method: "get", path: "/api/chat/history" }, + chatSend: { method: "post", path: "/api/chat/send" }, + }, + { paths: { "/api/chat/history": { post: {} } } }, + ); + + assert.deepEqual(missing, [ + { + operationId: "chatHistory", + method: "get", + path: "/api/chat/history", + reason: "method is missing", + }, + { + operationId: "chatSend", + method: "post", + path: "/api/chat/send", + reason: "path is missing", + }, + ]); + }); + + it("normalizes trailing slashes and path parameter names", () => { + assert.equal( + normalizePath("/api/albums/{album_id}/unlock/"), + "/api/albums/{}/unlock", + ); + }); + + it("rejects documents without OpenAPI paths", () => { + assert.throws( + () => findMissingOperations({}, {}), + /does not contain a paths object/u, + ); + }); +}); diff --git a/scripts/contracts/check-openapi.mjs b/scripts/contracts/check-openapi.mjs new file mode 100644 index 00000000..53c6dc71 --- /dev/null +++ b/scripts/contracts/check-openapi.mjs @@ -0,0 +1,37 @@ +import { readFile } from "node:fs/promises"; + +import { + findMissingOperations, + loadJsonSource, +} from "./openapi-contract.mjs"; + +const source = process.argv[2] ?? process.env.BACKEND_OPENAPI_SOURCE; +if (!source) { + throw new Error( + "Provide an OpenAPI JSON path/URL or set BACKEND_OPENAPI_SOURCE", + ); +} + +const contractUrl = new URL( + "../../src/data/services/api/api_contract.json", + import.meta.url, +); +const [contract, openApiDocument] = await Promise.all([ + readFile(contractUrl, "utf8").then(JSON.parse), + loadJsonSource(source), +]); +const missing = findMissingOperations(contract, openApiDocument); + +if (missing.length > 0) { + const details = missing + .map( + ({ operationId, method, path, reason }) => + `- ${operationId}: ${method.toUpperCase()} ${path} (${reason})`, + ) + .join("\n"); + throw new Error(`Backend OpenAPI is missing frontend operations:\n${details}`); +} + +console.log( + `Backend OpenAPI covers all ${Object.keys(contract).length} frontend operations.`, +); diff --git a/scripts/contracts/openapi-contract.mjs b/scripts/contracts/openapi-contract.mjs new file mode 100644 index 00000000..b58355c0 --- /dev/null +++ b/scripts/contracts/openapi-contract.mjs @@ -0,0 +1,51 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +export async function loadJsonSource(source) { + if (/^https?:\/\//u.test(source)) { + const response = await fetch(source, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + throw new Error( + `Unable to load OpenAPI document: ${response.status} ${response.statusText}`, + ); + } + return response.json(); + } + + const content = await readFile(resolve(source), "utf8"); + return JSON.parse(content); +} + +export function findMissingOperations(contract, openApiDocument) { + const openApiPaths = openApiDocument?.paths; + if (!openApiPaths || typeof openApiPaths !== "object") { + throw new Error("OpenAPI document does not contain a paths object"); + } + + const normalizedPaths = new Map( + Object.entries(openApiPaths).map(([path, pathItem]) => [ + normalizePath(path), + pathItem, + ]), + ); + + return Object.entries(contract).flatMap(([operationId, operation]) => { + const method = operation.method.toLowerCase(); + const pathItem = normalizedPaths.get(normalizePath(operation.path)); + if (!pathItem) { + return [{ operationId, ...operation, reason: "path is missing" }]; + } + if (!pathItem[method]) { + return [{ operationId, ...operation, reason: "method is missing" }]; + } + return []; + }); +} + +export function normalizePath(path) { + const withoutTrailingSlash = path.length > 1 ? path.replace(/\/+$/u, "") : path; + return withoutTrailingSlash.replace(/\{[^/{}]+\}/gu, "{}"); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index eb7dd4c1..0e39bbf5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -56,7 +56,12 @@ export default function RootLayout({ {/* SerwistProvider registers the service worker built from app/sw.ts. */} - {children} + + {children} + diff --git a/src/data/dto/__tests__/schema_dto.test.ts b/src/data/dto/__tests__/schema_dto.test.ts new file mode 100644 index 00000000..6eabee39 --- /dev/null +++ b/src/data/dto/__tests__/schema_dto.test.ts @@ -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; +const ExampleDto = createSchemaDto( + 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); + }); +}); diff --git a/src/data/dto/auth/request/facebook_identity_request.ts b/src/data/dto/auth/request/facebook_identity_request.ts index 7fcb397c..2e61a682 100644 --- a/src/data/dto/auth/request/facebook_identity_request.ts +++ b/src/data/dto/auth/request/facebook_identity_request.ts @@ -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, +); diff --git a/src/data/dto/auth/request/facebook_psid_login_request.ts b/src/data/dto/auth/request/facebook_psid_login_request.ts index a988a57f..998e7652 100644 --- a/src/data/dto/auth/request/facebook_psid_login_request.ts +++ b/src/data/dto/auth/request/facebook_psid_login_request.ts @@ -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, +); diff --git a/src/data/dto/auth/request/refresh_token_request.ts b/src/data/dto/auth/request/refresh_token_request.ts index 7aa417cd..ace009f3 100644 --- a/src/data/dto/auth/request/refresh_token_request.ts +++ b/src/data/dto/auth/request/refresh_token_request.ts @@ -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); diff --git a/src/data/dto/auth/response/facebook_identity_response.ts b/src/data/dto/auth/response/facebook_identity_response.ts index 5c15e3ee..b5b4e338 100644 --- a/src/data/dto/auth/response/facebook_identity_response.ts +++ b/src/data/dto/auth/response/facebook_identity_response.ts @@ -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, +); diff --git a/src/data/dto/auth/response/logout_response.ts b/src/data/dto/auth/response/logout_response.ts index bea4d12a..c0ed4e16 100644 --- a/src/data/dto/auth/response/logout_response.ts +++ b/src/data/dto/auth/response/logout_response.ts @@ -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); diff --git a/src/data/dto/feedback/response/feedback_submit_response.ts b/src/data/dto/feedback/response/feedback_submit_response.ts index e0956402..784c01a7 100644 --- a/src/data/dto/feedback/response/feedback_submit_response.ts +++ b/src/data/dto/feedback/response/feedback_submit_response.ts @@ -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); diff --git a/src/data/dto/metrics/request/app_event.ts b/src/data/dto/metrics/request/app_event.ts index 72e0d13c..2184ef0c 100644 --- a/src/data/dto/metrics/request/app_event.ts +++ b/src/data/dto/metrics/request/app_event.ts @@ -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; +export const AppEvent = createSchemaDto< + typeof AppEventSchema, + AppEventPublicKey +>(AppEventSchema); diff --git a/src/data/dto/metrics/request/pwa_event.ts b/src/data/dto/metrics/request/pwa_event.ts index ee0b7ea6..6cfff0d6 100644 --- a/src/data/dto/metrics/request/pwa_event.ts +++ b/src/data/dto/metrics/request/pwa_event.ts @@ -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; +export const PwaEvent = createSchemaDto< + typeof PwaEventSchema, + PwaEventPublicKey +>(PwaEventSchema); diff --git a/src/data/dto/schema_dto.ts b/src/data/dto/schema_dto.ts new file mode 100644 index 00000000..c8f3f613 --- /dev/null +++ b/src/data/dto/schema_dto.ts @@ -0,0 +1,59 @@ +import type { z } from "zod"; + +export type SchemaDto< + TSchema extends z.ZodType, + TPublicKey extends keyof z.output = never, +> = Readonly, TPublicKey>> & { + toJson(): z.output; +}; + +export interface SchemaDtoFactory< + TSchema extends z.ZodType, + TPublicKey extends keyof z.output = never, +> { + readonly prototype: SchemaDto; + [Symbol.hasInstance](value: unknown): boolean; + from(input: z.input): SchemaDto; + fromJson(json: unknown): SchemaDto; +} + +/** + * 为没有自定义映射逻辑的对象 Schema 创建不可变 DTO。 + * + * `TPublicKey` 只暴露业务代码实际读取的字段;完整解析结果仍会保留在实例中, + * 并由 `toJson` 按原 Schema 输出。包含嵌套 DTO 转换或领域方法的模型继续使用 + * 显式 class,避免把业务逻辑隐藏进通用工厂。 + */ +export function createSchemaDto< + TSchema extends z.ZodType, + TPublicKey extends keyof z.output = never, +>(schema: TSchema): SchemaDtoFactory { + class GeneratedSchemaDto { + private constructor(input: z.input) { + Object.assign(this, schema.parse(input) as object); + Object.freeze(this); + } + + static from( + input: z.input, + ): SchemaDto { + return new GeneratedSchemaDto(input) as SchemaDto< + TSchema, + TPublicKey + >; + } + + static fromJson(json: unknown): SchemaDto { + return GeneratedSchemaDto.from(json as z.input); + } + + toJson(): z.output { + return schema.parse(this); + } + } + + return GeneratedSchemaDto as unknown as SchemaDtoFactory< + TSchema, + TPublicKey + >; +} diff --git a/src/data/services/api/__tests__/api_path.test.ts b/src/data/services/api/__tests__/api_path.test.ts new file mode 100644 index 00000000..00f64c8a --- /dev/null +++ b/src/data/services/api/__tests__/api_path.test.ts @@ -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", + ); + }); +}); diff --git a/src/data/services/api/api_contract.json b/src/data/services/api/api_contract.json new file mode 100644 index 00000000..fcc96f42 --- /dev/null +++ b/src/data/services/api/api_contract.json @@ -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" } +} diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index 56110f54..78ab7ad5 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -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; } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 22091916..29b0f91a 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -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" }; }