refactor(data): establish API contract guardrails
CI / Quality and Bundle Budgets (push) Has been cancelled
CI / Quality and Bundle Budgets (push) Has been cancelled
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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` 执行。
|
||||||
@@ -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_*` 会在构建期固化到产物中,因此测试环境和生产环境会分别构建镜像。
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.`,
|
||||||
|
);
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user