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
+16
View File
@@ -29,5 +29,21 @@ jobs:
- name: Run quality checks - name: Run quality checks
run: pnpm quality 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 - name: Enforce bundle budgets
run: pnpm perf:bundle:check run: pnpm perf:bundle:check
+30
View File
@@ -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` 执行。
+2 -1
View File
@@ -8,7 +8,7 @@
| Workflow | 触发时机 | 职责 | | 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 部署 | | `.gitea/workflows/docker-image.yml` | `main``test` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 |
这样可以避免所有开发分支都发布镜像,并确保生产镜像通过发布门禁。 这样可以避免所有开发分支都发布镜像,并确保生产镜像通过发布门禁。
@@ -33,6 +33,7 @@
| `REGISTRY_PACKAGE_NAME` | `cozsweet-web` | 可选;Registry 镜像名不含 owner 或包名不一致时用于指定 package name | | `REGISTRY_PACKAGE_NAME` | `cozsweet-web` | 可选;Registry 镜像名不含 owner 或包名不一致时用于指定 package name |
| `TEST_ENV_FILE` | `.env.local` 的完整内容 | 测试环境构建期环境变量 | | `TEST_ENV_FILE` | `.env.local` 的完整内容 | 测试环境构建期环境变量 |
| `PRODUCTION_ENV_FILE` | `.env.production` 的完整内容 | 生产环境构建期环境变量 | | `PRODUCTION_ENV_FILE` | `.env.production` 的完整内容 | 生产环境构建期环境变量 |
| `BACKEND_OPENAPI_SOURCE` | `https://api.example.com/openapi.json` | 可选;CI 用于检查 Python 后端是否仍公开前端依赖的 method/path |
Next.js 的 `NEXT_PUBLIC_*` 会在构建期固化到产物中,因此测试环境和生产环境会分别构建镜像。 Next.js 的 `NEXT_PUBLIC_*` 会在构建期固化到产物中,因此测试环境和生产环境会分别构建镜像。
+5
View File
@@ -13,6 +13,7 @@ This project uses Playwright for end-to-end tests.
```bash ```bash
pnpm test:e2e pnpm test:e2e
pnpm test:e2e:chrome pnpm test:e2e:chrome
pnpm test:e2e:mobile-smoke
pnpm test:e2e:real pnpm test:e2e:real
pnpm test:e2e:prod 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. 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 ## Environment overrides
```bash ```bash
+4 -1
View File
@@ -17,7 +17,7 @@
"lint": "eslint", "lint": "eslint",
"lint:fix": "eslint --fix", "lint:fix": "eslint --fix",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"quality": "pnpm lint && pnpm typecheck && pnpm test", "quality": "pnpm lint && pnpm typecheck && pnpm test && pnpm test:contracts",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:ui": "vitest --ui", "test:ui": "vitest --ui",
@@ -25,8 +25,11 @@
"test:e2e:chrome": "PLAYWRIGHT_USE_SYSTEM_CHROME=1 playwright test --project=mock-chromium", "test:e2e:chrome": "PLAYWRIGHT_USE_SYSTEM_CHROME=1 playwright test --project=mock-chromium",
"test:e2e:headed": "playwright test --project=mock-chromium --headed", "test:e2e:headed": "playwright test --project=mock-chromium --headed",
"test:e2e:ui": "playwright test --ui", "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: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", "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": "next experimental-analyze --output && node scripts/performance/report-bundle.mjs",
"perf:bundle:check": "PERF_BUNDLE_ENFORCE=1 pnpm perf:bundle", "perf:bundle:check": "PERF_BUNDLE_ENFORCE=1 pnpm perf:bundle",
"perf:vitals": "node scripts/performance/collect-web-vitals.mjs", "perf:vitals": "node scripts/performance/collect-web-vitals.mjs",
+3
View File
@@ -27,6 +27,9 @@ export default defineConfig({
? { ? {
command: "pnpm dev -H 127.0.0.1 -p 3000", command: "pnpm dev -H 127.0.0.1 -p 3000",
url: baseURL, url: baseURL,
env: {
E2E_DISABLE_SERVICE_WORKER: "1",
},
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 120_000, timeout: 120_000,
} }
@@ -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,
);
});
});
+37
View File
@@ -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.`,
);
+51
View File
@@ -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, "{}");
}
+6 -1
View File
@@ -56,7 +56,12 @@ export default function RootLayout({
<SessionProvider> <SessionProvider>
<RootProviders> <RootProviders>
{/* SerwistProvider registers the service worker built from app/sw.ts. */} {/* 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> </RootProviders>
</SessionProvider> </SessionProvider>
</body> </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 { import {
FacebookIdentityRequestSchema, FacebookIdentityRequestSchema,
type FacebookIdentityRequestInput,
type FacebookIdentityRequestData,
} from "@/data/schemas/auth/request/facebook_identity_request"; } from "@/data/schemas/auth/request/facebook_identity_request";
import {
createSchemaDto,
type SchemaDto,
} from "@/data/dto/schema_dto";
export class FacebookIdentityRequest { export type FacebookIdentityRequest = SchemaDto<
private constructor(input: FacebookIdentityRequestInput) { typeof FacebookIdentityRequestSchema
const data = FacebookIdentityRequestSchema.parse(input); >;
Object.assign(this, data); export const FacebookIdentityRequest = createSchemaDto(
Object.freeze(this); FacebookIdentityRequestSchema,
} );
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);
}
}
@@ -3,28 +3,15 @@
*/ */
import { import {
FacebookPsidLoginRequestSchema, FacebookPsidLoginRequestSchema,
type FacebookPsidLoginRequestInput,
type FacebookPsidLoginRequestData,
} from "@/data/schemas/auth/request/facebook_psid_login_request"; } from "@/data/schemas/auth/request/facebook_psid_login_request";
import {
createSchemaDto,
type SchemaDto,
} from "@/data/dto/schema_dto";
export class FacebookPsidLoginRequest { export type FacebookPsidLoginRequest = SchemaDto<
private constructor(input: FacebookPsidLoginRequestInput) { typeof FacebookPsidLoginRequestSchema
const data = FacebookPsidLoginRequestSchema.parse(input); >;
Object.assign(this, data); export const FacebookPsidLoginRequest = createSchemaDto(
Object.freeze(this); FacebookPsidLoginRequestSchema,
}
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);
}
}
@@ -3,28 +3,17 @@
*/ */
import { import {
RefreshTokenRequestSchema, RefreshTokenRequestSchema,
type RefreshTokenRequestInput,
type RefreshTokenRequestData,
} from "@/data/schemas/auth/request/refresh_token_request"; } from "@/data/schemas/auth/request/refresh_token_request";
import {
createSchemaDto,
type SchemaDto,
} from "@/data/dto/schema_dto";
export class RefreshTokenRequest { export type RefreshTokenRequest = SchemaDto<
declare readonly refreshToken: string; typeof RefreshTokenRequestSchema,
"refreshToken"
private constructor(input: RefreshTokenRequestInput) { >;
const data = RefreshTokenRequestSchema.parse(input); export const RefreshTokenRequest = createSchemaDto<
Object.assign(this, data); typeof RefreshTokenRequestSchema,
Object.freeze(this); "refreshToken"
} >(RefreshTokenRequestSchema);
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);
}
}
@@ -3,28 +3,15 @@
*/ */
import { import {
FacebookIdentityResponseSchema, FacebookIdentityResponseSchema,
type FacebookIdentityResponseInput,
type FacebookIdentityResponseData,
} from "@/data/schemas/auth/response/facebook_identity_response"; } from "@/data/schemas/auth/response/facebook_identity_response";
import {
createSchemaDto,
type SchemaDto,
} from "@/data/dto/schema_dto";
export class FacebookIdentityResponse { export type FacebookIdentityResponse = SchemaDto<
private constructor(input: FacebookIdentityResponseInput) { typeof FacebookIdentityResponseSchema
const data = FacebookIdentityResponseSchema.parse(input); >;
Object.assign(this, data); export const FacebookIdentityResponse = createSchemaDto(
Object.freeze(this); FacebookIdentityResponseSchema,
}
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);
}
}
+12 -23
View File
@@ -3,28 +3,17 @@
*/ */
import { import {
LogoutResponseSchema, LogoutResponseSchema,
type LogoutResponseInput,
type LogoutResponseData,
} from "@/data/schemas/auth/response/logout_response"; } from "@/data/schemas/auth/response/logout_response";
import {
createSchemaDto,
type SchemaDto,
} from "@/data/dto/schema_dto";
export class LogoutResponse { export type LogoutResponse = SchemaDto<
declare readonly success: boolean; typeof LogoutResponseSchema,
"success"
private constructor(input: LogoutResponseInput) { >;
const data = LogoutResponseSchema.parse(input); export const LogoutResponse = createSchemaDto<
Object.assign(this, data); typeof LogoutResponseSchema,
Object.freeze(this); "success"
} >(LogoutResponseSchema);
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);
}
}
@@ -1,26 +1,16 @@
import { import {
FeedbackSubmitResponseSchema, FeedbackSubmitResponseSchema,
type FeedbackSubmitResponseData,
type FeedbackSubmitResponseInput,
} from "@/data/schemas/feedback"; } from "@/data/schemas/feedback";
import {
createSchemaDto,
type SchemaDto,
} from "@/data/dto/schema_dto";
export class FeedbackSubmitResponse { export type FeedbackSubmitResponse = SchemaDto<
declare readonly feedbackId: string; typeof FeedbackSubmitResponseSchema,
"feedbackId"
private constructor(input: FeedbackSubmitResponseInput) { >;
Object.assign(this, FeedbackSubmitResponseSchema.parse(input)); export const FeedbackSubmitResponse = createSchemaDto<
Object.freeze(this); typeof FeedbackSubmitResponseSchema,
} "feedbackId"
>(FeedbackSubmitResponseSchema);
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);
}
}
+10 -25
View File
@@ -3,30 +3,15 @@
*/ */
import { import {
AppEventSchema, AppEventSchema,
type AppEventInput,
type AppEventData,
} from "@/data/schemas/metrics/request/app_event"; } from "@/data/schemas/metrics/request/app_event";
import {
createSchemaDto,
type SchemaDto,
} from "@/data/dto/schema_dto";
export class AppEvent { type AppEventPublicKey = "userId" | "browser" | "userAgent";
declare readonly userId: string; export type AppEvent = SchemaDto<typeof AppEventSchema, AppEventPublicKey>;
declare readonly browser: string; export const AppEvent = createSchemaDto<
declare readonly userAgent: string; typeof AppEventSchema,
AppEventPublicKey
private constructor(input: AppEventInput) { >(AppEventSchema);
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);
}
}
+15 -27
View File
@@ -3,32 +3,20 @@
*/ */
import { import {
PwaEventSchema, PwaEventSchema,
type PwaEventInput,
type PwaEventData,
} from "@/data/schemas/metrics/request/pwa_event"; } from "@/data/schemas/metrics/request/pwa_event";
import {
createSchemaDto,
type SchemaDto,
} from "@/data/dto/schema_dto";
export class PwaEvent { type PwaEventPublicKey =
declare readonly deviceId: string; | "deviceId"
declare readonly deviceType: string; | "deviceType"
declare readonly timestamp: number; | "timestamp"
declare readonly pwaInstalled: boolean; | "pwaInstalled"
declare readonly pwaSupported: boolean; | "pwaSupported";
export type PwaEvent = SchemaDto<typeof PwaEventSchema, PwaEventPublicKey>;
private constructor(input: PwaEventInput) { export const PwaEvent = createSchemaDto<
const data = PwaEventSchema.parse(input); typeof PwaEventSchema,
Object.assign(this, data); PwaEventPublicKey
Object.freeze(this); >(PwaEventSchema);
}
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);
}
}
+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 { export class ApiPath {
private constructor() {} 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 登录 */ /** Google 登录 */
static readonly googleLogin = `${ApiPath._auth}/login/google`; static readonly googleLogin = apiContract.googleLogin.path;
/** Facebook 登录 */ /** Facebook 登录 */
static readonly facebookLogin = `${ApiPath._auth}/login/facebook`; static readonly facebookLogin = apiContract.facebookLogin.path;
/** Facebook ID 登录(v7.0 新增) */ /** Facebook ID 登录(v7.0 新增) */
static readonly facebookIdLogin = `${ApiPath._auth}/login/facebook/by-id`; static readonly facebookIdLogin = apiContract.facebookIdLogin.path;
/** Facebook PSID 登录 */ /** Facebook PSID 登录 */
static readonly facebookPsidLogin = `${ApiPath._auth}/login/facebook/psid`; static readonly facebookPsidLogin = apiContract.facebookPsidLogin.path;
/** 刷新 Token */ /** 刷新 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 等价) */ /** 获取个人信息(与 /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 */ /** 绑定 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 { 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 事件 */ /** 上报 PWA 事件 */
static readonly metricsPwaEvent = `${ApiPath._metrics}/pwa/event`; static readonly metricsPwaEvent = apiContract.metricsPwaEvent.path;
// ============ 数据上报相关(v7.0 新增) ============ // ============ 数据上报相关(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()); const rootLogger: PinoLogger = pino(createLoggerOptions());
function createLoggerOptions(): LoggerOptions { function createLoggerOptions(): LoggerOptions {
if (process.env.NODE_ENV === "test") {
return { level: "silent" };
}
if (!AppEnvUtil.canOutputLogs()) { if (!AppEnvUtil.canOutputLogs()) {
return { level: "silent" }; return { level: "silent" };
} }