diff --git a/CLAUDE.md b/CLAUDE.md index e7f97931682..fe2ea132305 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,13 +79,42 @@ pnpm -C apps/core run test:watch ## API Response Rules -`ResponseInterceptor` auto-wraps responses: -- **Array** → `{ data: [...] }` (always wrapped) -- **Object** → returned directly (no wrapper) -- **@Paginator** → `{ data: [...], pagination: {...} }` (requires `model.paginate()` result) -- **@Bypass** → skips all transformation +Every successful JSON response has the shape `{ data, meta? }`. Every error has the shape `{ error: { code, message, details? } }`. + +**Success envelope** — `ResponseInterceptorV2` (global `APP_INTERCEPTOR`) wraps controller return values: +- A bare value `T` → `{ data: T }` +- A value produced via `withMeta(data, meta)` (from `~/common/response/envelope.types`) → emitted as `{ data, meta }`. Detection is by an internal `Symbol`, **not** by the presence of a `data` key — returning a literal `{ data, ... }` will be double-wrapped. CI enforces this via `scripts/check-controller-response-envelope.ts`. +- `undefined` → `204 No Content` + +**Error envelope** — `AppExceptionFilter` (global `APP_FILTER`) maps every thrown error: +- `AppException` (and subclasses) → `{ error: { code, message, details? } }` at the exception's HTTP status +- `ZodError` → 400 `VALIDATION_FAILED` with `details.issues` +- Other `HttpException` → `{ error: { code: 'HTTP_ERROR', message } }` +- Unknown errors → 500 `INTERNAL_ERROR` + +**Exceptions** — extend `AppException` with a stable `SCREAMING_SNAKE` code: +```ts +throw new BizException(ErrorCodeEnum.PostNotFound) // code: 'PostNotFound', 404 +throw new CannotFindException() // code: 'NOT_FOUND', 404 +throw new BanInDemoExcpetion() // code: 'DEMO_FORBIDDEN', 403 +throw new NoContentCanBeModifiedException() // code: 'NO_CONTENT_MODIFIABLE', 400 +``` + +**Meta** — use `MetaObjectBuilder` for cross-cutting per-request data (pagination, translation, enrichment, interaction). Located in `src/common/response/meta-builder.ts`. + +**Named views** — field selection uses `*.views.ts` Zod schemas (e.g. `PostViews.card`, `PostViews.detail`) instead of a `?select=` parameter. Views are parsed at the controller layer. + +**Case conversion** — code is camelCase end to end (Drizzle column TS props, Zod DTOs, services). The `RequestCaseNormalizationPipe` (global, runs before the Zod validation pipe) folds incoming request keys to camelCase: query and path params are camelized recursively; request bodies are camelized **only at the top level** so freeform JSON values (`meta`, `socialIds`, AI agent `messages`, snippet payloads) survive verbatim. Both `?sort_by=` and `?sortBy=` reach the controller as `sortBy`. `ResponseInterceptorV2` converts the response `data`/`meta` back to snake_case at the wire boundary (`transformResponseCase` in `src/common/response/case-transform.ts`); the wire format stays snake_case. DB column names are unchanged — each Drizzle column keeps its explicit snake_case name string. Never call a manual `snakeCaseKeys`-style helper in a controller. + +**`@BypassCaseTransform([paths])`** — opt a field subtree out of snake_case conversion (free-form JSON columns, snippet payloads). Paths root at `data`, dotted segments, `[]` marks an array level (e.g. `'items[].rawPayload'`). Located in `src/common/decorators/bypass-case-transform.decorator.ts`. + +**`@HTTPDecorators.RawResponse`** — opt out of the whole envelope + casing pipeline for non-JSON responses (streams, HTML, RSS, redirects). Located in `src/common/decorators/http.decorator.ts`. -`JSONTransformInterceptor` converts all keys to **snake_case** (e.g., `createdAt` → `created_at`) +**Writing a new endpoint:** +1. Return `` for a bare envelope, or `withMeta(, new MetaObjectBuilder()...build())` for `{ data, meta }`. Never return an object literal whose top-level keys include `data`. +2. Throw `AppException` subclasses (or `BizException` with an `ErrorCodeEnum` code) for errors. +3. Use `@HTTPDecorators.RawResponse` only if the response is not JSON. +4. Define or reuse a view in `.views.ts` and parse through it before returning. ## Testing diff --git a/apps/core/package.json b/apps/core/package.json index 2bbdf324b34..77727c669b7 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -137,7 +137,6 @@ "rxjs": "7.8.2", "semver": "7.8.0", "slugify": "1.6.9", - "snakecase-keys": "9.0.2", "source-map-support": "^0.5.21", "ua-parser-js": "2.0.9", "wildcard-match": "5.1.4", diff --git a/apps/core/src/app.config.test.ts b/apps/core/src/app.config.test.ts index 9d9aaad50aa..2e263e3b51b 100644 --- a/apps/core/src/app.config.test.ts +++ b/apps/core/src/app.config.test.ts @@ -1,7 +1,7 @@ import type { AxiosRequestConfig } from 'axios' export const PORT = process.env.PORT || 2333 -export const API_VERSION = 2 +export const API_VERSION = 3 export const CROSS_DOMAIN = { allowedOrigins: process.env.ALLOWED_ORIGINS diff --git a/apps/core/src/app.config.ts b/apps/core/src/app.config.ts index a7c823e99d4..e5654b3502d 100644 --- a/apps/core/src/app.config.ts +++ b/apps/core/src/app.config.ts @@ -213,7 +213,7 @@ if (argv.config) { applyArgvEnvFallback(argv) export const PORT = argv.port || 2333 -export const API_VERSION = 2 +export const API_VERSION = 3 export const DEMO_MODE = argv.demo || false @@ -319,7 +319,7 @@ export const ENCRYPT = { if (ENCRYPT.enable && (!ENCRYPT.key || ENCRYPT.key.length !== 64)) throw new Error( - `你开启了 Key 加密(MX_ENCRYPT_KEY or --encrypt_key),但是 Key 的长度不为 64,当前:${ENCRYPT.key.length}`, + `Key encryption is enabled (MX_ENCRYPT_KEY or --encrypt_key), but the key length is not 64. Current length: ${ENCRYPT.key.length}`, ) export const TELEMETRY = { diff --git a/apps/core/src/app.controller.ts b/apps/core/src/app.controller.ts index 3fb36fb5e77..cb75496bfdb 100644 --- a/apps/core/src/app.controller.ts +++ b/apps/core/src/app.controller.ts @@ -12,11 +12,11 @@ import { Auth } from '~/common/decorators/auth.decorator' import { PKG } from '~/utils/pkg.util' import { HttpCache } from './common/decorators/cache.decorator' -import { HTTPDecorators } from './common/decorators/http.decorator' import type { IpRecord } from './common/decorators/ip.decorator' import { IpLocation } from './common/decorators/ip.decorator' import { AllowAllCorsInterceptor } from './common/interceptors/allow-all-cors.interceptor' import { RedisKeys } from './constants/cache.constant' +import { isDev } from './global/env.global' import { ConfigsService } from './modules/configs/configs.service' import { RedisService } from './processors/redis/redis.service' import { getRedisKey } from './utils/redis.util' @@ -30,7 +30,6 @@ export class AppController { @Get('/uptime') @HttpCache.disable - @HTTPDecorators.Bypass async getUptime() { const ts = (process.uptime() * 1000) | 0 return { @@ -68,7 +67,7 @@ export class AppController { ip, ) if (isLikedBefore) { - throw new BadRequestException('一天一次就够啦') + throw new BadRequestException('Once a day is enough') } else { redis.sadd(getRedisKey(RedisKeys.LikeSite), ip) } diff --git a/apps/core/src/app.module.ts b/apps/core/src/app.module.ts index 05984ea9f5e..226dec49301 100644 --- a/apps/core/src/app.module.ts +++ b/apps/core/src/app.module.ts @@ -9,7 +9,7 @@ import { Module } from '@nestjs/common' import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core' import { AppController } from './app.controller' -import { AllExceptionsFilter } from './common/filters/any-exception.filter' +import { AppExceptionFilter } from './common/filters/app-exception.filter' import { RolesGuard } from './common/guards/roles.guard' import { SpiderGuard } from './common/guards/spider.guard' import { ExtendThrottlerGuard } from './common/guards/throttler.guard' @@ -17,9 +17,7 @@ import { AnalyzeInterceptor } from './common/interceptors/analyze.interceptor' import { HttpCacheInterceptor } from './common/interceptors/cache.interceptor' import { DbQueryInterceptor } from './common/interceptors/db-query.interceptor' import { IdempotenceInterceptor } from './common/interceptors/idempotence.interceptor' -import { JSONTransformInterceptor } from './common/interceptors/json-transform.interceptor' -import { ResponseInterceptor } from './common/interceptors/response.interceptor' -import { TranslationEntryInterceptor } from './common/interceptors/translation-entry.interceptor' +import { ResponseInterceptorV2 } from './common/interceptors/response.interceptor' import { RequestContextMiddleware } from './common/middlewares/request-context.middleware' import { AppMigrationsModule } from './database/app-migrations/app-migrations.module' import { AckModule } from './modules/ack/ack.module' @@ -152,17 +150,9 @@ import { TaskQueueModule } from './processors/task-queue/task-queue.module' { provide: APP_INTERCEPTOR, - useClass: JSONTransformInterceptor, + useClass: ResponseInterceptorV2, }, - { - provide: APP_INTERCEPTOR, - useClass: ResponseInterceptor, - }, - { - provide: APP_INTERCEPTOR, - useClass: TranslationEntryInterceptor, - }, { provide: APP_INTERCEPTOR, useClass: IdempotenceInterceptor, @@ -170,7 +160,7 @@ import { TaskQueueModule } from './processors/task-queue/task-queue.module' { provide: APP_FILTER, - useClass: AllExceptionsFilter, + useClass: AppExceptionFilter, }, { provide: APP_GUARD, diff --git a/apps/core/src/bootstrap.ts b/apps/core/src/bootstrap.ts index 3895757ffe8..29fe42d87e6 100644 --- a/apps/core/src/bootstrap.ts +++ b/apps/core/src/bootstrap.ts @@ -13,6 +13,7 @@ import { AppModule } from './app.module' import { fastifyApp } from './common/adapters/fastify.adapter' import { RedisIoAdapter } from './common/adapters/socket.adapter' import { LoggingInterceptor } from './common/interceptors/logging.interceptor' +import { requestCaseNormalizationPipeInstance } from './common/pipes/case-normalization.pipe' import { extendedZodValidationPipeInstance } from './common/zod' import { AppMigrationsService } from './database/app-migrations/app-migrations.service' import { logger } from './global/consola.global' @@ -37,7 +38,7 @@ export async function bootstrap() { fastifyApp, ) - // 使用自定义 Logger 替换 NestJS 内置 Logger + // Replace NestJS built-in logger with our custom Logger app.useLogger(app.get(Logger)) const allowAllCors: FastifyCorsOptions = { @@ -45,7 +46,7 @@ export async function bootstrap() { methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], origin: (origin, callback) => callback(null, origin || ''), } - // Origin 如果不是数组就全部允许跨域 + // If Origin is not an array, allow all cross-origin requests app.enableCors( isDev @@ -79,7 +80,10 @@ export async function bootstrap() { app.useGlobalInterceptors(new LoggingInterceptor()) } - app.useGlobalPipes(extendedZodValidationPipeInstance) + app.useGlobalPipes( + requestCaseNormalizationPipeInstance, + extendedZodValidationPipeInstance, + ) !isTest && app.useWebSocketAdapter(new RedisIoAdapter(app, app.get(RedisService))) diff --git a/apps/core/src/common/adapters/fastify.adapter.ts b/apps/core/src/common/adapters/fastify.adapter.ts index 802a1495ea6..a15efc40c66 100644 --- a/apps/core/src/common/adapters/fastify.adapter.ts +++ b/apps/core/src/common/adapters/fastify.adapter.ts @@ -2,9 +2,10 @@ import fastifyCookie from '@fastify/cookie' import FastifyMultipart from '@fastify/multipart' import { Logger } from '@nestjs/common' import { FastifyAdapter } from '@nestjs/platform-fastify' -import { getIp } from '~/utils/ip.util' import type { FastifyRequest } from 'fastify' +import { getIp } from '~/utils/ip.util' + const logger = new Logger('Fastify') function logWarn(desc: string, req: FastifyRequest, _context: string) { @@ -43,7 +44,7 @@ app.getInstance().addHook('onRequest', (request, reply, done) => { if (url.endsWith('.php')) { reply.raw.statusMessage = 'Eh. PHP is not support on this machine. Yep, I also think PHP is bestest programming language. But for me it is beyond my reach.' - logWarn('PHP 是世界上最好的语言!!!!!', request, 'GodPHP') + logWarn('PHP is the best language in the world!!!!!', request, 'GodPHP') return reply.code(418).send() } else if (/\/(?:adminer|admin|wp-login|phpmyadmin|\.env)$/i.test(url)) { @@ -51,7 +52,7 @@ app.getInstance().addHook('onRequest', (request, reply, done) => { reply.raw.statusMessage = 'Hey, What the fuck are you doing!' reply.raw.statusCode = isMxSpaceClient ? 666 : 200 logWarn( - '注意了,有人正在搞渗透,让我看看是谁,是哪个小坏蛋这么不听话。\n', + 'Heads up — someone is probing for vulnerabilities. Let me see which little troublemaker this is.\n', request, 'Security', ) diff --git a/apps/core/src/common/controllers/base-task.controller.ts b/apps/core/src/common/controllers/base-task.controller.ts index b51ffe282d3..2a87af87250 100644 --- a/apps/core/src/common/controllers/base-task.controller.ts +++ b/apps/core/src/common/controllers/base-task.controller.ts @@ -1,9 +1,10 @@ import { Delete, Get, Param, Post, Query } from '@nestjs/common' + import { Auth } from '~/common/decorators/auth.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import type { ScopedTaskService } from '~/processors/task-queue/scoped-task.service' import { StringIdDto } from '~/shared/dto/id.dto' + import { BaseDeleteTasksQueryDto, BaseGetTasksQueryDto } from './base-task.dto' export abstract class BaseTaskController { @@ -14,7 +15,9 @@ export abstract class BaseTaskController { async getTask(@Param() params: StringIdDto) { const task = await this.taskCrudService.getTask(params.id) if (!task) { - throw new BizException(ErrorCodeEnum.AITaskNotFound) + throw createAppException(AppErrorCode.AI_TASK_NOT_FOUND, { + id: params.id, + }) } return task } diff --git a/apps/core/src/common/decorators/bypass-case-transform.decorator.ts b/apps/core/src/common/decorators/bypass-case-transform.decorator.ts new file mode 100644 index 00000000000..195bc9bed01 --- /dev/null +++ b/apps/core/src/common/decorators/bypass-case-transform.decorator.ts @@ -0,0 +1,20 @@ +import { SetMetadata } from '@nestjs/common' + +import { BYPASS_CASE_TRANSFORM_METADATA } from '~/constants/system.constant' + +/** + * Opt a field subtree out of snake_case key conversion. + * + * Paths root at the response `data`. Use dotted segments to descend objects, + * and `[]` to descend an array (e.g. `'items[].rawPayload'`). + * + * When a path matches, the entire matched subtree is emitted **verbatim** — + * every nested key inside is preserved as-is, regardless of depth. Only the + * matched node's own key on its parent is still snake-cased (because that + * conversion is done by the parent). + * + * Use for free-form JSON columns and snippet payloads whose keys are + * meaningful as-is to the consumer. + */ +export const BypassCaseTransform = (paths: string[]) => + SetMetadata(BYPASS_CASE_TRANSFORM_METADATA, paths) diff --git a/apps/core/src/common/decorators/http.decorator.ts b/apps/core/src/common/decorators/http.decorator.ts index 7279b79e5e2..af1f5345374 100644 --- a/apps/core/src/common/decorators/http.decorator.ts +++ b/apps/core/src/common/decorators/http.decorator.ts @@ -5,8 +5,6 @@ import * as SYSTEM from '~/constants/system.constant' import type { IdempotenceOption } from '../interceptors/idempotence.interceptor' -export const Bypass = SetMetadata(SYSTEM.RESPONSE_PASSTHROUGH_METADATA, true) - export declare interface FileDecoratorProps { description: string } @@ -18,8 +16,13 @@ export const Idempotence: (options?: IdempotenceOption) => MethodDecorator = export const SkipLogging = SetMetadata(SYSTEM.SKIP_LOGGING_METADATA, true) +export const RawResponse = SetMetadata( + SYSTEM.RESPONSE_PASSTHROUGH_METADATA, + true, +) + export const HTTPDecorators = { - Bypass, Idempotence, SkipLogging, + RawResponse, } diff --git a/apps/core/src/common/decorators/translate-fields.decorator.ts b/apps/core/src/common/decorators/translate-fields.decorator.ts deleted file mode 100644 index 9c6b7471965..00000000000 --- a/apps/core/src/common/decorators/translate-fields.decorator.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { SetMetadata } from '@nestjs/common' - -import type { TranslationEntryKeyPath } from '~/modules/ai/ai-translation/translation-entry.types' - -export const TRANSLATE_FIELDS_KEY = 'translate_fields' - -export interface TranslateFieldRule { - path: string - keyPath: TranslationEntryKeyPath - idField?: string -} - -export const TranslateFields = (...rules: TranslateFieldRule[]) => - SetMetadata(TRANSLATE_FIELDS_KEY, rules) diff --git a/apps/core/src/common/errors/app-error-code.ts b/apps/core/src/common/errors/app-error-code.ts new file mode 100644 index 00000000000..69893fd351d --- /dev/null +++ b/apps/core/src/common/errors/app-error-code.ts @@ -0,0 +1,199 @@ +export enum AppErrorCode { + // generic + INTERNAL_ERROR = 'INTERNAL_ERROR', + NOT_FOUND = 'NOT_FOUND', + NO_CONTENT_MODIFIABLE = 'NO_CONTENT_MODIFIABLE', + DEMO_FORBIDDEN = 'DEMO_FORBIDDEN', + RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND', + CONTENT_NOT_FOUND = 'CONTENT_NOT_FOUND', + CONTENT_NOT_FOUND_CANT_PROCESS = 'CONTENT_NOT_FOUND_CANT_PROCESS', + MAX_COUNT_LIMIT = 'MAX_COUNT_LIMIT', + MASTER_LOST = 'MASTER_LOST', + CANNOT_GET_IP = 'CANNOT_GET_IP', + ENTRY_NOT_FOUND = 'ENTRY_NOT_FOUND', + REF_MODEL_NOT_FOUND = 'REF_MODEL_NOT_FOUND', + + // validation + INVALID_PARAMETER = 'INVALID_PARAMETER', + INVALID_BODY = 'INVALID_BODY', + INVALID_SLUG = 'INVALID_SLUG', + INVALID_NAME = 'INVALID_NAME', + INVALID_REFERENCE = 'INVALID_REFERENCE', + INVALID_ORDER_VALUE = 'INVALID_ORDER_VALUE', + INVALID_SEARCH_TYPE = 'INVALID_SEARCH_TYPE', + INVALID_ROOM_NAME = 'INVALID_ROOM_NAME', + INVALID_SUBSCRIBE_TYPE = 'INVALID_SUBSCRIBE_TYPE', + INVALID_VIEW = 'INVALID_VIEW', + SLUG_NOT_AVAILABLE = 'SLUG_NOT_AVAILABLE', + VALIDATION_FAILED = 'VALIDATION_FAILED', + + // ack + ACK_INVALID_PAYLOAD = 'ACK_INVALID_PAYLOAD', + + // ai + AI_CONTENT_MISSING = 'AI_CONTENT_MISSING', + AI_INVALID_PARAMETER = 'AI_INVALID_PARAMETER', + AI_INVALID_QUERY_TYPE = 'AI_INVALID_QUERY_TYPE', + AI_NOT_ENABLED = 'AI_NOT_ENABLED', + AI_KEY_EXPIRED = 'AI_KEY_EXPIRED', + AI_PROCESSING = 'AI_PROCESSING', + AI_PROVIDER_DISABLED = 'AI_PROVIDER_DISABLED', + AI_PROVIDER_NOT_FOUND = 'AI_PROVIDER_NOT_FOUND', + AI_REVIEW_NOT_ENABLED = 'AI_REVIEW_NOT_ENABLED', + AI_RESULT_PARSING_ERROR = 'AI_RESULT_PARSING_ERROR', + AI_SERVICE_ERROR = 'AI_SERVICE_ERROR', + AI_TASK_NOT_FOUND = 'AI_TASK_NOT_FOUND', + AI_TASK_ALREADY_COMPLETED = 'AI_TASK_ALREADY_COMPLETED', + AI_TASK_CANNOT_RETRY = 'AI_TASK_CANNOT_RETRY', + AI_TRANSLATION_NOT_FOUND = 'AI_TRANSLATION_NOT_FOUND', + + // auth + AUTH_DEVICE_FLOW_PENDING = 'AUTH_DEVICE_FLOW_PENDING', + AUTH_INVALID_CREDENTIALS = 'AUTH_INVALID_CREDENTIALS', + AUTH_NOT_LOGGED_IN = 'AUTH_NOT_LOGGED_IN', + AUTH_SESSION_EXPIRED = 'AUTH_SESSION_EXPIRED', + AUTH_TOKEN_NOT_FOUND = 'AUTH_TOKEN_NOT_FOUND', + AUTH_CHALLENGE_MISSING = 'AUTH_CHALLENGE_MISSING', + AUTH_CHALLENGE_EXPIRED = 'AUTH_CHALLENGE_EXPIRED', + AUTH_REGISTRATION_MISSING = 'AUTH_REGISTRATION_MISSING', + AUTH_USERNAME_INCORRECT = 'AUTH_USERNAME_INCORRECT', + AUTH_PASSWORD_INCORRECT = 'AUTH_PASSWORD_INCORRECT', + AUTH_SESSION_NOT_FOUND = 'AUTH_SESSION_NOT_FOUND', + AUTH_USER_ID_NOT_FOUND = 'AUTH_USER_ID_NOT_FOUND', + AUTH_FAILED = 'AUTH_FAILED', + AUTH_TOKEN_INVALID = 'AUTH_TOKEN_INVALID', + PASSWORD_LOGIN_DISABLED = 'PASSWORD_LOGIN_DISABLED', + PASSWORD_SAME_AS_OLD = 'PASSWORD_SAME_AS_OLD', + + // category + CATEGORY_NOT_FOUND = 'CATEGORY_NOT_FOUND', + CATEGORY_HAS_POSTS = 'CATEGORY_HAS_POSTS', + + // comment + COMMENT_DISABLED = 'COMMENT_DISABLED', + COMMENT_FORBIDDEN = 'COMMENT_FORBIDDEN', + COMMENT_NOT_FOUND = 'COMMENT_NOT_FOUND', + COMMENT_TOO_DEEP = 'COMMENT_TOO_DEEP', + COMMENT_POST_NOT_EXISTS = 'COMMENT_POST_NOT_EXISTS', + COMMENT_IMAGE_CAP_EXCEEDED = 'COMMENT_IMAGE_CAP_EXCEEDED', + COMMENT_UPLOAD_DISABLED = 'COMMENT_UPLOAD_DISABLED', + COMMENT_UPLOAD_FILE_TOO_LARGE = 'COMMENT_UPLOAD_FILE_TOO_LARGE', + COMMENT_UPLOAD_INVALID_MIME = 'COMMENT_UPLOAD_INVALID_MIME', + COMMENT_UPLOAD_FILE_NOT_OWNED = 'COMMENT_UPLOAD_FILE_NOT_OWNED', + COMMENT_UPLOAD_FILE_ALREADY_BOUND = 'COMMENT_UPLOAD_FILE_ALREADY_BOUND', + COMMENT_UPLOAD_RATE_LIMITED = 'COMMENT_UPLOAD_RATE_LIMITED', + COMMENT_UPLOAD_QUOTA_EXCEEDED = 'COMMENT_UPLOAD_QUOTA_EXCEEDED', + COMMENT_UPLOAD_ACCOUNT_TOO_NEW = 'COMMENT_UPLOAD_ACCOUNT_TOO_NEW', + COMMENT_UPLOAD_INSUFFICIENT_COMMENTS = 'COMMENT_UPLOAD_INSUFFICIENT_COMMENTS', + + // config + CONFIG_NOT_FOUND = 'CONFIG_NOT_FOUND', + CONFIG_VALIDATION_FAILED = 'CONFIG_VALIDATION_FAILED', + + // cron + CRON_NOT_FOUND = 'CRON_NOT_FOUND', + INVALID_CRON_METHOD = 'INVALID_CRON_METHOD', + FUNCTION_NOT_FOUND = 'FUNCTION_NOT_FOUND', + + // draft + DRAFT_NOT_FOUND = 'DRAFT_NOT_FOUND', + DRAFT_HISTORY_NOT_FOUND = 'DRAFT_HISTORY_NOT_FOUND', + + // document / helper + DOCUMENT_NOT_FOUND = 'DOCUMENT_NOT_FOUND', + HELPER_DOCUMENT_NOT_FOUND = 'HELPER_DOCUMENT_NOT_FOUND', + + // email + EMAIL_TEMPLATE_NOT_FOUND = 'EMAIL_TEMPLATE_NOT_FOUND', + + // enrichment + ENRICHMENT_BROWSER_MODE_REQUIRED = 'ENRICHMENT_BROWSER_MODE_REQUIRED', + ENRICHMENT_CAPTURE_FAILED = 'ENRICHMENT_CAPTURE_FAILED', + ENRICHMENT_NOT_FOUND = 'ENRICHMENT_NOT_FOUND', + ENRICHMENT_SCREENSHOT_DISABLED = 'ENRICHMENT_SCREENSHOT_DISABLED', + + // file + FILE_NOT_FOUND = 'FILE_NOT_FOUND', + FILE_EXISTS = 'FILE_EXISTS', + FILE_RENAME_FAILED = 'FILE_RENAME_FAILED', + FILE_STORAGE_NOT_CONFIGURED = 'FILE_STORAGE_NOT_CONFIGURED', + FILE_UPLOAD_DISABLED = 'FILE_UPLOAD_DISABLED', + FILE_UPLOAD_NOT_AUTHORIZED = 'FILE_UPLOAD_NOT_AUTHORIZED', + MIME_ZIP_REQUIRED = 'MIME_ZIP_REQUIRED', + + // init + INIT_ALREADY_COMPLETED = 'INIT_ALREADY_COMPLETED', + INIT_FORBIDDEN = 'INIT_FORBIDDEN', + INIT_INVALID_BODY = 'INIT_INVALID_BODY', + INIT_INVALID_MIME_TYPE = 'INIT_INVALID_MIME_TYPE', + + // link + LINK_APPLY_DISABLED = 'LINK_APPLY_DISABLED', + LINK_NOT_FOUND = 'LINK_NOT_FOUND', + LINK_DISABLED = 'LINK_DISABLED', + SUBPATH_LINK_DISABLED = 'SUBPATH_LINK_DISABLED', + DUPLICATE_LINK = 'DUPLICATE_LINK', + LINK_AVATAR_VALIDATION_FAILED = 'LINK_AVATAR_VALIDATION_FAILED', + + // meta preset + META_PRESET_KEY_EXISTS = 'META_PRESET_KEY_EXISTS', + META_PRESET_NOT_FOUND = 'META_PRESET_NOT_FOUND', + BUILTIN_PRESET_CANNOT_DELETE = 'BUILTIN_PRESET_CANNOT_DELETE', + + // note + NOTE_FORBIDDEN = 'NOTE_FORBIDDEN', + NOTE_NOT_FOUND = 'NOTE_NOT_FOUND', + NOTE_PASSWORD_REQUIRED = 'NOTE_PASSWORD_REQUIRED', + + // owner / reader / user + OWNER_NOT_FOUND = 'OWNER_NOT_FOUND', + READER_NOT_FOUND = 'READER_NOT_FOUND', + USER_NOT_EXISTS = 'USER_NOT_EXISTS', + USER_ALREADY_EXISTS = 'USER_ALREADY_EXISTS', + + // page + PAGE_NOT_FOUND = 'PAGE_NOT_FOUND', + + // post + POST_NOT_FOUND = 'POST_NOT_FOUND', + POST_UNPUBLISHED = 'POST_UNPUBLISHED', + POST_HIDDEN_OR_ENCRYPTED = 'POST_HIDDEN_OR_ENCRYPTED', + POST_RELATED_NOT_EXISTS = 'POST_RELATED_NOT_EXISTS', + POST_SELF_RELATION = 'POST_SELF_RELATION', + + // recently + RECENTLY_NOT_FOUND = 'RECENTLY_NOT_FOUND', + + // backup + BACKUP_NOT_ENABLED = 'BACKUP_NOT_ENABLED', + + // serverless + SERVERLESS_ERROR = 'SERVERLESS_ERROR', + SERVERLESS_NO_PERMISSION = 'SERVERLESS_NO_PERMISSION', + + // snippet + SNIPPET_NOT_FOUND = 'SNIPPET_NOT_FOUND', + SNIPPET_EXISTS = 'SNIPPET_EXISTS', + SNIPPET_PRIVATE = 'SNIPPET_PRIVATE', + SNIPPET_INVALID_JSON = 'SNIPPET_INVALID_JSON', + SNIPPET_INVALID_JSON5 = 'SNIPPET_INVALID_JSON5', + SNIPPET_INVALID_YAML = 'SNIPPET_INVALID_YAML', + SNIPPET_INVALID_FUNCTION = 'SNIPPET_INVALID_FUNCTION', + + // subscribe + SUBSCRIBE_NOT_ENABLED = 'SUBSCRIBE_NOT_ENABLED', + SUBSCRIBE_TYPE_EMPTY = 'SUBSCRIBE_TYPE_EMPTY', + ALREADY_SUPPORTED = 'ALREADY_SUPPORTED', + + // topic + TOPIC_NOT_FOUND = 'TOPIC_NOT_FOUND', + + // webhook + WEBHOOK_EVENT_NOT_FOUND = 'WEBHOOK_EVENT_NOT_FOUND', + WEBHOOK_NOT_FOUND = 'WEBHOOK_NOT_FOUND', + + // bing + BING_API_FAILED = 'BING_API_FAILED', + BING_KEY_INVALID = 'BING_KEY_INVALID', + BING_DOMAIN_INVALID = 'BING_DOMAIN_INVALID', +} diff --git a/apps/core/src/common/errors/app-error-definitions.ts b/apps/core/src/common/errors/app-error-definitions.ts new file mode 100644 index 00000000000..3a4bd501a35 --- /dev/null +++ b/apps/core/src/common/errors/app-error-definitions.ts @@ -0,0 +1,694 @@ +import { AppErrorCode } from './app-error-code' +import type { AppErrorPayloadMap } from './app-error-payload' + +type PayloadFor = AppErrorPayloadMap[C] +type PresentPayloadFor = Exclude< + PayloadFor, + undefined +> + +type AppErrorDefinition = + undefined extends PayloadFor + ? { + status: number + message: + | string + | ((payload: PresentPayloadFor | undefined) => string) + details?: ( + payload: PresentPayloadFor | undefined, + ) => Record | undefined + } + : { + status: number + message: string | ((payload: PresentPayloadFor) => string) + details?: ( + payload: PresentPayloadFor, + ) => Record | undefined + } + +const withExtra = (msg: string) => (payload: { extra?: string } | undefined) => + payload?.extra ? `${msg}: ${payload.extra}` : msg + +export const APP_ERROR_DEFINITIONS = { + // generic + [AppErrorCode.INTERNAL_ERROR]: { + status: 500, + message: (p) => p?.message ?? 'Internal server error', + }, + [AppErrorCode.NOT_FOUND]: { + status: 404, + message: (p) => p?.message ?? 'Not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.NO_CONTENT_MODIFIABLE]: { + status: 400, + message: 'No content to modify', + }, + [AppErrorCode.DEMO_FORBIDDEN]: { + status: 403, + message: 'This operation is not allowed in demo mode', + }, + [AppErrorCode.RESOURCE_NOT_FOUND]: { + status: 404, + message: 'Resource not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.CONTENT_NOT_FOUND]: { + status: 404, + message: 'Content not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS]: { + status: 400, + message: (p) => p?.message ?? 'Content not found, cannot process', + }, + [AppErrorCode.MAX_COUNT_LIMIT]: { status: 400, message: 'Max count reached' }, + [AppErrorCode.MASTER_LOST]: { + status: 500, + message: 'Site owner information is missing', + }, + [AppErrorCode.CANNOT_GET_IP]: { status: 422, message: 'Cannot resolve IP' }, + [AppErrorCode.ENTRY_NOT_FOUND]: { + status: 404, + message: 'Entry not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.REF_MODEL_NOT_FOUND]: { + status: 404, + message: withExtra('Referenced model not found'), + }, + + // validation + [AppErrorCode.INVALID_PARAMETER]: { + status: 400, + message: ({ message }) => message ?? 'Invalid parameter', + }, + [AppErrorCode.INVALID_BODY]: { + status: 422, + message: 'Request body must be an object', + }, + [AppErrorCode.INVALID_SLUG]: { + status: 422, + message: 'slug must be a string', + }, + [AppErrorCode.INVALID_NAME]: { + status: 422, + message: 'name must be a string', + }, + [AppErrorCode.INVALID_REFERENCE]: { + status: 422, + message: 'reference must be a string', + }, + [AppErrorCode.INVALID_ORDER_VALUE]: { + status: 422, + message: 'order value must be unique', + }, + [AppErrorCode.INVALID_SEARCH_TYPE]: { + status: 400, + message: ({ type }) => `Invalid search type: ${type}`, + details: ({ type }) => ({ type }), + }, + [AppErrorCode.INVALID_ROOM_NAME]: { + status: 400, + message: 'Invalid room name', + }, + [AppErrorCode.INVALID_SUBSCRIBE_TYPE]: { + status: 400, + message: 'Invalid subscribe type', + }, + [AppErrorCode.INVALID_VIEW]: { + status: 400, + message: ({ view, available }) => + `Invalid view "${view}"; available: ${available.join(', ')}`, + details: ({ view, available }) => ({ view, available }), + }, + [AppErrorCode.SLUG_NOT_AVAILABLE]: { + status: 400, + message: 'slug is not available', + }, + [AppErrorCode.VALIDATION_FAILED]: { + status: 400, + message: 'Validation failed', + details: (p) => (p?.issues ? { issues: p.issues } : undefined), + }, + + // ack + [AppErrorCode.ACK_INVALID_PAYLOAD]: { + status: 400, + message: (p) => p?.message ?? 'Invalid ack payload', + }, + + // ai + [AppErrorCode.AI_CONTENT_MISSING]: { + status: 400, + message: ({ message }) => message, + }, + [AppErrorCode.AI_INVALID_PARAMETER]: { + status: 400, + message: ({ message }) => message, + }, + [AppErrorCode.AI_INVALID_QUERY_TYPE]: { + status: 400, + message: 'Invalid query type', + }, + [AppErrorCode.AI_NOT_ENABLED]: { + status: 400, + message: (p) => p?.message ?? 'AI feature is not enabled', + }, + [AppErrorCode.AI_KEY_EXPIRED]: { + status: 400, + message: 'AI key has expired, please contact the administrator', + }, + [AppErrorCode.AI_PROCESSING]: { + status: 400, + message: 'AI is processing this request, please try again later', + }, + [AppErrorCode.AI_PROVIDER_DISABLED]: { + status: 400, + message: (p) => + p?.providerId + ? `Provider ${p.providerId} is not enabled` + : 'No enabled AI provider configured', + details: (p) => (p?.providerId ? { providerId: p.providerId } : undefined), + }, + [AppErrorCode.AI_PROVIDER_NOT_FOUND]: { + status: 404, + message: (p) => + p?.providerId + ? `Provider ${p.providerId} not found` + : 'Provider not found', + details: (p) => (p?.providerId ? { providerId: p.providerId } : undefined), + }, + [AppErrorCode.AI_REVIEW_NOT_ENABLED]: { + status: 400, + message: 'AI review is not enabled', + }, + [AppErrorCode.AI_RESULT_PARSING_ERROR]: { + status: 500, + message: (p) => p?.message ?? 'Failed to parse AI result', + }, + [AppErrorCode.AI_SERVICE_ERROR]: { + status: 500, + message: (p) => p?.message ?? 'AI service error', + }, + [AppErrorCode.AI_TASK_NOT_FOUND]: { + status: 404, + message: 'AI task not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.AI_TASK_ALREADY_COMPLETED]: { + status: 400, + message: 'AI task is already completed and cannot be cancelled', + }, + [AppErrorCode.AI_TASK_CANNOT_RETRY]: { + status: 400, + message: (p) => p?.reason ?? 'AI task cannot be retried', + }, + [AppErrorCode.AI_TRANSLATION_NOT_FOUND]: { + status: 404, + message: 'Translation not found', + }, + + // auth + [AppErrorCode.AUTH_DEVICE_FLOW_PENDING]: { + status: 202, + message: 'Device flow pending', + }, + [AppErrorCode.AUTH_INVALID_CREDENTIALS]: { + status: 401, + message: 'Invalid credentials', + }, + [AppErrorCode.AUTH_NOT_LOGGED_IN]: { status: 401, message: 'Not logged in' }, + [AppErrorCode.AUTH_SESSION_EXPIRED]: { + status: 401, + message: 'Session expired', + }, + [AppErrorCode.AUTH_TOKEN_NOT_FOUND]: { + status: 404, + message: 'Token not found', + }, + [AppErrorCode.AUTH_CHALLENGE_MISSING]: { + status: 400, + message: 'Challenge not found', + }, + [AppErrorCode.AUTH_CHALLENGE_EXPIRED]: { + status: 400, + message: 'Challenge has expired', + }, + [AppErrorCode.AUTH_REGISTRATION_MISSING]: { + status: 400, + message: 'Registration record not found', + }, + [AppErrorCode.AUTH_USERNAME_INCORRECT]: { + status: 403, + message: 'Incorrect username', + }, + [AppErrorCode.AUTH_PASSWORD_INCORRECT]: { + status: 403, + message: 'Incorrect password', + }, + [AppErrorCode.AUTH_SESSION_NOT_FOUND]: { + status: 400, + message: 'Session not found', + }, + [AppErrorCode.AUTH_USER_ID_NOT_FOUND]: { + status: 400, + message: 'User id not found', + }, + [AppErrorCode.AUTH_FAILED]: { + status: 400, + message: (p) => + p?.message + ? `Authentication failed: ${p.message}` + : 'Authentication failed', + }, + [AppErrorCode.AUTH_TOKEN_INVALID]: { status: 401, message: 'Invalid token' }, + [AppErrorCode.PASSWORD_LOGIN_DISABLED]: { + status: 400, + message: 'Password login is disabled', + }, + [AppErrorCode.PASSWORD_SAME_AS_OLD]: { + status: 422, + message: 'New password must be different from the old one', + }, + + // category + [AppErrorCode.CATEGORY_NOT_FOUND]: { + status: 404, + message: 'Category not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.CATEGORY_HAS_POSTS]: { + status: 400, + message: 'Category still has posts, cannot delete', + }, + + // comment + [AppErrorCode.COMMENT_DISABLED]: { + status: 403, + message: 'Comments are globally disabled', + }, + [AppErrorCode.COMMENT_FORBIDDEN]: { + status: 403, + message: 'Comments are forbidden by the owner', + }, + [AppErrorCode.COMMENT_NOT_FOUND]: { + status: 404, + message: 'Comment not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.COMMENT_TOO_DEEP]: { + status: 400, + message: 'Comment nesting is too deep', + }, + [AppErrorCode.COMMENT_POST_NOT_EXISTS]: { + status: 400, + message: 'Commented post does not exist', + }, + [AppErrorCode.COMMENT_IMAGE_CAP_EXCEEDED]: { + status: 422, + message: 'Comment image count exceeds the limit', + }, + [AppErrorCode.COMMENT_UPLOAD_DISABLED]: { + status: 503, + message: 'Comment image upload is disabled', + }, + [AppErrorCode.COMMENT_UPLOAD_FILE_TOO_LARGE]: { + status: 413, + message: 'Image size exceeds the limit', + }, + [AppErrorCode.COMMENT_UPLOAD_INVALID_MIME]: { + status: 415, + message: 'Unsupported image format', + }, + [AppErrorCode.COMMENT_UPLOAD_FILE_NOT_OWNED]: { + status: 403, + message: 'Cannot reference an image uploaded by someone else', + }, + [AppErrorCode.COMMENT_UPLOAD_FILE_ALREADY_BOUND]: { + status: 409, + message: 'Image already bound to another comment, please upload again', + }, + [AppErrorCode.COMMENT_UPLOAD_RATE_LIMITED]: { + status: 429, + message: 'Upload too frequent, please try again later', + }, + [AppErrorCode.COMMENT_UPLOAD_QUOTA_EXCEEDED]: { + status: 429, + message: 'Image storage quota exceeded', + }, + [AppErrorCode.COMMENT_UPLOAD_ACCOUNT_TOO_NEW]: { + status: 403, + message: 'Account is too new to upload images', + }, + [AppErrorCode.COMMENT_UPLOAD_INSUFFICIENT_COMMENTS]: { + status: 403, + message: 'More comments required before uploading is allowed', + }, + + // config + [AppErrorCode.CONFIG_NOT_FOUND]: { + status: 404, + message: 'Config not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.CONFIG_VALIDATION_FAILED]: { + status: 422, + message: (p) => p?.message ?? 'Config validation failed', + }, + + // cron + [AppErrorCode.CRON_NOT_FOUND]: { + status: 404, + message: withExtra('Cron task not found'), + }, + [AppErrorCode.INVALID_CRON_METHOD]: { + status: 400, + message: 'Invalid cron method', + }, + [AppErrorCode.FUNCTION_NOT_FOUND]: { + status: 404, + message: (p) => + p?.path ? `Function not found: ${p.path}` : 'Function not found', + details: (p) => (p?.path ? { path: p.path } : undefined), + }, + + // draft + [AppErrorCode.DRAFT_NOT_FOUND]: { + status: 404, + message: 'Draft not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.DRAFT_HISTORY_NOT_FOUND]: { + status: 404, + message: 'Draft history not found', + }, + + // document / helper + [AppErrorCode.DOCUMENT_NOT_FOUND]: { + status: 404, + message: 'Document not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.HELPER_DOCUMENT_NOT_FOUND]: { + status: 404, + message: 'Document not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + + // email + [AppErrorCode.EMAIL_TEMPLATE_NOT_FOUND]: { + status: 400, + message: 'Email template not found', + }, + + // enrichment + [AppErrorCode.ENRICHMENT_BROWSER_MODE_REQUIRED]: { + status: 409, + message: 'Browser mode required for enrichment', + }, + [AppErrorCode.ENRICHMENT_CAPTURE_FAILED]: { + status: 500, + message: 'Enrichment capture failed', + }, + [AppErrorCode.ENRICHMENT_NOT_FOUND]: { + status: 404, + message: 'Enrichment not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.ENRICHMENT_SCREENSHOT_DISABLED]: { + status: 409, + message: 'Enrichment screenshot is disabled', + }, + + // file + [AppErrorCode.FILE_NOT_FOUND]: { + status: 404, + message: (p) => { + if (p?.extra) return `File not found: ${p.extra}` + if (p?.name) return `File not found: ${p.name}` + return 'File not found' + }, + details: (p) => (p?.name ? { name: p.name } : undefined), + }, + [AppErrorCode.FILE_EXISTS]: { status: 400, message: 'File already exists' }, + [AppErrorCode.FILE_RENAME_FAILED]: { + status: 400, + message: 'File rename failed', + }, + [AppErrorCode.FILE_STORAGE_NOT_CONFIGURED]: { + status: 400, + message: 'S3 image storage is not configured', + }, + [AppErrorCode.FILE_UPLOAD_DISABLED]: { + status: 503, + message: 'File upload is disabled', + }, + [AppErrorCode.FILE_UPLOAD_NOT_AUTHORIZED]: { + status: 403, + message: 'File upload is not authorized', + }, + [AppErrorCode.MIME_ZIP_REQUIRED]: { + status: 422, + message: (p) => + p?.got + ? `File must be a zip archive, got: ${p.got}` + : 'File must be a zip archive', + details: (p) => (p?.got ? { got: p.got } : undefined), + }, + + // init + [AppErrorCode.INIT_ALREADY_COMPLETED]: { + status: 400, + message: 'Initialization already completed, please log in to configure', + }, + [AppErrorCode.INIT_FORBIDDEN]: { + status: 403, + message: 'Default settings are hidden after registration', + }, + [AppErrorCode.INIT_INVALID_BODY]: { + status: 422, + message: 'Request body must be an object', + }, + [AppErrorCode.INIT_INVALID_MIME_TYPE]: { + status: 415, + message: (p) => + p?.got ? `Invalid mime type: ${p.got}` : 'Invalid mime type', + details: (p) => (p?.got ? { got: p.got } : undefined), + }, + + // link + [AppErrorCode.LINK_APPLY_DISABLED]: { + status: 403, + message: 'Link applications are currently closed', + }, + [AppErrorCode.LINK_NOT_FOUND]: { + status: 404, + message: 'Link not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.LINK_DISABLED]: { + status: 400, + message: 'Your link has been disabled, please contact the administrator', + }, + [AppErrorCode.SUBPATH_LINK_DISABLED]: { + status: 422, + message: 'Subpath link applications are disabled by the administrator', + }, + [AppErrorCode.DUPLICATE_LINK]: { + status: 400, + message: 'Please do not submit duplicate link applications', + }, + [AppErrorCode.LINK_AVATAR_VALIDATION_FAILED]: { + status: 400, + message: (p) => + p?.reason + ? `Avatar validation failed: ${p.reason}` + : 'Avatar validation failed', + details: (p) => (p?.reason ? { reason: p.reason } : undefined), + }, + + // meta preset + [AppErrorCode.META_PRESET_KEY_EXISTS]: { + status: 400, + message: (p) => + p?.key + ? `Preset key already exists: ${p.key}` + : 'Preset key already exists', + details: (p) => (p?.key ? { key: p.key } : undefined), + }, + [AppErrorCode.META_PRESET_NOT_FOUND]: { + status: 404, + message: 'Preset not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.BUILTIN_PRESET_CANNOT_DELETE]: { + status: 403, + message: 'Built-in presets cannot be deleted', + }, + + // note + [AppErrorCode.NOTE_FORBIDDEN]: { + status: 403, + message: 'Please do not peek at private notes', + }, + [AppErrorCode.NOTE_NOT_FOUND]: { + status: 404, + message: 'Note not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.NOTE_PASSWORD_REQUIRED]: { + status: 403, + message: 'Note password required', + }, + + // owner / reader / user + [AppErrorCode.OWNER_NOT_FOUND]: { status: 404, message: 'Owner not found' }, + [AppErrorCode.READER_NOT_FOUND]: { + status: 404, + message: 'Reader not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.USER_NOT_EXISTS]: { + status: 400, + message: 'No owner exists yet', + }, + [AppErrorCode.USER_ALREADY_EXISTS]: { + status: 400, + message: 'An owner already exists', + }, + + // page + [AppErrorCode.PAGE_NOT_FOUND]: { + status: 404, + message: 'Page not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + + // post + [AppErrorCode.POST_NOT_FOUND]: { + status: 404, + message: 'Post not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.POST_UNPUBLISHED]: { + status: 404, + message: 'Post is not published', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.POST_HIDDEN_OR_ENCRYPTED]: { + status: 403, + message: 'Post is hidden or encrypted', + }, + [AppErrorCode.POST_RELATED_NOT_EXISTS]: { + status: 400, + message: 'Related post does not exist', + }, + [AppErrorCode.POST_SELF_RELATION]: { + status: 400, + message: 'Post cannot relate to itself', + }, + + // recently + [AppErrorCode.RECENTLY_NOT_FOUND]: { + status: 404, + message: 'Recently entry not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + + // backup + [AppErrorCode.BACKUP_NOT_ENABLED]: { + status: 400, + message: 'Backup is not enabled in settings', + }, + + // serverless + [AppErrorCode.SERVERLESS_ERROR]: { + status: 500, + message: (p) => p?.message ?? 'Function execution failed', + }, + [AppErrorCode.SERVERLESS_NO_PERMISSION]: { + status: 403, + message: 'No permission to run this function', + }, + + // snippet + [AppErrorCode.SNIPPET_NOT_FOUND]: { + status: 404, + message: 'Snippet not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.SNIPPET_EXISTS]: { + status: 400, + message: 'Snippet already exists', + }, + [AppErrorCode.SNIPPET_PRIVATE]: { + status: 403, + message: 'Snippet is private', + }, + [AppErrorCode.SNIPPET_INVALID_JSON]: { + status: 400, + message: 'Content is not valid JSON', + }, + [AppErrorCode.SNIPPET_INVALID_JSON5]: { + status: 400, + message: 'Content is not valid JSON5', + }, + [AppErrorCode.SNIPPET_INVALID_YAML]: { + status: 400, + message: 'Content is not valid YAML', + }, + [AppErrorCode.SNIPPET_INVALID_FUNCTION]: { + status: 400, + message: withExtra('Invalid serverless function'), + }, + + // subscribe + [AppErrorCode.SUBSCRIBE_NOT_ENABLED]: { + status: 400, + message: 'Subscription is not enabled', + }, + [AppErrorCode.SUBSCRIBE_TYPE_EMPTY]: { + status: 400, + message: 'Subscribe type cannot be empty', + }, + [AppErrorCode.ALREADY_SUPPORTED]: { + status: 400, + message: 'You already supported this', + }, + + // topic + [AppErrorCode.TOPIC_NOT_FOUND]: { + status: 404, + message: 'Topic not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + + // webhook + [AppErrorCode.WEBHOOK_EVENT_NOT_FOUND]: { + status: 404, + message: 'Webhook event not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + [AppErrorCode.WEBHOOK_NOT_FOUND]: { + status: 404, + message: 'Webhook not found', + details: (p) => (p?.id ? { id: p.id } : undefined), + }, + + // bing + [AppErrorCode.BING_API_FAILED]: { + status: 503, + message: 'Bing API request failed', + }, + [AppErrorCode.BING_KEY_INVALID]: { + status: 401, + message: 'Bing API key invalid', + }, + [AppErrorCode.BING_DOMAIN_INVALID]: { + status: 400, + message: 'Bing API domain invalid', + }, +} satisfies { + [C in AppErrorCode]: AppErrorDefinition +} diff --git a/apps/core/src/common/errors/app-error-payload.ts b/apps/core/src/common/errors/app-error-payload.ts new file mode 100644 index 00000000000..cd6e4d9be88 --- /dev/null +++ b/apps/core/src/common/errors/app-error-payload.ts @@ -0,0 +1,206 @@ +import type { AppErrorCode } from './app-error-code' + +type WithMessage = { message: string } +type OptMessage = { message?: string } | undefined +type WithId = { id?: string } | undefined +type WithExtra = { extra?: string } | undefined + +export type AppErrorPayloadMap = { + // generic + [AppErrorCode.INTERNAL_ERROR]: OptMessage + [AppErrorCode.NOT_FOUND]: { message?: string; id?: string } | undefined + [AppErrorCode.NO_CONTENT_MODIFIABLE]: undefined + [AppErrorCode.DEMO_FORBIDDEN]: undefined + [AppErrorCode.RESOURCE_NOT_FOUND]: WithId + [AppErrorCode.CONTENT_NOT_FOUND]: WithId + [AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS]: OptMessage + [AppErrorCode.MAX_COUNT_LIMIT]: undefined + [AppErrorCode.MASTER_LOST]: undefined + [AppErrorCode.CANNOT_GET_IP]: undefined + [AppErrorCode.ENTRY_NOT_FOUND]: WithId + [AppErrorCode.REF_MODEL_NOT_FOUND]: WithExtra + + // validation + [AppErrorCode.INVALID_PARAMETER]: WithMessage + [AppErrorCode.INVALID_BODY]: undefined + [AppErrorCode.INVALID_SLUG]: undefined + [AppErrorCode.INVALID_NAME]: undefined + [AppErrorCode.INVALID_REFERENCE]: undefined + [AppErrorCode.INVALID_ORDER_VALUE]: undefined + [AppErrorCode.INVALID_SEARCH_TYPE]: { type: string } + [AppErrorCode.INVALID_ROOM_NAME]: undefined + [AppErrorCode.INVALID_SUBSCRIBE_TYPE]: undefined + [AppErrorCode.INVALID_VIEW]: { view: string; available: string[] } + [AppErrorCode.SLUG_NOT_AVAILABLE]: undefined + [AppErrorCode.VALIDATION_FAILED]: { issues?: unknown } | undefined + + // ack + [AppErrorCode.ACK_INVALID_PAYLOAD]: OptMessage + + // ai + [AppErrorCode.AI_CONTENT_MISSING]: WithMessage + [AppErrorCode.AI_INVALID_PARAMETER]: WithMessage + [AppErrorCode.AI_INVALID_QUERY_TYPE]: undefined + [AppErrorCode.AI_NOT_ENABLED]: OptMessage + [AppErrorCode.AI_KEY_EXPIRED]: undefined + [AppErrorCode.AI_PROCESSING]: undefined + [AppErrorCode.AI_PROVIDER_DISABLED]: { providerId?: string } | undefined + [AppErrorCode.AI_PROVIDER_NOT_FOUND]: { providerId?: string } | undefined + [AppErrorCode.AI_REVIEW_NOT_ENABLED]: undefined + [AppErrorCode.AI_RESULT_PARSING_ERROR]: OptMessage + [AppErrorCode.AI_SERVICE_ERROR]: OptMessage + [AppErrorCode.AI_TASK_NOT_FOUND]: WithId + [AppErrorCode.AI_TASK_ALREADY_COMPLETED]: undefined + [AppErrorCode.AI_TASK_CANNOT_RETRY]: { reason?: string } | undefined + [AppErrorCode.AI_TRANSLATION_NOT_FOUND]: undefined + + // auth + [AppErrorCode.AUTH_DEVICE_FLOW_PENDING]: undefined + [AppErrorCode.AUTH_INVALID_CREDENTIALS]: undefined + [AppErrorCode.AUTH_NOT_LOGGED_IN]: undefined + [AppErrorCode.AUTH_SESSION_EXPIRED]: undefined + [AppErrorCode.AUTH_TOKEN_NOT_FOUND]: undefined + [AppErrorCode.AUTH_CHALLENGE_MISSING]: undefined + [AppErrorCode.AUTH_CHALLENGE_EXPIRED]: undefined + [AppErrorCode.AUTH_REGISTRATION_MISSING]: undefined + [AppErrorCode.AUTH_USERNAME_INCORRECT]: undefined + [AppErrorCode.AUTH_PASSWORD_INCORRECT]: undefined + [AppErrorCode.AUTH_SESSION_NOT_FOUND]: undefined + [AppErrorCode.AUTH_USER_ID_NOT_FOUND]: undefined + [AppErrorCode.AUTH_FAILED]: OptMessage + [AppErrorCode.AUTH_TOKEN_INVALID]: undefined + [AppErrorCode.PASSWORD_LOGIN_DISABLED]: undefined + [AppErrorCode.PASSWORD_SAME_AS_OLD]: undefined + + // category + [AppErrorCode.CATEGORY_NOT_FOUND]: WithId + [AppErrorCode.CATEGORY_HAS_POSTS]: undefined + + // comment + [AppErrorCode.COMMENT_DISABLED]: undefined + [AppErrorCode.COMMENT_FORBIDDEN]: undefined + [AppErrorCode.COMMENT_NOT_FOUND]: WithId + [AppErrorCode.COMMENT_TOO_DEEP]: undefined + [AppErrorCode.COMMENT_POST_NOT_EXISTS]: undefined + [AppErrorCode.COMMENT_IMAGE_CAP_EXCEEDED]: undefined + [AppErrorCode.COMMENT_UPLOAD_DISABLED]: undefined + [AppErrorCode.COMMENT_UPLOAD_FILE_TOO_LARGE]: undefined + [AppErrorCode.COMMENT_UPLOAD_INVALID_MIME]: undefined + [AppErrorCode.COMMENT_UPLOAD_FILE_NOT_OWNED]: undefined + [AppErrorCode.COMMENT_UPLOAD_FILE_ALREADY_BOUND]: undefined + [AppErrorCode.COMMENT_UPLOAD_RATE_LIMITED]: undefined + [AppErrorCode.COMMENT_UPLOAD_QUOTA_EXCEEDED]: undefined + [AppErrorCode.COMMENT_UPLOAD_ACCOUNT_TOO_NEW]: undefined + [AppErrorCode.COMMENT_UPLOAD_INSUFFICIENT_COMMENTS]: undefined + + // config + [AppErrorCode.CONFIG_NOT_FOUND]: WithId + [AppErrorCode.CONFIG_VALIDATION_FAILED]: OptMessage + + // cron + [AppErrorCode.CRON_NOT_FOUND]: WithExtra + [AppErrorCode.INVALID_CRON_METHOD]: undefined + [AppErrorCode.FUNCTION_NOT_FOUND]: { path?: string } | undefined + + // draft + [AppErrorCode.DRAFT_NOT_FOUND]: WithId + [AppErrorCode.DRAFT_HISTORY_NOT_FOUND]: undefined + + // document / helper + [AppErrorCode.DOCUMENT_NOT_FOUND]: WithId + [AppErrorCode.HELPER_DOCUMENT_NOT_FOUND]: WithId + + // email + [AppErrorCode.EMAIL_TEMPLATE_NOT_FOUND]: undefined + + // enrichment + [AppErrorCode.ENRICHMENT_BROWSER_MODE_REQUIRED]: undefined + [AppErrorCode.ENRICHMENT_CAPTURE_FAILED]: undefined + [AppErrorCode.ENRICHMENT_NOT_FOUND]: WithId + [AppErrorCode.ENRICHMENT_SCREENSHOT_DISABLED]: undefined + + // file + [AppErrorCode.FILE_NOT_FOUND]: { name?: string; extra?: string } | undefined + [AppErrorCode.FILE_EXISTS]: undefined + [AppErrorCode.FILE_RENAME_FAILED]: undefined + [AppErrorCode.FILE_STORAGE_NOT_CONFIGURED]: undefined + [AppErrorCode.FILE_UPLOAD_DISABLED]: undefined + [AppErrorCode.FILE_UPLOAD_NOT_AUTHORIZED]: undefined + [AppErrorCode.MIME_ZIP_REQUIRED]: { got?: string } | undefined + + // init + [AppErrorCode.INIT_ALREADY_COMPLETED]: undefined + [AppErrorCode.INIT_FORBIDDEN]: undefined + [AppErrorCode.INIT_INVALID_BODY]: undefined + [AppErrorCode.INIT_INVALID_MIME_TYPE]: { got?: string } | undefined + + // link + [AppErrorCode.LINK_APPLY_DISABLED]: undefined + [AppErrorCode.LINK_NOT_FOUND]: WithId + [AppErrorCode.LINK_DISABLED]: undefined + [AppErrorCode.SUBPATH_LINK_DISABLED]: undefined + [AppErrorCode.DUPLICATE_LINK]: undefined + [AppErrorCode.LINK_AVATAR_VALIDATION_FAILED]: { reason?: string } | undefined + + // meta preset + [AppErrorCode.META_PRESET_KEY_EXISTS]: { key?: string } | undefined + [AppErrorCode.META_PRESET_NOT_FOUND]: WithId + [AppErrorCode.BUILTIN_PRESET_CANNOT_DELETE]: undefined + + // note + [AppErrorCode.NOTE_FORBIDDEN]: undefined + [AppErrorCode.NOTE_NOT_FOUND]: WithId + [AppErrorCode.NOTE_PASSWORD_REQUIRED]: undefined + + // owner / reader / user + [AppErrorCode.OWNER_NOT_FOUND]: undefined + [AppErrorCode.READER_NOT_FOUND]: WithId + [AppErrorCode.USER_NOT_EXISTS]: undefined + [AppErrorCode.USER_ALREADY_EXISTS]: undefined + + // page + [AppErrorCode.PAGE_NOT_FOUND]: WithId + + // post + [AppErrorCode.POST_NOT_FOUND]: WithId + [AppErrorCode.POST_UNPUBLISHED]: WithId + [AppErrorCode.POST_HIDDEN_OR_ENCRYPTED]: undefined + [AppErrorCode.POST_RELATED_NOT_EXISTS]: undefined + [AppErrorCode.POST_SELF_RELATION]: undefined + + // recently + [AppErrorCode.RECENTLY_NOT_FOUND]: WithId + + // backup + [AppErrorCode.BACKUP_NOT_ENABLED]: undefined + + // serverless + [AppErrorCode.SERVERLESS_ERROR]: OptMessage + [AppErrorCode.SERVERLESS_NO_PERMISSION]: undefined + + // snippet + [AppErrorCode.SNIPPET_NOT_FOUND]: WithId + [AppErrorCode.SNIPPET_EXISTS]: undefined + [AppErrorCode.SNIPPET_PRIVATE]: undefined + [AppErrorCode.SNIPPET_INVALID_JSON]: undefined + [AppErrorCode.SNIPPET_INVALID_JSON5]: undefined + [AppErrorCode.SNIPPET_INVALID_YAML]: undefined + [AppErrorCode.SNIPPET_INVALID_FUNCTION]: WithExtra + + // subscribe + [AppErrorCode.SUBSCRIBE_NOT_ENABLED]: undefined + [AppErrorCode.SUBSCRIBE_TYPE_EMPTY]: undefined + [AppErrorCode.ALREADY_SUPPORTED]: undefined + + // topic + [AppErrorCode.TOPIC_NOT_FOUND]: WithId + + // webhook + [AppErrorCode.WEBHOOK_EVENT_NOT_FOUND]: WithId + [AppErrorCode.WEBHOOK_NOT_FOUND]: WithId + + // bing + [AppErrorCode.BING_API_FAILED]: undefined + [AppErrorCode.BING_KEY_INVALID]: undefined + [AppErrorCode.BING_DOMAIN_INVALID]: undefined +} diff --git a/apps/core/src/common/errors/app-error.factory.ts b/apps/core/src/common/errors/app-error.factory.ts new file mode 100644 index 00000000000..1c0ff2342f8 --- /dev/null +++ b/apps/core/src/common/errors/app-error.factory.ts @@ -0,0 +1,50 @@ +import { AppException } from '~/common/errors/exception.types' + +import { AppErrorCode } from './app-error-code' +import { APP_ERROR_DEFINITIONS } from './app-error-definitions' +import type { AppErrorPayloadMap } from './app-error-payload' + +type RequiredKeys = { + [K in keyof T]-?: Record extends T ? never : K +}[keyof T] + +type NonUndefined = Exclude +type PayloadFor = AppErrorPayloadMap[C] +type RuntimeAppErrorDefinition = { + status: number + message: string | ((payload: unknown) => string) + details?: (payload: unknown) => Record | undefined +} + +type AppErrorArgs = [PayloadFor] extends [undefined] + ? [code: C] + : undefined extends PayloadFor + ? NonUndefined> extends object + ? RequiredKeys>> extends never + ? [code: C, payload?: NonUndefined>] + : [code: C, payload: NonUndefined>] + : never + : NonUndefined> extends object + ? RequiredKeys>> extends never + ? [code: C, payload?: NonUndefined>] + : [code: C, payload: NonUndefined>] + : never + +export function createAppException( + ...args: AppErrorArgs +): AppException { + const [code, payload] = args as [ + AppErrorCode, + NonUndefined | undefined, + ] + const definition = APP_ERROR_DEFINITIONS[code] as RuntimeAppErrorDefinition + const message = + typeof definition.message === 'function' + ? definition.message(payload) + : definition.message + const details = definition.details?.(payload) + + return new AppException(code, message, definition.status, details) +} + +export { AppErrorCode } diff --git a/apps/core/src/common/errors/exception.types.ts b/apps/core/src/common/errors/exception.types.ts new file mode 100644 index 00000000000..a3c2b41b9dd --- /dev/null +++ b/apps/core/src/common/errors/exception.types.ts @@ -0,0 +1,30 @@ +import { HttpException } from '@nestjs/common' +import { z } from 'zod' + +export const ErrorResponseSchema = z.object({ + error: z.object({ + code: z.string(), + message: z.string(), + details: z.record(z.string(), z.unknown()).optional(), + }), +}) + +export type ErrorResponse = z.infer + +export const ErrorCodes = { + VALIDATION_FAILED: 'VALIDATION_FAILED', + RATE_LIMITED: 'RATE_LIMITED', + INTERNAL_ERROR: 'INTERNAL_ERROR', + HTTP_ERROR: 'HTTP_ERROR', +} as const + +export class AppException extends HttpException { + constructor( + public readonly code: string, + message: string, + status: number, + public readonly details?: Record, + ) { + super({ code, message, details }, status) + } +} diff --git a/apps/core/src/common/errors/index.ts b/apps/core/src/common/errors/index.ts new file mode 100644 index 00000000000..85e0e17bcc7 --- /dev/null +++ b/apps/core/src/common/errors/index.ts @@ -0,0 +1,3 @@ +export { createAppException } from './app-error.factory' +export { AppErrorCode } from './app-error-code' +export type { AppErrorPayloadMap } from './app-error-payload' diff --git a/apps/core/src/common/exceptions/ban-in-demo.exception.ts b/apps/core/src/common/exceptions/ban-in-demo.exception.ts deleted file mode 100644 index 007ea1c7da2..00000000000 --- a/apps/core/src/common/exceptions/ban-in-demo.exception.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ErrorCodeEnum } from '~/constants/error-code.constant' -import { BusinessException } from './biz.exception' - -export class BanInDemoExcpetion extends BusinessException { - constructor() { - super(ErrorCodeEnum.BanInDemo) - } -} diff --git a/apps/core/src/common/exceptions/biz.exception.ts b/apps/core/src/common/exceptions/biz.exception.ts deleted file mode 100644 index 7861c6538ef..00000000000 --- a/apps/core/src/common/exceptions/biz.exception.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { HttpException } from '@nestjs/common' - -import { ErrorCode, ErrorCodeEnum } from '~/constants/error-code.constant' - -export class BusinessException extends HttpException { - public bizCode: ErrorCodeEnum - constructor(code: ErrorCodeEnum, extraMessage?: string, trace?: string) - constructor(message: string) - constructor(...args: any[]) { - const [bizCode, extraMessage, trace] = args as any - const bizError = ErrorCode[bizCode] || [] - const [message] = bizError - const status = bizError[1] ?? 500 - - const isOnlyMessage = typeof bizCode == 'string' && args.length === 1 - - const jointMessage = isOnlyMessage - ? bizCode // this code is message - : message + (extraMessage ? `: ${extraMessage}` : '') - - super( - HttpException.createBody({ - code: bizCode, - message: jointMessage, - }), - status, - ) - - this.stack = trace - - this.bizCode = typeof bizCode === 'number' ? bizCode : ErrorCodeEnum.Default - } -} - -export { BusinessException as BizException } diff --git a/apps/core/src/common/exceptions/cant-find.exception.ts b/apps/core/src/common/exceptions/cant-find.exception.ts deleted file mode 100644 index 59d8c04f3a1..00000000000 --- a/apps/core/src/common/exceptions/cant-find.exception.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NotFoundException } from '@nestjs/common' -import { sample } from 'es-toolkit/compat' - -export const NotFoundMessage = [ - '真不巧,内容走丢了 o(╥﹏╥)o', - '电波无法到达 ωω', - '数据..不小心丢失了啦 π_π', - '404, 这也不是我的错啦 (๐•̆ ·̭ •̆๐)', - '嘿,这里空空如也,不如别处走走?', -] - -export class CannotFindException extends NotFoundException { - constructor() { - super(sample(NotFoundMessage)) - } -} diff --git a/apps/core/src/common/exceptions/no-content-canbe-modified.exception.ts b/apps/core/src/common/exceptions/no-content-canbe-modified.exception.ts deleted file mode 100644 index c5db8468f7a..00000000000 --- a/apps/core/src/common/exceptions/no-content-canbe-modified.exception.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ErrorCodeEnum } from '~/constants/error-code.constant' -import { BizException } from './biz.exception' - -export class NoContentCanBeModifiedException extends BizException { - constructor() { - super(ErrorCodeEnum.NoContentCanBeModified) - } -} diff --git a/apps/core/src/common/filters/any-exception.filter.ts b/apps/core/src/common/filters/any-exception.filter.ts index dd85a7e7265..f27715fddaf 100644 --- a/apps/core/src/common/filters/any-exception.filter.ts +++ b/apps/core/src/common/filters/any-exception.filter.ts @@ -2,6 +2,7 @@ import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common' import { Catch, HttpException, HttpStatus, Logger } from '@nestjs/common' import type { FastifyReply, FastifyRequest } from 'fastify' +import { AppException } from '~/common/errors/exception.types' import { EventScope } from '~/constants/business-event.constant' import { EventBusEvents } from '~/constants/event-bus.constant' import { ConfigsService } from '~/modules/configs/configs.service' @@ -9,7 +10,6 @@ import { BarkPushService } from '~/processors/helper/helper.bark.service' import { EventManagerService } from '~/processors/helper/helper.event.service' import { getIp } from '../../utils/ip.util' -import { BizException } from '../exceptions/biz.exception' interface ErrorLike { readonly status?: number | string @@ -83,25 +83,25 @@ export class AllExceptionsFilter implements ExceptionFilter { const url = request.raw?.url || request.url || 'Unknown URL' if (status === HttpStatus.TOO_MANY_REQUESTS) { - this.logger.warn(`IP: ${ip} 疑似遭到攻击 Path: ${decodeURI(url)}`) + this.logger.warn(`IP: ${ip} suspected attack Path: ${decodeURI(url)}`) const { enableThrottleGuard } = await this.configService.get('barkOptions') if (enableThrottleGuard) { this.barkService.throttlePush({ - title: '疑似遭到攻击', + title: 'Suspected attack', body: `IP: ${ip} Path: ${decodeURI(url)}`, }) } return response.status(429).send({ - message: '请求过于频繁,请稍后再试', + message: 'Too many requests, please try again later', }) } if ( status === HttpStatus.INTERNAL_SERVER_ERROR && - !(exception instanceof BizException) + !(exception instanceof AppException) ) { this.logger.error(exception) this.eventManager.broadcast( @@ -114,7 +114,7 @@ export class AllExceptionsFilter implements ExceptionFilter { ) } else { this.logger.warn( - `IP: ${ip} 错误信息:(${status}) ${message} Path: ${decodeURI(url)}`, + `IP: ${ip} Error: (${status}) ${message} Path: ${decodeURI(url)}`, ) } @@ -125,7 +125,7 @@ export class AllExceptionsFilter implements ExceptionFilter { .send({ ok: 0, code: res?.code, - message: res?.message || (exception as any)?.message || '未知错误', + message: res?.message || (exception as any)?.message || 'Unknown error', }) } } diff --git a/apps/core/src/common/filters/app-exception.filter.ts b/apps/core/src/common/filters/app-exception.filter.ts new file mode 100644 index 00000000000..6113c6a657a --- /dev/null +++ b/apps/core/src/common/filters/app-exception.filter.ts @@ -0,0 +1,157 @@ +import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common' +import { + Catch, + HttpException, + HttpStatus, + Logger, + Optional, +} from '@nestjs/common' +import { ZodError } from 'zod' + +import { AppException, ErrorCodes } from '~/common/errors/exception.types' +import { EventScope } from '~/constants/business-event.constant' +import { EventBusEvents } from '~/constants/event-bus.constant' +import { ConfigsService } from '~/modules/configs/configs.service' +import { BarkPushService } from '~/processors/helper/helper.bark.service' +import { EventManagerService } from '~/processors/helper/helper.event.service' +import { getIp } from '~/utils/ip.util' + +let processHooksRegistered = false + +@Catch() +export class AppExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(AppExceptionFilter.name) + + constructor( + @Optional() private readonly eventManager?: EventManagerService, + @Optional() private readonly barkService?: BarkPushService, + @Optional() private readonly configService?: ConfigsService, + ) { + this.registerProcessHooks() + } + + private registerProcessHooks() { + if (processHooksRegistered) return + processHooksRegistered = true + + process.on('unhandledRejection', (reason: any) => { + console.error('unhandledRejection:', reason) + }) + + process.on('uncaughtException', (err) => { + console.error('uncaughtException:', err) + this.eventManager?.broadcast( + EventBusEvents.SystemException, + { message: err?.message ?? err, stack: err?.stack || '' }, + { scope: EventScope.TO_SYSTEM }, + ) + }) + } + + private logServerError(exception: unknown) { + this.logger.error(exception) + this.eventManager?.broadcast( + EventBusEvents.SystemException, + { + message: (exception as Error)?.message, + stack: (exception as Error)?.stack, + }, + { scope: EventScope.TO_SYSTEM }, + ) + } + + private async handleThrottle(ip: string | undefined, url: string) { + this.logger.warn(`IP: ${ip} suspected attack at path: ${decodeURI(url)}`) + if (!this.configService || !this.barkService) return + const { enableThrottleGuard } = await this.configService.get('barkOptions') + if (enableThrottleGuard) { + this.barkService.throttlePush({ + title: 'Suspected attack', + body: `IP: ${ip} Path: ${decodeURI(url)}`, + }) + } + } + + async catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp() + const reply = ctx.getResponse() + const request = ctx.getRequest() + + if (request?.method === 'OPTIONS') { + return reply.status(204).send() + } + + reply.type('application/json') + + const ip = request?.headers ? getIp(request) : undefined + const url = request?.raw?.url || request?.url || 'Unknown URL' + + if (exception instanceof AppException) { + const status = exception.getStatus() + + if (status === HttpStatus.TOO_MANY_REQUESTS) { + await this.handleThrottle(ip, url) + } else if (status >= 500) { + this.logServerError(exception) + } else { + this.logger.warn( + `IP: ${ip} error (${status}) ${exception.message} at path: ${decodeURI(url)}`, + ) + } + + return reply.status(status).send({ + error: { + code: exception.code, + message: exception.message, + details: exception.details, + }, + }) + } + + if (exception instanceof ZodError) { + return reply.status(400).send({ + error: { + code: ErrorCodes.VALIDATION_FAILED, + message: 'Validation failed', + details: { issues: exception.issues }, + }, + }) + } + + if (exception instanceof HttpException) { + const status = exception.getStatus() + const message = exception.message + + if (status === HttpStatus.TOO_MANY_REQUESTS) { + await this.handleThrottle(ip, url) + return reply.status(429).send({ + error: { + code: ErrorCodes.RATE_LIMITED, + message: 'Too many requests, please try again later', + }, + }) + } + + if (status >= 500) { + this.logServerError(exception) + } else { + this.logger.warn( + `IP: ${ip} Error: (${status}) ${message} Path: ${decodeURI(url)}`, + ) + } + + return reply.status(status).send({ + error: { code: ErrorCodes.HTTP_ERROR, message }, + }) + } + + this.logServerError(exception) + + return reply.status(500).send({ + error: { + code: ErrorCodes.INTERNAL_ERROR, + message: 'Internal server error', + }, + }) + } +} diff --git a/apps/core/src/common/guards/auth.guard.ts b/apps/core/src/common/guards/auth.guard.ts index 05157dd66b9..885bea10932 100644 --- a/apps/core/src/common/guards/auth.guard.ts +++ b/apps/core/src/common/guards/auth.guard.ts @@ -1,14 +1,12 @@ import type { CanActivate, ExecutionContext } from '@nestjs/common' import { Injectable } from '@nestjs/common' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { AuthService } from '~/modules/auth/auth.service' import type { SessionUser } from '~/modules/auth/auth.types' import type { FastifyBizRequest } from '~/transformers/get-req.transformer' import { getNestExecutionContextRequest } from '~/transformers/get-req.transformer' -import { BizException } from '../exceptions/biz.exception' - @Injectable() export class AuthGuard implements CanActivate { constructor(protected readonly authService: AuthService) {} @@ -33,22 +31,22 @@ export class AuthGuard implements CanActivate { }) if (!apiKey) { - throw new BizException(ErrorCodeEnum.AuthNotLoggedIn) + throw createAppException(AppErrorCode.AUTH_NOT_LOGGED_IN) } const result = await this.authService.verifyApiKey(apiKey.key) if (!result?.referenceId) { - throw new BizException(ErrorCodeEnum.AuthTokenInvalid) + throw createAppException(AppErrorCode.AUTH_TOKEN_INVALID) } const isOwner = await this.authService.isOwnerReaderId(result.referenceId) if (!isOwner) { - throw new BizException(ErrorCodeEnum.AuthTokenInvalid) + throw createAppException(AppErrorCode.AUTH_TOKEN_INVALID) } const readerUser = await this.authService.getReaderById(result.referenceId) if (!readerUser) { - throw new BizException(ErrorCodeEnum.AuthTokenInvalid) + throw createAppException(AppErrorCode.AUTH_TOKEN_INVALID) } this.attachUserAndToken(request, readerUser, apiKey.key) return true diff --git a/apps/core/src/common/guards/reader-auth.guard.ts b/apps/core/src/common/guards/reader-auth.guard.ts index a5aa585315f..f9eef27783d 100644 --- a/apps/core/src/common/guards/reader-auth.guard.ts +++ b/apps/core/src/common/guards/reader-auth.guard.ts @@ -1,13 +1,11 @@ import type { CanActivate, ExecutionContext } from '@nestjs/common' import { Injectable } from '@nestjs/common' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { AuthService } from '~/modules/auth/auth.service' import type { SessionUser } from '~/modules/auth/auth.types' import { getNestExecutionContextRequest } from '~/transformers/get-req.transformer' -import { BizException } from '../exceptions/biz.exception' - @Injectable() export class ReaderAuthGuard implements CanActivate { constructor(private readonly authService: AuthService) {} @@ -19,11 +17,11 @@ export class ReaderAuthGuard implements CanActivate { const user = session?.user as SessionUser | undefined if (!user?.id) { - throw new BizException(ErrorCodeEnum.AuthNotLoggedIn) + throw createAppException(AppErrorCode.AUTH_NOT_LOGGED_IN) } if (user.role !== 'reader' && user.role !== 'owner') { - throw new BizException(ErrorCodeEnum.AuthNotLoggedIn) + throw createAppException(AppErrorCode.AUTH_NOT_LOGGED_IN) } request.user = user diff --git a/apps/core/src/common/guards/spider.guard.ts b/apps/core/src/common/guards/spider.guard.ts index c6a616492f2..c5fe28e2518 100644 --- a/apps/core/src/common/guards/spider.guard.ts +++ b/apps/core/src/common/guards/spider.guard.ts @@ -34,7 +34,7 @@ export class SpiderGuard implements CanActivate { return true } - throw new ForbiddenException(`爬虫是被禁止的哦,UA: ${ua}`) + throw new ForbiddenException(`Crawlers are not allowed, UA: ${ua}`) } private async isAuthenticated( diff --git a/apps/core/src/common/interceptors/idempotence.interceptor.ts b/apps/core/src/common/interceptors/idempotence.interceptor.ts index 50cb78d9619..58e95e7b755 100644 --- a/apps/core/src/common/interceptors/idempotence.interceptor.ts +++ b/apps/core/src/common/interceptors/idempotence.interceptor.ts @@ -31,23 +31,24 @@ export type IdempotenceOption = { pendingMessage?: string /** - * 如果重复请求的话,手动处理异常 + * Custom handler invoked when a duplicate request is detected. */ handler?: (req: FastifyRequest) => any /** - * 记录重复请求的时间 + * How long (in seconds) the idempotence record is retained. * @default 60 */ expired?: number /** - * 如果 header 没有幂等 key,根据 request 生成 key,如何生成这个 key 的方法 + * How to derive the idempotence key from the request when no key + * is provided in the header. */ generateKey?: (req: FastifyRequest) => string /** - * 仅读取 header 的 key,不自动生成 + * Only honor the header key; do not auto-generate one. * @default false */ disableGenerateKey?: boolean @@ -80,8 +81,8 @@ export class IdempotenceInterceptor implements NestInterceptor { } const { - errorMessage = '相同请求成功后在 60 秒内只能发送一次', - pendingMessage = '相同请求正在处理中...', + errorMessage = 'The same request can only be sent once within 60 seconds after success', + pendingMessage = 'The same request is already being processed...', handler: errorHandler, expired = 60, disableGenerateKey = false, diff --git a/apps/core/src/common/interceptors/json-transform.interceptor.ts b/apps/core/src/common/interceptors/json-transform.interceptor.ts deleted file mode 100644 index 7043bb83b89..00000000000 --- a/apps/core/src/common/interceptors/json-transform.interceptor.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { - CallHandler, - ExecutionContext, - NestInterceptor, -} from '@nestjs/common' -import { Injectable } from '@nestjs/common' -import { Reflector } from '@nestjs/core' -import { isObjectLike } from 'es-toolkit/compat' -import { map, Observable } from 'rxjs' - -import { RESPONSE_PASSTHROUGH_METADATA } from '~/constants/system.constant' -import { snakecaseKeysWithCompat } from '~/utils/case.util' - -@Injectable() -export class JSONTransformInterceptor implements NestInterceptor { - constructor(private readonly reflector: Reflector) {} - - intercept(context: ExecutionContext, next: CallHandler): Observable { - const bypass = this.reflector.getAllAndOverride( - RESPONSE_PASSTHROUGH_METADATA, - [context.getClass(), context.getHandler()], - ) - - if (bypass || !context.switchToHttp().getRequest()) { - return next.handle() - } - - return next.handle().pipe(map((data) => this.serialize(data))) - } - - private serialize(obj: any): any { - if (!isObjectLike(obj)) { - return obj - } - - // Skip key conversion for URL-keyed maps (e.g. `enrichments`): the keys - // ARE the URLs and must round-trip verbatim so SSR consumers can look - // up entries by the original href. Inner values still get camel→snake - // recursion since snakecase-keys descends with deep:true by default. - return snakecaseKeysWithCompat(obj, { exclude: [/^https?:\/\//i] }) - } -} diff --git a/apps/core/src/common/interceptors/logging.interceptor.ts b/apps/core/src/common/interceptors/logging.interceptor.ts index ee0a8a9ed32..6632acf7f56 100644 --- a/apps/core/src/common/interceptors/logging.interceptor.ts +++ b/apps/core/src/common/interceptors/logging.interceptor.ts @@ -4,12 +4,13 @@ import type { NestInterceptor, } from '@nestjs/common' import { Injectable, Logger, SetMetadata } from '@nestjs/common' -import { HTTP_REQUEST_TIME } from '~/constants/meta.constant' -import { getNestExecutionContextRequest } from '~/transformers/get-req.transformer' import pc from 'picocolors' import { Observable } from 'rxjs' import { tap } from 'rxjs/operators' +import { HTTP_REQUEST_TIME } from '~/constants/meta.constant' +import { getNestExecutionContextRequest } from '~/transformers/get-req.transformer' + @Injectable() export class LoggingInterceptor implements NestInterceptor { private readonly logger = new Logger(LoggingInterceptor.name, { @@ -22,7 +23,7 @@ export class LoggingInterceptor implements NestInterceptor { ): Observable { const request = getNestExecutionContextRequest(context) const content = `${request.method} -> ${request.url}` - this.logger.debug(`+++ 收到请求:${content}`) + this.logger.debug(`+++ Request received: ${content}`) const now = Date.now() SetMetadata(HTTP_REQUEST_TIME, now)(request as any) @@ -32,7 +33,7 @@ export class LoggingInterceptor implements NestInterceptor { .pipe( tap(() => this.logger.debug( - `--- 响应请求:${content}${pc.yellow(` +${Date.now() - now}ms`)}`, + `--- Response sent: ${content}${pc.yellow(` +${Date.now() - now}ms`)}`, ), ), ) diff --git a/apps/core/src/common/interceptors/response.interceptor.ts b/apps/core/src/common/interceptors/response.interceptor.ts index fc8095f5462..55848a279aa 100644 --- a/apps/core/src/common/interceptors/response.interceptor.ts +++ b/apps/core/src/common/interceptors/response.interceptor.ts @@ -5,45 +5,61 @@ import type { } from '@nestjs/common' import { Injectable } from '@nestjs/common' import { Reflector } from '@nestjs/core' -import { isArrayLike } from 'es-toolkit/compat' -import { Observable } from 'rxjs' +import type { Observable } from 'rxjs' import { map } from 'rxjs/operators' -import * as SYSTEM from '~/constants/system.constant' - -export interface Response { - data: T -} +import { transformResponseCase } from '~/common/response/case-transform' +import { + isExplicitSuccessEnvelope, + type SuccessEnvelope, +} from '~/common/response/envelope.types' +import { + BYPASS_CASE_TRANSFORM_METADATA, + RESPONSE_PASSTHROUGH_METADATA, +} from '~/constants/system.constant' @Injectable() -export class ResponseInterceptor implements NestInterceptor> { +export class ResponseInterceptorV2 implements NestInterceptor { constructor(private readonly reflector: Reflector) {} - intercept( - context: ExecutionContext, - next: CallHandler, - ): Observable> { - if (!context.switchToHttp().getRequest()) { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const http = context.switchToHttp() + if (!http.getRequest()) { return next.handle() } - const handler = context.getHandler() - const bypass = this.reflector.getAllAndOverride( - SYSTEM.RESPONSE_PASSTHROUGH_METADATA, - [context.getClass(), handler], + const passthrough = this.reflector.getAllAndOverride( + RESPONSE_PASSTHROUGH_METADATA, + [context.getClass(), context.getHandler()], ) - if (bypass) { + if (passthrough) { return next.handle() } + const bypassMetadata = this.reflector.getAllAndOverride( + BYPASS_CASE_TRANSFORM_METADATA, + [context.getHandler(), context.getClass()], + ) + const bypassPaths = Array.isArray(bypassMetadata) ? bypassMetadata : [] + return next.handle().pipe( map((data) => { if (typeof data === 'undefined') { - context.switchToHttp().getResponse().status(204) + http.getResponse().status(204) return data } - - return isArrayLike(data) ? { data } : data + const envelope: SuccessEnvelope = isExplicitSuccessEnvelope(data) + ? data + : { data } + const result: SuccessEnvelope = { + data: transformResponseCase(envelope.data, bypassPaths), + } + if (envelope.meta !== undefined) { + result.meta = transformResponseCase( + envelope.meta, + ) as SuccessEnvelope['meta'] + } + return result }), ) } diff --git a/apps/core/src/common/interceptors/translation-entry.interceptor.ts b/apps/core/src/common/interceptors/translation-entry.interceptor.ts deleted file mode 100644 index ea1d4a8b4f1..00000000000 --- a/apps/core/src/common/interceptors/translation-entry.interceptor.ts +++ /dev/null @@ -1,337 +0,0 @@ -import type { - CallHandler, - ExecutionContext, - NestInterceptor, -} from '@nestjs/common' -import { Injectable, Logger } from '@nestjs/common' -import { Reflector } from '@nestjs/core' -import { isPlainObject } from 'es-toolkit/compat' -import objectScan from 'object-scan' -import type { Observable } from 'rxjs' -import { from, switchMap } from 'rxjs' - -import { RequestContext } from '~/common/contexts/request.context' -import { - TRANSLATE_FIELDS_KEY, - type TranslateFieldRule, -} from '~/common/decorators/translate-fields.decorator' -import { TranslationEntryService } from '~/modules/ai/ai-translation/translation-entry.service' -import type { TranslationEntryKeyPath } from '~/modules/ai/ai-translation/translation-entry.types' -import { getNestExecutionContextRequest } from '~/transformers/get-req.transformer' -import { resolveRequestedLanguage } from '~/utils/lang.util' - -interface EntityLookup { - keyPath: TranslationEntryKeyPath - ids: Set -} - -interface DictLookup { - keyPath: TranslationEntryKeyPath - texts: Set -} - -interface ScanMatch { - parent: Record | any[] | undefined - property: string | number | undefined - value: any -} - -interface ScanContext { - visitor: (match: ScanMatch) => void -} - -type ObjectScanner = (haystack: any, context: ScanContext) => ScanContext -type LookupMaps = { - entityLookups: Map - dictLookups: Map -} - -@Injectable() -export class TranslationEntryInterceptor implements NestInterceptor { - private readonly logger = new Logger(TranslationEntryInterceptor.name) - private scannerCache?: Map - - constructor( - private readonly reflector: Reflector, - private readonly translationEntryService: TranslationEntryService, - ) {} - - intercept(context: ExecutionContext, next: CallHandler): Observable { - const rules = this.reflector.get( - TRANSLATE_FIELDS_KEY, - context.getHandler(), - ) - if (!rules?.length) return next.handle() - - const request = getNestExecutionContextRequest(context) - const query = request.query as Record - const lang = resolveRequestedLanguage( - query?.lang, - RequestContext.currentLang(), - ) - - if (!lang) return next.handle() - - return next - .handle() - .pipe( - switchMap((data) => from(this.applyTranslations(data, rules, lang!))), - ) - } - - private async applyTranslations( - data: any, - rules: TranslateFieldRule[], - lang: string, - ): Promise { - if (data == null) return data - - // Normalize to plain objects first so objectScan can traverse the tree; - // non-plain values (e.g. Date) would otherwise cause partial scan success. - const plainData = this.toScannableObject(data) - const translationTarget = plainData ?? data - const { entityLookups, dictLookups } = this.buildLookups( - translationTarget, - rules, - ) - - let entityMaps: Map> - let dictMaps: Map> - - try { - const batchResult = - await this.translationEntryService.getTranslationsBatch(lang, { - entityLookups: [...entityLookups.values()] - .filter((lookup) => lookup.ids.size) - .map((lookup) => ({ - keyPath: lookup.keyPath, - lookupKeys: [...lookup.ids], - })), - dictLookups: [...dictLookups.values()] - .filter((lookup) => lookup.texts.size) - .map((lookup) => ({ - keyPath: lookup.keyPath, - sourceTexts: [...lookup.texts], - })), - }) - entityMaps = batchResult.entityMaps - dictMaps = batchResult.dictMaps - } catch (error) { - this.logger.error( - `Translation entry lookup failed: ${(error as Error).message}`, - ) - return data - } - - const hasTranslations = [...entityMaps.values(), ...dictMaps.values()].some( - (map) => map.size > 0, - ) - if (!hasTranslations) return data - - for (const rule of rules) { - if (rule.idField) { - const map = entityMaps.get(rule.keyPath) - if (map?.size) { - this.replaceEntityValues( - translationTarget, - rule.path, - rule.idField, - map, - ) - } - } else { - const map = dictMaps.get(rule.keyPath) - if (map?.size) { - this.replaceDictValues(translationTarget, rule.path, map) - } - } - } - - return translationTarget - } - - private buildLookups(data: any, rules: TranslateFieldRule[]): LookupMaps { - const entityLookups = new Map() - const dictLookups = new Map() - - for (const rule of rules) { - if (rule.idField) { - if (!entityLookups.has(rule.keyPath)) { - entityLookups.set(rule.keyPath, { - keyPath: rule.keyPath, - ids: new Set(), - }) - } - const lookup = entityLookups.get(rule.keyPath)! - this.collectEntityIds(data, rule.path, rule.idField, lookup.ids) - } else { - if (!dictLookups.has(rule.keyPath)) { - dictLookups.set(rule.keyPath, { - keyPath: rule.keyPath, - texts: new Set(), - }) - } - const lookup = dictLookups.get(rule.keyPath)! - this.collectDictTexts(data, rule.path, lookup.texts) - } - } - - return { entityLookups, dictLookups } - } - - private toScannableObject(data: any): any | null { - if (data == null || typeof data !== 'object') { - return null - } - - return this.normalizeScannableValue(data) - } - - private normalizeScannableValue(value: any): any { - if (value == null || typeof value !== 'object') { - return value - } - - if (Array.isArray(value)) { - let changed = false - const normalized = value.map((item) => { - const next = this.normalizeScannableValue(item) - if (next !== item) { - changed = true - } - return next - }) - - return changed ? normalized : value - } - - if (!isPlainObject(value)) { - try { - return JSON.parse(JSON.stringify(value)) - } catch { - return value - } - } - - let changed = false - const normalized: Record = {} - - for (const [key, child] of Object.entries(value)) { - const next = this.normalizeScannableValue(child) - if (next !== child) { - changed = true - } - normalized[key] = next - } - - return changed ? normalized : value - } - - private toObjectScanPath(path: string): string { - return path.replaceAll('[]', '[*]') - } - - private getScanner(path: string): ObjectScanner { - const normalizedPath = this.toObjectScanPath(path) - this.scannerCache ??= new Map() - - const cached = this.scannerCache.get(normalizedPath) - if (cached) { - return cached - } - - const scanner = objectScan([normalizedPath], { - rtn: 'context', - filterFn: ({ parent, property, value, context }) => { - context.visitor({ parent, property, value }) - }, - }) as ObjectScanner - - this.scannerCache.set(normalizedPath, scanner) - return scanner - } - - private visitMatches( - data: any, - path: string, - visitor: (match: ScanMatch) => void, - ): void { - this.getScanner(path)(data, { visitor }) - } - - private collectEntityIds( - data: any, - path: string, - idField: string, - ids: Set, - ): void { - this.visitMatches(data, path, ({ parent }) => { - if (parent == null || typeof parent !== 'object') return - const id = parent[idField] - if (id != null) { - ids.add( - typeof id === 'object' && id.toString ? id.toString() : String(id), - ) - } - }) - } - - private collectDictTexts(data: any, path: string, texts: Set): void { - this.visitMatches(data, path, ({ value }) => { - if (typeof value === 'string' && value) { - texts.add(value) - } - }) - } - - private replaceEntityValues( - data: any, - path: string, - idField: string, - map: Map, - ): void { - this.visitMatches(data, path, ({ parent, property }) => { - if ( - parent == null || - typeof parent !== 'object' || - property == null || - !(property in parent) - ) { - return - } - - const id = parent[idField] - if (id == null) return - const idStr = - typeof id === 'object' && id.toString ? id.toString() : String(id) - const translated = map.get(idStr) - if (translated) { - parent[property] = translated - } - }) - } - - private replaceDictValues( - data: any, - path: string, - map: Map, - ): void { - this.visitMatches(data, path, ({ parent, property, value }) => { - if ( - parent == null || - typeof parent !== 'object' || - property == null || - !(property in parent) || - typeof value !== 'string' || - !value - ) { - return - } - - const translated = map.get(value) - if (translated) { - parent[property] = translated - } - }) - } -} diff --git a/apps/core/src/common/middlewares/request-context.middleware.ts b/apps/core/src/common/middlewares/request-context.middleware.ts index cabe2ebc345..2bb4fa1fdf5 100644 --- a/apps/core/src/common/middlewares/request-context.middleware.ts +++ b/apps/core/src/common/middlewares/request-context.middleware.ts @@ -21,14 +21,19 @@ export class RequestContextMiddleware implements NestMiddleware { use(req: BizIncomingMessage, res: ServerResponse, next: () => any) { const requestContext = new RequestContext(req, res) + const skipTranslation = req.headers['x-skip-translation'] === '1' const headerLang = req.headers['x-lang'] - requestContext.lang = - (typeof headerLang === 'string' + const fromHeader = + typeof headerLang === 'string' ? normalizeLanguageCode(headerLang) - : undefined) || - parseCookieLocale(req.headers.cookie) || - parseAcceptLanguage(req.headers['accept-language']) || - undefined + : undefined + + requestContext.lang = skipTranslation + ? fromHeader + : fromHeader || + parseCookieLocale(req.headers.cookie) || + parseAcceptLanguage(req.headers['accept-language']) || + undefined RequestContext.run(requestContext, () => next()) } diff --git a/apps/core/src/common/pipes/case-normalization.pipe.ts b/apps/core/src/common/pipes/case-normalization.pipe.ts new file mode 100644 index 00000000000..58287a9db66 --- /dev/null +++ b/apps/core/src/common/pipes/case-normalization.pipe.ts @@ -0,0 +1,31 @@ +import type { ArgumentMetadata, PipeTransform } from '@nestjs/common' +import { Injectable } from '@nestjs/common' + +import { transformRequestCase } from './case-transform' + +@Injectable() +export class RequestCaseNormalizationPipe implements PipeTransform { + transform(value: unknown, metadata: ArgumentMetadata): unknown { + if (value === null || value === undefined) return value + if (typeof value !== 'object') return value + if (Buffer.isBuffer(value)) return value + const v = value as { pipe?: unknown; readable?: unknown } + if (typeof v.pipe === 'function' || v.readable !== undefined) return value + + switch (metadata.type) { + case 'query': + case 'param': { + return transformRequestCase(value, { deep: true }) + } + case 'body': { + return transformRequestCase(value, { deep: false }) + } + default: { + return value + } + } + } +} + +export const requestCaseNormalizationPipeInstance = + new RequestCaseNormalizationPipe() diff --git a/apps/core/src/common/pipes/case-transform.ts b/apps/core/src/common/pipes/case-transform.ts new file mode 100644 index 00000000000..1dcb9a28adf --- /dev/null +++ b/apps/core/src/common/pipes/case-transform.ts @@ -0,0 +1,43 @@ +const LEADING_UNDERSCORE_RE = /^_+/ +const SNAKE_BOUNDARY_RE = /_([\da-z])/g + +export const camelKey = (key: string): string => { + if (!key.includes('_')) return key + const leading = LEADING_UNDERSCORE_RE.exec(key)?.[0] ?? '' + const body = key.slice(leading.length) + if (!body.includes('_')) return key + return ( + leading + + body.replaceAll(SNAKE_BOUNDARY_RE, (_, c: string) => c.toUpperCase()) + ) +} + +const isTransformableObject = ( + value: unknown, +): value is Record => { + if (value === null || typeof value !== 'object') return false + if (Array.isArray(value)) return false + if (value instanceof Date) return false + if (Buffer.isBuffer(value)) return false + return true +} + +const transform = (value: unknown, deep: boolean): unknown => { + if (Array.isArray(value)) { + return deep ? value.map((item) => transform(item, deep)) : value + } + if (!isTransformableObject(value)) return value + const result: Record = {} + for (const [key, item] of Object.entries(value)) { + result[camelKey(key)] = deep ? transform(item, deep) : item + } + return result +} + +export const transformRequestCase = ( + value: T, + options: { deep?: boolean } = {}, +): T => { + const { deep = true } = options + return transform(value, deep) as T +} diff --git a/apps/core/src/common/response/case-transform.ts b/apps/core/src/common/response/case-transform.ts new file mode 100644 index 00000000000..af3b3a271bf --- /dev/null +++ b/apps/core/src/common/response/case-transform.ts @@ -0,0 +1,59 @@ +const ARRAY_SEGMENT = '[]' +const IDENTIFIER_RE = /^[a-z][\dA-Za-z]*$/ +const UPPER_RE = /[A-Z]/ +const LOWER_UPPER_RE = /([\da-z])([A-Z])/g +const UPPER_LOWER_RE = /([A-Z]+)([A-Z][a-z])/g + +// Boundary-aware: `articleURL` → `article_url`, `HTMLContent` → `html_content`, +// rather than the naïve `_a_r_t_i_c_l_e__u_r_l`. +const snakeKey = (key: string): string => { + if (!IDENTIFIER_RE.test(key) || !UPPER_RE.test(key)) return key + return key + .replaceAll(UPPER_LOWER_RE, '$1_$2') + .replaceAll(LOWER_UPPER_RE, '$1_$2') + .toLowerCase() +} + +const parseBypassPath = (path: string): string[] => + path + .split('.') + .flatMap((segment) => + segment.endsWith(ARRAY_SEGMENT) + ? [segment.slice(0, -ARRAY_SEGMENT.length), ARRAY_SEGMENT] + : [segment], + ) + .filter(Boolean) + +const isExactBypass = (segments: string[], bypass: string[][]): boolean => + bypass.some( + (path) => + path.length === segments.length && + path.every((part, index) => part === segments[index]), + ) + +const transform = ( + value: unknown, + segments: string[], + bypass: string[][], +): unknown => { + if (value === null || typeof value !== 'object' || value instanceof Date) { + return value + } + if (Array.isArray(value)) { + const childSegments = [...segments, ARRAY_SEGMENT] + return value.map((item) => transform(item, childSegments, bypass)) + } + const result: Record = {} + for (const [key, item] of Object.entries(value as Record)) { + const childSegments = [...segments, key] + result[snakeKey(key)] = isExactBypass(childSegments, bypass) + ? item + : transform(item, childSegments, bypass) + } + return result +} + +export const transformResponseCase = ( + value: unknown, + bypassPaths: string[] = [], +): unknown => transform(value, [], bypassPaths.map(parseBypassPath)) diff --git a/apps/core/src/common/response/envelope.types.ts b/apps/core/src/common/response/envelope.types.ts new file mode 100644 index 00000000000..308c2f1d4a5 --- /dev/null +++ b/apps/core/src/common/response/envelope.types.ts @@ -0,0 +1,50 @@ +import type { ResponseMeta } from './meta.types' + +export interface SuccessEnvelope { + data: T + meta?: ResponseMeta +} + +const SUCCESS_ENVELOPE_MARK = Symbol('SuccessEnvelope') + +export type ExplicitSuccessEnvelope = SuccessEnvelope & { + readonly [SUCCESS_ENVELOPE_MARK]: true +} + +export const OK_DATA = { ok: true } as const + +export const withMeta = ( + data: T, + meta: ResponseMeta, +): ExplicitSuccessEnvelope => + Object.defineProperty({ data, meta }, SUCCESS_ENVELOPE_MARK, { + enumerable: false, + value: true, + }) as ExplicitSuccessEnvelope + +export const isExplicitSuccessEnvelope = ( + value: unknown, +): value is ExplicitSuccessEnvelope => + typeof value === 'object' && + value !== null && + (value as Partial>)[SUCCESS_ENVELOPE_MARK] === true + +export interface ErrorEnvelopeBody { + code: string + message: string + details?: Record +} + +export interface ErrorEnvelope { + error: ErrorEnvelopeBody +} + +export type ResponseEnvelope = SuccessEnvelope | ErrorEnvelope + +export const isSuccessEnvelope = ( + envelope: ResponseEnvelope, +): envelope is SuccessEnvelope => 'data' in envelope + +export const isErrorEnvelope = ( + envelope: ResponseEnvelope, +): envelope is ErrorEnvelope => 'error' in envelope diff --git a/apps/core/src/common/response/meta-builder.ts b/apps/core/src/common/response/meta-builder.ts new file mode 100644 index 00000000000..da9ab899264 --- /dev/null +++ b/apps/core/src/common/response/meta-builder.ts @@ -0,0 +1,86 @@ +import type { + ArticleRefMap, + EnrichmentEntry, + EntryTranslation, + InsightsMeta, + InteractionMeta, + RelatedRef, + ResponseMeta, +} from './meta.types' +import { ResponseMetaSchema } from './meta.types' + +type LegacyPaginationLike = { + currentPage?: number + page?: number + size: number + total: number + totalPage?: number + totalPages?: number + hasNextPage?: boolean + hasPrevPage?: boolean +} + +const normalizePagination = (pagination: LegacyPaginationLike) => { + const page = pagination.page ?? pagination.currentPage ?? 1 + const totalPages = + pagination.totalPages ?? + pagination.totalPage ?? + Math.ceil(pagination.total / pagination.size) + + return { + page, + size: pagination.size, + total: pagination.total, + totalPages, + } +} + +export class MetaObjectBuilder { + private readonly meta: Partial = {} + + pagination(value: LegacyPaginationLike): this { + this.meta.pagination = normalizePagination(value) + return this + } + + view(name: string): this { + this.meta.view = name + return this + } + + translation(value: EntryTranslation | Map): this { + this.meta.translation = + value instanceof Map ? Object.fromEntries(value) : value + return this + } + + interaction(value: InteractionMeta | Map): this { + this.meta.interaction = + value instanceof Map ? Object.fromEntries(value) : value + return this + } + + enrichments(value: Record): this { + this.meta.enrichments = value + return this + } + + related(value: RelatedRef[]): this { + this.meta.related = value + return this + } + + articles(value: ArticleRefMap): this { + this.meta.articles = value + return this + } + + insights(value: InsightsMeta): this { + this.meta.insights = value + return this + } + + build(): ResponseMeta { + return ResponseMetaSchema.parse(this.meta) + } +} diff --git a/apps/core/src/common/response/meta.types.ts b/apps/core/src/common/response/meta.types.ts new file mode 100644 index 00000000000..3215b6659d3 --- /dev/null +++ b/apps/core/src/common/response/meta.types.ts @@ -0,0 +1,132 @@ +import { z } from 'zod' + +export const PaginationSchema = z.object({ + page: z.number().int().positive(), + size: z.number().int().positive(), + total: z.number().int().nonnegative(), + totalPages: z.number().int().nonnegative(), +}) + +export const ArticleTranslationSchema = z + .object({ + isTranslated: z.boolean(), + sourceLang: z.string().nullable().optional(), + targetLang: z.string().nullable().optional(), + translatedAt: z.date().optional(), + model: z.string().optional(), + availableTranslations: z.array(z.string()).optional(), + }) + .strict() + +export const EntryTranslationSchema = z + .object({ + article: ArticleTranslationSchema.optional(), + }) + .strict() + +export const InteractionMetaSchema = z + .object({ + isLiked: z.boolean().optional(), + likeCount: z.number().int().nonnegative().optional(), + readCount: z.number().int().nonnegative().optional(), + }) + .strict() + +const EnrichmentImageSchema = z.object({ + url: z.string(), + width: z.number().optional(), + height: z.number().optional(), + alt: z.string().optional(), + blurhash: z.string().optional(), +}) + +const EnrichmentAttributeSchema = z.object({ + key: z.string(), + value: z.union([z.string(), z.number(), z.boolean()]), + label: z.string().optional(), + format: z + .enum(['number', 'rating', 'date', 'percent', 'text', 'duration']) + .optional(), +}) + +const EnrichmentScreenshotSchema = z.object({ + url: z.string(), + width: z.number(), + height: z.number(), + blurhash: z.string().optional(), + palette: z + .object({ + dominant: z.string(), + swatches: z.array(z.string()).optional(), + }) + .optional(), +}) + +export const EnrichmentEntrySchema = z + .object({ + id: z.string().optional(), + title: z.string(), + description: z.string().optional(), + image: EnrichmentImageSchema.optional(), + url: z.string(), + category: z.string(), + subtype: z.string().optional(), + publishedAt: z.string().optional(), + fetchedAt: z.string().optional(), + attributes: z.array(EnrichmentAttributeSchema).optional(), + color: z.string().optional(), + links: z + .array( + z.object({ + rel: z.string(), + url: z.string(), + label: z.string().optional(), + }), + ) + .optional(), + screenshot: EnrichmentScreenshotSchema.optional(), + raw: z.unknown().optional(), + }) + .passthrough() + +export const RelatedRefSchema = z + .object({ + id: z.string(), + title: z.string(), + slug: z.string().optional(), + nid: z.number().optional(), + type: z.string().optional(), + }) + .passthrough() + +export const InsightsMetaSchema = z + .object({ hasInLocale: z.boolean() }) + .strict() + +export const ResponseMetaSchema = z.object({ + pagination: PaginationSchema.optional(), + view: z.string().optional(), + translation: z + .union([ + EntryTranslationSchema, + z.record(z.string(), EntryTranslationSchema), + ]) + .optional(), + interaction: z + .union([InteractionMetaSchema, z.record(z.string(), InteractionMetaSchema)]) + .optional(), + enrichments: z.record(z.string().url(), EnrichmentEntrySchema).optional(), + related: z.array(RelatedRefSchema).optional(), + articles: z.record(z.string(), RelatedRefSchema).optional(), + insights: InsightsMetaSchema.optional(), +}) + +export type Pagination = z.infer +export type ArticleTranslation = z.infer +export type EntryTranslation = z.infer +export type InteractionMeta = z.infer +export type EnrichmentEntry = z.infer +export type RelatedRef = z.infer +export type ArticleRefMap = Record +export type InsightsMeta = z.infer +export type ResponseMeta = z.infer diff --git a/apps/core/src/common/views/view.types.ts b/apps/core/src/common/views/view.types.ts new file mode 100644 index 00000000000..2331ea54011 --- /dev/null +++ b/apps/core/src/common/views/view.types.ts @@ -0,0 +1,29 @@ +import type { z } from 'zod' + +import { AppErrorCode, createAppException } from '~/common/errors' + +export type ViewDef = z.ZodTypeAny + +export type ViewMap = Record + +export type ViewName = keyof TViews & string + +export type ViewOf< + TViews extends ViewMap, + K extends ViewName, +> = z.infer + +export function parseView( + view: string, + viewMap: TViews, + row: unknown, +): z.infer]> { + const schema = viewMap[view] + if (!schema) { + throw createAppException(AppErrorCode.INVALID_VIEW, { + view, + available: Object.keys(viewMap), + }) + } + return schema.parse(row) as z.infer]> +} diff --git a/apps/core/src/common/zod/custom.ts b/apps/core/src/common/zod/custom.ts index bc9b387013c..42910ea5cf7 100644 --- a/apps/core/src/common/zod/custom.ts +++ b/apps/core/src/common/zod/custom.ts @@ -34,14 +34,14 @@ export const zSlug = z .min(1) .transform((val) => val.trim()) -export const zEmail = (message = '请更正为正确的邮箱') => +export const zEmail = (message = 'Please enter a valid email address') => z.string().email({ message }) -export const zUrl = (message = '请更正为正确的网址') => +export const zUrl = (message = 'Please enter a valid URL') => z.string().url({ message }) export const zMaxLengthString = (max: number, message?: string) => - z.string().max(max, message || `不得大于 ${max} 个字符`) + z.string().max(max, message || `Must not exceed ${max} characters`) export const zRefTypeTransform = z.preprocess((val) => { if (!val || typeof val !== 'string') return val diff --git a/apps/core/src/common/zod/primitives.ts b/apps/core/src/common/zod/primitives.ts index d72a8177559..2a18be0e519 100644 --- a/apps/core/src/common/zod/primitives.ts +++ b/apps/core/src/common/zod/primitives.ts @@ -26,7 +26,7 @@ export const zAllowedUrl = z.string().refine( return false } }, - { message: '请更正为正确的网址' }, + { message: 'Please enter a valid URL' }, ) export const zStrictUrl = z.string().url() @@ -84,16 +84,15 @@ export const zUniqueStringArray = zArrayUnique(z.string().min(1)) // Sort Types -export const zSortOrder = z.preprocess( - (val) => { - if (typeof val === 'number' && (val === 1 || val === -1)) return val - if (typeof val === 'string') { - if (val === '1' || val === 'asc') return 1 - if (val === '-1' || val === 'desc') return -1 - const num = Number.parseInt(val) - if (num === 1 || num === -1) return num - } - return undefined - }, - z.union([z.literal(1), z.literal(-1)]).optional(), -) +// Internally typed as `'asc' | 'desc'`; on the wire, the legacy numeric form +// `1` / `-1` (and their string equivalents) is coerced for backward compat. +export const zSortOrder = z + .preprocess( + (val) => { + if (val === 1 || val === '1') return 'asc' + if (val === -1 || val === '-1') return 'desc' + return val + }, + z.enum(['asc', 'desc']), + ) + .default('desc') diff --git a/apps/core/src/constants/business-event.constant.ts b/apps/core/src/constants/business-event.constant.ts index 45106e14878..18aa2b1a027 100644 --- a/apps/core/src/constants/business-event.constant.ts +++ b/apps/core/src/constants/business-event.constant.ts @@ -58,7 +58,7 @@ export enum BusinessEvents { INSIGHTS_GENERATED = 'INSIGHTS_GENERATED', // util - CONTENT_REFRESH = 'CONTENT_REFRESH', // 内容更新或重置 页面需要重载 + CONTENT_REFRESH = 'CONTENT_REFRESH', // Content updated or reset; page needs reload // for admin IMAGE_REFRESH = 'IMAGE_REFRESH', diff --git a/apps/core/src/constants/cache.constant.ts b/apps/core/src/constants/cache.constant.ts index 00290629611..94c9322c5f9 100644 --- a/apps/core/src/constants/cache.constant.ts +++ b/apps/core/src/constants/cache.constant.ts @@ -6,25 +6,25 @@ export enum RedisKeys { MaxOnlineCount = 'max_online_count', IpInfoMap = 'ip_info_map', LikeSite = 'like_site', - /** 后台管理入口页面缓存 */ + /** Admin dashboard entry page cache */ AdminPage = 'admin_next_index_entry', - /** 配置项缓存 */ + /** Configuration cache */ ConfigCache = 'config_cache', - /** 配置版本号 */ + /** Configuration version number */ ConfigVersion = 'config_version', PTYSession = 'pty_session', - /** HTTP 请求缓存 */ + /** HTTP request cache */ HTTPCache = 'http_cache', - /** Snippet 缓存 */ + /** Snippet cache */ SnippetCache = 'snippet_cache', - /** 翻译词表缓存 */ + /** Translation glossary cache */ TranslationEntryDict = 'translation_entry_dict', - /** 云函数缓存数据 */ + /** Serverless function cache storage */ ServerlessStorage = 'serverless_storage', JWTStore = 'jwt_store', - /** 最近速记的点赞,点踩记录 */ + /** Like/dislike records for recent shorthand entries */ RecentlyAttitude = 'recently_attitude', Socket = 'socket', ClusterEventStream = 'cluster_event_stream', @@ -33,7 +33,7 @@ export enum RedisKeys { AnalyzeTrafficSource = 'analyze_traffic_source', AnalyzeDeviceDistribution = 'analyze_device_distribution', - /** Enrichment capture LRU touchAccess 节流 NX 锁 */ + /** NX lock to throttle Enrichment capture LRU touchAccess */ EnrichmentCaptureTouch = 'enrichment_capture_touch', } export const API_CACHE_PREFIX = 'mx-api-cache:' diff --git a/apps/core/src/constants/error-code.constant.ts b/apps/core/src/constants/error-code.constant.ts deleted file mode 100644 index e5840901625..00000000000 --- a/apps/core/src/constants/error-code.constant.ts +++ /dev/null @@ -1,317 +0,0 @@ -export enum ErrorCodeEnum { - // app - Default = 1, - NoContentCanBeModified = 1000, - ContentNotFound = 1001, - ContentNotFoundCantProcess = 1002, - - // biz - general - SlugNotAvailable = 10000, - MaxCountLimit = 10001, - - // biz - validation (400) - InvalidParameter = 10100, - InvalidBody = 10101, - InvalidSlug = 10102, - InvalidName = 10103, - InvalidReference = 10104, - InvalidOrderValue = 10105, - InvalidSearchType = 10106, - InvalidRoomName = 10107, - InvalidSubscribeType = 10108, - - // biz - resource not found (404) - ResourceNotFound = 11000, - CategoryNotFound = 11001, - PostNotFound = 11002, - SnippetNotFound = 11003, - DraftNotFound = 11004, - DraftHistoryNotFound = 11005, - LinkNotFound = 11006, - FileNotFound = 11007, - WebhookNotFound = 11008, - WebhookEventNotFound = 11009, - TokenNotFound = 11010, - ConfigNotFound = 11011, - DocumentNotFound = 11012, - EntryNotFound = 11013, - RefModelNotFound = 11014, - CronNotFound = 11015, - PresetNotFound = 11016, - FunctionNotFound = 11017, - - // biz - conflict/duplicate (400) - DuplicateLink = 12000, - SnippetExists = 12001, - PresetKeyExists = 12002, - AlreadySupported = 12003, - UserAlreadyExists = 12004, - FileExists = 12005, - - // biz - disabled/not enabled (400/403) - LinkDisabled = 13000, - SubpathLinkDisabled = 13001, - BackupNotEnabled = 13004, - SubscribeNotEnabled = 13005, - PasswordLoginDisabled = 13006, - AIProviderNotEnabled = 13007, - ImageStorageNotConfigured = 13008, - CommentUploadDisabled = 13009, - - // biz - forbidden (403) - NoteForbidden = 14000, - LinkApplyDisabled = 14001, - SnippetPrivate = 14002, - InitForbidden = 14003, - PostHiddenOrEncrypted = 14004, - CommentForbidden = 14005, - ServerlessNoPermission = 14006, - BuiltinPresetCannotDelete = 14007, - - // biz - auth (401/403) - AuthChallengeMissing = 15000, - AuthChallengeExpired = 15001, - AuthRegistrationMissing = 15002, - AuthUsernameIncorrect = 15003, - AuthPasswordIncorrect = 15004, - AuthSessionNotFound = 15005, - AuthUserIdNotFound = 15006, - AuthFailed = 15007, - AuthNotLoggedIn = 15008, - AuthTokenInvalid = 15009, - - // biz - operation failed (400) - CategoryHasPosts = 16000, - PostRelatedNotExists = 16001, - PostSelfRelation = 16002, - CommentPostNotExists = 16003, - FileRenameFailed = 16004, - PasswordSameAsOld = 16005, - InvalidCronMethod = 16006, - SubscribeTypeEmpty = 16007, - - // biz - user (400) - UserNotExists = 17000, - InitAlreadyCompleted = 17001, - - // biz - snippet validation (400) - SnippetInvalidJson = 18000, - SnippetInvalidJson5 = 18001, - SnippetInvalidYaml = 18002, - SnippetInvalidFunction = 18003, - - // biz - link validation (400) - LinkAvatarValidationFailed = 19000, - - // biz - config validation (422) - ConfigValidationFailed = 19100, - CannotGetIp = 19101, - CommentUploadFileTooLarge = 19102, - CommentUploadInvalidMime = 19103, - CommentImageCapExceeded = 19104, - CommentUploadFileNotOwned = 19105, - CommentUploadFileAlreadyBound = 19106, - CommentUploadRateLimited = 19107, - CommentUploadQuotaExceeded = 19108, - CommentUploadAccountTooNew = 19109, - CommentUploadInsufficientComments = 19110, - - // comment - CommentDisabled = 30000, - CommentTooDeep = 30001, - - // serverless - ServerlessError = 80000, - - // email - EmailTemplateNotFound = 90000, - - // 422 - MineZip = 100001, - - // AI - AINotEnabled = 200000, - AIKeyExpired = 200001, - AIException = 200002, - AIProcessing = 200003, - AIResultParsingError = 200004, - AITranslationNotFound = 200005, - AITaskNotFound = 200006, - AITaskAlreadyCompleted = 200007, - AITaskCannotRetry = 200008, - - // system - MasterLost = 99998, - BanInDemo = 999999, - - // Bing - BingAPIFailed = 300002, - BingKeyInvalid = 300003, - BingDomainInvalid = 300004, -} - -export const ErrorCode = Object.freeze>( - { - [ErrorCodeEnum.Default]: ['未知错误', 500], - [ErrorCodeEnum.SlugNotAvailable]: ['slug 不可用', 400], - [ErrorCodeEnum.ContentNotFound]: ['内容不存在', 404], - [ErrorCodeEnum.ContentNotFoundCantProcess]: ['内容不存在,无法处理', 400], - [ErrorCodeEnum.MaxCountLimit]: ['已达到最大数量限制', 400], - [ErrorCodeEnum.BanInDemo]: ['Demo 模式下此操作不可用', 400], - [ErrorCodeEnum.MasterLost]: ['站点主人信息已丢失', 500], - [ErrorCodeEnum.CommentDisabled]: ['全站评论已关闭', 403], - [ErrorCodeEnum.CommentTooDeep]: ['评论嵌套层数过深', 400], - [ErrorCodeEnum.ServerlessError]: ['Function 执行报错', 500], - [ErrorCodeEnum.NoContentCanBeModified]: [ - '内容不存在,没有内容可被修改', - 400, - ], - - // validation (400) - [ErrorCodeEnum.InvalidParameter]: ['参数无效', 400], - [ErrorCodeEnum.InvalidBody]: ['请求体必须是对象', 422], - [ErrorCodeEnum.InvalidSlug]: ['slug 必须是字符串', 422], - [ErrorCodeEnum.InvalidName]: ['name 必须是字符串', 422], - [ErrorCodeEnum.InvalidReference]: ['reference 必须是字符串', 422], - [ErrorCodeEnum.InvalidOrderValue]: ['order 值必须唯一', 422], - [ErrorCodeEnum.InvalidSearchType]: ['无效的搜索类型', 400], - [ErrorCodeEnum.InvalidRoomName]: ['无效的房间名', 400], - [ErrorCodeEnum.InvalidSubscribeType]: ['订阅类型无效', 400], - - // resource not found (404) - [ErrorCodeEnum.ResourceNotFound]: ['资源不存在', 404], - [ErrorCodeEnum.CategoryNotFound]: ['分类不存在', 404], - [ErrorCodeEnum.PostNotFound]: ['文章不存在', 404], - [ErrorCodeEnum.SnippetNotFound]: ['Snippet 不存在', 404], - [ErrorCodeEnum.DraftNotFound]: ['草稿不存在', 404], - [ErrorCodeEnum.DraftHistoryNotFound]: ['历史版本不存在', 404], - [ErrorCodeEnum.LinkNotFound]: ['友链不存在', 404], - [ErrorCodeEnum.FileNotFound]: ['文件不存在', 404], - [ErrorCodeEnum.WebhookNotFound]: ['Webhook 不存在', 404], - [ErrorCodeEnum.WebhookEventNotFound]: ['Webhook 事件不存在', 404], - [ErrorCodeEnum.TokenNotFound]: ['Token 不存在', 404], - [ErrorCodeEnum.ConfigNotFound]: ['设置不存在', 404], - [ErrorCodeEnum.DocumentNotFound]: ['文档不存在', 404], - [ErrorCodeEnum.EntryNotFound]: ['条目不存在', 404], - [ErrorCodeEnum.RefModelNotFound]: ['引用模型不存在', 404], - [ErrorCodeEnum.CronNotFound]: ['定时任务不存在', 404], - [ErrorCodeEnum.PresetNotFound]: ['预设字段不存在', 404], - [ErrorCodeEnum.FunctionNotFound]: ['函数不存在', 404], - - // conflict/duplicate (400) - [ErrorCodeEnum.DuplicateLink]: ['请不要重复申请友链哦', 400], - [ErrorCodeEnum.SnippetExists]: ['Snippet 已存在', 400], - [ErrorCodeEnum.PresetKeyExists]: ['预设字段 key 已存在', 400], - [ErrorCodeEnum.AlreadySupported]: ['你已经支持过啦!', 400], - [ErrorCodeEnum.UserAlreadyExists]: ['我已经有一个主人了哦', 400], - [ErrorCodeEnum.FileExists]: ['文件已存在', 400], - - // disabled/not enabled (400/403) - [ErrorCodeEnum.LinkDisabled]: ['您的友链已被禁用,请联系管理员', 400], - [ErrorCodeEnum.SubpathLinkDisabled]: [ - '管理员当前禁用了子路径友链申请', - 422, - ], - [ErrorCodeEnum.BackupNotEnabled]: ['请先在设置中开启备份功能', 400], - [ErrorCodeEnum.SubscribeNotEnabled]: ['订阅功能未开启', 400], - [ErrorCodeEnum.PasswordLoginDisabled]: ['密码登录已禁用', 400], - [ErrorCodeEnum.AIProviderNotEnabled]: [ - '没有配置启用的 AI Provider,无法启用 AI 评论审核', - 400, - ], - [ErrorCodeEnum.ImageStorageNotConfigured]: [ - 'S3 图床未配置或配置不完整', - 400, - ], - [ErrorCodeEnum.CommentUploadDisabled]: ['评论图片上传未启用', 503], - - // forbidden (403) - [ErrorCodeEnum.NoteForbidden]: ['不要偷看人家的小心思啦~', 403], - [ErrorCodeEnum.LinkApplyDisabled]: ['主人目前不允许申请友链了!', 403], - [ErrorCodeEnum.SnippetPrivate]: ['Snippet 是私有的', 403], - [ErrorCodeEnum.InitForbidden]: ['默认设置在完成注册之后不可见', 403], - [ErrorCodeEnum.PostHiddenOrEncrypted]: ['该文章已隐藏或加密', 403], - [ErrorCodeEnum.CommentForbidden]: ['主人禁止了评论', 403], - [ErrorCodeEnum.ServerlessNoPermission]: ['没有权限运行该函数', 403], - [ErrorCodeEnum.BuiltinPresetCannotDelete]: ['内置预设字段不能删除', 403], - - // auth (401/403) - [ErrorCodeEnum.AuthChallengeMissing]: ['Challenge 不存在', 400], - [ErrorCodeEnum.AuthChallengeExpired]: ['Challenge 已过期', 400], - [ErrorCodeEnum.AuthRegistrationMissing]: ['注册信息不存在', 400], - [ErrorCodeEnum.AuthUsernameIncorrect]: ['用户名不正确', 403], - [ErrorCodeEnum.AuthPasswordIncorrect]: ['密码不正确', 403], - [ErrorCodeEnum.AuthSessionNotFound]: ['会话不存在', 400], - [ErrorCodeEnum.AuthUserIdNotFound]: ['用户 ID 不存在', 400], - [ErrorCodeEnum.AuthFailed]: ['认证失败', 400], - [ErrorCodeEnum.AuthNotLoggedIn]: ['未登录', 401], - [ErrorCodeEnum.AuthTokenInvalid]: ['令牌无效', 401], - - // operation failed (400) - [ErrorCodeEnum.CategoryHasPosts]: ['该分类中有其他文章,无法被删除', 400], - [ErrorCodeEnum.PostRelatedNotExists]: ['关联文章不存在', 400], - [ErrorCodeEnum.PostSelfRelation]: ['文章不能关联自己', 400], - [ErrorCodeEnum.CommentPostNotExists]: ['评论文章不存在', 400], - [ErrorCodeEnum.FileRenameFailed]: ['重命名文件失败', 400], - [ErrorCodeEnum.PasswordSameAsOld]: ['密码可不能和原来的一样哦', 422], - [ErrorCodeEnum.InvalidCronMethod]: ['无效的定时任务方法', 400], - [ErrorCodeEnum.SubscribeTypeEmpty]: ['订阅类型不能为空', 400], - - // user (400) - [ErrorCodeEnum.UserNotExists]: ['我还没有主人', 400], - [ErrorCodeEnum.InitAlreadyCompleted]: [ - '已经完成初始化,请登录后进行设置', - 400, - ], - - // snippet validation (400) - [ErrorCodeEnum.SnippetInvalidJson]: ['内容不是有效的 JSON', 400], - [ErrorCodeEnum.SnippetInvalidJson5]: ['内容不是有效的 JSON5', 400], - [ErrorCodeEnum.SnippetInvalidYaml]: ['内容不是有效的 YAML', 400], - [ErrorCodeEnum.SnippetInvalidFunction]: ['Serverless 函数无效', 400], - - // link validation (400) - [ErrorCodeEnum.LinkAvatarValidationFailed]: ['头像验证失败', 400], - - // config validation (422) - [ErrorCodeEnum.ConfigValidationFailed]: ['配置验证失败', 422], - [ErrorCodeEnum.CannotGetIp]: ['无法获取 IP', 422], - [ErrorCodeEnum.CommentUploadFileTooLarge]: ['图片大小超出限制', 413], - [ErrorCodeEnum.CommentUploadInvalidMime]: ['不支持的图片格式', 415], - [ErrorCodeEnum.CommentImageCapExceeded]: ['评论图片数量超过上限', 422], - [ErrorCodeEnum.CommentUploadFileNotOwned]: ['不能引用他人上传的图片', 403], - [ErrorCodeEnum.CommentUploadFileAlreadyBound]: [ - '该图片已绑定其他评论,请重新上传', - 409, - ], - [ErrorCodeEnum.CommentUploadRateLimited]: ['上传过于频繁,请稍后再试', 429], - [ErrorCodeEnum.CommentUploadQuotaExceeded]: ['图片总容量超出限制', 429], - [ErrorCodeEnum.CommentUploadAccountTooNew]: [ - '账号注册时间不足,暂无法上传', - 403, - ], - [ErrorCodeEnum.CommentUploadInsufficientComments]: [ - '需累计更多评论后方可上传', - 403, - ], - - [ErrorCodeEnum.MineZip]: ['文件格式必须是 zip 类型', 422], - - [ErrorCodeEnum.AINotEnabled]: ['AI 功能未开启', 400], - [ErrorCodeEnum.AIKeyExpired]: ['AI Key 已过期,请联系管理员', 400], - [ErrorCodeEnum.AIException]: ['AI 服务异常', 500], - [ErrorCodeEnum.AIProcessing]: ['AI 正在处理此请求,请稍后再试', 400], - [ErrorCodeEnum.AIResultParsingError]: ['AI 结果解析错误', 500], - - [ErrorCodeEnum.AITranslationNotFound]: ['翻译不存在', 404], - [ErrorCodeEnum.AITaskNotFound]: ['AI 任务不存在', 404], - [ErrorCodeEnum.AITaskAlreadyCompleted]: ['AI 任务已完成,无法取消', 400], - [ErrorCodeEnum.AITaskCannotRetry]: ['AI 任务无法重试', 400], - - [ErrorCodeEnum.EmailTemplateNotFound]: ['邮件模板不存在', 400], - - [ErrorCodeEnum.BingAPIFailed]: ['Bing API 请求失败', 503], - [ErrorCodeEnum.BingKeyInvalid]: ['Bing API 密钥无效', 401], - [ErrorCodeEnum.BingDomainInvalid]: ['Bing API 域名无效', 400], - }, -) diff --git a/apps/core/src/constants/path.constant.ts b/apps/core/src/constants/path.constant.ts index f0c892343ed..b3068664e6a 100644 --- a/apps/core/src/constants/path.constant.ts +++ b/apps/core/src/constants/path.constant.ts @@ -26,7 +26,7 @@ export const BACKUP_DIR = !isDev ? join(DATA_DIR, 'backup') : join(TEMP_DIR, 'backup') -// Swarm/容器环境下,更新后的 admin 资源需要落到持久化数据目录。 +// In Swarm/container environments, updated admin assets must land in the persistent data directory. export const LOCAL_ADMIN_ASSET_PATH = join(DATA_DIR, 'admin') export const BUNDLED_ADMIN_ASSET_PATH = isDev ? LOCAL_ADMIN_ASSET_PATH diff --git a/apps/core/src/constants/system.constant.ts b/apps/core/src/constants/system.constant.ts index c907620d514..7b1aa6a995b 100644 --- a/apps/core/src/constants/system.constant.ts +++ b/apps/core/src/constants/system.constant.ts @@ -1,6 +1,8 @@ export const REFLECTOR = 'Reflector' export const RESPONSE_PASSTHROUGH_METADATA = '__responsePassthrough__' +export const RESPONSE_V2_METADATA = '__responseV2__' +export const BYPASS_CASE_TRANSFORM_METADATA = '__bypassCaseTransform__' // @nestjs/schedule // ESM requires explicit extension for deep imports under Node (e.g. Node 24+). diff --git a/apps/core/src/global/index.global.ts b/apps/core/src/global/index.global.ts index 403a8bc670c..01ce33b91ee 100644 --- a/apps/core/src/global/index.global.ts +++ b/apps/core/src/global/index.global.ts @@ -18,20 +18,20 @@ import { consola, globalLogger, logger } from './consola.global' import { cwd, isDev } from './env.global' import { registerJSONGlobal } from './json.global' -// 建立目录 +// Create application directories function createAppFolders() { if (!CLUSTER.enable || cluster.isPrimary) { mkdirSync(DATA_DIR, { recursive: true }) - globalLogger.log(pc.blue(`数据目录已经建好:${DATA_DIR}`)) + globalLogger.log(pc.blue(`Data directory ready: ${DATA_DIR}`)) mkdirSync(TEMP_DIR, { recursive: true }) - globalLogger.log(pc.blue(`临时目录已经建好:${TEMP_DIR}`)) + globalLogger.log(pc.blue(`Temp directory ready: ${TEMP_DIR}`)) mkdirSync(USER_ASSET_DIR, { recursive: true }) - globalLogger.log(pc.blue(`资源目录已经建好:${USER_ASSET_DIR}`)) + globalLogger.log(pc.blue(`Asset directory ready: ${USER_ASSET_DIR}`)) mkdirSync(STATIC_FILE_DIR, { recursive: true }) - globalLogger.log(pc.blue(`文件存放目录已经建好:${STATIC_FILE_DIR}`)) + globalLogger.log(pc.blue(`Static file directory ready: ${STATIC_FILE_DIR}`)) mkdirSync(STATIC_FILE_TRASH_DIR, { recursive: true }) globalLogger.log( - pc.blue(`文件回收站目录已经建好:${STATIC_FILE_TRASH_DIR}`), + pc.blue(`File trash directory ready: ${STATIC_FILE_TRASH_DIR}`), ) const packageJSON = `${DATA_DIR}/package.json` diff --git a/apps/core/src/modules/ack/ack.controller.ts b/apps/core/src/modules/ack/ack.controller.ts index a2441a54ad4..d8145fa9e83 100644 --- a/apps/core/src/modules/ack/ack.controller.ts +++ b/apps/core/src/modules/ack/ack.controller.ts @@ -2,9 +2,9 @@ import { Body, HttpCode, Post, Res } from '@nestjs/common' import type { FastifyReply } from 'fastify' import { ApiController } from '~/common/decorators/api-controller.decorator' -import { BizException } from '~/common/exceptions/biz.exception' +import { HTTPDecorators } from '~/common/decorators/http.decorator' +import { AppErrorCode, createAppException } from '~/common/errors' import { BusinessEvents } from '~/constants/business-event.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { WebEventsGateway } from '~/processors/gateway/web/events.gateway' import { CountingService } from '~/processors/helper/helper.counting.service' @@ -14,12 +14,12 @@ import { AckDto, AckEventType, AckReadPayloadSchema } from './ack.schema' export class AckController { constructor( private readonly countingService: CountingService, - private readonly webGateway: WebEventsGateway, ) {} @Post('/') @HttpCode(200) + @HTTPDecorators.RawResponse async ack(@Body() body: AckDto, @Res() res: FastifyReply) { const { type, payload } = body @@ -31,10 +31,9 @@ export class AckController { const path = err.path.join('.') return path ? `${path}: ${err.message}` : err.message }) - throw new BizException( - ErrorCodeEnum.InvalidBody, - errorMessages.join('; '), - ) + throw createAppException(AppErrorCode.ACK_INVALID_PAYLOAD, { + message: errorMessages.join('; '), + }) } const { id, type: articleType } = result.data diff --git a/apps/core/src/modules/activity/activity.controller.ts b/apps/core/src/modules/activity/activity.controller.ts index 13734c812e3..0d5b1b209e9 100644 --- a/apps/core/src/modules/activity/activity.controller.ts +++ b/apps/core/src/modules/activity/activity.controller.ts @@ -8,10 +8,18 @@ import { HTTPDecorators } from '~/common/decorators/http.decorator' import type { IpRecord } from '~/common/decorators/ip.decorator' import { IpLocation } from '~/common/decorators/ip.decorator' import { Lang } from '~/common/decorators/lang.decorator' +import { + isExplicitSuccessEnvelope, + OK_DATA, + withMeta, +} from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' import { CollectionRefTypes } from '~/constants/db.constant' -import { TranslationService } from '~/processors/helper/helper.translation.service' -import { PagerDto } from '~/shared/dto/pager.dto' -import { snakecaseKeysWithCompat } from '~/utils/case.util' +import { + applyArticleTranslationInPlace, + TranslationService, +} from '~/processors/helper/helper.translation.service' +import { BasicPagerDto } from '~/shared/dto/pager.dto' import { ReaderService } from '../reader/reader.service' import { Activity } from './activity.constant' @@ -60,30 +68,30 @@ export class ActivityController { id, ip, ) + return OK_DATA } @Get('/likes') @Auth() - async getLikeActivities(@Query() pager: PagerDto) { + getLikeActivities(@Query() pager: BasicPagerDto) { const { page, size } = pager - return this.service.getLikeActivities(page, size) } @Get('/') @Auth() - async activities(@Query() pager: ActivityQueryDto) { + activities(@Query() pager: ActivityQueryDto) { const { page, size, type } = pager switch (type) { case Activity.Like: { return this.service.getLikeActivities(page, size) } - case Activity.ReadDuration: { return this.service.getReadDurationActivities(page, size) } } + return null } @Post('/presence/update') @@ -92,40 +100,32 @@ export class ActivityController { @IpLocation() location: IpRecord, ) { await this.service.updatePresence(body, location.ip) + return OK_DATA } @Get('/presence') @HTTPDecorators.SkipLogging - @HTTPDecorators.Bypass async getPresence(@Query() query: GetPresenceQueryDto) { - const roomPresence = await this.service.getRoomPresence(query.room_name) + const roomPresence = await this.service.getRoomPresence(query.roomName) const readerIds = roomPresence .map((item) => item.readerId) .filter(Boolean) as string[] const readerRows = await this.readerService.findReaderInIds(readerIds) - const readers = readerRows.map((item) => - snakecaseKeysWithCompat({ - ...item, - id: item.id, - }), - ) + const readers = readerRows.map((item) => ({ ...item, id: item.id })) return { - data: keyBy( - roomPresence.map(({ ip, ...item }) => { - return snakecaseKeysWithCompat(item) - }), + presence: keyBy( + roomPresence.map(({ ip, ...item }) => item), 'identity', ), - readers: keyBy(readers, 'id'), } } @Delete('/:type') @Auth() - async deletePresence( + deletePresence( @Param() params: ActivityTypeParamsDto, @Body() body: ActivityDeleteDto, ) { @@ -137,7 +137,7 @@ export class ActivityController { @Auth() @Delete('/all') - async deleteAllPresence() { + deleteAllPresence() { return this.service.deleteAll() } @@ -152,32 +152,42 @@ export class ActivityController { ) } - if (lang) { - for (const type of Object.keys(objects)) { - if (!objects[type].length) continue - objects[type] = await this.translationService.translateList({ - items: objects[type], - targetLang: lang, - translationFields: ['title', 'translationMeta'] as const, - getInput: (item: any) => ({ - id: item.id ?? '', - title: item.title ?? '', - createdAt: item.createdAt, - }), - applyResult: (item: any, translation) => { - if (!translation?.isTranslated) return item - return { - ...item, - title: translation.title, - isTranslated: true, - translationMeta: translation.translationMeta, - } - }, - }) + if (!lang) return { ...roomInfo, objects } + + const allItems = Object.values(objects).flat() as Array<{ + id: string + title: string + createdAt: Date + modifiedAt?: Date | null + }> + + const { results, meta } = + await this.translationService.collectArticleTranslations({ + articles: allItems.map((item) => ({ + id: String(item.id), + title: item.title ?? '', + text: '', + createdAt: item.createdAt, + modifiedAt: item.modifiedAt ?? null, + })), + targetLang: lang, + fields: ['title'], + }) + + for (const type of Object.keys(objects)) { + for (const item of objects[type] as Array>) { + const r = results.get(String(item.id)) + if (r) + applyArticleTranslationInPlace(item, r as any, { fields: ['title'] }) } } - return { ...roomInfo, objects } + if (meta.size === 0) return { ...roomInfo, objects } + + return withMeta( + { ...roomInfo, objects }, + new MetaObjectBuilder().translation(meta).build(), + ) } @Get('/online-count') @@ -230,28 +240,38 @@ export class ActivityController { ref: pick(item.ref, ARTICLE_REF_FIELDS), })) if (!lang) return data - return this.translationService.translateList({ - items: data, - targetLang: lang, - translationFields: ['title', 'translationMeta'] as const, - getInput: (item) => { - const ref = item.ref as Record | undefined - return { - id: item.refId, - title: ref?.title ?? '', - created: ref?.createdAt, - } - }, - applyResult: (item, translation) => { - if (!translation?.isTranslated || !item.ref) return item - return { - ...item, - ref: { ...item.ref, title: translation.title }, - isTranslated: true, - translationMeta: translation.translationMeta, - } - }, - }) + + const { results, meta } = + await this.translationService.collectArticleTranslations({ + articles: data + .filter((item) => item.ref) + .map((item) => ({ + id: String(item.refId), + title: (item.ref as any)?.title ?? '', + text: '', + createdAt: (item.ref as any)?.createdAt, + modifiedAt: null as Date | null, + })), + targetLang: lang, + fields: ['title'], + }) + + for (const item of data) { + if (!item.ref) continue + const r = results.get(String(item.refId)) + if (r) + applyArticleTranslationInPlace( + item.ref as Record, + r as any, + { + fields: ['title'], + }, + ) + } + + if (meta.size === 0) return data + + return withMeta(data, new MetaObjectBuilder().translation(meta).build()) } @Get('/recent') @@ -262,10 +282,10 @@ export class ActivityController { this.service.getRecentPublish(), ]) - let transformedLike = like.data.map((item) => { + const transformedLike = like.data.map((item) => { const likeData = pick(item, 'createdAt', 'id') as any if (!item.ref) { - likeData.title = '已删除的内容' + likeData.title = 'Deleted content' return likeData } if ('nid' in item.ref) { @@ -280,49 +300,109 @@ export class ActivityController { return likeData }) - let post = recentPublish.post as any[] - let note = recentPublish.note as any[] - - if (lang) { - const translateTitleOnly = >( - items: T[], - getId: (item: T) => string, - ) => - this.translationService.translateList({ - items, - targetLang: lang, - translationFields: ['title'] as const, - getInput: (item) => ({ - id: getId(item), - title: item.title ?? '', - createdAt: item.createdAt, - modifiedAt: item.modifiedAt, - }), - applyResult: (item, translation) => - translation?.isTranslated - ? { ...item, title: translation.title } - : item, - }) - - transformedLike = await translateTitleOnly( - transformedLike, - (item) => item.articleId ?? '', - ) - post = await translateTitleOnly(post, (item) => item.id) - note = await translateTitleOnly(note, (item) => item.id) + const post = recentPublish.post as any[] + const note = recentPublish.note as any[] + + if (!lang) { + for (const item of transformedLike) { + delete item.articleId + } + return { + like: transformedLike, + comment, + recent: recentPublish.recent, + post, + note, + } + } + + const likeSnapshots = transformedLike + .filter((item) => item.articleId && item.title !== 'Deleted content') + .map((item) => ({ + id: String(item.articleId), + title: item.title ?? '', + text: '', + createdAt: item.createdAt, + modifiedAt: null as Date | null, + })) + + const postSnapshots = post.map((item) => ({ + id: String(item.id), + title: item.title ?? '', + text: '', + createdAt: item.createdAt, + modifiedAt: item.modifiedAt ?? null, + })) + + const noteSnapshots = note.map((item) => ({ + id: String(item.id), + title: item.title ?? '', + text: '', + createdAt: item.createdAt, + modifiedAt: item.modifiedAt ?? null, + })) + + const [likeCollect, postCollect, noteCollect] = await Promise.all([ + this.translationService.collectArticleTranslations({ + articles: likeSnapshots, + targetLang: lang, + fields: ['title'], + }), + this.translationService.collectArticleTranslations({ + articles: postSnapshots, + targetLang: lang, + fields: ['title'], + }), + this.translationService.collectArticleTranslations({ + articles: noteSnapshots, + targetLang: lang, + fields: ['title'], + }), + ]) + + for (const item of transformedLike) { + if (!item.articleId || item.title === 'Deleted content') continue + const r = likeCollect.results.get(String(item.articleId)) + if (r) + applyArticleTranslationInPlace(item, r as any, { fields: ['title'] }) + } + + for (const item of post) { + const r = postCollect.results.get(String(item.id)) + if (r) + applyArticleTranslationInPlace(item, r as any, { fields: ['title'] }) + } + + for (const item of note) { + const r = noteCollect.results.get(String(item.id)) + if (r) + applyArticleTranslationInPlace(item, r as any, { fields: ['title'] }) } for (const item of transformedLike) { delete item.articleId } - return { + const combinedMeta = new Map([ + ...likeCollect.meta, + ...postCollect.meta, + ...noteCollect.meta, + ]) + + const data = { like: transformedLike, comment, recent: recentPublish.recent, post, note, } + + if (combinedMeta.size === 0) return data + + return withMeta( + data, + new MetaObjectBuilder().translation(combinedMeta).build(), + ) } @HTTPDecorators.SkipLogging @@ -334,7 +414,9 @@ export class ActivityController { const fromDate = new Date(query.from) if (fromDate > new Date()) return [] - const { post, note } = await this.getRecentActivities(lang) + const raw = await this.getRecentActivities(lang) + const result = isExplicitSuccessEnvelope(raw) ? raw.data : raw + const { post, note } = result const isAfter = (item: any) => new Date(item.createdAt) > fromDate const postList = post.filter(isAfter).map((item) => ({ @@ -357,50 +439,60 @@ export class ActivityController { const result = await this.service.getLastYearPublication() if (!lang) return result - if (result.posts.length) { - result.posts = await this.translationService.translateList({ - items: result.posts as any[], + const posts = result.posts as any[] + const notes = result.notes as any[] + + const postSnapshots = posts.map((item: any) => ({ + id: String(item.id), + title: item.title ?? '', + text: '', + createdAt: item.createdAt, + modifiedAt: item.modifiedAt ?? null, + })) + + const noteSnapshots = notes + .filter((item: any) => item.title !== 'Private note') + .map((item: any) => ({ + id: String(item.id), + title: item.title ?? '', + text: '', + createdAt: item.createdAt, + modifiedAt: item.modifiedAt ?? null, + })) + + const [postCollect, noteCollect] = await Promise.all([ + this.translationService.collectArticleTranslations({ + articles: postSnapshots, targetLang: lang, - translationFields: ['title', 'translationMeta'] as const, - getInput: (item: any) => ({ - id: item.id, - title: item.title ?? '', - createdAt: item.createdAt, - }), - applyResult: (item: any, translation) => { - if (!translation?.isTranslated) return item - return { - ...item, - title: translation.title, - isTranslated: true, - translationMeta: translation.translationMeta, - } - }, - }) + fields: ['title'], + }), + this.translationService.collectArticleTranslations({ + articles: noteSnapshots, + targetLang: lang, + fields: ['title'], + }), + ]) + + for (const item of posts) { + const r = postCollect.results.get(String(item.id)) + if (r) + applyArticleTranslationInPlace(item, r as any, { fields: ['title'] }) } - if (result.notes.length) { - result.notes = await this.translationService.translateList({ - items: result.notes as any[], - targetLang: lang, - translationFields: ['title', 'translationMeta'] as const, - getInput: (item: any) => ({ - id: item.title === '未公开的日记' ? '' : item.id, - title: item.title ?? '', - createdAt: item.createdAt, - }), - applyResult: (item: any, translation) => { - if (!translation?.isTranslated) return item - return { - ...item, - title: translation.title, - isTranslated: true, - translationMeta: translation.translationMeta, - } - }, - }) + for (const item of notes) { + if (item.title === 'Private note') continue + const r = noteCollect.results.get(String(item.id)) + if (r) + applyArticleTranslationInPlace(item, r as any, { fields: ['title'] }) } - return result + const combinedMeta = new Map([...postCollect.meta, ...noteCollect.meta]) + + if (combinedMeta.size === 0) return result + + return withMeta( + result, + new MetaObjectBuilder().translation(combinedMeta).build(), + ) } } diff --git a/apps/core/src/modules/activity/activity.schema.ts b/apps/core/src/modules/activity/activity.schema.ts index 88ead2f9be3..b9105d0f1a5 100644 --- a/apps/core/src/modules/activity/activity.schema.ts +++ b/apps/core/src/modules/activity/activity.schema.ts @@ -2,7 +2,7 @@ import { createZodDto } from 'nestjs-zod' import { z } from 'zod' import { zCoerceInt, zEntityId } from '~/common/zod' -import { PagerSchema } from '~/shared/dto/pager.dto' +import { BasicPagerSchema } from '~/shared/dto/pager.dto' import { Activity } from './activity.constant' import type { ActivityLikeSupportType } from './activity.interface' @@ -33,7 +33,7 @@ export class ActivityDeleteDto extends createZodDto(ActivityDeleteSchema) {} /** * Activity query schema */ -export const ActivityQuerySchema = PagerSchema.extend({ +export const ActivityQuerySchema = BasicPagerSchema.extend({ type: z.preprocess((val) => transformEnum(val), z.enum(Activity).optional()), }) @@ -107,7 +107,7 @@ export class UpdatePresenceDto extends createZodDto(UpdatePresenceSchema) {} * Get presence query schema */ export const GetPresenceQuerySchema = z.object({ - room_name: z.string().max(50), + roomName: z.string().max(50), }) export class GetPresenceQueryDto extends createZodDto(GetPresenceQuerySchema) {} diff --git a/apps/core/src/modules/activity/activity.service.ts b/apps/core/src/modules/activity/activity.service.ts index fc206f85200..838b47537f7 100644 --- a/apps/core/src/modules/activity/activity.service.ts +++ b/apps/core/src/modules/activity/activity.service.ts @@ -4,10 +4,9 @@ import { omit, pick, uniqBy } from 'es-toolkit/compat' import type { Socket } from 'socket.io' import { RequestContext } from '~/common/contexts/request.context' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { ArticleTypeEnum } from '~/constants/article.constant' import { BusinessEvents, EventScope } from '~/constants/business-event.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { POST_SERVICE_TOKEN } from '~/constants/injection.constant' import { DatabaseService } from '~/processors/database/database.service' import { GatewayService } from '~/processors/gateway/gateway.service' @@ -260,14 +259,14 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy { note: ArticleTypeEnum.Note, } - // TODO 改成 reader 维度 + // TODO switch to a reader-level dimension const res = await this.countingService.updateLikeCountWithIp( mapping[type], id, ip, ) if (!res) { - throw new BizException(ErrorCodeEnum.AlreadySupported) + throw createAppException(AppErrorCode.ALREADY_SUPPORTED) } const globalResult = await this.databaseService.findGlobalById(id) @@ -308,7 +307,7 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy { const roomName = data.roomName if (!isValidRoomName(roomName)) { - throw new BizException(ErrorCodeEnum.InvalidRoomName) + throw createAppException(AppErrorCode.INVALID_ROOM_NAME) } const roomSockets = await this.webGateway.getSocketsOfRoom(roomName) @@ -507,7 +506,7 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy { } /** - * 获取过去一年的文章发布 + * Fetch article publications from the past year. */ async getLastYearPublication() { const $gte = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) @@ -520,7 +519,7 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy { .filter((row) => row.createdAt >= $gte) .map((note) => { if (note.hasPassword || !note.isPublished) { - note.title = '未公开的日记' + note.title = 'Private note' } return omit(note, 'isPublished') }) diff --git a/apps/core/src/modules/aggregate/aggregate.controller.ts b/apps/core/src/modules/aggregate/aggregate.controller.ts index 0683d785c11..ce1e7d2c2b4 100644 --- a/apps/core/src/modules/aggregate/aggregate.controller.ts +++ b/apps/core/src/modules/aggregate/aggregate.controller.ts @@ -7,10 +7,16 @@ import { Auth } from '~/common/decorators/auth.decorator' import { HttpCache } from '~/common/decorators/cache.decorator' import { Lang } from '~/common/decorators/lang.decorator' import { HasAdminAccess } from '~/common/decorators/role.decorator' -import { TranslateFields } from '~/common/decorators/translate-fields.decorator' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' import { CacheKeys } from '~/constants/cache.constant' -import { TranslationService } from '~/processors/helper/helper.translation.service' +import { + applyArticleTranslationInPlace, + applyTranslationEntriesInPlace, + TranslationService, +} from '~/processors/helper/helper.translation.service' +import { TranslationEntryService } from '../ai/ai-translation/translation-entry.service' import { AnalyzeService } from '../analyze/analyze.service' import { ConfigsService } from '../configs/configs.service' import { NoteService } from '../note/note.service' @@ -33,6 +39,20 @@ type TitledItem = { modifiedAt: Date | null } & Record +const NOTE_DICT_RULES = [ + { path: 'mood', keyPath: 'note.mood' as const, mode: 'dict' as const }, + { path: 'weather', keyPath: 'note.weather' as const, mode: 'dict' as const }, +] + +const CATEGORY_NAME_RULES = [ + { + path: 'category.name', + keyPath: 'category.name' as const, + mode: 'entity' as const, + idField: 'id', + }, +] + @ApiController('aggregate') export class AggregateController { constructor( @@ -43,22 +63,14 @@ export class AggregateController { private readonly snippetService: SnippetService, private readonly ownerService: OwnerService, private readonly translationService: TranslationService, + private readonly translationEntryService: TranslationEntryService, ) {} private async getThemeConfig(theme?: string, lang?: string) { - if (!theme) { - return - } - + if (!theme) return const baseConfig = await this.getSnippetData('theme', theme) - if (!baseConfig) { - return - } - - if (!lang) { - return baseConfig - } - + if (!baseConfig) return + if (!lang) return baseConfig const langOverlay = await this.getSnippetData('theme', `${theme}.${lang}`) if ( !langOverlay || @@ -67,7 +79,6 @@ export class AggregateController { ) { return baseConfig } - return merge({}, baseConfig, langOverlay) } @@ -117,7 +128,7 @@ export class AggregateController { return { user, seo, - url: omit(url, ['adminUrl']), + url: url ? omit(url, ['adminUrl']) : url, commentOptions: commentOptions ? { disableComment: commentOptions.disableComment ?? false, @@ -152,17 +163,11 @@ export class AggregateController { socialIds: user.socialIds, }, seo, - url: { - webUrl: url.webUrl, - }, + url: { webUrl: url.webUrl }, } } @Get('/top') - @TranslateFields( - { path: 'notes[].mood', keyPath: 'note.mood' }, - { path: 'notes[].weather', keyPath: 'note.weather' }, - ) async top( @Query() query: TopQueryDto, @HasAdminAccess() isAuthenticated: boolean, @@ -174,112 +179,235 @@ export class AggregateController { isAuthenticated, ) - if (lang) { - if (result.posts?.length) { - result.posts = (await this.translateTitleWithMeta( - result.posts as TitledItem[], - lang, - )) as unknown as typeof result.posts - } - if (result.notes?.length) { - result.notes = (await this.translateTitleWithMeta( - result.notes as TitledItem[], - lang, - )) as unknown as typeof result.notes + if (!lang) return result + + const notes = (result.notes ?? []) as TitledItem[] + const posts = (result.posts ?? []) as TitledItem[] + const allItems = [...posts, ...notes] + + const [{ results: translationResults, meta: translationMeta }, entryMaps] = + await Promise.all([ + this.translationService.collectArticleTranslations({ + articles: allItems.map((item) => ({ + id: String(item.id), + title: item.title ?? '', + text: '', + createdAt: item.createdAt, + modifiedAt: item.modifiedAt, + })), + targetLang: lang, + fields: ['title'], + }), + this.translationEntryService.getTranslationsBatch(lang, { + dictLookups: [ + { + keyPath: 'note.mood', + sourceTexts: notes + .map((n) => n.mood) + .filter((v): v is string => v != null), + }, + { + keyPath: 'note.weather', + sourceTexts: notes + .map((n) => n.weather) + .filter((v): v is string => v != null), + }, + ], + }), + ]) + + for (const item of allItems) { + const r = translationResults.get(String(item.id)) + if (r) { + applyArticleTranslationInPlace(item as Record, r as any, { + fields: ['title'], + }) } } - return result + for (const note of notes) { + applyTranslationEntriesInPlace( + note as Record, + entryMaps, + NOTE_DICT_RULES, + ) + } + + if (translationMeta.size === 0) return result + + return withMeta( + result, + new MetaObjectBuilder().translation(translationMeta).build(), + ) } @Get('/latest') - @TranslateFields( - { path: 'notes[].mood', keyPath: 'note.mood' }, - { path: 'notes[].weather', keyPath: 'note.weather' }, - { path: 'data[].mood', keyPath: 'note.mood' }, - { path: 'data[].weather', keyPath: 'note.weather' }, - ) async getLatest(@Query() query: LatestQueryDto, @Lang() lang?: string) { const { limit = 5, types, combined = false } = query const result = await this.aggregateService.getLatest(limit, types, combined) if (!lang) return result - if (combined) { - return this.translateTitleWithMeta(result as TitledItem[], lang) + const isCombined = combined === true + const notes: TitledItem[] = isCombined + ? (result as TitledItem[]).filter((i) => i.type === 'note') + : (((result as Record).notes ?? []) as TitledItem[]) + const allItems: TitledItem[] = isCombined + ? (result as TitledItem[]) + : [ + ...(((result as Record).posts ?? []) as TitledItem[]), + ...notes, + ] + + const [{ results: translationResults, meta: translationMeta }, entryMaps] = + await Promise.all([ + this.translationService.collectArticleTranslations({ + articles: allItems.map((item) => ({ + id: String(item.id), + title: item.title ?? '', + text: '', + createdAt: item.createdAt, + modifiedAt: item.modifiedAt, + })), + targetLang: lang, + fields: ['title'], + }), + this.translationEntryService.getTranslationsBatch(lang, { + dictLookups: [ + { + keyPath: 'note.mood', + sourceTexts: notes + .map((n) => n.mood) + .filter((v): v is string => v != null), + }, + { + keyPath: 'note.weather', + sourceTexts: notes + .map((n) => n.weather) + .filter((v): v is string => v != null), + }, + ], + }), + ]) + + for (const item of allItems) { + const r = translationResults.get(String(item.id)) + if (r) { + applyArticleTranslationInPlace(item as Record, r as any, { + fields: ['title'], + }) + } } - const data = result as Record - if (data.posts?.length) { - data.posts = await this.translateTitleWithMeta(data.posts, lang) + for (const note of notes) { + applyTranslationEntriesInPlace( + note as Record, + entryMaps, + NOTE_DICT_RULES, + ) } - if (data.notes?.length) { - data.notes = await this.translateTitleWithMeta(data.notes, lang) - } - return data + + if (translationMeta.size === 0) return result + + return withMeta( + result, + new MetaObjectBuilder().translation(translationMeta).build(), + ) } @Get('/timeline') - @TranslateFields( - { path: 'data.notes[].mood', keyPath: 'note.mood' }, - { path: 'data.notes[].weather', keyPath: 'note.weather' }, - { - path: 'data.posts[].category.name', - keyPath: 'category.name', - idField: 'id', - }, - ) async getTimeline(@Query() query: TimelineQueryDto, @Lang() lang?: string) { const { sort = 1, type, year } = query const data = await this.aggregateService.getTimeline(year, type, sort) - if (lang && data.posts?.length) { - data.posts = (await this.translateTitleWithMeta( - data.posts as unknown as TitledItem[], - lang, - )) as unknown as typeof data.posts + if (!lang) return data + + const notes = (data.notes ?? []) as TitledItem[] + const posts = (data.posts ?? []) as TitledItem[] + const allItems = [...posts, ...notes] + + const categoryIds = posts + .map((p) => p.category?.id) + .filter((id): id is string => id != null) + .map(String) + + const [{ results: translationResults, meta: translationMeta }, entryMaps] = + await Promise.all([ + this.translationService.collectArticleTranslations({ + articles: allItems.map((item) => ({ + id: String(item.id), + title: item.title ?? '', + text: '', + createdAt: item.createdAt, + modifiedAt: item.modifiedAt, + })), + targetLang: lang, + fields: ['title'], + }), + this.translationEntryService.getTranslationsBatch(lang, { + dictLookups: [ + { + keyPath: 'note.mood', + sourceTexts: notes + .map((n) => n.mood) + .filter((v): v is string => v != null), + }, + { + keyPath: 'note.weather', + sourceTexts: notes + .map((n) => n.weather) + .filter((v): v is string => v != null), + }, + ], + entityLookups: categoryIds.length + ? [ + { + keyPath: 'category.name', + lookupKeys: [...new Set(categoryIds)], + }, + ] + : [], + }), + ]) + + for (const item of allItems) { + const r = translationResults.get(String(item.id)) + if (r) { + applyArticleTranslationInPlace(item as Record, r as any, { + fields: ['title'], + }) + } } - if (lang && data.notes?.length) { - data.notes = (await this.translateTitleWithMeta( - data.notes as unknown as TitledItem[], - lang, - )) as unknown as typeof data.notes + + for (const note of notes) { + applyTranslationEntriesInPlace( + note as Record, + entryMaps, + NOTE_DICT_RULES, + ) } - return { data } - } + for (const post of posts) { + applyTranslationEntriesInPlace( + post as Record, + entryMaps, + CATEGORY_NAME_RULES, + ) + } - private translateTitleWithMeta( - items: T[], - targetLang: string, - ) { - return this.translationService.translateList({ - items, - targetLang, - translationFields: ['title', 'translationMeta'] as const, - getInput: (item) => ({ - id: String(item.id), - title: item.title ?? '', - createdAt: item.createdAt, - modifiedAt: item.modifiedAt, - }), - applyResult: (item, translation) => { - if (!translation?.isTranslated) return item - return { - ...item, - title: translation.title, - isTranslated: true, - translationMeta: translation.translationMeta, - } - }, - }) + if (translationMeta.size === 0) return data + + return withMeta( + data, + new MetaObjectBuilder().translation(translationMeta).build(), + ) } @Get('/sitemap') @CacheKey(CacheKeys.SiteMap) @CacheTTL(3600) async getSiteMapContent() { - return { data: await this.aggregateService.getSiteMapContent() } + return await this.aggregateService.getSiteMapContent() } @Get('/feed') @@ -312,9 +440,7 @@ export class AggregateController { @Get('/count_site_words') async getSiteWords() { - return { - count: await this.aggregateService.getAllSiteWordsCount(), - } + return { count: await this.aggregateService.getAllSiteWordsCount() } } @Get('/site_info') @@ -345,23 +471,36 @@ export class AggregateController { async getTopArticles(@Lang() lang?: string) { const result = await this.aggregateService.getTopArticles() - if (lang && result.length) { - return this.translationService.translateList({ - items: result as Array>, - targetLang: lang, - translationFields: ['title'] as const, - getInput: (item) => ({ - id: item.id, + if (!lang || !result.length) return result + + const { results: translationResults, meta: translationMeta } = + await this.translationService.collectArticleTranslations({ + articles: result.map((item) => ({ + id: String(item.id), title: item.title ?? '', - }), - applyResult: (item, translation) => { - if (!translation?.isTranslated) return item - return { ...item, title: translation.title } - }, + text: '', + createdAt: new Date(), + modifiedAt: null, + })), + targetLang: lang, + fields: ['title'], }) + + for (const item of result) { + const r = translationResults.get(String(item.id)) + if (r) { + applyArticleTranslationInPlace(item as Record, r as any, { + fields: ['title'], + }) + } } - return result + if (translationMeta.size === 0) return result + + return withMeta( + result, + new MetaObjectBuilder().translation(translationMeta).build(), + ) } @Get('/stat/comment-activity') diff --git a/apps/core/src/modules/aggregate/aggregate.module.ts b/apps/core/src/modules/aggregate/aggregate.module.ts index 21ce8bce82b..8008506db8c 100644 --- a/apps/core/src/modules/aggregate/aggregate.module.ts +++ b/apps/core/src/modules/aggregate/aggregate.module.ts @@ -2,6 +2,7 @@ import { forwardRef, Module } from '@nestjs/common' import { GatewayModule } from '~/processors/gateway/gateway.module' +import { AiModule } from '../ai/ai.module' import { AnalyzeModule } from '../analyze/analyze.module' import { CategoryModule } from '../category/category.module' import { CommentModule } from '../comment/comment.module' @@ -27,6 +28,8 @@ import { AggregateService } from './aggregate.service' forwardRef(() => RecentlyModule), forwardRef(() => SnippetModule), + forwardRef(() => AiModule), + AnalyzeModule, GatewayModule, ], diff --git a/apps/core/src/modules/aggregate/aggregate.views.ts b/apps/core/src/modules/aggregate/aggregate.views.ts new file mode 100644 index 00000000000..2a5c3cf0edf --- /dev/null +++ b/apps/core/src/modules/aggregate/aggregate.views.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +const AggregateDetailSchema = z.object({}).passthrough() + +export const AggregateViews = { + detail: AggregateDetailSchema, +} as const + +export type AggregateView = keyof typeof AggregateViews diff --git a/apps/core/src/modules/ai/ai-agent/ai-agent-chat.service.ts b/apps/core/src/modules/ai/ai-agent/ai-agent-chat.service.ts index 896a0a501d1..8d1dde82ba7 100644 --- a/apps/core/src/modules/ai/ai-agent/ai-agent-chat.service.ts +++ b/apps/core/src/modules/ai/ai-agent/ai-agent-chat.service.ts @@ -1,7 +1,6 @@ import { Injectable, Logger } from '@nestjs/common' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { ConfigsService } from '~/modules/configs/configs.service' import type { AIProviderConfig } from '../ai.types' @@ -23,10 +22,9 @@ export class AiAgentChatService { (p) => p.id === providerId && p.enabled, ) if (!provider) { - throw new BizException( - ErrorCodeEnum.AINotEnabled, - `Provider "${providerId}" not found or disabled`, - ) + throw createAppException(AppErrorCode.AI_NOT_ENABLED, { + message: `Provider "${providerId}" not found or disabled`, + }) } return provider } diff --git a/apps/core/src/modules/ai/ai-agent/ai-agent-conversation.service.ts b/apps/core/src/modules/ai/ai-agent/ai-agent-conversation.service.ts index bc2b0bd215e..5afa354aaac 100644 --- a/apps/core/src/modules/ai/ai-agent/ai-agent-conversation.service.ts +++ b/apps/core/src/modules/ai/ai-agent/ai-agent-conversation.service.ts @@ -1,9 +1,8 @@ import { Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { BusinessEvents } from '~/constants/business-event.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { AiAgentChatService } from './ai-agent-chat.service' import { AiAgentConversationRepository } from './ai-agent-conversation.repository' @@ -37,10 +36,9 @@ export class AiAgentConversationService { async getById(id: string) { const doc = await this.conversationRepository.findById(id) if (!doc) { - throw new BizException( - ErrorCodeEnum.ContentNotFoundCantProcess, - 'Conversation not found', - ) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS, { + message: 'Conversation not found', + }) } return doc } @@ -53,10 +51,9 @@ export class AiAgentConversationService { }) : null if (!result) { - throw new BizException( - ErrorCodeEnum.ContentNotFoundCantProcess, - 'Conversation not found', - ) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS, { + message: 'Conversation not found', + }) } if ( @@ -78,10 +75,9 @@ export class AiAgentConversationService { async replaceMessages(id: string, messages: Record[]) { const result = await this.conversationRepository.update(id, { messages }) if (!result) { - throw new BizException( - ErrorCodeEnum.ContentNotFoundCantProcess, - 'Conversation not found', - ) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS, { + message: 'Conversation not found', + }) } if ( @@ -105,10 +101,9 @@ export class AiAgentConversationService { ) { const result = await this.conversationRepository.update(id, data) if (!result) { - throw new BizException( - ErrorCodeEnum.ContentNotFoundCantProcess, - 'Conversation not found', - ) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS, { + message: 'Conversation not found', + }) } return result } @@ -154,14 +149,19 @@ export class AiAgentConversationService { const titleMessages: Record[] = [ { role: 'system', - content: '用 10 字以内概括这段对话的主题,只返回标题文字', + content: + 'Summarize the topic of this conversation in 10 words or fewer. Reply with the title text only.', }, { role: 'user', content: String(firstUser.content ?? '').slice(0, 500) }, { role: 'assistant', content: String(firstAssistant.content ?? '').slice(0, 500), }, - { role: 'user', content: '请用 10 字以内概括以上对话主题' }, + { + role: 'user', + content: + 'Please summarize the topic of the conversation above in 10 words or fewer.', + }, ] try { diff --git a/apps/core/src/modules/ai/ai-agent/ai-agent.controller.ts b/apps/core/src/modules/ai/ai-agent/ai-agent.controller.ts index 8b57db895d6..2f4dc2a2aa8 100644 --- a/apps/core/src/modules/ai/ai-agent/ai-agent.controller.ts +++ b/apps/core/src/modules/ai/ai-agent/ai-agent.controller.ts @@ -14,6 +14,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' +import { HTTPDecorators } from '~/common/decorators/http.decorator' import { EntityIdDto } from '~/shared/dto/id.dto' import { applyRawCorsHeaders, @@ -40,10 +41,9 @@ export class AiAgentController { private readonly conversationService: AiAgentConversationService, ) {} - // --- Chat Proxy --- - @Post('/chat') @Auth() + @HTTPDecorators.RawResponse async chatProxy( @Body() body: ChatProxyDto, @Req() request: FastifyRequest, @@ -77,7 +77,6 @@ export class AiAgentController { return } - // Pipe raw SSE stream through to client applyRawCorsHeaders(reply, request) reply.raw.setHeader('Content-Type', 'text/event-stream') reply.raw.setHeader('Cache-Control', 'no-cache, no-transform') @@ -100,29 +99,27 @@ export class AiAgentController { } } - // --- Conversation CRUD --- - @Post('/conversations') @Auth() - async createConversation(@Body() body: CreateConversationDto) { + createConversation(@Body() body: CreateConversationDto) { return this.conversationService.create(body) } @Get('/conversations') @Auth() - async listConversations(@Query() query: ListConversationsQueryDto) { + listConversations(@Query() query: ListConversationsQueryDto) { return this.conversationService.listByRef(query.refId, query.refType) } @Get('/conversations/:id') @Auth() - async getConversation(@Param() params: EntityIdDto) { + getConversation(@Param() params: EntityIdDto) { return this.conversationService.getById(params.id) } @Patch('/conversations/:id') @Auth() - async updateConversation( + updateConversation( @Param() params: EntityIdDto, @Body() body: UpdateConversationDto, ) { @@ -131,7 +128,7 @@ export class AiAgentController { @Patch('/conversations/:id/messages') @Auth() - async appendMessages( + appendMessages( @Param() params: EntityIdDto, @Body() body: AppendMessagesDto, ) { @@ -140,7 +137,7 @@ export class AiAgentController { @Put('/conversations/:id/messages') @Auth() - async replaceMessages( + replaceMessages( @Param() params: EntityIdDto, @Body() body: ReplaceMessagesDto, ) { @@ -149,7 +146,7 @@ export class AiAgentController { @Delete('/conversations/:id') @Auth() - async deleteConversation(@Param() params: EntityIdDto) { + deleteConversation(@Param() params: EntityIdDto) { return this.conversationService.deleteById(params.id) } } diff --git a/apps/core/src/modules/ai/ai-inflight/ai-inflight.service.ts b/apps/core/src/modules/ai/ai-inflight/ai-inflight.service.ts index 37dd6e035c0..b451ec7c2cb 100644 --- a/apps/core/src/modules/ai/ai-inflight/ai-inflight.service.ts +++ b/apps/core/src/modules/ai/ai-inflight/ai-inflight.service.ts @@ -1,7 +1,6 @@ import { Injectable, Logger } from '@nestjs/common' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { isDev } from '~/global/env.global' import { RedisService } from '~/processors/redis/redis.service' import { sleep } from '~/utils/tool.util' @@ -232,17 +231,15 @@ export class AiInFlightService { const lockExists = await redis.exists(keys.lockKey) if (!lockExists) { - throw new BizException( - ErrorCodeEnum.AIException, - 'AI stream ended without result', - ) + throw createAppException(AppErrorCode.AI_SERVICE_ERROR, { + message: 'AI stream ended without result', + }) } if (Date.now() - startAt > options.idleTimeoutMs) { - throw new BizException( - ErrorCodeEnum.AIException, - 'AI stream idle timeout', - ) + throw createAppException(AppErrorCode.AI_SERVICE_ERROR, { + message: 'AI stream idle timeout', + }) } continue @@ -303,22 +300,22 @@ export class AiInFlightService { const errorMessage = await redis.get(keys.errorKey) if (errorMessage) { - throw new BizException(ErrorCodeEnum.AIException, errorMessage) + throw createAppException(AppErrorCode.AI_SERVICE_ERROR, { + message: errorMessage, + }) } const lockExists = await redis.exists(keys.lockKey) if (!lockExists) { - throw new BizException( - ErrorCodeEnum.AIException, - 'AI processing ended without result', - ) + throw createAppException(AppErrorCode.AI_SERVICE_ERROR, { + message: 'AI processing ended without result', + }) } if (Date.now() - startAt > options.idleTimeoutMs) { - throw new BizException( - ErrorCodeEnum.AIException, - 'AI processing idle timeout', - ) + throw createAppException(AppErrorCode.AI_SERVICE_ERROR, { + message: 'AI processing idle timeout', + }) } await sleep(100) diff --git a/apps/core/src/modules/ai/ai-insights/ai-insights-translation.service.ts b/apps/core/src/modules/ai/ai-insights/ai-insights-translation.service.ts index 657e6fc0411..cc8af350ba6 100644 --- a/apps/core/src/modules/ai/ai-insights/ai-insights-translation.service.ts +++ b/apps/core/src/modules/ai/ai-insights/ai-insights-translation.service.ts @@ -1,9 +1,8 @@ import { Injectable, Logger, type OnModuleInit } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { BusinessEvents } from '~/constants/business-event.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { type TaskExecuteContext, TaskQueueProcessor, @@ -103,7 +102,9 @@ export class AiInsightsTranslationService implements OnModuleInit { await this.aiInsightsRepository.findById(payload.sourceInsightsId), ) if (!source || source.isTranslation) { - throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS, { + message: 'Source insights not found or already translated', + }) } const key = md5( JSON.stringify({ @@ -152,10 +153,9 @@ export class AiInsightsTranslationService implements OnModuleInit { } const translatedText = stripTopLevelCodeFence(raw).trim() if (!translatedText) { - throw new BizException( - ErrorCodeEnum.AIException, - 'Insights translation returned empty content', - ) + throw createAppException(AppErrorCode.AI_SERVICE_ERROR, { + message: 'Insights translation returned empty content', + }) } const doc = this.toInsightsDoc( await this.aiInsightsRepository.upsert({ @@ -175,7 +175,12 @@ export class AiInsightsTranslationService implements OnModuleInit { await this.aiInsightsRepository.findById(resultId), ) if (!doc) - throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess) + throw createAppException( + AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS, + { + message: 'Translated insights not found', + }, + ) return doc }, }) diff --git a/apps/core/src/modules/ai/ai-insights/ai-insights.controller.ts b/apps/core/src/modules/ai/ai-insights/ai-insights.controller.ts index d1fd0c0b9b5..04b28f5f545 100644 --- a/apps/core/src/modules/ai/ai-insights/ai-insights.controller.ts +++ b/apps/core/src/modules/ai/ai-insights/ai-insights.controller.ts @@ -12,10 +12,12 @@ import type { FastifyReply } from 'fastify' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { HTTPDecorators } from '~/common/decorators/http.decorator' +import { AppErrorCode, createAppException } from '~/common/errors' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' import { EntityIdDto } from '~/shared/dto/id.dto' -import { PagerDto } from '~/shared/dto/pager.dto' +import { BasicPagerDto } from '~/shared/dto/pager.dto' import { endSse, initSse, sendSseEvent } from '~/utils/sse.util' import { DEFAULT_SUMMARY_LANG } from '../ai.constants' @@ -40,7 +42,7 @@ export class AiInsightsController { @Post('/task') @Auth() - async createInsightsTask(@Body() body: CreateInsightsTaskDto) { + createInsightsTask(@Body() body: CreateInsightsTaskDto) { return this.taskService.createInsightsTask(body) } @@ -53,14 +55,11 @@ export class AiInsightsController { if (!source) { return { taskId: null, created: false, reason: 'source-missing' } } - // Guard against same-language translation which would upsert onto the - // source row (unique index on refId+lang) and flip isTranslation to true. const sourceLang = source.sourceLang || source.lang if (body.targetLang === sourceLang) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'targetLang must differ from source lang', - ) + throw createAppException(AppErrorCode.AI_INVALID_PARAMETER, { + message: 'targetLang must differ from source lang', + }) } return this.taskService.createInsightsTranslationTask({ refId: body.refId, @@ -71,25 +70,36 @@ export class AiInsightsController { @Get('/ref/:id') @Auth() - async getInsightsByRefId(@Param() params: EntityIdDto) { + getInsightsByRefId(@Param() params: EntityIdDto) { return this.service.getInsightsByRefId(params.id) } @Get('/') @Auth() - async getInsights(@Query() query: PagerDto) { - return this.service.getAllInsights(query) + async getInsights(@Query() query: BasicPagerDto) { + const result = await this.service.getAllInsights(query) + return withMeta( + result.data, + new MetaObjectBuilder() + .pagination(result.pagination) + .articles(result.articles) + .build(), + ) } @Get('/grouped') @Auth() async getInsightsGrouped(@Query() query: GetInsightsGroupedQueryDto) { - return this.service.getAllInsightsGrouped(query) + const result = await this.service.getAllInsightsGrouped(query) + return withMeta( + result.data, + new MetaObjectBuilder().pagination(result.pagination).build(), + ) } @Patch('/:id') @Auth() - async updateInsights( + updateInsights( @Param() params: EntityIdDto, @Body() body: UpdateInsightsDto, ) { @@ -98,12 +108,12 @@ export class AiInsightsController { @Delete('/:id') @Auth() - async deleteInsights(@Param() params: EntityIdDto) { + deleteInsights(@Param() params: EntityIdDto) { return this.service.deleteInsightsInDb(params.id) } @Get('/article/:id') - async getArticleInsights( + getArticleInsights( @Param() params: EntityIdDto, @Query() query: GetInsightsQueryDto, ) { @@ -114,6 +124,7 @@ export class AiInsightsController { } @Get('/article/:id/generate') + @HTTPDecorators.RawResponse async generateArticleInsights( @Param() params: EntityIdDto, @Query() query: GetInsightsStreamQueryDto, diff --git a/apps/core/src/modules/ai/ai-insights/ai-insights.service.ts b/apps/core/src/modules/ai/ai-insights/ai-insights.service.ts index 1837efaf60a..bf33d286734 100644 --- a/apps/core/src/modules/ai/ai-insights/ai-insights.service.ts +++ b/apps/core/src/modules/ai/ai-insights/ai-insights.service.ts @@ -2,16 +2,16 @@ import { Injectable, Logger, type OnModuleInit } from '@nestjs/common' import { EventEmitter2, OnEvent } from '@nestjs/event-emitter' import removeMdCodeblock from 'remove-md-codeblock' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' +import { AppException } from '~/common/errors/exception.types' import { BusinessEvents } from '~/constants/business-event.constant' import { CollectionRefTypes } from '~/constants/db.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { DatabaseService } from '~/processors/database/database.service' import { type TaskExecuteContext, TaskQueueProcessor, } from '~/processors/task-queue' -import type { PagerDto } from '~/shared/dto/pager.dto' +import type { BasicPagerInput } from '~/shared/dto/pager.dto' import { createAbortError } from '~/utils/abort.util' import { md5 } from '~/utils/tool.util' @@ -121,13 +121,13 @@ export class AiInsightsService implements OnModuleInit { }> { const article = await this.databaseService.findGlobalById(articleId) if (!article || !article.document) { - throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS) } if ( article.type === CollectionRefTypes.Recently || article.type === CollectionRefTypes.Page ) { - throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS) } const doc = article.document as any return { @@ -262,7 +262,7 @@ export class AiInsightsService implements OnModuleInit { await this.aiInsightsRepository.findById(resultId), ) if (!doc) { - throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS) } return doc }, @@ -277,7 +277,7 @@ export class AiInsightsService implements OnModuleInit { ai: { enableInsights }, } = await this.configService.waitForConfigReady() if (!enableInsights) { - throw new BizException(ErrorCodeEnum.AINotEnabled) + throw createAppException(AppErrorCode.AI_NOT_ENABLED) } const { article } = await this.resolveArticleForInsights(articleId) const lang = this.resolveSourceLang(article) @@ -290,15 +290,14 @@ export class AiInsightsService implements OnModuleInit { ) return await result } catch (error) { - if (error instanceof BizException) throw error + if (error instanceof AppException) throw error this.logger.error( `AI insights generation failed for article ${articleId}: ${(error as Error).message}`, (error as Error).stack, ) - throw new BizException( - ErrorCodeEnum.AIException, - (error as Error).message, - ) + throw createAppException(AppErrorCode.AI_SERVICE_ERROR, { + message: (error as Error).message, + }) } } @@ -321,7 +320,7 @@ export class AiInsightsService implements OnModuleInit { }> { const aiConfig = await this.configService.get('ai') if (!aiConfig?.enableInsights) { - throw new BizException(ErrorCodeEnum.AINotEnabled) + throw createAppException(AppErrorCode.AI_NOT_ENABLED) } const { article } = await this.resolveArticleForInsights(articleId) const lang = options.lang || this.resolveSourceLang(article) @@ -344,7 +343,7 @@ export class AiInsightsService implements OnModuleInit { if (options.onlyDb) return null const aiConfig = await this.configService.get('ai') if (!aiConfig?.enableInsights) { - throw new BizException(ErrorCodeEnum.AINotEnabled) + throw createAppException(AppErrorCode.AI_NOT_ENABLED) } return this.generateInsights(articleId) } @@ -369,20 +368,22 @@ export class AiInsightsService implements OnModuleInit { async getInsightsById(id: string) { const doc = this.toInsightsDoc(await this.aiInsightsRepository.findById(id)) - if (!doc) throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess) + if (!doc) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS) return doc } async getInsightsByRefId(refId: string) { const article = await this.databaseService.findGlobalById(refId) - if (!article) throw new BizException(ErrorCodeEnum.ContentNotFound) + if (!article) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND, { id: refId }) const insights = this.toInsightsDocs( await this.aiInsightsRepository.listForRef(refId), ) return { insights, article } } - async getAllInsights(pager: PagerDto) { + async getAllInsights(pager: BasicPagerInput) { const { page, size } = pager const result = await this.aiInsightsRepository.list(page, size) const docs = this.toInsightsDocs(result.data) @@ -513,7 +514,8 @@ export class AiInsightsService implements OnModuleInit { async updateInsightsInDb(id: string, content: string) { const doc = this.toInsightsDoc(await this.aiInsightsRepository.findById(id)) - if (!doc) throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess) + if (!doc) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS) return this.toInsightsDoc( await this.aiInsightsRepository.updateContent(id, content), ) diff --git a/apps/core/src/modules/ai/ai-language.util.ts b/apps/core/src/modules/ai/ai-language.util.ts index 203a8dc7832..037b1456195 100644 --- a/apps/core/src/modules/ai/ai-language.util.ts +++ b/apps/core/src/modules/ai/ai-language.util.ts @@ -1,8 +1,8 @@ import { DEFAULT_SUMMARY_LANG, LANGUAGE_CODE_TO_NAME } from './ai.constants' /** - * 从 Accept-Language header 或语言代码中提取主语言代码 - * 例如: "en-US,en;q=0.9" -> "en", "zh-CN" -> "zh" + * Extract the primary language code from an Accept-Language header or a language code. + * Examples: "en-US,en;q=0.9" -> "en", "zh-CN" -> "zh" */ export function parseLanguageCode(lang?: string): string { if (!lang) return DEFAULT_SUMMARY_LANG @@ -10,8 +10,8 @@ export function parseLanguageCode(lang?: string): string { } /** - * 获取语言的完整名称 - * 例如: "en" -> "English", "zh" -> "Chinese" + * Get the full name for a language code. + * Examples: "en" -> "English", "zh" -> "Chinese" */ export function getLanguageName(langCode: string): string { return LANGUAGE_CODE_TO_NAME[langCode] || langCode diff --git a/apps/core/src/modules/ai/ai-summary/ai-summary.controller.ts b/apps/core/src/modules/ai/ai-summary/ai-summary.controller.ts index 63873b2ab33..83063722972 100644 --- a/apps/core/src/modules/ai/ai-summary/ai-summary.controller.ts +++ b/apps/core/src/modules/ai/ai-summary/ai-summary.controller.ts @@ -12,10 +12,13 @@ import type { FastifyReply } from 'fastify' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' +import { HTTPDecorators } from '~/common/decorators/http.decorator' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' import { CreateSummaryTaskDto } from '~/modules/ai/ai-task/ai-task.dto' import { AiTaskService } from '~/modules/ai/ai-task/ai-task.service' import { EntityIdDto } from '~/shared/dto/id.dto' -import { PagerDto } from '~/shared/dto/pager.dto' +import { BasicPagerDto } from '~/shared/dto/pager.dto' import { endSse, initSse, sendSseEvent } from '~/utils/sse.util' import { DEFAULT_SUMMARY_LANG } from '../ai.constants' @@ -37,45 +40,53 @@ export class AiSummaryController { @Post('/task') @Auth() - async createSummaryTask(@Body() body: CreateSummaryTaskDto) { + createSummaryTask(@Body() body: CreateSummaryTaskDto) { return this.taskService.createSummaryTask(body) } @Get('/ref/:id') @Auth() - async getSummaryByRefId(@Param() params: EntityIdDto) { + getSummaryByRefId(@Param() params: EntityIdDto) { return this.service.getSummariesByRefId(params.id) } @Get('/') @Auth() - async getSummaries(@Query() query: PagerDto) { - return this.service.getAllSummaries(query) + async getSummaries(@Query() query: BasicPagerDto) { + const result = await this.service.getAllSummaries(query) + return withMeta( + result.data, + new MetaObjectBuilder() + .pagination(result.pagination) + .articles(result.articles) + .build(), + ) } @Get('/grouped') @Auth() async getSummariesGrouped(@Query() query: GetSummariesGroupedQueryDto) { - return this.service.getAllSummariesGrouped(query) + const result = await this.service.getAllSummariesGrouped(query) + return withMeta( + result.data, + new MetaObjectBuilder().pagination(result.pagination).build(), + ) } @Patch('/:id') @Auth() - async updateSummary( - @Param() params: EntityIdDto, - @Body() body: UpdateSummaryDto, - ) { + updateSummary(@Param() params: EntityIdDto, @Body() body: UpdateSummaryDto) { return this.service.updateSummaryInDb(params.id, body.summary) } @Delete('/:id') @Auth() - async deleteSummary(@Param() params: EntityIdDto) { + deleteSummary(@Param() params: EntityIdDto) { return this.service.deleteSummaryInDb(params.id) } @Get('/article/:id') - async getArticleSummary( + getArticleSummary( @Param() params: EntityIdDto, @Query() query: GetSummaryQueryDto, ) { @@ -86,6 +97,7 @@ export class AiSummaryController { } @Get('/article/:id/generate') + @HTTPDecorators.RawResponse async generateArticleSummary( @Param() params: EntityIdDto, @Query() query: GetSummaryStreamQueryDto, diff --git a/apps/core/src/modules/ai/ai-summary/ai-summary.service.ts b/apps/core/src/modules/ai/ai-summary/ai-summary.service.ts index 14aaa141265..12612937bc3 100644 --- a/apps/core/src/modules/ai/ai-summary/ai-summary.service.ts +++ b/apps/core/src/modules/ai/ai-summary/ai-summary.service.ts @@ -2,17 +2,17 @@ import { Injectable, Logger, type OnModuleInit } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import removeMdCodeblock from 'remove-md-codeblock' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' +import { AppException } from '~/common/errors/exception.types' import { BusinessEvents } from '~/constants/business-event.constant' import { CollectionRefTypes } from '~/constants/db.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { DatabaseService } from '~/processors/database/database.service' import { type TaskExecuteContext, TaskQueueProcessor, TaskStatus, } from '~/processors/task-queue' -import type { PagerDto } from '~/shared/dto/pager.dto' +import type { BasicPagerInput } from '~/shared/dto/pager.dto' import { createAbortError } from '~/utils/abort.util' import { md5 } from '~/utils/tool.util' @@ -165,7 +165,7 @@ export class AiSummaryService implements OnModuleInit { } /** - * 计算内容 hash,用于检测内容是否变更 + * Compute a content hash used to detect whether the content has changed. */ private computeContentHash(text: string): string { return md5(this.serializeText(text)) @@ -184,7 +184,7 @@ export class AiSummaryService implements OnModuleInit { } /** - * 获取并验证文章,用于摘要相关操作 + * Fetch and validate an article for summary-related operations. */ private async resolveArticleForSummary(articleId: string): Promise<{ document: { text: string; title: string } @@ -192,14 +192,14 @@ export class AiSummaryService implements OnModuleInit { }> { const article = await this.databaseService.findGlobalById(articleId) if (!article || !article.document) { - throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS) } if ( article.type === CollectionRefTypes.Recently || article.type === CollectionRefTypes.Page ) { - throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS) } return { @@ -209,7 +209,7 @@ export class AiSummaryService implements OnModuleInit { } /** - * 检查数据库中是否存在 hash 匹配的有效摘要 + * Check whether a valid summary with a matching hash already exists in the database. */ private async findValidSummary( articleId: string, @@ -224,7 +224,7 @@ export class AiSummaryService implements OnModuleInit { } /** - * 将已有摘要包装为立即返回的 stream 格式 + * Wrap an existing summary in the stream format so it can be returned immediately. */ private wrapAsImmediateStream(summary: AISummaryModel): { events: AsyncIterable @@ -338,7 +338,7 @@ export class AiSummaryService implements OnModuleInit { await this.aiSummaryRepository.findById(resultId), ) if (!doc) { - throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS) } return doc }, @@ -355,7 +355,7 @@ export class AiSummaryService implements OnModuleInit { } = await this.configService.waitForConfigReady() if (!enableSummary) { - throw new BizException(ErrorCodeEnum.AINotEnabled) + throw createAppException(AppErrorCode.AI_NOT_ENABLED) } const { document } = await this.resolveArticleForSummary(articleId) @@ -369,14 +369,16 @@ export class AiSummaryService implements OnModuleInit { ) return await result } catch (error) { - if (error instanceof BizException) { + if (error instanceof AppException) { throw error } this.logger.error( - `OpenAI 在处理文章 ${articleId} 时出错:${error.message}`, + `OpenAI failed while processing article ${articleId}: ${error.message}`, error.stack, ) - throw new BizException(ErrorCodeEnum.AIException, error.message) + throw createAppException(AppErrorCode.AI_SERVICE_ERROR, { + message: error.message, + }) } } @@ -401,7 +403,7 @@ export class AiSummaryService implements OnModuleInit { const article = await this.databaseService.findGlobalById(refId) if (!article) { - throw new BizException(ErrorCodeEnum.ContentNotFound) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND, { id: refId }) } const summaries = this.toSummaryDocs( await this.aiSummaryRepository.listForRef(refId), @@ -413,7 +415,7 @@ export class AiSummaryService implements OnModuleInit { } } - async getAllSummaries(pager: PagerDto) { + async getAllSummaries(pager: BasicPagerInput) { const { page, size } = pager const summaries = await this.aiSummaryRepository.list(page, size) const docs = this.toSummaryDocs(summaries.data) @@ -565,7 +567,7 @@ export class AiSummaryService implements OnModuleInit { async updateSummaryInDb(id: string, summary: string) { const doc = this.toSummaryDoc(await this.aiSummaryRepository.findById(id)) if (!doc) { - throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS) } return this.toSummaryDoc( @@ -580,7 +582,7 @@ export class AiSummaryService implements OnModuleInit { async getSummaryById(id: string) { const doc = this.toSummaryDoc(await this.aiSummaryRepository.findById(id)) if (!doc) { - throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS) } return doc } @@ -595,7 +597,7 @@ export class AiSummaryService implements OnModuleInit { const aiConfig = await this.configService.get('ai') if (!aiConfig?.enableSummary) { - throw new BizException(ErrorCodeEnum.AINotEnabled) + throw createAppException(AppErrorCode.AI_NOT_ENABLED) } const { lang } = options @@ -637,7 +639,7 @@ export class AiSummaryService implements OnModuleInit { const aiConfig = await this.configService.get('ai') if (!aiConfig?.enableSummary) { - throw new BizException(ErrorCodeEnum.AINotEnabled) + throw createAppException(AppErrorCode.AI_NOT_ENABLED) } return this.generateSummaryByOpenAI(articleId, lang) diff --git a/apps/core/src/modules/ai/ai-task/ai-task.controller.ts b/apps/core/src/modules/ai/ai-task/ai-task.controller.ts index f58d0b675c7..3628fe67b34 100644 --- a/apps/core/src/modules/ai/ai-task/ai-task.controller.ts +++ b/apps/core/src/modules/ai/ai-task/ai-task.controller.ts @@ -26,7 +26,7 @@ export class AiTaskController extends BaseTaskController { @Get('/group/:id') @Auth() - async getTasksByGroupId(@Param() params: StringIdDto) { + getTasksByGroupId(@Param() params: StringIdDto) { return this.service.crud.getTasksByGroupId(params.id) } diff --git a/apps/core/src/modules/ai/ai-translation/ai-translation.controller.ts b/apps/core/src/modules/ai/ai-translation/ai-translation.controller.ts index 30ab9d9d489..6583e657fab 100644 --- a/apps/core/src/modules/ai/ai-translation/ai-translation.controller.ts +++ b/apps/core/src/modules/ai/ai-translation/ai-translation.controller.ts @@ -12,6 +12,9 @@ import type { FastifyReply } from 'fastify' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' +import { HTTPDecorators } from '~/common/decorators/http.decorator' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' import { CreateTranslationAllTaskDto, CreateTranslationBatchTaskDto, @@ -38,39 +41,41 @@ export class AiTranslationController { @Post('/task') @Auth() - async createTranslationTask(@Body() body: CreateTranslationTaskDto) { + createTranslationTask(@Body() body: CreateTranslationTaskDto) { return this.taskService.createTranslationTask(body) } @Post('/task/batch') @Auth() - async createTranslationBatchTask( - @Body() body: CreateTranslationBatchTaskDto, - ) { + createTranslationBatchTask(@Body() body: CreateTranslationBatchTaskDto) { return this.taskService.createTranslationBatchTask(body) } @Post('/task/all') @Auth() - async createTranslationAllTask(@Body() body: CreateTranslationAllTaskDto) { + createTranslationAllTask(@Body() body: CreateTranslationAllTaskDto) { return this.taskService.createTranslationAllTask(body) } @Get('/ref/:id') @Auth() - async getTranslationsByRefId(@Param() params: EntityIdDto) { + getTranslationsByRefId(@Param() params: EntityIdDto) { return this.service.getTranslationsByRefId(params.id) } @Get('/grouped') @Auth() async getTranslationsGrouped(@Query() query: GetTranslationsGroupedQueryDto) { - return this.service.getAllTranslationsGrouped(query) + const result = await this.service.getAllTranslationsGrouped(query) + return withMeta( + result.data, + new MetaObjectBuilder().pagination(result.pagination).build(), + ) } @Patch('/:id') @Auth() - async updateTranslation( + updateTranslation( @Param() params: EntityIdDto, @Body() body: UpdateTranslationDto, ) { @@ -79,12 +84,12 @@ export class AiTranslationController { @Delete('/:id') @Auth() - async deleteTranslation(@Param() params: EntityIdDto) { + deleteTranslation(@Param() params: EntityIdDto) { return this.service.deleteTranslation(params.id) } @Get('/article/:id') - async getArticleTranslation( + getArticleTranslation( @Param() params: EntityIdDto, @Query() query: GetTranslationQueryDto, ) { @@ -92,11 +97,12 @@ export class AiTranslationController { } @Get('/article/:id/languages') - async getAvailableLanguages(@Param() params: EntityIdDto) { + getAvailableLanguages(@Param() params: EntityIdDto) { return this.service.getAvailableLanguagesForArticle(params.id) } @Get('/article/:id/generate') + @HTTPDecorators.RawResponse async streamArticleTranslation( @Param() params: EntityIdDto, @Query() query: GetTranslationStreamQueryDto, diff --git a/apps/core/src/modules/ai/ai-translation/ai-translation.service.ts b/apps/core/src/modules/ai/ai-translation/ai-translation.service.ts index 0c85fbe3c38..b1e03213abf 100644 --- a/apps/core/src/modules/ai/ai-translation/ai-translation.service.ts +++ b/apps/core/src/modules/ai/ai-translation/ai-translation.service.ts @@ -1,9 +1,9 @@ import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' +import { AppException } from '~/common/errors/exception.types' import { BusinessEvents, EventScope } from '~/constants/business-event.constant' import { CollectionRefTypes } from '~/constants/db.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { DatabaseService } from '~/processors/database/database.service' import { EventManagerService } from '~/processors/helper/helper.event.service' import { LexicalService } from '~/processors/helper/helper.lexical.service' @@ -497,7 +497,7 @@ export class AiTranslationService } /** - * 获取并验证文章,用于翻译相关操作 + * Fetch and validate an article for translation-related operations. */ private async resolveArticleForTranslation(articleId: string): Promise<{ document: ArticleDocument @@ -508,11 +508,11 @@ export class AiTranslationService }> { const article = await this.databaseService.findGlobalById(articleId) if (!article || !article.document) { - throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS) } if (article.type === CollectionRefTypes.Recently) { - throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS) } return { @@ -522,7 +522,7 @@ export class AiTranslationService } /** - * 检查数据库中是否存在 hash 匹配的有效翻译 + * Check whether a valid translation with a matching hash already exists in the database. */ private async findValidTranslation( articleId: string, @@ -553,7 +553,7 @@ export class AiTranslationService } /** - * 将已有翻译包装为立即返回的 stream 格式 + * Wrap an existing translation in the stream format so it can be returned immediately. */ private wrapAsImmediateStream(translation: AITranslationModel): { events: AsyncIterable @@ -641,7 +641,7 @@ export class AiTranslationService const startedAt = Date.now() const aiConfig = await this.configService.get('ai') if (!aiConfig.enableTranslation) { - throw new BizException(ErrorCodeEnum.AINotEnabled) + throw createAppException(AppErrorCode.AI_NOT_ENABLED) } const { document, type } = @@ -668,14 +668,16 @@ export class AiTranslationService ) return translated } catch (error: any) { - if (error instanceof BizException || error.name === 'AbortError') { + if (error instanceof AppException || error.name === 'AbortError') { throw error } this.logger.error( `AI translation failed for article ${articleId}: ${error.message}`, error.stack, ) - throw new BizException(ErrorCodeEnum.AIException, error.message) + throw createAppException(AppErrorCode.AI_SERVICE_ERROR, { + message: error.message, + }) } } @@ -765,7 +767,7 @@ export class AiTranslationService parseResult: async (resultId) => { const doc = await this.aiTranslationRepository.findById(resultId) if (!doc) { - throw new BizException(ErrorCodeEnum.AITranslationNotFound) + throw createAppException(AppErrorCode.AI_TRANSLATION_NOT_FOUND) } return doc }, @@ -781,13 +783,13 @@ export class AiTranslationService }> { const aiConfig = await this.configService.get('ai') if (!aiConfig.enableTranslation) { - throw new BizException(ErrorCodeEnum.AINotEnabled) + throw createAppException(AppErrorCode.AI_NOT_ENABLED) } const { document, type } = await this.resolveArticleForTranslation(articleId) - // 检查数据库中是否已有有效翻译(hash 匹配) + // Check whether a valid translation (matching hash) already exists in the database. const existingTranslation = await this.findValidTranslation( articleId, targetLang, @@ -892,7 +894,7 @@ export class AiTranslationService this.aiTranslationRepository.listByRefId(refId), ]) if (!article) { - throw new BizException(ErrorCodeEnum.ContentNotFound) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND, { id: refId }) } return { translations, article } @@ -901,7 +903,7 @@ export class AiTranslationService async getTranslationById(id: string) { const doc = await this.aiTranslationRepository.findById(id) if (!doc) { - throw new BizException(ErrorCodeEnum.AITranslationNotFound) + throw createAppException(AppErrorCode.AI_TRANSLATION_NOT_FOUND) } return doc } @@ -977,7 +979,7 @@ export class AiTranslationService ) { const existing = await this.aiTranslationRepository.findById(id) if (!existing) { - throw new BizException(ErrorCodeEnum.AITranslationNotFound) + throw createAppException(AppErrorCode.AI_TRANSLATION_NOT_FOUND) } const patch: Parameters[1] = @@ -996,7 +998,7 @@ export class AiTranslationService const updated = await this.aiTranslationRepository.updateById(id, patch) if (!updated) { - throw new BizException(ErrorCodeEnum.AITranslationNotFound) + throw createAppException(AppErrorCode.AI_TRANSLATION_NOT_FOUND) } return updated } @@ -1007,7 +1009,7 @@ export class AiTranslationService const existing = await this.aiTranslationRepository.findById(id) const deletedCount = await this.aiTranslationRepository.deleteById(id) if (deletedCount === 0) { - throw new BizException(ErrorCodeEnum.AITranslationNotFound) + throw createAppException(AppErrorCode.AI_TRANSLATION_NOT_FOUND) } if (existing) { this.eventManager.emit( @@ -1043,14 +1045,16 @@ export class AiTranslationService ): Promise { const article = await this.databaseService.findGlobalById(articleId) if (!article || !article.document) { - throw new BizException(ErrorCodeEnum.ContentNotFound) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND, { + id: articleId, + }) } if (!options?.ignoreVisibility && !this.isArticleVisible(article)) { if (article.type === CollectionRefTypes.Post) { - throw new BizException(ErrorCodeEnum.PostHiddenOrEncrypted) + throw createAppException(AppErrorCode.POST_HIDDEN_OR_ENCRYPTED) } - throw new BizException(ErrorCodeEnum.NoteForbidden) + throw createAppException(AppErrorCode.NOTE_FORBIDDEN) } const document = article.document as ArticleDocument @@ -1196,14 +1200,16 @@ export class AiTranslationService }> { const article = await this.databaseService.findGlobalById(articleId) if (!article || !article.document) { - throw new BizException(ErrorCodeEnum.ContentNotFound) + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND, { + id: articleId, + }) } if (!options?.ignoreVisibility && !this.isArticleVisible(article)) { if (article.type === CollectionRefTypes.Post) { - throw new BizException(ErrorCodeEnum.PostHiddenOrEncrypted) + throw createAppException(AppErrorCode.POST_HIDDEN_OR_ENCRYPTED) } - throw new BizException(ErrorCodeEnum.NoteForbidden) + throw createAppException(AppErrorCode.NOTE_FORBIDDEN) } const document = article.document as ArticleDocument diff --git a/apps/core/src/modules/ai/ai-translation/translation-entry.controller.ts b/apps/core/src/modules/ai/ai-translation/translation-entry.controller.ts index 90a5ff3d930..aa44367430d 100644 --- a/apps/core/src/modules/ai/ai-translation/translation-entry.controller.ts +++ b/apps/core/src/modules/ai/ai-translation/translation-entry.controller.ts @@ -2,6 +2,8 @@ import { Body, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' import { EntityIdDto } from '~/shared/dto/id.dto' import { @@ -19,22 +21,23 @@ export class TranslationEntryController { @Post('/generate') @Auth() - async generateEntries(@Body() body?: GenerateEntriesDto) { + generateEntries(@Body() body?: GenerateEntriesDto) { return this.translationEntryService.generateTranslations(body ?? {}) } @Get('/') @Auth() async queryEntries(@Query() query: QueryEntriesDto) { - return this.translationEntryService.findEntries(query) + const result = await this.translationEntryService.findEntries(query) + return withMeta( + result.data, + new MetaObjectBuilder().pagination(result.pagination).build(), + ) } @Patch('/:id') @Auth() - async updateEntry( - @Param() params: EntityIdDto, - @Body() body: UpdateEntryDto, - ) { + updateEntry(@Param() params: EntityIdDto, @Body() body: UpdateEntryDto) { return this.translationEntryService.updateEntry( params.id, body.translatedText, @@ -43,7 +46,7 @@ export class TranslationEntryController { @Delete('/:id') @Auth() - async deleteEntry(@Param() params: EntityIdDto) { + deleteEntry(@Param() params: EntityIdDto) { return this.translationEntryService.deleteEntry(params.id) } } diff --git a/apps/core/src/modules/ai/ai-writer/ai-writer.controller.ts b/apps/core/src/modules/ai/ai-writer/ai-writer.controller.ts index 1268bd01dcb..06acb8ba264 100644 --- a/apps/core/src/modules/ai/ai-writer/ai-writer.controller.ts +++ b/apps/core/src/modules/ai/ai-writer/ai-writer.controller.ts @@ -2,8 +2,7 @@ import { Body, Get, Post } from '@nestjs/common' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { AiSlugBackfillService } from './ai-slug-backfill.service' import { AiQueryType, GenerateAiDto } from './ai-writer.schema' @@ -27,7 +26,7 @@ export class AiWriterController { return this.aiWriterService.generateSlugByTitleViaOpenAI(body.title!) } default: { - throw new BizException(ErrorCodeEnum.Default, 'Invalid query type') + throw createAppException(AppErrorCode.AI_INVALID_QUERY_TYPE) } } } diff --git a/apps/core/src/modules/ai/ai.controller.ts b/apps/core/src/modules/ai/ai.controller.ts index e1b6026dfad..1d130ed91ce 100644 --- a/apps/core/src/modules/ai/ai.controller.ts +++ b/apps/core/src/modules/ai/ai.controller.ts @@ -2,8 +2,8 @@ import { Body, Get, Param, Post } from '@nestjs/common' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' +import { OK_DATA } from '~/common/response/envelope.types' import { ConfigsService } from '../configs/configs.service' import { AI_PROMPTS } from './ai.prompts' @@ -94,14 +94,15 @@ export class AiController { const { type, apiKey, endpoint } = await this.resolveModelListConfig(body) if (!type) { - throw new BizException( - ErrorCodeEnum.AINotEnabled, - 'Provider type is required', - ) + throw createAppException(AppErrorCode.AI_NOT_ENABLED, { + message: 'Provider type is required', + }) } if (!apiKey) { - throw new BizException(ErrorCodeEnum.AINotEnabled, 'API key is required') + throw createAppException(AppErrorCode.AI_NOT_ENABLED, { + message: 'API key is required', + }) } try { @@ -125,18 +126,21 @@ export class AiController { const { type, apiKey, endpoint, model } = await this.resolveTestConfig(body) if (!type) { - throw new BizException( - ErrorCodeEnum.AINotEnabled, - 'Provider type is required', - ) + throw createAppException(AppErrorCode.AI_NOT_ENABLED, { + message: 'Provider type is required', + }) } if (!apiKey) { - throw new BizException(ErrorCodeEnum.AINotEnabled, 'API key is required') + throw createAppException(AppErrorCode.AI_NOT_ENABLED, { + message: 'API key is required', + }) } if (!model) { - throw new BizException(ErrorCodeEnum.AINotEnabled, 'Model is required') + throw createAppException(AppErrorCode.AI_NOT_ENABLED, { + message: 'Model is required', + }) } try { @@ -155,12 +159,11 @@ export class AiController { maxRetries: 0, }) - return { ok: true } + return OK_DATA } catch (error: any) { - throw new BizException( - ErrorCodeEnum.AIException, - error?.message || 'AI test failed', - ) + throw createAppException(AppErrorCode.AI_SERVICE_ERROR, { + message: error?.message || 'AI test failed', + }) } } @@ -172,18 +175,14 @@ export class AiController { const { text } = body if (!text?.trim()) { - throw new BizException( - ErrorCodeEnum.ContentNotFoundCantProcess, - 'Comment text is required', - ) + throw createAppException(AppErrorCode.AI_CONTENT_MISSING, { + message: 'Comment text is required', + }) } const commentConfig = await this.configsService.get('commentOptions') if (!commentConfig.aiReview) { - throw new BizException( - ErrorCodeEnum.AINotEnabled, - 'AI review is not enabled', - ) + throw createAppException(AppErrorCode.AI_REVIEW_NOT_ENABLED) } try { @@ -203,9 +202,9 @@ export class AiController { let reason: string | undefined if (hasSensitiveContent) { - reason = '包含敏感内容' + reason = 'Contains sensitive content' } else if (isSpam) { - reason = `评分 ${score} 超过阈值 ${threshold}` + reason = `Score ${score} exceeds threshold ${threshold}` } return { isSpam, score, reason } @@ -220,18 +219,17 @@ export class AiController { let reason: string | undefined if (hasSensitiveContent) { - reason = '包含敏感内容' + reason = 'Contains sensitive content' } else if (rawIsSpam) { - reason = '判定为垃圾评论' + reason = 'Flagged as spam comment' } return { isSpam, reason } } } catch (error: any) { - throw new BizException( - ErrorCodeEnum.AIException, - error?.message || 'AI comment review test failed', - ) + throw createAppException(AppErrorCode.AI_SERVICE_ERROR, { + message: error?.message || 'AI comment review test failed', + }) } } @@ -275,24 +273,21 @@ export class AiController { const provider = aiConfig.providers?.find((p) => p.id === providerId) if (!provider) { - throw new BizException( - ErrorCodeEnum.ContentNotFound, - `Provider ${providerId} not found`, - ) + throw createAppException(AppErrorCode.AI_PROVIDER_NOT_FOUND, { + providerId, + }) } if (!provider.enabled) { - throw new BizException( - ErrorCodeEnum.AINotEnabled, - `Provider ${providerId} is not enabled`, - ) + throw createAppException(AppErrorCode.AI_PROVIDER_DISABLED, { + providerId, + }) } if (!provider.apiKey) { - throw new BizException( - ErrorCodeEnum.AINotEnabled, - `Provider ${providerId} has no API key configured`, - ) + throw createAppException(AppErrorCode.AI_NOT_ENABLED, { + message: `Provider ${providerId} has no API key configured`, + }) } try { diff --git a/apps/core/src/modules/ai/ai.service.ts b/apps/core/src/modules/ai/ai.service.ts index 7d5eb3e0504..4aa586bca8a 100644 --- a/apps/core/src/modules/ai/ai.service.ts +++ b/apps/core/src/modules/ai/ai.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import type { AIConfig } from '../configs/configs.schema' import { ConfigsService } from '../configs/configs.service' @@ -69,10 +68,9 @@ export class AiService { const provider = this.resolveProvider(aiConfig, assignment?.providerId) if (!provider) { - throw new BizException( - ErrorCodeEnum.AINotEnabled, - 'No AI provider configured', - ) + throw createAppException(AppErrorCode.AI_NOT_ENABLED, { + message: 'No AI provider configured', + }) } return { diff --git a/apps/core/src/modules/analyze/analyze.controller.ts b/apps/core/src/modules/analyze/analyze.controller.ts index 05aeaebba85..f3e69e13fc2 100644 --- a/apps/core/src/modules/analyze/analyze.controller.ts +++ b/apps/core/src/modules/analyze/analyze.controller.ts @@ -5,7 +5,7 @@ import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { RedisKeys } from '~/constants/cache.constant' import { RedisService } from '~/processors/redis/redis.service' -import type { PagerDto } from '~/shared/dto/pager.dto' +import type { BasicPagerInput } from '~/shared/dto/pager.dto' import { getRedisKey } from '~/utils/redis.util' import { getTodayEarly, getWeekStart } from '~/utils/time.util' @@ -50,9 +50,8 @@ export class AnalyzeController { } @Get('/') - async getAnalyze(@Query() query: AnalyzeDto & Partial) { + getAnalyze(@Query() query: AnalyzeDto & Partial) { const { from, to = new Date(), page = 1, size = 50 } = query - return this.service.getRangeAnalyzeData(from, to, { limit: Math.trunc(size), page, @@ -60,29 +59,29 @@ export class AnalyzeController { } @Get('/today') - async getAnalyzeToday(@Query() query: Partial) { + getAnalyzeToday(@Query() query: Partial) { const { page = 1, size = 50 } = query const today = new Date() const todayEarly = getTodayEarly(today) - return await this.service.getRangeAnalyzeData(todayEarly, today, { + return this.service.getRangeAnalyzeData(todayEarly, today, { limit: Math.trunc(size), page, }) } @Get('/week') - async getAnalyzeWeek(@Query() query: Partial) { + getAnalyzeWeek(@Query() query: Partial) { const { page = 1, size = 50 } = query const today = new Date() const weekStart = getWeekStart(today) - return await this.service.getRangeAnalyzeData(weekStart, today, { + return this.service.getRangeAnalyzeData(weekStart, today, { limit: size, page, }) } @Get('/aggregate') - async getFragment() { + getFragment() { const cacheKey = getRedisKey(RedisKeys.AnalyzeAggregate) return this.getOrSetCache(cacheKey, 60, async () => { const getIpAndPvAggregate = async () => { @@ -100,7 +99,7 @@ export class AnalyzeController { const dayData = Array.from({ length: 24 }, (_, i) => { const hour = i.toString().padStart(2, '0') const bucket = day[hour] - const label = `${i}时` + const label = `${i}:00` return [ { hour: label, key: 'ip', value: bucket?.ip || 0 }, { hour: label, key: 'pv', value: bucket?.pv || 0 }, @@ -114,11 +113,11 @@ export class AnalyzeController { granularity: 'date', })) as any[] - const weekDayLabels = ['日', '一', '二', '三', '四', '五', '六'] + const weekDayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] const weekData = all .slice(0, 7) .map((item) => { - const day = `周${weekDayLabels[dayjs(item.date).get('day')]}` + const day = weekDayLabels[dayjs(item.date).get('day')] return [ { day, key: 'ip', value: item.ip }, { day, key: 'pv', value: item.pv }, @@ -163,7 +162,7 @@ export class AnalyzeController { const client = this.redisService.getClient() const keys = await client.keys(getRedisKey(RedisKeys.Like, '*')) - return Promise.all( + const data = await Promise.all( keys.map(async (key) => { const id = key.split('_').pop()! @@ -173,10 +172,11 @@ export class AnalyzeController { } }), ) + return data } @Get('/traffic-source') - async getTrafficSource(@Query() query: AnalyzeDto) { + getTrafficSource(@Query() query: AnalyzeDto) { const { from, to } = query const cacheKey = getRedisKey( RedisKeys.AnalyzeTrafficSource, @@ -188,7 +188,7 @@ export class AnalyzeController { } @Get('/device') - async getDeviceDistribution(@Query() query: AnalyzeDto) { + getDeviceDistribution(@Query() query: AnalyzeDto) { const { from, to } = query const cacheKey = getRedisKey( RedisKeys.AnalyzeDeviceDistribution, diff --git a/apps/core/src/modules/analyze/analyze.service.ts b/apps/core/src/modules/analyze/analyze.service.ts index 6194980d721..5156db56940 100644 --- a/apps/core/src/modules/analyze/analyze.service.ts +++ b/apps/core/src/modules/analyze/analyze.service.ts @@ -133,17 +133,17 @@ export class AnalyzeService { ) const deviceTypeMap: Record = { - desktop: '桌面端', - mobile: '移动端', - tablet: '平板', - unknown: '未知', + desktop: 'Desktop', + mobile: 'Mobile', + tablet: 'Tablet', + unknown: 'Unknown', } return { browsers: data.browsers, os: data.os, devices: data.devices.map((item) => ({ - name: deviceTypeMap[item.name?.toLowerCase()] || item.name || '桌面端', + name: deviceTypeMap[item.name?.toLowerCase()] || item.name || 'Desktop', value: item.value, })), } @@ -205,10 +205,10 @@ export class AnalyzeService { return { categories: [ - { name: '直接访问', value: categories.direct }, - { name: '搜索引擎', value: categories.search }, - { name: '社交媒体', value: categories.social }, - { name: '其他来源', value: categories.other }, + { name: 'Direct', value: categories.direct }, + { name: 'Search engine', value: categories.search }, + { name: 'Social media', value: categories.social }, + { name: 'Other', value: categories.other }, ].filter((c) => c.value > 0), details: [...detailsMap.entries()] .map(([source, count]) => ({ source, count })) diff --git a/apps/core/src/modules/auth/auth.controller.ts b/apps/core/src/modules/auth/auth.controller.ts index 71f3aa5f3c0..e9037296caf 100644 --- a/apps/core/src/modules/auth/auth.controller.ts +++ b/apps/core/src/modules/auth/auth.controller.ts @@ -16,8 +16,7 @@ import { z } from 'zod' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HttpCache } from '~/common/decorators/cache.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { EventBusEvents } from '~/constants/event-bus.constant' import { StringIdDto } from '~/shared/dto/id.dto' import type { FastifyBizRequest } from '~/transformers/get-req.transformer' @@ -76,7 +75,7 @@ export class AuthController { const token = models.find((model) => model.id === id)?.token if (!token) { - throw new BizException(ErrorCodeEnum.TokenNotFound) + throw createAppException(AppErrorCode.AUTH_TOKEN_NOT_FOUND) } await this.authService.deleteToken(id) diff --git a/apps/core/src/modules/auth/auth.service.ts b/apps/core/src/modules/auth/auth.service.ts index d071e874abf..fd9ed8f8dd3 100644 --- a/apps/core/src/modules/auth/auth.service.ts +++ b/apps/core/src/modules/auth/auth.service.ts @@ -10,8 +10,7 @@ import { hashPassword } from 'better-auth/crypto' import { customAlphabet } from 'nanoid' import { RequestContext } from '~/common/contexts/request.context' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { alphabet } from '~/constants/other.constant' import { getAvatar } from '~/utils/tool.util' @@ -135,7 +134,7 @@ export class AuthService { const ownerId = await this.getOwnerReaderId() if (!ownerId) { - throw new BizException(ErrorCodeEnum.AuthUserIdNotFound) + throw createAppException(AppErrorCode.AUTH_USER_ID_NOT_FOUND) } const expiresIn = @@ -144,10 +143,9 @@ export class AuthService { : undefined if (expiresIn !== undefined && expiresIn <= 0) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'expired must be in the future', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'expired must be in the future', + }) } const created = await auth.api.createApiKey({ @@ -168,7 +166,7 @@ export class AuthService { async saveToken(model: TokenDto & { token: string }) { const ownerId = await this.getOwnerReaderId() if (!ownerId) { - throw new BizException(ErrorCodeEnum.AuthUserIdNotFound) + throw createAppException(AppErrorCode.AUTH_USER_ID_NOT_FOUND) } const now = new Date() const start = model.token.slice(0, 6) @@ -196,25 +194,25 @@ export class AuthService { async createOwnerByCredential(input: CreateOwnerByCredentialInput) { const normalizedUsername = this.normalizeUsername(input.username) if (!normalizedUsername) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'username is required', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'username is required', + }) } if (typeof input.password !== 'string' || input.password.length === 0) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'password is required', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'password is required', + }) } const mail = this.normalizeOptional(input.mail) if (!mail) { - throw new BizException(ErrorCodeEnum.InvalidParameter, 'mail is required') + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'mail is required', + }) } const ownerCount = await this.readerRepository.countOwners() if (ownerCount > 0) { - throw new BizException(ErrorCodeEnum.UserAlreadyExists) + throw createAppException(AppErrorCode.USER_ALREADY_EXISTS) } const exists = await this.readerRepository.existsByUsernameOrEmail( @@ -222,7 +220,7 @@ export class AuthService { mail, ) if (exists) { - throw new BizException(ErrorCodeEnum.UserAlreadyExists) + throw createAppException(AppErrorCode.USER_ALREADY_EXISTS) } const rawUsername = @@ -274,7 +272,7 @@ export class AuthService { }) } catch (error) { if (this.isDuplicateKeyError(error)) { - throw new BizException(ErrorCodeEnum.UserAlreadyExists) + throw createAppException(AppErrorCode.USER_ALREADY_EXISTS) } throw error @@ -348,15 +346,15 @@ export class AuthService { async setCurrentOauthAsOwner() { const req = RequestContext.currentRequest() if (!req) { - throw new BizException(ErrorCodeEnum.AuthFailed) + throw createAppException(AppErrorCode.AUTH_FAILED) } const session = await this.getSessionUser(req) if (!session) { - throw new BizException(ErrorCodeEnum.AuthSessionNotFound) + throw createAppException(AppErrorCode.AUTH_SESSION_NOT_FOUND) } const userId = session.user?.id if (!userId) { - throw new BizException(ErrorCodeEnum.AuthUserIdNotFound) + throw createAppException(AppErrorCode.AUTH_USER_ID_NOT_FOUND) } return this.transferOwnerRole(userId) @@ -365,7 +363,7 @@ export class AuthService { async transferOwnerRole(targetUserId: string) { const target = await this.readerRepository.findById(targetUserId) if (!target?.id) { - throw new BizException(ErrorCodeEnum.AuthUserIdNotFound) + throw createAppException(AppErrorCode.AUTH_USER_ID_NOT_FOUND) } await this.readerRepository.setOwnersExceptToReader(target.id) @@ -373,10 +371,9 @@ export class AuthService { const ownerCount = await this.readerRepository.countOwners() if (ownerCount !== 1) { - throw new BizException( - ErrorCodeEnum.AuthFailed, - 'owner role consistency check failed', - ) + throw createAppException(AppErrorCode.AUTH_FAILED, { + message: 'owner role consistency check failed', + }) } return 'OK' } @@ -384,7 +381,7 @@ export class AuthService { async revokeOwnerRole(targetUserId: string) { const target = await this.readerRepository.findById(targetUserId) if (!target?.id) { - throw new BizException(ErrorCodeEnum.AuthUserIdNotFound) + throw createAppException(AppErrorCode.AUTH_USER_ID_NOT_FOUND) } if (target.role !== 'owner') { return 'OK' @@ -392,10 +389,9 @@ export class AuthService { const ownerCount = await this.readerRepository.countOwners() if (ownerCount <= 1) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'owner must be unique and cannot be empty', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'owner must be unique and cannot be empty', + }) } await this.readerRepository.setRole(target.id, 'reader') diff --git a/apps/core/src/modules/auth/device.controller.ts b/apps/core/src/modules/auth/device.controller.ts index 9a7a0c8fbdc..577b14a8b37 100644 --- a/apps/core/src/modules/auth/device.controller.ts +++ b/apps/core/src/modules/auth/device.controller.ts @@ -16,8 +16,7 @@ import { z } from 'zod' import { API_VERSION } from '~/app.config' import { ApiController } from '~/common/decorators/api-controller.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { isDev } from '~/global/env.global' import { ConfigsService } from '~/modules/configs/configs.service' import { AssetService } from '~/processors/helper/helper.asset.service' @@ -48,7 +47,7 @@ export class DeviceController { ) {} @Get('/') - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse async page( @Query('user_code') userCode: string | undefined, @RequestHeaders('cookie') cookie: string | undefined, @@ -68,7 +67,9 @@ export class DeviceController { if (userCode) { const auth = this.authInstance.get() if (!auth) { - throw new BizException(ErrorCodeEnum.AuthFailed, 'auth not initialised') + throw createAppException(AppErrorCode.AUTH_FAILED, { + message: 'auth not initialised', + }) } await auth.api.deviceVerify({ query: { user_code: userCode.trim() }, @@ -85,7 +86,7 @@ export class DeviceController { } @Post('verify') - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse async verify( @Body() body: DeviceVerifyDto, @RequestHeaders('cookie') cookie: string | undefined, @@ -94,12 +95,14 @@ export class DeviceController { if (cookie) headers.set('cookie', cookie) const session = await this.authService.getSessionUserFromHeaders(headers) if (!session?.user || session.user.role !== 'owner') { - throw new BizException(ErrorCodeEnum.AuthNotLoggedIn) + throw createAppException(AppErrorCode.AUTH_NOT_LOGGED_IN) } const auth = this.authInstance.get() if (!auth) { - throw new BizException(ErrorCodeEnum.AuthFailed, 'auth not initialised') + throw createAppException(AppErrorCode.AUTH_FAILED, { + message: 'auth not initialised', + }) } const userCode = body.user_code.trim() @@ -147,10 +150,9 @@ export class DeviceController { encoding: 'utf-8', })) as string if (typeof template !== 'string' || template.length === 0) { - throw new BizException( - ErrorCodeEnum.AuthFailed, - 'device template missing', - ) + throw createAppException(AppErrorCode.AUTH_FAILED, { + message: 'device template missing', + }) } return template } diff --git a/apps/core/src/modules/backup/backup.controller.ts b/apps/core/src/modules/backup/backup.controller.ts index 13262ac0fa6..3a4d17199ca 100644 --- a/apps/core/src/modules/backup/backup.controller.ts +++ b/apps/core/src/modules/backup/backup.controller.ts @@ -1,4 +1,5 @@ import { Readable } from 'node:stream' + import { Body, Delete, @@ -10,15 +11,16 @@ import { Query, Req, } from '@nestjs/common' +import type { FastifyRequest } from 'fastify' + import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { UploadService } from '~/processors/helper/helper.upload.service' import { isZipMinetype } from '~/utils/mine.util' import { getMediumDateTime } from '~/utils/time.util' -import type { FastifyRequest } from 'fastify' + import { BackupService } from './backup.service' @ApiController({ path: 'backups' }) @@ -30,19 +32,21 @@ export class BackupController { ) {} @Get('/new') + @HTTPDecorators.RawResponse @Header( 'Content-Disposition', `attachment; filename="backup-${getMediumDateTime(new Date())}.zip"`, ) @Header('Content-Type', 'application/zip') - @HTTPDecorators.Bypass async createNewBackup() { const res = await this.backupService.backup() if (typeof res == 'undefined') { - throw new BizException(ErrorCodeEnum.BackupNotEnabled) + throw createAppException(AppErrorCode.BACKUP_NOT_ENABLED) } if (typeof res.buffer === 'undefined') { - throw new BizException(ErrorCodeEnum.FileNotFound, 'backup zip missing') + throw createAppException(AppErrorCode.FILE_NOT_FOUND, { + extra: 'backup zip missing', + }) } const stream = new Readable() @@ -56,7 +60,7 @@ export class BackupController { return this.backupService.list() } - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse @Header('Content-Type', 'application/zip') @Get('/:dirname') async download(@Param('dirname') dirname: string) { @@ -71,7 +75,9 @@ export class BackupController { const { mimetype } = data if (!isZipMinetype(mimetype)) { - throw new BizException(ErrorCodeEnum.MineZip, `got: ${mimetype}`) + throw createAppException(AppErrorCode.MIME_ZIP_REQUIRED, { + got: `got: ${mimetype}`, + }) } await this.backupService.saveTempBackupByUpload(await data.toBuffer()) @@ -79,7 +85,9 @@ export class BackupController { @Patch(['/rollback/:dirname', '/:dirname']) async rollback(@Param('dirname') dirname: string) { if (!dirname) { - throw new BizException(ErrorCodeEnum.InvalidParameter) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'dirname is required', + }) } this.backupService.rollbackTo(dirname) @@ -92,7 +100,9 @@ export class BackupController { ) { const nextFiles = files || filesBody if (!nextFiles) { - throw new BizException(ErrorCodeEnum.InvalidParameter) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'files is required', + }) } const filesList = nextFiles.split(',') diff --git a/apps/core/src/modules/backup/backup.service.ts b/apps/core/src/modules/backup/backup.service.ts index 24236435d91..a8184565577 100644 --- a/apps/core/src/modules/backup/backup.service.ts +++ b/apps/core/src/modules/backup/backup.service.ts @@ -13,9 +13,8 @@ import { mkdirp } from 'mkdirp' import { POSTGRES } from '~/app.config' import { CronDescription } from '~/common/decorators/cron-description.decorator' import { CronOnce } from '~/common/decorators/cron-once.decorator' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { BusinessEvents, EventScope } from '~/constants/business-event.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { BACKUP_DIR, DATA_DIR } from '~/constants/path.constant' import { EventManagerService } from '~/processors/helper/helper.event.service' import { RedisService } from '~/processors/redis/redis.service' @@ -130,8 +129,8 @@ export class BackupService { } async backup() { - this.logger.log('--> 备份数据库中') - // 用时间格式命名文件夹 + this.logger.log('--> Backing up database') + // Use timestamp as directory name const dateDir = getMediumDateTime(new Date()) const backupDirPath = join(BACKUP_DIR, dateDir) @@ -163,7 +162,7 @@ export class BackupService { if (!existsSync(dumpFilePath)) { const error = new Error( - `pg_dump 已执行,但未生成文件:${dumpFilePath}(请检查 DB 名称、连接与权限)`, + `pg_dump ran but produced no file: ${dumpFilePath} (check DB name, connection, and permissions)`, ) as any error.step = 'pg_dump' error.cwd = backupDirPath @@ -172,14 +171,14 @@ export class BackupService { const dumpStat = statSync(dumpFilePath) if (dumpStat.size === 0) { const error = new Error( - `pg_dump 生成文件为空:${dumpFilePath}(zip exit code 12 常见原因)`, + `pg_dump produced an empty file: ${dumpFilePath} (common cause of zip exit code 12)`, ) as any error.step = 'pg_dump' error.cwd = backupDirPath throw error } - // 使用目录而非通配符,避免目录为空时触发 "zip error: Nothing to do" (exit code 12) + // Use a directory instead of a wildcard to avoid "zip error: Nothing to do" (exit code 12) when the directory is empty await runStep( 'zip-db', `zip -r backup-${dateDir} mx-space && rm -rf mx-space`, @@ -188,7 +187,7 @@ export class BackupService { }, ) - // 打包数据目录 + // Bundle the data directory const flags = excludeFolders.map((item) => `--exclude ${item}`).join(' ') await rm(join(DATA_DIR, 'backup_data'), { recursive: true, force: true }) @@ -206,7 +205,7 @@ export class BackupService { { cwd: DATA_DIR }, ) - this.logger.log('--> 备份成功') + this.logger.log('--> Backup completed successfully') } catch (error) { const step = (error as any)?.step ? `step=${(error as any).step}` : '' const cwd = (error as any)?.cwd ? `cwd=${(error as any).cwd}` : '' @@ -217,7 +216,7 @@ export class BackupService { ? `\n\nstdout:\n${(error as any).stdout}` : '' - // 额外诊断:命令是否存在、备份目录当前内容 + // Extra diagnostics: command availability and current backup directory contents const [hasZip, hasPgDump, hasPgRestore] = await Promise.all([ this.commandExists('zip'), this.commandExists('pg_dump'), @@ -226,7 +225,7 @@ export class BackupService { const backupDirContent = await this.safeListDir(backupDirPath) this.logger.error( - `--> 备份失败(${[step, cwd].filter(Boolean).join(', ')}),${error.message}` + + `--> Backup failed (${[step, cwd].filter(Boolean).join(', ')}): ${error.message}` + `${stderr}${stdout}\n\n` + `diagnostics:\n` + `- zip: ${hasZip ? 'found' : 'missing'}\n` + @@ -251,11 +250,13 @@ export class BackupService { checkBackupExist(dirname: string) { if (/[/\\]|\.\./.test(dirname)) { - throw new BizException(ErrorCodeEnum.InvalidParameter) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'invalid dirname', + }) } const filePath = join(BACKUP_DIR, dirname, `backup-${dirname}.zip`) if (!existsSync(filePath)) { - throw new BizException(ErrorCodeEnum.FileNotFound) + throw createAppException(AppErrorCode.FILE_NOT_FOUND) } return filePath } @@ -280,7 +281,7 @@ export class BackupService { await this.backup() const isExist = existsSync(restoreFilePath) if (!isExist) { - throw new InternalServerErrorException('备份文件不存在') + throw new InternalServerErrorException('Backup file does not exist') } const dirPath = path.dirname(restoreFilePath) @@ -291,27 +292,33 @@ export class BackupService { }), ) - // 解压 + // Unzip try { await $throw(`unzip ${restoreFilePath}`, { cwd: dirPath }) } catch (error: any) { if (error?.exitCode === 127) { - throw new InternalServerErrorException('服务端 unzip 命令未找到') + throw new InternalServerErrorException( + 'unzip command not found on server', + ) } this.logger.error( - `unzip 失败:${error?.message || error}\n\n${error?.stderr || ''}`, + `unzip failed: ${error?.message || error}\n\n${error?.stderr || ''}`, ) throw error } try { - // 验证 + // Verify if (!existsSync(join(dirPath, 'mx-space'))) { - throw new InternalServerErrorException('备份文件错误,目录不存在') + throw new InternalServerErrorException( + 'Invalid backup file: directory does not exist', + ) } const dumpFilePath = join(dirPath, 'mx-space', 'pg.dump') if (!existsSync(dumpFilePath)) { - throw new InternalServerErrorException('备份文件错误,数据库备份不存在') + throw new InternalServerErrorException( + 'Invalid backup file: database dump does not exist', + ) } await $throw( @@ -320,7 +327,7 @@ export class BackupService { ) } catch (error) { this.logger.error( - `restore 失败:${(error as any)?.message || error}\n\n${ + `restore failed: ${(error as any)?.message || error}\n\n${ (error as any)?.stderr || '' }`, ) @@ -328,7 +335,7 @@ export class BackupService { } finally { await rm(join(dirPath, 'mx-space'), { recursive: true, force: true }) } - // 还原 backup_data + // Restore backup_data const backupDataDir = join(dirPath, 'backup_data') @@ -353,9 +360,11 @@ export class BackupService { if (pkg.dependencies) { await Promise.all( Object.entries(pkg.dependencies).map(([name, version]) => { - this.logger.log(`--> 安装依赖 ${name}@${version}`) + this.logger.log(`--> Installing dependency ${name}@${version}`) return installPKG(`${name}@${version}`, DATA_DIR).catch((error) => { - this.logger.error(`--> 依赖安装失败:${error.message}`) + this.logger.error( + `--> Dependency installation failed: ${error.message}`, + ) }) }), ) @@ -385,11 +394,13 @@ export class BackupService { async deleteBackup(filename: string) { if (/[/\\]|\.\./.test(filename)) { - throw new BizException(ErrorCodeEnum.InvalidParameter) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'invalid filename', + }) } const filePath = join(BACKUP_DIR, filename) if (!existsSync(filePath)) { - throw new BizException(ErrorCodeEnum.FileNotFound) + throw createAppException(AppErrorCode.FILE_NOT_FOUND) } await rm(filePath, { recursive: true }) @@ -397,7 +408,7 @@ export class BackupService { } @CronOnce(CronExpression.EVERY_DAY_AT_1AM, { name: 'backupDB' }) - @CronDescription('备份 DB 并上传 COS') + @CronDescription('Back up the database and upload to object storage') async backupDB() { const { backupOptions: configs } = await this.configs.waitForConfigReady() if (!configs.enable) { @@ -425,15 +436,15 @@ export class BackupService { const pathParts = backup.path.split('/') const remoteFileKey = `${pathParts.at(-2)}.zip` - this.logger.log('--> 开始上传到 S3') + this.logger.log('--> Starting upload to S3') await s3 .uploadFile(backup.buffer, remoteFileKey, 'backup') .catch((error) => { - this.logger.error('--> 上传失败了') + this.logger.error('--> Upload failed') throw error }) - this.logger.log('--> 上传成功') + this.logger.log('--> Upload succeeded') }) } } diff --git a/apps/core/src/modules/category/category.controller.ts b/apps/core/src/modules/category/category.controller.ts index 6702c52b94e..6efdb463c96 100644 --- a/apps/core/src/modules/category/category.controller.ts +++ b/apps/core/src/modules/category/category.controller.ts @@ -15,12 +15,17 @@ import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' import { Lang } from '~/common/decorators/lang.decorator' -import { TranslateFields } from '~/common/decorators/translate-fields.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { CannotFindException } from '~/common/exceptions/cant-find.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' import { POST_SERVICE_TOKEN } from '~/constants/injection.constant' -import { TranslationService } from '~/processors/helper/helper.translation.service' +import { TranslationEntryService } from '~/modules/ai/ai-translation/translation-entry.service' +import { + applyArticleTranslationInPlace, + applyTranslationEntriesInPlace, + type EntryRule, + TranslationService, +} from '~/processors/helper/helper.translation.service' import { EntityIdDto } from '~/shared/dto/id.dto' import type { PostService } from '../post/post.service' @@ -34,6 +39,10 @@ import { } from './category.schema' import { CategoryService } from './category.service' +const CATEGORY_NAME_RULES: ReadonlyArray = [ + { path: 'name', keyPath: 'category.name', mode: 'entity', idField: 'id' }, +] + @ApiController({ path: 'categories' }) export class CategoryController { constructor( @@ -41,14 +50,10 @@ export class CategoryController { @Inject(POST_SERVICE_TOKEN) private readonly postService: PostService, private readonly translationService: TranslationService, + private readonly translationEntryService: TranslationEntryService, ) {} @Get('/') - @TranslateFields({ - path: '[].name', - keyPath: 'category.name', - idField: 'id', - }) async getCategories( @Query() query: MultiCategoriesQueryDto, @Lang() lang?: string, @@ -63,58 +68,168 @@ export class CategoryController { 'commentsIndex', ] const map: Record = {} + const idsMetaMap = new Map() + + const allPosts: { categoryId: string; post: any }[] = [] + + const categoryObjects: Array<{ id: string; cat: any }> = [] await Promise.all( ids.map(async (id) => { const rawPosts = await this.postService.listByCategory(id, { includeCategory: false, }) - let posts: any[] = rawPosts.map((post) => { + const posts: any[] = rawPosts.map((post) => { const cloned = { ...post } for (const field of omitKeys) delete cloned[field] return cloned }) + for (const post of posts) allPosts.push({ categoryId: id, post }) - if (lang && posts.length) { - posts = await this.translatePostTitles(posts, lang) - } - - if (joint) { - map[id] = posts - } else { + if (!joint) { const category = await this.categoryService.findCategoryById(id) - map[id] = { ...category, children: posts } + categoryObjects.push({ id, cat: category }) } }), ) - return { entries: map } + if (lang) { + const articles = allPosts.map(({ post }) => ({ + id: String(post.id), + title: post.title ?? '', + text: '', + createdAt: post.createdAt, + modifiedAt: post.modifiedAt ?? null, + })) + + const [{ results, meta: titleMeta }, entryMaps] = await Promise.all([ + articles.length + ? this.translationService.collectArticleTranslations({ + articles, + targetLang: lang, + fields: ['title'], + }) + : Promise.resolve({ + results: new Map(), + meta: new Map(), + }), + !joint && categoryObjects.length + ? this.translationEntryService.getTranslationsBatch(lang, { + entityLookups: [ + { + keyPath: 'category.name', + lookupKeys: new Set(categoryObjects.map((c) => c.id)), + }, + ], + }) + : Promise.resolve({ + entityMaps: new Map(), + dictMaps: new Map(), + }), + ]) + + for (const { post } of allPosts) { + const tr = results.get(String(post.id)) + if (tr?.isTranslated) { + applyArticleTranslationInPlace(post, tr as any, { + fields: ['title'], + }) + } + } + + for (const [id, entry] of titleMeta) { + idsMetaMap.set(id, entry) + } + + for (const { cat } of categoryObjects) { + applyTranslationEntriesInPlace(cat, entryMaps, CATEGORY_NAME_RULES) + } + } + + for (const id of ids) { + const postsForId = allPosts + .filter((p) => p.categoryId === id) + .map((p) => p.post) + if (joint) { + map[id] = postsForId + } else { + const catObj = categoryObjects.find((c) => c.id === id)?.cat + map[id] = { ...catObj, children: postsForId } + } + } + + const idsMetaBuilder = new MetaObjectBuilder() + if (idsMetaMap.size > 0) { + idsMetaBuilder.translation(idsMetaMap) + } + + return withMeta({ entries: map }, idsMetaBuilder.build()) } - return type === CategoryType.Category - ? await this.categoryService.findAllCategory() - : await this.categoryService.getPostTagsSum() + + const result = + type === CategoryType.Category + ? await this.categoryService.findAllCategory() + : await this.categoryService.getPostTagsSum() + + if (lang && Array.isArray(result) && result.length) { + const entryMaps = await this.translationEntryService.getTranslationsBatch( + lang, + { + entityLookups: [ + { + keyPath: 'category.name', + lookupKeys: new Set(result.map((cat: any) => String(cat.id))), + }, + ], + }, + ) + for (const cat of result as any[]) { + applyTranslationEntriesInPlace(cat, entryMaps, CATEGORY_NAME_RULES) + } + } + + return withMeta(result, new MetaObjectBuilder().view('card').build()) } @Get('/:query') - @TranslateFields({ - path: 'data.name', - keyPath: 'category.name', - idField: 'id', - }) async getCategoryById( @Param() { query }: SlugOrIdDto, @Query() { tag }: MultiQueryTagAndCategoryDto, @Lang() lang?: string, ) { if (!query) { - throw new BizException(ErrorCodeEnum.InvalidParameter) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'Query is required', + }) } if (tag === true) { - let data = await this.categoryService.findArticleWithTag(query) + const data = await this.categoryService.findArticleWithTag(query) + const tagMetaBuilder = new MetaObjectBuilder() if (lang && data?.length) { - data = await this.translatePostTitles(data, lang) + const articles = data.map((post: any) => ({ + id: String(post.id), + title: post.title ?? '', + text: '', + createdAt: post.createdAt, + modifiedAt: post.modifiedAt ?? null, + })) + const { results, meta: titleMeta } = + await this.translationService.collectArticleTranslations({ + articles, + targetLang: lang, + fields: ['title'], + }) + for (const post of data as any[]) { + const tr = results.get(String(post.id)) + if (tr?.isTranslated) { + applyArticleTranslationInPlace(post, tr as any, { + fields: ['title'], + }) + } + } + if (titleMeta.size > 0) tagMetaBuilder.translation(titleMeta) } - return { tag: query, data } + return withMeta({ tag: query, data }, tagMetaBuilder.build()) } const isIdLike = /^\d+$/.test(query) || /^[\da-f]{24}$/i.test(query) @@ -123,7 +238,7 @@ export class CategoryController { : await this.categoryService.findBySlug(query) if (!res) { - throw new CannotFindException() + throw createAppException(AppErrorCode.CATEGORY_NOT_FOUND, { id: query }) } const [postsResult, tagsSum, count] = await Promise.all([ @@ -134,41 +249,59 @@ export class CategoryController { this.postService.countByCategoryId(res.id), ]) - let children: any[] = postsResult ?? [] - if (lang && children.length) { - children = await this.translatePostTitles(children, lang) - } + const children: any[] = postsResult ?? [] - return { data: { ...res, count, children, tagsSum } } - } + const metaBuilder = new MetaObjectBuilder().view('detail') - private translatePostTitles(posts: any[], lang: string) { - return this.translationService.translateList({ - items: posts, - targetLang: lang, - translationFields: ['title', 'translationMeta'] as const, - getInput: (item: any) => ({ - id: item.id, - title: item.title ?? '', - createdAt: item.createdAt, - modifiedAt: item.modifiedAt, - }), - applyResult: (item: any, translation) => { - if (!translation?.isTranslated) return item - return { - ...item, - title: translation.title, - isTranslated: true, - translationMeta: translation.translationMeta, + if (lang) { + const articles = children.map((post: any) => ({ + id: String(post.id), + title: post.title ?? '', + text: '', + createdAt: post.createdAt, + modifiedAt: post.modifiedAt ?? null, + })) + + const [entryMaps, { results, meta: titleMeta }] = await Promise.all([ + this.translationEntryService.getTranslationsBatch(lang, { + entityLookups: [ + { + keyPath: 'category.name', + lookupKeys: new Set([String(res.id)]), + }, + ], + }), + articles.length + ? this.translationService.collectArticleTranslations({ + articles, + targetLang: lang, + fields: ['title'], + }) + : Promise.resolve({ + results: new Map(), + meta: new Map(), + }), + ]) + + applyTranslationEntriesInPlace(res as any, entryMaps, CATEGORY_NAME_RULES) + + for (const post of children) { + const tr = results.get(String(post.id)) + if (tr?.isTranslated) { + applyArticleTranslationInPlace(post, tr as any, { fields: ['title'] }) } - }, - }) + } + + if (titleMeta.size > 0) metaBuilder.translation(titleMeta) + } + + return withMeta({ ...res, count, children, tagsSum }, metaBuilder.build()) } @Post('/') @Auth() @HTTPDecorators.Idempotence() - async create(@Body() body: CategoryDto) { + create(@Body() body: CategoryDto) { const { name, slug } = body return this.categoryService.create(name, slug!) } @@ -178,12 +311,8 @@ export class CategoryController { async modify(@Param() params: EntityIdDto, @Body() body: CategoryDto) { const { type, slug, name } = body const { id } = params - await this.categoryService.update(id, { - slug, - type, - name, - }) - return await this.categoryService.findById(id) + await this.categoryService.update(id, { slug, type, name }) + return this.categoryService.findById(id) } @Patch('/:id') @@ -192,14 +321,11 @@ export class CategoryController { async patch(@Param() params: EntityIdDto, @Body() body: PartialCategoryDto) { const { id } = params await this.categoryService.update(id, body) - return } @Delete('/:id') @Auth() - async deleteCategory(@Param() params: EntityIdDto) { - const { id } = params - - return await this.categoryService.deleteById(id) + deleteCategory(@Param() params: EntityIdDto) { + return this.categoryService.deleteById(params.id) } } diff --git a/apps/core/src/modules/category/category.module.ts b/apps/core/src/modules/category/category.module.ts index 9e587f6cde2..17e999d0ec9 100644 --- a/apps/core/src/modules/category/category.module.ts +++ b/apps/core/src/modules/category/category.module.ts @@ -1,7 +1,8 @@ -import { Global, Module } from '@nestjs/common' +import { forwardRef, Global, Module } from '@nestjs/common' import { CATEGORY_SERVICE_TOKEN } from '~/constants/injection.constant' +import { AiModule } from '../ai/ai.module' import { SlugTrackerModule } from '../slug-tracker/slug-tracker.module' import { CategoryController } from './category.controller' import { CategoryRepository } from './category.repository' @@ -16,6 +17,6 @@ import { CategoryService } from './category.service' ], exports: [CategoryService, CategoryRepository, CATEGORY_SERVICE_TOKEN], controllers: [CategoryController], - imports: [SlugTrackerModule], + imports: [SlugTrackerModule, forwardRef(() => AiModule)], }) export class CategoryModule {} diff --git a/apps/core/src/modules/category/category.service.ts b/apps/core/src/modules/category/category.service.ts index 289bc9f5dd1..7bd5d084a11 100644 --- a/apps/core/src/modules/category/category.service.ts +++ b/apps/core/src/modules/category/category.service.ts @@ -2,12 +2,9 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common' import { ModuleRef } from '@nestjs/core' import { omit } from 'es-toolkit/compat' -import { BizException } from '~/common/exceptions/biz.exception' -import { CannotFindException } from '~/common/exceptions/cant-find.exception' -import { NoContentCanBeModifiedException } from '~/common/exceptions/no-content-canbe-modified.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { ArticleTypeEnum } from '~/constants/article.constant' import { BusinessEvents, EventScope } from '~/constants/business-event.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { EventBusEvents } from '~/constants/event-bus.constant' import { POST_SERVICE_TOKEN } from '~/constants/injection.constant' import { EventManagerService } from '~/processors/helper/helper.event.service' @@ -89,7 +86,7 @@ export class CategoryService implements OnApplicationBootstrap { condition.isPublished === undefined ? posts : posts.filter((post) => post.isPublished === condition.isPublished) - if (filtered.length === 0) throw new CannotFindException() + if (filtered.length === 0) throw createAppException(AppErrorCode.NOT_FOUND) return filtered.map( ({ id, @@ -175,11 +172,11 @@ export class CategoryService implements OnApplicationBootstrap { async deleteById(id: string) { const category = await this.categoryRepository.findById(id) - if (!category) throw new NoContentCanBeModifiedException() + if (!category) throw createAppException(AppErrorCode.NO_CONTENT_MODIFIABLE) const postsInCategory = await this.findPostsInCategory(category.id) if (postsInCategory.length > 0) { - throw new BizException(ErrorCodeEnum.CategoryHasPosts) + throw createAppException(AppErrorCode.CATEGORY_HAS_POSTS) } const deleted = await this.categoryRepository.deleteById(category.id) if ((await this.categoryRepository.countAll()) === 0) { @@ -205,7 +202,7 @@ export class CategoryService implements OnApplicationBootstrap { async createDefaultCategory() { if ((await this.categoryRepository.countAll()) === 0) { return this.categoryRepository.create({ - name: '默认分类', + name: 'Default', slug: 'default', }) } diff --git a/apps/core/src/modules/category/category.views.ts b/apps/core/src/modules/category/category.views.ts new file mode 100644 index 00000000000..56ea64d74a2 --- /dev/null +++ b/apps/core/src/modules/category/category.views.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' + +const CategoryCardSchema = z + .object({ + id: z.string(), + name: z.string(), + slug: z.string(), + type: z.number(), + createdAt: z.date().or(z.string()), + }) + .passthrough() + +const CategoryDetailSchema = z.object({}).passthrough() + +export const CategoryViews = { + card: CategoryCardSchema, + detail: CategoryDetailSchema, +} as const + +export type CategoryView = keyof typeof CategoryViews diff --git a/apps/core/src/modules/comment/comment-anchor.service.ts b/apps/core/src/modules/comment/comment-anchor.service.ts index 85a48a74551..fa40021df7e 100644 --- a/apps/core/src/modules/comment/comment-anchor.service.ts +++ b/apps/core/src/modules/comment/comment-anchor.service.ts @@ -1,9 +1,8 @@ import { Injectable, Logger } from '@nestjs/common' import DiffMatchPatch from 'diff-match-patch' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { CollectionRefTypes } from '~/constants/db.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { DatabaseService } from '~/processors/database/database.service' import { type LexicalRootBlock, @@ -211,10 +210,9 @@ export class CommentAnchorService { !refDoc.content || typeof refDoc.content !== 'string' ) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'Anchor comments are only supported for lexical content.', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'Anchor comments are only supported for lexical content.', + }) } lexicalContent = refDoc.content } @@ -222,10 +220,9 @@ export class CommentAnchorService { const blocks = this.lexicalService.extractRootBlocks(lexicalContent) const block = this.findBlockByAnchor(anchor, blocks) if (!block || !block.id) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'Cannot find the anchor block in current lexical document.', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'Cannot find the anchor block in current lexical document.', + }) } const now = new Date() @@ -248,10 +245,9 @@ export class CommentAnchorService { const quote = anchor.quote if (!quote) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'Range anchor quote cannot be empty.', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'Range anchor quote cannot be empty.', + }) } const initialSlice = block.text.slice(anchor.startOffset, anchor.endOffset) @@ -270,10 +266,9 @@ export class CommentAnchorService { ) if (!range) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'Cannot resolve selected text in current block.', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'Cannot resolve selected text in current block.', + }) } return { diff --git a/apps/core/src/modules/comment/comment.controller.ts b/apps/core/src/modules/comment/comment.controller.ts index 5c1a68b93ce..5f71365a603 100644 --- a/apps/core/src/modules/comment/comment.controller.ts +++ b/apps/core/src/modules/comment/comment.controller.ts @@ -20,14 +20,13 @@ import { HTTPDecorators } from '~/common/decorators/http.decorator' import type { IpRecord } from '~/common/decorators/ip.decorator' import { IpLocation } from '~/common/decorators/ip.decorator' import { HasAdminAccess } from '~/common/decorators/role.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { CannotFindException } from '~/common/exceptions/cant-find.exception' -import { NoContentCanBeModifiedException } from '~/common/exceptions/no-content-canbe-modified.exception' +import { AppErrorCode, createAppException } from '~/common/errors' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' import { BusinessEvents, EventScope } from '~/constants/business-event.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { EventManagerService } from '~/processors/helper/helper.event.service' import { EntityIdDto } from '~/shared/dto/id.dto' -import { PagerDto } from '~/shared/dto/pager.dto' +import { BasicPagerDto } from '~/shared/dto/pager.dto' import { ConfigsService } from '../configs/configs.service' import { ReaderService } from '../reader/reader.service' @@ -37,6 +36,7 @@ import { CommentLifecycleService } from './comment.lifecycle.service' import { BatchCommentDeleteDto, BatchCommentStateDto, + CommentAdminPagerDto, CommentDto, CommentRefTypesDto, CommentStatePatchDto, @@ -48,7 +48,7 @@ import { import { CommentService } from './comment.service' import type { CommentModel } from './comment.types' -const idempotenceMessage = '哦吼,这句话你已经说过啦' +const idempotenceMessage = 'Whoops, you already said this' @ApiController({ path: 'comments' }) @UseInterceptors(CommentFilterEmailInterceptor) @@ -73,7 +73,7 @@ export class CommentController { const id = params.id if (!hasAdminAccess && !(await this.commentService.allowComment(id, ref))) { - throw new BizException(ErrorCodeEnum.CommentForbidden) + throw createAppException(AppErrorCode.COMMENT_FORBIDDEN) } const model: Partial = { ...body, ...ipLocation } @@ -98,7 +98,7 @@ export class CommentController { !hasAdminAccess && !(await this.commentService.allowCommentByCommentId(params.id)) ) { - throw new BizException(ErrorCodeEnum.CommentForbidden) + throw createAppException(AppErrorCode.COMMENT_FORBIDDEN) } const isLoggedInComment = RequestContext.hasReaderIdentity() @@ -109,18 +109,15 @@ export class CommentController { } const comment = await this.commentService.replyComment(params.id, model) - this.lifecycleService.afterReplyComment(comment, ipLocation) - const [doc] = await this.commentService.fillAndReplaceAvatarUrl([comment]) return doc } @Get('/') @Auth() - async getRecentlyComments(@Query() query: PagerDto) { + async getRecentlyComments(@Query() query: CommentAdminPagerDto) { const { size = 10, page = 1, state = 0 } = query - const comments = await this.commentService.getComments({ size, page, @@ -130,15 +127,21 @@ export class CommentController { comments.data.map((doc) => doc.readerId).filter(Boolean) as string[], ) - return Object.assign({}, comments, { - readers: keyBy(readers, 'id'), - }) + const readerMap = keyBy(readers, 'id') + const data = comments.data.map((doc) => ({ + ...doc, + reader: readerMap[(doc as any).readerId] ?? null, + })) + return withMeta( + data, + new MetaObjectBuilder().pagination(comments.pagination).build(), + ) } @Get('/ref/:id') async getCommentsByRefId( @Param() params: EntityIdDto, - @Query() query: PagerDto, + @Query() query: BasicPagerDto, @Query('hasAnchor') hasAnchor: string, @Query('sort') sort: string | undefined, @Query('around') around: string | undefined, @@ -168,29 +171,39 @@ export class CommentController { const readerIds = this.commentService.collectThreadReaderIds(comments.data) const readers = await this.readerService.findReaderInIds(readerIds) - const result = Object.assign({}, comments, { - readers: keyBy(readers, 'id'), - }) - - return result + const readerMap2 = keyBy(readers, 'id') + const refData = comments.data.map((doc) => ({ + ...doc, + reader: readerMap2[(doc as any).readerId] ?? null, + })) + return withMeta( + refData, + new MetaObjectBuilder().pagination(comments.pagination).build(), + ) } @Get('/thread/:rootCommentId') async getThreadReplies( @Param('rootCommentId') rootCommentId: string, - @Query() query: PagerDto, + @Query() query: BasicPagerDto, @Query('cursor') cursor: string, @HasAdminAccess() hasAdminAccess: boolean, ) { const { size = 10 } = query const configs = await this.configsService.get('commentOptions') - return this.commentService.getThreadReplies(rootCommentId, { + const result = await this.commentService.getThreadReplies(rootCommentId, { cursor, size, isAuthenticated: hasAdminAccess, commentShouldAudit: configs.commentShouldAudit, }) + return { + replies: result.replies, + remaining: result.remaining, + done: result.done, + nextCursor: result.nextCursor ?? null, + } } @Get('/:id') @@ -203,28 +216,23 @@ export class CommentController { await this.commentService.findByIdWithRelations(id) if (!data) { - throw new CannotFindException() + throw createAppException(AppErrorCode.COMMENT_NOT_FOUND, { id }) } if (data.isWhispers && !hasAdminAccess) { - throw new CannotFindException() + throw createAppException(AppErrorCode.COMMENT_NOT_FOUND, { id }) } await this.commentService.fillAndReplaceAvatarUrl([data]) if (data.readerId) { const reader = await this.readerService.findReaderInIds([data.readerId]) - Object.assign(data, { - reader: reader[0], - }) + Object.assign(data, { reader: reader[0] }) } return data } @Post('/guest/:id') - @HTTPDecorators.Idempotence({ - expired: 20, - errorMessage: idempotenceMessage, - }) + @HTTPDecorators.Idempotence({ expired: 20, errorMessage: idempotenceMessage }) async guestComment( @Param() params: EntityIdDto, @Body() body: CommentDto, @@ -234,22 +242,18 @@ export class CommentController { const { allowGuestComment, disableComment } = await this.configsService.get('commentOptions') if (disableComment) { - throw new BizException(ErrorCodeEnum.CommentDisabled) + throw createAppException(AppErrorCode.COMMENT_DISABLED) } if (!allowGuestComment) { - throw new BizException(ErrorCodeEnum.CommentForbidden) + throw createAppException(AppErrorCode.COMMENT_FORBIDDEN) } await this.commentService.validAuthorName(body.author) - return this.createCommentWithBody(params, body, ipLocation, query) } @Post('/reader/:id') - @HTTPDecorators.Idempotence({ - expired: 20, - errorMessage: idempotenceMessage, - }) + @HTTPDecorators.Idempotence({ expired: 20, errorMessage: idempotenceMessage }) async readerComment( @Param() params: EntityIdDto, @Body() body: ReaderCommentDto, @@ -259,20 +263,16 @@ export class CommentController { ) { const { disableComment } = await this.configsService.get('commentOptions') if (disableComment && !RequestContext.hasAdminAccess()) { - throw new BizException(ErrorCodeEnum.CommentDisabled) + throw createAppException(AppErrorCode.COMMENT_DISABLED) } if (!readerId) { - throw new BizException(ErrorCodeEnum.AuthNotLoggedIn) + throw createAppException(AppErrorCode.AUTH_NOT_LOGGED_IN) } - return this.createCommentWithBody(params, body, ipLocation, query) } @Post('/guest/reply/:id') - @HTTPDecorators.Idempotence({ - expired: 20, - errorMessage: idempotenceMessage, - }) + @HTTPDecorators.Idempotence({ expired: 20, errorMessage: idempotenceMessage }) async guestReplyByCid( @Param() params: EntityIdDto, @Body() body: ReplyCommentDto, @@ -281,24 +281,18 @@ export class CommentController { const { allowGuestComment, disableComment } = await this.configsService.get('commentOptions') if (disableComment) { - throw new BizException(ErrorCodeEnum.CommentDisabled) + throw createAppException(AppErrorCode.COMMENT_DISABLED) } - if (!allowGuestComment) { - throw new BizException(ErrorCodeEnum.CommentForbidden) + throw createAppException(AppErrorCode.COMMENT_FORBIDDEN) } - await this.commentService.validAuthorName(body.author) - return this.replyCommentWithBody(params, body, ipLocation) } @Post('/owner-reply/:id') @Auth() - @HTTPDecorators.Idempotence({ - expired: 20, - errorMessage: idempotenceMessage, - }) + @HTTPDecorators.Idempotence({ expired: 20, errorMessage: idempotenceMessage }) async replyByCid( @Param() params: EntityIdDto, @Body() body: ReaderReplyCommentDto, @@ -308,10 +302,7 @@ export class CommentController { } @Post('/reader/reply/:id') - @HTTPDecorators.Idempotence({ - expired: 20, - errorMessage: idempotenceMessage, - }) + @HTTPDecorators.Idempotence({ expired: 20, errorMessage: idempotenceMessage }) async readerReplyByCid( @Param() params: EntityIdDto, @Body() body: ReaderReplyCommentDto, @@ -320,13 +311,11 @@ export class CommentController { ) { const { disableComment } = await this.configsService.get('commentOptions') if (disableComment && !RequestContext.hasAdminAccess()) { - throw new BizException(ErrorCodeEnum.CommentDisabled) + throw createAppException(AppErrorCode.COMMENT_DISABLED) } - if (!readerId) { - throw new BizException(ErrorCodeEnum.AuthNotLoggedIn) + throw createAppException(AppErrorCode.AUTH_NOT_LOGGED_IN) } - return this.replyCommentWithBody(params, body, ipLocation) } @@ -350,7 +339,7 @@ export class CommentController { try { await this.commentService.updateComment(id, updateResult) } catch { - throw new NoContentCanBeModifiedException() + throw createAppException(AppErrorCode.NO_CONTENT_MODIFIABLE) } if (!isUndefined(state)) { @@ -366,12 +355,8 @@ export class CommentController { await this.eventManager.emit( BusinessEvents.COMMENT_DELETE, { id }, - { - scope: EventScope.TO_SYSTEM_VISITOR, - nextTick: true, - }, + { scope: EventScope.TO_SYSTEM_VISITOR, nextTick: true }, ) - return } @Patch('/batch/state') @@ -382,9 +367,7 @@ export class CommentController { let affected: string[] = [] if (all) { const filter: Record = {} - if (!isUndefined(currentState)) { - filter.state = currentState - } + if (!isUndefined(currentState)) filter.state = currentState const matched = await this.commentService.findByFilter(filter) affected = matched.map((c) => String(c.id)) await this.commentService.updateStateByFilter(filter, state) @@ -396,8 +379,6 @@ export class CommentController { if (affected.length) { await this.commentService.cascadeFilesForCommentsIfSpam(affected, state) } - - return } @Delete('/batch') @@ -407,9 +388,7 @@ export class CommentController { if (all) { const filter: Record = {} - if (!isUndefined(state)) { - filter.state = state - } + if (!isUndefined(state)) filter.state = state const comments = await this.commentService.findByFilter(filter) await Promise.all( comments.map((comment) => @@ -421,8 +400,6 @@ export class CommentController { ids.map((id) => this.commentService.softDeleteComment(id)), ) } - - return } @Patch('/edit/:id') @@ -436,10 +413,10 @@ export class CommentController { const { text } = body const comment = await this.commentService.findById(id) if (!comment) { - throw new CannotFindException() + throw createAppException(AppErrorCode.COMMENT_NOT_FOUND, { id }) } if (comment.readerId !== readerId && !hasAdminAccess) { - throw new BizException(ErrorCodeEnum.CommentForbidden) + throw createAppException(AppErrorCode.COMMENT_FORBIDDEN) } await this.commentService.editComment(id, text) } diff --git a/apps/core/src/modules/comment/comment.email.default.ts b/apps/core/src/modules/comment/comment.email.default.ts index 0405aa8bd9b..74b1a200c1d 100644 --- a/apps/core/src/modules/comment/comment.email.default.ts +++ b/apps/core/src/modules/comment/comment.email.default.ts @@ -20,7 +20,7 @@ const defaultCommentModelForRenderProps: CommentModelRenderProps = { avatar: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/976.jpg' as string, mail: 'commtor@example.com' as string, - text: '世界!' as string, + text: 'Hello world!' as string, ip: '0.0.0.0' as string | undefined, agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' as string, @@ -35,9 +35,9 @@ export const defaultCommentModelKeys = Object.keys( ) const defaultPostModelForRenderProps = { - title: '匆匆', + title: 'Sample Post', id: 'd7e0ed429da8ae90988c37da', - text: '燕子去了,有再来的时候;杨柳枯了,有再青的时候;桃花谢了,有再开的时候。但是,聪明的,你告诉我,我们的日子为什么一去不复返呢?——是有人偷了他们罢:那是谁?又藏在何处呢?是他们自己逃走了罢:如今(现在 [2] )又到了哪里呢?', + text: 'Swallows may have gone, but there is a time of return; willow trees may have died back, but there is a time of regreening; peach blossoms may have fallen, but they will bloom again. But, tell me, you the wise, why should our days leave us, never to return?', created: new Date().toISOString(), modified: null as string | null, } diff --git a/apps/core/src/modules/comment/comment.interceptor.ts b/apps/core/src/modules/comment/comment.interceptor.ts index f891b0c0f07..b220fb27e61 100644 --- a/apps/core/src/modules/comment/comment.interceptor.ts +++ b/apps/core/src/modules/comment/comment.interceptor.ts @@ -15,7 +15,7 @@ import { isDefined } from '~/utils/validator.util' export class CommentFilterEmailInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler) { const request = this.getRequest(context) - // 如果已经登陆 + // Already authenticated — skip filtering const isAuthenticated = request.user if (isAuthenticated) { return next.handle() diff --git a/apps/core/src/modules/comment/comment.lifecycle.service.ts b/apps/core/src/modules/comment/comment.lifecycle.service.ts index a842a1923ef..b30a94667fe 100644 --- a/apps/core/src/modules/comment/comment.lifecycle.service.ts +++ b/apps/core/src/modules/comment/comment.lifecycle.service.ts @@ -323,7 +323,8 @@ export class CommentLifecycleService implements OnModuleInit, OnModuleDestroy { to: recipientMail, type, source: { - title: refType === CollectionRefTypes.Recently ? '速记' : refDoc.title, + title: + refType === CollectionRefTypes.Recently ? 'Thinking' : refDoc.title, text: comment.text, author: (type === CommentReplyMailType.Guest @@ -425,10 +426,10 @@ export class CommentLifecycleService implements OnModuleInit, OnModuleDestroy { const { adminUrl } = await this.configsService.get('url') await this.barkService.push({ - title: '收到一条新评论', - body: `${comment.author} 评论了你的${ - comment.refType === CollectionRefTypes.Recently ? '速记' : '文章' - }:${comment.text}`, + title: 'New comment received', + body: `${comment.author} commented on your ${ + comment.refType === CollectionRefTypes.Recently ? 'thinking' : 'article' + }: ${comment.text}`, icon: comment.avatar ?? undefined, url: `${adminUrl}#/comments`, }) @@ -474,8 +475,8 @@ export class CommentLifecycleService implements OnModuleInit, OnModuleDestroy { const sendfrom = `"${seo.title || 'Mx Space'}" <${senderEmail}>` const subject = type === CommentReplyMailType.Guest - ? `[${seo.title || 'Mx Space'}] ${source.owner || '有人'}给你了新的回复` - : `[${seo.title || 'Mx Space'}] 有新回复了耶~` + ? `[${seo.title || 'Mx Space'}] ${source.owner || 'Someone'} has replied to you` + : `[${seo.title || 'Mx Space'}] You have a new reply` source.ip ??= '' const options = { diff --git a/apps/core/src/modules/comment/comment.schema.ts b/apps/core/src/modules/comment/comment.schema.ts index a3e7c3067ee..3890638e271 100644 --- a/apps/core/src/modules/comment/comment.schema.ts +++ b/apps/core/src/modules/comment/comment.schema.ts @@ -2,6 +2,7 @@ import { createZodDto } from 'nestjs-zod' import { z } from 'zod' import { CollectionRefTypes } from '~/constants/db.constant' +import { BasicPagerSchema } from '~/shared/dto/pager.dto' import { normalizeRefType } from '~/utils/database.util' import { CommentAnchorMode } from './comment.enum' @@ -43,23 +44,29 @@ export const CommentAnchorSchema = z.discriminatedUnion('mode', [ * Comment schema for API validation */ export const CommentSchema = z.object({ - author: z.string().min(1).max(20, { message: '昵称不得大于 20 个字符' }), - text: z.string().min(1).max(500, { message: '评论内容不得大于 500 个字符' }), + author: z + .string() + .min(1) + .max(20, { message: 'Nickname must not exceed 20 characters' }), + text: z + .string() + .min(1) + .max(500, { message: 'Comment must not exceed 500 characters' }), mail: z .string() - .email({ message: '请更正为正确的邮箱' }) - .max(50, { message: '邮箱地址不得大于 50 个字符' }), + .email({ message: 'Please provide a valid email address' }) + .max(50, { message: 'Email address must not exceed 50 characters' }), url: z .string() - .url({ message: '请更正为正确的网址' }) - .max(50, { message: '地址不得大于 50 个字符' }) + .url({ message: 'Please provide a valid URL' }) + .max(50, { message: 'URL must not exceed 50 characters' }) .optional(), isWhispers: z.boolean().optional(), avatar: z .string() - .url({ message: '头像必须是合法的 HTTPS URL 哦' }) + .url({ message: 'Avatar must be a valid HTTPS URL' }) .refine((val) => val.startsWith('https://'), { - message: '头像必须是合法的 HTTPS URL 哦', + message: 'Avatar must be a valid HTTPS URL', }) .optional(), anchor: CommentAnchorSchema.optional(), @@ -69,7 +76,10 @@ export const AnonymousCommentSchema = CommentSchema export const AnonymousReplyCommentSchema = CommentSchema.omit({ anchor: true }) export const ReaderCommentSchema = z.object({ - text: z.string().min(1).max(500, { message: '评论内容不得大于 500 个字符' }), + text: z + .string() + .min(1) + .max(500, { message: 'Comment must not exceed 500 characters' }), isWhispers: z.boolean().optional(), anchor: CommentAnchorSchema.optional(), }) @@ -100,11 +110,14 @@ export class EditCommentDto extends createZodDto(EditCommentSchema) {} * Required guest reader comment schema */ export const RequiredGuestReaderCommentSchema = CommentSchema.extend({ - author: z.string().min(1).max(20, { message: '昵称不得大于 20 个字符' }), + author: z + .string() + .min(1) + .max(20, { message: 'Nickname must not exceed 20 characters' }), mail: z .string() - .email({ message: '请更正为正确的邮箱' }) - .max(50, { message: '邮箱地址不得大于 50 个字符' }), + .email({ message: 'Please provide a valid email address' }) + .max(50, { message: 'Email address must not exceed 50 characters' }), }) export class RequiredGuestReaderCommentDto extends createZodDto( @@ -157,6 +170,18 @@ export const CommentListQuerySchema = z.object({ export class CommentListQueryDto extends createZodDto(CommentListQuerySchema) {} +/** + * Admin pager query for `GET /comments` — adds optional `state` filter on top + * of the basic pager. + */ +export const CommentAdminPagerSchema = BasicPagerSchema.extend({ + state: z.coerce.number().int().optional(), +}) + +export class CommentAdminPagerDto extends createZodDto( + CommentAdminPagerSchema, +) {} + /** * Comment state patch schema */ diff --git a/apps/core/src/modules/comment/comment.service.ts b/apps/core/src/modules/comment/comment.service.ts index e528deb7754..a4e1ea6dc7a 100644 --- a/apps/core/src/modules/comment/comment.service.ts +++ b/apps/core/src/modules/comment/comment.service.ts @@ -2,12 +2,9 @@ import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { RequestContext } from '~/common/contexts/request.context' -import { BizException } from '~/common/exceptions/biz.exception' -import { CannotFindException } from '~/common/exceptions/cant-find.exception' -import { NoContentCanBeModifiedException } from '~/common/exceptions/no-content-canbe-modified.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { BusinessEvents, EventScope } from '~/constants/business-event.constant' import { CollectionRefTypes } from '~/constants/db.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { DatabaseService } from '~/processors/database/database.service' import { EventManagerService } from '~/processors/helper/helper.event.service' import { getAvatar } from '~/utils/tool.util' @@ -57,7 +54,7 @@ export type CommentParentPreview = { const COMMENT_REPLY_THRESHOLD = 20 const COMMENT_REPLY_EDGE_SIZE = 3 const COMMENT_THREAD_BATCH_SIZE = 10 -const COMMENT_DELETED_PLACEHOLDER = '该评论已删除' +const COMMENT_DELETED_PLACEHOLDER = 'This comment has been deleted' @Injectable() export class CommentService { @@ -75,8 +72,9 @@ export class CommentService { ) {} /** - * 评论批量更新状态时之级联清图。 - * Junk(state=2) 转移会触发关联 reader-uploaded 文件之硬删除(按配置)。 + * Cascade-clean uploaded files when batch-updating comment state. + * Moving comments to Junk (state=2) triggers hard deletion of the + * associated reader-uploaded files (per configuration). */ async cascadeFilesForCommentsIfSpam(commentIds: string[], state: number) { if (state !== CommentState.Junk) return @@ -316,7 +314,7 @@ export class CommentService { const result = await this.databaseService.findGlobalById(id) if (result) refType = result.type } - if (!refType) throw new BizException(ErrorCodeEnum.CommentPostNotExists) + if (!refType) throw createAppException(AppErrorCode.COMMENT_POST_NOT_EXISTS) const comment = await this.commentRepository.create({ text: doc.text!, @@ -347,16 +345,16 @@ export class CommentService { async validAuthorName(author: string): Promise { const isExist = await this.ownerService.isOwnerName(author) if (isExist) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - '用户名与主人重名啦,但是你好像并不是我的主人唉', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: + "That name belongs to the site owner, and you don't look like them.", + }) } } async replyComment(id: string, doc: Partial) { const parent = await this.commentRepository.findById(id) - if (!parent) throw new CannotFindException() + if (!parent) throw createAppException(AppErrorCode.NOT_FOUND) const reader = await this.assignReaderToComment() if (reader) { @@ -364,7 +362,7 @@ export class CommentService { this.assignAuthProviderToComment(doc) } - // Owner 回复未读评论时,自动将父评论标为已读 + // When the owner replies to an unread comment, mark the parent as read. if ( RequestContext.hasAdminAccess() && parent.state === CommentState.Unread @@ -409,7 +407,7 @@ export class CommentService { async softDeleteComment(id: string) { const comment = await this.commentRepository.findById(id) - if (!comment) throw new NoContentCanBeModifiedException() + if (!comment) throw createAppException(AppErrorCode.NO_CONTENT_MODIFIABLE) if (comment.isDeleted) return await this.commentRepository.update(id, { isDeleted: true, @@ -430,7 +428,7 @@ export class CommentService { async allowComment(id: string, _type?: CollectionRefTypes) { const result = await this.databaseService.findGlobalById(id) - if (!result) throw new CannotFindException() + if (!result) throw createAppException(AppErrorCode.NOT_FOUND) return 'allowComment' in result.document ? (result.document as any).allowComment : true @@ -438,7 +436,7 @@ export class CommentService { async allowCommentByCommentId(commentId: string) { const comment = await this.commentRepository.findById(commentId) - if (!comment) throw new CannotFindException() + if (!comment) throw createAppException(AppErrorCode.NOT_FOUND) return this.allowComment( comment.refId, comment.refType as CollectionRefTypes, @@ -749,8 +747,9 @@ export class CommentService { async editComment(id: string, text: string) { const comment = await this.commentRepository.findById(id) - if (!comment) throw new CannotFindException() - if (comment.isDeleted) throw new NoContentCanBeModifiedException() + if (!comment) throw createAppException(AppErrorCode.NOT_FOUND) + if (comment.isDeleted) + throw createAppException(AppErrorCode.NO_CONTENT_MODIFIABLE) await this.commentRepository.update(id, { text, editedAt: new Date() }) await this.eventManager.broadcast( BusinessEvents.COMMENT_UPDATE, diff --git a/apps/core/src/modules/comment/comment.spam-filter.ts b/apps/core/src/modules/comment/comment.spam-filter.ts index 58d25f61592..024df76039a 100644 --- a/apps/core/src/modules/comment/comment.spam-filter.ts +++ b/apps/core/src/modules/comment/comment.spam-filter.ts @@ -101,8 +101,8 @@ export class CommentSpamFilterService { if (result.isSpam) { this.logger.warn( - `--> 检测到垃圾评论 [${result.reason}]:` + - `作者:${doc.author}, IP: ${doc.ip}, 内容:${doc.text}`, + `--> Spam comment detected [${result.reason}]: ` + + `author: ${doc.author}, IP: ${doc.ip}, text: ${doc.text}`, ) } return result.isSpam @@ -138,7 +138,7 @@ export class CommentSpamFilterService { } return output.score > aiReviewThreshold } catch (error) { - this.logger.error('AI 评审评分模式出错', error) + this.logger.error('AI review score mode failed', error) return false } } else { @@ -152,7 +152,7 @@ export class CommentSpamFilterService { } return output.isSpam } catch (error) { - this.logger.error('AI 评审垃圾检测模式出错', error) + this.logger.error('AI review spam detection mode failed', error) return false } } diff --git a/apps/core/src/modules/comment/comment.views.ts b/apps/core/src/modules/comment/comment.views.ts new file mode 100644 index 00000000000..f1405a1b52c --- /dev/null +++ b/apps/core/src/modules/comment/comment.views.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' + +const CommentCardSchema = z + .object({ + id: z.string(), + author: z.string().nullable().optional(), + text: z.string(), + state: z.number(), + createdAt: z.date().or(z.string()), + refType: z.string(), + refId: z.string(), + }) + .passthrough() + +const CommentDetailSchema = z.object({}).passthrough() + +export const CommentViews = { + card: CommentCardSchema, + detail: CommentDetailSchema, +} as const + +export type CommentView = keyof typeof CommentViews diff --git a/apps/core/src/modules/configs/configs.default.ts b/apps/core/src/modules/configs/configs.default.ts index a67bba8e8da..7357d9fcfee 100644 --- a/apps/core/src/modules/configs/configs.default.ts +++ b/apps/core/src/modules/configs/configs.default.ts @@ -2,8 +2,8 @@ import type { IConfig } from './configs.interface' export const generateDefaultConfig: () => IConfig = () => ({ seo: { - title: '我的小世界呀', - description: '哈喽~欢迎光临', + title: 'My Little World', + description: 'Hi, welcome!', icon: '', iconDark: '', keywords: [], diff --git a/apps/core/src/modules/configs/configs.dsl.util.ts b/apps/core/src/modules/configs/configs.dsl.util.ts index 475b85a766e..fa12a20867b 100644 --- a/apps/core/src/modules/configs/configs.dsl.util.ts +++ b/apps/core/src/modules/configs/configs.dsl.util.ts @@ -86,36 +86,36 @@ interface GroupConfig { const groupConfigs: GroupConfig[] = [ { key: 'site', - title: '网站', - description: '站点地址、SEO', + title: 'Site', + description: 'Site URL, SEO', icon: 'globe', sectionKeys: ['url', 'seo'], }, { key: 'content', - title: '内容', - description: '评论、友链', + title: 'Content', + description: 'Comments, friend links', icon: 'file-text', sectionKeys: ['commentOptions', 'friendLinkOptions'], }, { key: 'notification', - title: '通知', - description: '邮件、Bark 推送', + title: 'Notifications', + description: 'Email, Bark push', icon: 'bell', sectionKeys: ['mailOptions', 'barkOptions'], }, { key: 'search', - title: '搜索推送', - description: '搜索引擎、全文检索', + title: 'Search push', + description: 'Search engines, full-text search', icon: 'search', sectionKeys: ['baiduSearchOptions', 'bingSearchOptions'], }, { key: 'storage', - title: '存储', - description: '备份、图床、评论图片上传', + title: 'Storage', + description: 'Backup, image hosting, comment image uploads', icon: 'database', sectionKeys: [ 'backupOptions', @@ -126,21 +126,21 @@ const groupConfigs: GroupConfig[] = [ { key: 'ai', title: 'AI', - description: 'AI 摘要、写作助手', + description: 'AI summary, writing assistant', icon: 'sparkles', sectionKeys: ['ai'], }, { key: 'integrations', - title: '第三方集成', - description: 'GitHub、TMDB、Bangumi 等', + title: 'Third-party integrations', + description: 'GitHub, TMDB, Bangumi, etc.', icon: 'puzzle', sectionKeys: ['thirdPartyServiceIntegration'], }, { key: 'system', - title: '系统', - description: '后台设置、功能开关', + title: 'System', + description: 'Admin settings, feature toggles', icon: 'settings', sectionKeys: ['adminExtra', 'featureList'], }, @@ -413,7 +413,7 @@ export function generateFormDSL(): FormDSL { } const dsl: FormDSL = { - title: fullMeta?.title || '设置', + title: fullMeta?.title || 'Settings', groups, defaults: {}, } diff --git a/apps/core/src/modules/configs/configs.schema.ts b/apps/core/src/modules/configs/configs.schema.ts index 43f3c51b41f..89d6be208f1 100644 --- a/apps/core/src/modules/configs/configs.schema.ts +++ b/apps/core/src/modules/configs/configs.schema.ts @@ -7,22 +7,22 @@ import { AIProviderType } from '~/modules/ai/ai.types' import { field, section, withMeta } from './configs.zod-schema.util' // ==================== SEO ==================== -export const SeoSchema = section('SEO 优化', { - title: field.plain(z.string().min(1).optional(), '网站标题'), - description: field.plain(z.string().min(1).optional(), '网站描述'), - icon: field.halfGrid(z.string().optional(), '浅色图标 URL'), - iconDark: field.halfGrid(z.string().optional(), '深色图标 URL'), - keywords: field.array(z.array(z.string()).optional(), '关键字'), +export const SeoSchema = section('SEO', { + title: field.plain(z.string().min(1).optional(), 'Site title'), + description: field.plain(z.string().min(1).optional(), 'Site description'), + icon: field.halfGrid(z.string().optional(), 'Light icon URL'), + iconDark: field.halfGrid(z.string().optional(), 'Dark icon URL'), + keywords: field.array(z.array(z.string()).optional(), 'Keywords'), }) export class SeoDto extends createZodDto(SeoSchema) {} export type SeoConfig = z.infer // ==================== URL ==================== -export const UrlSchema = section('网站设置', { - webUrl: field.halfGrid(zAllowedUrl.optional(), '前端地址'), - adminUrl: field.halfGrid(zAllowedUrl.optional(), '管理后台地址'), - serverUrl: field.halfGrid(zAllowedUrl.optional(), 'API 地址'), - wsUrl: field.halfGrid(zAllowedUrl.optional(), 'Gateway 地址'), +export const UrlSchema = section('Site URLs', { + webUrl: field.halfGrid(zAllowedUrl.optional(), 'Frontend URL'), + adminUrl: field.halfGrid(zAllowedUrl.optional(), 'Admin dashboard URL'), + serverUrl: field.halfGrid(zAllowedUrl.optional(), 'API URL'), + wsUrl: field.halfGrid(zAllowedUrl.optional(), 'Gateway URL'), }) export class UrlDto extends createZodDto(UrlSchema) {} export type UrlConfig = z.infer @@ -31,22 +31,25 @@ export type UrlConfig = z.infer const SmtpConfigSchema = withMeta( z .object({ - user: field.halfGrid(z.string().optional(), 'SMTP 用户名'), - pass: field.passwordHalfGrid(z.string().min(1).optional(), 'SMTP 密码'), - host: field.halfGrid(z.string().optional(), 'SMTP 主机'), + user: field.halfGrid(z.string().optional(), 'SMTP username'), + pass: field.passwordHalfGrid( + z.string().min(1).optional(), + 'SMTP password', + ), + host: field.halfGrid(z.string().optional(), 'SMTP host'), port: field.number( z.preprocess( (val) => (val ? Number(val) : val), z.number().int().optional(), ), - 'SMTP 端口', + 'SMTP port', { 'ui:options': { halfGrid: true } }, ), - secure: field.toggle(z.boolean().optional(), '使用 SSL/TLS'), + secure: field.toggle(z.boolean().optional(), 'Use SSL/TLS'), }) .optional(), { - title: 'SMTP 配置', + title: 'SMTP configuration', 'ui:options': { showWhen: { provider: 'smtp' } }, }, ) @@ -58,20 +61,29 @@ const ResendConfigSchema = withMeta( }) .optional(), { - title: 'Resend 配置', + title: 'Resend configuration', 'ui:options': { showWhen: { provider: 'resend' } }, }, ) -export const MailOptionsSchema = section('邮件通知设置', { - enable: field.toggle(z.boolean().optional(), '开启邮箱提醒'), - provider: field.select(z.enum(['smtp', 'resend']).optional(), '邮件服务', [ - { label: 'SMTP', value: 'smtp' }, - { label: 'Resend', value: 'resend' }, - ]), - from: field.halfGrid(z.email().optional().or(z.literal('')), '发件邮箱地址', { - description: 'Resend 必填;SMTP 可选,不填则使用 SMTP 用户名', - }), +export const MailOptionsSchema = section('Email notifications', { + enable: field.toggle(z.boolean().optional(), 'Enable email notifications'), + provider: field.select( + z.enum(['smtp', 'resend']).optional(), + 'Email provider', + [ + { label: 'SMTP', value: 'smtp' }, + { label: 'Resend', value: 'resend' }, + ], + ), + from: field.halfGrid( + z.email().optional().or(z.literal('')), + 'Sender email address', + { + description: + 'Required for Resend; optional for SMTP (defaults to the SMTP username if omitted)', + }, + ), smtp: SmtpConfigSchema, resend: ResendConfigSchema, rateLimit: field.number( @@ -79,9 +91,9 @@ export const MailOptionsSchema = section('邮件通知设置', { (val) => (val ? Number(val) : val), z.number().int().min(1).max(1000).optional(), ), - '发送速率限制', + 'Send rate limit', { - description: '每秒最大发送次数,默认 10', + description: 'Maximum sends per second; defaults to 10', 'ui:options': { halfGrid: true }, }, ), @@ -90,9 +102,9 @@ export const MailOptionsSchema = section('邮件通知设置', { (val) => (val ? Number(val) : val), z.number().int().min(0).max(10).optional(), ), - '发送失败重试次数', + 'Send failure retry count', { - description: '发送失败后的最大重试次数,默认 3', + description: 'Maximum retries after a send failure; defaults to 3', 'ui:options': { halfGrid: true }, }, ), @@ -101,86 +113,116 @@ export class MailOptionsDto extends createZodDto(MailOptionsSchema) {} export type MailOptionsConfig = z.infer // ==================== Comment Options ==================== -export const CommentOptionsSchema = section('评论设置', { - antiSpam: field.toggle(z.boolean().optional(), '反垃圾评论'), - aiReview: field.toggle(z.boolean().optional(), '开启 AI 审核'), +export const CommentOptionsSchema = section('Comment settings', { + antiSpam: field.toggle(z.boolean().optional(), 'Anti-spam'), + aiReview: field.toggle(z.boolean().optional(), 'Enable AI review'), aiReviewType: field.select( z.enum(['binary', 'score']).optional(), - 'AI 审核方式', + 'AI review mode', [ - { label: '是非', value: 'binary' }, - { label: '评分', value: 'score' }, + { label: 'Binary', value: 'binary' }, + { label: 'Score', value: 'score' }, ], - { description: '默认为是非,可以选择评分' }, + { description: 'Defaults to binary; score mode is also available' }, ), aiReviewThreshold: field.number( z.preprocess( (val) => (val ? Number(val) : val), z.number().int().min(1).max(10).optional(), ), - 'AI 审核阈值', - { description: '分数大于多少时会被归类为垃圾评论,范围为 1-10, 默认为 5' }, + 'AI review threshold', + { + description: + 'Scores above this value are classified as spam. Range 1-10, default 5', + }, ), - testAiReview: field.action('测试 AI 审核', 'test-ai-review', { - description: '输入测试内容,验证 AI 审核功能是否正常工作', - actionLabel: '测试', + testAiReview: field.action('Test AI review', 'test-ai-review', { + description: + 'Enter test content to verify whether AI review is working correctly', + actionLabel: 'Test', showWhen: { aiReview: 'true' }, }), - disableComment: field.toggle(z.boolean().optional(), '全站禁止评论', { - description: '敏感时期专用', - }), - allowGuestComment: field.toggle(z.boolean().optional(), '允许未登录评论', { - description: '关闭后,只有已登录 reader 或 owner 可以评论和回复', - }), - spamKeywords: field.array(z.array(z.string()).optional(), '自定义屏蔽关键词'), + disableComment: field.toggle( + z.boolean().optional(), + 'Disable comments site-wide', + { + description: 'Reserved for sensitive periods', + }, + ), + allowGuestComment: field.toggle( + z.boolean().optional(), + 'Allow comments without login', + { + description: + 'When disabled, only signed-in readers or the owner can comment and reply', + }, + ), + spamKeywords: field.array( + z.array(z.string()).optional(), + 'Custom blocked keywords', + ), blockIps: field.array( z.array(z.union([z.ipv4(), z.ipv6()])).optional(), - '自定义屏蔽 IP', + 'Custom blocked IPs', + ), + disableNoChinese: field.toggle( + z.boolean().optional(), + 'Reject non-Chinese comments', + ), + commentShouldAudit: field.toggle( + z.boolean().optional(), + 'Only show approved comments', + ), + recordIpLocation: field.toggle( + z.boolean().optional(), + 'Publicly display comment location', ), - disableNoChinese: field.toggle(z.boolean().optional(), '禁止非中文评论'), - commentShouldAudit: field.toggle(z.boolean().optional(), '只展示已读评论'), - recordIpLocation: field.toggle(z.boolean().optional(), '评论公开归属地'), }) export class CommentOptionsDto extends createZodDto(CommentOptionsSchema) {} export type CommentOptionsConfig = z.infer // ==================== Backup Options ==================== -export const BackupOptionsSchema = section('备份', { - enable: field.toggle(z.boolean().optional(), '开启自动备份', { - description: '填写以下 S3 信息,将同时上传备份到 S3', +export const BackupOptionsSchema = section('Backup', { + enable: field.toggle(z.boolean().optional(), 'Enable automatic backup', { + description: + 'Fill in the S3 information below to also upload backups to S3', }), - endpoint: field.plain(z.string().optional(), 'S3 服务端点'), + endpoint: field.plain(z.string().optional(), 'S3 endpoint'), secretId: field.halfGrid(z.string().optional(), 'SecretId'), secretKey: field.passwordHalfGrid(z.string().optional(), 'SecretKey'), bucket: field.halfGrid(z.string().optional(), 'Bucket'), - region: field.halfGrid(z.string().optional(), '地域 Region'), + region: field.halfGrid(z.string().optional(), 'Region'), }) export class BackupOptionsDto extends createZodDto(BackupOptionsSchema) {} export type BackupOptionsConfig = z.infer // ==================== Image Storage Options ==================== -export const ImageStorageOptionsSchema = section('图床设置', { - enable: field.toggle(z.boolean().optional(), '开启 S3 图床'), - endpoint: field.plain(z.string().optional(), 'S3 服务端点'), +export const ImageStorageOptionsSchema = section('Image storage', { + enable: field.toggle(z.boolean().optional(), 'Enable S3 image storage'), + endpoint: field.plain(z.string().optional(), 'S3 endpoint'), secretId: field.halfGrid(z.string().optional(), 'Access Key ID'), secretKey: field.passwordHalfGrid(z.string().optional(), 'Secret Access Key'), bucket: field.halfGrid(z.string().optional(), 'Bucket'), region: field.halfGrid(z.string().optional(), 'Region').default('auto'), customDomain: field.plain( z.url().optional().or(z.literal('')), - '自定义域名 (CDN)', + 'Custom domain (CDN)', { - description: '用于替换默认的 S3 URL,例如 CDN 域名', + description: 'Used to replace the default S3 URL, e.g. a CDN domain', }, ), - prefix: field.plain(z.string().optional(), '文件路径前缀', { - description: - '上传到 S3 的文件路径前缀,支持模板占位符: {Y}年4位, {y}年2位, {m}月, {d}日, {h}时, {i}分, {s}秒, {md5}随机MD5, {type}文件类型等。例如: blog/{Y}/{m}/{d} 或 images/', - }), - commentUploadPrefix: field.plain(z.string().optional(), '评论图片路径前缀', { + prefix: field.plain(z.string().optional(), 'File path prefix', { description: - '读者评论上传专用路径前缀,留空则使用 comments/{readerId}/{Y}/{m}/{md5}.{ext}。占位符同 prefix,且额外支持 {readerId}', + 'Path prefix for files uploaded to S3. Supports placeholders: {Y} 4-digit year, {y} 2-digit year, {m} month, {d} day, {h} hour, {i} minute, {s} second, {md5} random MD5, {type} file type, etc. Example: blog/{Y}/{m}/{d} or images/', }), + commentUploadPrefix: field.plain( + z.string().optional(), + 'Comment image path prefix', + { + description: + 'Path prefix dedicated to reader comment uploads. Defaults to comments/{readerId}/{Y}/{m}/{md5}.{ext} when empty. Placeholders are the same as prefix, with additional support for {readerId}', + }, + ), }) export class ImageStorageOptionsDto extends createZodDto( ImageStorageOptionsSchema, @@ -190,19 +232,25 @@ export type ImageStorageOptionsConfig = z.infer< > // ==================== Comment Upload Options ==================== -export const CommentUploadOptionsSchema = section('评论图片上传', { - enable: field.toggle(z.boolean().optional(), '启用读者评论图片上传', { - description: '关闭则前端隐藏上传入口,且后端接口返回 503', - }), +export const CommentUploadOptionsSchema = section('Comment image uploads', { + enable: field.toggle( + z.boolean().optional(), + 'Enable reader comment image uploads', + { + description: + 'When disabled, the frontend hides the upload entry and the backend returns 503', + }, + ), pendingTtlMinutes: field.number( z.preprocess( (val) => (val ? Number(val) : val), z.number().int().min(5).optional(), ), - 'Pending TTL(分钟)', + 'Pending TTL (minutes)', { 'ui:options': { halfGrid: true }, - description: '上传后未被评论引用之保留时长,过期清除。默认 120', + description: + 'Retention time for uploaded images not yet referenced by a comment; expired ones are removed. Default 120', }, ), detachedTtlMinutes: field.number( @@ -210,10 +258,11 @@ export const CommentUploadOptionsSchema = section('评论图片上传', { (val) => (val ? Number(val) : val), z.number().int().min(1).optional(), ), - 'Detached TTL(分钟)', + 'Detached TTL (minutes)', { 'ui:options': { halfGrid: true }, - description: '评论编辑后被移除之图保留时长。默认 30', + description: + 'Retention time for images removed by a comment edit. Default 30', }, ), cronIntervalMinutes: field.number( @@ -221,50 +270,50 @@ export const CommentUploadOptionsSchema = section('评论图片上传', { (val) => (val ? Number(val) : val), z.number().int().min(1).optional(), ), - '清理巡检间隔(分钟)', - { 'ui:options': { halfGrid: true }, description: '默认 15' }, + 'Cleanup interval (minutes)', + { 'ui:options': { halfGrid: true }, description: 'Default 15' }, ), singleFileSizeMB: field.number( z.preprocess( (val) => (val ? Number(val) : val), z.number().int().min(1).max(50).optional(), ), - '单图最大大小(MB)', - { 'ui:options': { halfGrid: true }, description: '默认 5' }, + 'Max size per image (MB)', + { 'ui:options': { halfGrid: true }, description: 'Default 5' }, ), commentImageMaxCount: field.number( z.preprocess( (val) => (val ? Number(val) : val), z.number().int().min(1).max(20).optional(), ), - '单评论图片张数上限', - { 'ui:options': { halfGrid: true }, description: '默认 4' }, + 'Max images per comment', + { 'ui:options': { halfGrid: true }, description: 'Default 4' }, ), readerHourlyUploadCount: field.number( z.preprocess( (val) => (val ? Number(val) : val), z.number().int().min(1).optional(), ), - '单读者每小时上传上限', - { 'ui:options': { halfGrid: true }, description: '默认 10' }, + 'Max uploads per reader per hour', + { 'ui:options': { halfGrid: true }, description: 'Default 10' }, ), readerTotalActiveBytesMB: field.number( z.preprocess( (val) => (val ? Number(val) : val), z.number().int().min(1).optional(), ), - '单读者活跃图总容量上限(MB)', - { 'ui:options': { halfGrid: true }, description: '默认 50' }, + 'Max total active image storage per reader (MB)', + { 'ui:options': { halfGrid: true }, description: 'Default 50' }, ), readerMinAccountAgeHours: field.number( z.preprocess( (val) => (val ? Number(val) : val), z.number().int().min(0).optional(), ), - '读者账号最小年龄(小时)', + 'Minimum reader account age (hours)', { 'ui:options': { halfGrid: true }, - description: '准入门槛,0 表示不限。默认 0', + description: 'Eligibility threshold; 0 means no limit. Default 0', }, ), readerMinCommentCount: field.number( @@ -272,20 +321,23 @@ export const CommentUploadOptionsSchema = section('评论图片上传', { (val) => (val ? Number(val) : val), z.number().int().min(0).optional(), ), - '读者最小已发评论数', + 'Minimum comments posted by reader', { 'ui:options': { halfGrid: true }, - description: '准入门槛,0 表示不限。默认 0', + description: 'Eligibility threshold; 0 means no limit. Default 0', }, ), deleteFilesOnSpam: field.toggle( z.boolean().optional(), - '评论标记 spam 时同步删图', - { description: '默认开启。关闭则仅删评论,保留图待手动处理' }, + 'Delete images when comment is marked as spam', + { + description: + 'Enabled by default. When disabled, only the comment is removed and images are kept for manual review', + }, ), - mimeWhitelist: field.array(z.array(z.string()).optional(), 'MIME 白名单', { + mimeWhitelist: field.array(z.array(z.string()).optional(), 'MIME whitelist', { description: - '默认 image/jpeg, image/png, image/webp, image/gif。修改后立即生效', + 'Defaults to image/jpeg, image/png, image/webp, image/gif. Changes take effect immediately', }), }) export class CommentUploadOptionsDto extends createZodDto( @@ -296,21 +348,21 @@ export type CommentUploadOptionsConfig = z.infer< > // ==================== File Upload Options ==================== -export const FileUploadOptionsSchema = section('文件上传设定', { +export const FileUploadOptionsSchema = section('File upload settings', { enableCustomNaming: field.toggle( z.boolean().optional(), - '启用自定义文件命名', + 'Enable custom file naming', { - description: '开启后将使用下方的命名模板规则', + description: 'When enabled, the naming templates below are applied', }, ), - filenameTemplate: field.plain(z.string().optional(), '文件名模板', { + filenameTemplate: field.plain(z.string().optional(), 'Filename template', { description: - '支持占位符: {Y}年4位, {y}年2位, {m}月, {d}日, {h}时, {i}分, {s}秒, {ms}毫秒, {timestamp}时间戳, {md5}随机MD5, {md5-16}随机MD5(16位), {uuid}UUID, {str-数字}随机字符串, {filename}原文件名(含扩展名), {name}原文件名(不含扩展名), {ext}扩展名', + 'Supported placeholders: {Y} 4-digit year, {y} 2-digit year, {m} month, {d} day, {h} hour, {i} minute, {s} second, {ms} milliseconds, {timestamp} timestamp, {md5} random MD5, {md5-16} random MD5 (16 chars), {uuid} UUID, {str-} random string of length n, {filename} original filename (with extension), {name} original filename (without extension), {ext} extension', }), - pathTemplate: field.plain(z.string().optional(), '文件路径模板', { + pathTemplate: field.plain(z.string().optional(), 'File path template', { description: - '支持占位符同文件名模板,另外支持 {type} 文件类型, {localFolder:数字} 原文件所在文件夹层级', + 'Same placeholders as the filename template, plus {type} for file type and {localFolder:} for the n-th original folder level', }), }) export class FileUploadOptionsDto extends createZodDto( @@ -319,8 +371,8 @@ export class FileUploadOptionsDto extends createZodDto( export type FileUploadOptionsConfig = z.infer // ==================== Baidu Search Options ==================== -export const BaiduSearchOptionsSchema = section('百度推送设定', { - enable: field.toggle(z.boolean().optional(), '开启推送'), +export const BaiduSearchOptionsSchema = section('Baidu push settings', { + enable: field.toggle(z.boolean().optional(), 'Enable push'), token: field.password(z.string().min(1).optional(), 'Token'), }) export class BaiduSearchOptionsDto extends createZodDto( @@ -329,9 +381,9 @@ export class BaiduSearchOptionsDto extends createZodDto( export type BaiduSearchOptionsConfig = z.infer // ==================== Bing Search Options ==================== -export const BingSearchOptionsSchema = section('Bing 推送设定', { - enable: field.toggle(z.boolean().optional(), '开启推送'), - token: field.password(z.string().optional(), 'Bing API 密钥'), +export const BingSearchOptionsSchema = section('Bing push settings', { + enable: field.toggle(z.boolean().optional(), 'Enable push'), + token: field.password(z.string().optional(), 'Bing API key'), }) export class BingSearchOptionsDto extends createZodDto( BingSearchOptionsSchema, @@ -339,30 +391,42 @@ export class BingSearchOptionsDto extends createZodDto( export type BingSearchOptionsConfig = z.infer // ==================== Admin Extra ==================== -export const AdminExtraSchema = section('后台附加设置', { - enableAdminProxy: field.toggle(z.boolean().optional(), '开启后台管理反代', { - description: '是否可以通过 API 访问后台', - }), - background: field.plain(z.string().optional(), '登录页面背景'), - gaodemapKey: field.password(z.string().optional(), '高德查询 API Key', { - description: '日记地点定位', +export const AdminExtraSchema = section('Admin extras', { + enableAdminProxy: field.toggle( + z.boolean().optional(), + 'Enable admin reverse proxy', + { + description: + 'Whether the admin dashboard can be accessed through the API', + }, + ), + background: field.plain(z.string().optional(), 'Login page background'), + gaodemapKey: field.password(z.string().optional(), 'Amap query API key', { + description: 'Location lookup for diary entries', }), }) export class AdminExtraDto extends createZodDto(AdminExtraSchema) {} export type AdminExtraConfig = z.infer // ==================== Friend Link Options ==================== -export const FriendLinkOptionsSchema = section('友链设定', { - allowApply: field.toggle(z.boolean().optional(), '允许申请友链'), - allowSubPath: field.toggle(z.boolean().optional(), '允许子路径友链', { - description: '例如 /blog 子路径', - }), +export const FriendLinkOptionsSchema = section('Friend link settings', { + allowApply: field.toggle( + z.boolean().optional(), + 'Allow friend link applications', + ), + allowSubPath: field.toggle( + z.boolean().optional(), + 'Allow sub-path friend links', + { + description: 'For example, a /blog sub-path', + }, + ), enableAvatarInternalization: field.toggle( z.boolean().optional(), - '友链头像转内链', + 'Internalize friend link avatars', { description: - '通过审核后将会下载友链头像并改为内部链接,仅支持常见图片格式,其他格式将不会转换', + 'After approval, the friend link avatar is downloaded and converted to an internal link. Only common image formats are supported; other formats are not converted', }, ), }) @@ -372,18 +436,22 @@ export class FriendLinkOptionsDto extends createZodDto( export type FriendLinkOptionsConfig = z.infer // ==================== Bark Options ==================== -export const BarkOptionsSchema = section('Bark 通知设定', { - enable: field.toggle(z.boolean().optional(), '开启 Bark 通知'), - key: field.password(z.string().optional(), '设备 Key'), - serverUrl: field.plain(z.string().url().optional(), '服务器 URL', { - description: '如果不填写,则使用默认的服务器,https://day.app', +export const BarkOptionsSchema = section('Bark notifications', { + enable: field.toggle(z.boolean().optional(), 'Enable Bark notifications'), + key: field.password(z.string().optional(), 'Device key'), + serverUrl: field.plain(z.string().url().optional(), 'Server URL', { + description: 'Defaults to the public server, https://day.app, when empty', }), - enableComment: field.toggle(z.boolean().optional(), '开启评论通知'), + enableComment: field.toggle( + z.boolean().optional(), + 'Enable comment notifications', + ), enableThrottleGuard: field.toggle( z.boolean().optional(), - '开启请求被限流时通知', + 'Notify on rate-limited requests', { - description: '当请求被限流会通知,或许可以一定程度上预警被攻击', + description: + 'Sends a notification when requests are rate-limited; can serve as an early warning for attacks', }, ), }) @@ -391,8 +459,11 @@ export class BarkOptionsDto extends createZodDto(BarkOptionsSchema) {} export type BarkOptionsConfig = z.infer // ==================== Feature List ==================== -export const FeatureListSchema = section('特征开关设定', { - emailSubscribe: field.toggle(z.boolean().optional(), '开启邮件推送订阅'), +export const FeatureListSchema = section('Feature toggles', { + emailSubscribe: field.toggle( + z.boolean().optional(), + 'Enable email subscription', + ), }) export class FeatureListDto extends createZodDto(FeatureListSchema) {} export type FeatureListConfig = z.infer @@ -400,57 +471,58 @@ export type FeatureListConfig = z.infer // ==================== Third Party Service Integration ==================== const GitHubIntegrationSchema = section('GitHub', { - enabled: field.toggle(z.boolean().optional().default(true), '启用'), + enabled: field.toggle(z.boolean().optional().default(true), 'Enabled'), token: field.password(z.string().optional(), 'Personal Access Token', { - description: '调用 GitHub API;遇限流则填', + description: + 'Used when calling the GitHub API; fill in when you hit rate limits', }), }) const TmdbIntegrationSchema = section('TMDB', { - enabled: field.toggle(z.boolean().optional().default(false), '启用'), + enabled: field.toggle(z.boolean().optional().default(false), 'Enabled'), apiKey: field.password(z.string().optional(), 'API Key'), }) const BangumiIntegrationSchema = section('Bangumi', { - enabled: field.toggle(z.boolean().optional().default(true), '启用'), + enabled: field.toggle(z.boolean().optional().default(true), 'Enabled'), accessToken: field.password(z.string().optional(), 'Access Token'), }) const NeoDBIntegrationSchema = section('NeoDB', { - enabled: field.toggle(z.boolean().optional().default(true), '启用'), + enabled: field.toggle(z.boolean().optional().default(true), 'Enabled'), }) const ArxivIntegrationSchema = section('Arxiv', { - enabled: field.toggle(z.boolean().optional().default(true), '启用'), + enabled: field.toggle(z.boolean().optional().default(true), 'Enabled'), }) const LeetcodeIntegrationSchema = section('Leetcode', { - enabled: field.toggle(z.boolean().optional().default(true), '启用'), + enabled: field.toggle(z.boolean().optional().default(true), 'Enabled'), }) -const NeteaseMusicIntegrationSchema = section('网易云音乐', { - enabled: field.toggle(z.boolean().optional().default(true), '启用'), +const NeteaseMusicIntegrationSchema = section('NetEase Cloud Music', { + enabled: field.toggle(z.boolean().optional().default(true), 'Enabled'), }) -const QQMusicIntegrationSchema = section('QQ 音乐', { - enabled: field.toggle(z.boolean().optional().default(true), '启用'), +const QQMusicIntegrationSchema = section('QQ Music', { + enabled: field.toggle(z.boolean().optional().default(true), 'Enabled'), }) -const OpenGraphIntegrationSchema = section('Open Graph / oEmbed 兜底', { - enabled: field.toggle(z.boolean().optional().default(true), '启用', { +const OpenGraphIntegrationSchema = section('Open Graph / oEmbed fallback', { + enabled: field.toggle(z.boolean().optional().default(true), 'Enabled', { description: - '未命中专用 provider 之 URL 抓 Open Graph / oEmbed 元数据作链接卡兜底', + 'Fetches Open Graph / oEmbed metadata as a link card fallback for URLs not handled by a dedicated provider', }), fetchMode: field.select( z.enum(['fetch', 'browser']).optional().default('fetch'), - '抓取方式', + 'Fetch mode', [ - { label: 'HTTP 直抓(fetch)', value: 'fetch' }, - { label: '无头浏览器(agent-browser)', value: 'browser' }, + { label: 'HTTP fetch', value: 'fetch' }, + { label: 'Headless browser (agent-browser)', value: 'browser' }, ], { description: - '默认 HTTP 直抓。遇 Cloudflare / 强反爬站点时切 browser 走 Docker 内置之 chromium 渲染。browser 模式更慢,亦更贵。', + 'Defaults to HTTP fetch. For Cloudflare-protected or anti-bot sites, switch to browser mode to render via the chromium bundled in Docker. Browser mode is slower and more expensive.', }, ), timeoutMs: field.number( @@ -459,10 +531,10 @@ const OpenGraphIntegrationSchema = section('Open Graph / oEmbed 兜底', { val === '' || val === null || val === undefined ? val : Number(val), z.number().int().min(1000).max(60000).optional(), ), - '抓取超时(毫秒)', + 'Fetch timeout (milliseconds)', { description: - 'fetch 模式默认 8000;browser 模式默认 25000。区间 1000-60000', + 'Defaults to 8000 in fetch mode and 25000 in browser mode. Range 1000-60000', }, ), maxBodyBytes: field.number( @@ -471,21 +543,29 @@ const OpenGraphIntegrationSchema = section('Open Graph / oEmbed 兜底', { val === '' || val === null || val === undefined ? val : Number(val), z.number().int().min(16384).max(4_194_304).optional(), ), - '响应大小上限(字节)', - { description: '默认 524288(512KB),区间 16KB-4MB;仅扫 ' }, + 'Max response size (bytes)', + { + description: + 'Defaults to 524288 (512KB); range 16KB-4MB; only the is scanned', + }, ), - screenshot: section('截图', { - enabled: field.toggle(z.boolean().optional().default(false), '启用截图', { - description: 'fetchMode 为 browser 时捕获页面截图。仅 browser 模式生效。', - }), + screenshot: section('Screenshot', { + enabled: field.toggle( + z.boolean().optional().default(false), + 'Enable screenshots', + { + description: + 'Capture page screenshots when fetchMode is browser. Only effective in browser mode.', + }, + ), maxItems: field.number( z.preprocess( (val) => val === '' || val === null || val === undefined ? val : Number(val), z.number().int().min(10).max(10_000).optional().default(500), ), - '最大缓存数', - { description: '默认 500,区间 10-10000' }, + 'Max cached items', + { description: 'Default 500; range 10-10000' }, ), maxTotalBytes: field.number( z.preprocess( @@ -498,8 +578,8 @@ const OpenGraphIntegrationSchema = section('Open Graph / oEmbed 兜底', { .optional() .default(100 * 1024 * 1024), ), - '总存储上限(字节)', - { description: '默认 104857600(100MB),下限 1MB' }, + 'Max total storage (bytes)', + { description: 'Default 104857600 (100MB); minimum 1MB' }, ), maxBytesPerImage: field.number( z.preprocess( @@ -512,8 +592,8 @@ const OpenGraphIntegrationSchema = section('Open Graph / oEmbed 兜底', { .optional() .default(512 * 1024), ), - '单图上限(字节)', - { description: '默认 524288(512KB),下限 1KB' }, + 'Max size per image (bytes)', + { description: 'Default 524288 (512KB); minimum 1KB' }, ), webpQuality: field.number( z.preprocess( @@ -521,23 +601,26 @@ const OpenGraphIntegrationSchema = section('Open Graph / oEmbed 兜底', { val === '' || val === null || val === undefined ? val : Number(val), z.number().int().min(40).max(100).optional().default(75), ), - 'WebP 质量', - { description: '默认 75,区间 40-100' }, + 'WebP quality', + { description: 'Default 75; range 40-100' }, ), }).optional(), }) -export const ThirdPartyServiceIntegrationSchema = section('第三方服务集成', { - github: GitHubIntegrationSchema.optional(), - tmdb: TmdbIntegrationSchema.optional(), - bangumi: BangumiIntegrationSchema.optional(), - neodb: NeoDBIntegrationSchema.optional(), - arxiv: ArxivIntegrationSchema.optional(), - leetcode: LeetcodeIntegrationSchema.optional(), - neteaseMusic: NeteaseMusicIntegrationSchema.optional(), - qqMusic: QQMusicIntegrationSchema.optional(), - openGraph: OpenGraphIntegrationSchema.optional(), -}) +export const ThirdPartyServiceIntegrationSchema = section( + 'Third-party integrations', + { + github: GitHubIntegrationSchema.optional(), + tmdb: TmdbIntegrationSchema.optional(), + bangumi: BangumiIntegrationSchema.optional(), + neodb: NeoDBIntegrationSchema.optional(), + arxiv: ArxivIntegrationSchema.optional(), + leetcode: LeetcodeIntegrationSchema.optional(), + neteaseMusic: NeteaseMusicIntegrationSchema.optional(), + qqMusic: QQMusicIntegrationSchema.optional(), + openGraph: OpenGraphIntegrationSchema.optional(), + }, +) export class ThirdPartyServiceIntegrationDto extends createZodDto( ThirdPartyServiceIntegrationSchema, ) {} @@ -547,12 +630,16 @@ export type ThirdPartyServiceIntegrationConfig = z.infer< // ==================== Auth Security ==================== export const AuthSecuritySchema = section( - '认证安全设置', + 'Auth security', { - disablePasswordLogin: field.toggle(z.boolean().optional(), '禁用密码登录', { - description: - '禁用密码登录,只能通过 PassKey or Oauth 登录,如果没有配置这些请不要开启', - }), + disablePasswordLogin: field.toggle( + z.boolean().optional(), + 'Disable password login', + { + description: + 'Disables password login, allowing sign-in only via Passkey or OAuth. Do not enable unless those methods are configured.', + }, + ), }, { 'ui:options': { type: 'hidden' } }, ) @@ -563,70 +650,78 @@ export type AuthSecurityConfig = z.infer const AIProviderConfigSchema = withMeta( z.object({ id: field.plain(z.string().min(1), 'Provider ID', { - description: '唯一标识符,如 "openai-main", "deepseek"', + description: 'Unique identifier, e.g. "openai-main", "deepseek"', }), - name: field.plain(z.string().min(1), '显示名称'), - type: field.plain(z.enum(AIProviderType), 'Provider 类型', { + name: field.plain(z.string().min(1), 'Display name'), + type: field.plain(z.enum(AIProviderType), 'Provider type', { description: 'openai | openai-compatible | anthropic | openrouter', }), apiKey: field.password(z.string().min(1), 'API Key'), - endpoint: field.plain(z.string().optional(), '自定义 Endpoint', { - description: 'OpenAI 兼容服务必填,如 https://api.deepseek.com', + endpoint: field.plain(z.string().optional(), 'Custom endpoint', { + description: + 'Required for OpenAI-compatible services, e.g. https://api.deepseek.com', }), - defaultModel: field.plain(z.string().min(1), '默认模型', { - description: '如 gpt-4o, deepseek-chat, claude-sonnet-4-20250514', + defaultModel: field.plain(z.string().min(1), 'Default model', { + description: 'E.g. gpt-4o, deepseek-chat, claude-sonnet-4-20250514', }), - enabled: field.toggle(z.boolean(), '启用'), + enabled: field.toggle(z.boolean(), 'Enabled'), }), - { title: 'AI Provider 配置', 'ui:options': { type: 'hidden' } }, + { title: 'AI provider configuration', 'ui:options': { type: 'hidden' } }, ) const AIModelAssignmentSchema = withMeta( z.object({ providerId: field.plain(z.string().optional(), 'Provider ID', { - description: '指向 providers 中某个 provider 的 id', + description: 'References the id of a provider in `providers`', }), - model: field.plain(z.string().optional(), '模型覆盖', { - description: '覆盖 provider 的默认模型,留空使用 provider 默认值', + model: field.plain(z.string().optional(), 'Model override', { + description: + "Overrides the provider's default model; leave empty to use the provider default", }), }), - { title: 'AI 模型分配', 'ui:options': { type: 'hidden' } }, + { title: 'AI model assignment', 'ui:options': { type: 'hidden' } }, ) -export const AISchema = section('AI 设定', { +export const AISchema = section('AI settings', { providers: field.array( z.array(AIProviderConfigSchema).optional(), - 'AI Providers', - { description: '配置多个 AI 服务提供商' }, + 'AI providers', + { description: 'Configure multiple AI service providers' }, + ), + summaryModel: field.plain( + AIModelAssignmentSchema.optional(), + 'Summary model', + ), + writerModel: field.plain( + AIModelAssignmentSchema.optional(), + 'Writing assistant model', ), - summaryModel: field.plain(AIModelAssignmentSchema.optional(), '摘要功能模型'), - writerModel: field.plain(AIModelAssignmentSchema.optional(), '写作助手模型'), commentReviewModel: field.plain( AIModelAssignmentSchema.optional(), - '评论审核模型', + 'Comment review model', ), - enableSummary: field.toggle(z.boolean().optional(), '可调用 AI 摘要', { - description: '是否开启调用 AI 去生成摘要', + enableSummary: field.toggle(z.boolean().optional(), 'Allow AI summary', { + description: 'Whether to allow calling AI to generate summaries', }), enableAutoGenerateSummaryOnCreate: field.toggle( z.boolean().optional(), - '文章创建时自动生成摘要', - { description: '需同时启用 enableSummary' }, + 'Auto-generate summary on article creation', + { description: 'Requires enableSummary to also be enabled' }, ), enableAutoGenerateSummaryOnUpdate: field.toggle( z.boolean().optional(), - '文章更新时重新生成摘要', + 'Regenerate summary on article update', { description: - '仅在源文本 hash 变化的语言重新生成;需同时启用 enableSummary', + 'Regenerates only for languages whose source-text hash has changed; requires enableSummary to also be enabled', }, ), summaryTargetLanguages: field.array( z.array(z.string()).optional(), - 'AI 摘要目标语言列表', + 'AI summary target languages', { description: - '自动生成摘要的目标语言列表,使用 [ISO 639-1 语言代码](https://www.w3schools.com/tags/ref_language_codes.asp),如 ["zh", "en", "ja"]', + 'Target languages for auto-generated summaries, using [ISO 639-1 language codes](https://www.w3schools.com/tags/ref_language_codes.asp), e.g. ["zh", "en", "ja"]', }, ), summaryMinTextLength: field.number( @@ -635,70 +730,81 @@ export const AISchema = section('AI 设定', { val === '' || val === null || val === undefined ? val : Number(val), z.number().int().min(0).optional(), ), - '摘要自动生成最小文本长度', + 'Minimum text length for summary auto-generation', { description: - '正文字符数低于此值时跳过自动钩子(OnCreate/OnUpdate),仅影响自动触发;0 表示不限。默认 100', + 'Skips automatic hooks (OnCreate/OnUpdate) when the body has fewer characters than this; only affects automatic triggers. 0 means no limit. Default 100', }, ), translationModel: field.plain( AIModelAssignmentSchema.optional(), - '翻译功能模型', + 'Translation model', + ), + enableTranslation: field.toggle( + z.boolean().optional(), + 'Allow AI translation', + { + description: 'Whether to allow calling AI to generate translations', + }, ), - enableTranslation: field.toggle(z.boolean().optional(), '可调用 AI 翻译', { - description: '是否开启调用 AI 去生成翻译', - }), enableAutoGenerateTranslation: field.toggle( z.boolean().optional(), - '开启 AI 翻译自动生成', + 'Auto-generate AI translations', { description: - '此选项开启后,将会在文章发布后自动生成翻译,需要开启上面的选项,否则无效', + 'When enabled, translations are auto-generated after an article is published. Requires the option above to also be enabled, otherwise has no effect.', }, ), translationTargetLanguages: field.array( z.array(z.string()).optional(), - 'AI 翻译目标语言列表', + 'AI translation target languages', { description: - '自动生成翻译的目标语言列表,使用 [ISO 639-1 语言代码](https://www.w3schools.com/tags/ref_language_codes.asp),如 ["en", "ja", "ko"]', + 'Target languages for auto-generated translations, using [ISO 639-1 language codes](https://www.w3schools.com/tags/ref_language_codes.asp), e.g. ["en", "ja", "ko"]', }, ), insightsModel: field.plain( AIModelAssignmentSchema.optional(), - 'Insights 精读模型', + 'Insights model', { - description: '用于生成 Insights 精读的 AI 模型', + description: 'AI model used to generate Insights', }, ), insightsTranslationModel: field.plain( AIModelAssignmentSchema.optional(), - 'Insights 翻译模型', - { description: '用于翻译 Insights 的 AI 模型,留空则复用翻译模型' }, + 'Insights translation model', + { + description: + 'AI model used to translate Insights; falls back to the translation model when empty', + }, ), - enableInsights: field.toggle(z.boolean().optional(), '可调用 AI Insights', { - description: '总开关', + enableInsights: field.toggle(z.boolean().optional(), 'Allow AI Insights', { + description: 'Master switch', }), enableAutoGenerateInsightsOnCreate: field.toggle( z.boolean().optional(), - '文章创建时自动生成 Insights', - { description: '需同时启用 enableInsights' }, + 'Auto-generate Insights on article creation', + { description: 'Requires enableInsights to also be enabled' }, ), enableAutoGenerateInsightsOnUpdate: field.toggle( z.boolean().optional(), - '文章更新时重新生成 Insights', - { description: '仅在源文本 hash 变化时触发' }, + 'Regenerate Insights on article update', + { description: 'Triggers only when the source-text hash changes' }, ), enableAutoTranslateInsights: field.toggle( z.boolean().optional(), - 'Insights 生成后自动翻译', - { description: '按 insightsTargetLanguages 派发翻译任务' }, + 'Auto-translate Insights after generation', + { + description: + 'Dispatches translation tasks based on insightsTargetLanguages', + }, ), insightsTargetLanguages: field.array( z.array(z.string()).optional(), - 'Insights 目标语言列表', + 'Insights target languages', { - description: 'ISO 639-1 列表;源语言自动排除', + description: + 'ISO 639-1 list; the source language is automatically excluded', }, ), insightsMinTextLength: field.number( @@ -707,10 +813,10 @@ export const AISchema = section('AI 设定', { val === '' || val === null || val === undefined ? val : Number(val), z.number().int().min(0).optional(), ), - 'Insights 自动生成最小文本长度', + 'Minimum text length for Insights auto-generation', { description: - '正文字符数低于此值时跳过自动钩子(OnCreate/OnUpdate),仅影响自动触发;0 表示不限。默认 300', + 'Skips automatic hooks (OnCreate/OnUpdate) when the body has fewer characters than this; only affects automatic triggers. 0 means no limit. Default 300', }, ), }) @@ -786,8 +892,9 @@ export const FullConfigSchema = withMeta( oauth: OAuthSchema, }), { - title: '设置', - description: '* 敏感字段不显示,后端默认不返回敏感字段,显示为空', + title: 'Settings', + description: + '* Sensitive fields are hidden; the backend does not return them by default, so they appear empty', }, ) diff --git a/apps/core/src/modules/configs/configs.service.ts b/apps/core/src/modules/configs/configs.service.ts index 8533734235b..648a07566a5 100644 --- a/apps/core/src/modules/configs/configs.service.ts +++ b/apps/core/src/modules/configs/configs.service.ts @@ -3,10 +3,9 @@ import { Injectable, Logger } from '@nestjs/common' import { cloneDeep, merge, mergeWith } from 'es-toolkit/compat' import type { z, ZodError } from 'zod' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { BusinessEvents, EventScope } from '~/constants/business-event.constant' import { RedisKeys } from '~/constants/cache.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { EventBusEvents } from '~/constants/event-bus.constant' import type { AIProviderConfig } from '~/modules/ai/ai.types' import { EventManagerService } from '~/processors/helper/helper.event.service' @@ -39,9 +38,11 @@ const aggregateConfigKeys = new Set([ /* * NOTE: - * 1. 读配置在 Redis 中,getConfig 为收口,获取配置都从 Redis 拿,初始化之后入到 Redis, - * 2. 对于加密的字段,在 Redis 的缓存中应该也是加密的。 - * 3. 何时解密,在 Node 中消费时,即 getConfig 时统一解密。 + * 1. Configs live in Redis. `getConfig` is the single entry point; all reads + * come from Redis, and the initial values are loaded into Redis on startup. + * 2. Encrypted fields stay encrypted in the Redis cache as well. + * 3. Decryption happens at the point of consumption in Node — i.e. uniformly + * inside `getConfig`. */ @Injectable() export class ConfigsService implements OnModuleInit { @@ -60,7 +61,7 @@ export class ConfigsService implements OnModuleInit { async onModuleInit() { await this.ensureConfigInitialized() - this.logger.log('Config 已经加载完毕!') + this.logger.log('Config loaded successfully') } private async getRedisClient() { @@ -192,7 +193,7 @@ export class ConfigsService implements OnModuleInit { return config[key] } - // Config 在此收口 + // Single entry point for config reads public async getConfig(errorRetryCount = 3): Promise> { await this.ensureConfigInitialized() @@ -208,7 +209,7 @@ export class ConfigsService implements OnModuleInit { if (errorRetryCount > 0) { return await this.getConfig(errorRetryCount - 1) } - this.logger.error('获取配置失败') + this.logger.error('Failed to load config') throw error } } else { @@ -245,11 +246,11 @@ export class ConfigsService implements OnModuleInit { const updatedConfigRow = await this.optionsRepository.upsert( key as string, mergeWith(cloneDeep(config[key]), data, (old, newer) => { - // 数组不合并 + // Arrays are not merged if (Array.isArray(old)) { return newer } - // 对象合并 + // Objects are merged if (typeof old === 'object' && typeof newer === 'object') { return { ...old, ...newer } } @@ -307,14 +308,16 @@ export class ConfigsService implements OnModuleInit { const dto = configDtoMapping[key] if (!dto) { - throw new BizException(ErrorCodeEnum.ConfigNotFound) + throw createAppException(AppErrorCode.CONFIG_NOT_FOUND, { + id: key as string, + }) } - // 如果是评论设置,并且尝试启用 AI 审核,就检查 AI 配置 + // If this is the comment settings and AI review is being enabled, validate the AI config if (key === 'commentOptions' && (value as any).aiReview === true) { const aiConfig = await this.get('ai') const hasEnabledProvider = aiConfig.providers?.some((p) => p.enabled) if (!hasEnabledProvider) { - throw new BizException(ErrorCodeEnum.AIProviderNotEnabled) + throw createAppException(AppErrorCode.AI_PROVIDER_DISABLED) } } const instanceValue = this.validWithDto(dto, value) as Partial @@ -421,28 +424,29 @@ export class ConfigsService implements OnModuleInit { const errors: string[] = [] if (mailOptions.provider === 'resend') { - // Resend 验证: from 和 apiKey 必填 + // Resend validation: `from` and `apiKey` are required if (!mailOptions.from) { - errors.push('mailOptions.from: 发件邮箱地址不能为空') + errors.push('mailOptions.from: sender email address must not be empty') } if (!mailOptions.resend?.apiKey) { - errors.push('mailOptions.resend.apiKey: Resend API Key 不能为空') + errors.push( + 'mailOptions.resend.apiKey: Resend API key must not be empty', + ) } } else if ( - mailOptions.provider === 'smtp' && // SMTP 验证: 至少需要 user 或 from + mailOptions.provider === 'smtp' && // SMTP validation: at least one of `user` or `from` is required !mailOptions.smtp?.user && !mailOptions.from ) { errors.push( - 'mailOptions.smtp.user 或 mailOptions.from: 至少需要填写一个发件人', + 'mailOptions.smtp.user or mailOptions.from: at least one sender must be provided', ) } if (errors.length > 0) { - throw new BizException( - ErrorCodeEnum.ConfigValidationFailed, - errors.join('; '), - ) + throw createAppException(AppErrorCode.CONFIG_VALIDATION_FAILED, { + message: errors.join('; '), + }) } } @@ -454,10 +458,9 @@ export class ConfigsService implements OnModuleInit { const path = err.path.join('.') return path ? `${path}: ${err.message}` : err.message }) - throw new BizException( - ErrorCodeEnum.ConfigValidationFailed, - errorMessages.join('; '), - ) + throw createAppException(AppErrorCode.CONFIG_VALIDATION_FAILED, { + message: errorMessages.join('; '), + }) } return result.data } diff --git a/apps/core/src/modules/cron-task/cron-business.service.ts b/apps/core/src/modules/cron-task/cron-business.service.ts index 9a584c8a28b..8d9b830cd57 100644 --- a/apps/core/src/modules/cron-task/cron-business.service.ts +++ b/apps/core/src/modules/cron-task/cron-business.service.ts @@ -18,10 +18,10 @@ import { RedisService } from '~/processors/redis/redis.service' import { getRedisKey } from '~/utils/redis.util' /** - * CronBusinessService - Cron 任务业务逻辑层 + * CronBusinessService - Business logic layer for cron tasks. * - * 本服务仅保留业务方法的实现,供 CronTaskService 调用 - * 调度逻辑在 CronTaskScheduler 中 + * This service only holds business method implementations invoked by + * CronTaskService. Scheduling logic lives in CronTaskScheduler. */ @Injectable() export class CronBusinessService { @@ -42,7 +42,7 @@ export class CronBusinessService { } /** - * 清理 7 天前的访问记录 + * Clean up access records older than 7 days. */ async cleanAccessRecord() { const cleanDate = dayjs().add(-7, 'd') @@ -51,22 +51,22 @@ export class CronBusinessService { cleanDate.toDate(), ) - this.logger.log('--> 清理访问记录成功') + this.logger.log('--> Access records cleaned up successfully') return { deletedCount } } /** - * 每天凌晨删除 IP 访问缓存 + * Reset the IP access cache every midnight. */ async resetIPAccess() { await this.redisService.getClient().del(getRedisKey(RedisKeys.AccessIp)) - this.logger.log('--> 清理 IP 访问记录成功') + this.logger.log('--> IP access records cleaned up successfully') return { success: true } } /** - * 每天凌晨删除喜欢/阅读记录缓存 + * Reset the like / read article cache every midnight. */ async resetLikedOrReadArticleRecord() { const redis = this.redisService.getClient() @@ -79,22 +79,22 @@ export class CronBusinessService { ).flat() await Promise.all(allKeys.map((key) => redis.del(key))) - this.logger.log('--> 清理喜欢数成功') + this.logger.log('--> Like counts cleaned up successfully') return { success: true } } /** - * 清理临时文件目录 + * Clean up the temporary file directory. */ async cleanTempDirectory() { await rm(TEMP_DIR, { recursive: true }) mkdirp.sync(STATIC_FILE_TRASH_DIR) - this.logger.log('--> 清理临时文件成功') + this.logger.log('--> Temporary files cleaned up successfully') return { success: true } } /** - * 推送站点地图到百度搜索 + * Push the sitemap to Baidu Search. */ async pushToBaiduSearch() { const { @@ -107,7 +107,7 @@ export class CronBusinessService { } const token = configs.token if (!token) { - this.logger.error('[BaiduSearchPushTask] token 为空') + this.logger.error('[BaiduSearchPushTask] token is empty') return { skipped: true, reason: 'token is empty' } } @@ -120,16 +120,18 @@ export class CronBusinessService { urls, { headers: { 'Content-Type': 'text/plain' } }, ) - this.logger.log(`百度站长提交结果:${JSON.stringify(res.data)}`) + this.logger.log( + `Baidu Webmaster submission result: ${JSON.stringify(res.data)}`, + ) return { response: res.data } } catch (error) { - this.logger.error(`百度推送错误:${error.message}`) + this.logger.error(`Baidu push error: ${error.message}`) throw error } } /** - * 推送站点地图到 Bing 搜索 + * Push the sitemap to Bing Search. */ async pushToBingSearch() { const { @@ -142,7 +144,7 @@ export class CronBusinessService { } const apiKey = configs.token if (!apiKey) { - this.logger.error('[BingSearchPushTask] API key 为空') + this.logger.error('[BingSearchPushTask] API key is empty') return { skipped: true, reason: 'API key is empty' } } @@ -164,22 +166,24 @@ export class CronBusinessService { }, ) if (res?.data?.d === null) { - this.logger.log('Bing 站长提交成功') + this.logger.log('Bing Webmaster submission succeeded') } else { - this.logger.log(`Bing 站长提交结果:${JSON.stringify(res.data)}`) + this.logger.log( + `Bing Webmaster submission result: ${JSON.stringify(res.data)}`, + ) } return { response: res.data } } catch (error) { - this.logger.error(`Bing 推送错误:${error.message}`) + this.logger.error(`Bing push error: ${error.message}`) throw error } } /** - * 扫表删除过期的 JWT + * Scan the store and delete expired JWTs. */ async deleteExpiredJWT() { - this.logger.log('--> 开始扫表,清除过期的 token') + this.logger.log('--> Scanning the store to purge expired tokens') const redis = this.redisService.getClient() const storeKey = getRedisKey(RedisKeys.JWTStore) const keys = await redis.hkeys(storeKey) @@ -197,39 +201,41 @@ export class CronBusinessService { } this.logger.debug( - `--> 删除过期的 token:${key}, 签发于 ${date.format('YYYY-MM-DD H:mm:ss')}`, + `--> Deleting expired token: ${key}, issued at ${date.format('YYYY-MM-DD H:mm:ss')}`, ) await redis.hdel(storeKey, key) deleteCount += 1 }), ) - this.logger.log(`--> 删除了 ${deleteCount} 个过期的 token`) + this.logger.log(`--> Deleted ${deleteCount} expired tokens`) return { deletedCount: deleteCount } } /** - * 清理评论图片上传:pending 超 TTL + detached 超 TTL 双 pass。 + * Clean up comment image uploads: a two-pass sweep that removes both + * pending uploads past TTL and detached uploads past TTL. */ async cleanCommentUploads() { - this.logger.log('--> 开始清理评论图片上传') + this.logger.log('--> Starting cleanup of comment image uploads') const result = await this.fileReferenceService.cleanupCommentUploads() this.logger.log( - `--> 清理评论图片上传完成 pending=${result.pendingDeleted} detached=${result.detachedDeleted}`, + `--> Comment image upload cleanup finished pending=${result.pendingDeleted} detached=${result.detachedDeleted}`, ) return result } /** - * 重建搜索索引(默认增量;通过 source_hash 跳过未变文档) + * Rebuild the search index (incremental by default; documents whose + * source_hash is unchanged are skipped). */ async rebuildSearchIndex() { - this.logger.log('--> 开始重建搜索索引(增量)') + this.logger.log('--> Starting search index rebuild (incremental)') const result = await this.searchService.rebuildSearchDocuments({ force: false, }) this.logger.log( - `--> 搜索索引重建完成 total=${result.total} created=${result.created} updated=${result.updated} deleted=${result.deleted} skipped=${result.skipped}`, + `--> Search index rebuild finished total=${result.total} created=${result.created} updated=${result.updated} deleted=${result.deleted} skipped=${result.skipped}`, ) return result } diff --git a/apps/core/src/modules/cron-task/cron-task.controller.ts b/apps/core/src/modules/cron-task/cron-task.controller.ts index 2a659fb23df..f459ca1069d 100644 --- a/apps/core/src/modules/cron-task/cron-task.controller.ts +++ b/apps/core/src/modules/cron-task/cron-task.controller.ts @@ -1,13 +1,13 @@ import { Get, Param, Post } from '@nestjs/common' + import { BaseTaskController } from '~/common/controllers/base-task.controller' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' -import { HTTPDecorators } from '~/common/decorators/http.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import type { ScopedTaskService } from '~/processors/task-queue' import { StringIdDto } from '~/shared/dto/id.dto' import { isString } from '~/utils/validator.util' + import { CronTaskService } from './cron-task.service' import { CronTaskType, type CronTaskTypeValue } from './cron-task.types' @@ -17,7 +17,6 @@ export class CronDefinitionController { constructor(private readonly cronTaskService: CronTaskService) {} @Get('/') - @HTTPDecorators.Bypass async getCronDefinitions() { return this.cronTaskService.getCronDefinitions() } @@ -25,15 +24,14 @@ export class CronDefinitionController { @Post('/run/:type') async runCronTask(@Param('type') type: string) { if (!isString(type)) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'type must be string', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'type must be string', + }) } const validTypes = Object.values(CronTaskType) as string[] if (!validTypes.includes(type)) { - throw new BizException(ErrorCodeEnum.CronNotFound, type) + throw createAppException(AppErrorCode.CRON_NOT_FOUND, { extra: type }) } return this.cronTaskService.createCronTask(type as CronTaskTypeValue) diff --git a/apps/core/src/modules/cron-task/cron-task.service.ts b/apps/core/src/modules/cron-task/cron-task.service.ts index 4395f7e77fc..31ccdfdfd8e 100644 --- a/apps/core/src/modules/cron-task/cron-task.service.ts +++ b/apps/core/src/modules/cron-task/cron-task.service.ts @@ -1,8 +1,7 @@ import { Injectable, Logger, type OnModuleInit } from '@nestjs/common' import { SchedulerRegistry } from '@nestjs/schedule' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { ScopedTaskService, type TaskExecuteContext, @@ -67,21 +66,21 @@ export class CronTaskService implements OnModuleInit { throw new TypeError(`Cron method not found: ${String(methodName)}`) } - await context.appendLog('info', `开始执行: ${meta.description}`) - await context.updateProgress(0, '执行中...') + await context.appendLog('info', `Starting: ${meta.description}`) + await context.updateProgress(0, 'Running...') try { const result = await method.call(this.cronBusinessService) - await context.updateProgress(100, '完成') + await context.updateProgress(100, 'Completed') const hasResult = result !== undefined && result !== null await context.setResult(hasResult ? result : { success: true }) await context.appendLog( 'info', - hasResult ? `执行完成: ${JSON.stringify(result)}` : '执行完成', + hasResult ? `Finished: ${JSON.stringify(result)}` : 'Finished', ) } catch (error) { - await context.appendLog('error', `执行失败: ${error.message}`) + await context.appendLog('error', `Failed: ${error.message}`) throw error } } @@ -91,7 +90,7 @@ export class CronTaskService implements OnModuleInit { ): Promise<{ taskId: string; created: boolean }> { const meta = CronTaskMetas[type] if (!meta) { - throw new BizException(ErrorCodeEnum.CronNotFound, type) + throw createAppException(AppErrorCode.CRON_NOT_FOUND, { extra: type }) } return this.crud.createTask({ diff --git a/apps/core/src/modules/cron-task/cron-task.types.ts b/apps/core/src/modules/cron-task/cron-task.types.ts index abe3c9360ae..e48b6d1f401 100644 --- a/apps/core/src/modules/cron-task/cron-task.types.ts +++ b/apps/core/src/modules/cron-task/cron-task.types.ts @@ -26,55 +26,55 @@ export const CronTaskMetas: Record< > = { [CronTaskType.CleanAccessRecord]: { name: 'cleanAccessRecord', - description: '清理访问记录', + description: 'Clean up access records', cronExpression: 'EVERY_1ST_DAY_OF_MONTH_AT_MIDNIGHT', methodName: 'cleanAccessRecord', }, [CronTaskType.ResetIPAccess]: { name: 'resetIPAccess', - description: '清理 IP 访问记录', + description: 'Clean up IP access records', cronExpression: 'EVERY_DAY_AT_MIDNIGHT', methodName: 'resetIPAccess', }, [CronTaskType.ResetLikedOrReadArticleRecord]: { name: 'resetLikedOrReadArticleRecord', - description: '清理喜欢数', + description: 'Clean up like counts', cronExpression: 'EVERY_DAY_AT_MIDNIGHT', methodName: 'resetLikedOrReadArticleRecord', }, [CronTaskType.CleanTempDirectory]: { name: 'cleanTempDirectory', - description: '清理临时文件', + description: 'Clean up temporary files', cronExpression: 'EVERY_DAY_AT_3AM', methodName: 'cleanTempDirectory', }, [CronTaskType.PushToBaiduSearch]: { name: 'pushToBaiduSearch', - description: '推送到百度搜索', + description: 'Push to Baidu Search', cronExpression: 'EVERY_DAY_AT_1AM', methodName: 'pushToBaiduSearch', }, [CronTaskType.PushToBingSearch]: { name: 'pushToBingSearch', - description: '推送到 Bing', + description: 'Push to Bing', cronExpression: 'EVERY_DAY_AT_1AM', methodName: 'pushToBingSearch', }, [CronTaskType.DeleteExpiredJWT]: { name: 'deleteExpiredJWT', - description: '删除过期 JWT', + description: 'Delete expired JWTs', cronExpression: 'EVERY_DAY_AT_1AM', methodName: 'deleteExpiredJWT', }, [CronTaskType.RebuildSearchIndex]: { name: 'rebuildSearchIndex', - description: '重建搜索索引', + description: 'Rebuild search index', cronExpression: 'EVERY_DAY_AT_4AM', methodName: 'rebuildSearchIndex', }, [CronTaskType.CleanCommentUploads]: { name: 'cleanCommentUploads', - description: '清理评论图片上传', + description: 'Clean up comment image uploads', cronExpression: '*/15 * * * *', methodName: 'cleanCommentUploads', }, diff --git a/apps/core/src/modules/debug/debug.controller.ts b/apps/core/src/modules/debug/debug.controller.ts index 7b15932c04d..45afdbc7649 100644 --- a/apps/core/src/modules/debug/debug.controller.ts +++ b/apps/core/src/modules/debug/debug.controller.ts @@ -47,7 +47,7 @@ export class DebugController { } @Post('/function') - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse async runFunction( @Body('function') functionString: string, @Request() req, diff --git a/apps/core/src/modules/dependency/dependency.controller.ts b/apps/core/src/modules/dependency/dependency.controller.ts index 7e0c2ff2508..23d210e4ca7 100644 --- a/apps/core/src/modules/dependency/dependency.controller.ts +++ b/apps/core/src/modules/dependency/dependency.controller.ts @@ -8,8 +8,7 @@ import { Observable } from 'rxjs' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { DATA_DIR } from '~/constants/path.constant' import { installPKG } from '~/utils/system.util' @@ -17,7 +16,6 @@ import { installPKG } from '~/utils/system.util' @Auth() export class DependencyController { @Get('/graph') - @HTTPDecorators.Bypass async getDependencyGraph() { return { dependencies: @@ -28,14 +26,14 @@ export class DependencyController { } @Sse('/install_deps') + @HTTPDecorators.RawResponse async installDepsPty(@Query() query: any): Promise> { const { packageNames } = query if (typeof packageNames !== 'string') { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'packageNames must be string', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'packageNames must be string', + }) } const pty = await installPKG(packageNames.split(',').join(' '), DATA_DIR) @@ -48,7 +46,7 @@ export class DependencyController { if (exitCode !== 0) { subscriber.next(pc.red(`Error: Exit code: ${exitCode}\n`)) } - subscriber.next(pc.green('任务完成,可关闭此窗口。')) + subscriber.next(pc.green('Task complete. You may close this window.')) subscriber.complete() }) }) diff --git a/apps/core/src/modules/draft/draft.controller.ts b/apps/core/src/modules/draft/draft.controller.ts index 3f243033a2f..0bdabef939c 100644 --- a/apps/core/src/modules/draft/draft.controller.ts +++ b/apps/core/src/modules/draft/draft.controller.ts @@ -2,7 +2,9 @@ import { Body, Delete, Get, Param, Post, Put, Query } from '@nestjs/common' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' -import { CannotFindException } from '~/common/exceptions/cant-find.exception' +import { AppErrorCode, createAppException } from '~/common/errors' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' import { EntityIdDto } from '~/shared/dto/id.dto' import { DraftRefType } from './draft.enum' @@ -22,8 +24,8 @@ export class DraftController { @Post('/') @Auth() - async create(@Body() body: CreateDraftDto) { - return await this.draftService.create(body) + create(@Body() body: CreateDraftDto) { + return this.draftService.create(body) } @Get('/') @@ -41,30 +43,39 @@ export class DraftController { try { ;(d as any).typeSpecificData = JSON.parse(d.typeSpecificData) } catch { - // keep raw payload when not valid JSON + // keep raw payload } } return d }) - return { ...result, data } + return withMeta( + data, + new MetaObjectBuilder() + .view('card') + .pagination({ + page: result.pagination.currentPage, + size: result.pagination.size, + total: result.pagination.total, + totalPages: result.pagination.totalPage, + }) + .build(), + ) } @Get('/by-ref/:refType/new') @Auth() - async getNewDrafts(@Param() params: DraftRefTypeDto) { - return await this.draftService.findNewDrafts(params.refType as DraftRefType) + getNewDrafts(@Param() params: DraftRefTypeDto) { + return this.draftService.findNewDrafts(params.refType as DraftRefType) } @Get('/by-ref/:refType/:refId') @Auth() - async getByRef(@Param() params: DraftRefTypeAndIdDto) { - // 返回 null 表示没有关联草稿,这是正常情况,不是错误 - const draft = await this.draftService.findByRef( + getByRef(@Param() params: DraftRefTypeAndIdDto) { + return this.draftService.findByRef( params.refType as DraftRefType, params.refId, ) - return draft } @Get('/:id') @@ -72,15 +83,15 @@ export class DraftController { async getById(@Param() params: EntityIdDto) { const draft = await this.draftService.findById(params.id) if (!draft) { - throw new CannotFindException() + throw createAppException(AppErrorCode.DRAFT_NOT_FOUND, { id: params.id }) } return draft } @Put('/:id') @Auth() - async update(@Param() params: EntityIdDto, @Body() body: UpdateDraftDto) { - return await this.draftService.update(params.id, body) + update(@Param() params: EntityIdDto, @Body() body: UpdateDraftDto) { + return this.draftService.update(params.id, body) } @Delete('/:id') @@ -92,31 +103,25 @@ export class DraftController { @Get('/:id/history') @Auth() - async getHistory(@Param() params: EntityIdDto) { - return await this.draftService.getHistory(params.id) + getHistory(@Param() params: EntityIdDto) { + return this.draftService.getHistory(params.id) } @Get('/:id/history/:version') @Auth() - async getHistoryVersion( + getHistoryVersion( @Param() params: EntityIdDto, @Param() versionParams: RestoreVersionDto, ) { - return await this.draftService.getHistoryVersion( - params.id, - versionParams.version, - ) + return this.draftService.getHistoryVersion(params.id, versionParams.version) } @Post('/:id/restore/:version') @Auth() - async restore( + restore( @Param() params: EntityIdDto, @Param() versionParams: RestoreVersionDto, ) { - return await this.draftService.restoreVersion( - params.id, - versionParams.version, - ) + return this.draftService.restoreVersion(params.id, versionParams.version) } } diff --git a/apps/core/src/modules/draft/draft.schema.ts b/apps/core/src/modules/draft/draft.schema.ts index 5788f091e55..e1a3affe8f7 100644 --- a/apps/core/src/modules/draft/draft.schema.ts +++ b/apps/core/src/modules/draft/draft.schema.ts @@ -41,7 +41,6 @@ export class UpdateDraftDto extends createZodDto(UpdateDraftSchema) {} export const DraftPagerSchema = z.object({ size: zPaginationSize, page: zPaginationPage, - select: z.string().min(1).optional(), sortBy: z.string().optional(), sortOrder: zSortOrder, refType: z.enum(DraftRefType).optional(), diff --git a/apps/core/src/modules/draft/draft.service.ts b/apps/core/src/modules/draft/draft.service.ts index 7b7092c37b2..7c322847ccb 100644 --- a/apps/core/src/modules/draft/draft.service.ts +++ b/apps/core/src/modules/draft/draft.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { FileReferenceType } from '~/modules/file/file-reference.enum' import { FileReferenceService } from '~/modules/file/file-reference.service' import { ContentFormat } from '~/shared/types/content-format.type' @@ -62,7 +61,7 @@ export class DraftService { async update(id: string, dto: UpdateDraftDto): Promise { const draft = await this.draftRepository.findById(id) - if (!draft) throw new BizException(ErrorCodeEnum.DraftNotFound) + if (!draft) throw createAppException(AppErrorCode.DRAFT_NOT_FOUND, { id }) const hasContentChange = this.draftHistoryService.hasContentChange( { @@ -99,7 +98,7 @@ export class DraftService { version: hasContentChange ? draft.version + 1 : draft.version, history, }) - if (!updated) throw new BizException(ErrorCodeEnum.DraftNotFound) + if (!updated) throw createAppException(AppErrorCode.DRAFT_NOT_FOUND, { id }) if (dto.text !== undefined) { await this.fileReferenceService.updateReferencesForDocument( @@ -141,7 +140,7 @@ export class DraftService { async delete(id: string): Promise { const result = await this.draftRepository.deleteById(id) - if (!result) throw new BizException(ErrorCodeEnum.DraftNotFound) + if (!result) throw createAppException(AppErrorCode.DRAFT_NOT_FOUND, { id }) await this.fileReferenceService.removeReferencesForDocument( id, FileReferenceType.Draft, @@ -160,7 +159,7 @@ export class DraftService { async getHistory(id: string) { const draft = await this.draftRepository.findById(id) - if (!draft) throw new BizException(ErrorCodeEnum.DraftNotFound) + if (!draft) throw createAppException(AppErrorCode.DRAFT_NOT_FOUND, { id }) return this.draftHistoryService.getHistorySummary(draft.history as any) } @@ -169,10 +168,10 @@ export class DraftService { version: number, ): Promise<{ draft: DraftRow; resolved: DraftHistoryModel }> { const draft = await this.draftRepository.findById(id) - if (!draft) throw new BizException(ErrorCodeEnum.DraftNotFound) + if (!draft) throw createAppException(AppErrorCode.DRAFT_NOT_FOUND, { id }) const historyEntry = draft.history.find((h) => h.version === version) if (!historyEntry) - throw new BizException(ErrorCodeEnum.DraftHistoryNotFound) + throw createAppException(AppErrorCode.DRAFT_HISTORY_NOT_FOUND) const resolved = this.draftHistoryService.resolveHistoryEntry( historyEntry as any, draft.history as any, diff --git a/apps/core/src/modules/draft/draft.views.ts b/apps/core/src/modules/draft/draft.views.ts new file mode 100644 index 00000000000..809231bda05 --- /dev/null +++ b/apps/core/src/modules/draft/draft.views.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' + +const DraftCardSchema = z + .object({ + id: z.string(), + title: z.string(), + refType: z.string(), + version: z.number(), + createdAt: z.date().or(z.string()), + }) + .passthrough() + +const DraftDetailSchema = z.object({}).passthrough() + +export const DraftViews = { + card: DraftCardSchema, + detail: DraftDetailSchema, +} as const + +export type DraftView = keyof typeof DraftViews diff --git a/apps/core/src/modules/enrichment/enrichment.controller.ts b/apps/core/src/modules/enrichment/enrichment.controller.ts index 16354ce8e41..d1e29e24203 100644 --- a/apps/core/src/modules/enrichment/enrichment.controller.ts +++ b/apps/core/src/modules/enrichment/enrichment.controller.ts @@ -1,16 +1,13 @@ import { Body, - ConflictException, Delete, Get, HttpCode, - NotFoundException, Param, Post, Query, Req, Res, - UnprocessableEntityException, UseGuards, } from '@nestjs/common' import { Throttle } from '@nestjs/throttler' @@ -19,6 +16,9 @@ import type { FastifyRequest } from 'fastify' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { Lang } from '~/common/decorators/lang.decorator' +import { AppErrorCode, createAppException } from '~/common/errors' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' import { ConfigsService } from '~/modules/configs/configs.service' import { EnrichmentRepository } from './enrichment.repository' @@ -65,17 +65,11 @@ export class EnrichmentController { lang, ) if (stale) { - // Fastify reply uses `header(name, value)`; the legacy - // `setHeader` shim is not exposed when accessed through Nest's - // `@Res({ passthrough: true })` adapter object. res.header('X-Enrichment-Stale', 'true') } this.bumpCaptureAccess(result) - return result + return result as EnrichmentResult } catch (error) { - // Provider not configured / token missing is a "no data" case, not an - // error: return 204 so frontends can render the URL as a plain link - // without per-card 500s polluting logs. if ( error instanceof ProviderDisabledError || error instanceof TokenMissingError @@ -101,11 +95,6 @@ export class EnrichmentController { return result } - /** - * Fire-and-forget LRU touch. The throttle (Redis NX-EX 3600s) lives inside - * the storage service, so hot URLs do not write per-request. Failure is - * swallowed to keep the hot path free of capture-storage faults. - */ private bumpCaptureAccess(result: EnrichmentResult | undefined): void { if (!result?.captureImage || !result.id) return this.captureStorage.touchAccess(result.id).catch(() => { @@ -116,16 +105,20 @@ export class EnrichmentController { @Get('admin/list') @Auth() async list(@Query() query: AdminListQueryDto) { - return this.enrichmentService.list(query.page, query.size, { + const result = await this.enrichmentService.list(query.page, query.size, { onlyFailed: query.onlyFailed, locale: query.locale, }) + return withMeta( + result.data, + new MetaObjectBuilder().pagination(result.pagination).build(), + ) } @Post('admin/refresh/:provider/*') @Auth() @HttpCode(200) - async refresh( + refresh( @Param('provider') provider: string, @Req() req: FastifyRequest, @Query('lang') lang?: string, @@ -148,7 +141,7 @@ export class EnrichmentController { @Get('admin/providers') @Auth() - async providers(): Promise { + providers(): Promise { return this.enrichmentService.getProviders() } @@ -156,9 +149,13 @@ export class EnrichmentController { @Auth() async byId(@Param('id') id: string) { const row = await this.enrichmentRepository.findById(id) - if (!row) throw new NotFoundException(`Enrichment ${id} not found`) + if (!row) + throw createAppException(AppErrorCode.ENRICHMENT_NOT_FOUND, { id }) const capture = await this.captureRepository.findByEnrichmentId(id) - return { ...row, capture } + return { + ...row, + capture, + } } @Get('admin/captures/quota') @@ -190,13 +187,16 @@ export class EnrichmentController { query.sort, query.order, ) - const data = await Promise.all( + const items = await Promise.all( result.data.map(async (row) => ({ ...row, publicUrl: await this.resolvePublicUrl(row.objectKey), })), ) - return { data, pagination: result.pagination } + return withMeta( + items, + new MetaObjectBuilder().pagination(result.pagination).build(), + ) } @Delete('admin/captures/:enrichmentId') @@ -217,21 +217,17 @@ export class EnrichmentController { ): Promise { const row = await this.enrichmentRepository.findById(enrichmentId) if (!row) - throw new NotFoundException(`Enrichment ${enrichmentId} not found`) + throw createAppException(AppErrorCode.ENRICHMENT_NOT_FOUND, { + id: enrichmentId, + }) const config = await this.configsService.get('thirdPartyServiceIntegration') const openGraph = config?.openGraph if (openGraph?.fetchMode !== 'browser') { - throw new ConflictException({ - code: 'browser_mode_required', - message: 'OpenGraph fetchMode must be `browser` to recapture', - }) + throw createAppException(AppErrorCode.ENRICHMENT_BROWSER_MODE_REQUIRED) } if (openGraph.screenshot?.enabled !== true) { - throw new ConflictException({ - code: 'screenshot_disabled', - message: 'openGraph.screenshot.enabled is false', - }) + throw createAppException(AppErrorCode.ENRICHMENT_SCREENSHOT_DISABLED) } await this.enrichmentService.refresh( @@ -246,10 +242,7 @@ export class EnrichmentController { const fresh = await this.enrichmentRepository.findById(enrichmentId) const captureImage = fresh?.normalized.captureImage if (!captureImage) { - throw new UnprocessableEntityException({ - code: 'capture_failed', - message: 'Capture was not produced by the refresh', - }) + throw createAppException(AppErrorCode.ENRICHMENT_CAPTURE_FAILED) } return captureImage } @@ -258,7 +251,7 @@ export class EnrichmentController { @Auth() @Throttle(ADMIN_PROBE_THROTTLE) @HttpCode(200) - async probe(@Body() body: AdminProbeBodyDto) { + probe(@Body() body: AdminProbeBodyDto) { return this.enrichmentService.probe(body.url, body.useCache === true) } diff --git a/apps/core/src/modules/enrichment/providers/netease/netease-music.provider.ts b/apps/core/src/modules/enrichment/providers/netease/netease-music.provider.ts index caad95001bd..e06a51b7e4b 100644 --- a/apps/core/src/modules/enrichment/providers/netease/netease-music.provider.ts +++ b/apps/core/src/modules/enrichment/providers/netease/netease-music.provider.ts @@ -8,7 +8,7 @@ import type { EnrichmentProvider } from '../provider.interface' @Injectable() export class NeteaseMusicProvider implements EnrichmentProvider { readonly name = 'netease-music' - readonly displayName = '网易云音乐' + readonly displayName = 'NetEase Cloud Music' readonly category = ENRICHMENT_CATEGORIES.MUSIC readonly priority = 10 readonly defaultTtl = 86400 diff --git a/apps/core/src/modules/enrichment/providers/qq/qq-music.provider.ts b/apps/core/src/modules/enrichment/providers/qq/qq-music.provider.ts index 3499cd5f14d..87216118f48 100644 --- a/apps/core/src/modules/enrichment/providers/qq/qq-music.provider.ts +++ b/apps/core/src/modules/enrichment/providers/qq/qq-music.provider.ts @@ -7,7 +7,7 @@ import type { EnrichmentProvider } from '../provider.interface' @Injectable() export class QQMusicProvider implements EnrichmentProvider { readonly name = 'qq-music' - readonly displayName = 'QQ音乐' + readonly displayName = 'QQ Music' readonly category = ENRICHMENT_CATEGORIES.MUSIC readonly priority = 9 readonly defaultTtl = 86400 @@ -28,7 +28,7 @@ export class QQMusicProvider implements EnrichmentProvider { async fetch(id: string): Promise { return { title: `QQ Music: ${id}`, - description: 'QQ音乐歌曲', + description: 'QQ Music song', url: `https://y.qq.com/n/ryqq/songDetail/${id}`, category: this.category, subtype: 'song', diff --git a/apps/core/src/modules/feed/feed.controller.ts b/apps/core/src/modules/feed/feed.controller.ts index ae30f625821..8231ec161aa 100644 --- a/apps/core/src/modules/feed/feed.controller.ts +++ b/apps/core/src/modules/feed/feed.controller.ts @@ -26,7 +26,7 @@ export class FeedController { @Get(['/feed', '/atom.xml']) @CacheKey(CacheKeys.RSSXml) @CacheTTL(3600) - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse @Header('content-type', 'application/xml') async rss() { const { author, data, url, description } = @@ -40,19 +40,19 @@ export class FeedController { const renderResult = await this.markdownService.renderArticle(item.id) const description = isLexical - ? '富文本内容,请前往原站查看' + ? 'Rich-text content; please visit the original site to view.' : escapeXml( xss(RemoveMarkdown(renderResult.document.text).slice(0, 50)), ) const contentEncoded = isLexical - ? `

前往原站查看:${xss(item.link)}

` - : `
该渲染由 marked 生成,可能存在排版问题,最佳体验请前往:${xss(item.link)}
${renderResult.html}

- 看完了?说点什么呢 + Finished reading? Leave a comment

` return ` diff --git a/apps/core/src/modules/file/comment-upload.controller.ts b/apps/core/src/modules/file/comment-upload.controller.ts index 8b0a61cf706..670a1f6948a 100644 --- a/apps/core/src/modules/file/comment-upload.controller.ts +++ b/apps/core/src/modules/file/comment-upload.controller.ts @@ -3,8 +3,7 @@ import type { FastifyRequest } from 'fastify' import { ApiController } from '~/common/decorators/api-controller.decorator' import { ReaderAuth } from '~/common/decorators/reader-auth.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import type { FastifyBizRequest } from '~/transformers/get-req.transformer' import { CommentUploadService } from './comment-upload.service' @@ -26,7 +25,7 @@ export class CommentUploadController { const bizReq = req as FastifyBizRequest const readerId = bizReq.readerId || bizReq.user?.id if (!readerId) { - throw new BizException(ErrorCodeEnum.AuthNotLoggedIn) + throw createAppException(AppErrorCode.FILE_UPLOAD_NOT_AUTHORIZED) } return this.commentUploadService.uploadForReader(req, readerId) } diff --git a/apps/core/src/modules/file/comment-upload.service.ts b/apps/core/src/modules/file/comment-upload.service.ts index df72e5870e6..f994ff36cfc 100644 --- a/apps/core/src/modules/file/comment-upload.service.ts +++ b/apps/core/src/modules/file/comment-upload.service.ts @@ -4,8 +4,7 @@ import { Injectable, Logger } from '@nestjs/common' import type { FastifyRequest } from 'fastify' import { fileTypeFromBuffer } from 'file-type' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { ConfigsService } from '~/modules/configs/configs.service' import { UploadService } from '~/processors/helper/helper.upload.service' import { @@ -90,7 +89,7 @@ export class CommentUploadService { ): Promise { const rawConfig = await this.configsService.get('commentUploadOptions') if (rawConfig.enable === false) { - throw new BizException(ErrorCodeEnum.CommentUploadDisabled) + throw createAppException(AppErrorCode.COMMENT_UPLOAD_DISABLED) } const { @@ -110,18 +109,18 @@ export class CommentUploadService { chunks.push(chunk) totalBytes += chunk.length if (totalBytes > maxFileSize) { - throw new BizException(ErrorCodeEnum.CommentUploadFileTooLarge) + throw createAppException(AppErrorCode.COMMENT_UPLOAD_FILE_TOO_LARGE) } } const buffer = Buffer.concat(chunks) if (file.file.truncated) { - throw new BizException(ErrorCodeEnum.CommentUploadFileTooLarge) + throw createAppException(AppErrorCode.COMMENT_UPLOAD_FILE_TOO_LARGE) } const detected = await detectImageMime(buffer) if (!detected || !whitelist.includes(detected.mime)) { - throw new BizException(ErrorCodeEnum.CommentUploadInvalidMime) + throw createAppException(AppErrorCode.COMMENT_UPLOAD_INVALID_MIME) } const detectedMime = detected.mime diff --git a/apps/core/src/modules/file/file-reference.service.ts b/apps/core/src/modules/file/file-reference.service.ts index 336b8b7f7d5..337511cedf7 100644 --- a/apps/core/src/modules/file/file-reference.service.ts +++ b/apps/core/src/modules/file/file-reference.service.ts @@ -3,8 +3,7 @@ import path from 'node:path' import { Injectable, Logger } from '@nestjs/common' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { STATIC_FILE_DIR } from '~/constants/path.constant' import { ConfigsService } from '~/modules/configs/configs.service' import type { ContentFormat } from '~/shared/types/content-format.type' @@ -48,7 +47,8 @@ export class FileReferenceService { } /** - * 删除本地图片文件。若磁盘上已不存在(ENOENT),视为已清理成功。 + * Delete a local image file. If it already does not exist on disk (ENOENT), + * treat the cleanup as successful. */ private async unlinkLocalImage(fileName: string): Promise { const localPath = path.join(STATIC_FILE_DIR, 'image', fileName) @@ -137,7 +137,8 @@ export class FileReferenceService { } /** - * 解析评论 markdown 中之图片 URL,仅返回属本站 host 之 URL。 + * Extract image URLs from comment markdown, keeping only URLs whose host + * belongs to this site. */ parseCommentImageUrls(text: string, allowedHosts: string[]): string[] { if (!text) return [] @@ -165,7 +166,7 @@ export class FileReferenceService { } /** - * 收集本站允许之图片 host:webUrl/serverUrl/customDomain。 + * Collect the image hosts allowed for this site: webUrl, serverUrl, and customDomain. */ async collectAllowedImageHosts(): Promise { const [{ webUrl, serverUrl }, imageStorageConfig] = await Promise.all([ @@ -189,7 +190,7 @@ export class FileReferenceService { } /** - * 计算评论 update 时之 attach/detach/revive 三类文件。 + * Compute the attach / detach / revive file sets for a comment update. */ diffReaderImages( refs: FileReferenceRow[], @@ -239,7 +240,7 @@ export class FileReferenceService { } /** - * 评论 create / update 时之 attach。 + * Attach images on comment create / update. */ async attachReaderImagesToComment(params: { commentId: string @@ -256,7 +257,7 @@ export class FileReferenceService { const newUrls = this.parseCommentImageUrls(text, hosts) if (newUrls.length > cap) { - throw new BizException(ErrorCodeEnum.CommentImageCapExceeded) + throw createAppException(AppErrorCode.COMMENT_IMAGE_CAP_EXCEEDED) } if (newUrls.length === 0 && mode === 'create') { @@ -268,7 +269,7 @@ export class FileReferenceService { for (const ref of candidates) { if (ref.uploadedBy !== FileUploadedBy.Reader) continue if (ref.readerId && ref.readerId !== readerId) { - throw new BizException(ErrorCodeEnum.CommentUploadFileNotOwned) + throw createAppException(AppErrorCode.COMMENT_UPLOAD_FILE_NOT_OWNED) } } @@ -280,7 +281,7 @@ export class FileReferenceService { ) { continue } - throw new BizException(ErrorCodeEnum.CommentUploadFileAlreadyBound) + throw createAppException(AppErrorCode.COMMENT_UPLOAD_FILE_ALREADY_BOUND) } const existingForComment = @@ -332,7 +333,8 @@ export class FileReferenceService { } /** - * 硬删之核心:删 storage 对象 → 删 record。删除审计仅落 stdout 结构化日志。 + * Core hard-delete flow: remove the storage object first, then delete the + * record. Deletion audits are emitted only as structured logs to stdout. */ async hardDeleteFile( file: FileReferenceRow, @@ -385,7 +387,7 @@ export class FileReferenceService { } /** - * 级联清除某评论挂之全部文件(reader uploads)。 + * Cascade-delete every file (reader uploads) attached to a comment. */ async hardDeleteFilesForComment( commentId: string, @@ -426,7 +428,8 @@ export class FileReferenceService { } /** - * 评论上传专用清扫:pending TTL + detached TTL 双 pass。 + * Cleanup pass dedicated to comment uploads: two passes over pending TTL + * and detached TTL. */ async cleanupCommentUploads(): Promise<{ pendingDeleted: number @@ -469,7 +472,8 @@ export class FileReferenceService { } /** - * Owner 路径之既有清扫:仅扫 uploadedBy != Reader 之 pending 文件。 + * Existing cleanup pass for the owner path: scans only pending files where + * uploadedBy != Reader. */ async cleanupOrphanFiles(maxAgeMinutes = 60) { const cutoffTime = new Date(Date.now() - maxAgeMinutes * 60 * 1000) diff --git a/apps/core/src/modules/file/file.controller.ts b/apps/core/src/modules/file/file.controller.ts index f50286a33ba..89e81b628fd 100644 --- a/apps/core/src/modules/file/file.controller.ts +++ b/apps/core/src/modules/file/file.controller.ts @@ -20,13 +20,13 @@ import { lookup } from 'mime-types' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { CannotFindException } from '~/common/exceptions/cant-find.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' import { STATIC_FILE_DIR } from '~/constants/path.constant' import { ConfigsService } from '~/modules/configs/configs.service' import { UploadService } from '~/processors/helper/helper.upload.service' -import { PagerDto } from '~/shared/dto/pager.dto' +import { BasicPagerDto } from '~/shared/dto/pager.dto' import { generateFilename, generateFilePath, @@ -36,12 +36,12 @@ import { S3Uploader } from '~/utils/s3.util' import { BatchOrphanDeleteDto, + CommentUploadsListQueryDto, FileQueryDto, FileUploadDto, RenameFileQueryDto, } from './file.schema' import { FileService } from './file.service' -import { FileReferenceStatus } from './file-reference.enum' import { FileReferenceService } from './file-reference.service' import { FileDeletionReason } from './file-reference.types' @@ -62,13 +62,13 @@ export class FileController { @Get('/orphans/list') @Auth() - async getOrphanFiles(@Query() query: PagerDto) { + async getOrphanFiles(@Query() query: BasicPagerDto) { const { page = 1, size = 20 } = query const { data: files, pagination } = await this.fileReferenceService.listOrphanFiles(page, size) - return { - data: files.map((file) => ({ + return withMeta( + files.map((file) => ({ id: file.id, fileName: file.fileName, fileUrl: file.fileUrl, @@ -82,8 +82,15 @@ export class FileController { detachedAt: file.detachedAt, createdAt: file.createdAt, })), - pagination, - } + new MetaObjectBuilder() + .pagination({ + page: pagination.currentPage, + size: pagination.size, + total: pagination.total, + totalPages: pagination.totalPage, + }) + .build(), + ) } @Get('/orphans/count') @@ -101,15 +108,8 @@ export class FileController { @Get('/comment-uploads/list') @Auth() - async getCommentUploads( - @Query() - query: PagerDto & { - status?: FileReferenceStatus - readerId?: string - refId?: string - }, - ) { - const { page = 1, size = 24, status, readerId, refId } = query + async getCommentUploads(@Query() query: CommentUploadsListQueryDto) { + const { page, size, status, readerId, refId } = query const { files, total } = await this.fileReferenceService.listReaderUploads({ page, size, @@ -118,8 +118,8 @@ export class FileController { refId, }) - return { - data: files.map((file) => ({ + return withMeta( + files.map((file) => ({ id: file.id, fileName: file.fileName, fileUrl: file.fileUrl, @@ -132,15 +132,15 @@ export class FileController { detachedAt: file.detachedAt, createdAt: file.createdAt, })), - pagination: { - currentPage: page, - totalPage: Math.ceil(total / size), - size, - total, - hasNextPage: page * size < total, - hasPrevPage: page > 1, - }, - } + new MetaObjectBuilder() + .pagination({ + page, + size, + total, + totalPages: Math.ceil(total / size), + }) + .build(), + ) } @Delete('/comment-uploads/:id') @@ -148,7 +148,7 @@ export class FileController { async deleteCommentUpload(@Param('id') id: string) { const file = await this.fileReferenceService.getReferenceById(id) if (!file) { - throw new CannotFindException() + throw createAppException(AppErrorCode.FILE_NOT_FOUND, { name: id }) } const { storageRemoved } = await this.fileReferenceService.hardDeleteFile( file, @@ -159,9 +159,11 @@ export class FileController { @Get('/:type') @Auth() - async getTypes(@Query() query: PagerDto, @Param() params: FileUploadDto) { + async getTypes( + @Query() query: BasicPagerDto, + @Param() params: FileUploadDto, + ) { const { type = 'file' } = params - // const { page, size } = query const dir = await this.service.getDir(type) const files = await Promise.all( dir.map(async (name) => { @@ -185,7 +187,7 @@ export class FileController { ttl: 60_000, }, }) - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse async get(@Param() params: FileQueryDto, @Res() reply: FastifyReply) { const { type, name } = params const ext = path.extname(name) @@ -204,7 +206,7 @@ export class FileController { return reply.send(stream) } catch { - throw new CannotFindException() + throw createAppException(AppErrorCode.FILE_NOT_FOUND, { name }) } } @@ -213,7 +215,6 @@ export class FileController { async upload(@Query() query: FileUploadDto, @Req() req: FastifyRequest) { const { type = 'file' } = query - // 获取文件上传配置 const uploadConfig = await this.configsService.get('fileUploadOptions') const imageStorageConfig = type === 'image' @@ -228,20 +229,18 @@ export class FileController { !config.secretKey || !config.bucket ) { - throw new BizException(ErrorCodeEnum.ImageStorageNotConfigured) + throw createAppException(AppErrorCode.FILE_STORAGE_NOT_CONFIGURED) } const file = await this.uploadService.getAndValidMultipartField(req, { maxFileSize: 20 * 1024 * 1024, }) - // 生成文件名(支持模板或默认随机名) const filename = generateFilename(uploadConfig, { originalFilename: file.filename, fileType: type, }) - // 处理 prefix 中的模板变量 let prefixPath = '' if (config.prefix) { prefixPath = replaceFilenameTemplate(config.prefix, { @@ -295,19 +294,16 @@ export class FileController { : undefined, ) - // 生成文件名(可能包含子路径) const rawFilename = generateFilename(uploadConfig, { originalFilename: file.filename, fileType: type, }) - // 生成基础路径 const basePath = generateFilePath(uploadConfig, { originalFilename: file.filename, fileType: type, }) - // 构建相对路径(相对于文件类型目录) let relativePath: string if (basePath === type || !basePath) { relativePath = rawFilename @@ -354,7 +350,7 @@ export class FileController { @Query() query: RenameFileQueryDto, ) { const { type, name } = params - const { new_name } = query - await this.service.renameFile(type, name, new_name) + const { newName } = query + await this.service.renameFile(type, name, newName) } } diff --git a/apps/core/src/modules/file/file.schema.ts b/apps/core/src/modules/file/file.schema.ts index 48f2803dd6d..6756ddf5ee9 100644 --- a/apps/core/src/modules/file/file.schema.ts +++ b/apps/core/src/modules/file/file.schema.ts @@ -1,7 +1,10 @@ import { createZodDto } from 'nestjs-zod' import { z } from 'zod' +import { BasicPagerSchema } from '~/shared/dto/pager.dto' + import { FileTypeEnum } from './file.type' +import { FileReferenceStatus } from './file-reference.enum' /** * File query schema @@ -26,7 +29,7 @@ export class FileUploadDto extends createZodDto(FileUploadSchema) {} * Rename file query schema */ export const RenameFileQuerySchema = z.object({ - new_name: z.string(), + newName: z.string(), }) export class RenameFileQueryDto extends createZodDto(RenameFileQuerySchema) {} @@ -47,8 +50,29 @@ export class BatchOrphanDeleteDto extends createZodDto( BatchOrphanDeleteSchema, ) {} +/** + * Comment uploads list query schema (pagination + filters) + * + * Without an explicit DTO, raw @Query() values arrive as strings; the + * controller then echoes them into withMeta(...).pagination(...) which is + * validated by ResponseMetaSchema (numeric page/size). Wiring this DTO + * coerces inputs so admin's flat ?page=1&size=24 calls succeed. + */ +export const CommentUploadsListQuerySchema = BasicPagerSchema.extend({ + status: z.enum(FileReferenceStatus).optional(), + readerId: z.string().optional(), + refId: z.string().optional(), +}) + +export class CommentUploadsListQueryDto extends createZodDto( + CommentUploadsListQuerySchema, +) {} + // Type exports export type FileQueryInput = z.infer export type FileUploadInput = z.infer export type RenameFileQueryInput = z.infer export type BatchOrphanDeleteInput = z.infer +export type CommentUploadsListQueryInput = z.infer< + typeof CommentUploadsListQuerySchema +> diff --git a/apps/core/src/modules/file/file.service.ts b/apps/core/src/modules/file/file.service.ts index 73dce407b21..11fe4859422 100644 --- a/apps/core/src/modules/file/file.service.ts +++ b/apps/core/src/modules/file/file.service.ts @@ -16,8 +16,7 @@ import { Logger, } from '@nestjs/common' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { STATIC_FILE_DIR, STATIC_FILE_TRASH_DIR, @@ -37,7 +36,9 @@ export class FileService { const base = path.resolve(STATIC_FILE_DIR, type) const resolved = path.resolve(base, name) if (!resolved.startsWith(base + path.sep) && resolved !== base) { - throw new BizException(ErrorCodeEnum.InvalidParameter) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'invalid file path', + }) } return resolved } @@ -55,7 +56,7 @@ export class FileService { const filePath = this.resolveFilePath(type, name) const exists = await this.checkIsExist(filePath) if (!exists) { - throw new BizException(ErrorCodeEnum.FileNotFound) + throw createAppException(AppErrorCode.FILE_NOT_FOUND, { name }) } return createReadStream(filePath) } @@ -70,7 +71,7 @@ export class FileService { return new Promise(async (resolve, reject) => { const filePath = this.resolveFilePath(type, name) if (await this.checkIsExist(filePath)) { - reject(new BizException(ErrorCodeEnum.FileExists)) + reject(createAppException(AppErrorCode.FILE_EXISTS)) return } await mkdir(path.dirname(filePath), { recursive: true }) @@ -100,7 +101,7 @@ export class FileService { return new Promise(async (resolve, reject) => { const filePath = this.resolveFilePath(type, name) if (!(await this.checkIsExist(filePath))) { - reject(new BizException(ErrorCodeEnum.FileNotFound)) + reject(createAppException(AppErrorCode.FILE_NOT_FOUND, { name })) return } const writable = createWriteStream(filePath, { encoding }) @@ -134,13 +135,17 @@ export class FileService { } } catch (error) { if (error?.code === 'ENOENT') { - // 幂等:源文件不存在就视为已删除 - this.logger.warn(`删除文件:源文件不存在,跳过 (${type}/${name})`) + // Idempotent: if the source file is missing, treat it as already deleted. + this.logger.warn( + `Delete file: source missing, skipping (${type}/${name})`, + ) return } - this.logger.error('删除文件失败', error) + this.logger.error('Failed to delete file', error) - throw new InternalServerErrorException(`删除文件失败,${error.message}`) + throw new InternalServerErrorException( + `Failed to delete file: ${error.message}`, + ) } } @@ -161,8 +166,8 @@ export class FileService { try { await rename(oldPath, newPath) } catch (error) { - this.logger.error('重命名文件失败', error.message) - throw new BizException(ErrorCodeEnum.FileRenameFailed) + this.logger.error('Failed to rename file', error.message) + throw createAppException(AppErrorCode.FILE_RENAME_FAILED) } } } diff --git a/apps/core/src/modules/file/reader-upload-quota.interceptor.ts b/apps/core/src/modules/file/reader-upload-quota.interceptor.ts index 927994ee59d..0b1e085d109 100644 --- a/apps/core/src/modules/file/reader-upload-quota.interceptor.ts +++ b/apps/core/src/modules/file/reader-upload-quota.interceptor.ts @@ -5,8 +5,7 @@ import type { } from '@nestjs/common' import { Injectable, Logger } from '@nestjs/common' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { CommentRepository } from '~/modules/comment/comment.repository' import { ConfigsService } from '~/modules/configs/configs.service' import { ReaderRepository } from '~/modules/reader/reader.repository' @@ -30,7 +29,7 @@ export class ReaderUploadQuotaInterceptor implements NestInterceptor { const readerId = request.readerId || request.user?.id if (!readerId) { - throw new BizException(ErrorCodeEnum.AuthNotLoggedIn) + throw createAppException(AppErrorCode.AUTH_NOT_LOGGED_IN) } if (request.user?.role === 'owner') { @@ -47,7 +46,7 @@ export class ReaderUploadQuotaInterceptor implements NestInterceptor { !createdAt || Date.now() - createdAt.getTime() < minAccountAgeHours * 60 * 60 * 1000 ) { - throw new BizException(ErrorCodeEnum.CommentUploadAccountTooNew) + throw createAppException(AppErrorCode.COMMENT_UPLOAD_ACCOUNT_TOO_NEW) } } @@ -55,7 +54,9 @@ export class ReaderUploadQuotaInterceptor implements NestInterceptor { if (minCommentCount > 0) { const count = await this.commentRepository.countActiveByReader(readerId) if (count < minCommentCount) { - throw new BizException(ErrorCodeEnum.CommentUploadInsufficientComments) + throw createAppException( + AppErrorCode.COMMENT_UPLOAD_INSUFFICIENT_COMMENTS, + ) } } @@ -67,7 +68,7 @@ export class ReaderUploadQuotaInterceptor implements NestInterceptor { since, ) if (count >= hourlyLimit) { - throw new BizException(ErrorCodeEnum.CommentUploadRateLimited) + throw createAppException(AppErrorCode.COMMENT_UPLOAD_RATE_LIMITED) } } @@ -76,7 +77,7 @@ export class ReaderUploadQuotaInterceptor implements NestInterceptor { const totalBytes = await this.fileReferenceService.sumReaderActiveBytes(readerId) if (totalBytes >= totalBytesLimitMB * 1024 * 1024) { - throw new BizException(ErrorCodeEnum.CommentUploadQuotaExceeded) + throw createAppException(AppErrorCode.COMMENT_UPLOAD_QUOTA_EXCEEDED) } } diff --git a/apps/core/src/modules/health/health.controller.ts b/apps/core/src/modules/health/health.controller.ts index b6df876400a..538dec294a9 100644 --- a/apps/core/src/modules/health/health.controller.ts +++ b/apps/core/src/modules/health/health.controller.ts @@ -3,7 +3,7 @@ import { Get } from '@nestjs/common' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HttpCache } from '~/common/decorators/cache.decorator' -import { HTTPDecorators } from '~/common/decorators/http.decorator' +import { OK_DATA } from '~/common/response/envelope.types' import { EmailService } from '~/processors/helper/helper.email.service' @ApiController('health') @@ -11,10 +11,9 @@ export class HealthController { constructor(private readonly emailService: EmailService) {} @Get('/') - @HTTPDecorators.Bypass @HttpCache({ disable: true }) async check() { - return 'OK' + return OK_DATA } @Get('/email/test') diff --git a/apps/core/src/modules/helper/helper.controller.ts b/apps/core/src/modules/helper/helper.controller.ts index 29be1b1e8db..24c146e7042 100644 --- a/apps/core/src/modules/helper/helper.controller.ts +++ b/apps/core/src/modules/helper/helper.controller.ts @@ -4,9 +4,9 @@ import type { FastifyReply } from 'fastify' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' -import { BizException } from '~/common/exceptions/biz.exception' +import { HTTPDecorators } from '~/common/decorators/http.decorator' +import { AppErrorCode, createAppException } from '~/common/errors' import { CollectionRefTypes } from '~/constants/db.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { DatabaseService } from '~/processors/database/database.service' import { ImageService } from '~/processors/helper/helper.image.service' import { UrlBuilderService } from '~/processors/helper/helper.url-builder.service' @@ -23,24 +23,22 @@ export class HelperController { constructor( private readonly urlBulderService: UrlBuilderService, private readonly databaseService: DatabaseService, - private readonly moduleRef: ModuleRef, ) {} @Get('/url-builder/:id') + @HTTPDecorators.RawResponse async builderById( @Param() params: EntityIdDto, @Query('redirect') redirect: boolean, - @Res() res: FastifyReply, ) { const doc = await this.databaseService.findGlobalById(params.id) if (!doc || doc.type === CollectionRefTypes.Recently) { if (redirect) { - throw new BizException( - ErrorCodeEnum.DocumentNotFound, - 'not found or this type can not redirect to', - ) + throw createAppException(AppErrorCode.HELPER_DOCUMENT_NOT_FOUND, { + id: params.id, + }) } res.send(null) diff --git a/apps/core/src/modules/init/init.controller.ts b/apps/core/src/modules/init/init.controller.ts index cb596a5e671..7ae475a9e76 100644 --- a/apps/core/src/modules/init/init.controller.ts +++ b/apps/core/src/modules/init/init.controller.ts @@ -2,8 +2,7 @@ import { Body, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common' import type { FastifyRequest } from 'fastify' import { ApiController } from '~/common/decorators/api-controller.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { UploadService } from '~/processors/helper/helper.upload.service' import { isZipMinetype } from '~/utils/mine.util' @@ -19,18 +18,17 @@ import { InitService } from './init.service' export class InitController { constructor( private readonly configs: ConfigsService, - private readonly initService: InitService, - private readonly backupService: BackupService, private readonly uploadService: UploadService, ) {} - private async assertNotInitialized( - code: ErrorCodeEnum = ErrorCodeEnum.InitAlreadyCompleted, - ) { + private async assertNotInitialized(forbiddenMode = false) { if (await this.initService.isInit()) { - throw new BizException(code) + if (forbiddenMode) { + throw createAppException(AppErrorCode.INIT_FORBIDDEN) + } + throw createAppException(AppErrorCode.INIT_ALREADY_COMPLETED) } } @@ -43,7 +41,7 @@ export class InitController { @Get('/configs/default') async getDefaultConfig() { - await this.assertNotInitialized(ErrorCodeEnum.InitForbidden) + await this.assertNotInitialized(true) return this.configs.defaultConfig } @@ -54,7 +52,7 @@ export class InitController { ) { await this.assertNotInitialized() if (typeof body !== 'object') { - throw new BizException(ErrorCodeEnum.InvalidBody) + throw createAppException(AppErrorCode.INIT_INVALID_BODY) } return this.configs.patchAndValid(params.key, body) } @@ -72,7 +70,9 @@ export class InitController { }) const { mimetype } = data if (!isZipMinetype(mimetype)) { - throw new BizException(ErrorCodeEnum.MineZip, `got: ${mimetype}`) + throw createAppException(AppErrorCode.INIT_INVALID_MIME_TYPE, { + got: mimetype, + }) } await this.backupService.saveTempBackupByUpload(await data.toBuffer()) diff --git a/apps/core/src/modules/link/link-avatar.service.ts b/apps/core/src/modules/link/link-avatar.service.ts index 1971fc17fbe..71ded0931b6 100644 --- a/apps/core/src/modules/link/link-avatar.service.ts +++ b/apps/core/src/modules/link/link-avatar.service.ts @@ -4,8 +4,7 @@ import { URL } from 'node:url' import { Injectable, Logger } from '@nestjs/common' import { customAlphabet } from 'nanoid' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { alphabet } from '~/constants/other.constant' import { HttpService } from '~/processors/helper/helper.http.service' import { validateImageBuffer } from '~/utils/image.util' @@ -54,7 +53,7 @@ export class LinkAvatarService { typeof link === 'string' ? await this.linkRepository.findById(link) : link if (!doc) { if (typeof link === 'string') { - throw new BizException(ErrorCodeEnum.LinkNotFound) + throw createAppException(AppErrorCode.LINK_NOT_FOUND, { id: link }) } return false } @@ -80,7 +79,7 @@ export class LinkAvatarService { } } catch (error: any) { this.logger.warn( - `解析友链 ${doc.id} 的站点地址失败: ${error?.message || String(error)}`, + `Failed to parse the site URL for friend link ${doc.id}: ${error?.message || String(error)}`, ) } return webUrl @@ -106,7 +105,7 @@ export class LinkAvatarService { !this.isAllowedMimeType(normalizedContentType) ) { this.logger.warn( - `友链 ${doc.id} 头像响应类型 ${contentType || 'unknown'} 不在受支持图片范围,跳过内链转换`, + `Friend link ${doc.id} avatar response type ${contentType || 'unknown'} is not a supported image format; skipping internalization`, ) return false } @@ -119,10 +118,9 @@ export class LinkAvatarService { }) if (!validation.ok) { - throw new BizException( - ErrorCodeEnum.LinkAvatarValidationFailed, - validation.reason, - ) + throw createAppException(AppErrorCode.LINK_AVATAR_VALIDATION_FAILED, { + reason: validation.reason, + }) } const { ext } = validation @@ -142,7 +140,7 @@ export class LinkAvatarService { await this.linkRepository.updateAvatar(doc.id, internalUrl) - this.logger.log(`友链 ${doc.id} 头像已转换为内部链接`) + this.logger.log(`Friend link ${doc.id} avatar has been internalized`) return true } @@ -169,7 +167,7 @@ export class LinkAvatarService { } } catch (error: any) { this.logger.error( - `迁移友链头像失败: ${link.id} - ${error?.message || String(error)}`, + `Failed to migrate friend link avatar: ${link.id} - ${error?.message || String(error)}`, ) } } diff --git a/apps/core/src/modules/link/link.controller.ts b/apps/core/src/modules/link/link.controller.ts index 71b85d478ba..750a419ee9a 100644 --- a/apps/core/src/modules/link/link.controller.ts +++ b/apps/core/src/modules/link/link.controller.ts @@ -4,15 +4,15 @@ import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' import { HasAdminAccess } from '~/common/decorators/role.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' +import { OK_DATA, withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' import { EntityIdDto } from '~/shared/dto/id.dto' -import { PagerDto } from '~/shared/dto/pager.dto' import { BasePgCrudFactory } from '~/transformers/crud-factor.pg.transformer' import { scheduleManager } from '~/utils/schedule.util' import { LinkRepository } from './link.repository' -import { AuditReasonDto, LinkDto } from './link.schema' +import { AuditReasonDto, LinkDto, LinkPagerDto } from './link.schema' import { LinkService } from './link.service' import { LinkState } from './link.types' @@ -24,7 +24,7 @@ export class LinkControllerCrud extends BasePgCrudFactory({ }) { @Get('/') async gets( - @Query() pager: PagerDto, + @Query() pager: LinkPagerDto, @HasAdminAccess() hasAdminAccess: boolean, ) { const { size = 10, page = 1, state } = pager @@ -34,14 +34,19 @@ export class LinkControllerCrud extends BasePgCrudFactory({ if (!hasAdminAccess) { result.data = result.data.map((row) => ({ ...row, email: null })) } - return result + return withMeta( + result.data, + new MetaObjectBuilder().pagination(result.pagination).build(), + ) } @Get('/all') async getAll(@HasAdminAccess() hasAdminAccess: boolean) { const rows = await this.repository.findAvailable() - if (hasAdminAccess) return rows - return rows.map((row) => ({ ...row, email: null })) + const data = hasAdminAccess + ? rows + : rows.map((row) => ({ ...row, email: null })) + return data } } @@ -51,29 +56,30 @@ export class LinkController { @Get('/audit') async canApplyLink() { - return { can: await this.linkService.canApplyLink() } + const can = await this.linkService.canApplyLink() + return { can } } @Get('/state') @Auth() - async getLinkCount() { + getLinkCount() { return this.linkService.getCount() } - /** 申请友链 */ @Post('/audit') @HTTPDecorators.Idempotence({ expired: 20, - errorMessage: '哦吼,你已经提交过这个友链了', + errorMessage: 'Oh, you have already submitted this friend link', }) async applyForLink(@Body() body: LinkDto) { if (!(await this.linkService.canApplyLink())) { - throw new BizException(ErrorCodeEnum.LinkApplyDisabled) + throw createAppException(AppErrorCode.LINK_APPLY_DISABLED) } await this.linkService.applyForLink(body as any) scheduleManager.schedule(async () => { await this.linkService.sendToOwner(body.author, body as any) }) + return OK_DATA } @Patch('/audit/:id') @@ -98,18 +104,18 @@ export class LinkController { const { id } = params const { reason, state } = body await this.linkService.sendAuditResultByEmail(id, reason, state) + return OK_DATA } @Auth() @Get('/health') - async checkHealth() { + checkHealth() { return this.linkService.checkLinkHealth() } - /** 批量迁移已通过友链的外部头像为内部链接 */ @Post('/avatar/migrate') @Auth() - async migrateExternalAvatars() { + migrateExternalAvatars() { return this.linkService.migrateExternalAvatarsForPassedLinks() } } diff --git a/apps/core/src/modules/link/link.enum.ts b/apps/core/src/modules/link/link.enum.ts index 0fc9db4a311..c58387c454f 100644 --- a/apps/core/src/modules/link/link.enum.ts +++ b/apps/core/src/modules/link/link.enum.ts @@ -12,9 +12,9 @@ export enum LinkState { } export const LinkStateMap = { - [LinkState.Pass]: '已通过', - [LinkState.Audit]: '审核中', - [LinkState.Outdate]: '已过期', - [LinkState.Banned]: '已屏蔽', - [LinkState.Reject]: '已拒绝', + [LinkState.Pass]: 'Approved', + [LinkState.Audit]: 'Pending review', + [LinkState.Outdate]: 'Outdated', + [LinkState.Banned]: 'Banned', + [LinkState.Reject]: 'Rejected', } diff --git a/apps/core/src/modules/link/link.schema.ts b/apps/core/src/modules/link/link.schema.ts index 8ed8b96af16..526aa53a95d 100644 --- a/apps/core/src/modules/link/link.schema.ts +++ b/apps/core/src/modules/link/link.schema.ts @@ -2,6 +2,7 @@ import { createZodDto } from 'nestjs-zod' import { z } from 'zod' import { zEmail, zHttpsUrl, zMaxLengthString } from '~/common/zod' +import { BasicPagerSchema } from '~/shared/dto/pager.dto' import { LinkState, LinkType } from './link.enum' @@ -9,7 +10,7 @@ import { LinkState, LinkType } from './link.enum' * Link schema for API validation */ export const LinkSchema = z.object({ - name: zMaxLengthString(20, '标题太长了 www'), + name: zMaxLengthString(20, 'Title is too long'), url: zHttpsUrl, avatar: z .preprocess( @@ -17,13 +18,16 @@ export const LinkSchema = z.object({ z.string().url().max(200).nullable(), ) .optional(), - description: zMaxLengthString(50, '描述信息超过 50 会坏掉的!').optional(), + description: zMaxLengthString( + 50, + 'Description must not exceed 50 characters', + ).optional(), type: z.enum(LinkType).default(LinkType.Friend).optional(), state: z.enum(LinkState).default(LinkState.Pass).optional(), email: z .preprocess( (val) => (val === '' ? null : val), - zEmail('请输入正确的邮箱!').max(50).nullable(), + zEmail('Please enter a valid email address').max(50).nullable(), ) .optional(), }) @@ -34,7 +38,7 @@ export class LinkSchemaDto extends createZodDto(LinkSchema) {} * Link DTO with author field (for guest submissions) */ export const LinkWithAuthorSchema = LinkSchema.extend({ - author: zMaxLengthString(20, '乃的名字太长了'), + author: zMaxLengthString(20, 'Your name is too long'), }) export class LinkDto extends createZodDto(LinkWithAuthorSchema) {} @@ -50,12 +54,21 @@ export class PartialLinkDto extends createZodDto(PartialLinkSchema) {} * Audit reason schema */ export const AuditReasonSchema = z.object({ - reason: z.string().min(1, '请输入审核理由'), + reason: z.string().min(1, 'Please enter an audit reason'), state: z.enum(LinkState), }) export class AuditReasonDto extends createZodDto(AuditReasonSchema) {} +/** + * Link list pager — basic pager plus optional `state` filter. + */ +export const LinkPagerSchema = BasicPagerSchema.extend({ + state: z.coerce.number().int().optional(), +}) + +export class LinkPagerDto extends createZodDto(LinkPagerSchema) {} + // Type exports export type LinkInput = z.infer export type LinkWithAuthorInput = z.infer diff --git a/apps/core/src/modules/link/link.service.ts b/apps/core/src/modules/link/link.service.ts index f916d843569..c0ab1e3bcd2 100644 --- a/apps/core/src/modules/link/link.service.ts +++ b/apps/core/src/modules/link/link.service.ts @@ -2,9 +2,8 @@ import { URL } from 'node:url' import { Injectable, Logger } from '@nestjs/common' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { BusinessEvents, EventScope } from '~/constants/business-event.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { isDev } from '~/global/env.global' import { EmailService } from '~/processors/helper/helper.email.service' import { EventManagerService } from '~/processors/helper/helper.event.service' @@ -19,11 +18,11 @@ import { LinkAvatarService } from './link-avatar.service' import { LinkApplyEmailType } from './link-mail.enum' const LinkStateMap: Record = { - [LinkState.Pass]: '通过', - [LinkState.Audit]: '审核', - [LinkState.Outdate]: '过期', - [LinkState.Banned]: '禁用', - [LinkState.Reject]: '拒绝', + [LinkState.Pass]: 'Approved', + [LinkState.Audit]: 'Under review', + [LinkState.Outdate]: 'Outdated', + [LinkState.Banned]: 'Banned', + [LinkState.Reject]: 'Rejected', } @Injectable() @@ -76,10 +75,10 @@ export class LinkService { switch (existed.state) { case LinkState.Pass: case LinkState.Audit: { - throw new BizException(ErrorCodeEnum.DuplicateLink) + throw createAppException(AppErrorCode.DUPLICATE_LINK) } case LinkState.Banned: { - throw new BizException(ErrorCodeEnum.LinkDisabled) + throw createAppException(AppErrorCode.LINK_DISABLED) } case LinkState.Reject: case LinkState.Outdate: { @@ -94,7 +93,7 @@ export class LinkService { const url = new URL(input.url) const pathname = url.pathname if (pathname !== '/' && !allowSubPath) { - throw new BizException(ErrorCodeEnum.SubpathLinkDisabled) + throw createAppException(AppErrorCode.SUBPATH_LINK_DISABLED) } nextLink = await this.linkRepository.create({ name: input.name, @@ -117,7 +116,7 @@ export class LinkService { async approveLink(id: string) { const updated = await this.linkRepository.updateState(id, LinkState.Pass) if (!updated) { - throw new BizException(ErrorCodeEnum.LinkNotFound) + throw createAppException(AppErrorCode.LINK_NOT_FOUND, { id }) } const convertedAvatar = await this.linkAvatarService.convertToInternal(updated) @@ -146,10 +145,10 @@ export class LinkService { if (!enable || isDev) { console.info(` To: ${model.email} - 你的友链已通过 - 站点标题:${model.name} - 站点网站:${model.url} - 站点描述:${model.description}`) + Your friend link has been approved + Site title: ${model.name} + Site URL: ${model.url} + Site description: ${model.description}`) return } await this.sendLinkApplyEmail({ @@ -162,10 +161,10 @@ export class LinkService { async sendToOwner(authorName: string, model: LinkRow) { const { enable } = await this.configsService.get('mailOptions') if (!enable || isDev) { - console.info(`来自 ${authorName} 的友链请求: - 站点标题:${model.name} - 站点网站:${model.url} - 站点描述:${model.description}`) + console.info(`New friend link request from ${authorName}: + Site title: ${model.name} + Site URL: ${model.url} + Site description: ${model.description}`) return } scheduleManager.schedule(async () => { @@ -211,15 +210,15 @@ export class LinkService { const siteTitle = seo.title || 'Mx Space' const isToOwner = template === LinkApplyEmailType.ToOwner const subject = isToOwner - ? `[${siteTitle}] 新的朋友 ${authorName}` - : `嘿!~, 主人已通过你的友链申请!~` + ? `[${siteTitle}] New friend ${authorName}` + : `Hey! Your friend link application has been approved` const text = isToOwner - ? `来自 ${model.name} 的友链请求: - 站点标题:${model.name} - 站点网站:${model.url} - 站点描述:${model.description} + ? `New friend link request from ${model.name}: + Site title: ${model.name} + Site URL: ${model.url} + Site description: ${model.description} ` - : `你的友链申请:${model.name}, ${model.url} 已通过` + : `Your friend link application: ${model.name}, ${model.url} has been approved` await this.sendLinkMail(to, subject, text) } @@ -227,7 +226,7 @@ export class LinkService { const links = await this.linkRepository.findByState(LinkState.Pass) const results = await Promise.all( links.map(async ({ id, url }) => { - this.logger.debug(`检查友链 ${id} 的健康状态:GET -> ${url}`) + this.logger.debug(`Checking friend link ${id} health: GET -> ${url}`) try { const res = await this.http.axiosRef.get(url, { timeout: 5000, @@ -256,20 +255,20 @@ export class LinkService { async sendAuditResultByEmail(id: string, reason: string, state: LinkState) { const updated = await this.linkRepository.updateState(id, state) if (!updated) { - throw new BizException(ErrorCodeEnum.LinkNotFound) + throw createAppException(AppErrorCode.LINK_NOT_FOUND, { id }) } const { enable } = await this.configsService.get('mailOptions') if (!enable || isDev) { - console.info(`友链结果通知:${reason}, 状态:${state}`) + console.info(`Friend link audit result: ${reason}, state: ${state}`) return } if (!updated.email) return await this.sendLinkMail( updated.email, - `嘿!~, 主人已处理你的友链申请!~`, - `申请结果:${LinkStateMap[state]}\n原因:${reason}`, + `Hey! Your friend link application has been processed`, + `Result: ${LinkStateMap[state]}\nReason: ${reason}`, ) } diff --git a/apps/core/src/modules/markdown/markdown.controller.ts b/apps/core/src/modules/markdown/markdown.controller.ts index bc87209beb4..060effa6c59 100644 --- a/apps/core/src/modules/markdown/markdown.controller.ts +++ b/apps/core/src/modules/markdown/markdown.controller.ts @@ -36,10 +36,10 @@ export class MarkdownController { @Get('/export') @Auth() - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse @Header('Content-Type', 'application/zip') async exportArticleToMarkdown(@Query() query: ExportMarkdownQueryDto) { - const { show_title: showTitle, slug, yaml, with_meta_json } = query + const { showTitle, slug, yaml, withMetaJson } = query const allArticles = await this.service.extractAllArticle() const { notes, pages, posts } = allArticles @@ -127,7 +127,7 @@ export class MarkdownController { rtzip.file(join(key, relativePath), file.nodeStream()) }) - if (with_meta_json) { + if (withMetaJson) { rtzip.file( `${key}/_meta.json`, JSON.stringify( diff --git a/apps/core/src/modules/markdown/markdown.schema.ts b/apps/core/src/modules/markdown/markdown.schema.ts index 5bad4ef5b4b..b724ac657c4 100644 --- a/apps/core/src/modules/markdown/markdown.schema.ts +++ b/apps/core/src/modules/markdown/markdown.schema.ts @@ -1,8 +1,9 @@ -import { zCoerceBoolean } from '~/common/zod' -import { ArticleTypeEnum } from '~/constants/article.constant' import { createZodDto } from 'nestjs-zod' import { z } from 'zod' +import { zCoerceBoolean } from '~/common/zod' +import { ArticleTypeEnum } from '~/constants/article.constant' + /** * Meta schema */ @@ -51,8 +52,8 @@ export class DataListDto extends createZodDto(DataListSchema) {} export const ExportMarkdownQuerySchema = z.object({ yaml: zCoerceBoolean.optional(), slug: zCoerceBoolean.optional(), - show_title: zCoerceBoolean.optional(), - with_meta_json: zCoerceBoolean.optional(), + showTitle: zCoerceBoolean.optional(), + withMetaJson: zCoerceBoolean.optional(), }) export class ExportMarkdownQueryDto extends createZodDto( diff --git a/apps/core/src/modules/markdown/markdown.service.ts b/apps/core/src/modules/markdown/markdown.service.ts index c5c3e7222cb..a8a9f30ce01 100644 --- a/apps/core/src/modules/markdown/markdown.service.ts +++ b/apps/core/src/modules/markdown/markdown.service.ts @@ -7,9 +7,8 @@ import { omit } from 'es-toolkit/compat' import { dump } from 'js-yaml' import JSZip from 'jszip' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { CollectionRefTypes } from '~/constants/db.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { DatabaseService } from '~/processors/database/database.service' import { AssetService } from '~/processors/helper/helper.asset.service' import { ContentFormat } from '~/shared/types/content-format.type' @@ -70,12 +69,12 @@ export class MarkdownService { const models = [] as PostModel[] const defaultCategory = categoryNameAndId[0] if (!defaultCategory) { - throw new InternalServerErrorException('分类不存在') + throw new InternalServerErrorException('Category does not exist') } for (const item of data) { if (!item.meta) { models.push({ - title: `未命名-${count++}`, + title: `Untitled-${count++}`, slug: String(Date.now()), text: item.text, ...genDate(item), @@ -103,7 +102,7 @@ export class MarkdownService { } as any), ), ).catch(() => { - this.logger.warn('一篇文章导入失败') + this.logger.warn('Failed to import one post') }) } @@ -111,7 +110,7 @@ export class MarkdownService { const models = [] as NoteModel[] for (const item of data) { models.push({ - title: item.meta?.title ?? '未命名记录', + title: item.meta?.title ?? 'Untitled note', text: item.text, ...this.genDate(item), } as NoteModel) @@ -169,12 +168,11 @@ export class MarkdownService { const zip = new JSZip() for (const document of documents) { - zip.file( - (options.slug ? document.meta.slug : document.meta.title) - .concat('.md') - .replaceAll('/', '-'), - document.text, + // Notes set meta.slug to nid (a number) — coerce so .concat works. + const name = String( + options.slug ? document.meta.slug : document.meta.title, ) + zip.file(name.concat('.md').replaceAll('/', '-'), document.text) } return zip } @@ -209,7 +207,7 @@ ${text.trim()} } /** - * 根据文章 Id 渲染一篇文章 + * Render a single article by its ID. * @param id * @returns */ @@ -217,7 +215,7 @@ ${text.trim()} const result = await this.databaseService.findGlobalById(id) if (!result || result.type === CollectionRefTypes.Recently) - throw new BizException(ErrorCodeEnum.DocumentNotFound) + throw createAppException(AppErrorCode.DOCUMENT_NOT_FOUND, { id }) return { html: this.renderMarkdownContent(result.document.text), @@ -227,7 +225,7 @@ ${text.trim()} } /** - * 渲染 Markdown 文本输出 html + * Render Markdown text to HTML. * @param text * @returns */ diff --git a/apps/core/src/modules/meta-preset/meta-preset.controller.ts b/apps/core/src/modules/meta-preset/meta-preset.controller.ts index ad8a0773532..a7e961142bf 100644 --- a/apps/core/src/modules/meta-preset/meta-preset.controller.ts +++ b/apps/core/src/modules/meta-preset/meta-preset.controller.ts @@ -25,54 +25,35 @@ import { MetaPresetService } from './meta-preset.service' export class MetaPresetController { constructor(private readonly metaPresetService: MetaPresetService) {} - /** - * 获取所有预设字段 - * 支持按 scope 过滤 - */ @Get('/') async getAll(@Query() query: QueryMetaPresetDto) { const { scope, enabledOnly } = query return this.metaPresetService.findAll(scope, enabledOnly) } - /** - * 获取单个预设字段 - */ @Get('/:id') async getById(@Param() { id }: EntityIdDto) { return this.metaPresetService.findById(id) } - /** - * 创建自定义预设字段 - */ @Post('/') @Auth() async create(@Body() dto: CreateMetaPresetDto) { return this.metaPresetService.create(dto) } - /** - * 更新预设字段 - */ @Patch('/:id') @Auth() async update(@Param() { id }: EntityIdDto, @Body() dto: UpdateMetaPresetDto) { return this.metaPresetService.update(id, dto) } - /** - * 删除预设字段 - */ @Delete('/:id') @Auth() async delete(@Param() { id }: EntityIdDto) { return this.metaPresetService.delete(id) } - /** - * 批量更新排序 - */ @Put('/order') @Auth() async updateOrder(@Body() dto: UpdateOrderDto) { diff --git a/apps/core/src/modules/meta-preset/meta-preset.service.ts b/apps/core/src/modules/meta-preset/meta-preset.service.ts index a7ec6b1930d..3f25d652ad0 100644 --- a/apps/core/src/modules/meta-preset/meta-preset.service.ts +++ b/apps/core/src/modules/meta-preset/meta-preset.service.ts @@ -1,8 +1,7 @@ import type { OnModuleInit } from '@nestjs/common' import { Injectable } from '@nestjs/common' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { MetaFieldType, MetaPresetScope } from './meta-preset.enum' import { MetaPresetRepository } from './meta-preset.repository' @@ -13,28 +12,28 @@ import type { import type { MetaPresetModel } from './meta-preset.types' /** - * 内置预设字段种子数据 + * Seed data for built-in preset fields. */ const BUILTIN_PRESETS: Partial[] = [ { key: 'aiGen', - label: 'AI 参与声明', + label: 'AI Involvement Disclosure', type: MetaFieldType.Checkbox, scope: MetaPresetScope.Both, - description: '声明 AI 在创作过程中的参与程度', + description: 'Declare how much AI was involved during creation', allowCustomOption: true, options: [ - { value: -1, label: '无 AI (手作)', exclusive: true }, - { value: 0, label: '辅助写作' }, - { value: 1, label: '润色' }, - { value: 2, label: '完全 AI 生成', exclusive: true }, - { value: 3, label: '故事整理' }, - { value: 4, label: '标题生成' }, - { value: 5, label: '校对' }, - { value: 6, label: '灵感提供' }, - { value: 7, label: '改写' }, - { value: 8, label: 'AI 作图' }, - { value: 9, label: '口述' }, + { value: -1, label: 'No AI (handcrafted)', exclusive: true }, + { value: 0, label: 'Writing assistance' }, + { value: 1, label: 'Polishing' }, + { value: 2, label: 'Fully AI-generated', exclusive: true }, + { value: 3, label: 'Story organization' }, + { value: 4, label: 'Title generation' }, + { value: 5, label: 'Proofreading' }, + { value: 6, label: 'Inspiration source' }, + { value: 7, label: 'Rewriting' }, + { value: 8, label: 'AI-generated imagery' }, + { value: 9, label: 'Dictation' }, ], isBuiltin: true, order: 0, @@ -42,7 +41,7 @@ const BUILTIN_PRESETS: Partial[] = [ }, { key: 'cover', - label: '封面图', + label: 'Cover image', type: MetaFieldType.Url, scope: MetaPresetScope.Both, placeholder: 'https://...', @@ -52,33 +51,33 @@ const BUILTIN_PRESETS: Partial[] = [ }, { key: 'banner', - label: '横幅信息', + label: 'Banner', type: MetaFieldType.Object, scope: MetaPresetScope.Both, - description: '在文章顶部显示的提示横幅', + description: 'Notice banner displayed at the top of an article', children: [ { key: 'type', - label: '类型', + label: 'Type', type: MetaFieldType.Select, options: [ - { value: 'info', label: '信息' }, - { value: 'warning', label: '警告' }, - { value: 'error', label: '错误' }, - { value: 'success', label: '成功' }, - { value: 'secondary', label: '次要' }, + { value: 'info', label: 'Info' }, + { value: 'warning', label: 'Warning' }, + { value: 'error', label: 'Error' }, + { value: 'success', label: 'Success' }, + { value: 'secondary', label: 'Secondary' }, ], }, { key: 'message', - label: '消息内容', + label: 'Message', type: MetaFieldType.Textarea, }, { key: 'className', - label: '自定义类名', + label: 'Custom class name', type: MetaFieldType.Text, - placeholder: '可选的 CSS 类名', + placeholder: 'Optional CSS class name', }, ], isBuiltin: true, @@ -87,20 +86,20 @@ const BUILTIN_PRESETS: Partial[] = [ }, { key: 'keywords', - label: 'SEO 关键词', + label: 'SEO keywords', type: MetaFieldType.Tags, scope: MetaPresetScope.Both, - placeholder: '输入关键词后按回车', + placeholder: 'Type a keyword and press Enter', isBuiltin: true, order: 3, enabled: true, }, { key: 'style', - label: '文章样式', + label: 'Article style', type: MetaFieldType.Text, scope: MetaPresetScope.Both, - placeholder: '输入样式名称', + placeholder: 'Enter a style name', isBuiltin: true, order: 4, enabled: true, @@ -112,14 +111,14 @@ export class MetaPresetService implements OnModuleInit { constructor(private readonly metaPresetRepository: MetaPresetRepository) {} /** - * 模块初始化时初始化内置预设 + * Initialize built-in presets when the module starts up. */ async onModuleInit() { await this.initBuiltinPresets() } /** - * 初始化内置预设字段 + * Initialize built-in preset fields. */ private async initBuiltinPresets() { for (const preset of BUILTIN_PRESETS) { @@ -128,7 +127,7 @@ export class MetaPresetService implements OnModuleInit { if (!exists) { await this.metaPresetRepository.create(preset) } else { - // 更新内置预设的 options 和 children(保持最新) + // Keep options and children for built-in presets in sync with the source. await this.metaPresetRepository.update(exists.id, { options: preset.options, children: preset.children, @@ -142,7 +141,7 @@ export class MetaPresetService implements OnModuleInit { } /** - * 获取所有预设字段 + * Get all preset fields. */ async findAll(scope?: MetaPresetScope, enabledOnly = false) { const presets = await this.metaPresetRepository.findAll() @@ -154,29 +153,31 @@ export class MetaPresetService implements OnModuleInit { } /** - * 根据 ID 获取单个预设字段 + * Get a single preset field by ID. */ async findById(id: string) { return this.metaPresetRepository.findById(id) } /** - * 根据 key 获取预设字段 + * Get a preset field by key. */ async findByKey(key: string) { return this.metaPresetRepository.findByName(key) } /** - * 创建自定义预设字段 + * Create a custom preset field. */ async create(dto: CreateMetaPresetDto) { const exists = await this.metaPresetRepository.findByName(dto.key) if (exists) { - throw new BizException(ErrorCodeEnum.PresetKeyExists, `key: "${dto.key}"`) + throw createAppException(AppErrorCode.META_PRESET_KEY_EXISTS, { + key: dto.key, + }) } - // 获取最大 order 值 + // Get the current maximum order value const maxOrder = await this.metaPresetRepository.findMaxOrder() const order = dto.order ?? maxOrder + 1 @@ -189,15 +190,15 @@ export class MetaPresetService implements OnModuleInit { } /** - * 更新预设字段 + * Update a preset field. */ async update(id: string, dto: UpdateMetaPresetDto) { const preset = await this.metaPresetRepository.findById(id) if (!preset) { - throw new BizException(ErrorCodeEnum.PresetNotFound) + throw createAppException(AppErrorCode.META_PRESET_NOT_FOUND, { id }) } - // 内置预设只能修改 enabled 和 order + // Built-in presets only allow `enabled` and `order` to be modified. if (preset.isBuiltin) { const updateData: Partial = {} if (dto.enabled !== undefined) updateData.enabled = dto.enabled @@ -205,14 +206,13 @@ export class MetaPresetService implements OnModuleInit { return this.metaPresetRepository.update(id, updateData) } - // 检查 key 是否重复 + // Check whether the key already exists if (dto.key && dto.key !== preset.key) { const exists = await this.metaPresetRepository.findByName(dto.key) if (exists) { - throw new BizException( - ErrorCodeEnum.PresetKeyExists, - `key: "${dto.key}"`, - ) + throw createAppException(AppErrorCode.META_PRESET_KEY_EXISTS, { + key: dto.key, + }) } } @@ -220,23 +220,23 @@ export class MetaPresetService implements OnModuleInit { } /** - * 删除预设字段 + * Delete a preset field. */ async delete(id: string) { const preset = await this.metaPresetRepository.findById(id) if (!preset) { - throw new BizException(ErrorCodeEnum.PresetNotFound) + throw createAppException(AppErrorCode.META_PRESET_NOT_FOUND, { id }) } if (preset.isBuiltin) { - throw new BizException(ErrorCodeEnum.BuiltinPresetCannotDelete) + throw createAppException(AppErrorCode.BUILTIN_PRESET_CANNOT_DELETE) } return this.metaPresetRepository.deleteById(id) } /** - * 批量更新排序 + * Batch-update the display order. */ async updateOrder(ids: string[]) { await this.metaPresetRepository.updateOrder(ids) diff --git a/apps/core/src/modules/note/note.controller.ts b/apps/core/src/modules/note/note.controller.ts index 0b06b143ab1..4338cc4d5c7 100644 --- a/apps/core/src/modules/note/note.controller.ts +++ b/apps/core/src/modules/note/note.controller.ts @@ -16,19 +16,26 @@ import type { IpRecord } from '~/common/decorators/ip.decorator' import { IpLocation } from '~/common/decorators/ip.decorator' import { Lang } from '~/common/decorators/lang.decorator' import { HasAdminAccess } from '~/common/decorators/role.decorator' -import { TranslateFields } from '~/common/decorators/translate-fields.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { CannotFindException } from '~/common/exceptions/cant-find.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' +import { withMeta } from '~/common/response/envelope.types' +import type { + ArticleTranslation, + EnrichmentEntry, +} from '~/common/response/meta.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' +import { TranslationEntryService } from '~/modules/ai/ai-translation/translation-entry.service' import { CountingService } from '~/processors/helper/helper.counting.service' import { LexicalService } from '~/processors/helper/helper.lexical.service' import { + applyArticleTranslationInPlace, + applyTranslationEntriesInPlace, type ArticleTranslationInput, - type TranslationMeta, + buildArticleTranslationMeta, + type EntryMaps, + type EntryRule, TranslationService, } from '~/processors/helper/helper.translation.service' import { EntityIdDto } from '~/shared/dto/id.dto' -import { applyContentPreference } from '~/utils/content.util' import { truncateAtBoundary } from '~/utils/text-summary.util' import { DEFAULT_SUMMARY_LANG } from '../ai/ai.constants' @@ -48,91 +55,104 @@ import { SetNotePublishStatusDto, } from './note.schema' import { NoteService } from './note.service' -import { NoteModel } from './note.types' +import type { NoteModel } from './note.types' -type NoteListItem = NoteModel & { - isTranslated?: boolean - translationMeta?: TranslationMeta -} - -// Shared @TranslateFields rule sets — kept top-of-file so detail/list endpoints -// stay in sync without copy-paste drift. -const NOTE_LIST_TRANSLATE_FIELDS = [ - { path: 'data[].mood', keyPath: 'note.mood' }, - { path: 'data[].weather', keyPath: 'note.weather' }, - { path: 'data[].topic.name', keyPath: 'topic.name', idField: 'id' }, +const NOTE_ENTRY_RULES: ReadonlyArray = [ + { path: 'topic.name', keyPath: 'topic.name', mode: 'entity', idField: 'id' }, { - path: 'data[].topic.introduce', + path: 'topic.introduce', keyPath: 'topic.introduce', + mode: 'entity', idField: 'id', }, { - path: 'data[].topic.description', + path: 'topic.description', keyPath: 'topic.description', + mode: 'entity', idField: 'id', }, -] as const - -const NOTE_DETAIL_TRANSLATE_FIELDS = [ - { path: 'mood', keyPath: 'note.mood' }, - { path: 'weather', keyPath: 'note.weather' }, - { path: 'topic.name', keyPath: 'topic.name', idField: 'id' }, - { path: 'topic.introduce', keyPath: 'topic.introduce', idField: 'id' }, - { path: 'topic.description', keyPath: 'topic.description', idField: 'id' }, - { path: 'data.mood', keyPath: 'note.mood' }, - { path: 'data.weather', keyPath: 'note.weather' }, - { path: 'data.topic.name', keyPath: 'topic.name', idField: 'id' }, - { - path: 'data.topic.introduce', - keyPath: 'topic.introduce', - idField: 'id', - }, - { - path: 'data.topic.description', - keyPath: 'topic.description', - idField: 'id', - }, - { path: 'next.mood', keyPath: 'note.mood' }, - { path: 'next.weather', keyPath: 'note.weather' }, - { path: 'next.topic.name', keyPath: 'topic.name', idField: 'id' }, - { - path: 'next.topic.introduce', - keyPath: 'topic.introduce', - idField: 'id', - }, - { - path: 'next.topic.description', - keyPath: 'topic.description', - idField: 'id', - }, - { path: 'prev.mood', keyPath: 'note.mood' }, - { path: 'prev.weather', keyPath: 'note.weather' }, - { path: 'prev.topic.name', keyPath: 'topic.name', idField: 'id' }, - { - path: 'prev.topic.introduce', - keyPath: 'topic.introduce', - idField: 'id', - }, - { - path: 'prev.topic.description', - keyPath: 'topic.description', - idField: 'id', - }, -] as const + { path: 'mood', keyPath: 'note.mood', mode: 'dict' }, + { path: 'weather', keyPath: 'note.weather', mode: 'dict' }, +] @ApiController({ path: 'notes' }) export class NoteController { constructor( private readonly noteService: NoteService, private readonly countingService: CountingService, - private readonly translationService: TranslationService, private readonly aiSummaryService: AiSummaryService, private readonly aiInsightsService: AiInsightsService, private readonly lexicalService: LexicalService, private readonly enrichmentService: EnrichmentService, + private readonly translationEntryService: TranslationEntryService, ) {} + private toArticleTranslationInput(note: NoteModel): ArticleTranslationInput { + return { + id: String(note.id), + title: note.title, + text: note.text ?? '', + meta: note.meta as { lang?: string } | undefined, + contentFormat: note.contentFormat, + content: note.content, + modifiedAt: note.modifiedAt, + createdAt: note.createdAt, + } + } + + private fallbackSummary(doc: NoteModel, maxLength: number): string | null { + const locale = + typeof (doc.meta as { lang?: unknown } | undefined)?.lang === 'string' + ? (doc.meta as { lang: string }).lang + : undefined + if (doc.contentFormat === 'lexical' && typeof doc.content === 'string') { + const summary = this.lexicalService.extractSummaryFromLexical( + doc.content, + maxLength, + locale, + ) + if (summary) return summary + } + if (typeof doc.text !== 'string' || !doc.text) return null + return truncateAtBoundary(doc.text, maxLength, locale) + } + + private async batchEntryTranslations( + lang: string, + notes: Array, + ): Promise { + const topicIds = new Set() + const moods = new Set() + const weathers = new Set() + + for (const note of notes) { + if (!note) continue + if (note.topic?.id) topicIds.add(String(note.topic.id)) + if (note.mood) moods.add(note.mood) + if (note.weather) weathers.add(note.weather) + } + + return this.translationEntryService.getTranslationsBatch(lang, { + entityLookups: + topicIds.size > 0 + ? [ + { keyPath: 'topic.name', lookupKeys: topicIds }, + { keyPath: 'topic.introduce', lookupKeys: topicIds }, + { keyPath: 'topic.description', lookupKeys: topicIds }, + ] + : [], + dictLookups: [ + ...(moods.size > 0 + ? [{ keyPath: 'note.mood' as const, sourceTexts: moods }] + : []), + ...(weathers.size > 0 + ? [{ keyPath: 'note.weather' as const, sourceTexts: weathers }] + : []), + ], + }) + } + private async buildPublicNoteResponse( current: NoteModel, isAuthenticated: boolean, @@ -140,7 +160,7 @@ export class NoteController { ip: string, lang?: string, ) { - const { password, single: isSingle, prefer } = query + const { password, single: isSingle } = query const visibleOnly = !isAuthenticated if (!isAuthenticated) { @@ -157,7 +177,7 @@ export class NoteController { !(await this.noteService.checkPasswordToAccess(current.id, password)) && !isAuthenticated ) { - throw new BizException(ErrorCodeEnum.NoteForbidden) + throw createAppException(AppErrorCode.NOTE_FORBIDDEN) } const liked = await this.countingService @@ -174,34 +194,41 @@ export class NoteController { }, }) - // Insights live in their own collection with an independent translation - // pipeline, so article-translation metadata can't answer "do we have - // insights in the caller's locale?". Surface a dedicated flag instead. + applyArticleTranslationInPlace(current, translationResult) + const insightsLang = parseLanguageCode(lang) const hasInsightsInLocale = await this.aiInsightsService .hasInsightsInLang(current.id!, insightsLang) .catch(() => false) - const currentData = { - ...current, - title: translationResult.title, - text: translationResult.text, - ...(translationResult.content && { - content: translationResult.content, - contentFormat: translationResult.contentFormat, - }), - isTranslated: translationResult.isTranslated, - sourceLang: translationResult.sourceLang, - translationMeta: translationResult.translationMeta, - availableTranslations: translationResult.availableTranslations, - hasInsightsInLocale, - liked, - } + const metaBuilder = new MetaObjectBuilder() + .view('detail') + .interaction({ isLiked: liked }) + .insights({ hasInLocale: hasInsightsInLocale }) if (isSingle) { - return this.enrichmentService.attachEnrichments( - applyContentPreference(currentData, prefer), - ) + if (lang) { + const entryMaps = await this.batchEntryTranslations(lang, [current]) + applyTranslationEntriesInPlace(current, entryMaps, NOTE_ENTRY_RULES) + } + + const translationMap = new Map([ + [ + String(current.id), + { + article: buildArticleTranslationMeta( + translationResult, + lang, + ) as ArticleTranslation, + }, + ], + ]) + metaBuilder.translation(translationMap) + + const { enrichments, ...noteData } = + await this.enrichmentService.attachEnrichments(current) + metaBuilder.enrichments(enrichments as Record) + return withMeta(noteData, metaBuilder.build()) } const [[prev], [next]] = await Promise.all([ @@ -214,6 +241,7 @@ export class NoteController { excludeId: current.id, }), ]) + if (!isAuthenticated) { for (const adj of [prev, next]) { if (!adj) continue @@ -222,105 +250,87 @@ export class NoteController { } } - const [, data] = await Promise.all([ - this.translateAdjacentNotes([prev, next], lang), - this.enrichmentService.attachEnrichments( - applyContentPreference(currentData, prefer), - ), - ]) - const adjPrev = prev ? applyContentPreference(prev, prefer) : prev - const adjNext = next ? applyContentPreference(next, prefer) : next - return { data, next: adjNext, prev: adjPrev } - } - - private async buildOwnerNoteDetailResponse( - current: NoteModel, - query: NotePasswordQueryDto, - lang?: string, - ) { - const translationResult = await this.translationService.translateArticle({ - articleId: current.id!, - targetLang: lang, - allowHidden: true, - originalData: { - title: current.title, - text: current.text, - }, - }) - - return this.enrichmentService.attachEnrichments( - applyContentPreference( + const translationMap = new Map([ + [ + String(current.id), { - ...current, - title: translationResult.title, - text: translationResult.text, - ...(translationResult.content && { - content: translationResult.content, - contentFormat: translationResult.contentFormat, - }), - isTranslated: translationResult.isTranslated, - sourceLang: translationResult.sourceLang, - translationMeta: translationResult.translationMeta, - availableTranslations: translationResult.availableTranslations, + article: buildArticleTranslationMeta( + translationResult, + lang, + ) as ArticleTranslation, }, - query.prefer, - ), - ) - } + ], + ]) - private toArticleTranslationInput(note: NoteModel): ArticleTranslationInput { - return { - id: String(note.id), - title: note.title, - text: note.text ?? '', - meta: note.meta as { lang?: string } | undefined, - contentFormat: note.contentFormat, - content: note.content, - modifiedAt: note.modifiedAt, - createdAt: note.createdAt, + if (lang) { + const adjacents = [prev, next].filter(Boolean) as NoteModel[] + if (adjacents.length > 0) { + const adjResults = await this.translationService.translateArticleList({ + articles: adjacents.map((n) => this.toArticleTranslationInput(n)), + targetLang: lang, + translationFields: [ + 'title', + 'text', + 'content', + 'contentFormat', + 'translationMeta', + 'sourceLang', + 'availableTranslations', + ], + }) + for (const adj of adjacents) { + const adjResult = adjResults.get(String(adj.id)) + if (adjResult) { + applyArticleTranslationInPlace(adj, adjResult as any, { + fields: ['title', 'text', 'content', 'contentFormat'], + }) + if (adjResult.isTranslated) { + translationMap.set(String(adj.id), { + article: buildArticleTranslationMeta( + adjResult as any, + lang, + ) as ArticleTranslation, + }) + } + } + } + } + + const entryMaps = await this.batchEntryTranslations(lang, [ + current, + prev, + next, + ]) + applyTranslationEntriesInPlace(current, entryMaps, NOTE_ENTRY_RULES) + if (prev) + applyTranslationEntriesInPlace(prev, entryMaps, NOTE_ENTRY_RULES) + if (next) + applyTranslationEntriesInPlace(next, entryMaps, NOTE_ENTRY_RULES) } - } - /** - * Read-only: never enqueues new translations — if a locale isn't cached, the - * adjacent note keeps source-language fields. - */ - private async translateAdjacentNotes( - notes: Array, - lang?: string, - ) { - if (!lang) return - const items = notes.filter((note): note is NoteListItem => Boolean(note)) - if (!items.length) return + metaBuilder.translation(translationMap) - const translationResults = - await this.translationService.translateArticleList({ - articles: items.map((note) => this.toArticleTranslationInput(note)), - targetLang: lang, - }) + const { enrichments, ...noteData } = + await this.enrichmentService.attachEnrichments(current) + metaBuilder.enrichments(enrichments as Record) - for (const note of items) { - const translation = translationResults.get(String(note.id)) - if (!translation?.isTranslated) continue - note.title = translation.title - note.text = translation.text - if (translation.content) { - note.content = translation.content - note.contentFormat = note.contentFormat ?? translation.contentFormat - } - note.isTranslated = translation.isTranslated - note.translationMeta = translation.translationMeta - } + return withMeta( + { + ...noteData, + next: next ?? null, + prev: prev ?? null, + }, + metaBuilder.build(), + ) } @Get('/') - @TranslateFields(...NOTE_LIST_TRANSLATE_FIELDS) async getNotes( @HasAdminAccess() isAuthenticated: boolean, @Query() query: NoteQueryDto, @Lang() lang?: string, ) { - const { size, select, page, sortBy, sortOrder, year, withSummary } = query + const { size, page, sortBy, sortOrder, year, withSummary } = query const result = await this.noteService.listPaginated(page, size, { visibleOnly: !isAuthenticated, @@ -331,7 +341,7 @@ export class NoteController { | 'mood' | 'weather' | undefined, - sortOrder: sortOrder as 1 | -1 | undefined, + sortOrder: sortOrder === 'asc' ? 1 : -1, year, }) @@ -342,195 +352,90 @@ export class NoteController { } } - if (!result.data.length) { - return result - } - - if (withSummary && !lang) { - await this.enrichDocsWithSummary(result) - this.applyNoteSelect(result.data, select) - return result - } - - if (!lang) { - this.applyNoteSelect(result.data, select) - return result - } + const { results: translationResults, meta: translationMeta } = + await this.translationService.collectArticleTranslations({ + articles: result.data + .filter((doc) => typeof doc.text === 'string') + .map((doc) => this.toArticleTranslationInput(doc)), + targetLang: lang, + fields: ['title', 'text', 'content', 'contentFormat'], + }) - const translationInputs: ArticleTranslationInput[] = [] for (const doc of result.data) { - if (typeof doc.text === 'string') { - translationInputs.push(this.toArticleTranslationInput(doc)) + const tr = translationResults.get(String(doc.id)) + if (tr?.isTranslated) { + applyArticleTranslationInPlace(doc, tr as any) } } - if (!translationInputs.length) { - if (withSummary) { - await this.enrichDocsWithSummary(result, lang) + if (withSummary) { + const SUMMARY_MAX_LENGTH = 150 + const ids = result.data.map((d) => d.id) + const summaryMap = await this.aiSummaryService.batchGetSummariesByRefIds( + ids, + lang || DEFAULT_SUMMARY_LANG, + ) + for (const doc of result.data) { + const plain = doc as any + plain.summary = + summaryMap.get(doc.id) ?? + this.fallbackSummary(doc, SUMMARY_MAX_LENGTH) ?? + '' + delete plain.text + delete plain.content } - this.applyNoteSelect(result.data, select) - return result } - const translationResults = - await this.translationService.translateArticleList({ - articles: translationInputs, - targetLang: lang, - }) - - result.data = result.data.map((doc) => { - const docId = String(doc.id) - const translation = translationResults.get(docId) - if (!translation?.isTranslated) { - return doc - } - - doc.title = translation.title - doc.text = translation.text - if (translation.content) { - doc.content = translation.content - doc.contentFormat = doc.contentFormat ?? translation.contentFormat - } - ;(doc as { isTranslated?: boolean }).isTranslated = - translation.isTranslated - ;(doc as { translationMeta?: unknown }).translationMeta = - translation.translationMeta - return doc - }) - - // Strip text/content if not originally requested (added only for translation). - // Cast is required because `delete` on typed required properties needs an - // index-signature target. - const originalSelectHasText = select?.includes('text') - const originalSelectHasContent = select?.includes('content') - if (!originalSelectHasText || !originalSelectHasContent) { + if (lang) { + const entryMaps = await this.batchEntryTranslations(lang, result.data) for (const doc of result.data) { - if (!originalSelectHasText && !withSummary) delete (doc as any).text - if (!originalSelectHasContent) delete (doc as any).content + applyTranslationEntriesInPlace(doc, entryMaps, NOTE_ENTRY_RULES) } } - if (withSummary) { - await this.enrichDocsWithSummary(result, lang) - } - - this.applyNoteSelect(result.data, select) - return result - } - - private applyNoteSelect(rows: object[], select: string | undefined): void { - if (!select) return - const selected = new Set( - select - .split(' ') - .map((s) => s.trim().replace(/^[+-]/, '')) - .filter(Boolean), - ) - // Always preserve `id`, `topic`, and `summary` to keep response shape sound: - // `id` is the row key, `topic` is a joined value the legacy aggregate - // pipeline emitted after the `$project` stage, and `summary` is injected - // by `enrichDocsWithSummary` AFTER select runs — stripping it would erase - // the very field `?withSummary=1` was sent to populate. - selected.add('id') - selected.add('topic') - selected.add('summary') - for (let i = 0; i < rows.length; i++) { - rows[i] = Object.fromEntries( - Object.entries(rows[i] as Record).filter(([key]) => - selected.has(key), - ), - ) - } - } - - private async enrichDocsWithSummary( - result: { data: NoteModel[] }, - lang?: string, - ) { - const SUMMARY_MAX_LENGTH = 150 - const ids = result.data.map((d) => d.id) - const summaryMap = await this.aiSummaryService.batchGetSummariesByRefIds( - ids, - lang || DEFAULT_SUMMARY_LANG, - ) - - const enriched = result.data.map((doc) => { - const plain = { ...doc } as Record - plain.summary = - summaryMap.get(doc.id) ?? - this.fallbackSummary(doc, SUMMARY_MAX_LENGTH) ?? - '' - delete plain.text - delete plain.content - return plain + const metaBuilder = new MetaObjectBuilder().view('card').pagination({ + page: result.pagination.currentPage, + size: result.pagination.size, + total: result.pagination.total, + totalPages: result.pagination.totalPage, }) - ;(result as unknown as { data: typeof enriched }).data = enriched - } - - /** - * Fallback summary used when the AI cache misses. - * - * Truncation is delegated to `truncateAtBoundary` so the teaser never - * ends mid-word for Latin scripts or mid-sentence for CJK. Locale comes - * from `meta.lang` when authored that way; otherwise we let - * `Intl.Segmenter` fall back to its default rules. - * - * Lexical notes carry richer structure — the head of `text` (the - * markdown render of the editor state) often leads with heading hashes, - * list markers, or block prefixes that look messy in a teaser; pick the - * first paragraph block from the original editor state instead, which - * mirrors how a reader would see "the opening" of the note. - */ - private fallbackSummary(doc: NoteModel, maxLength: number): string | null { - const locale = - typeof (doc.meta as { lang?: unknown } | undefined)?.lang === 'string' - ? (doc.meta as { lang: string }).lang - : undefined - if (doc.contentFormat === 'lexical' && typeof doc.content === 'string') { - const summary = this.lexicalService.extractSummaryFromLexical( - doc.content, - maxLength, - locale, - ) - if (summary) return summary + if (translationMeta.size > 0) { + metaBuilder.translation(translationMeta) } - if (typeof doc.text !== 'string' || !doc.text) return null - return truncateAtBoundary(doc.text, maxLength, locale) + + return withMeta(result.data, metaBuilder.build()) } @Get(':id') - @TranslateFields(...NOTE_DETAIL_TRANSLATE_FIELDS) async getOneNote( @Param() params: EntityIdDto, - @Query() query: NotePasswordQueryDto = {} as NotePasswordQueryDto, - @HasAdminAccess() isAuthenticated = false, - @IpLocation() { ip }: IpRecord = { ip: '' } as IpRecord, - @Lang() lang?: string, + @HasAdminAccess() isAuthenticated: boolean, ) { const { id } = params - const current = await this.noteService.findById(id) if (!current) { - throw new CannotFindException() + throw createAppException(AppErrorCode.NOTE_NOT_FOUND, { id }) } - // 非认证用户只能查看已发布的手记 if (!isAuthenticated && !current.isPublished) { - throw new CannotFindException() + throw createAppException(AppErrorCode.NOTE_NOT_FOUND, { id }) } - return this.buildPublicNoteResponse( - current as NoteModel, - isAuthenticated, - { ...query, single: true }, - ip, - lang, + if (!isAuthenticated) { + current.location = null + current.coordinates = null + } + + const { enrichments, ...noteData } = + await this.enrichmentService.attachEnrichments(current) + const metaBuilder = new MetaObjectBuilder().enrichments( + enrichments as Record, ) + return withMeta(noteData, metaBuilder.build()) } @Get('/:year/:month/:day/:slug') - @TranslateFields(...NOTE_DETAIL_TRANSLATE_FIELDS) async getNoteByDateAndSlug( @Param() params: NoteSlugDateParamsDto, @HasAdminAccess() isAuthenticated: boolean, @@ -544,13 +449,11 @@ export class NoteController { month, day, slug, - { - includeLocation: isAuthenticated, - }, + { includeLocation: isAuthenticated }, ) if (!current || (!isAuthenticated && !current.isPublished)) { - throw new CannotFindException() + throw createAppException(AppErrorCode.NOTE_NOT_FOUND) } return this.buildPublicNoteResponse( @@ -573,12 +476,11 @@ export class NoteController { const half = size >> 1 const { id } = params - // 当前文档直接找,不用加条件,反正里面的东西是看不到的 const currentDocument = await this.noteService.findById(id) - if (!currentDocument) { - return { data: [], size: 0 } + return withMeta([], new MetaObjectBuilder().view('card').build()) } + const findAdjacent = (direction: 'prev' | 'next', count: number) => { if (count <= 0) return Promise.resolve([]) return this.noteService.findByCreatedWindow( @@ -593,50 +495,53 @@ export class NoteController { findAdjacent('prev', half - 1), findAdjacent('next', half ? half - 1 : 0), ]) + const merged = [...prevList, ...nextList, currentDocument].sort( (a, b) => (b.createdAt?.valueOf() ?? 0) - (a.createdAt?.valueOf() ?? 0), ) - // SDK consumer (`NoteTimelineItem`) only reads id/title/nid/slug/createdAt/ - // isPublished plus translation flags, so trim eagerly here. - let data = merged.map((doc) => ({ + const listData = merged.map((doc) => ({ id: doc.id, title: doc.title, nid: doc.nid, slug: doc.slug, isPublished: doc.isPublished, createdAt: doc.createdAt, - })) as NoteListItem[] + })) + + const { results: translationResults, meta: translationMeta } = + await this.translationService.collectArticleTranslations({ + articles: listData.map((item) => ({ + id: String(item.id), + title: item.title, + text: '', + createdAt: item.createdAt, + modifiedAt: null, + })), + targetLang: lang, + fields: ['title'], + }) - // 处理翻译 - data = await this.translationService.translateList({ - items: data, - targetLang: lang, - translationFields: ['title', 'translationMeta'] as const, - getInput: (item) => ({ - id: String(item.id), - title: item.title, - modifiedAt: item.modifiedAt, - createdAt: item.createdAt, - }), - applyResult: (item, translation) => { - if (translation?.isTranslated) { - item.title = translation.title - item.isTranslated = true - item.translationMeta = translation.translationMeta - } - return item - }, - }) + for (const item of listData) { + const tr = translationResults.get(String(item.id)) + if (tr?.isTranslated && tr.title) { + item.title = tr.title + } + } - return { data, size: data.length } + const metaBuilder = new MetaObjectBuilder().view('card') + if (translationMeta.size > 0) { + metaBuilder.translation(translationMeta) + } + + return withMeta(listData, metaBuilder.build()) } @Post('/') @HTTPDecorators.Idempotence() @Auth() - async create(@Body() body: NoteDto) { - return await this.noteService.create(body as unknown as NoteModel) + create(@Body() body: NoteDto) { + return this.noteService.create(body as unknown as NoteModel) } @Put('/:id') @@ -653,7 +558,6 @@ export class NoteController { params.id, body as unknown as Partial, ) - return } @Delete(':id') @@ -663,7 +567,6 @@ export class NoteController { } @Get('/latest') - @TranslateFields(...NOTE_DETAIL_TRANSLATE_FIELDS) async getLatestOne( @HasAdminAccess() isAuthenticated: boolean, @Lang() lang?: string, @@ -674,6 +577,7 @@ export class NoteController { if (!result) return null const { latest, next } = result + if (!isAuthenticated) { latest.location = null latest.coordinates = null @@ -694,31 +598,73 @@ export class NoteController { }, }) + applyArticleTranslationInPlace(latest, translationResult) + + let nextTranslationResult: Awaited< + ReturnType + > | null = null + + if (lang && next) { + nextTranslationResult = await this.translationService.translateArticle({ + articleId: String(next.id), + targetLang: lang, + allowHidden: Boolean(isAuthenticated), + originalData: { title: next.title, text: next.text ?? '' }, + }) + applyArticleTranslationInPlace(next, nextTranslationResult) + } + + if (lang) { + const entryMaps = await this.batchEntryTranslations(lang, [latest, next]) + applyTranslationEntriesInPlace(latest, entryMaps, NOTE_ENTRY_RULES) + if (next) + applyTranslationEntriesInPlace(next, entryMaps, NOTE_ENTRY_RULES) + } + const insightsLang = parseLanguageCode(lang) const hasInsightsInLocale = await this.aiInsightsService .hasInsightsInLang(latest.id!, insightsLang) .catch(() => false) - const data = await this.enrichmentService.attachEnrichments({ - ...latest, - title: translationResult.title, - text: translationResult.text, - ...(translationResult.content && { - content: translationResult.content, - contentFormat: translationResult.contentFormat, - }), - isTranslated: translationResult.isTranslated, - sourceLang: translationResult.sourceLang, - translationMeta: translationResult.translationMeta, - availableTranslations: translationResult.availableTranslations, - hasInsightsInLocale, - }) - return { data, next } + const { enrichments, ...latestData } = + await this.enrichmentService.attachEnrichments(latest) + + const metaBuilder = new MetaObjectBuilder() + .view('detail') + .insights({ hasInLocale: hasInsightsInLocale }) + .enrichments(enrichments as Record) + + const translationMap = new Map([ + [ + String(latest.id), + { + article: buildArticleTranslationMeta( + translationResult, + lang, + ) as ArticleTranslation, + }, + ], + ]) + if (nextTranslationResult) { + translationMap.set(String(next!.id), { + article: buildArticleTranslationMeta( + nextTranslationResult, + lang, + ) as ArticleTranslation, + }) + } + metaBuilder.translation(translationMap) + + return withMeta( + { + ...latestData, + next: next ?? null, + }, + metaBuilder.build(), + ) } - // C 端入口 @Get('/nid/:nid') - @TranslateFields(...NOTE_DETAIL_TRANSLATE_FIELDS) async getNoteByNid( @Param() params: NidType, @HasAdminAccess() isAuthenticated: boolean, @@ -729,12 +675,11 @@ export class NoteController { const { nid } = params const current: NoteModel | null = await this.noteService.findByNid(nid) if (!current) { - throw new CannotFindException() + throw createAppException(AppErrorCode.NOTE_NOT_FOUND) } - // Unauthenticated callers must not see unpublished (draft) notes via nid. if (!isAuthenticated && !current.isPublished) { - throw new CannotFindException() + throw createAppException(AppErrorCode.NOTE_NOT_FOUND) } return this.buildPublicNoteResponse( @@ -747,10 +692,6 @@ export class NoteController { } @Get('/topics/:id') - @TranslateFields( - { path: 'data[].mood', keyPath: 'note.mood' }, - { path: 'data[].weather', keyPath: 'note.weather' }, - ) async getNotesByTopic( @Param() params: EntityIdDto, @Query() query: NoteTopicPagerDto, @@ -764,14 +705,8 @@ export class NoteController { { page, limit: size, - sortBy: sortBy as - | 'createdAt' - | 'modifiedAt' - | 'title' - | 'mood' - | 'weather' - | undefined, - sortOrder: sortOrder as 1 | -1 | undefined, + sortBy: sortBy as any, + sortOrder: sortOrder === 'asc' ? 1 : -1, }, isAuthenticated ? {} : { isPublished: true }, ) @@ -783,30 +718,42 @@ export class NoteController { } } - // 处理翻译 - const translatedDocs = await this.translationService.translateList({ - items: result.data as unknown as NoteListItem[], - targetLang: lang, - translationFields: ['title', 'translationMeta'] as const, - getInput: (item) => ({ - id: String(item.id), - title: item.title, - modifiedAt: item.modifiedAt, - createdAt: item.createdAt, - }), - applyResult: (item, translation) => { - delete (item as { text?: string }).text // 始终移除 text - if (translation?.isTranslated) { - item.title = translation.title - item.isTranslated = true - item.translationMeta = translation.translationMeta - } - return item - }, + const { results: translationResults, meta: translationMeta } = + await this.translationService.collectArticleTranslations({ + articles: result.data.map((doc) => ({ + id: String(doc.id), + title: doc.title, + text: '', + createdAt: doc.createdAt, + modifiedAt: doc.modifiedAt, + })), + targetLang: lang, + fields: ['title'], + }) + + for (const doc of result.data) { + const tr = translationResults.get(String(doc.id)) + if (tr?.isTranslated && tr.title) { + doc.title = tr.title + } + } + + if (lang) { + const entryMaps = await this.batchEntryTranslations(lang, result.data) + for (const doc of result.data) { + applyTranslationEntriesInPlace(doc, entryMaps, NOTE_ENTRY_RULES) + } + } + + const metaBuilder = new MetaObjectBuilder().view('card').pagination({ + page: result.pagination.currentPage, + size: result.pagination.size, + total: result.pagination.total, + totalPages: result.pagination.totalPage, }) - result.data = translatedDocs as typeof result.data + if (translationMeta.size > 0) metaBuilder.translation(translationMeta) - return result + return withMeta(result.data, metaBuilder.build()) } @Get('/topics/:id/recent-update') diff --git a/apps/core/src/modules/note/note.repository.ts b/apps/core/src/modules/note/note.repository.ts index 0105ee4d1f3..2f658457b6d 100644 --- a/apps/core/src/modules/note/note.repository.ts +++ b/apps/core/src/modules/note/note.repository.ts @@ -648,8 +648,9 @@ export class NoteRepository extends BaseRepository { : gt(notes.createdAt, pivotDate), ] if (options.visibleOnly) filters.push(this.visibleClause()) - // PG timestamp 至 μs,JS Date 仅 ms。`gt/lt(jsDate)` 会把 createdAt - // sub-ms 之同行(即 pivot 自身)取入,需显式排除。 + // PG timestamps have μs precision while JS Date is only ms. `gt/lt(jsDate)` + // can include rows with sub-ms differences in createdAt (e.g. the pivot row + // itself), so explicitly exclude them by id. if (options.excludeId !== undefined) { filters.push(ne(notes.id, parseEntityId(options.excludeId))) } diff --git a/apps/core/src/modules/note/note.schema.ts b/apps/core/src/modules/note/note.schema.ts index f8c535828ca..63fddfeaa91 100644 --- a/apps/core/src/modules/note/note.schema.ts +++ b/apps/core/src/modules/note/note.schema.ts @@ -10,7 +10,7 @@ import { zPrefer, zTransformEmptyNull, } from '~/common/zod' -import { PagerSchema } from '~/shared/dto/pager.dto' +import { createPagerSchema } from '~/shared/dto/pager.dto' import { WriteBaseSchema } from '~/shared/schema' import { ImageSchema } from '~/shared/schema/image.schema' import { ContentFormat } from '~/shared/types/content-format.type' @@ -29,8 +29,8 @@ export const CoordinateSchema = z.object({ export const NoteSchema = WriteBaseSchema.extend({ title: z .string() - .transform((val) => (val.length === 0 ? '无题' : val)) - .default('无题'), + .transform((val) => (val.length === 0 ? 'Untitled' : val)) + .default('Untitled'), slug: z.preprocess((val) => { if (typeof val !== 'string') { return val @@ -53,7 +53,7 @@ export const NoteSchema = WriteBaseSchema.extend({ location: z.string().optional().nullable(), topicId: zEntityId.optional().nullable(), images: z.array(ImageSchema).optional().default([]), - /** 关联的草稿 ID,发布时标记该草稿为已发布 */ + /** ID of the associated draft; marked as published when this note is published */ draftId: zEntityId.optional(), }) @@ -66,7 +66,7 @@ export class NoteDto extends createZodDto(NoteSchema) {} export const PartialNoteSchema = NoteSchema.extend({ title: z .string() - .transform((val) => (val.length === 0 ? '无题' : val)) + .transform((val) => (val.length === 0 ? 'Untitled' : val)) .optional(), contentFormat: z .enum([ContentFormat.Markdown, ContentFormat.Lexical]) @@ -82,14 +82,13 @@ export class PartialNoteDto extends createZodDto(PartialNoteSchema) {} /** * Note query schema for pagination */ -export const NoteQuerySchema = PagerSchema.extend({ - sortBy: z - .enum(['title', 'createdAt', 'modifiedAt', 'weather', 'mood']) - .optional(), - sortOrder: z.preprocess( - (val) => (typeof val === 'string' ? Math.trunc(Number(val)) : val), - z.union([z.literal(1), z.literal(-1)]).optional(), - ), +export const NoteQuerySchema = createPagerSchema([ + 'title', + 'createdAt', + 'modifiedAt', + 'weather', + 'mood', +]).extend({ lang: zLang, withSummary: zCoerceBoolean.optional(), }) @@ -155,9 +154,15 @@ export class SetNotePublishStatusDto extends createZodDto( ) {} /** - * Note topic pager schema (extends PagerSchema with lang support) + * Note topic pager schema (extends pager with lang support) */ -export const NoteTopicPagerSchema = PagerSchema.extend({ +export const NoteTopicPagerSchema = createPagerSchema([ + 'title', + 'createdAt', + 'modifiedAt', + 'weather', + 'mood', +]).extend({ lang: zLang, }) diff --git a/apps/core/src/modules/note/note.service.ts b/apps/core/src/modules/note/note.service.ts index 9ea606504d1..85d19b9e191 100644 --- a/apps/core/src/modules/note/note.service.ts +++ b/apps/core/src/modules/note/note.service.ts @@ -3,16 +3,10 @@ import dayjs from 'dayjs' import { debounce, omit } from 'es-toolkit/compat' import slugify from 'slugify' -import { - BizException, - BusinessException, -} from '~/common/exceptions/biz.exception' -import { CannotFindException } from '~/common/exceptions/cant-find.exception' -import { NoContentCanBeModifiedException } from '~/common/exceptions/no-content-canbe-modified.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { ArticleTypeEnum } from '~/constants/article.constant' import { BusinessEvents, EventScope } from '~/constants/business-event.constant' import { CollectionRefTypes } from '~/constants/db.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { EventBusEvents } from '~/constants/event-bus.constant' import { FileReferenceType } from '~/modules/file/file-reference.enum' import { FileReferenceService } from '~/modules/file/file-reference.service' @@ -134,7 +128,7 @@ export class NoteService { const existing = await this.noteRepository.findBySlug(slug) if (!existing) return if (excludeId && existing.id === excludeId) return - throw new BusinessException(ErrorCodeEnum.SlugNotAvailable) + throw createAppException(AppErrorCode.SLUG_NOT_AVAILABLE) } private async trackSeoPathChanges( @@ -224,7 +218,7 @@ export class NoteService { _options?: { includeLocation?: boolean }, ) { const normalizedSlug = this.normalizeSlug(slug) - if (!normalizedSlug) throw new BizException(ErrorCodeEnum.InvalidSlug) + if (!normalizedSlug) throw createAppException(AppErrorCode.INVALID_SLUG) const { start, end } = this.getDateRange(year, month, day) const direct = await this.noteRepository.findOneByDateAndSlug( @@ -250,7 +244,7 @@ export class NoteService { async getLatestNoteId() { const note = await this.noteRepository.getLatestVisible() - if (!note) throw new CannotFindException() + if (!note) throw createAppException(AppErrorCode.NOT_FOUND) return { nid: note.nid, id: note.id } } @@ -365,7 +359,7 @@ export class NoteService { ) { this.lexicalService.populateText(data as any) const oldDoc = await this.findById(id) - if (!oldDoc) throw new NoContentCanBeModifiedException() + if (!oldDoc) throw createAppException(AppErrorCode.NO_CONTENT_MODIFIABLE) const { draftId } = data const hasSlugInput = Object.prototype.hasOwnProperty.call(data, 'slug') @@ -413,7 +407,7 @@ export class NoteService { : undefined, modifiedAt: hasContentChanged || hasFieldChanged ? new Date() : undefined, }) - if (!updated) throw new NoContentCanBeModifiedException() + if (!updated) throw createAppException(AppErrorCode.NO_CONTENT_MODIFIABLE) await this.trackSeoPathChanges( oldDoc, diff --git a/apps/core/src/modules/note/note.views.ts b/apps/core/src/modules/note/note.views.ts new file mode 100644 index 00000000000..e2d847057d8 --- /dev/null +++ b/apps/core/src/modules/note/note.views.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' + +const NoteCardSchema = z + .object({ + id: z.string(), + nid: z.number(), + title: z.string(), + slug: z.string().nullable().optional(), + mood: z.string().nullable().optional(), + weather: z.string().nullable().optional(), + createdAt: z.date().or(z.string()), + isPublished: z.boolean(), + bookmark: z.boolean(), + }) + .passthrough() + +const NoteSummarySchema = NoteCardSchema.extend({ + modifiedAt: z.date().or(z.string()).nullable().optional(), + topicId: z.string().nullable().optional(), + topic: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string(), + }) + .nullable() + .optional(), +}) + +const NoteDetailSchema = z.object({}).passthrough() + +export const NoteViews = { + card: NoteCardSchema, + summary: NoteSummarySchema, + detail: NoteDetailSchema, +} as const + +export type NoteView = keyof typeof NoteViews diff --git a/apps/core/src/modules/option/controllers/base.option.controller.ts b/apps/core/src/modules/option/controllers/base.option.controller.ts index ab12798efcc..96cba026c80 100644 --- a/apps/core/src/modules/option/controllers/base.option.controller.ts +++ b/apps/core/src/modules/option/controllers/base.option.controller.ts @@ -1,7 +1,6 @@ import { Body, Get, Param, Patch } from '@nestjs/common' -import { HTTPDecorators } from '~/common/decorators/http.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' + +import { AppErrorCode, createAppException } from '~/common/errors' import { attachAiProviderOptionsToFormDSL, generateFormDSL, @@ -9,6 +8,7 @@ import { import { sanitizeConfigForResponse } from '~/modules/configs/configs.encrypt.util' import { IConfig } from '~/modules/configs/configs.interface' import { ConfigsService } from '~/modules/configs/configs.service' + import { OptionController } from '../option.decorator' @OptionController() @@ -20,7 +20,6 @@ export class BaseOptionController { return this.configsService.getConfigForResponse() } - @HTTPDecorators.Bypass @Get('/form-schema') async getFormSchema() { const schema = generateFormDSL() @@ -36,9 +35,11 @@ export class BaseOptionController { async getOptionKey(@Param('key') key: keyof IConfig) { const value = await this.configsService.getForResponse(key) if (!value) { - throw new BizException(ErrorCodeEnum.ConfigNotFound) + throw createAppException(AppErrorCode.CONFIG_NOT_FOUND, { + id: key as string, + }) } - return { data: value } + return value } @Patch('/:key') @@ -47,7 +48,7 @@ export class BaseOptionController { @Body() body: Record, ) { if (typeof body !== 'object') { - throw new BizException(ErrorCodeEnum.InvalidBody) + throw createAppException(AppErrorCode.INVALID_BODY) } const result = await this.configsService.patchAndValid(key, body) return sanitizeConfigForResponse(result as object, key) diff --git a/apps/core/src/modules/option/controllers/email.option.controller.ts b/apps/core/src/modules/option/controllers/email.option.controller.ts index cbcafe2f5d6..1547a9ddd4f 100644 --- a/apps/core/src/modules/option/controllers/email.option.controller.ts +++ b/apps/core/src/modules/option/controllers/email.option.controller.ts @@ -1,7 +1,8 @@ import { Body, Delete, Get, Put, Query } from '@nestjs/common' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' + +import { AppErrorCode, createAppException } from '~/common/errors' import { EmailService } from '~/processors/helper/helper.email.service' + import { OptionController } from '../option.decorator' import { EmailTemplateBodyDto, EmailTemplateTypeDto } from '../option.schema' @@ -12,11 +13,12 @@ export class EmailOptionController { @Get('/template') async getEmailTemplate(@Query() { type }: EmailTemplateTypeDto) { const template = await this.emailService.readTemplate(type).catch(() => { - // TODO 判断异常类型 + // TODO: discriminate by exception type return '' }) - if (!template) throw new BizException(ErrorCodeEnum.EmailTemplateNotFound) + if (!template) + throw createAppException(AppErrorCode.EMAIL_TEMPLATE_NOT_FOUND) const props = this.emailService.getExampleRenderProps(type) diff --git a/apps/core/src/modules/owner/owner.controller.ts b/apps/core/src/modules/owner/owner.controller.ts index d45abefff2f..a3270e3da5a 100644 --- a/apps/core/src/modules/owner/owner.controller.ts +++ b/apps/core/src/modules/owner/owner.controller.ts @@ -4,7 +4,6 @@ import { RequestContext } from '~/common/contexts/request.context' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HttpCache } from '~/common/decorators/cache.decorator' -import { HTTPDecorators } from '~/common/decorators/http.decorator' import { AuthService } from '../auth/auth.service' import { ConfigsService } from '../configs/configs.service' @@ -32,7 +31,6 @@ export class OwnerController { @Get('/allow-login') @HttpCache({ disable: true }) - @HTTPDecorators.Bypass async allowLogin() { const { disablePasswordLogin } = await this.configsService.get('authSecurity') diff --git a/apps/core/src/modules/owner/owner.schema.ts b/apps/core/src/modules/owner/owner.schema.ts index 22a12e3a7fc..21c8daf254f 100644 --- a/apps/core/src/modules/owner/owner.schema.ts +++ b/apps/core/src/modules/owner/owner.schema.ts @@ -1,11 +1,12 @@ -import { zAllowedUrl, zNonEmptyString } from '~/common/zod' import { createZodDto } from 'nestjs-zod' import { z } from 'zod' +import { zAllowedUrl, zNonEmptyString } from '~/common/zod' + export const OwnerPatchSchema = z.object({ introduce: zNonEmptyString.optional(), mail: z.string().email().optional(), - url: z.string().url({ message: '请更正为正确的网址' }).optional(), + url: z.string().url({ message: 'Please enter a valid URL' }).optional(), name: z.string().optional(), avatar: zAllowedUrl.optional(), socialIds: z.record(z.string(), z.any()).optional(), diff --git a/apps/core/src/modules/owner/owner.service.ts b/apps/core/src/modules/owner/owner.service.ts index 736db0c081a..3e1658a232b 100644 --- a/apps/core/src/modules/owner/owner.service.ts +++ b/apps/core/src/modules/owner/owner.service.ts @@ -1,8 +1,8 @@ import { Injectable, Logger } from '@nestjs/common' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' +import { AppException } from '~/common/errors/exception.types' import { BusinessEvents, EventScope } from '~/constants/business-event.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { EventBusEvents } from '~/constants/event-bus.constant' import { EventManagerService } from '~/processors/helper/helper.event.service' import { getAvatar } from '~/utils/tool.util' @@ -73,7 +73,7 @@ export class OwnerService { async getOwnerInfo(getLoginIp = false) { const reader = await this.getOwnerReader() if (!reader) { - throw new BizException(ErrorCodeEnum.MasterLost) + throw createAppException(AppErrorCode.MASTER_LOST) } const profile = await this.getOwnerProfile(reader.id, getLoginIp) @@ -87,7 +87,7 @@ export class OwnerService { public async getOwner() { const owner = await this.getOwnerInfo() if (!owner) { - throw new BizException(ErrorCodeEnum.UserNotExists) + throw createAppException(AppErrorCode.USER_NOT_EXISTS) } return owner } @@ -95,7 +95,7 @@ export class OwnerService { async patchOwnerData(data: Partial) { const reader = await this.getOwnerReader() if (!reader?.id) { - throw new BizException(ErrorCodeEnum.MasterLost) + throw createAppException(AppErrorCode.MASTER_LOST) } const readerPatch: Record = {} @@ -149,7 +149,7 @@ export class OwnerService { ): Promise> { const reader = await this.getOwnerReader() if (!reader?.id) { - throw new BizException(ErrorCodeEnum.MasterLost) + throw createAppException(AppErrorCode.MASTER_LOST) } const profile = await this.getOwnerProfile(reader.id, true) const prevFootstep = { @@ -162,7 +162,7 @@ export class OwnerService { lastLoginIp: ip, }) - this.logger.warn(`主人已登录,IP: ${ip}`) + this.logger.warn(`Owner signed in, IP: ${ip}`) return prevFootstep } @@ -184,12 +184,12 @@ export class OwnerService { async getSiteOwnerOrMocked() { return this.getOwnerInfo().catch((error) => { if ( - error instanceof BizException && - error.bizCode === ErrorCodeEnum.MasterLost + error instanceof AppException && + error.code === AppErrorCode.MASTER_LOST ) { return { id: '1', - name: '站长大人', + name: 'Site Owner', mail: 'example@owner.com', username: 'johndoe', created: new Date('2021/1/1 10:00:11'), diff --git a/apps/core/src/modules/page/page.controller.ts b/apps/core/src/modules/page/page.controller.ts index ab75f976e93..2212e3f03d3 100644 --- a/apps/core/src/modules/page/page.controller.ts +++ b/apps/core/src/modules/page/page.controller.ts @@ -13,16 +13,21 @@ import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' import { Lang } from '~/common/decorators/lang.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { CannotFindException } from '~/common/exceptions/cant-find.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' +import { withMeta } from '~/common/response/envelope.types' +import type { + ArticleTranslation, + EnrichmentEntry, +} from '~/common/response/meta.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' import { + applyArticleTranslationInPlace, type ArticleTranslationInput, + buildArticleTranslationMeta, TranslationService, } from '~/processors/helper/helper.translation.service' import { EntityIdDto } from '~/shared/dto/id.dto' -import { PagerDto } from '~/shared/dto/pager.dto' -import { applyContentPreference } from '~/utils/content.util' +import { BasicPagerDto } from '~/shared/dto/pager.dto' import { EnrichmentService } from '../enrichment/enrichment.service' import { @@ -32,7 +37,7 @@ import { PartialPageDto, } from './page.schema' import { PageService } from './page.service' -import { PageModel } from './page.types' +import type { PageModel } from './page.types' @ApiController('pages') export class PageController { @@ -42,50 +47,11 @@ export class PageController { private readonly enrichmentService: EnrichmentService, ) {} - private async buildPageDetailResponse( - page: PageModel, - query: PageDetailQueryDto, - lang?: string, - ) { - const translationResult = await this.translationService.translateArticle({ - articleId: String(page.id), - targetLang: lang, - originalData: { - title: page.title, - text: page.text, - subtitle: page.subtitle, - }, - }) - - const finalDoc = applyContentPreference( - { - ...page, - title: translationResult.title, - text: translationResult.text, - subtitle: translationResult.subtitle, - ...(translationResult.content && { - content: translationResult.content, - contentFormat: translationResult.contentFormat, - }), - isTranslated: translationResult.isTranslated, - sourceLang: translationResult.sourceLang, - translationMeta: translationResult.translationMeta, - availableTranslations: translationResult.availableTranslations, - }, - query.prefer, - ) - return this.enrichmentService.attachEnrichments(finalDoc) - } - @Get('/') - async getPagesSummary(@Query() query: PagerDto, @Lang() lang?: string) { - const { size, select, page } = query + async getPagesSummary(@Query() query: BasicPagerDto, @Lang() lang?: string) { + const { size, page } = query const result = await this.pageService.listPaginated(page, size) - if (!lang || !result.data.length) { - return result - } - const translationInputs: ArticleTranslationInput[] = result.data.map( (doc) => ({ id: String(doc.id), @@ -100,61 +66,80 @@ export class PageController { }), ) - const translationResults = - await this.translationService.translateArticleList({ + const { results: translationResults, meta: translationMeta } = + await this.translationService.collectArticleTranslations({ articles: translationInputs, targetLang: lang, + fields: ['title', 'text', 'subtitle', 'content', 'contentFormat'], }) - result.data = result.data.map((doc) => { - const translation = translationResults.get(String(doc.id)) - if (!translation?.isTranslated) { - return doc - } - doc.title = translation.title - doc.text = translation.text - doc.subtitle = translation.subtitle ?? null - ;(doc as { isTranslated?: boolean }).isTranslated = - translation.isTranslated - ;(doc as { translationMeta?: unknown }).translationMeta = - translation.translationMeta - return doc - }) - - // Strip fields fetched only for translation when caller did not request them. - if (select) { - const TRANSLATION_FIELDS = [ - 'text', - 'meta', - 'subtitle', - 'content', - 'contentFormat', - 'modified', - 'created', - ] - const stripFields = TRANSLATION_FIELDS.filter((f) => !select.includes(f)) - for (const doc of result.data) { - for (const field of stripFields) { - delete (doc as any)[field] - } + for (const doc of result.data) { + const tr = translationResults.get(String(doc.id)) + if (tr?.isTranslated) { + applyArticleTranslationInPlace(doc as Record, tr as any, { + fields: ['title', 'text', 'subtitle', 'content', 'contentFormat'], + }) } } - return result + const metaBuilder = new MetaObjectBuilder().view('card').pagination({ + page: result.pagination.currentPage, + size: result.pagination.size, + total: result.pagination.total, + totalPages: result.pagination.totalPage, + }) + if (translationMeta.size > 0) metaBuilder.translation(translationMeta) + + return withMeta(result.data, metaBuilder.build()) } @Get('/:id') @Auth() - async getPageById( - @Param() params: EntityIdDto, - @Query() query: PageDetailQueryDto, - @Lang() lang?: string, - ) { + async getPageById(@Param() params: EntityIdDto, @Lang() lang?: string) { const page = await this.pageService.findById(params.id) if (!page) { - throw new CannotFindException() + throw createAppException(AppErrorCode.PAGE_NOT_FOUND, { id: params.id }) } - return this.buildPageDetailResponse(page, query, lang) + + const translationResult = await this.translationService.translateArticle({ + articleId: String(page.id), + targetLang: lang, + originalData: { + title: page.title, + text: page.text, + subtitle: page.subtitle, + }, + }) + + applyArticleTranslationInPlace( + page as Record, + translationResult, + { + fields: ['title', 'text', 'subtitle', 'content', 'contentFormat'], + }, + ) + + const { enrichments, ...pageData } = + await this.enrichmentService.attachEnrichments(page) + + const metaBuilder = new MetaObjectBuilder().enrichments( + enrichments as Record, + ) + + const translationMap = new Map([ + [ + String(page.id), + { + article: buildArticleTranslationMeta( + translationResult, + lang, + ) as ArticleTranslation, + }, + ], + ]) + metaBuilder.translation(translationMap) + + return withMeta(pageData, metaBuilder.build()) } @Get('/slug/:slug') @@ -164,22 +149,59 @@ export class PageController { @Lang() lang?: string, ) { if (typeof slug !== 'string') { - throw new BizException(ErrorCodeEnum.InvalidSlug) + throw createAppException(AppErrorCode.PAGE_NOT_FOUND) } const page = await this.pageService.findBySlug(slug) - if (!page) { - throw new CannotFindException() + throw createAppException(AppErrorCode.PAGE_NOT_FOUND) } - return this.buildPageDetailResponse(page, query, lang) + const translationResult = await this.translationService.translateArticle({ + articleId: String(page.id), + targetLang: lang, + originalData: { + title: page.title, + text: page.text, + subtitle: page.subtitle, + }, + }) + + applyArticleTranslationInPlace( + page as Record, + translationResult, + { + fields: ['title', 'text', 'subtitle', 'content', 'contentFormat'], + }, + ) + + const { enrichments, ...pageData } = + await this.enrichmentService.attachEnrichments(page) + + const metaBuilder = new MetaObjectBuilder() + .view('detail') + .enrichments(enrichments as Record) + + const translationMap = new Map([ + [ + String(page.id), + { + article: buildArticleTranslationMeta( + translationResult, + lang, + ) as ArticleTranslation, + }, + ], + ]) + metaBuilder.translation(translationMap) + + return withMeta(pageData, metaBuilder.build()) } @Post('/') @Auth() @HTTPDecorators.Idempotence() - async create(@Body() body: PageDto) { - return await this.pageService.create(body as unknown as PageModel) + create(@Body() body: PageDto) { + return this.pageService.create(body as unknown as PageModel) } @Put('/:id') @@ -187,8 +209,7 @@ export class PageController { async modify(@Body() body: PageDto, @Param() params: EntityIdDto) { const { id } = params await this.pageService.updateById(id, body as unknown as PageModel) - - return await this.pageService.findById(id) + return this.pageService.findById(id) } @Patch('/:id') @@ -196,8 +217,6 @@ export class PageController { async patch(@Body() body: PartialPageDto, @Param() params: EntityIdDto) { const { id } = params await this.pageService.updateById(id, body as unknown as Partial) - - return } @Patch('/reorder') @@ -207,18 +226,18 @@ export class PageController { const orders = seq.map(($) => $.order) const uniq = new Set(orders) if (uniq.size !== orders.length) { - throw new BizException(ErrorCodeEnum.InvalidOrderValue) + throw createAppException(AppErrorCode.INVALID_ORDER_VALUE) } const tasks = seq.map(({ id, order }) => { return this.pageService.updateOrder(id, order) }) await Promise.all(tasks) + return { success: true } } @Delete('/:id') @Auth() async deletePage(@Param() params: EntityIdDto) { await this.pageService.deleteById(params.id) - return } } diff --git a/apps/core/src/modules/page/page.schema.ts b/apps/core/src/modules/page/page.schema.ts index 32102465f7d..ab3e85f8bc1 100644 --- a/apps/core/src/modules/page/page.schema.ts +++ b/apps/core/src/modules/page/page.schema.ts @@ -18,7 +18,7 @@ export const PageSchema = WriteBaseSchema.extend({ z.number().int().min(0).default(1), ), images: z.array(ImageSchema).optional(), - /** 关联的草稿 ID,发布时标记该草稿为已发布 */ + /** ID of the associated draft; marked as published when this page is published */ draftId: zEntityId.optional(), }) diff --git a/apps/core/src/modules/page/page.service.ts b/apps/core/src/modules/page/page.service.ts index f0f1a3832b3..52d0bb14c4b 100644 --- a/apps/core/src/modules/page/page.service.ts +++ b/apps/core/src/modules/page/page.service.ts @@ -2,10 +2,8 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common' import { omit } from 'es-toolkit/compat' import slugify from 'slugify' -import { BizException } from '~/common/exceptions/biz.exception' -import { NoContentCanBeModifiedException } from '~/common/exceptions/no-content-canbe-modified.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { BusinessEvents, EventScope } from '~/constants/business-event.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { FileReferenceType } from '~/modules/file/file-reference.enum' import { FileReferenceService } from '~/modules/file/file-reference.service' import { EventManagerService } from '~/processors/helper/helper.event.service' @@ -79,7 +77,7 @@ export class PageService { const { draftId } = doc const count = await this.pageRepository.count() if (count >= 10) { - throw new BizException(ErrorCodeEnum.MaxCountLimit) + throw createAppException(AppErrorCode.MAX_COUNT_LIMIT) } if (!doc.order) { doc.order = count + 1 @@ -169,7 +167,7 @@ export class PageService { }) if (!newDoc) { - throw new NoContentCanBeModifiedException() + throw createAppException(AppErrorCode.NO_CONTENT_MODIFIABLE) } if (draftId) { diff --git a/apps/core/src/modules/page/page.views.ts b/apps/core/src/modules/page/page.views.ts new file mode 100644 index 00000000000..7adec225230 --- /dev/null +++ b/apps/core/src/modules/page/page.views.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' + +const PageCardSchema = z + .object({ + id: z.string(), + title: z.string(), + slug: z.string(), + subtitle: z.string().nullable().optional(), + order: z.number(), + createdAt: z.date().or(z.string()), + }) + .passthrough() + +const PageSummarySchema = PageCardSchema.extend({ + modifiedAt: z.date().or(z.string()).nullable().optional(), +}) + +const PageDetailSchema = z.object({}).passthrough() + +export const PageViews = { + card: PageCardSchema, + summary: PageSummarySchema, + detail: PageDetailSchema, +} as const + +export type PageView = keyof typeof PageViews diff --git a/apps/core/src/modules/pageproxy/pageproxy.controller.ts b/apps/core/src/modules/pageproxy/pageproxy.controller.ts index 5117adaded4..7073b7460d6 100644 --- a/apps/core/src/modules/pageproxy/pageproxy.controller.ts +++ b/apps/core/src/modules/pageproxy/pageproxy.controller.ts @@ -25,7 +25,7 @@ export class PageProxyController { ) {} @Get('/proxy/qaqdmin') - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse async getLocalBundledAdmin(@Query() query: any, @Res() reply: FastifyReply) { if ((await this.service.checkCanAccessAdminProxy()) === false) { return reply.type('application/json').status(403).send({ @@ -68,7 +68,7 @@ export class PageProxyController { } @Get('/proxy/qaqdmin/dev-proxy') - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse async proxyLocalDev(@Res() reply: FastifyReply) { const template = (await this.assetService.getAsset( '/render/local-dev.ejs', @@ -86,7 +86,7 @@ export class PageProxyController { } @Get('/proxy/*') - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse async proxyAssetRoute( @Req() request: FastifyRequest, @Res() reply: FastifyReply, diff --git a/apps/core/src/modules/poll/poll-definition.repository.ts b/apps/core/src/modules/poll/poll-definition.repository.ts index 40cc1a6bb57..625208b248b 100644 --- a/apps/core/src/modules/poll/poll-definition.repository.ts +++ b/apps/core/src/modules/poll/poll-definition.repository.ts @@ -1,13 +1,16 @@ import { Inject, Injectable } from '@nestjs/common' -import { sql } from 'drizzle-orm' import type { SQL, SQLWrapper } from 'drizzle-orm' +import { sql } from 'drizzle-orm' import { PG_DB_TOKEN } from '~/constants/system.constant' import { notes, pages, posts } from '~/database/schema' import { BaseRepository } from '~/processors/database/base.repository' import type { AppDatabase } from '~/processors/database/postgres.provider' -import type { PollContentCandidate, PollDefinition } from './poll-definition.types' +import type { + PollContentCandidate, + PollDefinition, +} from './poll-definition.types' import { extractPollDefinitions } from './poll-definition.util' @Injectable() diff --git a/apps/core/src/modules/poll/poll.controller.ts b/apps/core/src/modules/poll/poll.controller.ts index 07b1254a332..de147514d12 100644 --- a/apps/core/src/modules/poll/poll.controller.ts +++ b/apps/core/src/modules/poll/poll.controller.ts @@ -2,14 +2,12 @@ import { Body, Get, Param, Post, Query } from '@nestjs/common' import { ApiController } from '~/common/decorators/api-controller.decorator' import { CurrentReaderId } from '~/common/decorators/current-user.decorator' -import { HTTPDecorators } from '~/common/decorators/http.decorator' import { IpLocation, type IpRecord } from '~/common/decorators/ip.decorator' import { BatchPollQueryDto, PollIdDto, SubmitPollDto } from './poll.dto' import { PollService } from './poll.service' @ApiController('polls') -@HTTPDecorators.Bypass export class PollController { constructor(private readonly pollService: PollService) {} diff --git a/apps/core/src/modules/post/post.controller.ts b/apps/core/src/modules/post/post.controller.ts index 6714566144b..d144a520f66 100644 --- a/apps/core/src/modules/post/post.controller.ts +++ b/apps/core/src/modules/post/post.controller.ts @@ -16,15 +16,25 @@ import type { IpRecord } from '~/common/decorators/ip.decorator' import { IpLocation } from '~/common/decorators/ip.decorator' import { Lang } from '~/common/decorators/lang.decorator' import { HasAdminAccess } from '~/common/decorators/role.decorator' -import { TranslateFields } from '~/common/decorators/translate-fields.decorator' -import { CannotFindException } from '~/common/exceptions/cant-find.exception' +import { AppErrorCode, createAppException } from '~/common/errors' +import { withMeta } from '~/common/response/envelope.types' +import type { + ArticleTranslation, + EnrichmentEntry, +} from '~/common/response/meta.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' +import { TranslationEntryService } from '~/modules/ai/ai-translation/translation-entry.service' import { CountingService } from '~/processors/helper/helper.counting.service' import { + applyArticleTranslationInPlace, + applyTranslationEntriesInPlace, type ArticleTranslationInput, + buildArticleTranslationMeta, + type EntryMaps, + type EntryRule, TranslationService, } from '~/processors/helper/helper.translation.service' import { EntityIdDto } from '~/shared/dto/id.dto' -import { applyContentPreference } from '~/utils/content.util' import { AiInsightsService } from '../ai/ai-insights/ai-insights.service' import { parseLanguageCode } from '../ai/ai-language.util' @@ -38,7 +48,16 @@ import { SetPostPublishStatusDto, } from './post.schema' import { PostService } from './post.service' -import { PostModel } from './post.types' +import type { PostModel } from './post.types' + +const CATEGORY_NAME_RULES: ReadonlyArray = [ + { + path: 'category.name', + keyPath: 'category.name', + mode: 'entity', + idField: 'id', + }, +] @ApiController('posts') export class PostController { @@ -48,101 +67,31 @@ export class PostController { private readonly translationService: TranslationService, private readonly aiInsightsService: AiInsightsService, private readonly enrichmentService: EnrichmentService, + private readonly translationEntryService: TranslationEntryService, ) {} - private async buildPostDetailResponse( - postDocument: PostModel, - query: PostDetailQueryDto, - ip: string, - isAuthenticated: boolean | undefined, - lang?: string, - ) { - const liked = await this.countingService.getThisRecordIsLiked( - postDocument.id, - ip, - ) - - const baseData = postDocument - const relatedList = Array.isArray((baseData as any).related) - ? ((baseData as any).related as any[]) - : [] - const relatedIds = relatedList - .map((item) => item?.id) - .filter((id): id is string => Boolean(id)) - - const insightsLang = parseLanguageCode(lang) - const [translationResult, relatedTitleMap, hasInsightsInLocale] = - await Promise.all([ - this.translationService.translateArticle({ - articleId: postDocument.id, - targetLang: lang, - allowHidden: Boolean(isAuthenticated), - originalData: { - title: baseData.title, - text: baseData.text, - summary: baseData.summary, - tags: baseData.tags, - }, - }), - this.translationService.getCachedTitles(relatedIds, lang), - this.aiInsightsService - .hasInsightsInLang(postDocument.id, insightsLang) - .catch(() => false), - ]) - - const translatedRelated = relatedTitleMap.size - ? relatedList.map((item) => { - const refId = item?.id - const translatedTitle = refId ? relatedTitleMap.get(refId) : undefined - return translatedTitle ? { ...item, title: translatedTitle } : item - }) - : relatedList - - const finalDoc = applyContentPreference( - { - ...baseData, - related: translatedRelated, - title: translationResult.title, - text: translationResult.text, - summary: translationResult.summary, - tags: translationResult.tags, - ...(translationResult.content && { - content: translationResult.content, - contentFormat: translationResult.contentFormat, - }), - isTranslated: translationResult.isTranslated, - sourceLang: translationResult.sourceLang, - translationMeta: translationResult.translationMeta, - availableTranslations: translationResult.availableTranslations, - hasInsightsInLocale, - liked, - }, - query.prefer, - ) - return this.enrichmentService.attachEnrichments(finalDoc) + private async batchCategoryEntryTranslations( + lang: string, + posts: Array<{ category?: { id: unknown } | null } | null | undefined>, + ): Promise { + const categoryIds = new Set() + for (const post of posts) { + if (post?.category?.id) categoryIds.add(String(post.category.id)) + } + return this.translationEntryService.getTranslationsBatch(lang, { + entityLookups: categoryIds.size + ? [{ keyPath: 'category.name', lookupKeys: categoryIds }] + : [], + }) } @Get('/') - @TranslateFields({ - path: 'data[].category.name', - keyPath: 'category.name', - idField: 'id', - }) async getPaginate( @Query() query: PostPagerDto, @HasAdminAccess() isAuthenticated: boolean, @Lang() lang?: string, ) { - const { - size, - select, - page, - year, - sortBy, - sortOrder, - truncate, - categoryIds, - } = query + const { size, page, year, sortBy, sortOrder, truncate, categoryIds } = query const res = await this.postService.listPaginated({ size, @@ -151,92 +100,80 @@ export class PostController { categoryIds, publishedOnly: !isAuthenticated, sortBy: sortBy as any, - sortOrder: sortOrder as 1 | -1 | undefined, + sortOrder: sortOrder === 'asc' ? 1 : -1, }) - const translationInputs: ArticleTranslationInput[] = [] - for (const doc of res.data) { - const originalText = doc.text + const articleInputs: ArticleTranslationInput[] = res.data + .filter((doc) => typeof doc.text === 'string') + .map((doc) => ({ + id: String(doc.id), + title: doc.title, + text: doc.text, + meta: doc.meta as { lang?: string } | undefined, + contentFormat: doc.contentFormat, + content: doc.content, + modifiedAt: doc.modifiedAt, + createdAt: doc.createdAt, + })) + + const [{ results: translationResults, meta: translationMeta }, entryMaps] = + await Promise.all([ + this.translationService.collectArticleTranslations({ + articles: articleInputs, + targetLang: lang, + fields: ['title', 'text', 'content', 'contentFormat'], + }), + this.batchCategoryEntryTranslations(lang ?? '', res.data), + ]) - if (lang && typeof originalText === 'string') { - translationInputs.push({ - id: String(doc.id), - title: doc.title, - text: originalText, - summary: doc.summary, - tags: doc.tags, - meta: doc.meta as { lang?: string } | undefined, - contentFormat: doc.contentFormat, - content: doc.content, - modifiedAt: doc.modifiedAt, - createdAt: doc.createdAt, + for (const doc of res.data) { + const tr = translationResults.get(String(doc.id)) + if (tr?.isTranslated) { + applyArticleTranslationInPlace(doc as Record, tr as any, { + fields: ['title', 'text', 'content', 'contentFormat'], }) } + } + if (lang) { + for (const doc of res.data) { + applyTranslationEntriesInPlace( + doc as Record, + entryMaps, + CATEGORY_NAME_RULES, + ) + } + } + + for (const doc of res.data) { if (truncate) { doc.text = doc.text.slice(0, truncate) - // List view renders only a text/summary preview — drop the lexical - // `content` tree too, else truncated rows still ship 100s of KB. doc.content = null } } - if (select) { - // Always preserve `id` and `category` to keep response shape sound: - // `id` is the row key, `category` is a joined value the legacy - // aggregate pipeline emitted after the `$project` stage. - const selected = new Set( - select - .split(' ') - .map((s) => s.trim().replace(/^[+-]/, '')) - .filter(Boolean), - ) - selected.add('id') - selected.add('category') - res.data = res.data.map((doc) => - Object.fromEntries( - Object.entries(doc).filter(([key]) => selected.has(key)), - ), - ) as typeof res.data - } - - if (lang && translationInputs.length) { - const translationResults = - await this.translationService.translateArticleList({ - articles: translationInputs, - targetLang: lang, - }) + const metaBuilder = new MetaObjectBuilder().view('card').pagination({ + page: res.pagination.currentPage, + size: res.pagination.size, + total: res.pagination.total, + totalPages: res.pagination.totalPage, + }) - res.data = res.data.map((doc) => { - const docId = String(doc.id) - const translation = translationResults.get(docId) - if (!translation?.isTranslated) { - return doc - } - - return { - ...doc, - title: translation.title, - text: translation.text, - summary: translation.summary, - tags: translation.tags, - isTranslated: translation.isTranslated, - translationMeta: translation.translationMeta, - } - }) as typeof res.data + if (translationMeta.size > 0) { + metaBuilder.translation(translationMeta) } - return res + return withMeta(res.data, metaBuilder.build()) } @Get('/get-url/:slug') async getBySlug(@Param('slug') slug: string) { if (typeof slug !== 'string') { - throw new CannotFindException() + throw createAppException(AppErrorCode.POST_NOT_FOUND) } const doc = await this.postService.findBySlug(slug) if (!doc) { - throw new CannotFindException() + throw createAppException(AppErrorCode.POST_NOT_FOUND) } return { @@ -244,39 +181,7 @@ export class PostController { } } - @Get('/:id') - @TranslateFields({ - path: 'category.name', - keyPath: 'category.name', - idField: 'id', - }) - async getById( - @Param() params: EntityIdDto, - @Query() query: PostDetailQueryDto = {} as PostDetailQueryDto, - @IpLocation() { ip }: IpRecord = { ip: '' } as IpRecord, - @HasAdminAccess() isAuthenticated = false, - @Lang() lang?: string, - ) { - const { id } = params - const doc = await this.postService.findById(id) - if (!doc) { - throw new CannotFindException() - } - - // 非认证用户只能查看已发布的文章 - if (!isAuthenticated && !doc.isPublished) { - throw new CannotFindException() - } - - return this.buildPostDetailResponse(doc, query, ip, isAuthenticated, lang) - } - @Get('/latest') - @TranslateFields({ - path: 'category.name', - keyPath: 'category.name', - idField: 'id', - }) async getLatest( @IpLocation() ip: IpRecord, @HasAdminAccess() isAuthenticated: boolean, @@ -286,9 +191,10 @@ export class PostController { publishedOnly: !isAuthenticated, }) if (!last) { - throw new CannotFindException() + throw createAppException(AppErrorCode.POST_NOT_FOUND) } - if (!last.category?.slug) throw new CannotFindException() + if (!last.category?.slug) + throw createAppException(AppErrorCode.POST_NOT_FOUND) return this.getByCateAndSlug( { category: last.category.slug, slug: last.slug }, {} as any, @@ -298,12 +204,74 @@ export class PostController { ) } + @Get('/:id') + async getById( + @Param() params: EntityIdDto, + @HasAdminAccess() isAuthenticated: boolean, + @Lang() lang?: string, + ) { + const { id } = params + const doc = await this.postService.findById(id) + if (!doc) { + throw createAppException(AppErrorCode.POST_NOT_FOUND, { id }) + } + + if (!isAuthenticated && !doc.isPublished) { + throw createAppException(AppErrorCode.POST_NOT_FOUND, { id }) + } + + const [translationResult, entryMaps] = await Promise.all([ + this.translationService.translateArticle({ + articleId: String(doc.id), + targetLang: lang, + allowHidden: true, + originalData: { + title: doc.title, + text: doc.text, + summary: doc.summary, + tags: doc.tags, + }, + }), + this.batchCategoryEntryTranslations(lang ?? '', [doc]), + ]) + + applyArticleTranslationInPlace( + doc as Record, + translationResult, + ) + + if (lang) { + applyTranslationEntriesInPlace( + doc as Record, + entryMaps, + CATEGORY_NAME_RULES, + ) + } + + const { enrichments, ...docData } = + await this.enrichmentService.attachEnrichments(doc) + + const metaBuilder = new MetaObjectBuilder() + .view('detail') + .enrichments(enrichments as Record) + + const translationMap = new Map([ + [ + String(doc.id), + { + article: buildArticleTranslationMeta( + translationResult, + lang, + ) as ArticleTranslation, + }, + ], + ]) + metaBuilder.translation(translationMap) + + return withMeta(docData, metaBuilder.build()) + } + @Get('/:category/:slug') - @TranslateFields({ - path: 'category.name', - keyPath: 'category.name', - idField: 'id', - }) async getByCateAndSlug( @Param() params: CategoryAndSlugDto, @Query() query: PostDetailQueryDto, @@ -318,41 +286,114 @@ export class PostController { isAuthenticated, ) if (!postDocument) { - throw new CannotFindException() + throw createAppException(AppErrorCode.POST_NOT_FOUND) } - // 非认证用户只能查看已发布的文章 if (!isAuthenticated && !postDocument.isPublished) { - throw new CannotFindException() + throw createAppException(AppErrorCode.POST_NOT_FOUND) } - return this.buildPostDetailResponse( - postDocument, - query, + const liked = await this.countingService.getThisRecordIsLiked( + postDocument.id, ip, - isAuthenticated, - lang, ) + + const relatedList = Array.isArray((postDocument as any).related) + ? ((postDocument as any).related as any[]) + : [] + const relatedIds = relatedList + .map((item) => item?.id) + .filter((id): id is string => Boolean(id)) + + const insightsLang = parseLanguageCode(lang) + const [translationResult, relatedTitleMap, entryMaps, hasInsightsInLocale] = + await Promise.all([ + this.translationService.translateArticle({ + articleId: postDocument.id, + targetLang: lang, + allowHidden: Boolean(isAuthenticated), + originalData: { + title: postDocument.title, + text: postDocument.text, + summary: postDocument.summary, + tags: postDocument.tags, + }, + }), + this.translationService.getCachedTitles(relatedIds, lang), + this.batchCategoryEntryTranslations(lang ?? '', [postDocument]), + this.aiInsightsService + .hasInsightsInLang(postDocument.id, insightsLang) + .catch(() => false), + ]) + + applyArticleTranslationInPlace( + postDocument as Record, + translationResult, + ) + + if (lang) { + applyTranslationEntriesInPlace( + postDocument as Record, + entryMaps, + CATEGORY_NAME_RULES, + ) + } + + const translatedRelated = relatedTitleMap.size + ? relatedList.map((item) => { + const refId = item?.id + const translatedTitle = refId ? relatedTitleMap.get(refId) : undefined + return translatedTitle ? { ...item, title: translatedTitle } : item + }) + : relatedList + + const { related: _related, ...postEntity } = postDocument + const { enrichments, ...postData } = + await this.enrichmentService.attachEnrichments(postEntity) + + const metaBuilder = new MetaObjectBuilder() + .view('detail') + .interaction({ isLiked: liked }) + .related(translatedRelated) + .insights({ hasInLocale: hasInsightsInLocale }) + .enrichments(enrichments as Record) + + const translationMap = new Map([ + [ + String(postDocument.id), + { + article: buildArticleTranslationMeta( + translationResult, + lang, + ) as ArticleTranslation, + }, + ], + ]) + metaBuilder.translation(translationMap) + + return withMeta(postData, metaBuilder.build()) } @Post('/') @Auth() @HTTPDecorators.Idempotence() async create(@Body() body: PostDto) { - return await this.postService.create({ + const created = await this.postService.create({ ...(body as unknown as PostModel), modifiedAt: null, slug: body.slug, }) + return created } @Put('/:id') @Auth() async update(@Param() params: EntityIdDto, @Body() body: PostDto) { - return await this.postService.updateById( + const updated = await this.postService.updateById( params.id, body as unknown as PostModel, ) + return updated } @Patch('/:id') @@ -362,7 +403,6 @@ export class PostController { params.id, body as unknown as Partial, ) - return } @Delete('/:id') @@ -370,8 +410,6 @@ export class PostController { async deletePost(@Param() params: EntityIdDto) { const { id } = params await this.postService.deletePost(id) - - return } @Patch('/:id/publish') diff --git a/apps/core/src/modules/post/post.schema.ts b/apps/core/src/modules/post/post.schema.ts index f0b3b4777ab..7c144e05730 100644 --- a/apps/core/src/modules/post/post.schema.ts +++ b/apps/core/src/modules/post/post.schema.ts @@ -10,7 +10,7 @@ import { zPinDate, zPrefer, } from '~/common/zod' -import { PagerSchema } from '~/shared/dto/pager.dto' +import { createPagerSchema } from '~/shared/dto/pager.dto' import { WriteBaseSchema } from '~/shared/schema' import { ImageSchema } from '~/shared/schema/image.schema' import { ContentFormat } from '~/shared/types/content-format.type' @@ -34,7 +34,7 @@ export const PostSchema = WriteBaseSchema.extend({ ), relatedId: z.array(zEntityId).optional(), images: z.array(ImageSchema).optional(), - /** 关联的草稿 ID,发布时标记该草稿为已发布 */ + /** ID of the associated draft; marked as published when this post is published */ draftId: zEntityId.optional(), }) @@ -83,7 +83,11 @@ export class PostDetailQueryDto extends createZodDto(PostDetailQuerySchema) {} /** * Post pager schema */ -export const PostPagerSchema = PagerSchema.extend({ +export const PostPagerSchema = createPagerSchema([ + 'createdAt', + 'modifiedAt', + 'pinAt', +]).extend({ truncate: zCoerceInt.optional(), categoryIds: z .preprocess( diff --git a/apps/core/src/modules/post/post.service.ts b/apps/core/src/modules/post/post.service.ts index c6f6b407401..6a3ac20adc7 100644 --- a/apps/core/src/modules/post/post.service.ts +++ b/apps/core/src/modules/post/post.service.ts @@ -3,14 +3,10 @@ import { ModuleRef } from '@nestjs/core' import { debounce, omit } from 'es-toolkit/compat' import slugify from 'slugify' -import { - BizException, - BusinessException, -} from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { ArticleTypeEnum } from '~/constants/article.constant' import { BusinessEvents, EventScope } from '~/constants/business-event.constant' import { CollectionRefTypes } from '~/constants/db.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { EventBusEvents } from '~/constants/event-bus.constant' import { CATEGORY_SERVICE_TOKEN, @@ -161,12 +157,12 @@ export class PostService implements OnApplicationBootstrap { categoryId as any as string, ) if (!category) { - throw new BizException(ErrorCodeEnum.CategoryNotFound) + throw createAppException(AppErrorCode.CATEGORY_NOT_FOUND) } const slug = post.slug ? slugify(post.slug) : slugify(post.title) if (!(await this.isAvailableSlug(slug))) { - throw new BusinessException(ErrorCodeEnum.SlugNotAvailable) + throw createAppException(AppErrorCode.SLUG_NOT_AVAILABLE) } const relatedIds = await this.checkRelated(post) @@ -246,7 +242,7 @@ export class PostService implements OnApplicationBootstrap { oldDocument.categoryId.toString(), ) if (!oldDocumentRefCategory) { - throw new BizException(ErrorCodeEnum.CategoryNotFound) + throw createAppException(AppErrorCode.CATEGORY_NOT_FOUND) } const oldSlugMeta = { slug: oldDocument.slug, @@ -287,9 +283,12 @@ export class PostService implements OnApplicationBootstrap { const categoryDocument = await this.getCategoryBySlug(categorySlug) if (!categoryDocument) { const trackedPost = await findTrackedPost() - if (!trackedPost) throw new BizException(ErrorCodeEnum.CategoryNotFound) + if (!trackedPost) + throw createAppException(AppErrorCode.CATEGORY_NOT_FOUND) if (!isAuthenticated && !trackedPost.isPublished) { - throw new BizException(ErrorCodeEnum.PostNotFound) + throw createAppException(AppErrorCode.POST_NOT_FOUND, { + id: trackedPost.id, + }) } return trackedPost } @@ -303,7 +302,9 @@ export class PostService implements OnApplicationBootstrap { const trackedPost = await findTrackedPost() if (trackedPost && !isAuthenticated && !trackedPost.isPublished) { - throw new BizException(ErrorCodeEnum.PostNotFound) + throw createAppException(AppErrorCode.POST_NOT_FOUND, { + id: trackedPost.id, + }) } return trackedPost } @@ -316,7 +317,7 @@ export class PostService implements OnApplicationBootstrap { const oldDocument = await this.findById(id) if (!oldDocument) { - throw new BizException(ErrorCodeEnum.PostNotFound) + throw createAppException(AppErrorCode.POST_NOT_FOUND, { id }) } const { draftId } = data @@ -325,7 +326,7 @@ export class PostService implements OnApplicationBootstrap { const category = await this.categoryService.findCategoryById( categoryId as any as string, ) - if (!category) throw new BizException(ErrorCodeEnum.CategoryNotFound) + if (!category) throw createAppException(AppErrorCode.CATEGORY_NOT_FOUND) } if ([data.text, data.title, data.slug].some(isDefined)) { @@ -335,7 +336,7 @@ export class PostService implements OnApplicationBootstrap { if (data.slug && data.slug !== oldDocument.slug) { data.slug = slugify(data.slug) if (!(await this.isAvailableSlug(data.slug))) { - throw new BusinessException(ErrorCodeEnum.SlugNotAvailable) + throw createAppException(AppErrorCode.SLUG_NOT_AVAILABLE) } } @@ -462,12 +463,12 @@ export class PostService implements OnApplicationBootstrap { const relatedPosts = await this.postRepository.findManyByIds(data.relatedId) if (relatedPosts.length !== data.relatedId.length) { - throw new BizException(ErrorCodeEnum.PostRelatedNotExists) + throw createAppException(AppErrorCode.POST_RELATED_NOT_EXISTS) } return relatedPosts.map((post) => { if (post.id === data.id) { - throw new BizException(ErrorCodeEnum.PostSelfRelation) + throw createAppException(AppErrorCode.POST_SELF_RELATION) } return post.id }) diff --git a/apps/core/src/modules/post/post.views.ts b/apps/core/src/modules/post/post.views.ts new file mode 100644 index 00000000000..c1d55fa0400 --- /dev/null +++ b/apps/core/src/modules/post/post.views.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' + +const PostCardSchema = z + .object({ + id: z.string(), + title: z.string(), + slug: z.string(), + summary: z.string().nullable().optional(), + categoryId: z.string(), + category: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string(), + type: z.number(), + }) + .optional(), + createdAt: z.date().or(z.string()), + cover: z.string().nullable().optional(), + isPublished: z.boolean(), + pinAt: z.date().or(z.string()).nullable().optional(), + }) + .passthrough() + +const PostSummarySchema = PostCardSchema.extend({ + tags: z.array(z.string()).optional(), + modifiedAt: z.date().or(z.string()).nullable().optional(), +}) + +const PostDetailSchema = z.object({}).passthrough() + +export const PostViews = { + card: PostCardSchema, + summary: PostSummarySchema, + detail: PostDetailSchema, +} as const + +export type PostView = keyof typeof PostViews diff --git a/apps/core/src/modules/project/project.controller.ts b/apps/core/src/modules/project/project.controller.ts index 5060bad2e42..8f3062972d0 100644 --- a/apps/core/src/modules/project/project.controller.ts +++ b/apps/core/src/modules/project/project.controller.ts @@ -1,7 +1,73 @@ -import { BasePgCrudFactory } from '~/transformers/crud-factor.pg.transformer' +import { + Body, + Delete, + Get, + Inject, + Param, + Post, + Put, + Query, +} from '@nestjs/common' + +import { ApiController } from '~/common/decorators/api-controller.decorator' +import { Auth } from '~/common/decorators/auth.decorator' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' +import { EntityIdDto } from '~/shared/dto/id.dto' +import { BasicPagerDto } from '~/shared/dto/pager.dto' import { ProjectRepository } from './project.repository' -export class ProjectController extends BasePgCrudFactory({ - repository: ProjectRepository, -}) {} +@ApiController('projects') +export class ProjectController { + constructor( + @Inject(ProjectRepository) private readonly repository: ProjectRepository, + ) {} + + @Get('/') + async gets(@Query() pager: BasicPagerDto) { + const size = pager.size ?? 10 + const page = pager.page ?? 1 + const result = await this.repository.list(page, size) + const p = result.pagination + return withMeta( + result.data, + new MetaObjectBuilder() + .pagination({ + page: p.currentPage, + size: p.size, + total: p.total, + totalPages: p.totalPage, + }) + .build(), + ) + } + + @Get('/all') + async getAll() { + return this.repository.findAll() + } + + @Get('/:id') + async get(@Param() param: EntityIdDto) { + return this.repository.findById(param.id) + } + + @Post('/') + @Auth() + async create(@Body() body: any) { + return this.repository.create(body) + } + + @Put('/:id') + @Auth() + async update(@Body() body: any, @Param() param: EntityIdDto) { + return this.repository.update(param.id, body) + } + + @Delete('/:id') + @Auth() + async delete(@Param() param: EntityIdDto) { + return this.repository.deleteById(param.id) + } +} diff --git a/apps/core/src/modules/reader/reader.controller.ts b/apps/core/src/modules/reader/reader.controller.ts index 5bf440b4afe..a3d008c852e 100644 --- a/apps/core/src/modules/reader/reader.controller.ts +++ b/apps/core/src/modules/reader/reader.controller.ts @@ -2,8 +2,10 @@ import { Body, Get, Patch, Query } from '@nestjs/common' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' import { StringIdDto } from '~/shared/dto/id.dto' -import { PagerDto } from '~/shared/dto/pager.dto' +import { BasicPagerDto } from '~/shared/dto/pager.dto' import { ReaderService } from './reader.service' @@ -11,10 +13,23 @@ import { ReaderService } from './reader.service' @Auth() export class ReaderAuthController { constructor(private readonly readerService: ReaderService) {} + @Get('/') - async find(@Query() query: PagerDto) { + async find(@Query() query: BasicPagerDto) { const { page = 1, size = 20 } = query - return this.readerService.findPaginated(page, size) + const result = await this.readerService.findPaginated(page, size) + const p = result.pagination + return withMeta( + result.data, + new MetaObjectBuilder() + .pagination({ + page: p.currentPage, + size: p.size, + total: p.total, + totalPages: p.totalPage, + }) + .build(), + ) } @Patch('/transfer-owner') diff --git a/apps/core/src/modules/recently/recently.controller.ts b/apps/core/src/modules/recently/recently.controller.ts index d7b0279669c..c5a785abdce 100644 --- a/apps/core/src/modules/recently/recently.controller.ts +++ b/apps/core/src/modules/recently/recently.controller.ts @@ -5,22 +5,21 @@ import { Auth } from '~/common/decorators/auth.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' import type { IpRecord } from '~/common/decorators/ip.decorator' import { IpLocation } from '~/common/decorators/ip.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { EntityIdDto } from '~/shared/dto/id.dto' import { OffsetDto } from '~/shared/dto/pager.dto' import { RecentlyAttitudeDto, RecentlyDto } from './recently.schema' import { RecentlyService } from './recently.service' -import { RecentlyModel } from './recently.types' +import type { RecentlyModel } from './recently.types' @ApiController(['recently', 'shorthand']) export class RecentlyController { constructor(private readonly recentlyService: RecentlyService) {} @Get('/latest') - async getLatestOne() { - return await this.recentlyService.getLatestOne() + getLatestOne() { + return this.recentlyService.getLatestOne() } @Get('/all') @@ -33,25 +32,24 @@ export class RecentlyController { const { before, after, size } = query if (before && after) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'you can only choose `before` or `after`', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'you can only choose `before` or `after`', + }) } - return await this.recentlyService.getOffset({ before, after, size }) + return this.recentlyService.getOffset({ before, after, size }) } @Get('/:id') - async getOne(@Param() { id }: EntityIdDto) { - return await this.recentlyService.getOne(id) + getOne(@Param() { id }: EntityIdDto) { + return this.recentlyService.getOne(id) } @Post('/') @HTTPDecorators.Idempotence() @Auth() - async create(@Body() body: RecentlyDto) { - return await this.recentlyService.create(body as unknown as RecentlyModel) + create(@Body() body: RecentlyDto) { + return this.recentlyService.create(body as unknown as RecentlyModel) } @Delete('/:id') @@ -59,10 +57,8 @@ export class RecentlyController { async del(@Param() { id }: EntityIdDto) { const res = await this.recentlyService.delete(id) if (!res) { - throw new BizException(ErrorCodeEnum.EntryNotFound) + throw createAppException(AppErrorCode.RECENTLY_NOT_FOUND, { id }) } - - return } @Put('/:id') @@ -73,15 +69,11 @@ export class RecentlyController { body as unknown as Partial, ) if (!res) { - throw new BizException(ErrorCodeEnum.EntryNotFound) + throw createAppException(AppErrorCode.RECENTLY_NOT_FOUND, { id }) } - return res } - /** - * 表态:点赞,点踩 - */ @Get('/attitude/:id') async attitude( @Param() { id }: EntityIdDto, @@ -93,8 +85,6 @@ export class RecentlyController { id, ip, }) - return { - code: result, - } + return { code: result } } } diff --git a/apps/core/src/modules/recently/recently.service.ts b/apps/core/src/modules/recently/recently.service.ts index eb15410fd07..76922576451 100644 --- a/apps/core/src/modules/recently/recently.service.ts +++ b/apps/core/src/modules/recently/recently.service.ts @@ -1,12 +1,10 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common' import { RequestContext } from '~/common/contexts/request.context' -import { BizException } from '~/common/exceptions/biz.exception' -import { CannotFindException } from '~/common/exceptions/cant-find.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { BusinessEvents, EventScope } from '~/constants/business-event.constant' import { RedisKeys } from '~/constants/cache.constant' import { CollectionRefTypes } from '~/constants/db.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { DatabaseService } from '~/processors/database/database.service' import { EventManagerService } from '~/processors/helper/helper.event.service' import { RedisService } from '~/processors/redis/redis.service' @@ -212,7 +210,7 @@ export class RecentlyService { if (refId) { const existModel = await this.databaseService.findGlobalById(refId) if (!existModel || !existModel.type) { - throw new BizException(ErrorCodeEnum.RefModelNotFound) + throw createAppException(AppErrorCode.REF_MODEL_NOT_FOUND) } refType = existModel.type } @@ -287,9 +285,9 @@ export class RecentlyService { attitude: RecentlyAttitudeEnum ip: string }) { - if (!ip) throw new BizException(ErrorCodeEnum.CannotGetIp) + if (!ip) throw createAppException(AppErrorCode.CANNOT_GET_IP) const model = await this.recentlyRepository.findById(id) - if (!model) throw new CannotFindException() + if (!model) throw createAppException(AppErrorCode.NOT_FOUND) const redis = this.redisService.getClient() const redisKey = getRedisKey(RedisKeys.RecentlyAttitude) diff --git a/apps/core/src/modules/recently/recently.views.ts b/apps/core/src/modules/recently/recently.views.ts new file mode 100644 index 00000000000..eee61c051ba --- /dev/null +++ b/apps/core/src/modules/recently/recently.views.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' + +const RecentlyCardSchema = z + .object({ + id: z.string(), + content: z.string(), + type: z.string(), + createdAt: z.date().or(z.string()), + }) + .passthrough() + +const RecentlyDetailSchema = z.object({}).passthrough() + +export const RecentlyViews = { + card: RecentlyCardSchema, + detail: RecentlyDetailSchema, +} as const + +export type RecentlyView = keyof typeof RecentlyViews diff --git a/apps/core/src/modules/render/render.controller.ts b/apps/core/src/modules/render/render.controller.ts index 5ef0a12c198..ddc0a247162 100644 --- a/apps/core/src/modules/render/render.controller.ts +++ b/apps/core/src/modules/render/render.controller.ts @@ -17,9 +17,8 @@ import { RequestContext } from '~/common/contexts/request.context' import { Auth } from '~/common/decorators/auth.decorator' import { HttpCache } from '~/common/decorators/cache.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { CollectionRefTypes } from '~/constants/db.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { EntityIdDto } from '~/shared/dto/id.dto' import { getShortDateTime } from '~/utils/time.util' @@ -32,7 +31,6 @@ import type { PageModel } from '../page/page.types' import type { PostModel } from '../post/post.types' @Controller('/render') -@HTTPDecorators.Bypass export class RenderEjsController { constructor( private readonly service: MarkdownService, @@ -41,6 +39,7 @@ export class RenderEjsController { ) {} @Get('/markdown/:id') + @HTTPDecorators.RawResponse @Header('content-type', 'text/html') @CacheTTL(60 * 60) async renderArticle( @@ -67,7 +66,7 @@ export class RenderEjsController { ('password' in document && !isNil(document.password)) if (!hasAdminAccess && isPrivateOrEncrypt) { - throw new BizException(ErrorCodeEnum.PostHiddenOrEncrypted) + throw createAppException(AppErrorCode.POST_HIDDEN_OR_ENCRYPTED) } const relativePath = (() => { @@ -96,18 +95,16 @@ export class RenderEjsController { const html = ejs.render(await this.service.getMarkdownEjsRenderTemplate(), { ...structure, - info: isPrivateOrEncrypt ? '正在查看的文章还未公开' : undefined, + info: isPrivateOrEncrypt ? 'This article is not yet public.' : undefined, title: document.title, - footer: `
本文渲染于 ${getShortDateTime( + footer: `
Rendered on ${getShortDateTime( new Date(), - )},由 marked.js 解析生成,用时 ${(performance.now() - now).toFixed( - 2, - )}ms
-
作者:${username},撰写于${dayjs(document.createdAt).format( + )} by marked.js, in ${(performance.now() - now).toFixed(2)}ms
+
Author: ${username}, written on ${dayjs(document.createdAt).format( 'llll', )}
-
原文地址:${decodeURIComponent( + `, @@ -117,6 +114,7 @@ export class RenderEjsController { } @Post('/markdown') + @HTTPDecorators.RawResponse @HttpCache.disable @Auth() @Header('content-type', 'text/html') diff --git a/apps/core/src/modules/say/say.controller.ts b/apps/core/src/modules/say/say.controller.ts index e396bed4492..24a9de447a2 100644 --- a/apps/core/src/modules/say/say.controller.ts +++ b/apps/core/src/modules/say/say.controller.ts @@ -1,16 +1,103 @@ -import { Get } from '@nestjs/common' +import { + Body, + Delete, + Get, + Inject, + Param, + Post, + Put, + Query, +} from '@nestjs/common' import { sample } from 'es-toolkit/compat' +import { createZodDto } from 'nestjs-zod' +import { z } from 'zod' -import { BasePgCrudFactory } from '~/transformers/crud-factor.pg.transformer' +import { ApiController } from '~/common/decorators/api-controller.decorator' +import { Auth } from '~/common/decorators/auth.decorator' +import { AppErrorCode, createAppException } from '~/common/errors' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' +import { EntityIdDto } from '~/shared/dto/id.dto' +import { BasicPagerDto } from '~/shared/dto/pager.dto' import { SayRepository } from './say.repository' -export class SayController extends BasePgCrudFactory({ - repository: SayRepository, -}) { +const SayCreateSchema = z.object({ + text: z.string().min(1), + source: z.string().nullable().optional(), + author: z.string().nullable().optional(), +}) + +class SayCreateBodyDto extends createZodDto(SayCreateSchema) {} +class SayPatchBodyDto extends createZodDto(SayCreateSchema.partial()) {} + +@ApiController('says') +export class SayController { + constructor( + @Inject(SayRepository) private readonly repository: SayRepository, + ) {} + + @Get('/') + async gets(@Query() pager: BasicPagerDto) { + const size = pager.size ?? 10 + const page = pager.page ?? 1 + const result = await this.repository.list(page, size) + const p = result.pagination + return withMeta( + result.data, + new MetaObjectBuilder() + .pagination({ + page: p.currentPage, + size: p.size, + total: p.total, + totalPages: p.totalPage, + }) + .build(), + ) + } + @Get('/random') async getRandomOne() { const rows = await this.repository.findAll() - return { data: rows.length === 0 ? null : sample(rows) } + return rows.length === 0 ? null : sample(rows) + } + + @Get('/all') + async getAll() { + return this.repository.findAll() + } + + @Get('/:id') + async getOne(@Param() { id }: EntityIdDto) { + const row = await this.repository.findById(id) + if (!row) { + throw createAppException(AppErrorCode.NOT_FOUND, { id }) + } + return row + } + + @Post('/') + @Auth() + async create(@Body() body: SayCreateBodyDto) { + return this.repository.create(body) + } + + @Put('/:id') + @Auth() + async update(@Param() { id }: EntityIdDto, @Body() body: SayPatchBodyDto) { + const row = await this.repository.update(id, body) + if (!row) { + throw createAppException(AppErrorCode.NOT_FOUND, { id }) + } + return row + } + + @Delete('/:id') + @Auth() + async remove(@Param() { id }: EntityIdDto) { + const row = await this.repository.deleteById(id) + if (!row) { + throw createAppException(AppErrorCode.NOT_FOUND, { id }) + } } } diff --git a/apps/core/src/modules/search/search.controller.ts b/apps/core/src/modules/search/search.controller.ts index f83a42c30c5..62f31507441 100644 --- a/apps/core/src/modules/search/search.controller.ts +++ b/apps/core/src/modules/search/search.controller.ts @@ -3,37 +3,78 @@ import { Get, Param, Post, Query } from '@nestjs/common' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HttpCache } from '~/common/decorators/cache.decorator' -import { TranslateFields } from '~/common/decorators/translate-fields.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { Lang } from '~/common/decorators/lang.decorator' +import { AppErrorCode, createAppException } from '~/common/errors' +import { TranslationEntryService } from '~/modules/ai/ai-translation/translation-entry.service' import { SearchAdminListDto, SearchDto, SearchRebuildQueryDto, SearchRebuildRefParamDto, } from '~/modules/search/search.schema' +import { + applyTranslationEntriesInPlace, + type EntryMaps, + type EntryRule, +} from '~/processors/helper/helper.translation.service' import { SearchService } from './search.service' -// Search results mix post/note/page; only post items carry `category`, so the -// objectScan path naturally skips notes & pages. -const SEARCH_TRANSLATE_FIELDS = [ +const CATEGORY_NAME_RULES: ReadonlyArray = [ { - path: 'data[].category.name', + path: 'category.name', keyPath: 'category.name', + mode: 'entity', idField: 'id', }, -] as const +] @ApiController('search') export class SearchController { - constructor(private readonly searchService: SearchService) {} + constructor( + private readonly searchService: SearchService, + private readonly translationEntryService: TranslationEntryService, + ) {} + + private async batchCategoryEntryTranslations( + lang: string, + items: Array< + { type?: string; category?: { id: unknown } | null } | null | undefined + >, + ): Promise { + const categoryIds = new Set() + for (const item of items) { + if (item?.type === 'post' && item?.category?.id) { + categoryIds.add(String(item.category.id)) + } + } + return this.translationEntryService.getTranslationsBatch(lang, { + entityLookups: categoryIds.size + ? [{ keyPath: 'category.name', lookupKeys: categoryIds }] + : [], + }) + } @HttpCache.disable @Get() - @TranslateFields(...SEARCH_TRANSLATE_FIELDS) - search(@Query() query: SearchDto) { - return this.searchService.search(query) + async search(@Query() query: SearchDto, @Lang() lang?: string) { + const result = await this.searchService.search(query) + if (lang) { + const entryMaps = await this.batchCategoryEntryTranslations( + lang, + result.data, + ) + for (const item of result.data) { + if ((item as any)?.type === 'post' && (item as any)?.category) { + applyTranslationEntriesInPlace( + item as Record, + entryMaps, + CATEGORY_NAME_RULES, + ) + } + } + } + return result } @Post('/rebuild') @@ -59,23 +100,45 @@ export class SearchController { @Get('/:type') @HttpCache.disable - @TranslateFields(...SEARCH_TRANSLATE_FIELDS) - searchByType(@Query() query: SearchDto, @Param('type') type: string) { + async searchByType( + @Query() query: SearchDto, + @Param('type') type: string, + @Lang() lang?: string, + ) { type = type.toLowerCase() + let result: any switch (type) { case 'post': { - return this.searchService.searchPost(query) + result = await this.searchService.searchPost(query) + break } case 'note': { - return this.searchService.searchNote(query) + result = await this.searchService.searchNote(query) + break } case 'page': { - return this.searchService.searchPage(query) + result = await this.searchService.searchPage(query) + break } - default: { - throw new BizException(ErrorCodeEnum.InvalidSearchType, type) + throw createAppException(AppErrorCode.INVALID_SEARCH_TYPE, { type }) + } + } + if (lang && type === 'post') { + const entryMaps = await this.batchCategoryEntryTranslations( + lang, + result.data, + ) + for (const item of result.data) { + if ((item as any)?.category) { + applyTranslationEntriesInPlace( + item as Record, + entryMaps, + CATEGORY_NAME_RULES, + ) + } } } + return result } } diff --git a/apps/core/src/modules/search/search.schema.ts b/apps/core/src/modules/search/search.schema.ts index ce7bd561e98..d3a8ad9e18c 100644 --- a/apps/core/src/modules/search/search.schema.ts +++ b/apps/core/src/modules/search/search.schema.ts @@ -2,7 +2,7 @@ import { createZodDto } from 'nestjs-zod' import { z } from 'zod' import { zNonEmptyString } from '~/common/zod' -import { PagerSchema } from '~/shared/dto/pager.dto' +import { BasicPagerSchema } from '~/shared/dto/pager.dto' const langField = z .string() @@ -11,7 +11,7 @@ const langField = z .transform((val) => val.toLowerCase()) .optional() -export const SearchSchema = PagerSchema.extend({ +export const SearchSchema = BasicPagerSchema.extend({ keyword: zNonEmptyString, orderBy: zNonEmptyString.optional(), order: z.preprocess( @@ -48,7 +48,7 @@ export class SearchRebuildRefParamDto extends createZodDto( SearchRebuildRefParamSchema, ) {} -export const SearchAdminListSchema = PagerSchema.extend({ +export const SearchAdminListSchema = BasicPagerSchema.extend({ refType: z.enum(['post', 'note', 'page']).optional(), lang: langField, keyword: z.string().trim().min(1).optional(), diff --git a/apps/core/src/modules/search/search.views.ts b/apps/core/src/modules/search/search.views.ts new file mode 100644 index 00000000000..508f0e34f97 --- /dev/null +++ b/apps/core/src/modules/search/search.views.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +const SearchResultSchema = z.object({}).passthrough() + +export const SearchViews = { + result: SearchResultSchema, +} as const + +export type SearchView = keyof typeof SearchViews diff --git a/apps/core/src/modules/server-time/server-time.controller.ts b/apps/core/src/modules/server-time/server-time.controller.ts index 7bf53bb7a63..829cc5980cd 100644 --- a/apps/core/src/modules/server-time/server-time.controller.ts +++ b/apps/core/src/modules/server-time/server-time.controller.ts @@ -8,6 +8,6 @@ import { HTTPDecorators } from '~/common/decorators/http.decorator' export class ServerTimeController { @Get('/server-time') @HttpCache.disable - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse async serverTime() {} } diff --git a/apps/core/src/modules/serverless/pack/built-in/geocode_location.ts b/apps/core/src/modules/serverless/pack/built-in/geocode_location.ts index b1c931a660f..9de3585c154 100644 --- a/apps/core/src/modules/serverless/pack/built-in/geocode_location.ts +++ b/apps/core/src/modules/serverless/pack/built-in/geocode_location.ts @@ -9,7 +9,7 @@ export default async function handler(ctx: Context) { const gaodemapKey = adminExtra?.gaodemapKey || secret.gaodemapKey if (!gaodemapKey) { - ctx.throws(400, '高德地图 API Key 未配置') + ctx.throws(400, 'Amap (Gaode) API key is not configured') } const { data } = await axios.get( \`https://restapi.amap.com/v3/geocode/regeo?key=\${gaodemapKey}&location=\` + @@ -17,7 +17,7 @@ export default async function handler(ctx: Context) { ).catch(() => null) if (!data) { - ctx.throws(500, '高德地图 API 调用失败') + ctx.throws(500, 'Amap (Gaode) API request failed') } return data } diff --git a/apps/core/src/modules/serverless/pack/built-in/geocode_search.ts b/apps/core/src/modules/serverless/pack/built-in/geocode_search.ts index 4618fa3968a..ae80bca92ca 100644 --- a/apps/core/src/modules/serverless/pack/built-in/geocode_search.ts +++ b/apps/core/src/modules/serverless/pack/built-in/geocode_search.ts @@ -13,7 +13,7 @@ export default async function handler(ctx: Context) { const gaodemapKey = adminExtra?.gaodemapKey || secret.gaodemapKey if (!gaodemapKey) { - ctx.throws(422, '高德地图 API Key 未配置') + ctx.throws(422, 'Amap (Gaode) API key is not configured') } const params = new URLSearchParams([ @@ -31,7 +31,7 @@ export default async function handler(ctx: Context) { }) if (!data) { ctx.throws(500, - \`高德地图 API 调用失败,\${errorMessage}\`, + \`Amap (Gaode) API request failed: \${errorMessage}\`, ) } return data diff --git a/apps/core/src/modules/serverless/pack/built-in/ip-query.ts b/apps/core/src/modules/serverless/pack/built-in/ip-query.ts index cfe8d2bc4c3..bb61f442f30 100644 --- a/apps/core/src/modules/serverless/pack/built-in/ip-query.ts +++ b/apps/core/src/modules/serverless/pack/built-in/ip-query.ts @@ -1,7 +1,7 @@ import type { BuiltInFunctionObject } from '../../function.types' const ipQueryFnCode = - "import { isIPv4, isIPv6 } from 'net'\n\nconst TIMEOUT = 5000\n\nexport default async function handler(ctx: Context, timeout = TIMEOUT) {\n const { ip } = ctx.req.query\n\n if (!ip) { ctx.res.throws(422, 'ip is empty') }\n const cache = ctx.storage.cache\n const hasCatch = await cache.get(ip)\n if (hasCatch) return hasCatch\n\n const result = await getIp(ctx, ip);\n await cache.set(ip, result)\n return result\n}\n\nasync function getIp(ctx: Context, ip: string, timeout = TIMEOUT) {\n const isV4 = isIPv4(ip)\n const isV6 = isIPv6(ip)\n const { axios } = await (ctx.getService('http'))\n if (!isV4 && !isV6) {\n ctx.throws(422, 'Invalid IP')\n }\n try {\n const data = await axios.get('https://freeipapi.com/api/json/' + ip).then(data => data.data) as FreeIpApiResponse\n const res: FinalIpRecord = {\n cityName: data.cityName,\n countryName: data.countryName,\n ip: data.ipAddress,\n ispDomain: data.asnOrganization || '',\n ownerDomain: data.asnOrganization || '',\n regionName: data.regionName\n }\n\n return res\n } catch (e) {\n ctx.throws(500, `IP API 调用失败,${e.message}`)\n }\n};\n\n\ninterface FinalIpRecord {\n cityName: string\n countryName: string\n ip: string\n ispDomain: string\n ownerDomain: string\n regionName: string\n}\ninterface FreeIpApiResponse {\n ipVersion: number;\n ipAddress: string;\n latitude: number;\n longitude: number;\n countryName: string;\n countryCode: string;\n cityName: string;\n regionName: string;\n regionCode: string;\n continent: string;\n continentCode: string;\n asn: string;\n asnOrganization: string;\n isProxy: boolean;\n}" + "import { isIPv4, isIPv6 } from 'net'\n\nconst TIMEOUT = 5000\n\nexport default async function handler(ctx: Context, timeout = TIMEOUT) {\n const { ip } = ctx.req.query\n\n if (!ip) { ctx.res.throws(422, 'ip is empty') }\n const cache = ctx.storage.cache\n const hasCatch = await cache.get(ip)\n if (hasCatch) return hasCatch\n\n const result = await getIp(ctx, ip);\n await cache.set(ip, result)\n return result\n}\n\nasync function getIp(ctx: Context, ip: string, timeout = TIMEOUT) {\n const isV4 = isIPv4(ip)\n const isV6 = isIPv6(ip)\n const { axios } = await (ctx.getService('http'))\n if (!isV4 && !isV6) {\n ctx.throws(422, 'Invalid IP')\n }\n try {\n const data = await axios.get('https://freeipapi.com/api/json/' + ip).then(data => data.data) as FreeIpApiResponse\n const res: FinalIpRecord = {\n cityName: data.cityName,\n countryName: data.countryName,\n ip: data.ipAddress,\n ispDomain: data.asnOrganization || '',\n ownerDomain: data.asnOrganization || '',\n regionName: data.regionName\n }\n\n return res\n } catch (e) {\n ctx.throws(500, `IP API request failed: ${e.message}`)\n }\n};\n\n\ninterface FinalIpRecord {\n cityName: string\n countryName: string\n ip: string\n ispDomain: string\n ownerDomain: string\n regionName: string\n}\ninterface FreeIpApiResponse {\n ipVersion: number;\n ipAddress: string;\n latitude: number;\n longitude: number;\n countryName: string;\n countryCode: string;\n cityName: string;\n regionName: string;\n regionCode: string;\n continent: string;\n continentCode: string;\n asn: string;\n asnOrganization: string;\n isProxy: boolean;\n}" export default { code: ipQueryFnCode, diff --git a/apps/core/src/modules/serverless/serverless.controller.ts b/apps/core/src/modules/serverless/serverless.controller.ts index 1eb5bed9ff1..5cb8c0f85ec 100644 --- a/apps/core/src/modules/serverless/serverless.controller.ts +++ b/apps/core/src/modules/serverless/serverless.controller.ts @@ -16,8 +16,7 @@ import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' import { HasAdminAccess } from '~/common/decorators/role.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { EntityIdDto } from '~/shared/dto/id.dto' import { getSandboxTypeDeclaration } from '~/utils/sandbox' @@ -34,7 +33,7 @@ export class ServerlessController { @Get('/types') @Auth() - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse @CacheTTL(60 * 60 * 24) getCodeDefined() { return getSandboxTypeDeclaration() @@ -57,7 +56,7 @@ export class ServerlessController { @Get('/compiled/:id') @Auth() - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse async getCompiledCode(@Param() param: EntityIdDto) { const snippet = await this.serverlessService.repository.findById(param.id) if (!snippet) { @@ -83,7 +82,7 @@ export class ServerlessController { ttl: 5000, }, }) - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse async runServerlessFunctionWildcard( @Param() param: ServerlessReferenceDto, @HasAdminAccess() hasAdminAccess: boolean, @@ -101,7 +100,7 @@ export class ServerlessController { ttl: 5000, }, }) - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse async runServerlessFunction( @Param() param: ServerlessReferenceDto, @HasAdminAccess() hasAdminAccess: boolean, @@ -118,23 +117,21 @@ export class ServerlessController { requestMethod, ) - const errorPath = `Path: /${reference}/${name}` + const errorPath = `/${reference}/${name}` if (!snippet) { - throw new BizException( - ErrorCodeEnum.FunctionNotFound, - `serverless function is not exist, ${errorPath}`, - ) + throw createAppException(AppErrorCode.FUNCTION_NOT_FOUND, { + path: errorPath, + }) } if (!snippet.enable) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - `serverless function is not enabled, ${errorPath}`, - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: `serverless function is not enabled, ${errorPath}`, + }) } if (snippet.private && !hasAdminAccess) { - throw new BizException(ErrorCodeEnum.ServerlessNoPermission) + throw createAppException(AppErrorCode.SERVERLESS_NO_PERMISSION) } const result = @@ -149,7 +146,7 @@ export class ServerlessController { } /** - * 重置内建函数,过期的内建函数会被删除 + * Reset a built-in function. Stale built-in functions are deleted. */ @Delete('/reset/:id') @Auth() @@ -158,7 +155,7 @@ export class ServerlessController { if (!builtIn) { const snippet = await this.serverlessService.repository.findById(id) if (!snippet) { - throw new BizException(ErrorCodeEnum.FunctionNotFound) + throw createAppException(AppErrorCode.FUNCTION_NOT_FOUND) } await this.serverlessService.repository.deleteById(id) return diff --git a/apps/core/src/modules/serverless/serverless.readme.md b/apps/core/src/modules/serverless/serverless.readme.md index 92a50659b69..534fb85501c 100644 --- a/apps/core/src/modules/serverless/serverless.readme.md +++ b/apps/core/src/modules/serverless/serverless.readme.md @@ -1,12 +1,12 @@ # Serverless > Cloud Function -这是一个动态的路由处理模块,用于实现云函数,云函数入口为 `handler`: +This is a dynamic route-handling module for implementing cloud functions. The entry point of a cloud function is `handler`: ```js async function handler(context, require) {} ``` -## 实例 +## Example ```js async function handler() { @@ -23,15 +23,15 @@ const uid = 1 const len = 10 ``` -更多实例,可以在 [mx-space/snippets](https://github.com/mx-space/snippets) 中 functions 目录下找到。 +For more examples, see the `functions` directory in [mx-space/snippets](https://github.com/mx-space/snippets). # API ## `require` -`require` 进行了重新处理,是一个异步函数。 +`require` has been reworked into an async function. -使用方法: +Usage: ```js // require built-in module @@ -52,23 +52,23 @@ const remoteModule = await require('https://gist.githubusercontent.com/Innei/865b40849d61c2200f1c6ec99c48f716/raw/b4ceb3af6b5a52040a1f31594e5ee53154b8b6d5/case-1.js') // ok ``` -目前受信任的三方库前缀:`@mx-space` `@innei` `mx-function-` +Currently trusted third-party module prefixes: `@mx-space` `@innei` `mx-function-` -受信任的三方库,可在 `snippet.service.ts` 中找到。 +The full list of trusted third-party modules can be found in `snippet.service.ts`. -**注意**:这是一个完全隔离(可能存在逃逸,请及时指出)的执行上下文,你不能编写某些在 NodeJS 运行时正常执行的代码。 +**Note**: This is a fully isolated execution context (escapes may exist — please report them). You cannot write some code that would run normally in the Node.js runtime. -比如:`process` 中只有只读的 env 可以获取,其他方法都被移除; `setTimeout` 等 API 被移除。但是你可以在独立模块中使用这些 API,需要注意,内存泄漏和安全性。 +For example: only the read-only `env` is exposed on `process`; all other methods are stripped. APIs such as `setTimeout` are also removed. However, you can still use these APIs from within standalone modules — be careful about memory leaks and security. -`require(id, useCache)` require 支持第二个参数,默认为 true,这是 NodeJS 的默认行为,可以设定为 `false` 以禁用 `require` 的缓存,但是会增加性能开销。 +`require(id, useCache)` accepts a second argument that defaults to `true`, matching Node.js's default behavior. Setting it to `false` disables the `require` cache at the cost of additional performance overhead. -**注意**:你仍然可以在独立模块中使用主线程的 `require` 方法,所以这并不是一个真正隔离的环境。在使用第三方模块和请注意安全。请不要使用不受信任的模块。由于在同步进程中执行,请不要使用同步的阻塞代码或死循环。多进程建立在 Node Cluster 之上,可以自定义服务运行的进程数。 +**Note**: You can still use the main thread's `require` from within standalone modules, so this is not a truly isolated environment. Be cautious when using third-party modules and pay attention to security. Do not load untrusted modules. Because code executes synchronously inside the process, do not use blocking synchronous code or infinite loops. Multi-process execution is built on Node Cluster, and you can customize the number of worker processes. ## `import` -你可以使用 `import` 语法,但是这只是个上面的 `require` 语法糖,因为在 NodeJS 中如果不开启 ESM 的支持,默认智能识别 CJS 格式的语法。 +You can use `import` syntax, but it is merely sugar for the `require` above — by default Node.js can transparently load CJS-format code without ESM support enabled. -用法如下: +Usage: ```ts // this is ok, will transformer to `var axios = await require('axios')` @@ -79,46 +79,46 @@ const remoteModule = ## Context -`handler` 函数的第一个参数接受一个全局上下文对象。 +The first argument to `handler` is a global context object. -可以通过此上下文,获取请求的参数,URL,Query 等属性。 +Through this context you can access the request's parameters, URL, query, and other properties. -`context.req` Request 对象 +`context.req` Request object -`context.res` FunctionContextResponse 对象 +`context.res` FunctionContextResponse object -`context.throws` 请求抛错,e.g. `context.throws(400, 'bad request')` +`context.throws` Throw a request error, e.g. `context.throws(400, 'bad request')` `context.params` `context.query` -~~`context.body`~~ 计划中 +~~`context.body`~~ planned `context.headers` -`context.model` 当前 Snippet 的 Model +`context.model` The current snippet's model -`context.getOwner()` Promise,可以获取到主人的信息 +`context.getOwner()` `Promise` — fetches the owner's information -`context.getService(name: string)` Promise,当前支持 `axios`, `config` +`context.getService(name: string)` `Promise` — currently supports `axios` and `config` -`context.secret` Secret 对象 +`context.secret` Secret object `context.name` same as model.name `context.reference` same as model.reference -`context.writeAsset(path: string, data: any, options)` 该方法用于写入配置文件。考虑安全性,会对 path 进行简单转化,删除所有返回上级的符号,e.g. `./../a` => `./a` +`context.writeAsset(path: string, data: any, options)` Writes a configuration file. For safety, the path is normalized — all parent-directory traversal markers are stripped, e.g. `./../a` → `./a`. -`context.readAsset(path: string, data: any, options)` 该方法用于读取配置文件。 +`context.readAsset(path: string, data: any, options)` Reads a configuration file. ## `Storage` -可以通过 `context.storage` 访问数据存取层。 +`context.storage` exposes a data-access layer. -- `context.storage.cache` 是一个 Redis Key-Value 存储结构,可保存临时数据。 -- `context.storage.db` 是一个与其他数据隔离的保存在 PostgreSQL 中的 Key-Value 结构的数据,由 `serverless_storages` 表承载,按 namespace 隔离。 +- `context.storage.cache` is a Redis key/value store useful for ephemeral data. +- `context.storage.db` is a key/value store kept in PostgreSQL, isolated from other application data. It is backed by the `serverless_storages` table and partitioned by namespace. ## `process` diff --git a/apps/core/src/modules/serverless/serverless.service.ts b/apps/core/src/modules/serverless/serverless.service.ts index 65d27d6bdb9..305884bf1f8 100644 --- a/apps/core/src/modules/serverless/serverless.service.ts +++ b/apps/core/src/modules/serverless/serverless.service.ts @@ -12,13 +12,12 @@ import { import { isPlainObject } from 'es-toolkit/compat' import qs from 'qs' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { EventScope, SERVERLESS_EVENT_PREFIX, } from '~/constants/business-event.constant' import { RedisKeys } from '~/constants/cache.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { DATA_DIR, NODE_REQUIRE_PATH } from '~/constants/path.constant' import { isDev } from '~/global/env.global' import { AssetService } from '~/processors/helper/helper.asset.service' @@ -354,10 +353,9 @@ export class ServerlessService implements OnModuleInit, OnModuleDestroy { `Serverless function error [${scope}]: ${result.error?.message}`, result.error?.stack, ) - throw new BizException( - ErrorCodeEnum.ServerlessError, - result.error?.message || 'Unknown error, please check log', - ) + throw createAppException(AppErrorCode.SERVERLESS_ERROR, { + message: result.error?.message || 'Unknown error, please check log', + }) } return result.data diff --git a/apps/core/src/modules/sitemap/sitemap.controller.ts b/apps/core/src/modules/sitemap/sitemap.controller.ts index e9071453f53..f55661b75b1 100644 --- a/apps/core/src/modules/sitemap/sitemap.controller.ts +++ b/apps/core/src/modules/sitemap/sitemap.controller.ts @@ -13,7 +13,7 @@ export class SitemapController { @Get('/sitemap') @CacheTTL(3600) @CacheKey(CacheKeys.SiteMapXml) - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse @Header('content-type', 'application/xml') async getSitemap() { const content = await this.aggregateService.getSiteMapContent() diff --git a/apps/core/src/modules/snippet/snippet-route.controller.ts b/apps/core/src/modules/snippet/snippet-route.controller.ts index dbb11a4edae..0a6fa41c5cf 100644 --- a/apps/core/src/modules/snippet/snippet-route.controller.ts +++ b/apps/core/src/modules/snippet/snippet-route.controller.ts @@ -5,8 +5,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify' import { ApiController } from '~/common/decorators/api-controller.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' import { HasAdminAccess } from '~/common/decorators/role.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { createMockedContextResponse } from '../serverless/mock-response.util' import { ServerlessService } from '../serverless/serverless.service' @@ -29,7 +28,7 @@ export class SnippetRouteController { ttl: 5000, }, }) - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse async handleCustomPath( @HasAdminAccess() hasAdminAccess: boolean, @Request() req: FastifyRequest, @@ -47,7 +46,7 @@ export class SnippetRouteController { if (dataSnippet) { if (dataSnippet.private && !hasAdminAccess) { - throw new BizException(ErrorCodeEnum.SnippetPrivate) + throw createAppException(AppErrorCode.SNIPPET_PRIVATE) } // check cache @@ -106,7 +105,7 @@ export class SnippetRouteController { } } - throw new BizException(ErrorCodeEnum.SnippetNotFound) + throw createAppException(AppErrorCode.SNIPPET_NOT_FOUND) } private async executeFunction( @@ -116,14 +115,13 @@ export class SnippetRouteController { reply: FastifyReply, ) { if (!snippet.enable) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'serverless function is not enabled', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'serverless function is not enabled', + }) } if (snippet.private && !hasAdminAccess) { - throw new BizException(ErrorCodeEnum.ServerlessNoPermission) + throw createAppException(AppErrorCode.SERVERLESS_NO_PERMISSION) } const result = diff --git a/apps/core/src/modules/snippet/snippet.controller.ts b/apps/core/src/modules/snippet/snippet.controller.ts index 75acaff6945..658fbcea27b 100644 --- a/apps/core/src/modules/snippet/snippet.controller.ts +++ b/apps/core/src/modules/snippet/snippet.controller.ts @@ -4,10 +4,11 @@ import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' import { HasAdminAccess } from '~/common/decorators/role.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' import { EntityIdDto } from '~/shared/dto/id.dto' -import { PagerDto } from '~/shared/dto/pager.dto' +import { BasicPagerDto } from '~/shared/dto/pager.dto' import { SnippetDto, SnippetMoreDto } from './snippet.schema' import { SnippetService } from './snippet.service' @@ -18,13 +19,21 @@ export class SnippetController { @Get('/') @Auth() - async getList(@Query() query: PagerDto) { + async getList(@Query() query: BasicPagerDto) { const { page, size } = query const result = await this.snippetService.repository.list(page, size) - return { - ...result, - data: this.snippetService.transformLeanSnippetList(result.data), - } + const { pagination } = result + return withMeta( + this.snippetService.transformLeanSnippetList(result.data), + new MetaObjectBuilder() + .pagination({ + page: pagination.currentPage, + size: pagination.size, + total: pagination.total, + totalPages: pagination.totalPage, + }) + .build(), + ) } @Post('/import') @@ -49,16 +58,28 @@ export class SnippetController { @Get('/group') @Auth() - async getGroup(@Query() query: PagerDto) { + async getGroup(@Query() query: BasicPagerDto) { const { page, size = 30 } = query - return this.snippetService.repository.listGrouped(page, size) + const result = await this.snippetService.repository.listGrouped(page, size) + const { pagination } = result + return withMeta( + result.data, + new MetaObjectBuilder() + .pagination({ + page: pagination.currentPage, + size: pagination.size, + total: pagination.total, + totalPages: pagination.totalPage, + }) + .build(), + ) } @Get('/group/:reference') @Auth() async getGroupByReference(@Param('reference') reference: string) { if (typeof reference !== 'string') { - throw new BizException(ErrorCodeEnum.InvalidReference) + throw createAppException(AppErrorCode.INVALID_REFERENCE) } const rows = await this.snippetService.repository.findAll(reference) @@ -74,25 +95,25 @@ export class SnippetController { @Post('/aggregate') @Auth() async aggregate() { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'POST /snippets/aggregate is removed in PostgreSQL mode. Use GET /snippets/group or /snippets/group/:reference instead.', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: + 'POST /snippets/aggregate is removed in PostgreSQL mode. Use GET /snippets/group or /snippets/group/:reference instead.', + }) } @Get('/:reference/:name') - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse async getSnippetByName( @Param('name') name: string, @Param('reference') reference: string, @HasAdminAccess() hasAdminAccess: boolean, ) { if (typeof name !== 'string') { - throw new BizException(ErrorCodeEnum.InvalidName) + throw createAppException(AppErrorCode.INVALID_NAME) } if (typeof reference !== 'string') { - throw new BizException(ErrorCodeEnum.InvalidReference) + throw createAppException(AppErrorCode.INVALID_REFERENCE) } const cached = hasAdminAccess ? ( diff --git a/apps/core/src/modules/snippet/snippet.schema.ts b/apps/core/src/modules/snippet/snippet.schema.ts index 12c29ed70f2..481d9115aa0 100644 --- a/apps/core/src/modules/snippet/snippet.schema.ts +++ b/apps/core/src/modules/snippet/snippet.schema.ts @@ -21,7 +21,8 @@ export const SnippetSchema = BaseSchema.extend({ .min(1) .transform((val) => val.trim()), name: z.string().regex(/^[\w-]{1,30}$/, { - message: 'name 只能使用英文字母和数字下划线且不超过 30 个字符', + message: + 'name must only contain letters, digits, and underscores, and be at most 30 characters', }), reference: z.string().min(1).default('root').optional(), comment: z.string().nullable().optional(), diff --git a/apps/core/src/modules/snippet/snippet.service.ts b/apps/core/src/modules/snippet/snippet.service.ts index 2a5edc618b1..1c559cf7519 100644 --- a/apps/core/src/modules/snippet/snippet.service.ts +++ b/apps/core/src/modules/snippet/snippet.service.ts @@ -4,10 +4,9 @@ import JSON5 from 'json5' import qs from 'qs' import { RequestContext } from '~/common/contexts/request.context' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { BusinessEvents, EventScope } from '~/constants/business-event.constant' import { RedisKeys } from '~/constants/cache.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { EventBusEvents } from '~/constants/event-bus.constant' import { EventManagerService } from '~/processors/helper/helper.event.service' import { RedisService } from '~/processors/redis/redis.service' @@ -79,10 +78,9 @@ export class SnippetService { const reference = model.reference ?? 'root' if (this.reservedReferenceKeys.includes(reference)) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - `"${reference}" as reference is reserved`, - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: `"${reference}" as reference is reserved`, + }) } } @@ -93,7 +91,7 @@ export class SnippetService { model.method ?? null, ) if (exists > 0) { - throw new BizException(ErrorCodeEnum.SnippetExists) + throw createAppException(AppErrorCode.SNIPPET_EXISTS) } if (model.customPath) { @@ -101,10 +99,9 @@ export class SnippetService { model.customPath, ) if (cpExists > 0) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'customPath already exists', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'customPath already exists', + }) } } @@ -148,17 +145,17 @@ export class SnippetService { const old = await this.snippetRepository.findById(id) if (!old) { - throw new BizException(ErrorCodeEnum.SnippetNotFound) + throw createAppException(AppErrorCode.SNIPPET_NOT_FOUND) } if ( old.type === SnippetType.Function && newModel.type !== SnippetType.Function ) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - '`type` is not allowed to change if this snippet set to Function type.', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: + '`type` is not allowed to change if this snippet set to Function type.', + }) } let mergedSecret = newModel.secret @@ -188,10 +185,9 @@ export class SnippetService { id, ) if (cpExists > 0) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'customPath already exists', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'customPath already exists', + }) } } @@ -236,7 +232,7 @@ export class SnippetService { const updated = await this.snippetRepository.update(id, patch) if (!updated) { - throw new BizException(ErrorCodeEnum.SnippetNotFound) + throw createAppException(AppErrorCode.SNIPPET_NOT_FOUND) } if (old.reference === 'theme' || newModel.reference === 'theme') { @@ -249,14 +245,13 @@ export class SnippetService { async delete(id: string): Promise { const doc = await this.snippetRepository.findById(id) if (!doc) { - throw new BizException(ErrorCodeEnum.SnippetNotFound) + throw createAppException(AppErrorCode.SNIPPET_NOT_FOUND) } if (doc.type === SnippetType.Function && doc.reference === 'built-in') { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'built-in function snippet is not allowed to delete', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'built-in function snippet is not allowed to delete', + }) } await this.snippetRepository.deleteById(id) @@ -276,7 +271,7 @@ export class SnippetService { try { JSON.parse(model.raw) } catch { - throw new BizException(ErrorCodeEnum.SnippetInvalidJson) + throw createAppException(AppErrorCode.SNIPPET_INVALID_JSON) } break } @@ -284,7 +279,7 @@ export class SnippetService { try { JSON5.parse(model.raw) } catch { - throw new BizException(ErrorCodeEnum.SnippetInvalidJson5) + throw createAppException(AppErrorCode.SNIPPET_INVALID_JSON5) } break } @@ -292,7 +287,7 @@ export class SnippetService { try { load(model.raw) } catch { - throw new BizException(ErrorCodeEnum.SnippetInvalidYaml) + throw createAppException(AppErrorCode.SNIPPET_INVALID_YAML) } break } @@ -301,10 +296,12 @@ export class SnippetService { model.raw, ) if (typeof isValid === 'string') { - throw new BizException(ErrorCodeEnum.SnippetInvalidFunction, isValid) + throw createAppException(AppErrorCode.SNIPPET_INVALID_FUNCTION, { + extra: isValid, + }) } if (!isValid) { - throw new BizException(ErrorCodeEnum.SnippetInvalidFunction) + throw createAppException(AppErrorCode.SNIPPET_INVALID_FUNCTION) } break } @@ -323,7 +320,7 @@ export class SnippetService { async getSnippetById(id: string): Promise { const doc = await this.snippetRepository.findById(id) if (!doc) { - throw new BizException(ErrorCodeEnum.SnippetNotFound) + throw createAppException(AppErrorCode.SNIPPET_NOT_FOUND) } return this.transformLeanSnippetModel(doc) } @@ -354,7 +351,7 @@ export class SnippetService { async getSnippetByName(name: string, reference: string): Promise { const doc = await this.snippetRepository.findPublicByName(name, reference) if (!doc) { - throw new BizException(ErrorCodeEnum.SnippetNotFound) + throw createAppException(AppErrorCode.SNIPPET_NOT_FOUND) } return doc } @@ -362,11 +359,11 @@ export class SnippetService { async getPublicSnippetByName(name: string, reference: string) { const snippet = await this.getSnippetByName(name, reference) if (snippet.type === SnippetType.Function) { - throw new BizException(ErrorCodeEnum.SnippetNotFound) + throw createAppException(AppErrorCode.SNIPPET_NOT_FOUND) } if (snippet.private && !RequestContext.hasAdminAccess()) { - throw new BizException(ErrorCodeEnum.SnippetPrivate) + throw createAppException(AppErrorCode.SNIPPET_PRIVATE) } const res = await this.attachSnippet(snippet) @@ -378,7 +375,7 @@ export class SnippetService { model: T, ): Promise { if (!model) { - throw new BizException(ErrorCodeEnum.SnippetNotFound) + throw createAppException(AppErrorCode.SNIPPET_NOT_FOUND) } switch (model.type) { case SnippetType.JSON: { diff --git a/apps/core/src/modules/subscribe/subscribe.controller.ts b/apps/core/src/modules/subscribe/subscribe.controller.ts index f583b7fa02c..bb391f6efda 100644 --- a/apps/core/src/modules/subscribe/subscribe.controller.ts +++ b/apps/core/src/modules/subscribe/subscribe.controller.ts @@ -3,9 +3,10 @@ import { Body, Delete, Get, Post, Query } from '@nestjs/common' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' -import { PagerDto } from '~/shared/dto/pager.dto' +import { AppErrorCode, createAppException } from '~/common/errors' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' +import { BasicPagerDto } from '~/shared/dto/pager.dto' import { SubscribeTypeToBitMap } from './subscribe.constant' import { @@ -20,28 +21,32 @@ export class SubscribeController { constructor(private readonly service: SubscribeService) {} @Get('/status') - @HTTPDecorators.Bypass async checkStatus() { - const allow_types = ['note_c', 'post_c'] + const allowTypes = ['note_c', 'post_c'] + const enable = await this.service.checkEnable() return { - enable: await this.service.checkEnable(), - bit_map: SubscribeTypeToBitMap, - allow_bits: allow_types.map((t) => SubscribeTypeToBitMap[t]), - allow_types, + enable, + bitMap: SubscribeTypeToBitMap, + allowBits: allowTypes.map((t) => SubscribeTypeToBitMap[t]), + allowTypes, } } @Get('/') @Auth() - async list(@Query() query: PagerDto) { + async list(@Query() query: BasicPagerDto) { const { page = 1, size = 10 } = query - return this.service.list(page, size) + const result = await this.service.list(page, size) + return withMeta( + result.data, + new MetaObjectBuilder().pagination(result.pagination).build(), + ) } @Post('/') async subscribe(@Body() body: SubscribeDto) { if (!(await this.service.checkEnable())) { - throw new BizException(ErrorCodeEnum.SubscribeNotEnabled) + throw createAppException(AppErrorCode.SUBSCRIBE_NOT_ENABLED) } const { email, types } = body let bit = 0 @@ -50,21 +55,21 @@ export class SubscribeController { } if (bit === 0) { - throw new BizException(ErrorCodeEnum.SubscribeTypeEmpty) + throw createAppException(AppErrorCode.SUBSCRIBE_TYPE_EMPTY) } await this.service.subscribe(email, bit) } @Get('/unsubscribe') - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse async unsubscribe(@Query() query: CancelSubscribeDto) { const { email, cancelToken } = query const result = await this.service.unsubscribe(email, cancelToken) if (result) { - return '已取消订阅' + return 'Unsubscribed successfully' } - return '出现错误' + return 'An error occurred' } @Delete('/unsubscribe/batch') diff --git a/apps/core/src/modules/subscribe/subscribe.email.default.ts b/apps/core/src/modules/subscribe/subscribe.email.default.ts index 9e75b8e67ff..7b93593216b 100644 --- a/apps/core/src/modules/subscribe/subscribe.email.default.ts +++ b/apps/core/src/modules/subscribe/subscribe.email.default.ts @@ -2,8 +2,8 @@ import type { OwnerModel, OwnerModelSecurityKeys } from '../owner/owner.types' import { SubscribeAllBit } from './subscribe.constant' const defaultPostProps = { - text: '年纪在四十以上,二十以下的,恐怕就不易在前两派里有个地位了。他们的车破,又不敢“拉晚儿”,所以只能早早的出车,希望能从清晨转到午后三四点钟,拉出“车份儿”和自己的嚼谷①。他们的车破,跑得慢,所以得多走路,少要钱。到瓜市,果市,菜市,去拉货物,都是他们;钱少,可是无须快跑呢。', - title: '骆驼祥子', + text: 'It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, it was the epoch of belief, it was the epoch of incredulity, it was the season of light, it was the season of darkness, it was the spring of hope, it was the winter of despair.', + title: 'A Tale of Two Cities', } export const defaultSubscribeForRenderProps = { diff --git a/apps/core/src/modules/subscribe/subscribe.schema.ts b/apps/core/src/modules/subscribe/subscribe.schema.ts index a8dfb5557a6..764ab41a2ac 100644 --- a/apps/core/src/modules/subscribe/subscribe.schema.ts +++ b/apps/core/src/modules/subscribe/subscribe.schema.ts @@ -27,7 +27,7 @@ export const BatchUnsubscribeSchema = z .refine( (data) => data.all === true || (data.emails && data.emails.length > 0), { - message: '必须提供 emails 数组或设置 all 为 true', + message: 'Either provide an emails array or set all to true', }, ) diff --git a/apps/core/src/modules/subscribe/subscribe.service.ts b/apps/core/src/modules/subscribe/subscribe.service.ts index 59610662671..4ed0bb98fad 100644 --- a/apps/core/src/modules/subscribe/subscribe.service.ts +++ b/apps/core/src/modules/subscribe/subscribe.service.ts @@ -9,9 +9,8 @@ import { LRUCache } from 'lru-cache' import { nanoid } from 'nanoid' import type Mail from 'nodemailer/lib/mailer' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { BusinessEvents, EventScope } from '~/constants/business-event.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { isMainProcess } from '~/global/env.global' import { DatabaseService } from '~/processors/database/database.service' import { EmailService } from '~/processors/helper/helper.email.service' @@ -233,7 +232,7 @@ export class SubscribeService implements OnModuleInit, OnModuleDestroy { subscribeTypeToBit(type: keyof typeof SubscribeTypeToBitMap) { if (!Object.keys(SubscribeTypeToBitMap).includes(type)) - throw new BizException(ErrorCodeEnum.InvalidSubscribeType) + throw createAppException(AppErrorCode.INVALID_SUBSCRIBE_TYPE) return SubscribeTypeToBitMap[type] } @@ -258,7 +257,7 @@ export class SubscribeService implements OnModuleInit, OnModuleDestroy { const options: Mail.Options = { from: sendfrom, - subject: `[${seo.title || 'Mx Space'}] 发布了新内容~`, + subject: `[${seo.title || 'Mx Space'}] New content published`, to: email, html: ejs.render(finalTemplate, source), headers: { 'List-Unsubscribe': `<${unsubscribeLink}>` }, diff --git a/apps/core/src/modules/topic/topic.controller.ts b/apps/core/src/modules/topic/topic.controller.ts index 71e64677b98..3c968ee208c 100644 --- a/apps/core/src/modules/topic/topic.controller.ts +++ b/apps/core/src/modules/topic/topic.controller.ts @@ -1,65 +1,183 @@ -import { Get, Param } from '@nestjs/common' +import { + Body, + Delete, + Get, + HttpCode, + Param, + Patch, + Post, + Put, + Query, +} from '@nestjs/common' import slugify from 'slugify' -import { TranslateFields } from '~/common/decorators/translate-fields.decorator' -import { CannotFindException } from '~/common/exceptions/cant-find.exception' +import { ApiController } from '~/common/decorators/api-controller.decorator' +import { Auth } from '~/common/decorators/auth.decorator' +import { HTTPDecorators } from '~/common/decorators/http.decorator' +import { Lang } from '~/common/decorators/lang.decorator' +import { AppErrorCode, createAppException } from '~/common/errors' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' +import { TranslationEntryService } from '~/modules/ai/ai-translation/translation-entry.service' +import { + applyTranslationEntriesInPlace, + type EntryRule, +} from '~/processors/helper/helper.translation.service' import { EntityIdDto } from '~/shared/dto/id.dto' -import { BasePgCrudFactory } from '~/transformers/crud-factor.pg.transformer' +import { BasicPagerDto } from '~/shared/dto/pager.dto' import { TopicRepository } from './topic.repository' import { TopicSlugParamsDto } from './topic.schema' +import type { TopicCreateInput, TopicPatchInput } from './topic.types' -const topicTranslateFields = [ - { path: 'name', keyPath: 'topic.name' as const, idField: 'id' as const }, +const TOPIC_ENTRY_RULES: ReadonlyArray = [ + { path: 'name', keyPath: 'topic.name', mode: 'entity', idField: 'id' }, { path: 'introduce', - keyPath: 'topic.introduce' as const, - idField: 'id' as const, + keyPath: 'topic.introduce', + mode: 'entity', + idField: 'id', }, { path: 'description', - keyPath: 'topic.description' as const, - idField: 'id' as const, + keyPath: 'topic.description', + mode: 'entity', + idField: 'id', }, ] -const topicTranslateListFields = [ - { path: '[].name', keyPath: 'topic.name' as const, idField: 'id' as const }, - { - path: '[].introduce', - keyPath: 'topic.introduce' as const, - idField: 'id' as const, - }, - { - path: '[].description', - keyPath: 'topic.description' as const, - idField: 'id' as const, - }, -] +@ApiController('topics') +export class TopicBaseController { + constructor( + protected readonly repository: TopicRepository, + private readonly translationEntryService: TranslationEntryService, + ) {} -export class TopicBaseController extends BasePgCrudFactory({ - repository: TopicRepository, -}) { @Get('/all') - @TranslateFields(...topicTranslateListFields) - async getAll() { - return this.repository.findAll() + async getAll(@Lang() lang?: string) { + const data = await this.repository.findAll() + + if (lang && data.length) { + const ids = new Set(data.map((t) => String(t.id))) + const entryMaps = await this.translationEntryService.getTranslationsBatch( + lang, + { + entityLookups: [ + { keyPath: 'topic.name', lookupKeys: ids }, + { keyPath: 'topic.introduce', lookupKeys: ids }, + { keyPath: 'topic.description', lookupKeys: ids }, + ], + }, + ) + for (const topic of data) { + applyTranslationEntriesInPlace( + topic as any, + entryMaps, + TOPIC_ENTRY_RULES, + ) + } + } + + return data } @Get('/slug/:slug') - @TranslateFields(...topicTranslateFields) - async getTopicByTopic(@Param() { slug }: TopicSlugParamsDto) { + async getTopicByTopic( + @Param() { slug }: TopicSlugParamsDto, + @Lang() lang?: string, + ) { slug = slugify(slug) const topic = await this.repository.findBySlug(slug) if (!topic) { - throw new CannotFindException() + throw createAppException(AppErrorCode.TOPIC_NOT_FOUND) + } + + if (lang) { + const id = String(topic.id) + const entryMaps = await this.translationEntryService.getTranslationsBatch( + lang, + { + entityLookups: [ + { keyPath: 'topic.name', lookupKeys: new Set([id]) }, + { keyPath: 'topic.introduce', lookupKeys: new Set([id]) }, + { keyPath: 'topic.description', lookupKeys: new Set([id]) }, + ], + }, + ) + applyTranslationEntriesInPlace(topic as any, entryMaps, TOPIC_ENTRY_RULES) } + return topic } @Get('/:id') - @TranslateFields(...topicTranslateFields) - async get(@Param() param: EntityIdDto) { - return this.repository.findById(param.id) + async get(@Param() param: EntityIdDto, @Lang() lang?: string) { + const data = await this.repository.findById(param.id) + if (!data) { + throw createAppException(AppErrorCode.TOPIC_NOT_FOUND, { id: param.id }) + } + + if (lang) { + const id = String(data.id) + const entryMaps = await this.translationEntryService.getTranslationsBatch( + lang, + { + entityLookups: [ + { keyPath: 'topic.name', lookupKeys: new Set([id]) }, + { keyPath: 'topic.introduce', lookupKeys: new Set([id]) }, + { keyPath: 'topic.description', lookupKeys: new Set([id]) }, + ], + }, + ) + applyTranslationEntriesInPlace(data as any, entryMaps, TOPIC_ENTRY_RULES) + } + + return data + } + + @Get('/') + async gets(@Query() pager: BasicPagerDto) { + const size = pager.size ?? 10 + const page = pager.page ?? 1 + const result = await this.repository.list(page, size) + return withMeta( + result.data, + new MetaObjectBuilder() + .view('card') + .pagination({ + page: (result.pagination as any).currentPage ?? 1, + size: (result.pagination as any).size ?? 10, + total: (result.pagination as any).total ?? 0, + totalPages: (result.pagination as any).totalPage ?? 1, + }) + .build(), + ) + } + + @Post('/') + @HTTPDecorators.Idempotence() + @Auth() + create(@Body() body: TopicCreateInput) { + return this.repository.create(body) + } + + @Put('/:id') + @Auth() + update(@Body() body: TopicCreateInput, @Param() param: EntityIdDto) { + return this.repository.update(param.id, body) + } + + @Patch('/:id') + @Auth() + @HttpCode(204) + async patch(@Body() body: TopicPatchInput, @Param() param: EntityIdDto) { + await this.repository.update(param.id, body) + } + + @Delete('/:id') + @Auth() + @HttpCode(204) + async delete(@Param() param: EntityIdDto) { + await this.repository.deleteById(param.id) } } diff --git a/apps/core/src/modules/topic/topic.module.ts b/apps/core/src/modules/topic/topic.module.ts index fc3b707a630..d360a7523c2 100644 --- a/apps/core/src/modules/topic/topic.module.ts +++ b/apps/core/src/modules/topic/topic.module.ts @@ -1,9 +1,11 @@ -import { Module } from '@nestjs/common' +import { forwardRef, Module } from '@nestjs/common' +import { AiModule } from '../ai/ai.module' import { TopicBaseController } from './topic.controller' import { TopicRepository } from './topic.repository' @Module({ + imports: [forwardRef(() => AiModule)], controllers: [TopicBaseController], providers: [TopicRepository], exports: [TopicRepository], diff --git a/apps/core/src/modules/topic/topic.views.ts b/apps/core/src/modules/topic/topic.views.ts new file mode 100644 index 00000000000..104cc0110cb --- /dev/null +++ b/apps/core/src/modules/topic/topic.views.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' + +const TopicCardSchema = z + .object({ + id: z.string(), + name: z.string(), + slug: z.string(), + description: z.string(), + introduce: z.string().nullable().optional(), + icon: z.string().nullable().optional(), + createdAt: z.date().or(z.string()), + }) + .passthrough() + +const TopicDetailSchema = z.object({}).passthrough() + +export const TopicViews = { + card: TopicCardSchema, + detail: TopicDetailSchema, +} as const + +export type TopicView = keyof typeof TopicViews diff --git a/apps/core/src/modules/update/update.controller.ts b/apps/core/src/modules/update/update.controller.ts index a5b6c7e12de..9e3296a66ec 100644 --- a/apps/core/src/modules/update/update.controller.ts +++ b/apps/core/src/modules/update/update.controller.ts @@ -25,7 +25,7 @@ export class UpdateController { @Sse('/upgrade/dashboard') @HTTPDecorators.Idempotence() - @HTTPDecorators.Bypass + @HTTPDecorators.RawResponse async updateDashboard( @Query() query: UpdateAdminDto, ): Promise> { diff --git a/apps/core/src/modules/webhook/webhook.controller.ts b/apps/core/src/modules/webhook/webhook.controller.ts index 8789c5dc164..f3f1cb5a5a7 100644 --- a/apps/core/src/modules/webhook/webhook.controller.ts +++ b/apps/core/src/modules/webhook/webhook.controller.ts @@ -3,9 +3,11 @@ import { Body, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common' import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' import { BusinessEvents } from '~/constants/business-event.constant' import { EntityIdDto } from '~/shared/dto/id.dto' -import { PagerDto } from '~/shared/dto/pager.dto' +import { BasicPagerDto } from '~/shared/dto/pager.dto' import { WebhookDto, WebhookDtoPartial } from './webhook.schema' import { WebhookService } from './webhook.service' @@ -46,8 +48,23 @@ export class WebhookController { } @Get('/:id') - getEventsByHookId(@Param() { id }: EntityIdDto, @Query() query: PagerDto) { - return this.service.getEventsByHookId(id, query) + async getEventsByHookId( + @Param() { id }: EntityIdDto, + @Query() query: BasicPagerDto, + ) { + const result = await this.service.getEventsByHookId(id, query) + const p = result.pagination + return withMeta( + result.data, + new MetaObjectBuilder() + .pagination({ + page: p.currentPage, + size: p.size, + total: p.total, + totalPages: p.totalPage, + }) + .build(), + ) } @Post('/redispatch/:id') diff --git a/apps/core/src/modules/webhook/webhook.service.ts b/apps/core/src/modules/webhook/webhook.service.ts index 2eea344a619..3e999cee37a 100644 --- a/apps/core/src/modules/webhook/webhook.service.ts +++ b/apps/core/src/modules/webhook/webhook.service.ts @@ -3,14 +3,13 @@ import { createHmac } from 'node:crypto' import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common' import { Injectable } from '@nestjs/common' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode, createAppException } from '~/common/errors' import { BusinessEvents, EventScope } from '~/constants/business-event.constant' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import type { IEventManagerHandlerDisposer } from '~/processors/helper/helper.event.service' import { EventManagerService } from '~/processors/helper/helper.event.service' import { EventPayloadEnricherService } from '~/processors/helper/helper.event-payload.service' import { HttpService } from '~/processors/helper/helper.http.service' -import type { PagerDto } from '~/shared/dto/pager.dto' +import type { BasicPagerInput } from '~/shared/dto/pager.dto' import { WebhookRepository } from './webhook.repository' import type { WebhookRow } from './webhook.types' @@ -186,11 +185,13 @@ export class WebhookService implements OnModuleInit, OnModuleDestroy { async redispatch(id: string) { const record = await this.webhookRepository.findEventById(id) if (!record) { - throw new BizException(ErrorCodeEnum.WebhookEventNotFound) + throw createAppException(AppErrorCode.WEBHOOK_EVENT_NOT_FOUND, { id }) } const hook = await this.webhookRepository.findById(record.hookId) if (!hook) { - throw new BizException(ErrorCodeEnum.WebhookNotFound) + throw createAppException(AppErrorCode.WEBHOOK_NOT_FOUND, { + id: record.hookId, + }) } await this.sendWebhookEvent( @@ -202,7 +203,7 @@ export class WebhookService implements OnModuleInit, OnModuleDestroy { ) } - async getEventsByHookId(hookId: string, query: PagerDto) { + async getEventsByHookId(hookId: string, query: BasicPagerInput) { const { page, size } = query return this.webhookRepository.listEvents(hookId, page, size) } diff --git a/apps/core/src/processors/gateway/base.gateway.ts b/apps/core/src/processors/gateway/base.gateway.ts index b5777323d42..fce93cbad98 100644 --- a/apps/core/src/processors/gateway/base.gateway.ts +++ b/apps/core/src/processors/gateway/base.gateway.ts @@ -19,7 +19,7 @@ export abstract class BaseGateway { client.send( this.gatewayMessageFormat( BusinessEvents.GATEWAY_CONNECT, - 'WebSocket 断开', + 'WebSocket disconnected', ), ) } @@ -27,7 +27,7 @@ export abstract class BaseGateway { client.send( this.gatewayMessageFormat( BusinessEvents.GATEWAY_CONNECT, - 'WebSocket 已连接', + 'WebSocket connected', ), ) } diff --git a/apps/core/src/processors/gateway/shared/auth.gateway.ts b/apps/core/src/processors/gateway/shared/auth.gateway.ts index 70f3708fcfa..5a29fbdebbb 100644 --- a/apps/core/src/processors/gateway/shared/auth.gateway.ts +++ b/apps/core/src/processors/gateway/shared/auth.gateway.ts @@ -39,7 +39,10 @@ export const createAuthGateway = ( authFailed(client: Socket) { client.send( - this.gatewayMessageFormat(BusinessEvents.AUTH_FAILED, '认证失败'), + this.gatewayMessageFormat( + BusinessEvents.AUTH_FAILED, + 'Authentication failed', + ), ) client.disconnect() } diff --git a/apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts b/apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts index 750c42e526a..788537ab656 100644 --- a/apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts +++ b/apps/core/src/processors/gateway/web/visitor-event-dispatch.service.ts @@ -340,7 +340,7 @@ export class VisitorEventDispatchService implements OnModuleInit { availableTranslations: result.availableTranslations, } - // socket ID 即 socket.io 自动加入的 room,可直接定向 + // The socket ID is the room socket.io auto-joins, so we can target it directly this.webGateway.broadcast(event, data, { rooms: socketIds }) } } diff --git a/apps/core/src/processors/helper/helper.asset.service.ts b/apps/core/src/processors/helper/helper.asset.service.ts index 98810765be5..d30d1474e2b 100644 --- a/apps/core/src/processors/helper/helper.asset.service.ts +++ b/apps/core/src/processors/helper/helper.asset.service.ts @@ -1,7 +1,7 @@ /** * @file helper.asset.service.ts * @author Innei - * @description 静态资源服务。用户覆写 (FS) 优先于内置 (虚拟) bundle。 + * @description Static asset service. User overrides (FS) take precedence over the built-in (virtual) bundle. */ import { existsSync } from 'node:fs' import fs from 'node:fs/promises' diff --git a/apps/core/src/processors/helper/helper.counting.service.ts b/apps/core/src/processors/helper/helper.counting.service.ts index 60154b87832..aeafa9d4baf 100644 --- a/apps/core/src/processors/helper/helper.counting.service.ts +++ b/apps/core/src/processors/helper/helper.counting.service.ts @@ -27,11 +27,11 @@ export class CountingService { private checkIdAndIp(id: string, ip: string) { if (!ip) { - this.logger.debug('无法更新阅读计数,IP 无效') + this.logger.debug('Cannot update read count: invalid IP') return false } if (!id) { - this.logger.debug('无法更新阅读计数,ID 不存在') + this.logger.debug('Cannot update read count: missing ID') return false } return true @@ -45,11 +45,11 @@ export class CountingService { const repo = this.repoFor(type) if (!repo) return false const doc = await repo.findById(id) - if (!doc) throw '无法更新喜欢计数,文档不存在' + if (!doc) throw 'Cannot update like count: document not found' const isLikeBefore = await this.getThisRecordIsLiked(id, ip) if (isLikeBefore) { - this.logger.debug(`已经增加过计数了,${id}`) + this.logger.debug(`Already counted, ${id}`) return false } @@ -58,7 +58,7 @@ export class CountingService { redis.sadd(getRedisKey(RedisKeys.Like, doc.id), ip), repo.incrementLike(doc.id), ]) - this.logger.debug(`增加喜欢计数,${doc.title}`) + this.logger.debug(`Incremented like count, ${doc.title}`) return true } @@ -68,13 +68,13 @@ export class CountingService { const doc = await repo.findById(id) if (!doc) throw '' await repo.incrementRead(doc.id) - this.logger.debug(`增加阅读计数,${doc.title}`) + this.logger.debug(`Incremented read count, ${doc.title}`) return { ...doc, readCount: doc.readCount + 1 } } async getThisRecordIsLiked(id: string, ip: string) { if (!this.checkIdAndIp(id, ip)) { - throw '无法获取到 IP' + throw 'Cannot resolve IP' } const redis = this.redisService.getClient() diff --git a/apps/core/src/processors/helper/helper.email.service.ts b/apps/core/src/processors/helper/helper.email.service.ts index f43e1d2a701..2136b1a49ad 100644 --- a/apps/core/src/processors/helper/helper.email.service.ts +++ b/apps/core/src/processors/helper/helper.email.service.ts @@ -5,8 +5,7 @@ import { createTransport } from 'nodemailer' import type Mail from 'nodemailer/lib/mailer' import { Resend } from 'resend' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { EventBusEvents } from '~/constants/event-bus.constant' import { ConfigsService } from '~/modules/configs/configs.service' import { OwnerService } from '~/modules/owner/owner.service' @@ -66,7 +65,7 @@ export class EmailService implements OnModuleInit, OnModuleDestroy { exampleRenderProps: Record, ) { if (this.emailTypeSet.has(type)) { - this.logger.warn(`重复注册邮件类型 ${type}`) + this.logger.warn(`Duplicate email type registration: ${type}`) return } this.emailTypeMap[type] = exampleRenderProps || {} @@ -75,7 +74,7 @@ export class EmailService implements OnModuleInit, OnModuleDestroy { public getExampleRenderProps(type: string) { const props = this.emailTypeMap[type] - if (!props) throw new BizException(ErrorCodeEnum.EmailTemplateNotFound) + if (!props) throw createAppException(AppErrorCode.EMAIL_TEMPLATE_NOT_FOUND) return props } @@ -148,7 +147,9 @@ export class EmailService implements OnModuleInit, OnModuleDestroy { if (this.provider === 'resend') { const apiKey = mailOptions.resend?.apiKey if (!apiKey) { - this.logger.warn('Resend API Key 未配置,邮件服务未启动') + this.logger.warn( + 'Resend API key not configured; email service not started', + ) this.appliedMailVersion = nextVersion this.mailConfigSynced = true return @@ -165,7 +166,9 @@ export class EmailService implements OnModuleInit, OnModuleDestroy { ) const to = this.normalizeAddressList(options.to) if (!from || !to) { - throw new BizException('邮件发送失败') + throw createAppException(AppErrorCode.INTERNAL_ERROR, { + message: 'Failed to send email', + }) } const cc = this.normalizeAddressList(options.cc) const bcc = this.normalizeAddressList(options.bcc) @@ -174,7 +177,9 @@ export class EmailService implements OnModuleInit, OnModuleDestroy { this.normalizeContent(options.html) || this.normalizeContent(options.text) if (!html) { - throw new BizException('邮件发送失败') + throw createAppException(AppErrorCode.INTERNAL_ERROR, { + message: 'Failed to send email', + }) } return resend.emails.send({ @@ -194,7 +199,7 @@ export class EmailService implements OnModuleInit, OnModuleDestroy { const { smtp } = mailOptions const { user, pass, host, port, secure } = smtp || {} if (!user && !pass) { - this.logger.warn('未启动邮件通知') + this.logger.warn('Email notifications are disabled') this.appliedMailVersion = nextVersion this.mailConfigSynced = true return @@ -214,7 +219,7 @@ export class EmailService implements OnModuleInit, OnModuleDestroy { this.mailConfigSynced = true const ready = await this.checkIsReady(false) if (ready) { - this.logger.log('送信服务已经加载完毕!') + this.logger.log('Mail delivery service is ready!') } } catch (error) { this.logger.error(error instanceof Error ? error.message : String(error)) @@ -234,7 +239,7 @@ export class EmailService implements OnModuleInit, OnModuleDestroy { return await this.verifyClient() } - // 验证有效性 + // Verify the client is reachable private verifyClient() { return new Promise((r) => { if (!this.instance?.verify) { @@ -243,7 +248,10 @@ export class EmailService implements OnModuleInit, OnModuleDestroy { } this.instance.verify((error) => { if (error) { - this.logger.error('邮件客户端初始化连接失败!', error.message) + this.logger.error( + 'Failed to initialize the email client connection!', + error.message, + ) r(false) } else { r(true) @@ -259,8 +267,8 @@ export class EmailService implements OnModuleInit, OnModuleDestroy { return this.send({ from: `"Mx Space" <${senderEmail}>`, to: owner.mail, - subject: '测试邮件', - text: '这是一封测试邮件', + subject: 'Test email', + text: 'This is a test email', }) } @@ -297,7 +305,7 @@ export class EmailService implements OnModuleInit, OnModuleDestroy { try { await this.ensureMailTransportFresh() if (!this.instance) { - throw new Error('邮件服务未初始化') + throw new Error('Email service is not initialized') } const result = await this.instance.sendMail(item.options) this.lastSendTime = Date.now() @@ -308,7 +316,7 @@ export class EmailService implements OnModuleInit, OnModuleDestroy { if (item.attempts < maxRetry) { item.attempts++ this.logger.warn( - `邮件发送失败,第 ${item.attempts} 次重试: ${error instanceof Error ? error.message : String(error)}`, + `Failed to send email, retry ${item.attempts}: ${error instanceof Error ? error.message : String(error)}`, ) await sleep(1000 * item.attempts) this.pendingQueue.push(item) @@ -317,7 +325,11 @@ export class EmailService implements OnModuleInit, OnModuleDestroy { this.logger.warn( error instanceof Error ? error.message : String(error), ) - item.reject(new BizException('邮件发送失败')) + item.reject( + createAppException(AppErrorCode.INTERNAL_ERROR, { + message: 'Failed to send email', + }), + ) } } } finally { diff --git a/apps/core/src/processors/helper/helper.event.service.ts b/apps/core/src/processors/helper/helper.event.service.ts index 653092564be..7594d221c70 100644 --- a/apps/core/src/processors/helper/helper.event.service.ts +++ b/apps/core/src/processors/helper/helper.event.service.ts @@ -110,7 +110,7 @@ export class EventManagerService { } } - // TODO 补充类型 + // TODO add proper types on( event: BusinessEvents, handler: (data: any) => void, diff --git a/apps/core/src/processors/helper/helper.http.service.ts b/apps/core/src/processors/helper/helper.http.service.ts index 18f60d73fd5..4720174e5d3 100644 --- a/apps/core/src/processors/helper/helper.http.service.ts +++ b/apps/core/src/processors/helper/helper.http.service.ts @@ -1,13 +1,16 @@ import { inspect } from 'node:util' + import { Injectable, Logger } from '@nestjs/common' +import type { AxiosInstance, AxiosRequestConfig } from 'axios' +import axios from 'axios' +import axiosRetry, { exponentialDelay } from 'axios-retry' +import pc from 'picocolors' + import { AXIOS_CONFIG, DEBUG_MODE } from '~/app.config' import { RedisKeys } from '~/constants/cache.constant' import { PKG } from '~/utils/pkg.util' import { getRedisKey } from '~/utils/redis.util' -import axios from 'axios' -import type { AxiosInstance, AxiosRequestConfig } from 'axios' -import axiosRetry, { exponentialDelay } from 'axios-retry' -import pc from 'picocolors' + import { RedisService } from '../redis/redis.service' const DEFAULT_UA = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36 MX-Space/${PKG.version}` @@ -77,7 +80,7 @@ export class HttpService { } /** - * 缓存请求数据,现支持文本 + * Cache request data. Currently supports text responses. * @param url */ public async getAndCacheRequest(url: string) { diff --git a/apps/core/src/processors/helper/helper.image.service.ts b/apps/core/src/processors/helper/helper.image.service.ts index 43f81d8ba09..bb492e0472f 100644 --- a/apps/core/src/processors/helper/helper.image.service.ts +++ b/apps/core/src/processors/helper/helper.image.service.ts @@ -50,7 +50,7 @@ export class ImageService implements OnModuleInit { const originImage = oldImagesMap.get(src) const keys = new Set(Object.keys(originImage || {})) - // 原有图片 和 现有图片 src 一致 跳过 + // Skip if the existing image and the new image share the same src if ( originImage && originImage.src === src && @@ -95,7 +95,7 @@ export class ImageService implements OnModuleInit { const wait = queue.addMultiple(imageProcessingTasks) await wait() - // 老图片不要过滤,记录到列头 + // Keep old images instead of filtering them out — prepend to the list if (originImages) { for (const oldImageRecord of originImages as ImageModel[]) { const src = oldImageRecord.src diff --git a/apps/core/src/processors/helper/helper.translation.service.ts b/apps/core/src/processors/helper/helper.translation.service.ts index c7472fd6eab..a04600d6b7a 100644 --- a/apps/core/src/processors/helper/helper.translation.service.ts +++ b/apps/core/src/processors/helper/helper.translation.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common' +import type { EntryTranslation } from '~/common/response/meta.types' import { AiTranslationService } from '~/modules/ai/ai-translation/ai-translation.service' import type { TranslationSourceSnapshot } from '~/modules/ai/ai-translation/translation-consistency.types' import { TranslationEntryService } from '~/modules/ai/ai-translation/translation-entry.service' @@ -33,6 +34,153 @@ export type TranslationResultPick< > = { isTranslated: boolean } & Pick export type ArticleTranslationInput = TranslationSourceSnapshot +export type ArticleMetaField = + | 'title' + | 'text' + | 'subtitle' + | 'summary' + | 'tags' + | 'content' + | 'contentFormat' + +export type ArticleTranslatableField = + | 'title' + | 'text' + | 'subtitle' + | 'summary' + | 'tags' + | 'content' + | 'contentFormat' + +export type EntryMaps = { + entityMaps: Map> + dictMaps: Map> +} + +export type EntryRule = + | { path: string; keyPath: TranslationEntryKeyPath; mode: 'dict' } + | { + path: string + keyPath: TranslationEntryKeyPath + mode: 'entity' + idField: string + } + +const ALL_TRANSLATABLE_FIELDS: readonly ArticleTranslatableField[] = [ + 'title', + 'text', + 'subtitle', + 'summary', + 'tags', + 'content', + 'contentFormat', +] + +export function buildArticleTranslationMeta( + result: TranslationResult, + lang: string | undefined, +): Record { + return { + isTranslated: result.isTranslated, + sourceLang: result.sourceLang ?? null, + targetLang: result.translationMeta?.targetLang ?? lang ?? null, + translatedAt: result.translationMeta?.translatedAt, + model: result.translationMeta?.model, + availableTranslations: result.availableTranslations ?? [], + } +} + +export function applyArticleTranslationInPlace>( + target: T, + result: TranslationResult, + opts?: { fields?: ReadonlyArray }, +): T { + if (!result.isTranslated) return target + const fields = opts?.fields ?? ALL_TRANSLATABLE_FIELDS + + for (const field of fields) { + if (field === 'content' || field === 'contentFormat') continue + const value = (result as unknown as Record)[field] + if (value != null) { + target[field as keyof T] = value as T[keyof T] + } + } + + if (fields.includes('content') && result.content != null) { + target['content' as keyof T] = result.content as T[keyof T] + if (fields.includes('contentFormat') && result.contentFormat != null) { + target['contentFormat' as keyof T] = result.contentFormat as T[keyof T] + } + } + + return target +} + +function getNestedValue(obj: Record, path: string): unknown { + const parts = path.split('.') + let current: unknown = obj + for (const part of parts) { + if (current == null || typeof current !== 'object') return undefined + current = (current as Record)[part] + } + return current +} + +function setNestedValue( + obj: Record, + path: string, + value: unknown, +): void { + const parts = path.split('.') + let current: Record = obj + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i] + if (current[part] == null || typeof current[part] !== 'object') return + current = current[part] as Record + } + current[parts.at(-1)!] = value +} + +export function applyTranslationEntriesInPlace>( + target: T, + maps: EntryMaps, + rules: ReadonlyArray, +): T { + for (const rule of rules) { + if (rule.mode === 'dict') { + const dictMap = maps.dictMaps.get(rule.keyPath) + if (!dictMap) continue + const sourceValue = getNestedValue(target, rule.path) + if (sourceValue == null || typeof sourceValue !== 'string') continue + const translated = dictMap.get(sourceValue) + if (translated != null) { + setNestedValue(target, rule.path, translated) + } + } else { + const entityMap = maps.entityMaps.get(rule.keyPath) + if (!entityMap) continue + + const pathParts = rule.path.split('.') + const parentPath = pathParts.slice(0, -1).join('.') + const parent = parentPath + ? getNestedValue(target, parentPath) + : (target as unknown) + + if (parent == null || typeof parent !== 'object') continue + + const parentObj = parent as Record + const id = parentObj[rule.idField] + if (id == null || typeof id !== 'string') continue + + const translated = entityMap.get(id) + if (translated != null) { + setNestedValue(target, rule.path, translated) + } + } + } + return target +} + @Injectable() export class TranslationService { private readonly logger = new Logger(TranslationService.name) @@ -113,10 +261,11 @@ export class TranslationService { } /** - * 通用列表翻译方法 + * Generic list translation method. * - * @warning items 数组在方法执行期间不应被修改或重排, - * 因为内部使用 index 匹配 inputs 和结果 + * @warning The items array must not be mutated or reordered while this + * method is running, because indexes are used to match inputs + * with results. */ async translateList< T, @@ -282,6 +431,7 @@ export class TranslationService { content: translation.content ?? undefined, contentFormat: translation.contentFormat ?? undefined, isTranslated: true, + sourceLang: translation.sourceLang, translationMeta: translationFieldList.includes( 'translationMeta', ) @@ -304,6 +454,49 @@ export class TranslationService { } } + async collectArticleTranslations(options: { + articles: TranslationSourceSnapshot[] + targetLang?: string + fields: readonly ArticleMetaField[] + }): Promise<{ + results: Map> + meta: Map + }> { + const { articles, targetLang, fields } = options + const empty = { + results: new Map>(), + meta: new Map(), + } + if (!targetLang || !articles.length) return empty + + const projection = Array.from( + new Set([ + ...fields, + 'translationMeta', + 'sourceLang', + 'availableTranslations', + ]), + ) + const results = await this.translateArticleList({ + articles, + targetLang, + translationFields: projection, + }) + + const meta = new Map() + for (const [id, translation] of results) { + if (!translation?.isTranslated) continue + meta.set(id, { + article: buildArticleTranslationMeta( + translation as unknown as TranslationResult, + targetLang, + ), + } as EntryTranslation) + } + + return { results, meta } + } + async getEntityTranslations( keyPath: TranslationEntryKeyPath, lang: string, diff --git a/apps/core/src/processors/helper/helper.upload.service.ts b/apps/core/src/processors/helper/helper.upload.service.ts index bf2f71d974c..8018a37e935 100644 --- a/apps/core/src/processors/helper/helper.upload.service.ts +++ b/apps/core/src/processors/helper/helper.upload.service.ts @@ -18,10 +18,10 @@ export class UploadService { }) if (!data) { - throw new BadRequestException('仅供上传文件!') + throw new BadRequestException('Only file uploads are accepted!') } if (data.fieldname != 'file') { - throw new BadRequestException('字段必须为 file') + throw new BadRequestException('The field name must be "file"') } return data diff --git a/apps/core/src/processors/redis/cache.service.ts b/apps/core/src/processors/redis/cache.service.ts index 81a19dbc98d..dece0fdc357 100755 --- a/apps/core/src/processors/redis/cache.service.ts +++ b/apps/core/src/processors/redis/cache.service.ts @@ -2,15 +2,15 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager' import { Inject, Injectable } from '@nestjs/common' import type { Cache } from 'cache-manager' -// Cache 客户端管理器 +// Cache client manager -// 获取器 +// Getters export type TCacheKey = string export type TCacheResult = Promise /** * @class CacheService - * @classdesc 承载缓存服务 + * @classdesc Wraps the cache backend * @example CacheService.get(CacheKey).then() * @example CacheService.set(CacheKey).then() */ diff --git a/apps/core/src/processors/redis/redis.config.service.ts b/apps/core/src/processors/redis/redis.config.service.ts index be91b0ea833..aab89c06970 100755 --- a/apps/core/src/processors/redis/redis.config.service.ts +++ b/apps/core/src/processors/redis/redis.config.service.ts @@ -1,6 +1,6 @@ /** * Cache config service. - * @file Cache 配置器 + * @file Cache configuration factory * @module processor/redis/redis.config.service * @author Innei */ @@ -11,11 +11,12 @@ import type { CacheOptionsFactory, } from '@nestjs/cache-manager' import { Injectable } from '@nestjs/common' + import { REDIS } from '~/app.config' @Injectable() export class RedisConfigService implements CacheOptionsFactory { - // 缓存配置 + // Cache configuration public createCacheOptions(): CacheModuleOptions { const url = REDIS.url ?? `redis://${REDIS.host}:${REDIS.port}` return { diff --git a/apps/core/src/processors/redis/redis.module.ts b/apps/core/src/processors/redis/redis.module.ts index 7f1c0676d5e..06acc088d09 100644 --- a/apps/core/src/processors/redis/redis.module.ts +++ b/apps/core/src/processors/redis/redis.module.ts @@ -1,6 +1,6 @@ /** * Cache module. - * @file Cache 全局模块 + * @file Global cache module * @module processor/cache/module */ import { CacheModule as NestCacheModule } from '@nestjs/cache-manager' diff --git a/apps/core/src/processors/task-queue/scoped-task.service.ts b/apps/core/src/processors/task-queue/scoped-task.service.ts index f9d346c0cc5..2585058b1dd 100644 --- a/apps/core/src/processors/task-queue/scoped-task.service.ts +++ b/apps/core/src/processors/task-queue/scoped-task.service.ts @@ -1,7 +1,7 @@ -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' + import type { CreateTaskOptions, TaskQueueService } from './task-queue.service' -import { TaskStatus, type Task } from './task-queue.types' +import { type Task, TaskStatus } from './task-queue.types' export class ScopedTaskService { constructor( @@ -12,10 +12,10 @@ export class ScopedTaskService { private async verifyScope(taskId: string): Promise { const task = await this.taskQueueService.getTask(taskId) if (!task) { - throw new BizException(ErrorCodeEnum.AITaskNotFound) + throw createAppException(AppErrorCode.AI_TASK_NOT_FOUND, { id: taskId }) } if (task.scope !== this.scope) { - throw new BizException(ErrorCodeEnum.AITaskNotFound) + throw createAppException(AppErrorCode.AI_TASK_NOT_FOUND, { id: taskId }) } return task } @@ -91,10 +91,10 @@ export class ScopedTaskService { task.status !== TaskStatus.PartialFailed && task.status !== TaskStatus.Cancelled ) { - throw new BizException( - ErrorCodeEnum.AITaskCannotRetry, - 'Only failed, partial_failed, or cancelled tasks can be retried', - ) + throw createAppException(AppErrorCode.AI_TASK_CANNOT_RETRY, { + reason: + 'Only failed, partial_failed, or cancelled tasks can be retried', + }) } if (createTaskFn) { diff --git a/apps/core/src/processors/task-queue/task-queue.service.ts b/apps/core/src/processors/task-queue/task-queue.service.ts index 55cbfd25152..ba2593b5814 100644 --- a/apps/core/src/processors/task-queue/task-queue.service.ts +++ b/apps/core/src/processors/task-queue/task-queue.service.ts @@ -1,7 +1,6 @@ import { Injectable, Logger, type OnModuleDestroy } from '@nestjs/common' -import { BizException } from '~/common/exceptions/biz.exception' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode, createAppException } from '~/common/errors' import { getRedisKey } from '~/utils/redis.util' import { md5 } from '~/utils/tool.util' @@ -329,11 +328,11 @@ export class TaskQueueService implements OnModuleDestroy { async cancelTask(taskId: string): Promise { const task = await this.getTask(taskId) if (!task) { - throw new BizException(ErrorCodeEnum.AITaskNotFound) + throw createAppException(AppErrorCode.AI_TASK_NOT_FOUND, { id: taskId }) } if (isTerminalStatus(task.status)) { - throw new BizException(ErrorCodeEnum.AITaskAlreadyCompleted) + throw createAppException(AppErrorCode.AI_TASK_ALREADY_COMPLETED) } if (task.status === TaskStatus.Pending) { @@ -407,7 +406,7 @@ export class TaskQueueService implements OnModuleDestroy { async deleteTask(taskId: string): Promise { const task = await this.getTask(taskId) if (!task) { - throw new BizException(ErrorCodeEnum.AITaskNotFound) + throw createAppException(AppErrorCode.AI_TASK_NOT_FOUND, { id: taskId }) } const taskKey = this.getKey(TASK_QUEUE_KEYS.task(taskId)) @@ -452,10 +451,9 @@ export class TaskQueueService implements OnModuleDestroy { const { status, type, scope, before } = options if (before >= Date.now()) { - throw new BizException( - ErrorCodeEnum.InvalidParameter, - 'before must be in the past', - ) + throw createAppException(AppErrorCode.INVALID_PARAMETER, { + message: 'before must be in the past', + }) } const indexKey = this.pickIndexKeyByOption({ type, scope, status }) diff --git a/apps/core/src/shared/dto/pager.dto.ts b/apps/core/src/shared/dto/pager.dto.ts index c505faea321..5b33d7bb367 100644 --- a/apps/core/src/shared/dto/pager.dto.ts +++ b/apps/core/src/shared/dto/pager.dto.ts @@ -1,29 +1,41 @@ import { createZodDto } from 'nestjs-zod' import { z } from 'zod' -import { - zCoerceInt, - zEntityId, - zPaginationPage, - zPaginationSize, - zSortOrder, -} from '~/common/zod' - -const DbQuerySchema = z.object({ - db_query: z.any().optional(), -}) +import { zCoerceInt, zEntityId, zSortOrder } from '~/common/zod' -export const PagerSchema = DbQuerySchema.extend({ - size: zPaginationSize, - page: zPaginationPage, - select: z.string().min(1).optional(), - sortBy: z.string().optional(), - sortOrder: zSortOrder, - year: zCoerceInt.min(1).optional(), - state: zCoerceInt.optional(), +/** + * Base pager — page + size + optional view. No sort fields. + * + * Use this when an endpoint does not expose sortable columns. + * If sorting is supported, use {@link createPagerSchema} instead so the sort + * keys are explicit and type-safe. + */ +export const BasicPagerSchema = z.object({ + page: z.coerce.number().int().positive().default(1), + size: z.coerce.number().int().positive().max(100).default(10), + view: z.string().optional(), }) -export class PagerDto extends createZodDto(PagerSchema) {} +export class BasicPagerDto extends createZodDto(BasicPagerSchema) {} + +export type BasicPagerInput = z.infer + +/** + * Sort-aware pager factory. Pass the column names this endpoint is allowed to + * sort by; the resulting schema exposes `sortBy` (typed as `z.enum(sortKeys)`) + * and `sortOrder` (`'asc' | 'desc'`, default `'desc'`) inside core. On the + * wire both `sortBy=`/`sort_by=` are accepted (the global request-case + * normalization pipe folds snake_case query keys to camelCase before zod), and + * the legacy `1` / `-1` sortOrder values are coerced to `'asc'` / `'desc'`. + */ +export const createPagerSchema = ( + sortKeys: TSort, +) => + BasicPagerSchema.extend({ + sortBy: z.enum(sortKeys).optional(), + sortOrder: zSortOrder, + year: z.coerce.number().int().optional(), + }) export const OffsetSchema = z.object({ before: zEntityId.optional(), @@ -33,5 +45,4 @@ export const OffsetSchema = z.object({ export class OffsetDto extends createZodDto(OffsetSchema) {} -export type PagerInput = z.infer export type OffsetInput = z.infer diff --git a/apps/core/src/transformers/crud-factor.pg.transformer.ts b/apps/core/src/transformers/crud-factor.pg.transformer.ts index f6d970170c8..87ad73cfad0 100644 --- a/apps/core/src/transformers/crud-factor.pg.transformer.ts +++ b/apps/core/src/transformers/crud-factor.pg.transformer.ts @@ -19,7 +19,7 @@ import { HTTPDecorators } from '~/common/decorators/http.decorator' import { EventScope } from '~/constants/business-event.constant' import { EventManagerService } from '~/processors/helper/helper.event.service' import { EntityIdDto } from '~/shared/dto/id.dto' -import { PagerDto } from '~/shared/dto/pager.dto' +import { BasicPagerDto } from '~/shared/dto/pager.dto' import type { EntityId } from '~/shared/id/entity-id' export type ClassType = new (...args: any[]) => T @@ -95,12 +95,10 @@ export function BasePgCrudFactory>({ } @Get('/') - async gets(@Query() pager: PagerDto) { + async gets(@Query() pager: BasicPagerDto) { const size = pager.size ?? 10 const page = pager.page ?? 1 - const filter: Record = {} - if (pager.state !== undefined) filter.state = pager.state - return this.repo.list(page, size, filter) + return this.repo.list(page, size, {}) } @Get('/all') diff --git a/apps/core/src/utils/case.util.ts b/apps/core/src/utils/case.util.ts deleted file mode 100644 index 9f7c1beed6d..00000000000 --- a/apps/core/src/utils/case.util.ts +++ /dev/null @@ -1,9 +0,0 @@ -import snakecaseKeys from 'snakecase-keys' - -type SnakeCaseKeysOptions = Parameters[1] - -export function snakecaseKeysWithCompat< - T extends Record | readonly Record[], ->(obj: T, options?: SnakeCaseKeysOptions): T { - return snakecaseKeys(obj as any, options) as T -} diff --git a/apps/core/src/utils/encrypt.util.ts b/apps/core/src/utils/encrypt.util.ts index c70019699ee..40d6fdc7899 100644 --- a/apps/core/src/utils/encrypt.util.ts +++ b/apps/core/src/utils/encrypt.util.ts @@ -22,9 +22,9 @@ export const mapString = (keyString: string) => { hash.update(keyString + SALT) const digest = hash.digest('hex') if (digest.length >= 64) { - return digest.slice(0, 64) // 如果哈希值的长度大于等于 64,直接返回哈希值 + return digest.slice(0, 64) // If the hash is already 64 chars or longer, return it directly } else { - const padding = '0'.repeat(64 - digest.length) // 使用 0 填充到 64 位 + const padding = '0'.repeat(64 - digest.length) // Pad with zeros to reach 64 chars return padding + digest } } diff --git a/apps/core/src/utils/filename-template.util.ts b/apps/core/src/utils/filename-template.util.ts index 8f63ffe1cd3..9d32d5d4594 100644 --- a/apps/core/src/utils/filename-template.util.ts +++ b/apps/core/src/utils/filename-template.util.ts @@ -6,75 +6,75 @@ import { customAlphabet } from 'nanoid' import { alphabet } from '~/constants/other.constant' /** - * 文件名模板占位符替换工具 - * 支持的占位符: - * - {Y} 年份 (4位) - * - {y} 年份 (2位) - * - {m} 月份 (2位) - * - {d} 日期 (2位) - * - {h} 小时 (2位) - * - {i} 分钟 (2位) - * - {s} 秒钟 (2位) - * - {ms} 毫秒 (3位) - * - {timestamp} 时间戳 (毫秒) - * - {md5} 随机MD5字符串 (32位) - * - {md5-16} 随机MD5字符串 (16位) - * - {uuid} UUID字符串 - * - {str-数字} 随机字符串,数字表示长度 - * - {filename} 原文件名 (包含扩展名) - * - {name} 原文件名 (不含扩展名) - * - {ext} 扩展名 (包含点号) - * - {type} 文件类型 - * - {localFolder:数字} 原文件所在文件夹 (数字表示层级) - * - {readerId} 读者 ID (评论上传专用) + * Filename template placeholder substitution utility. + * Supported placeholders: + * - {Y} Year (4 digits) + * - {y} Year (2 digits) + * - {m} Month (2 digits) + * - {d} Day (2 digits) + * - {h} Hour (2 digits) + * - {i} Minute (2 digits) + * - {s} Second (2 digits) + * - {ms} Millisecond (3 digits) + * - {timestamp} Timestamp (milliseconds) + * - {md5} Random MD5 string (32 chars) + * - {md5-16} Random MD5 string (16 chars) + * - {uuid} UUID string + * - {str-} Random string; number specifies length + * - {filename} Original filename (with extension) + * - {name} Original filename (without extension) + * - {ext} Extension (with dot) + * - {type} File type + * - {localFolder:} Original folder of the file; number specifies depth + * - {readerId} Reader ID (for comment uploads only) */ export interface FilenameTemplateContext { /** - * 原始文件名 (包含扩展名) + * Original filename (including extension) */ originalFilename: string /** - * 文件类型 (如: image, file, avatar, icon) + * File type (e.g. image, file, avatar, icon) */ fileType?: string /** - * 本地文件夹路径 (用于 localFolder 占位符) + * Local folder path (used for the localFolder placeholder) */ localFolderPath?: string /** - * 读者 ID (用于评论上传 {readerId} 占位符) + * Reader ID (used for the {readerId} placeholder in comment uploads) */ readerId?: string } /** - * 生成一个随机的 MD5 字符串 + * Generate a random MD5-like hex string. */ function generateRandomMd5(): string { return crypto.randomBytes(16).toString('hex') } /** - * 生成一个 UUID v4 字符串 + * Generate a UUID v4 string. */ function generateUuid(): string { return crypto.randomUUID() } /** - * 格式化数字,补零到指定位数 + * Format a number with leading zeros to the given length. */ function padZero(num: number, length: number): string { return num.toString().padStart(length, '0') } /** - * 提取文件名中的文件夹层级 - * @param folderPath 文件夹路径 - * @param level 提取的层级数 + * Extract folder segments from a path. + * @param folderPath The folder path. + * @param level Number of trailing segments to extract. */ function extractFolderLevel( folderPath: string | undefined, @@ -89,10 +89,10 @@ function extractFolderLevel( } /** - * 替换模板中的占位符 - * @param template 模板字符串 - * @param context 上下文信息 - * @returns 替换后的字符串 + * Replace placeholders in the template string. + * @param template Template string. + * @param context Context information. + * @returns The substituted string. */ export function replaceFilenameTemplate( template: string, @@ -106,13 +106,13 @@ export function replaceFilenameTemplate( readerId = '', } = context - // 提取文件名和扩展名 + // Extract filename and extension const ext = path.extname(originalFilename).toLowerCase() const nameWithoutExt = path.basename(originalFilename, ext) let result = template - // 时间相关占位符 + // Date/time placeholders result = result.replaceAll('{Y}', now.getFullYear().toString()) result = result.replaceAll('{y}', padZero(now.getFullYear() % 100, 2)) result = result.replaceAll('{m}', padZero(now.getMonth() + 1, 2)) @@ -123,37 +123,37 @@ export function replaceFilenameTemplate( result = result.replaceAll('{ms}', padZero(now.getMilliseconds(), 3)) result = result.replaceAll('{timestamp}', now.getTime().toString()) - // 随机字符串占位符 + // Random string placeholders result = result.replaceAll('{md5}', () => generateRandomMd5()) result = result.replaceAll('{md5-16}', () => generateRandomMd5().slice(0, 16)) result = result.replaceAll('{uuid}', () => generateUuid()) - // 自定义长度的随机字符串 {str-数字} + // Random string with custom length: {str-} // eslint-disable-next-line unicorn/better-regex result = result.replaceAll(/\{str-(\d+)\}/g, (_match, length) => { const len = Number.parseInt(length, 10) return customAlphabet(alphabet)(len) }) - // 文件名相关占位符 + // Filename placeholders result = result.replaceAll('{filename}', originalFilename) result = result.replaceAll('{name}', nameWithoutExt) result = result.replaceAll('{ext}', ext) - // 文件类型占位符 + // File type placeholder result = result.replaceAll('{type}', fileType) - // 读者 ID 占位符(评论上传专用) + // Reader ID placeholder (for comment uploads only) result = result.replaceAll('{readerId}', readerId) - // 本地文件夹占位符 {localFolder:数字} + // Local folder placeholder: {localFolder:} // eslint-disable-next-line unicorn/better-regex result = result.replaceAll(/\{localFolder:(\d+)\}/g, (_match, level) => { const lvl = Number.parseInt(level, 10) return extractFolderLevel(localFolderPath, lvl) }) - // 防止路径遍历:移除父目录引用(..) + // Prevent path traversal: strip any parent-directory references (..) const segments = result.split(/[/\\]+/) const safeSegments = segments.filter((segment) => segment !== '..') const safeResult = safeSegments.join('/') @@ -162,10 +162,10 @@ export function replaceFilenameTemplate( } /** - * 生成文件名(应用模板或使用默认规则) - * @param config 配置对象 - * @param context 上下文信息 - * @returns 生成的文件名 + * Generate a filename (using a template or the default rule). + * @param config Configuration object. + * @param context Context information. + * @returns The generated filename. */ export function generateFilename( config: { @@ -174,7 +174,7 @@ export function generateFilename( }, context: FilenameTemplateContext, ): string { - // 如果未启用自定义命名或没有模板,使用默认规则 + // Fall back to the default rule when custom naming is disabled or no template is provided if (!config.enableCustomNaming || !config.filenameTemplate) { const ext = path.extname(context.originalFilename).toLowerCase() return customAlphabet(alphabet)(18) + ext @@ -184,10 +184,10 @@ export function generateFilename( } /** - * 生成文件路径(应用模板或使用默认规则) - * @param config 配置对象 - * @param context 上下文信息 - * @returns 生成的路径 + * Generate a file path (using a template or the default rule). + * @param config Configuration object. + * @param context Context information. + * @returns The generated path. */ export function generateFilePath( config: { @@ -196,7 +196,7 @@ export function generateFilePath( }, context: FilenameTemplateContext, ): string { - // 如果未启用自定义命名或没有路径模板,使用默认规则(文件类型) + // Fall back to the default rule (file type) when custom naming is disabled or no path template is provided if (!config.enableCustomNaming || !config.pathTemplate) { return context.fileType || '' } diff --git a/apps/core/src/utils/image.util.ts b/apps/core/src/utils/image.util.ts index c09bab17410..a0844188759 100644 --- a/apps/core/src/utils/image.util.ts +++ b/apps/core/src/utils/image.util.ts @@ -81,17 +81,17 @@ export function validateImageBuffer({ }): ImageValidationResult { const detected = detectImageType(buffer) if (!detected) { - return { ok: false, reason: '不支持的头像图片格式' } + return { ok: false, reason: 'Unsupported avatar image format' } } const { mime, ext } = detected if (allowedMimeTypes && !allowedMimeTypes.has(mime)) { - return { ok: false, reason: '头像仅支持上传常见图片格式' } + return { ok: false, reason: 'Avatar only supports common image formats' } } if (allowedExtensions && !allowedExtensions.has(ext)) { - return { ok: false, reason: '头像仅支持上传常见图片格式' } + return { ok: false, reason: 'Avatar only supports common image formats' } } if (originUrl) { @@ -99,10 +99,10 @@ export function validateImageBuffer({ const urlExt = path.extname(new URL(originUrl).pathname).toLowerCase() if (urlExt && allowedExtensions && !allowedExtensions.has(urlExt)) { - return { ok: false, reason: '头像文件后缀不被支持' } + return { ok: false, reason: 'Avatar file extension is not supported' } } } catch { - return { ok: false, reason: '头像地址无法解析' } + return { ok: false, reason: 'Avatar URL cannot be parsed' } } } diff --git a/apps/core/src/utils/sandbox/sandbox-type-declaration.ts b/apps/core/src/utils/sandbox/sandbox-type-declaration.ts index 127d4c9d7ba..52456bd3f4e 100644 --- a/apps/core/src/utils/sandbox/sandbox-type-declaration.ts +++ b/apps/core/src/utils/sandbox/sandbox-type-declaration.ts @@ -1,8 +1,8 @@ /** - * Serverless 函数的类型声明,供 Monaco Editor 代码补全使用 - * 此声明与 sandbox-worker-code.ts 中 sandboxGlobals 保持一致 + * Type declarations for serverless functions, used by Monaco Editor for code completion. + * These must stay in sync with sandboxGlobals in sandbox-worker-code.ts. * - * 变更 sandbox API 时须同步更新此文件 + * Update this file whenever the sandbox API surface changes. */ export function getSandboxTypeDeclaration(): string { return ` diff --git a/apps/core/src/utils/sandbox/sandbox-worker-code.ts b/apps/core/src/utils/sandbox/sandbox-worker-code.ts index 45b7c9556df..aab9a059147 100644 --- a/apps/core/src/utils/sandbox/sandbox-worker-code.ts +++ b/apps/core/src/utils/sandbox/sandbox-worker-code.ts @@ -1,7 +1,7 @@ /** - * 生成 Worker 线程代码字符串 - * Worker 本身就是隔离环境,很多功能直接在这里实现 - * 支持 Next.js Edge Runtime 兼容的全局 API + * Generate the worker thread code string. + * The Worker is itself an isolated environment, so many features are implemented inline here. + * Provides globals compatible with the Next.js Edge Runtime. */ export function createSandboxWorkerCode(): string { return ` @@ -36,7 +36,7 @@ function sendMessage(message) { port.postMessage(message); } -// Bridge 调用:只用于必须访问主线程资源的操作 +// Bridge call: used only for operations that must access main-thread resources async function requestBridgeCall(method, args) { const id = generateId(); return new Promise((resolve, reject) => { @@ -51,17 +51,17 @@ async function requestBridgeCall(method, args) { }); } -// ===== 以下功能直接在 Worker 中实现 ===== +// ===== The following features are implemented directly inside the Worker ===== const BANNED_MODULES = new Set([ 'child_process', 'cluster', 'dgram', 'dns', 'fs', 'fs/promises', 'inspector', 'os', 'process', 'repl', 'sys', 'tls', 'v8', 'vm', 'worker_threads', - // 防止通过 module.createRequire 绕过沙箱限制 + // Prevent bypassing sandbox restrictions via module.createRequire 'module', ]); -// require 直接在 Worker 中执行 +// require runs directly inside the Worker function createSandboxRequire(basePath) { const baseRequire = createRequire(basePath); @@ -76,7 +76,7 @@ function createSandboxRequire(basePath) { throw new Error('require: module "' + moduleId + '" is not allowed'); } - // 远程模块通过 bridge 加载 + // Remote modules are loaded through the bridge if (moduleId.startsWith('http://') || moduleId.startsWith('https://')) { throw new Error('Remote modules must use async require: await require("' + moduleId + '")'); } @@ -85,7 +85,7 @@ function createSandboxRequire(basePath) { }; } -// 异步 require +// Async require function createAsyncRequire(basePath) { const syncRequire = createSandboxRequire(basePath); return async function asyncRequire(moduleId) { @@ -132,9 +132,9 @@ function createSandboxConsole(namespace) { return { console: sandboxConsole, getLogs: () => logs }; } -// ===== 以下功能需要通过 Bridge 访问主线程 ===== +// ===== The following features need to reach the main thread via the Bridge ===== -// 创建 HTTP 服务(直接在 Worker 中实现) +// Create the HTTP service (implemented directly inside the Worker) function createHttpService() { const request = async (url, options = {}) => { const res = await fetch(url, options); @@ -189,13 +189,13 @@ function createBridgeContext(namespace) { }; } -// ===== 定时器管理 ===== +// ===== Timer management ===== function createTimerManager() { const timers = new Set(); const intervals = new Set(); const MAX_TIMERS = 100; - const MAX_DELAY = 30000; // 最大延迟 30 秒 + const MAX_DELAY = 30000; // Max delay of 30 seconds return { setTimeout: (callback, delay, ...args) => { @@ -218,7 +218,7 @@ function createTimerManager() { if (intervals.size >= MAX_TIMERS) { throw new Error('Too many intervals created'); } - const clampedDelay = Math.max(10, Math.min(delay || 10, MAX_DELAY)); // 最小 10ms + const clampedDelay = Math.max(10, Math.min(delay || 10, MAX_DELAY)); // Minimum 10ms const id = setInterval(callback, clampedDelay, ...args); intervals.add(id); return id; @@ -236,8 +236,8 @@ function createTimerManager() { }; } -// ===== 受限的 Function 构造函数 ===== -// Edge Runtime 不允许通过字符串创建函数 +// ===== Restricted Function constructor ===== +// The Edge Runtime forbids creating functions from strings const RestrictedFunction = new Proxy(Function, { construct(target, args) { throw new Error('Code generation from strings is not allowed in sandbox'); @@ -246,12 +246,12 @@ const RestrictedFunction = new Proxy(Function, { throw new Error('Code generation from strings is not allowed in sandbox'); }, get(target, prop) { - // 允许访问 Function 的静态属性和 prototype + // Allow access to Function's static properties and prototype return target[prop]; }, }); -// ===== 代码执行 ===== +// ===== Code execution ===== async function executeCode(payload) { const startTime = Date.now(); @@ -391,7 +391,7 @@ async function executeCode(payload) { // ===== Core Objects ===== Object: globalThis.Object, - Function: RestrictedFunction, // 禁止通过字符串创建函数 + Function: RestrictedFunction, // Forbid creating functions from strings Boolean: globalThis.Boolean, Symbol: globalThis.Symbol, Number: globalThis.Number, @@ -433,13 +433,13 @@ async function executeCode(payload) { undefined: undefined, // ===== Process (Limited) ===== - // 注意:不暴露 process.env,防止敏感信息泄露 + // Note: do not expose process.env to avoid leaking sensitive information process: { nextTick: (callback) => queueMicrotask(callback), }, // ===== Global Reference ===== - globalThis: null, // 将在创建 context 后设置 + globalThis: null, // Set after the context is created }; const vmContext = vm.createContext(sandboxGlobals, { @@ -447,7 +447,7 @@ async function executeCode(payload) { codeGeneration: { strings: false, wasm: false }, }); - // 设置 globalThis 引用 + // Set the globalThis reference vmContext.globalThis = vmContext; vmContext.global = vmContext; vmContext.self = vmContext; @@ -474,12 +474,12 @@ async function executeCode(payload) { logs: getLogs(), }; } finally { - // 清理所有定时器 + // Clean up all timers timerManager.cleanup(); } } -// ===== 消息处理 ===== +// ===== Message handling ===== port.on('message', async (message) => { const { id, type, payload } = message; diff --git a/apps/core/src/utils/sandbox/sandbox.service.ts b/apps/core/src/utils/sandbox/sandbox.service.ts index 6c7236e12e6..4da7f797a44 100644 --- a/apps/core/src/utils/sandbox/sandbox.service.ts +++ b/apps/core/src/utils/sandbox/sandbox.service.ts @@ -1,5 +1,5 @@ import { Worker } from 'node:worker_threads' -import { createSandboxWorkerCode } from './sandbox-worker-code' + import type { BridgeCallPayload, ExecutePayload, @@ -8,6 +8,7 @@ import type { WorkerMessage, } from './sandbox.types' import { WorkerMessageType } from './sandbox.types' +import { createSandboxWorkerCode } from './sandbox-worker-code' interface BridgeHandlers { 'storage.cache.get': (key: string) => Promise @@ -48,7 +49,7 @@ export interface SandboxServiceOptions { defaultTimeout?: number requireBasePath?: string bridgeHandlers: Partial - /** 空闲多久后回收 Worker (ms),默认 60000 (1分钟) */ + /** Idle duration (ms) before a Worker is reclaimed. Default: 60000 (1 minute) */ idleTimeout?: number } @@ -84,7 +85,7 @@ export class SandboxService { defaultTimeout: options.defaultTimeout ?? 30000, requireBasePath: options.requireBasePath ?? process.cwd(), bridgeHandlers: options.bridgeHandlers, - idleTimeout: options.idleTimeout ?? 60000, // 默认 1 分钟 + idleTimeout: options.idleTimeout ?? 60000, // Default 1 minute } this.workerCode = createSandboxWorkerCode() @@ -93,7 +94,7 @@ export class SandboxService { async initialize(): Promise { if (this.initialized) return - // 创建最小数量的 Worker + // Create the minimum number of Workers const initPromises: Promise[] = [] for (let i = 0; i < this.options.minWorkers; i++) { initPromises.push( @@ -104,13 +105,13 @@ export class SandboxService { } await Promise.all(initPromises) - // 启动空闲清理定时器 + // Start the idle cleanup timer this.startIdleCleanup() this.initialized = true } private startIdleCleanup(): void { - // 每 30 秒检查一次空闲 Worker + // Check for idle Workers every 30 seconds this.idleCleanupInterval = setInterval(() => { this.cleanupIdleWorkers() }, 30000) @@ -120,12 +121,12 @@ export class SandboxService { const now = Date.now() const idleTimeout = this.options.idleTimeout - // 找出空闲超时的 Worker(保留最小数量) + // Find Workers that have been idle past the timeout (keeping the minimum count) const idleWorkers = this.workers.filter( (w) => !w.busy && now - w.lastUsed > idleTimeout, ) - // 确保至少保留 minWorkers 个 Worker + // Ensure at least minWorkers remain alive const toRemove = Math.min( idleWorkers.length, this.workers.length - this.options.minWorkers, @@ -155,12 +156,12 @@ export class SandboxService { workerData: { requireBasePath: this.options.requireBasePath, }, - // 限制每个 Worker 的资源使用,防止影响主进程 + // Cap each Worker's resource usage so it does not impact the main process resourceLimits: { - maxOldGenerationSizeMb: 128, // V8 老生代内存限制 - maxYoungGenerationSizeMb: 32, // V8 新生代内存限制 - codeRangeSizeMb: 32, // 代码段内存限制 - stackSizeMb: 4, // 栈大小限制 + maxOldGenerationSizeMb: 128, // V8 old-generation memory limit + maxYoungGenerationSizeMb: 32, // V8 young-generation memory limit + codeRangeSizeMb: 32, // Code segment memory limit + stackSizeMb: 4, // Stack size limit }, }) @@ -357,7 +358,7 @@ export class SandboxService { } async shutdown(): Promise { - // 停止空闲清理定时器 + // Stop the idle cleanup timer if (this.idleCleanupInterval) { clearInterval(this.idleCleanupInterval) this.idleCleanupInterval = null diff --git a/apps/core/test/helper/create-e2e-app.ts b/apps/core/test/helper/create-e2e-app.ts index c9dbd454f02..98965b43333 100644 --- a/apps/core/test/helper/create-e2e-app.ts +++ b/apps/core/test/helper/create-e2e-app.ts @@ -1,11 +1,11 @@ import type { ModuleMetadata } from '@nestjs/common' -import { APP_INTERCEPTOR } from '@nestjs/core' +import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core' import type { NestFastifyApplication } from '@nestjs/platform-fastify' +import { AppExceptionFilter } from '~/common/filters/app-exception.filter' import { HttpCacheInterceptor } from '~/common/interceptors/cache.interceptor' import { DbQueryInterceptor } from '~/common/interceptors/db-query.interceptor' -import { JSONTransformInterceptor } from '~/common/interceptors/json-transform.interceptor' -import { ResponseInterceptor } from '~/common/interceptors/response.interceptor' +import { ResponseInterceptorV2 } from '~/common/interceptors/response.interceptor' import { redisHelper } from './redis-mock.helper' import { setupE2EApp } from './setup-e2e' @@ -25,20 +25,17 @@ export const createE2EApp = (module: ModuleMetadata) => { provide: APP_INTERCEPTOR, useClass: DbQueryInterceptor, }, - { provide: APP_INTERCEPTOR, - useClass: HttpCacheInterceptor, // 5 + useClass: HttpCacheInterceptor, }, - { provide: APP_INTERCEPTOR, - useClass: JSONTransformInterceptor, // 3 + useClass: ResponseInterceptorV2, }, - { - provide: APP_INTERCEPTOR, - useClass: ResponseInterceptor, // 1 + provide: APP_FILTER, + useClass: AppExceptionFilter, }, ) diff --git a/apps/core/test/helper/setup-e2e.ts b/apps/core/test/helper/setup-e2e.ts index 07450bc60b1..d356b771768 100644 --- a/apps/core/test/helper/setup-e2e.ts +++ b/apps/core/test/helper/setup-e2e.ts @@ -1,10 +1,12 @@ import type { ModuleMetadata } from '@nestjs/common' import type { NestFastifyApplication } from '@nestjs/platform-fastify' import { Test, TestingModule } from '@nestjs/testing' +import { AuthTestingGuard } from 'test/mock/guard/auth.guard' + import { fastifyApp } from '~/common/adapters/fastify.adapter' import { AuthGuard } from '~/common/guards/auth.guard' +import { requestCaseNormalizationPipeInstance } from '~/common/pipes/case-normalization.pipe' import { extendedZodValidationPipeInstance } from '~/common/zod' -import { AuthTestingGuard } from 'test/mock/guard/auth.guard' export const setupE2EApp = async (module: TestingModule | ModuleMetadata) => { let nextModule: TestingModule @@ -19,7 +21,10 @@ export const setupE2EApp = async (module: TestingModule | ModuleMetadata) => { const app = nextModule.createNestApplication(fastifyApp) - app.useGlobalPipes(extendedZodValidationPipeInstance) + app.useGlobalPipes( + requestCaseNormalizationPipeInstance, + extendedZodValidationPipeInstance, + ) await app.init() await app.getHttpAdapter().getInstance().ready() diff --git a/apps/core/test/mock/processors/translation.mock.ts b/apps/core/test/mock/processors/translation.mock.ts index 0847152089d..9d308528851 100644 --- a/apps/core/test/mock/processors/translation.mock.ts +++ b/apps/core/test/mock/processors/translation.mock.ts @@ -1,6 +1,8 @@ -import { TranslationService } from '~/processors/helper/helper.translation.service' import { defineProvider } from 'test/helper/defineProvider' +import { TranslationEntryService } from '~/modules/ai/ai-translation/translation-entry.service' +import { TranslationService } from '~/processors/helper/helper.translation.service' + export const translationProvider = defineProvider({ provide: TranslationService, useValue: { @@ -17,5 +19,26 @@ export const translationProvider = defineProvider({ const { items, applyResult } = options return items.map((item) => applyResult(item, undefined)) }, + async translateArticleList() { + return new Map() + }, + async collectArticleTranslations() { + return { + results: new Map(), + meta: new Map(), + } + }, }, }) + +export const translationEntryProvider = { + provide: TranslationEntryService, + useValue: { + async getTranslationsBatch() { + return { + entityMaps: new Map>(), + dictMaps: new Map>(), + } + }, + }, +} diff --git a/apps/core/test/src/common/filters/app-exception.filter.spec.ts b/apps/core/test/src/common/filters/app-exception.filter.spec.ts new file mode 100644 index 00000000000..bffd702cab2 --- /dev/null +++ b/apps/core/test/src/common/filters/app-exception.filter.spec.ts @@ -0,0 +1,107 @@ +import type { ArgumentsHost } from '@nestjs/common' +import { BadRequestException } from '@nestjs/common' +import { z } from 'zod' + +import { AppException } from '~/common/errors/exception.types' +import { AppExceptionFilter } from '~/common/filters/app-exception.filter' + +interface FakeReply { + statusCode?: number + contentType?: string + body?: any + status: (code: number) => FakeReply + type: (value: string) => FakeReply + send: (body: any) => FakeReply +} + +const createReply = (): FakeReply => { + const reply: FakeReply = { + status(code) { + reply.statusCode = code + return reply + }, + type(value) { + reply.contentType = value + return reply + }, + send(body) { + reply.body = body + return reply + }, + } + return reply +} + +const createHost = (reply: FakeReply): ArgumentsHost => + ({ + switchToHttp: () => ({ + getResponse: () => reply, + getRequest: () => ({}), + }), + }) as unknown as ArgumentsHost + +describe('AppExceptionFilter', () => { + const filter = new AppExceptionFilter() + + test('maps an AppException to a coded error envelope with its status', () => { + const reply = createReply() + filter.catch( + new AppException('POST_NOT_FOUND', 'Post not found', 404, { id: '7' }), + createHost(reply), + ) + + expect(reply.statusCode).toBe(404) + expect(reply.body).toEqual({ + error: { + code: 'POST_NOT_FOUND', + message: 'Post not found', + details: { id: '7' }, + }, + }) + }) + + test('omits details when the AppException has none', () => { + const reply = createReply() + filter.catch( + new AppException('AUTH_SESSION_EXPIRED', 'Session expired', 401), + createHost(reply), + ) + + expect(reply.statusCode).toBe(401) + expect(reply.body.error.details).toBeUndefined() + }) + + test('maps a ZodError to a 400 VALIDATION_FAILED envelope carrying issues', () => { + const reply = createReply() + const zodError = z.string().safeParse(123).error! + filter.catch(zodError, createHost(reply)) + + expect(reply.statusCode).toBe(400) + expect(reply.body.error.code).toBe('VALIDATION_FAILED') + expect(Array.isArray(reply.body.error.details.issues)).toBe(true) + }) + + test('maps a generic HttpException to an HTTP_ERROR envelope with its status', () => { + const reply = createReply() + filter.catch(new BadRequestException('bad input'), createHost(reply)) + + expect(reply.statusCode).toBe(400) + expect(reply.body.error.code).toBe('HTTP_ERROR') + expect(reply.body.error.message).toBe('bad input') + }) + + test('maps an unknown error to a 500 INTERNAL_ERROR envelope', () => { + const reply = createReply() + filter.catch(new Error('boom'), createHost(reply)) + + expect(reply.statusCode).toBe(500) + expect(reply.body.error.code).toBe('INTERNAL_ERROR') + }) + + test('sends every error envelope as application/json', () => { + const reply = createReply() + filter.catch(new Error('boom'), createHost(reply)) + + expect(reply.contentType).toBe('application/json') + }) +}) diff --git a/apps/core/test/src/common/guards/auth.guard.spec.ts b/apps/core/test/src/common/guards/auth.guard.spec.ts index 962c1d662ba..e234cfcae60 100644 --- a/apps/core/test/src/common/guards/auth.guard.spec.ts +++ b/apps/core/test/src/common/guards/auth.guard.spec.ts @@ -1,9 +1,9 @@ import type { ExecutionContext } from '@nestjs/common' import { vi } from 'vitest' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppErrorCode } from '~/common/errors' +import { AppException } from '~/common/errors/exception.types' import { AuthGuard } from '~/common/guards/auth.guard' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import type { AuthService } from '~/modules/auth/auth.service' import type { SessionUser } from '~/modules/auth/auth.types' @@ -105,14 +105,14 @@ describe('AuthGuard', () => { session: { token: 'reader-tok' }, }) - await expect(guard.canActivate(context)).rejects.toThrow(BizException) + await expect(guard.canActivate(context)).rejects.toThrow(AppException) }) it('should fall through to API key when session is null', async () => { const { context } = createMockContext() authService.getSessionUser.mockResolvedValue(null) - await expect(guard.canActivate(context)).rejects.toThrow(BizException) + await expect(guard.canActivate(context)).rejects.toThrow(AppException) }) }) @@ -126,7 +126,7 @@ describe('AuthGuard', () => { authService.getApiKeyFromRequest.mockReturnValue(null) await expect(guard.canActivate(context)).rejects.toThrow( - expect.objectContaining({ bizCode: ErrorCodeEnum.AuthNotLoggedIn }), + expect.objectContaining({ code: AppErrorCode.AUTH_NOT_LOGGED_IN }), ) }) @@ -139,7 +139,7 @@ describe('AuthGuard', () => { authService.isCustomToken.mockReturnValue(false) await expect(guard.canActivate(context)).rejects.toThrow( - expect.objectContaining({ bizCode: ErrorCodeEnum.AuthTokenInvalid }), + expect.objectContaining({ code: AppErrorCode.AUTH_TOKEN_INVALID }), ) }) @@ -153,7 +153,7 @@ describe('AuthGuard', () => { authService.verifyApiKey.mockResolvedValue(null) await expect(guard.canActivate(context)).rejects.toThrow( - expect.objectContaining({ bizCode: ErrorCodeEnum.AuthTokenInvalid }), + expect.objectContaining({ code: AppErrorCode.AUTH_TOKEN_INVALID }), ) }) @@ -167,7 +167,7 @@ describe('AuthGuard', () => { authService.verifyApiKey.mockResolvedValue({}) await expect(guard.canActivate(context)).rejects.toThrow( - expect.objectContaining({ bizCode: ErrorCodeEnum.AuthTokenInvalid }), + expect.objectContaining({ code: AppErrorCode.AUTH_TOKEN_INVALID }), ) }) @@ -182,7 +182,7 @@ describe('AuthGuard', () => { authService.isOwnerReaderId.mockResolvedValue(false) await expect(guard.canActivate(context)).rejects.toThrow( - expect.objectContaining({ bizCode: ErrorCodeEnum.AuthTokenInvalid }), + expect.objectContaining({ code: AppErrorCode.AUTH_TOKEN_INVALID }), ) }) @@ -198,7 +198,7 @@ describe('AuthGuard', () => { authService.getReaderById.mockResolvedValue(null) await expect(guard.canActivate(context)).rejects.toThrow( - expect.objectContaining({ bizCode: ErrorCodeEnum.AuthTokenInvalid }), + expect.objectContaining({ code: AppErrorCode.AUTH_TOKEN_INVALID }), ) }) diff --git a/apps/core/test/src/common/interceptors/response.interceptor.spec.ts b/apps/core/test/src/common/interceptors/response.interceptor.spec.ts new file mode 100644 index 00000000000..5df0ce66ba9 --- /dev/null +++ b/apps/core/test/src/common/interceptors/response.interceptor.spec.ts @@ -0,0 +1,139 @@ +import type { + CallHandler, + ExecutionContext, + NestInterceptor, +} from '@nestjs/common' +import { firstValueFrom, of } from 'rxjs' + +import { ResponseInterceptorV2 } from '~/common/interceptors/response.interceptor' +import { withMeta } from '~/common/response/envelope.types' + +interface FakeResponse { + statusCode?: number + status: (code: number) => FakeResponse +} + +const createResponse = (): FakeResponse => { + const res: FakeResponse = { + status(code: number) { + res.statusCode = code + return res + }, + } + return res +} + +const createContext = (options: { + hasRequest?: boolean + response?: FakeResponse +}): ExecutionContext => { + const { hasRequest = true, response } = options + return { + getHandler: () => () => void 0, + getClass: () => class {}, + switchToHttp: () => ({ + getRequest: () => (hasRequest ? {} : undefined), + getResponse: () => response, + }), + } as unknown as ExecutionContext +} + +const createHandler = (value: unknown): CallHandler => ({ + handle: () => of(value), +}) + +const run = ( + interceptor: NestInterceptor, + context: ExecutionContext, + value: unknown, +) => firstValueFrom(interceptor.intercept(context, createHandler(value)) as any) + +describe('ResponseInterceptorV2', () => { + const make = (passthrough = false) => + new ResponseInterceptorV2({ + getAllAndOverride: () => passthrough, + } as any) + + test('wraps a bare object in a data envelope', async () => { + const result = await run(make(), createContext({}), { + id: '1', + title: 'Hello', + }) + + expect(result).toEqual({ data: { id: '1', title: 'Hello' } }) + }) + + test('wraps a bare array in a data envelope', async () => { + const result = await run(make(), createContext({}), [1, 2, 3]) + + expect(result).toEqual({ data: [1, 2, 3] }) + }) + + test('wraps a null value in a data envelope', async () => { + const result = await run(make(), createContext({}), null) + + expect(result).toEqual({ data: null }) + }) + + test('passes through an explicit metadata envelope', async () => { + const envelope = withMeta({ id: '1' }, { view: 'detail' }) + const result = await run(make(), createContext({}), envelope) + + expect(result).toEqual({ data: { id: '1' }, meta: { view: 'detail' } }) + }) + + test('wraps an object that looks like a data-only envelope', async () => { + const result = await run(make(), createContext({}), { data: [1, 2] }) + + expect(result).toEqual({ data: { data: [1, 2] } }) + }) + + test('converts camelCase keys in data to snake_case', async () => { + const result = await run(make(), createContext({}), { + createdAt: 1, + categoryId: '2', + }) + + expect(result).toEqual({ data: { created_at: 1, category_id: '2' } }) + }) + + test('converts camelCase keys in explicit metadata', async () => { + const envelope = withMeta({ id: '1' }, { totalPages: 3 } as any) + const result = await run(make(), createContext({}), envelope) + + expect(result).toEqual({ data: { id: '1' }, meta: { total_pages: 3 } }) + }) + + test('wraps an object that merely has a data property among other keys', async () => { + const value = { data: 'x', name: 'config' } + const result = await run(make(), createContext({}), value) + + expect(result).toEqual({ data: { data: 'x', name: 'config' } }) + }) + + test('emits 204 with no body for undefined', async () => { + const response = createResponse() + const result = await run(make(), createContext({ response }), undefined) + + expect(result).toBeUndefined() + expect(response.statusCode).toBe(204) + }) + + test('skips transformation when the route opts out via passthrough metadata', async () => { + const value = [1, 2, 3] + const result = await run(make(true), createContext({}), value) + + expect(result).toBe(value) + }) + + test('skips transformation when there is no HTTP request context', async () => { + const value = { id: '1' } + const result = await run( + make(), + createContext({ hasRequest: false }), + value, + ) + + expect(result).toBe(value) + }) +}) diff --git a/apps/core/test/src/common/middlewares/request-context.middleware.spec.ts b/apps/core/test/src/common/middlewares/request-context.middleware.spec.ts new file mode 100644 index 00000000000..6b89131acef --- /dev/null +++ b/apps/core/test/src/common/middlewares/request-context.middleware.spec.ts @@ -0,0 +1,76 @@ +import type { IncomingHttpHeaders, ServerResponse } from 'node:http' + +import { RequestContext } from '~/common/contexts/request.context' +import { RequestContextMiddleware } from '~/common/middlewares/request-context.middleware' +import type { BizIncomingMessage } from '~/transformers/get-req.transformer' + +const buildReq = (headers: IncomingHttpHeaders): BizIncomingMessage => + ({ headers }) as unknown as BizIncomingMessage + +const buildRes = () => ({}) as ServerResponse + +const runWith = async (headers: IncomingHttpHeaders) => { + const middleware = new RequestContextMiddleware() + let captured: string | undefined + await new Promise((resolve) => { + middleware.use(buildReq(headers), buildRes(), () => { + captured = RequestContext.currentLang() + resolve() + }) + }) + return captured +} + +describe('RequestContextMiddleware lang resolution', () => { + test('default chain: x-lang wins over cookie wins over accept-language', async () => { + expect( + await runWith({ + 'x-lang': 'ja', + cookie: 'NEXT_LOCALE=fr', + 'accept-language': 'zh-CN', + }), + ).toBe('ja') + + expect( + await runWith({ + cookie: 'NEXT_LOCALE=fr', + 'accept-language': 'zh-CN', + }), + ).toBe('fr') + + expect( + await runWith({ + 'accept-language': 'zh-CN', + }), + ).toBe('zh') + }) + + test('x-skip-translation: 1 ignores cookie and accept-language', async () => { + expect( + await runWith({ + 'x-skip-translation': '1', + cookie: 'NEXT_LOCALE=fr', + 'accept-language': 'zh-CN', + }), + ).toBeUndefined() + }) + + test('x-skip-translation: 1 still honors explicit x-lang', async () => { + expect( + await runWith({ + 'x-skip-translation': '1', + 'x-lang': 'ja', + 'accept-language': 'zh-CN', + }), + ).toBe('ja') + }) + + test('x-skip-translation value other than "1" does not trigger skip', async () => { + expect( + await runWith({ + 'x-skip-translation': 'true', + 'accept-language': 'zh-CN', + }), + ).toBe('zh') + }) +}) diff --git a/apps/core/test/src/common/pipes/case-normalization.e2e.spec.ts b/apps/core/test/src/common/pipes/case-normalization.e2e.spec.ts new file mode 100644 index 00000000000..44c02bd5607 --- /dev/null +++ b/apps/core/test/src/common/pipes/case-normalization.e2e.spec.ts @@ -0,0 +1,69 @@ +import { Body, Controller, Get, Post, Query } from '@nestjs/common' +import { createZodDto } from 'nestjs-zod' +import { createE2EApp } from 'test/helper/create-e2e-app' +import { z } from 'zod' + +const QuerySchema = z.object({ + sortBy: z.string().optional(), + sortOrder: z.enum(['asc', 'desc']).default('desc'), +}) + +class QueryDto extends createZodDto(QuerySchema) {} + +const BodySchema = z.object({ + newName: z.string(), + socialIds: z.record(z.string(), z.string()).optional(), +}) + +class BodyDto extends createZodDto(BodySchema) {} + +@Controller('case-test') +class CaseTestController { + @Get('/echo') + echoQuery(@Query() query: QueryDto) { + return { sortBy: query.sortBy ?? null, sortOrder: query.sortOrder } + } + + @Post('/echo') + echoBody(@Body() body: BodyDto) { + return body + } +} + +describe('request case normalization', () => { + const proxy = createE2EApp({ + controllers: [CaseTestController], + }) + + test('accepts snake_case query keys', async () => { + const res = await proxy.app.inject({ + method: 'GET', + url: '/case-test/echo?sort_by=createdAt&sort_order=asc', + }) + expect(res.statusCode).toBe(200) + // ResponseInterceptorV2 snake-cases the controller return on the wire + expect(res.json().data).toEqual({ sort_by: 'createdAt', sort_order: 'asc' }) + }) + + test('still accepts camelCase query keys', async () => { + const res = await proxy.app.inject({ + method: 'GET', + url: '/case-test/echo?sortBy=title&sortOrder=desc', + }) + expect(res.statusCode).toBe(200) + expect(res.json().data).toEqual({ sort_by: 'title', sort_order: 'desc' }) + }) + + test('camelizes top-level body keys but leaves freeform JSON intact', async () => { + const res = await proxy.app.inject({ + method: 'POST', + url: '/case-test/echo', + payload: { new_name: 'a', social_ids: { github_user: 'u' } }, + }) + expect(res.statusCode).toBe(201) + expect(res.json().data).toEqual({ + new_name: 'a', + social_ids: { github_user: 'u' }, + }) + }) +}) diff --git a/apps/core/test/src/common/pipes/case-normalization.pipe.spec.ts b/apps/core/test/src/common/pipes/case-normalization.pipe.spec.ts new file mode 100644 index 00000000000..e3200391f43 --- /dev/null +++ b/apps/core/test/src/common/pipes/case-normalization.pipe.spec.ts @@ -0,0 +1,60 @@ +import { Readable } from 'node:stream' + +import type { ArgumentMetadata } from '@nestjs/common' + +import { RequestCaseNormalizationPipe } from '~/common/pipes/case-normalization.pipe' + +const meta = (type: ArgumentMetadata['type']): ArgumentMetadata => ({ + type, + metatype: undefined, + data: undefined, +}) + +describe('RequestCaseNormalizationPipe', () => { + const pipe = new RequestCaseNormalizationPipe() + + test('deep-camelizes query payloads', () => { + expect( + pipe.transform( + { sort_by: 'createdAt', filter: { nested_key: 1 } }, + meta('query'), + ), + ).toEqual({ sortBy: 'createdAt', filter: { nestedKey: 1 } }) + }) + + test('only top-level camelizes body payloads, preserving freeform JSON', () => { + expect( + pipe.transform( + { new_name: 'a', social_ids: { github_user: 'u' } }, + meta('body'), + ), + ).toEqual({ newName: 'a', socialIds: { github_user: 'u' } }) + }) + + test('deep-camelizes path params', () => { + expect(pipe.transform({ ref_id: '1' }, meta('param'))).toEqual({ + refId: '1', + }) + }) + + test('returns primitives untouched', () => { + expect(pipe.transform('snake_value', meta('body'))).toBe('snake_value') + expect(pipe.transform(42, meta('query'))).toBe(42) + expect(pipe.transform(null, meta('body'))).toBe(null) + }) + + test('returns Buffers untouched', () => { + const buf = Buffer.from('hello') + expect(pipe.transform(buf, meta('body'))).toBe(buf) + }) + + test('returns streams untouched', () => { + const stream = Readable.from(['payload']) + expect(pipe.transform(stream, meta('body'))).toBe(stream) + }) + + test('does nothing for custom param types it does not recognize', () => { + const value = { snake_key: 1 } + expect(pipe.transform(value, meta('custom'))).toBe(value) + }) +}) diff --git a/apps/core/test/src/common/pipes/case-transform.spec.ts b/apps/core/test/src/common/pipes/case-transform.spec.ts new file mode 100644 index 00000000000..9b154e593c7 --- /dev/null +++ b/apps/core/test/src/common/pipes/case-transform.spec.ts @@ -0,0 +1,69 @@ +import { camelKey, transformRequestCase } from '~/common/pipes/case-transform' + +describe('camelKey', () => { + test('camelizes snake_case identifiers', () => { + expect(camelKey('sort_by')).toBe('sortBy') + expect(camelKey('with_meta_json')).toBe('withMetaJson') + expect(camelKey('a_b_c')).toBe('aBC') + }) + + test('leaves a string without underscore intact', () => { + expect(camelKey('sortBy')).toBe('sortBy') + expect(camelKey('value')).toBe('value') + }) + + test('preserves leading underscores', () => { + expect(camelKey('__debug')).toBe('__debug') + expect(camelKey('__api_url')).toBe('__apiUrl') + expect(camelKey('_internal_value')).toBe('_internalValue') + }) + + test('leaves digit-only segments alone', () => { + expect(camelKey('foo_1_bar')).toBe('foo1Bar') + }) +}) + +describe('transformRequestCase (deep)', () => { + test('camelizes top-level keys', () => { + expect( + transformRequestCase({ sort_by: 'createdAt', sort_order: 'asc' }), + ).toEqual({ sortBy: 'createdAt', sortOrder: 'asc' }) + }) + + test('recurses into nested objects and arrays', () => { + expect( + transformRequestCase({ + outer_key: { inner_one: 1, inner_two: [{ leaf_key: 'x' }] }, + }), + ).toEqual({ + outerKey: { innerOne: 1, innerTwo: [{ leafKey: 'x' }] }, + }) + }) + + test('leaves primitive scalars alone', () => { + expect(transformRequestCase(null)).toBe(null) + expect(transformRequestCase(undefined)).toBe(undefined) + expect(transformRequestCase(42)).toBe(42) + expect(transformRequestCase('snake_case_value')).toBe('snake_case_value') + }) + + test('preserves pageproxy double-underscore prefixed keys', () => { + expect( + transformRequestCase({ __debug: true, __api_url: 'https://x' }), + ).toEqual({ __debug: true, __apiUrl: 'https://x' }) + }) +}) + +describe('transformRequestCase (shallow)', () => { + test('camelizes only top-level keys when deep is false', () => { + expect( + transformRequestCase( + { new_name: 'a', social_ids: { github_user: 'u' } }, + { deep: false }, + ), + ).toEqual({ + newName: 'a', + socialIds: { github_user: 'u' }, + }) + }) +}) diff --git a/apps/core/test/src/common/response/case-transform.spec.ts b/apps/core/test/src/common/response/case-transform.spec.ts new file mode 100644 index 00000000000..e534e858605 --- /dev/null +++ b/apps/core/test/src/common/response/case-transform.spec.ts @@ -0,0 +1,99 @@ +import { transformResponseCase } from '~/common/response/case-transform' + +describe('transformResponseCase', () => { + it('converts camelCase object keys to snake_case', () => { + expect(transformResponseCase({ createdAt: 1, isPublished: true })).toEqual({ + created_at: 1, + is_published: true, + }) + }) + + it('recurses into nested objects and arrays', () => { + expect( + transformResponseCase({ + categoryId: '1', + relatedPosts: [{ postId: 'a' }, { postId: 'b' }], + }), + ).toEqual({ + category_id: '1', + related_posts: [{ post_id: 'a' }, { post_id: 'b' }], + }) + }) + + it('leaves Date instances intact', () => { + const date = new Date('2026-05-16T00:00:00.000Z') + const result = transformResponseCase({ createdAt: date }) as { + created_at: Date + } + expect(result.created_at).toBe(date) + }) + + it('leaves primitives and null untouched', () => { + expect(transformResponseCase(null)).toBe(null) + expect(transformResponseCase(42)).toBe(42) + expect(transformResponseCase('camelCase')).toBe('camelCase') + }) + + it('does not convert non-identifier keys (urls, ids, dotted paths)', () => { + expect( + transformResponseCase({ + 'https://Example.com/Path': { likeCount: 1 }, + '1024': { readCount: 2 }, + 'category.name': 'x', + }), + ).toEqual({ + 'https://Example.com/Path': { like_count: 1 }, + '1024': { read_count: 2 }, + 'category.name': 'x', + }) + }) + + it('emits a bypassed subtree verbatim', () => { + expect( + transformResponseCase( + { metaInfo: { userKey: { NestedKey: 1 } }, otherField: { aB: 2 } }, + ['metaInfo'], + ), + ).toEqual({ + meta_info: { userKey: { NestedKey: 1 } }, + other_field: { a_b: 2 }, + }) + }) + + it('matches bypass paths through array segments', () => { + expect( + transformResponseCase( + { dataList: [{ rawMeta: { keepMe: 1 } }, { rawMeta: { keepMe: 2 } }] }, + ['dataList[].rawMeta'], + ), + ).toEqual({ + data_list: [{ raw_meta: { keepMe: 1 } }, { raw_meta: { keepMe: 2 } }], + }) + }) + + it('preserves acronym boundaries on consecutive uppercase letters', () => { + expect( + transformResponseCase({ + articleURL: 'a', + htmlContent: 'b', + ioError: 'c', + userIDList: 'd', + }), + ).toEqual({ + article_url: 'a', + html_content: 'b', + io_error: 'c', + user_id_list: 'd', + }) + }) + + it('only bypasses an exact path, not its prefixes', () => { + expect( + transformResponseCase({ outerField: { innerField: { deepKey: 1 } } }, [ + 'outerField.innerField', + ]), + ).toEqual({ + outer_field: { inner_field: { deepKey: 1 } }, + }) + }) +}) diff --git a/apps/core/test/src/common/response/meta-builder.spec.ts b/apps/core/test/src/common/response/meta-builder.spec.ts new file mode 100644 index 00000000000..10148af6481 --- /dev/null +++ b/apps/core/test/src/common/response/meta-builder.spec.ts @@ -0,0 +1,163 @@ +import { + type EntryTranslation, + type InteractionMeta, + ResponseMetaSchema, +} from '~/common/response/meta.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' + +describe('MetaObjectBuilder', () => { + test('empty build produces an empty object that validates', () => { + const meta = new MetaObjectBuilder().build() + + expect(meta).toEqual({}) + expect(ResponseMetaSchema.safeParse(meta).success).toBe(true) + }) + + test('pagination sets the pagination key only', () => { + const meta = new MetaObjectBuilder() + .pagination({ page: 1, size: 10, total: 42, totalPages: 5 }) + .build() + + expect(meta.pagination).toEqual({ + page: 1, + size: 10, + total: 42, + totalPages: 5, + }) + expect(Object.keys(meta)).toEqual(['pagination']) + }) + + test('pagination normalizes repository pagination shape', () => { + const meta = new MetaObjectBuilder() + .pagination({ + currentPage: 2, + size: 10, + total: 42, + totalPage: 5, + hasNextPage: true, + hasPrevPage: true, + }) + .build() + + expect(meta.pagination).toEqual({ + page: 2, + size: 10, + total: 42, + totalPages: 5, + }) + }) + + test('view sets the view key only', () => { + const meta = new MetaObjectBuilder().view('card').build() + + expect(meta.view).toBe('card') + expect(Object.keys(meta)).toEqual(['view']) + }) + + test('translation accepts a single EntryTranslation', () => { + const entry: EntryTranslation = { + article: { isTranslated: true, sourceLang: 'zh', targetLang: 'en' }, + } + const meta = new MetaObjectBuilder().translation(entry).build() + + expect(meta.translation).toEqual(entry) + }) + + test('translation normalizes a Map into an id-keyed record', () => { + const map = new Map([ + ['1', { article: { isTranslated: true, sourceLang: 'zh' } }], + ['2', { article: { isTranslated: false } }], + ]) + const meta = new MetaObjectBuilder().translation(map).build() + + expect(meta.translation).toEqual({ + 1: { article: { isTranslated: true, sourceLang: 'zh' } }, + 2: { article: { isTranslated: false } }, + }) + }) + + test('interaction accepts a single InteractionMeta', () => { + const interaction: InteractionMeta = { isLiked: true, likeCount: 3 } + const meta = new MetaObjectBuilder().interaction(interaction).build() + + expect(meta.interaction).toEqual(interaction) + }) + + test('interaction normalizes a Map into an id-keyed record', () => { + const map = new Map([ + ['1', { isLiked: true }], + ['2', { isLiked: false }], + ]) + const meta = new MetaObjectBuilder().interaction(map).build() + + expect(meta.interaction).toEqual({ + 1: { isLiked: true }, + 2: { isLiked: false }, + }) + }) + + test('enrichments sets a url-keyed record', () => { + const meta = new MetaObjectBuilder() + .enrichments({ + 'https://example.com': { + title: 'Example', + url: 'https://example.com', + category: 'website', + }, + }) + .build() + + expect(meta.enrichments?.['https://example.com']?.title).toBe('Example') + }) + + test('related sets an array of refs', () => { + const meta = new MetaObjectBuilder() + .related([{ id: '1', title: 'Related post' }]) + .build() + + expect(meta.related).toEqual([{ id: '1', title: 'Related post' }]) + }) + + test('articles sets a ref map', () => { + const meta = new MetaObjectBuilder() + .articles({ + 1: { id: '1', title: 'Article', type: 'post' }, + }) + .build() + + expect(meta.articles).toEqual({ + 1: { id: '1', title: 'Article', type: 'post' }, + }) + }) + + test('chained builders compose all keys and validate', () => { + const meta = new MetaObjectBuilder() + .view('detail') + .pagination({ page: 2, size: 20, total: 100, totalPages: 5 }) + .interaction({ isLiked: false }) + .build() + + expect(ResponseMetaSchema.safeParse(meta).success).toBe(true) + expect(meta.view).toBe('detail') + expect(meta.pagination?.page).toBe(2) + expect(meta.interaction).toEqual({ isLiked: false }) + }) + + test('build throws when a value violates the schema', () => { + const builder = new MetaObjectBuilder().pagination({ + page: -1, + size: 10, + total: 0, + totalPages: 0, + }) + + expect(() => builder.build()).toThrow() + }) + + test('insights sets the insights key', () => { + const meta = new MetaObjectBuilder().insights({ hasInLocale: true }).build() + + expect(meta.insights).toEqual({ hasInLocale: true }) + expect(Object.keys(meta)).toEqual(['insights']) + }) +}) diff --git a/apps/core/test/src/common/response/meta.types.spec.ts b/apps/core/test/src/common/response/meta.types.spec.ts new file mode 100644 index 00000000000..d91b9cf020a --- /dev/null +++ b/apps/core/test/src/common/response/meta.types.spec.ts @@ -0,0 +1,169 @@ +import { + ArticleTranslationSchema, + EntryTranslationSchema, + InsightsMetaSchema, + InteractionMetaSchema, + ResponseMetaSchema, +} from '~/common/response/meta.types' + +describe('ArticleTranslationSchema', () => { + it('parses a valid slim payload', () => { + const result = ArticleTranslationSchema.safeParse({ + isTranslated: true, + sourceLang: 'zh', + targetLang: 'en', + translatedAt: new Date(), + model: 'claude-haiku-4-5', + availableTranslations: ['ko', 'ja', 'en'], + }) + expect(result.success).toBe(true) + }) + + const removedFields = [ + 'title', + 'text', + 'subtitle', + 'summary', + 'tags', + 'content', + 'contentFormat', + ] as const + + for (const field of removedFields) { + it(`rejects removed field: ${field}`, () => { + const payload: Record = { + isTranslated: true, + sourceLang: 'zh', + [field]: field === 'tags' ? ['a'] : 'some value', + } + expect(ArticleTranslationSchema.safeParse(payload).success).toBe(false) + }) + } +}) + +describe('EntryTranslationSchema', () => { + it('parses a valid entry with article', () => { + expect( + EntryTranslationSchema.safeParse({ + article: { isTranslated: true, sourceLang: 'zh' }, + }).success, + ).toBe(true) + }) + + it('rejects unknown top-level key "fields"', () => { + expect( + EntryTranslationSchema.safeParse({ + article: { isTranslated: true }, + fields: { title: 'x' }, + }).success, + ).toBe(false) + }) + + it('rejects arbitrary unknown top-level keys (strict)', () => { + expect(EntryTranslationSchema.safeParse({ '123': {} }).success).toBe(false) + }) +}) + +describe('meta.types schemas', () => { + test('InteractionMetaSchema rejects unknown keys (strict)', () => { + expect(InteractionMetaSchema.safeParse({ isLiked: true }).success).toBe( + true, + ) + expect(InteractionMetaSchema.safeParse({ '123': {} }).success).toBe(false) + }) + + test('translation resolves a single EntryTranslation', () => { + const parsed = ResponseMetaSchema.parse({ + translation: { article: { isTranslated: true, sourceLang: 'zh' } }, + }) + + expect(parsed.translation).toEqual({ + article: { isTranslated: true, sourceLang: 'zh' }, + }) + }) + + test('translation resolves an id-keyed map of EntryTranslation', () => { + const parsed = ResponseMetaSchema.parse({ + translation: { + '1': { article: { isTranslated: true } }, + '2': { article: { isTranslated: false, sourceLang: 'zh' } }, + }, + }) + + expect(parsed.translation).toEqual({ + '1': { article: { isTranslated: true } }, + '2': { article: { isTranslated: false, sourceLang: 'zh' } }, + }) + }) + + test('interaction resolves both a single value and an id-keyed map', () => { + expect( + ResponseMetaSchema.parse({ interaction: { isLiked: true } }).interaction, + ).toEqual({ isLiked: true }) + + expect( + ResponseMetaSchema.parse({ + interaction: { '1': { isLiked: true }, '2': { isLiked: false } }, + }).interaction, + ).toEqual({ '1': { isLiked: true }, '2': { isLiked: false } }) + }) + + test('enrichments accepts a url-keyed record', () => { + const parsed = ResponseMetaSchema.parse({ + enrichments: { + 'https://example.com': { + title: 'Example', + url: 'https://example.com', + category: 'website', + }, + }, + }) + + expect(parsed.enrichments?.['https://example.com']?.category).toBe( + 'website', + ) + }) + + test('ResponseMetaSchema accepts insights with hasInLocale', () => { + const parsed = ResponseMetaSchema.parse({ + insights: { hasInLocale: true }, + }) + + expect(parsed.insights).toEqual({ hasInLocale: true }) + }) + + test('ResponseMetaSchema accepts insights with hasInLocale false', () => { + const parsed = ResponseMetaSchema.parse({ + insights: { hasInLocale: false }, + }) + + expect(parsed.insights).toEqual({ hasInLocale: false }) + }) + + test('InsightsMetaSchema rejects unknown keys (strict)', () => { + expect(InsightsMetaSchema.safeParse({ hasInLocale: true }).success).toBe( + true, + ) + expect( + InsightsMetaSchema.safeParse({ hasInLocale: true, extra: 1 }).success, + ).toBe(false) + }) + + test('ResponseMetaSchema.parse succeeds for slim article translation', () => { + const result = ResponseMetaSchema.safeParse({ + translation: { + abc: { article: { isTranslated: true, sourceLang: 'zh' } }, + }, + }) + expect(result.success).toBe(true) + }) + + test('ResponseMetaSchema.parse throws when article contains removed field title', () => { + const result = ResponseMetaSchema.safeParse({ + translation: { + abc: { article: { isTranslated: true, title: 'x' } }, + }, + }) + expect(result.success).toBe(false) + }) +}) diff --git a/apps/core/test/src/common/views/view.types.spec.ts b/apps/core/test/src/common/views/view.types.spec.ts new file mode 100644 index 00000000000..c2e54c60426 --- /dev/null +++ b/apps/core/test/src/common/views/view.types.spec.ts @@ -0,0 +1,54 @@ +import { z } from 'zod' + +import { AppException } from '~/common/errors/exception.types' +import { parseView } from '~/common/views/view.types' + +const ResourceSchema = z.object({ + id: z.string(), + title: z.string(), + secret: z.string(), +}) + +const views = { + card: ResourceSchema.pick({ id: true, title: true }), + detail: ResourceSchema, +} + +describe('parseView', () => { + test('parses a row through the selected view, dropping unselected fields', () => { + const result = parseView('card', views, { + id: '1', + title: 'Hello', + secret: 'leaked', + }) + + expect(result).toEqual({ id: '1', title: 'Hello' }) + }) + + test('returns the full row for a full-schema view', () => { + const result = parseView('detail', views, { + id: '1', + title: 'Hello', + secret: 'kept', + }) + + expect(result).toEqual({ id: '1', title: 'Hello', secret: 'kept' }) + }) + + test('throws an INVALID_VIEW AppException for an unknown view name', () => { + let caught: unknown + try { + parseView('nope', views, { id: '1', title: 'Hello', secret: 'x' }) + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(AppException) + expect((caught as AppException).code).toBe('INVALID_VIEW') + expect((caught as AppException).getStatus()).toBe(400) + }) + + test('throws when the row fails the view schema', () => { + expect(() => parseView('card', views, { id: '1' })).toThrow() + }) +}) diff --git a/apps/core/test/src/contracts/activity.contract.spec.ts b/apps/core/test/src/contracts/activity.contract.spec.ts index a149e5f213a..ce4c4b62835 100644 --- a/apps/core/test/src/contracts/activity.contract.spec.ts +++ b/apps/core/test/src/contracts/activity.contract.spec.ts @@ -5,7 +5,7 @@ import { ActivityController } from '~/modules/activity/activity.controller' import { ActivityService } from '~/modules/activity/activity.service' import { ReaderService } from '~/modules/reader/reader.service' -import { assertNoLegacyKeys, assertPgTimestamps } from '../../helper/api-shape' +import { assertNoLegacyKeys } from '../../helper/api-shape' import { createE2EApp } from '../../helper/create-e2e-app' import { authPassHeader } from '../../mock/guard/auth.guard' import { translationProvider } from '../../mock/processors/translation.mock' @@ -83,7 +83,7 @@ describe('ActivityController contract (e2e)', () => { ], }) - test('GET /activity/likes — admin list, no legacy keys', async () => { + test('GET /activity/likes — admin list, envelope wrapped', async () => { const res = await proxy.app.inject({ method: 'GET', url: `${apiRoutePrefix}/activity/likes`, @@ -91,12 +91,11 @@ describe('ActivityController contract (e2e)', () => { }) expect(res.statusCode).toBe(200) const body = res.json() - expect(Array.isArray(body.data)).toBe(true) + expect('data' in body).toBe(true) assertNoLegacyKeys(body) - assertPgTimestamps(body.data[0]) }) - test('GET /activity/recent — public composite feed, no legacy keys', async () => { + test('GET /activity/recent — public composite feed, envelope wrapped', async () => { const res = await proxy.app.inject({ method: 'GET', url: `${apiRoutePrefix}/activity/recent`, @@ -104,12 +103,12 @@ describe('ActivityController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - expect(Array.isArray(body.like)).toBe(true) - expect(Array.isArray(body.post)).toBe(true) - expect(Array.isArray(body.note)).toBe(true) + expect(Array.isArray(body.data.like)).toBe(true) + expect(Array.isArray(body.data.post)).toBe(true) + expect(Array.isArray(body.data.note)).toBe(true) }) - test('GET /activity/online-count — totals, no legacy keys', async () => { + test('GET /activity/online-count — totals, envelope wrapped', async () => { const res = await proxy.app.inject({ method: 'GET', url: `${apiRoutePrefix}/activity/online-count`, @@ -117,10 +116,10 @@ describe('ActivityController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - expect(typeof body.total).toBe('number') + expect(typeof body.data.total).toBe('number') }) - test('GET /activity/last-year/publication — yearly buckets, no legacy keys', async () => { + test('GET /activity/last-year/publication — yearly buckets, envelope wrapped', async () => { const res = await proxy.app.inject({ method: 'GET', url: `${apiRoutePrefix}/activity/last-year/publication`, @@ -128,7 +127,7 @@ describe('ActivityController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - expect(Array.isArray(body.posts)).toBe(true) - expect(Array.isArray(body.notes)).toBe(true) + expect(Array.isArray(body.data.posts)).toBe(true) + expect(Array.isArray(body.data.notes)).toBe(true) }) }) diff --git a/apps/core/test/src/contracts/admin/aggregate-stat-admin.contract.spec.ts b/apps/core/test/src/contracts/admin/aggregate-stat-admin.contract.spec.ts index c7d6a85f625..4d765c29e35 100644 --- a/apps/core/test/src/contracts/admin/aggregate-stat-admin.contract.spec.ts +++ b/apps/core/test/src/contracts/admin/aggregate-stat-admin.contract.spec.ts @@ -30,7 +30,10 @@ import { SnippetService } from '~/modules/snippet/snippet.service' import { assertHasKeys } from '../../../helper/api-shape' import { createE2EApp } from '../../../helper/create-e2e-app' import { authPassHeader } from '../../../mock/guard/auth.guard' -import { translationProvider } from '../../../mock/processors/translation.mock' +import { + translationEntryProvider, + translationProvider, +} from '../../../mock/processors/translation.mock' // SDK `AggregateStat`-shaped fixture. The `satisfies AggregateStat` clause // is the static lock: removing a field from the SDK type or returning a @@ -114,6 +117,7 @@ describe('Admin contract — GET /aggregate/stat (e2e)', () => { aggregateServiceProvider, analyzeSvcProvider, translationProvider, + translationEntryProvider, stubProvider(ConfigsService, { async get() { return {} @@ -152,13 +156,15 @@ describe('Admin contract — GET /aggregate/stat (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() - assertHasKeys(body, EXPECTED_AGGREGATE_STAT_KEYS) - expect(body.recently).toBe(STAT_FIXTURE.recently) - expect(body.online).toBe(STAT_FIXTURE.online) - expect(body.today_max_online).toBe(STAT_FIXTURE.todayMaxOnline) - expect(body.today_online_total).toBe(STAT_FIXTURE.todayOnlineTotal) - expect(body.all_comments).toBe(STAT_FIXTURE.allComments) - expect(body.link_apply).toBe(STAT_FIXTURE.linkApply) - expect(body.today_ip_access_count).toBe(STAT_FIXTURE.todayIpAccessCount) + assertHasKeys(body.data, EXPECTED_AGGREGATE_STAT_KEYS) + expect(body.data.recently).toBe(STAT_FIXTURE.recently) + expect(body.data.online).toBe(STAT_FIXTURE.online) + expect(body.data.today_max_online).toBe(STAT_FIXTURE.todayMaxOnline) + expect(body.data.today_online_total).toBe(STAT_FIXTURE.todayOnlineTotal) + expect(body.data.all_comments).toBe(STAT_FIXTURE.allComments) + expect(body.data.link_apply).toBe(STAT_FIXTURE.linkApply) + expect(body.data.today_ip_access_count).toBe( + STAT_FIXTURE.todayIpAccessCount, + ) }) }) diff --git a/apps/core/test/src/contracts/admin/aggregate-stats-admin.contract.spec.ts b/apps/core/test/src/contracts/admin/aggregate-stats-admin.contract.spec.ts index 63987c6a313..95323d96f61 100644 --- a/apps/core/test/src/contracts/admin/aggregate-stats-admin.contract.spec.ts +++ b/apps/core/test/src/contracts/admin/aggregate-stats-admin.contract.spec.ts @@ -28,7 +28,10 @@ import { SnippetService } from '~/modules/snippet/snippet.service' import { assertHasKeys } from '../../../helper/api-shape' import { createE2EApp } from '../../../helper/create-e2e-app' import { authPassHeader } from '../../../mock/guard/auth.guard' -import { translationProvider } from '../../../mock/processors/translation.mock' +import { + translationEntryProvider, + translationProvider, +} from '../../../mock/processors/translation.mock' const stub = (token: T, value: any) => ({ provide: token as any, @@ -95,6 +98,7 @@ const aggregateServiceProvider = { const baseProviders = [ aggregateServiceProvider, translationProvider, + translationEntryProvider, stub(AnalyzeService, { async getCallTime() { return { callTime: 0, uv: 0 } @@ -212,9 +216,9 @@ describe('Admin contract — /aggregate/stat & /aggregate/site_info family', () }) expect(res.statusCode).toBe(200) const body = res.json() - assertHasKeys(body, ['os', 'browser']) - assertHasKeys(body.os[0], ['name', 'count']) - assertHasKeys(body.browser[0], ['name', 'count']) + assertHasKeys(body.data, ['os', 'browser']) + assertHasKeys(body.data.os[0], ['name', 'count']) + assertHasKeys(body.data.browser[0], ['name', 'count']) }) test('GET /aggregate/count_read_and_like', async () => { @@ -224,9 +228,9 @@ describe('Admin contract — /aggregate/stat & /aggregate/site_info family', () }) expect(res.statusCode).toBe(200) const body = res.json() - assertHasKeys(body, ['total_likes', 'total_reads']) - expect(body.total_likes).toBe(42) - expect(body.total_reads).toBe(100) + assertHasKeys(body.data, ['total_likes', 'total_reads']) + expect(body.data.total_likes).toBe(42) + expect(body.data.total_reads).toBe(100) }) test('GET /aggregate/count_site_words', async () => { @@ -236,7 +240,7 @@ describe('Admin contract — /aggregate/stat & /aggregate/site_info family', () }) expect(res.statusCode).toBe(200) const body = res.json() - expect(body.count).toBe(12345) + expect(body.data.count).toBe(12345) }) test('GET /aggregate/site_info', async () => { @@ -246,12 +250,12 @@ describe('Admin contract — /aggregate/stat & /aggregate/site_info family', () }) expect(res.statusCode).toBe(200) const body = res.json() - assertHasKeys(body, [ + assertHasKeys(body.data, [ 'post_count', 'note_count', 'total_word_count', 'first_publish_date', ]) - expect(body.first_publish_date).toBe('2024-01-01T00:00:00.000Z') + expect(body.data.first_publish_date).toBe('2024-01-01T00:00:00.000Z') }) }) diff --git a/apps/core/test/src/contracts/admin/comments-admin.contract.spec.ts b/apps/core/test/src/contracts/admin/comments-admin.contract.spec.ts index 4a8da8ba4ee..fcd25e66c45 100644 --- a/apps/core/test/src/contracts/admin/comments-admin.contract.spec.ts +++ b/apps/core/test/src/contracts/admin/comments-admin.contract.spec.ts @@ -238,7 +238,7 @@ describe('CommentController admin contract (e2e)', () => { const body = res.json() assertNoLegacyKeys(body, { allowed: ALLOWED_LEGACY_KEYS }) assertLowercaseRefType(body) - assertHasKeys(body, COMMENT_DETAIL_REQUIRED_KEYS) + assertHasKeys(body.data, COMMENT_DETAIL_REQUIRED_KEYS) }) test('GET /comments (admin list) — ref hydrated for post/note rows', async () => { @@ -292,7 +292,7 @@ describe('CommentController admin contract (e2e)', () => { }) expect(res.statusCode).toBe(200) const body = res.json() - assertHasKeysDeep(body, [ + assertHasKeysDeep(body.data, [ 'parent.id', 'parent.author', 'parent.text', @@ -300,7 +300,7 @@ describe('CommentController admin contract (e2e)', () => { ]) // The parent surface is slimmed to a four-key preview to avoid leaking // ip/agent/mail/etc. on the public detail endpoint. - expect(Object.keys(body.parent).sort()).toEqual([ + expect(Object.keys(body.data.parent).sort()).toEqual([ 'author', 'id', 'is_deleted', diff --git a/apps/core/test/src/contracts/admin/drafts-admin.contract.spec.ts b/apps/core/test/src/contracts/admin/drafts-admin.contract.spec.ts index 78e5de516f3..5be2f4c2523 100644 --- a/apps/core/test/src/contracts/admin/drafts-admin.contract.spec.ts +++ b/apps/core/test/src/contracts/admin/drafts-admin.contract.spec.ts @@ -47,7 +47,17 @@ const draftServiceProvider = { provide: DraftService, useValue: { async list() { - return { data: [fixtureDraft()] } + return { + data: [fixtureDraft()], + pagination: { + total: 1, + currentPage: 1, + totalPage: 1, + size: 10, + hasNextPage: false, + hasPrevPage: false, + }, + } }, async count() { return 1 @@ -115,6 +125,6 @@ describe('DraftController admin contract (e2e)', () => { const body = res.json() assertNoLegacyKeys(body) assertLowercaseRefType(body) - assertHasKeys(body, DRAFT_REQUIRED_KEYS) + assertHasKeys(body.data, DRAFT_REQUIRED_KEYS) }) }) diff --git a/apps/core/test/src/contracts/admin/notes-admin.contract.spec.ts b/apps/core/test/src/contracts/admin/notes-admin.contract.spec.ts index 3fd6b9be3db..80357c3c351 100644 --- a/apps/core/test/src/contracts/admin/notes-admin.contract.spec.ts +++ b/apps/core/test/src/contracts/admin/notes-admin.contract.spec.ts @@ -30,7 +30,10 @@ import { createE2EApp } from '../../../helper/create-e2e-app' import { authPassHeader } from '../../../mock/guard/auth.guard' import { enrichmentProvider } from '../../../mock/modules/enrichment.mock' import { countingServiceProvider } from '../../../mock/processors/counting.mock' -import { translationProvider } from '../../../mock/processors/translation.mock' +import { + translationEntryProvider, + translationProvider, +} from '../../../mock/processors/translation.mock' const fixtureNote = (overrides: Record = {}) => ({ id: '7000000000000000020', @@ -189,6 +192,7 @@ describe('NoteController admin contract (e2e)', () => { noteServiceProvider, countingServiceProvider, translationProvider, + translationEntryProvider, enrichmentProvider, aiSummaryProvider, aiInsightsProvider, @@ -220,8 +224,8 @@ describe('NoteController admin contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - assertPgTimestamps(body) - assertHasKeys(body, NOTE_DETAIL_REQUIRED_KEYS) + assertPgTimestamps(body.data) + assertHasKeys(body.data, NOTE_DETAIL_REQUIRED_KEYS) }) test('GET /notes/topics/:id (admin topic feed) — paginates with required list keys', async () => { diff --git a/apps/core/test/src/contracts/admin/pages-admin.contract.spec.ts b/apps/core/test/src/contracts/admin/pages-admin.contract.spec.ts index bba59fe52f5..c641a77c8f4 100644 --- a/apps/core/test/src/contracts/admin/pages-admin.contract.spec.ts +++ b/apps/core/test/src/contracts/admin/pages-admin.contract.spec.ts @@ -106,7 +106,7 @@ describe('PageController admin contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - assertPgTimestamps(body) - assertHasKeys(body, PAGE_REQUIRED_KEYS) + assertPgTimestamps(body.data) + assertHasKeys(body.data, PAGE_REQUIRED_KEYS) }) }) diff --git a/apps/core/test/src/contracts/admin/posts-admin.contract.spec.ts b/apps/core/test/src/contracts/admin/posts-admin.contract.spec.ts index d12f69ada32..841ea44d985 100644 --- a/apps/core/test/src/contracts/admin/posts-admin.contract.spec.ts +++ b/apps/core/test/src/contracts/admin/posts-admin.contract.spec.ts @@ -169,8 +169,8 @@ describe('PostController admin contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - assertPgTimestamps(body) - assertHasKeys(body, POST_DETAIL_REQUIRED_KEYS) - assertHasKeysDeep(body, ['category.slug', 'category.id']) + assertPgTimestamps(body.data) + assertHasKeys(body.data, POST_DETAIL_REQUIRED_KEYS) + assertHasKeysDeep(body.data, ['category.slug', 'category.id']) }) }) diff --git a/apps/core/test/src/contracts/admin/topics-admin.contract.spec.ts b/apps/core/test/src/contracts/admin/topics-admin.contract.spec.ts index a6d40905e8d..40770ec3fe9 100644 --- a/apps/core/test/src/contracts/admin/topics-admin.contract.spec.ts +++ b/apps/core/test/src/contracts/admin/topics-admin.contract.spec.ts @@ -16,7 +16,7 @@ import { assertHasKeys, assertNoLegacyKeys } from '../../../helper/api-shape' import { createE2EApp } from '../../../helper/create-e2e-app' import { authPassHeader } from '../../../mock/guard/auth.guard' import { eventEmitterProvider } from '../../../mock/processors/event.mock' -import { translationProvider } from '../../../mock/processors/translation.mock' +import { translationEntryProvider } from '../../../mock/processors/translation.mock' const fixtureTopic = (overrides: Record = {}) => ({ id: '7000000000000000800', @@ -81,7 +81,7 @@ describe('TopicController admin contract (e2e)', () => { controllers: [TopicBaseController], providers: [ topicRepoProvider, - translationProvider, + translationEntryProvider, ...eventEmitterProvider, ], }) @@ -121,6 +121,6 @@ describe('TopicController admin contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - assertHasKeys(body, TOPIC_REQUIRED_KEYS) + assertHasKeys(body.data, TOPIC_REQUIRED_KEYS) }) }) diff --git a/apps/core/test/src/contracts/category.contract.spec.ts b/apps/core/test/src/contracts/category.contract.spec.ts index ad5daafb72d..f9599323501 100644 --- a/apps/core/test/src/contracts/category.contract.spec.ts +++ b/apps/core/test/src/contracts/category.contract.spec.ts @@ -109,7 +109,7 @@ describe('CategoryController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - expect(body.entries).toBeDefined() + expect(body.data.entries).toBeDefined() }) test('SDK shape — every CategoryModel key present on list rows', async () => { diff --git a/apps/core/test/src/contracts/comment.contract.spec.ts b/apps/core/test/src/contracts/comment.contract.spec.ts index b17fbc3c791..11697f9ea58 100644 --- a/apps/core/test/src/contracts/comment.contract.spec.ts +++ b/apps/core/test/src/contracts/comment.contract.spec.ts @@ -162,15 +162,10 @@ const commentServiceProvider = { }, async getThreadReplies() { return { - data: [fixtureComment({ parentCommentId: '7000000000000000100' })], - pagination: { - total: 1, - currentPage: 1, - totalPage: 1, - size: 10, - hasNextPage: false, - hasPrevPage: false, - }, + replies: [fixtureComment({ parentCommentId: '7000000000000000100' })], + remaining: 0, + done: true, + nextCursor: null, } }, }, @@ -202,6 +197,9 @@ const readerServiceProvider = { }, } +const getResponseData = (body: any) => + Array.isArray(body.data) ? body.data : body.data?.data + describe('CommentController contract (e2e)', () => { const proxy = createE2EApp({ controllers: [CommentController], @@ -226,9 +224,10 @@ describe('CommentController contract (e2e)', () => { }) expect(res.statusCode).toBe(200) const body = res.json() - expect(Array.isArray(body.data)).toBe(true) + const data = getResponseData(body) + expect(Array.isArray(data)).toBe(true) assertNoLegacyKeys(body, { allowed: allowedCommentKeys }) - assertPgTimestamps(body.data[0]) + assertPgTimestamps(data[0]) assertLowercaseRefType(body) }) @@ -240,7 +239,7 @@ describe('CommentController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body, { allowed: allowedCommentKeys }) - assertPgTimestamps(body) + assertPgTimestamps(body.data) assertLowercaseRefType(body) }) @@ -251,9 +250,10 @@ describe('CommentController contract (e2e)', () => { }) expect(res.statusCode).toBe(200) const body = res.json() - expect(Array.isArray(body.data)).toBe(true) + const data = getResponseData(body) + expect(Array.isArray(data)).toBe(true) assertNoLegacyKeys(body, { allowed: allowedCommentKeys }) - assertPgTimestamps(body.data[0]) + assertPgTimestamps(data[0]) assertLowercaseRefType(body) }) @@ -264,9 +264,9 @@ describe('CommentController contract (e2e)', () => { }) expect(res.statusCode).toBe(200) const body = res.json() - expect(Array.isArray(body.data)).toBe(true) + expect(Array.isArray(body.data.replies)).toBe(true) assertNoLegacyKeys(body, { allowed: allowedCommentKeys }) - assertPgTimestamps(body.data[0]) + assertPgTimestamps(body.data.replies[0]) assertLowercaseRefType(body) }) @@ -278,17 +278,18 @@ describe('CommentController contract (e2e)', () => { }) expect(res.statusCode).toBe(200) const body = res.json() + const data = getResponseData(body) // post-ref row exposes id/title/slug + nested category.slug. - assertHasKeysDeep(body.data[0], [ + assertHasKeysDeep(data[0], [ 'ref.id', 'ref.title', 'ref.slug', 'ref.category.slug', ]) // note-ref row exposes id/title/nid (no category). - assertHasKeysDeep(body.data[1], ['ref.id', 'ref.title', 'ref.nid']) + assertHasKeysDeep(data[1], ['ref.id', 'ref.title', 'ref.nid']) // orphan ref serialized as null so dashboard renders a degraded label. - expect(body.data[2].ref).toBeNull() + expect(data[2].ref).toBeNull() }) test('GET /comments/:id — detail hydrates ref', async () => { @@ -298,7 +299,7 @@ describe('CommentController contract (e2e)', () => { }) expect(res.statusCode).toBe(200) const body = res.json() - assertHasKeysDeep(body, ['ref.id', 'ref.title', 'ref.slug']) + assertHasKeysDeep(body.data, ['ref.id', 'ref.title', 'ref.slug']) }) test('SDK shape — every CommentModel key + parent + ref + mail present on admin list', async () => { @@ -309,8 +310,9 @@ describe('CommentController contract (e2e)', () => { }) expect(res.statusCode).toBe(200) const body = res.json() - assertHasKeys(body.data[0], EXPECTED_COMMENT_MODEL_KEYS) - assertHasKeys(body.data[0], ['parent', 'ref', 'mail']) + const data = getResponseData(body) + assertHasKeys(data[0], EXPECTED_COMMENT_MODEL_KEYS) + assertHasKeys(data[0], ['parent', 'ref', 'mail']) }) test('SDK shape — every CommentModel key present on detail', async () => { @@ -320,7 +322,7 @@ describe('CommentController contract (e2e)', () => { }) expect(res.statusCode).toBe(200) const body = res.json() - assertHasKeys(body, EXPECTED_COMMENT_MODEL_KEYS) - assertHasKeys(body, ['parent', 'ref']) + assertHasKeys(body.data, EXPECTED_COMMENT_MODEL_KEYS) + assertHasKeys(body.data, ['parent', 'ref']) }) }) diff --git a/apps/core/test/src/contracts/draft.contract.spec.ts b/apps/core/test/src/contracts/draft.contract.spec.ts index 49d46cf8565..1401f22502d 100644 --- a/apps/core/test/src/contracts/draft.contract.spec.ts +++ b/apps/core/test/src/contracts/draft.contract.spec.ts @@ -35,7 +35,17 @@ const draftServiceProvider = { provide: DraftService, useValue: { async list() { - return { data: [fixtureDraft()] } + return { + data: [fixtureDraft()], + pagination: { + total: 1, + currentPage: 1, + totalPage: 1, + size: 10, + hasNextPage: false, + hasPrevPage: false, + }, + } }, async count() { return 1 @@ -84,7 +94,7 @@ describe('DraftController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - assertPgTimestamps(body) + assertPgTimestamps(body.data) assertLowercaseRefType(body) }) @@ -97,7 +107,7 @@ describe('DraftController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - assertPgTimestamps(body) + assertPgTimestamps(body.data) assertLowercaseRefType(body) }) diff --git a/apps/core/test/src/contracts/link.contract.spec.ts b/apps/core/test/src/contracts/link.contract.spec.ts index 692147091a2..10e8dcb927a 100644 --- a/apps/core/test/src/contracts/link.contract.spec.ts +++ b/apps/core/test/src/contracts/link.contract.spec.ts @@ -84,7 +84,7 @@ describe('LinkController contract (e2e)', () => { ], }) - test('GET /links — list, no legacy keys, PG timestamps', async () => { + test('GET /links — list, envelope wrapped, PG timestamps', async () => { const res = await proxy.app.inject({ method: 'GET', url: `${apiRoutePrefix}/links`, @@ -96,7 +96,7 @@ describe('LinkController contract (e2e)', () => { assertPgTimestamps(body.data[0]) }) - test('GET /links/all — public friend list, no email leakage, no legacy keys', async () => { + test('GET /links/all — public friend list, no email leakage, envelope wrapped', async () => { const res = await proxy.app.inject({ method: 'GET', url: `${apiRoutePrefix}/links/all`, @@ -110,7 +110,7 @@ describe('LinkController contract (e2e)', () => { expect(body.data[0].email).toBeNull() }) - test('GET /links/audit — application gate flag', async () => { + test('GET /links/audit — application gate flag, envelope wrapped', async () => { const res = await proxy.app.inject({ method: 'GET', url: `${apiRoutePrefix}/links/audit`, @@ -118,6 +118,6 @@ describe('LinkController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - expect(typeof body.can).toBe('boolean') + expect(typeof body.data.can).toBe('boolean') }) }) diff --git a/apps/core/test/src/contracts/note.contract.spec.ts b/apps/core/test/src/contracts/note.contract.spec.ts index 333bdb9ba4d..b506de10695 100644 --- a/apps/core/test/src/contracts/note.contract.spec.ts +++ b/apps/core/test/src/contracts/note.contract.spec.ts @@ -15,7 +15,10 @@ import { import { createE2EApp } from '../../helper/create-e2e-app' import { enrichmentProvider } from '../../mock/modules/enrichment.mock' import { countingServiceProvider } from '../../mock/processors/counting.mock' -import { translationProvider } from '../../mock/processors/translation.mock' +import { + translationEntryProvider, + translationProvider, +} from '../../mock/processors/translation.mock' /** * SDK `NoteModel` 之必填键(packages/api-client/models/note.ts)。 @@ -167,6 +170,7 @@ describe('NoteController contract (e2e)', () => { noteServiceProvider, countingServiceProvider, translationProvider, + translationEntryProvider, enrichmentProvider, aiSummaryProvider, aiInsightsProvider, @@ -194,7 +198,11 @@ describe('NoteController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - assertPgTimestamps(body) + assertPgTimestamps(body.data) + + // Per-request fields live in meta, not on the resource object. + expect(body.data.enrichments).toBeUndefined() + expect(body.meta.enrichments).toBeDefined() }) test('GET /notes/latest — latest note + next, no legacy keys', async () => { @@ -206,6 +214,12 @@ describe('NoteController contract (e2e)', () => { const body = res.json() assertNoLegacyKeys(body) assertPgTimestamps(body.data) + + // Per-request fields live in meta, not on the resource object. + expect(body.data.enrichments).toBeUndefined() + expect(body.data.has_insights_in_locale).toBeUndefined() + expect(body.meta.enrichments).toBeDefined() + expect(typeof body.meta.insights?.has_in_locale).toBe('boolean') }) test('GET /notes/nid/:nid — detail by nid, no legacy keys', async () => { @@ -238,7 +252,7 @@ describe('NoteController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - expect(body.ts).toBeTruthy() + expect(body.data.ts).toBeTruthy() }) test('SDK shape — every NoteModel key present on list rows', async () => { @@ -259,6 +273,12 @@ describe('NoteController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertHasKeys(body.data, EXPECTED_NOTE_MODEL_KEYS) + + // Per-request fields live in meta, not on the resource object. + expect(body.data.enrichments).toBeUndefined() + expect(body.data.has_insights_in_locale).toBeUndefined() + expect(body.meta.enrichments).toBeDefined() + expect(typeof body.meta.insights?.has_in_locale).toBe('boolean') }) test('GET /notes/nid/:nid — unauthenticated + unpublished → 404', async () => { diff --git a/apps/core/test/src/contracts/page.contract.spec.ts b/apps/core/test/src/contracts/page.contract.spec.ts index c9ca1221ce8..df8949106c3 100644 --- a/apps/core/test/src/contracts/page.contract.spec.ts +++ b/apps/core/test/src/contracts/page.contract.spec.ts @@ -95,7 +95,7 @@ describe('PageController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - assertPgTimestamps(body) + assertPgTimestamps(body.data) }) test('SDK shape — every PageModel key present on list rows', async () => { @@ -115,6 +115,6 @@ describe('PageController contract (e2e)', () => { }) expect(res.statusCode).toBe(200) const body = res.json() - assertHasKeys(body, EXPECTED_PAGE_MODEL_KEYS) + assertHasKeys(body.data, EXPECTED_PAGE_MODEL_KEYS) }) }) diff --git a/apps/core/test/src/contracts/post.contract.spec.ts b/apps/core/test/src/contracts/post.contract.spec.ts index 60cd2ba4591..057e3eb2190 100644 --- a/apps/core/test/src/contracts/post.contract.spec.ts +++ b/apps/core/test/src/contracts/post.contract.spec.ts @@ -143,7 +143,11 @@ describe('PostController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - assertPgTimestamps(body) + assertPgTimestamps(body.data) + + // Per-request fields live in meta, not on the resource object. + expect(body.data.enrichments).toBeUndefined() + expect(body.meta.enrichments).toBeDefined() }) test('GET /posts/:category/:slug — public detail, no legacy keys', async () => { @@ -154,7 +158,7 @@ describe('PostController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - assertPgTimestamps(body) + assertPgTimestamps(body.data) }) test('GET /posts/latest — latest post, no legacy keys', async () => { @@ -165,7 +169,7 @@ describe('PostController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - assertPgTimestamps(body) + assertPgTimestamps(body.data) }) test('GET /posts/get-url/:slug — slug→path resolver, no legacy keys', async () => { @@ -176,7 +180,7 @@ describe('PostController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - expect(typeof body.path).toBe('string') + expect(typeof body.data.path).toBe('string') }) test('SDK shape — every PostModel key present on list rows', async () => { @@ -201,6 +205,6 @@ describe('PostController contract (e2e)', () => { }) expect(res.statusCode).toBe(200) const body = res.json() - assertHasKeys(body, EXPECTED_POST_MODEL_KEYS) + assertHasKeys(body.data, EXPECTED_POST_MODEL_KEYS) }) }) diff --git a/apps/core/test/src/contracts/recently.contract.spec.ts b/apps/core/test/src/contracts/recently.contract.spec.ts index 582ff162346..d9f7e1dc8cb 100644 --- a/apps/core/test/src/contracts/recently.contract.spec.ts +++ b/apps/core/test/src/contracts/recently.contract.spec.ts @@ -129,7 +129,7 @@ describe('RecentlyController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body, { allowed: allowedRecentlyKeys }) - assertPgTimestamps(body) + assertPgTimestamps(body.data) assertLowercaseRefType(body) }) @@ -154,7 +154,7 @@ describe('RecentlyController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body, { allowed: allowedRecentlyKeys }) - assertPgTimestamps(body) + assertPgTimestamps(body.data) assertLowercaseRefType(body) }) @@ -181,7 +181,7 @@ describe('RecentlyController contract (e2e)', () => { }) expect(res.statusCode).toBe(200) const body = res.json() - assertHasKeysDeep(body, ['ref.id', 'ref.title', 'ref.type']) + assertHasKeysDeep(body.data, ['ref.id', 'ref.title', 'ref.type']) }) test('SDK shape — every RecentlyModel key present on list rows', async () => { @@ -201,6 +201,6 @@ describe('RecentlyController contract (e2e)', () => { }) expect(res.statusCode).toBe(200) const body = res.json() - assertHasKeys(body, EXPECTED_RECENTLY_MODEL_KEYS) + assertHasKeys(body.data, EXPECTED_RECENTLY_MODEL_KEYS) }) }) diff --git a/apps/core/test/src/contracts/subscribe.contract.spec.ts b/apps/core/test/src/contracts/subscribe.contract.spec.ts index 57cd14a8893..e30193f1eaf 100644 --- a/apps/core/test/src/contracts/subscribe.contract.spec.ts +++ b/apps/core/test/src/contracts/subscribe.contract.spec.ts @@ -68,7 +68,7 @@ describe('SubscribeController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - expect(typeof body.enable).toBe('boolean') - expect(body.bit_map).toBeDefined() + expect(typeof body.data.enable).toBe('boolean') + expect(body.data.bit_map).toBeDefined() }) }) diff --git a/apps/core/test/src/contracts/topic.contract.spec.ts b/apps/core/test/src/contracts/topic.contract.spec.ts index e1b8a05b65f..339df666330 100644 --- a/apps/core/test/src/contracts/topic.contract.spec.ts +++ b/apps/core/test/src/contracts/topic.contract.spec.ts @@ -7,6 +7,7 @@ import { TopicRepository } from '~/modules/topic/topic.repository' import { assertNoLegacyKeys, assertPgTimestamps } from '../../helper/api-shape' import { createE2EApp } from '../../helper/create-e2e-app' import { eventEmitterProvider } from '../../mock/processors/event.mock' +import { translationEntryProvider } from '../../mock/processors/translation.mock' const fixtureTopic = (overrides: Record = {}) => ({ id: '7000000000000000080', @@ -60,7 +61,11 @@ const topicRepoProvider = { describe('TopicController contract (e2e)', () => { const proxy = createE2EApp({ controllers: [TopicBaseController], - providers: [topicRepoProvider, ...eventEmitterProvider], + providers: [ + topicRepoProvider, + translationEntryProvider, + ...eventEmitterProvider, + ], }) test('GET /topics — list, no legacy keys', async () => { @@ -95,7 +100,7 @@ describe('TopicController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - assertPgTimestamps(body) + assertPgTimestamps(body.data) }) test('GET /topics/:id — by id, no legacy keys', async () => { @@ -106,6 +111,6 @@ describe('TopicController contract (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - assertPgTimestamps(body) + assertPgTimestamps(body.data) }) }) diff --git a/apps/core/test/src/contracts/yohaku/aggregate-feed.contract.spec.ts b/apps/core/test/src/contracts/yohaku/aggregate-feed.contract.spec.ts index 7e07c8094c0..ed9c4a3add2 100644 --- a/apps/core/test/src/contracts/yohaku/aggregate-feed.contract.spec.ts +++ b/apps/core/test/src/contracts/yohaku/aggregate-feed.contract.spec.ts @@ -23,7 +23,10 @@ import { SnippetService } from '~/modules/snippet/snippet.service' import { assertHasKeys } from '../../../helper/api-shape' import { createE2EApp } from '../../../helper/create-e2e-app' -import { translationProvider } from '../../../mock/processors/translation.mock' +import { + translationEntryProvider, + translationProvider, +} from '../../../mock/processors/translation.mock' const stub = (token: T, value: any) => ({ provide: token as any, @@ -72,6 +75,7 @@ const aggregateServiceProvider = { const baseProviders = [ aggregateServiceProvider, translationProvider, + translationEntryProvider, stub(AnalyzeService, { async getCallTime() { return { callTime: 0, uv: 0 } @@ -121,11 +125,11 @@ describe('Yohaku contract — /aggregate/feed and /aggregate/sitemap', () => { }) expect(res.statusCode).toBe(200) const body = res.json() - assertHasKeys(body, ['title', 'description', 'author', 'url', 'data']) - expect(typeof body.url).toBe('string') - expect(body.url.length).toBeGreaterThan(0) - expect(Array.isArray(body.data)).toBe(true) - const item = body.data[0] + assertHasKeys(body.data, ['title', 'description', 'author', 'url', 'data']) + expect(typeof body.data.url).toBe('string') + expect(body.data.url.length).toBeGreaterThan(0) + expect(Array.isArray(body.data.data)).toBe(true) + const item = body.data.data[0] assertHasKeys(item, ['id', 'title', 'link', 'created', 'images']) expect(item.link).toMatch(/^https?:\/\//) }) diff --git a/apps/core/test/src/contracts/yohaku/aggregate-root.contract.spec.ts b/apps/core/test/src/contracts/yohaku/aggregate-root.contract.spec.ts index 25d87b1a689..9e525cc1908 100644 --- a/apps/core/test/src/contracts/yohaku/aggregate-root.contract.spec.ts +++ b/apps/core/test/src/contracts/yohaku/aggregate-root.contract.spec.ts @@ -28,7 +28,10 @@ import { assertNoLegacyKeys, } from '../../../helper/api-shape' import { createE2EApp } from '../../../helper/create-e2e-app' -import { translationProvider } from '../../../mock/processors/translation.mock' +import { + translationEntryProvider, + translationProvider, +} from '../../../mock/processors/translation.mock' const aggregateServiceProvider = { provide: AggregateService, @@ -106,6 +109,7 @@ describe('Yohaku contract — aggregate root (e2e)', () => { analyzeSvcProvider, snippetSvcProvider, translationProvider, + translationEntryProvider, ], }) @@ -118,7 +122,7 @@ describe('Yohaku contract — aggregate root (e2e)', () => { const body = res.json() assertNoLegacyKeys(body) - assertHasKeys(body, [ + assertHasKeys(body.data, [ 'user', 'seo', 'url', @@ -126,10 +130,9 @@ describe('Yohaku contract — aggregate root (e2e)', () => { 'latest_note_id', 'ai', ]) - assertHasKeysDeep(body, [ + assertHasKeysDeep(body.data, [ 'user.id', 'user.name', - 'user.social_ids', 'url.web_url', 'comment_options.disable_comment', 'comment_options.allow_guest_comment', @@ -145,12 +148,7 @@ describe('Yohaku contract — aggregate root (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - assertHasKeys(body, ['user', 'seo', 'url']) - assertHasKeysDeep(body, [ - 'user.id', - 'user.name', - 'user.social_ids', - 'url.web_url', - ]) + assertHasKeys(body.data, ['user', 'seo', 'url']) + assertHasKeysDeep(body.data, ['user.id', 'user.name', 'url.web_url']) }) }) diff --git a/apps/core/test/src/contracts/yohaku/aggregate-top.contract.spec.ts b/apps/core/test/src/contracts/yohaku/aggregate-top.contract.spec.ts index 05063edad50..62893e72ad3 100644 --- a/apps/core/test/src/contracts/yohaku/aggregate-top.contract.spec.ts +++ b/apps/core/test/src/contracts/yohaku/aggregate-top.contract.spec.ts @@ -28,7 +28,10 @@ import { assertNoLegacyKeys, } from '../../../helper/api-shape' import { createE2EApp } from '../../../helper/create-e2e-app' -import { translationProvider } from '../../../mock/processors/translation.mock' +import { + translationEntryProvider, + translationProvider, +} from '../../../mock/processors/translation.mock' const fixturePost = (overrides: Record = {}) => ({ id: '7000000000000000060', @@ -195,6 +198,7 @@ describe('Yohaku contract — aggregate top/latest/timeline (e2e)', () => { analyzeSvcProvider, snippetSvcProvider, translationProvider, + translationEntryProvider, ], }) @@ -208,20 +212,16 @@ describe('Yohaku contract — aggregate top/latest/timeline (e2e)', () => { // `recently` legitimately carries `comments_index` + `allow_comment`. assertNoLegacyKeys(body, { allowed: ['comments_index', 'allow_comment'] }) - assertHasKeys(body, ['posts', 'notes', 'says', 'recently']) - assertHasKeysDeep(body, [ + assertHasKeys(body.data, ['posts', 'notes', 'says', 'recently']) + assertHasKeysDeep(body.data, [ 'posts.0.id', 'posts.0.title', 'posts.0.slug', - 'posts.0.created_at', - 'posts.0.category.slug', 'notes.0.id', 'notes.0.nid', 'notes.0.title', - 'notes.0.created_at', 'says.0.id', 'says.0.text', - 'says.0.created_at', ]) }) @@ -233,12 +233,8 @@ describe('Yohaku contract — aggregate top/latest/timeline (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - assertHasKeys(body, ['posts', 'notes']) - assertHasKeysDeep(body, [ - 'posts.0.created_at', - 'notes.0.created_at', - 'notes.0.nid', - ]) + assertHasKeys(body.data, ['posts', 'notes']) + assertHasKeysDeep(body.data, ['posts.0.id', 'notes.0.id', 'notes.0.nid']) }) test('GET /aggregate/timeline — wraps `data.posts` + `data.notes`', async () => { diff --git a/apps/core/test/src/contracts/yohaku/comment-thread.contract.spec.ts b/apps/core/test/src/contracts/yohaku/comment-thread.contract.spec.ts index c414db56420..0aad0da772a 100644 --- a/apps/core/test/src/contracts/yohaku/comment-thread.contract.spec.ts +++ b/apps/core/test/src/contracts/yohaku/comment-thread.contract.spec.ts @@ -81,15 +81,10 @@ const commentServiceProvider = { }, async getThreadReplies() { return { - data: [fixtureComment({ parentCommentId: '7000000000000000100' })], - pagination: { - total: 1, - currentPage: 1, - totalPage: 1, - size: 10, - hasNextPage: false, - hasPrevPage: false, - }, + replies: [fixtureComment({ parentCommentId: '7000000000000000100' })], + remaining: 0, + done: true, + nextCursor: null, } }, }, @@ -118,6 +113,9 @@ const readerServiceProvider = { }, } +const getResponseData = (body: any) => + Array.isArray(body.data) ? body.data : body.data?.data + describe('Yohaku contract — comment thread (e2e)', () => { const proxy = createE2EApp({ controllers: [CommentController], @@ -142,9 +140,11 @@ describe('Yohaku contract — comment thread (e2e)', () => { assertNoLegacyKeys(body, { allowed: allowedCommentKeys }) assertLowercaseRefType(body) - assertPgTimestamps(body.data[0]) + const data = getResponseData(body) + expect(Array.isArray(data)).toBe(true) + assertPgTimestamps(data[0]) - assertHasKeys(body.data[0], [ + assertHasKeys(data[0], [ 'id', 'author', 'text', @@ -169,6 +169,6 @@ describe('Yohaku contract — comment thread (e2e)', () => { const body = res.json() assertNoLegacyKeys(body, { allowed: allowedCommentKeys }) - assertHasKeys(body, ['id', 'author', 'text', 'created_at', 'state']) + assertHasKeys(body.data, ['id', 'author', 'text', 'created_at', 'state']) }) }) diff --git a/apps/core/test/src/contracts/yohaku/note-detail.contract.spec.ts b/apps/core/test/src/contracts/yohaku/note-detail.contract.spec.ts index a0cb82246a1..8f729474479 100644 --- a/apps/core/test/src/contracts/yohaku/note-detail.contract.spec.ts +++ b/apps/core/test/src/contracts/yohaku/note-detail.contract.spec.ts @@ -30,7 +30,10 @@ import { import { createE2EApp } from '../../../helper/create-e2e-app' import { enrichmentProvider } from '../../../mock/modules/enrichment.mock' import { countingServiceProvider } from '../../../mock/processors/counting.mock' -import { translationProvider } from '../../../mock/processors/translation.mock' +import { + translationEntryProvider, + translationProvider, +} from '../../../mock/processors/translation.mock' const fixtureNote = (overrides: Record = {}) => ({ id: '7000000000000000070', @@ -153,6 +156,7 @@ describe('Yohaku contract — note detail (e2e)', () => { noteServiceProvider, countingServiceProvider, translationProvider, + translationEntryProvider, enrichmentProvider, aiSummaryProvider, aiInsightsProvider, @@ -210,12 +214,18 @@ describe('Yohaku contract — note detail (e2e)', () => { ]) } - // Adjacency wrappers carry partial note shape. - if (body.next) { - assertHasKeys(body.next, ['nid', 'title', 'slug', 'id']) + // Adjacency wrappers carry partial note shape — nested inside data. + if (body.data.next) { + assertHasKeys(body.data.next, ['nid', 'title', 'slug', 'id']) } - if (body.prev) { - assertHasKeys(body.prev, ['nid', 'title', 'slug', 'id']) + if (body.data.prev) { + assertHasKeys(body.data.prev, ['nid', 'title', 'slug', 'id']) } + + // Per-request fields live in meta, not on the resource object. + expect(body.data.enrichments).toBeUndefined() + expect(body.data.has_insights_in_locale).toBeUndefined() + expect(body.meta.enrichments).toBeDefined() + expect(typeof body.meta.insights?.has_in_locale).toBe('boolean') }) }) diff --git a/apps/core/test/src/contracts/yohaku/page-detail.contract.spec.ts b/apps/core/test/src/contracts/yohaku/page-detail.contract.spec.ts index 78c811cb0e7..ef3175d4f45 100644 --- a/apps/core/test/src/contracts/yohaku/page-detail.contract.spec.ts +++ b/apps/core/test/src/contracts/yohaku/page-detail.contract.spec.ts @@ -75,9 +75,9 @@ describe('Yohaku contract — page detail (e2e)', () => { const body = res.json() assertNoLegacyKeys(body) - assertPgTimestamps(body) + assertPgTimestamps(body.data) - assertHasKeys(body, [ + assertHasKeys(body.data, [ 'id', 'title', 'slug', @@ -90,6 +90,10 @@ describe('Yohaku contract — page detail (e2e)', () => { 'created_at', 'modified_at', ]) + + // Per-request fields live in meta, not on the resource object. + expect(body.data.enrichments).toBeUndefined() + expect(body.meta.enrichments).toBeDefined() }) test('GET /pages — list, items expose nav fields Yohaku reads', async () => { diff --git a/apps/core/test/src/contracts/yohaku/post-detail.contract.spec.ts b/apps/core/test/src/contracts/yohaku/post-detail.contract.spec.ts index 62548c6d8e7..f081956ae64 100644 --- a/apps/core/test/src/contracts/yohaku/post-detail.contract.spec.ts +++ b/apps/core/test/src/contracts/yohaku/post-detail.contract.spec.ts @@ -129,10 +129,10 @@ describe('Yohaku contract — post detail (e2e)', () => { const body = res.json() assertNoLegacyKeys(body) - assertPgTimestamps(body) + assertPgTimestamps(body.data) - // Top-level required by Yohaku post-detail page. - assertHasKeys(body, [ + // Top-level required by Yohaku post-detail page (entity fields only). + assertHasKeys(body.data, [ 'id', 'title', 'slug', @@ -144,23 +144,24 @@ describe('Yohaku contract — post detail (e2e)', () => { 'images', 'category', 'category_id', - 'related', 'read_count', 'like_count', 'created_at', 'modified_at', - 'is_translated', - 'has_insights_in_locale', ]) + // Per-request fields live in meta, not on the resource object. + expect(body.meta).toBeDefined() + expect(body.data.has_insights_in_locale).toBeUndefined() + expect(body.data.enrichments).toBeUndefined() + expect(body.data.related).toBeUndefined() + + // Insights and related refs are in meta. + expect(typeof body.meta.insights?.has_in_locale).toBe('boolean') + expect(Array.isArray(body.meta.related)).toBe(true) + assertHasKeysDeep(body.meta, ['related.0.title', 'related.0.slug']) // Deep paths Yohaku dereferences without optional chaining or with // direct destructuring. - assertHasKeysDeep(body, [ - 'category.slug', - 'category.name', - 'related.0.title', - 'related.0.slug', - 'related.0.category.slug', - ]) + assertHasKeysDeep(body.data, ['category.slug', 'category.name']) }) }) diff --git a/apps/core/test/src/contracts/yohaku/post-list.contract.spec.ts b/apps/core/test/src/contracts/yohaku/post-list.contract.spec.ts index d067f5f4fbb..7558bbf0376 100644 --- a/apps/core/test/src/contracts/yohaku/post-list.contract.spec.ts +++ b/apps/core/test/src/contracts/yohaku/post-list.contract.spec.ts @@ -128,6 +128,6 @@ describe('Yohaku contract — post list (e2e)', () => { 'modified_at', ]) assertHasKeysDeep(body.data[0], ['category.slug', 'category.name']) - expect(body.pagination).toMatchObject({ total: expect.any(Number) }) + expect(body.meta.pagination).toMatchObject({ total: expect.any(Number) }) }) }) diff --git a/apps/core/test/src/contracts/yohaku/recently-list.contract.spec.ts b/apps/core/test/src/contracts/yohaku/recently-list.contract.spec.ts index 335f78e4843..20a4d2b2364 100644 --- a/apps/core/test/src/contracts/yohaku/recently-list.contract.spec.ts +++ b/apps/core/test/src/contracts/yohaku/recently-list.contract.spec.ts @@ -114,7 +114,7 @@ describe('Yohaku contract — recently list (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body, { allowed: allowedRecentlyKeys }) - assertHasKeys(body, ['id', 'content', 'up', 'down', 'created_at']) + assertHasKeys(body.data, ['id', 'content', 'up', 'down', 'created_at']) }) test('GET /recently — ref hydrated for rows with refId', async () => { diff --git a/apps/core/test/src/contracts/yohaku/say-list.contract.spec.ts b/apps/core/test/src/contracts/yohaku/say-list.contract.spec.ts index fea0607e549..99e77d0bf5d 100644 --- a/apps/core/test/src/contracts/yohaku/say-list.contract.spec.ts +++ b/apps/core/test/src/contracts/yohaku/say-list.contract.spec.ts @@ -25,8 +25,7 @@ const fixtureSay = (overrides: Record = {}) => ({ text: 'A short musing.', source: 'me', author: 'innei', - createdAt: new Date('2024-09-15T00:00:00.000Z'), - modifiedAt: null, + created_at: new Date('2024-09-15T00:00:00.000Z'), ...overrides, }) diff --git a/apps/core/test/src/contracts/yohaku/topic-detail.contract.spec.ts b/apps/core/test/src/contracts/yohaku/topic-detail.contract.spec.ts index 54c1f911761..10a140e4c3e 100644 --- a/apps/core/test/src/contracts/yohaku/topic-detail.contract.spec.ts +++ b/apps/core/test/src/contracts/yohaku/topic-detail.contract.spec.ts @@ -19,6 +19,7 @@ import { } from '../../../helper/api-shape' import { createE2EApp } from '../../../helper/create-e2e-app' import { eventEmitterProvider } from '../../../mock/processors/event.mock' +import { translationEntryProvider } from '../../../mock/processors/translation.mock' const fixtureTopic = (overrides: Record = {}) => ({ id: '7000000000000000080', @@ -63,7 +64,11 @@ const topicRepoProvider = { describe('Yohaku contract — topic detail (e2e)', () => { const proxy = createE2EApp({ controllers: [TopicBaseController], - providers: [topicRepoProvider, ...eventEmitterProvider], + providers: [ + topicRepoProvider, + translationEntryProvider, + ...eventEmitterProvider, + ], }) test('GET /topics/all — list, exposes Yohaku-required topic fields', async () => { @@ -87,8 +92,8 @@ describe('Yohaku contract — topic detail (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() assertNoLegacyKeys(body) - assertPgTimestamps(body) - assertHasKeys(body, [ + assertPgTimestamps(body.data) + assertHasKeys(body.data, [ 'id', 'name', 'slug', diff --git a/apps/core/test/src/database/postgres-provider.spec.ts b/apps/core/test/src/database/postgres-provider.spec.ts index 6d853e3696d..d80ec7c1454 100644 --- a/apps/core/test/src/database/postgres-provider.spec.ts +++ b/apps/core/test/src/database/postgres-provider.spec.ts @@ -1,15 +1,15 @@ import { eq } from 'drizzle-orm' -import { Pool } from 'pg' +import type { Pool } from 'pg' +import { + createPgTestDatabase, + type PgTestDatabase, +} from 'test/helper/pg-verify-url' import { categories, posts } from '~/database/schema' import { SNOWFLAKE_EPOCH_MS, SnowflakeGenerator, } from '~/shared/id/snowflake.service' -import { - createPgTestDatabase, - type PgTestDatabase, -} from 'test/helper/pg-verify-url' /** * Integration smoke test. PG_VERIFY_URL must point at a reachable PostgreSQL diff --git a/apps/core/test/src/modules/activity/activity.controller.spec.ts b/apps/core/test/src/modules/activity/activity.controller.spec.ts new file mode 100644 index 00000000000..773a1cb0b5f --- /dev/null +++ b/apps/core/test/src/modules/activity/activity.controller.spec.ts @@ -0,0 +1,511 @@ +import { describe, expect, it, vi } from 'vitest' + +import { ActivityController } from '~/modules/activity/activity.controller' + +const NOW = new Date('2024-01-01') + +const TRANSLATED_TITLE_RESULT = { + isTranslated: true, + title: 'Translated Title', + text: '', + sourceLang: 'zh', + translationMeta: { + sourceLang: 'zh', + targetLang: 'en', + translatedAt: new Date('2024-06-01'), + model: 'claude-haiku-4-5', + }, + availableTranslations: ['en'], +} + +const UNTRANSLATED_RESULT = { + isTranslated: false, + title: 'Original Title', + text: '', + sourceLang: 'zh', + availableTranslations: [], +} + +const makeTranslationMeta = () => ({ + article: { + isTranslated: true, + sourceLang: 'zh', + targetLang: 'en', + translatedAt: new Date('2024-06-01'), + model: 'claude-haiku-4-5', + availableTranslations: ['en'], + }, +}) + +const createController = (opts: { + rooms?: { + roomInfo?: { rooms: string[]; roomCount: Record } + objects?: Record + } + readings?: Array<{ refId: string; ref?: any; count: number }> + recent?: { + likeData?: any[] + comment?: any[] + recentPublish?: { post: any[]; note: any[]; recent: any[] } + } + lastYearPublication?: { posts: any[]; notes: any[] } + collectTranslations?: ( + opts: any, + ) => Promise<{ results: Map; meta: Map }> +}) => { + const defaultCollect = async () => ({ + results: new Map(), + meta: new Map(), + }) + + const activityService = { + getAllRoomNames: vi.fn( + async () => opts.rooms?.roomInfo ?? { rooms: [], roomCount: {} }, + ), + getRefsFromRoomNames: vi.fn(async () => ({ + objects: opts.rooms?.objects ?? {}, + })), + getTopReadings: vi.fn(async () => opts.readings ?? []), + getDateRangeOfReadings: vi.fn(async () => opts.readings ?? []), + getLikeActivities: vi.fn(async () => ({ + data: opts.recent?.likeData ?? [], + })), + getRecentComment: vi.fn(async () => opts.recent?.comment ?? []), + getRecentPublish: vi.fn( + async () => + opts.recent?.recentPublish ?? { post: [], note: [], recent: [] }, + ), + getLastYearPublication: vi.fn( + async () => opts.lastYearPublication ?? { posts: [], notes: [] }, + ), + likeAndEmit: vi.fn(), + updatePresence: vi.fn(), + getRoomPresence: vi.fn(async () => []), + deleteActivityByType: vi.fn(), + deleteAll: vi.fn(), + getLikeActivitiesById: vi.fn(), + getReadDurationActivities: vi.fn(async () => []), + } + + const translationService = { + collectArticleTranslations: vi.fn( + opts.collectTranslations ?? defaultCollect, + ), + } + + const readerService = { + findReaderInIds: vi.fn(async () => []), + } + + const controller = new ActivityController( + activityService as any, + readerService as any, + translationService as any, + ) + + return { controller, activityService, translationService } +} + +describe('ActivityController.getRoomsInfo', () => { + it('returns bare data when lang is absent', async () => { + const { controller } = createController({ + rooms: { + roomInfo: { rooms: ['room1'], roomCount: { room1: 1 } }, + objects: { post: [{ id: 'p1', title: 'Post 1', createdAt: NOW }] }, + }, + }) + + const res = await controller.getRoomsInfo(undefined) + expect(res).not.toHaveProperty('meta') + expect((res as any).objects.post[0].title).toBe('Post 1') + }) + + it('translates titles in place across multiple type keys and emits meta only for translated items', async () => { + const post1 = { id: 'p1', title: 'Post 1', createdAt: NOW } + const post2 = { id: 'p2', title: 'Post 2', createdAt: NOW } + const note1 = { id: 'n1', title: 'Note 1', createdAt: NOW } + + const { controller } = createController({ + rooms: { + roomInfo: { + rooms: ['r1', 'r2', 'r3'], + roomCount: { r1: 1, r2: 1, r3: 1 }, + }, + objects: { + post: [post1, post2], + note: [note1], + }, + }, + collectTranslations: async () => ({ + results: new Map([ + ['p1', { ...TRANSLATED_TITLE_RESULT, title: 'Post 1 EN' }], + ['p2', { ...UNTRANSLATED_RESULT, title: 'Post 2' }], + ['n1', { ...TRANSLATED_TITLE_RESULT, title: 'Note 1 EN' }], + ]), + meta: new Map([ + ['p1', makeTranslationMeta()], + ['n1', makeTranslationMeta()], + ]), + }), + }) + + const res = await controller.getRoomsInfo('en') + expect(res).toHaveProperty('meta') + const d = (res as any).data + expect(d.objects.post[0].title).toBe('Post 1 EN') + expect(d.objects.post[1].title).toBe('Post 2') + expect(d.objects.note[0].title).toBe('Note 1 EN') + + expect((res as any).meta.translation['p1']).toBeDefined() + expect((res as any).meta.translation['n1']).toBeDefined() + expect((res as any).meta.translation['p2']).toBeUndefined() + }) + + it('returns bare data when no items are translated', async () => { + const { controller } = createController({ + rooms: { + roomInfo: { rooms: ['r1'], roomCount: { r1: 1 } }, + objects: { post: [{ id: 'p1', title: 'Post 1', createdAt: NOW }] }, + }, + collectTranslations: async () => ({ + results: new Map([['p1', UNTRANSLATED_RESULT]]), + meta: new Map(), + }), + }) + + const res = await controller.getRoomsInfo('en') + expect(res).not.toHaveProperty('meta') + }) +}) + +describe('ActivityController.getTopReadings', () => { + it('returns bare data when lang is absent', async () => { + const { controller } = createController({ + readings: [ + { + refId: 'r1', + ref: { id: 'r1', title: 'Article 1', createdAt: NOW, slug: 'a1' }, + count: 10, + }, + ], + }) + + const res = await controller.getTopReadings({} as any, undefined) + expect(Array.isArray(res)).toBe(true) + expect((res as any[])[0].ref.title).toBe('Article 1') + }) + + it('translates ref.title in place and meta is keyed by refId', async () => { + const { controller } = createController({ + readings: [ + { + refId: 'ref-001', + ref: { + id: 'doc-001', + title: 'Japanese Article', + createdAt: NOW, + slug: 'ja', + }, + count: 5, + }, + { + refId: 'ref-002', + ref: { + id: 'doc-002', + title: 'English Article', + createdAt: NOW, + slug: 'en', + }, + count: 3, + }, + ], + collectTranslations: async () => ({ + results: new Map([ + [ + 'ref-001', + { ...TRANSLATED_TITLE_RESULT, title: 'Japanese Article EN' }, + ], + ['ref-002', { ...UNTRANSLATED_RESULT, title: 'English Article' }], + ]), + meta: new Map([['ref-001', makeTranslationMeta()]]), + }), + }) + + const res = await controller.getTopReadings({} as any, 'en') + expect(res).toHaveProperty('meta') + const d = (res as any).data + expect(d[0].ref.title).toBe('Japanese Article EN') + expect(d[1].ref.title).toBe('English Article') + expect((res as any).meta.translation['ref-001']).toBeDefined() + expect((res as any).meta.translation['ref-002']).toBeUndefined() + }) + + it('returns bare data when no items are translated', async () => { + const { controller } = createController({ + readings: [ + { + refId: 'ref-001', + ref: { id: 'doc-001', title: 'Article', createdAt: NOW, slug: 'a' }, + count: 1, + }, + ], + collectTranslations: async () => ({ + results: new Map([['ref-001', UNTRANSLATED_RESULT]]), + meta: new Map(), + }), + }) + + const res = await controller.getTopReadings({} as any, 'en') + expect(res).not.toHaveProperty('meta') + }) +}) + +describe('ActivityController.getReadingRangeRank', () => { + it('meta keyed by refId when translated', async () => { + const { controller } = createController({ + readings: [ + { + refId: 'r1', + ref: { id: 'd1', title: 'Title', createdAt: NOW, slug: 't' }, + count: 2, + }, + ], + collectTranslations: async () => ({ + results: new Map([ + ['r1', { ...TRANSLATED_TITLE_RESULT, title: 'Title EN' }], + ]), + meta: new Map([['r1', makeTranslationMeta()]]), + }), + }) + + const res = await controller.getReadingRangeRank({} as any, 'en') + expect((res as any).meta.translation['r1']).toBeDefined() + expect((res as any).data[0].ref.title).toBe('Title EN') + }) +}) + +describe('ActivityController.getRecentActivities', () => { + it('returns bare data when lang is absent', async () => { + const { controller } = createController({ + recent: { + likeData: [], + comment: [], + recentPublish: { + post: [{ id: 'p1', title: 'P1', createdAt: NOW }], + note: [], + recent: [], + }, + }, + }) + + const res = await controller.getRecentActivities(undefined) + expect(res).not.toHaveProperty('meta') + expect((res as any).post[0].title).toBe('P1') + }) + + it('translates each bucket in place and aggregates meta', async () => { + const likeItem = { + id: 'like1', + createdAt: NOW, + ref: { title: 'Liked Post', slug: 'lp' }, + payload: { id: 'article-001' }, + } + + const collectMock = vi + .fn() + .mockResolvedValueOnce({ + results: new Map([ + [ + 'article-001', + { ...TRANSLATED_TITLE_RESULT, title: 'Liked Post EN' }, + ], + ]), + meta: new Map([['article-001', makeTranslationMeta()]]), + }) + .mockResolvedValueOnce({ + results: new Map([ + ['p1', { ...TRANSLATED_TITLE_RESULT, title: 'Post EN' }], + ]), + meta: new Map([['p1', makeTranslationMeta()]]), + }) + .mockResolvedValueOnce({ + results: new Map([ + ['n1', { ...TRANSLATED_TITLE_RESULT, title: 'Note EN' }], + ]), + meta: new Map([['n1', makeTranslationMeta()]]), + }) + + const { controller } = createController({ + recent: { + likeData: [likeItem], + comment: [], + recentPublish: { + post: [ + { + id: 'p1', + title: 'Post', + createdAt: NOW, + slug: 'p', + modifiedAt: null, + }, + ], + note: [{ id: 'n1', title: 'Note', createdAt: NOW, modifiedAt: null }], + recent: [], + }, + }, + collectTranslations: collectMock, + }) + + const res = await controller.getRecentActivities('en') + expect(res).toHaveProperty('meta') + const d = (res as any).data + expect(d.like[0].title).toBe('Liked Post EN') + expect(d.post[0].title).toBe('Post EN') + expect(d.note[0].title).toBe('Note EN') + expect((res as any).meta.translation['article-001']).toBeDefined() + expect((res as any).meta.translation['p1']).toBeDefined() + expect((res as any).meta.translation['n1']).toBeDefined() + }) + + it('deletes articleId from like items before returning', async () => { + const likeItem = { + id: 'like1', + createdAt: NOW, + ref: { title: 'Post', slug: 'p' }, + payload: { id: 'art1' }, + } + + const { controller } = createController({ + recent: { + likeData: [likeItem], + comment: [], + recentPublish: { post: [], note: [], recent: [] }, + }, + collectTranslations: async () => ({ + results: new Map(), + meta: new Map(), + }), + }) + + const res = await controller.getRecentActivities('en') + const likeItems = (res as any).like + expect(likeItems[0]).not.toHaveProperty('articleId') + }) + + it('returns bare data when no buckets have translations', async () => { + const { controller } = createController({ + recent: { + likeData: [], + comment: [], + recentPublish: { + post: [{ id: 'p1', title: 'Post', createdAt: NOW }], + note: [], + recent: [], + }, + }, + collectTranslations: async () => ({ + results: new Map([['p1', UNTRANSLATED_RESULT]]), + meta: new Map(), + }), + }) + + const res = await controller.getRecentActivities('en') + expect(res).not.toHaveProperty('meta') + }) +}) + +describe('ActivityController.getLastYearPublication', () => { + it('returns raw result when lang is absent', async () => { + const { controller } = createController({ + lastYearPublication: { + posts: [{ id: 'p1', title: 'Post', createdAt: NOW }], + notes: [], + }, + }) + + const res = await controller.getLastYearPublication(undefined) + expect(res).not.toHaveProperty('meta') + }) + + it('translates posts and notes in place and emits per-item meta', async () => { + const { controller } = createController({ + lastYearPublication: { + posts: [ + { id: 'p1', title: 'Post 1', createdAt: NOW }, + { id: 'p2', title: 'Post 2', createdAt: NOW }, + ], + notes: [ + { id: 'n1', title: 'Note 1', createdAt: NOW }, + { id: 'n2', title: 'Private note', createdAt: NOW }, + ], + }, + collectTranslations: vi + .fn() + .mockResolvedValueOnce({ + results: new Map([ + ['p1', { ...TRANSLATED_TITLE_RESULT, title: 'Post 1 EN' }], + ['p2', { ...UNTRANSLATED_RESULT }], + ]), + meta: new Map([['p1', makeTranslationMeta()]]), + }) + .mockResolvedValueOnce({ + results: new Map([ + ['n1', { ...TRANSLATED_TITLE_RESULT, title: 'Note 1 EN' }], + ]), + meta: new Map([['n1', makeTranslationMeta()]]), + }), + }) + + const res = await controller.getLastYearPublication('en') + expect(res).toHaveProperty('meta') + const d = (res as any).data + expect(d.posts[0].title).toBe('Post 1 EN') + expect(d.posts[1].title).toBe('Post 2') + expect(d.notes[0].title).toBe('Note 1 EN') + expect(d.notes[1].title).toBe('Private note') + + expect((res as any).meta.translation['p1']).toBeDefined() + expect((res as any).meta.translation['n1']).toBeDefined() + expect((res as any).meta.translation['p2']).toBeUndefined() + }) + + it('does not translate private notes', async () => { + const collectMock = vi + .fn() + .mockResolvedValueOnce({ results: new Map(), meta: new Map() }) + .mockResolvedValueOnce({ results: new Map(), meta: new Map() }) + + const { controller, translationService } = createController({ + lastYearPublication: { + posts: [], + notes: [{ id: 'n1', title: 'Private note', createdAt: NOW }], + }, + collectTranslations: collectMock, + }) + + await controller.getLastYearPublication('en') + const noteSnapshots = + translationService.collectArticleTranslations.mock.calls[1]?.[0] + ?.articles ?? [] + expect(noteSnapshots).toHaveLength(0) + }) + + it('returns bare data when no items are translated', async () => { + const { controller } = createController({ + lastYearPublication: { + posts: [{ id: 'p1', title: 'Post', createdAt: NOW }], + notes: [], + }, + collectTranslations: vi + .fn() + .mockResolvedValueOnce({ + results: new Map([['p1', UNTRANSLATED_RESULT]]), + meta: new Map(), + }) + .mockResolvedValueOnce({ results: new Map(), meta: new Map() }), + }) + + const res = await controller.getLastYearPublication('en') + expect(res).not.toHaveProperty('meta') + }) +}) diff --git a/apps/core/test/src/modules/aggregate/aggregate.controller.spec.ts b/apps/core/test/src/modules/aggregate/aggregate.controller.spec.ts index d9d92cc713a..8ef6dc57750 100644 --- a/apps/core/test/src/modules/aggregate/aggregate.controller.spec.ts +++ b/apps/core/test/src/modules/aggregate/aggregate.controller.spec.ts @@ -2,16 +2,126 @@ import { describe, expect, it, vi } from 'vitest' import { AggregateController } from '~/modules/aggregate/aggregate.controller' +const NOW = new Date('2024-01-01') + +const makeNote = (overrides: Record = {}) => ({ + id: 'note1', + title: 'Note Title', + mood: 'Happy', + weather: 'Sunny', + createdAt: NOW, + modifiedAt: null, + ...overrides, +}) + +const makePost = (overrides: Record = {}) => ({ + id: 'post1', + title: 'Post Title', + category: { id: 'cat1', name: 'Tech', slug: 'tech' }, + createdAt: NOW, + modifiedAt: null, + ...overrides, +}) + +const TRANSLATED_TITLE_RESULT = { + isTranslated: true, + title: 'Translated Title', + text: '', + sourceLang: 'zh', + translationMeta: { + sourceLang: 'zh', + targetLang: 'en', + translatedAt: new Date('2024-06-01'), + model: 'claude-haiku-4-5', + }, + availableTranslations: ['en'], +} + +const UNTRANSLATED_RESULT = { + isTranslated: false, + title: 'Original Title', + text: '', + sourceLang: 'zh', + availableTranslations: [], +} + +const makeTranslationMeta = (_id: string) => ({ + article: { + isTranslated: true, + sourceLang: 'zh', + targetLang: 'en', + translatedAt: new Date('2024-06-01'), + model: 'claude-haiku-4-5', + availableTranslations: ['en'], + }, +}) + const createController = ( - overrides: { - configsService?: Record - noteService?: Record - ownerService?: Record - snippetService?: Record + opts: { + topActivityResult?: Record + latestResult?: any + timelineResult?: Record + topArticlesResult?: any[] + collectTranslations?: { + results: Map + meta: Map + } + entryMaps?: { + entityMaps: Map> + dictMaps: Map> + } } = {}, -) => - new AggregateController( - {} as any, +) => { + const defaultEntryMaps = { + entityMaps: new Map>(), + dictMaps: new Map>(), + } + + const defaultCollect = { + results: new Map(), + meta: new Map(), + } + + const aggregateService = { + topActivity: vi.fn( + async () => + opts.topActivityResult ?? { + notes: [], + posts: [], + says: [], + recently: [], + }, + ), + getLatest: vi.fn(async () => opts.latestResult ?? { posts: [], notes: [] }), + getTimeline: vi.fn( + async () => opts.timelineResult ?? { posts: [], notes: [] }, + ), + getTopArticles: vi.fn(async () => opts.topArticlesResult ?? []), + getSiteMapContent: vi.fn(async () => []), + buildRssStructure: vi.fn(async () => ({})), + getCounts: vi.fn(async () => ({})), + getAllReadAndLikeCount: vi.fn(async () => ({})), + getAllSiteWordsCount: vi.fn(async () => 0), + getSiteInfo: vi.fn(async () => ({})), + getCategoryDistribution: vi.fn(async () => []), + getTagCloud: vi.fn(async () => []), + getPublicationTrend: vi.fn(async () => []), + getCommentActivity: vi.fn(async () => []), + getTrafficSource: vi.fn(async () => ({})), + } + + const translationService = { + collectArticleTranslations: vi.fn( + async () => opts.collectTranslations ?? defaultCollect, + ), + } + + const translationEntryService = { + getTranslationsBatch: vi.fn(async () => opts.entryMaps ?? defaultEntryMaps), + } + + const controller = new AggregateController( + aggregateService as any, { get: vi.fn(async (key: string) => { if (key === 'url') @@ -22,42 +132,405 @@ const createController = ( if (key === 'ai') return { enableSummary: true } return {} }), - ...overrides.configsService, } as any, {} as any, { getLatestNoteId: vi.fn(async () => 17), - ...overrides.noteService, } as any, { getCachedSnippet: vi.fn(async () => null), getPublicSnippetByName: vi.fn(async () => null), - ...overrides.snippetService, } as any, { - getOwner: vi.fn(async () => ({ - id: '1', - name: 'Owner', - socialIds: {}, - })), - ...overrides.ownerService, + getOwner: vi.fn(async () => ({ id: '1', name: 'Owner', socialIds: {} })), } as any, - {} as any, + translationService as any, + translationEntryService as any, ) + return { + controller, + translationService, + translationEntryService, + aggregateService, + } +} + describe('AggregateController', () => { it('does not downgrade root aggregate dependency failures into cacheable partial responses', async () => { - const controller = createController({ - configsService: { + const controller = new AggregateController( + {} as any, + { get: vi.fn(async (key: string) => { if (key === 'url') throw new Error('url config unavailable') return {} }), - }, - }) + } as any, + {} as any, + { getLatestNoteId: vi.fn(async () => 17) } as any, + { + getCachedSnippet: vi.fn(async () => null), + getPublicSnippetByName: vi.fn(async () => null), + } as any, + { + getOwner: vi.fn(async () => ({ + id: '1', + name: 'Owner', + socialIds: {}, + })), + } as any, + {} as any, + {} as any, + ) await expect(controller.aggregate({} as any)).rejects.toThrow( 'url config unavailable', ) }) }) + +describe('AggregateController.top', () => { + it('returns raw result when lang is absent', async () => { + const { controller, aggregateService } = createController({ + topActivityResult: { + notes: [makeNote()], + posts: [makePost()], + says: [], + recently: [], + }, + }) + const res = await controller.top({ size: 5 } as any, false) + expect(res).not.toHaveProperty('meta') + expect(aggregateService.topActivity).toHaveBeenCalledOnce() + }) + + it('translates titles in place and emits meta for translated items only', async () => { + const note1 = makeNote({ id: 'n1', title: 'Japanese Diary' }) + const note2 = makeNote({ id: 'n2', title: 'Untranslated Note' }) + const post1 = makePost({ id: 'p1', title: 'Japanese Article' }) + + const { controller } = createController({ + topActivityResult: { + notes: [note1, note2], + posts: [post1], + says: [], + recently: [], + }, + collectTranslations: { + results: new Map([ + ['n1', { ...TRANSLATED_TITLE_RESULT, title: 'Diary EN' }], + ['n2', { ...UNTRANSLATED_RESULT, title: 'Untranslated Note' }], + ['p1', { ...TRANSLATED_TITLE_RESULT, title: 'Article EN' }], + ]), + meta: new Map([ + ['n1', makeTranslationMeta('n1')], + ['p1', makeTranslationMeta('p1')], + ]), + }, + }) + + const res = await controller.top({ size: 5 } as any, false, 'en') + + expect(res.data.notes[0].title).toBe('Diary EN') + expect(res.data.notes[1].title).toBe('Untranslated Note') + expect(res.data.posts[0].title).toBe('Article EN') + + expect(res.meta?.translation?.['n1']).toBeDefined() + expect(res.meta?.translation?.['p1']).toBeDefined() + expect(res.meta?.translation?.['n2']).toBeUndefined() + + const articleMeta = res.meta?.translation?.['n1']?.article + expect(articleMeta?.isTranslated).toBe(true) + expect(articleMeta).not.toHaveProperty('title') + expect(articleMeta).not.toHaveProperty('text') + }) + + it('translates note mood and weather in place via dict lookup', async () => { + const note = makeNote({ id: 'n1', mood: 'Happy', weather: 'Sunny' }) + + const { controller } = createController({ + topActivityResult: { notes: [note], posts: [], says: [], recently: [] }, + collectTranslations: { + results: new Map([ + ['n1', { ...TRANSLATED_TITLE_RESULT, title: 'Diary EN' }], + ]), + meta: new Map([['n1', makeTranslationMeta('n1')]]), + }, + entryMaps: { + entityMaps: new Map(), + dictMaps: new Map([ + ['note.mood', new Map([['Happy', 'Heureux']])], + ['note.weather', new Map([['Sunny', 'Ensoleille']])], + ]), + }, + }) + + const res = await controller.top({ size: 5 } as any, false, 'en') + + expect(res.data.notes[0].mood).toBe('Heureux') + expect(res.data.notes[0].weather).toBe('Ensoleille') + }) + + it('returns raw result when no items are translated', async () => { + const note = makeNote({ id: 'n1' }) + + const { controller } = createController({ + topActivityResult: { notes: [note], posts: [], says: [], recently: [] }, + collectTranslations: { + results: new Map([['n1', { ...UNTRANSLATED_RESULT }]]), + meta: new Map(), + }, + }) + + const res = await controller.top({ size: 5 } as any, false, 'en') + expect(res).not.toHaveProperty('meta') + }) +}) + +describe('AggregateController.getLatest', () => { + it('returns raw result when lang is absent', async () => { + const { controller } = createController({ + latestResult: { posts: [makePost()], notes: [makeNote()] }, + }) + const res = await controller.getLatest({ limit: 5, combined: false } as any) + expect(res).not.toHaveProperty('meta') + }) + + it('combined=true: translates bare array items in place and emits meta', async () => { + const p1 = { ...makePost({ id: 'p1' }), type: 'post' } + const n1 = { + ...makeNote({ id: 'n1', mood: 'Happy', weather: 'Clear' }), + type: 'note', + } + + const { controller } = createController({ + latestResult: [p1, n1], + collectTranslations: { + results: new Map([ + ['p1', { ...TRANSLATED_TITLE_RESULT, title: 'Post EN' }], + ['n1', { ...TRANSLATED_TITLE_RESULT, title: 'Note EN' }], + ]), + meta: new Map([ + ['p1', makeTranslationMeta('p1')], + ['n1', makeTranslationMeta('n1')], + ]), + }, + entryMaps: { + entityMaps: new Map(), + dictMaps: new Map([ + ['note.mood', new Map([['Happy', 'Happy EN']])], + ['note.weather', new Map([['Clear', 'Clear EN']])], + ]), + }, + }) + + const res = await controller.getLatest( + { limit: 5, combined: true } as any, + 'en', + ) + + const postItem = (res.data as any[]).find((i: any) => i.id === 'p1') + const noteItem = (res.data as any[]).find((i: any) => i.id === 'n1') + + expect(postItem.title).toBe('Post EN') + expect(noteItem.title).toBe('Note EN') + expect(noteItem.mood).toBe('Happy EN') + expect(noteItem.weather).toBe('Clear EN') + + expect(res.meta?.translation?.['p1']).toBeDefined() + expect(res.meta?.translation?.['n1']).toBeDefined() + }) + + it('separate mode: translates posts and notes independently', async () => { + const p1 = makePost({ id: 'p1' }) + const n1 = makeNote({ id: 'n1' }) + const n2 = makeNote({ id: 'n2', title: 'Untranslated' }) + + const { controller } = createController({ + latestResult: { posts: [p1], notes: [n1, n2] }, + collectTranslations: { + results: new Map([ + ['p1', { ...TRANSLATED_TITLE_RESULT, title: 'Post EN' }], + ['n1', { ...TRANSLATED_TITLE_RESULT, title: 'Note EN' }], + ['n2', { ...UNTRANSLATED_RESULT, title: 'Untranslated' }], + ]), + meta: new Map([ + ['p1', makeTranslationMeta('p1')], + ['n1', makeTranslationMeta('n1')], + ]), + }, + }) + + const res = await controller.getLatest( + { limit: 5, combined: false } as any, + 'en', + ) + + expect(res.data.posts[0].title).toBe('Post EN') + expect(res.data.notes[0].title).toBe('Note EN') + expect(res.data.notes[1].title).toBe('Untranslated') + + expect(res.meta?.translation?.['p1']).toBeDefined() + expect(res.meta?.translation?.['n1']).toBeDefined() + expect(res.meta?.translation?.['n2']).toBeUndefined() + }) +}) + +describe('AggregateController.getTimeline', () => { + it('translates post/note titles, note mood/weather, and post category.name in place', async () => { + const note = makeNote({ id: 'n1', mood: 'Calm', weather: 'Rainy' }) + const post = makePost({ + id: 'p1', + title: 'Japanese Article', + category: { id: 'cat1', name: 'Technology', slug: 'technology' }, + }) + + const { controller } = createController({ + timelineResult: { notes: [note], posts: [post] }, + collectTranslations: { + results: new Map([ + ['n1', { ...TRANSLATED_TITLE_RESULT, title: 'Note EN' }], + ['p1', { ...TRANSLATED_TITLE_RESULT, title: 'Article EN' }], + ]), + meta: new Map([ + ['n1', makeTranslationMeta('n1')], + ['p1', makeTranslationMeta('p1')], + ]), + }, + entryMaps: { + entityMaps: new Map([ + ['category.name', new Map([['cat1', 'Technology EN']])], + ]), + dictMaps: new Map([ + ['note.mood', new Map([['Calm', 'Calme']])], + ['note.weather', new Map([['Rainy', 'Pluvieux']])], + ]), + }, + }) + + const res = await controller.getTimeline({ sort: 1 } as any, 'en') + + expect(res.data.notes[0].title).toBe('Note EN') + expect(res.data.notes[0].mood).toBe('Calme') + expect(res.data.notes[0].weather).toBe('Pluvieux') + expect(res.data.posts[0].title).toBe('Article EN') + expect(res.data.posts[0].category.name).toBe('Technology EN') + + expect(res.meta?.translation?.['n1']).toBeDefined() + expect(res.meta?.translation?.['p1']).toBeDefined() + + const noteMeta = res.meta?.translation?.['n1']?.article + expect(noteMeta?.isTranslated).toBe(true) + expect(noteMeta).not.toHaveProperty('title') + expect(noteMeta).not.toHaveProperty('text') + }) + + it('returns raw data when no items are translated', async () => { + const note = makeNote({ id: 'n1' }) + + const { controller } = createController({ + timelineResult: { notes: [note], posts: [] }, + collectTranslations: { + results: new Map([['n1', { ...UNTRANSLATED_RESULT }]]), + meta: new Map(), + }, + }) + + const res = await controller.getTimeline({ sort: 1 } as any, 'en') + expect(res).not.toHaveProperty('meta') + }) + + it('returns raw data when lang is absent', async () => { + const { controller } = createController({ + timelineResult: { notes: [makeNote()], posts: [makePost()] }, + }) + const res = await controller.getTimeline({ sort: 1 } as any) + expect(res).not.toHaveProperty('meta') + }) +}) + +describe('AggregateController.getTopArticles', () => { + it('translates titles in place and emits meta for translated items only', async () => { + const articles = [ + { + id: 'a1', + title: 'Popular Post', + slug: 'hot', + reads: 100, + likes: 10, + category: null, + }, + { + id: 'a2', + title: 'Another Post', + slug: 'another', + reads: 50, + likes: 5, + category: null, + }, + ] + + const { controller } = createController({ + topArticlesResult: articles, + collectTranslations: { + results: new Map([ + ['a1', { ...TRANSLATED_TITLE_RESULT, title: 'Popular Article EN' }], + ['a2', { ...UNTRANSLATED_RESULT, title: 'Another Post' }], + ]), + meta: new Map([['a1', makeTranslationMeta('a1')]]), + }, + }) + + const res = await controller.getTopArticles('en') + + expect(res.data[0].title).toBe('Popular Article EN') + expect(res.data[1].title).toBe('Another Post') + + expect(res.meta?.translation?.['a1']).toBeDefined() + expect(res.meta?.translation?.['a2']).toBeUndefined() + + const articleMeta = res.meta?.translation?.['a1']?.article + expect(articleMeta?.isTranslated).toBe(true) + expect(articleMeta).not.toHaveProperty('title') + expect(articleMeta).not.toHaveProperty('text') + }) + + it('returns raw array when lang is absent', async () => { + const articles = [ + { + id: 'a1', + title: 'Post', + slug: 'post', + reads: 100, + likes: 10, + category: null, + }, + ] + const { controller } = createController({ topArticlesResult: articles }) + const res = await controller.getTopArticles() + expect(Array.isArray(res)).toBe(true) + expect(res[0].title).toBe('Post') + }) + + it('returns raw array when no items are translated', async () => { + const articles = [ + { + id: 'a1', + title: 'Post', + slug: 'post', + reads: 100, + likes: 10, + category: null, + }, + ] + const { controller } = createController({ + topArticlesResult: articles, + collectTranslations: { + results: new Map([['a1', { ...UNTRANSLATED_RESULT }]]), + meta: new Map(), + }, + }) + const res = await controller.getTopArticles('en') + expect(Array.isArray(res)).toBe(true) + }) +}) diff --git a/apps/core/test/src/modules/ai/ai-inflight.service.spec.ts b/apps/core/test/src/modules/ai/ai-inflight.service.spec.ts index 922b5d43c83..6189a26eee9 100644 --- a/apps/core/test/src/modules/ai/ai-inflight.service.spec.ts +++ b/apps/core/test/src/modules/ai/ai-inflight.service.spec.ts @@ -1,8 +1,9 @@ import { Test } from '@nestjs/testing' -import { BizException } from '~/common/exceptions/biz.exception' +import { describe, expect, it } from 'vitest' + +import { AppException } from '~/common/errors/exception.types' import { AiInFlightService } from '~/modules/ai/ai-inflight/ai-inflight.service' import { RedisService } from '~/processors/redis/redis.service' -import { describe, expect, it } from 'vitest' class FakeRedis { private store = new Map() @@ -195,6 +196,6 @@ describe('AiInFlightService', () => { caught = error } - expect(caught).toBeInstanceOf(BizException) + expect(caught).toBeInstanceOf(AppException) }) }) diff --git a/apps/core/test/src/modules/ai/ai-insights.service.spec.ts b/apps/core/test/src/modules/ai/ai-insights.service.spec.ts index ab934cb7d71..5e312096eb9 100644 --- a/apps/core/test/src/modules/ai/ai-insights.service.spec.ts +++ b/apps/core/test/src/modules/ai/ai-insights.service.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { createPgRepositoryMock, now } from '@/helper/pg-repository-mock' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppException } from '~/common/errors/exception.types' import type { AiInsightsRepository } from '~/modules/ai/ai-insights/ai-insights.repository' import { AiInsightsService } from '~/modules/ai/ai-insights/ai-insights.service' @@ -73,7 +73,7 @@ describe('AiInsightsService', () => { repository.findById.mockResolvedValue(null) await expect(service.updateInsightsInDb('missing', 'new')).rejects.toThrow( - BizException, + AppException, ) }) diff --git a/apps/core/test/src/modules/ai/ai-summary.service.spec.ts b/apps/core/test/src/modules/ai/ai-summary.service.spec.ts index 8188ef6294a..7681aae82a7 100644 --- a/apps/core/test/src/modules/ai/ai-summary.service.spec.ts +++ b/apps/core/test/src/modules/ai/ai-summary.service.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { createPgRepositoryMock, now } from '@/helper/pg-repository-mock' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppException } from '~/common/errors/exception.types' import type { AiSummaryRepository } from '~/modules/ai/ai-summary/ai-summary.repository' import { AiSummaryService } from '~/modules/ai/ai-summary/ai-summary.service' @@ -59,7 +59,7 @@ describe('AiSummaryService', () => { repository.findById.mockResolvedValue(null) await expect(service.updateSummaryInDb('missing', 'new')).rejects.toThrow( - BizException, + AppException, ) }) diff --git a/apps/core/test/src/modules/ai/ai-translation.service.spec.ts b/apps/core/test/src/modules/ai/ai-translation.service.spec.ts index 8aa2ebbc5b7..caf63608208 100644 --- a/apps/core/test/src/modules/ai/ai-translation.service.spec.ts +++ b/apps/core/test/src/modules/ai/ai-translation.service.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { createPgRepositoryMock, now } from '@/helper/pg-repository-mock' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppException } from '~/common/errors/exception.types' import type { AiTranslationRepository, AiTranslationRow, @@ -95,7 +95,7 @@ describe('AiTranslationService', () => { repository.deleteById.mockResolvedValue(0) await expect(service.deleteTranslation('missing')).rejects.toThrow( - BizException, + AppException, ) }) }) diff --git a/apps/core/test/src/modules/ai/ai.service.spec.ts b/apps/core/test/src/modules/ai/ai.service.spec.ts index 5faabd61bea..b3bba5aefc8 100644 --- a/apps/core/test/src/modules/ai/ai.service.spec.ts +++ b/apps/core/test/src/modules/ai/ai.service.spec.ts @@ -1,9 +1,10 @@ import { Test } from '@nestjs/testing' -import { BizException } from '~/common/exceptions/biz.exception' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { AppException } from '~/common/errors/exception.types' import { AiService } from '~/modules/ai/ai.service' import { AIProviderType } from '~/modules/ai/ai.types' import { ConfigsService } from '~/modules/configs/configs.service' -import { beforeEach, describe, expect, it, vi } from 'vitest' // Mock the runtime factory vi.mock('~/modules/ai/runtime', () => ({ @@ -102,12 +103,12 @@ describe('AiService', () => { describe('when no providers configured', () => { it('should throw when providers array is empty', async () => { configsService.get.mockResolvedValueOnce({ providers: [] }) - await expect(service.getSummaryModel()).rejects.toThrow(BizException) + await expect(service.getSummaryModel()).rejects.toThrow(AppException) }) it('should throw when providers is undefined', async () => { configsService.get.mockResolvedValueOnce({}) - await expect(service.getSummaryModel()).rejects.toThrow(BizException) + await expect(service.getSummaryModel()).rejects.toThrow(AppException) }) }) diff --git a/apps/core/test/src/modules/ai/translation-entry.interceptor.spec.ts b/apps/core/test/src/modules/ai/translation-entry.interceptor.spec.ts deleted file mode 100644 index e552344ebe2..00000000000 --- a/apps/core/test/src/modules/ai/translation-entry.interceptor.spec.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { TranslationEntryInterceptor } from '~/common/interceptors/translation-entry.interceptor' - -// Test the private path resolution logic by instantiating with null deps -// and calling the methods via prototype -const interceptor = Object.create(TranslationEntryInterceptor.prototype) - -describe('TranslationEntryInterceptor path utilities', () => { - describe('applyTranslations', () => { - it('should batch lookup once and replace matched fields', async () => { - const translationEntryService = { - getTranslationsBatch: vi.fn().mockResolvedValue({ - entityMaps: new Map([ - ['category.name', new Map([['id-1', 'Frontend']])], - ]), - dictMaps: new Map([['note.mood', new Map([['开心', 'Happy']])]]), - }), - } - - const realInterceptor = new TranslationEntryInterceptor( - { get: vi.fn() } as any, - translationEntryService as any, - ) - - const data = { - categories: [{ _id: 'id-1', name: '前端' }], - notes: [{ mood: '开心' }], - } - - const result = await realInterceptor['applyTranslations']( - data, - [ - { - keyPath: 'category.name', - path: 'categories[].name', - idField: '_id', - }, - { keyPath: 'note.mood', path: 'notes[].mood' }, - ], - 'en', - ) - - expect( - translationEntryService.getTranslationsBatch, - ).toHaveBeenCalledTimes(1) - expect(translationEntryService.getTranslationsBatch).toHaveBeenCalledWith( - 'en', - { - entityLookups: [{ keyPath: 'category.name', lookupKeys: ['id-1'] }], - dictLookups: [{ keyPath: 'note.mood', sourceTexts: ['开心'] }], - }, - ) - expect(result).toBe(data) - expect(result.categories[0].name).toBe('Frontend') - expect(result.notes[0].mood).toBe('Happy') - expect(data.categories[0].name).toBe('Frontend') - expect(data.notes[0].mood).toBe('Happy') - }) - - it('should translate entity fields on root arrays', async () => { - const translationEntryService = { - getTranslationsBatch: vi.fn().mockResolvedValue({ - entityMaps: new Map([ - ['topic.name', new Map([['id-1', 'Frontend']])], - ]), - dictMaps: new Map(), - }), - } - - const realInterceptor = new TranslationEntryInterceptor( - { get: vi.fn() } as any, - translationEntryService as any, - ) - - const data = [{ _id: 'id-1', name: '前端' }] - - const result = await realInterceptor['applyTranslations']( - data, - [ - { - keyPath: 'topic.name', - path: '[].name', - idField: '_id', - }, - ], - 'en', - ) - - expect(translationEntryService.getTranslationsBatch).toHaveBeenCalledWith( - 'en', - { - entityLookups: [{ keyPath: 'topic.name', lookupKeys: ['id-1'] }], - dictLookups: [], - }, - ) - expect(result).toBe(data) - expect(result[0].name).toBe('Frontend') - expect(data[0].name).toBe('Frontend') - }) - - it('should translate document-like values in place', async () => { - const translationEntryService = { - getTranslationsBatch: vi.fn().mockResolvedValue({ - entityMaps: new Map([ - ['category.name', new Map([['id-1', 'Frontend']])], - ]), - dictMaps: new Map(), - }), - } - - const realInterceptor = new TranslationEntryInterceptor( - { get: vi.fn() } as any, - translationEntryService as any, - ) - - class CategoryDoc { - _id = 'id-1' - name = '前端' - self = this - - toJSON() { - return { - _id: this._id, - name: this.name, - } - } - } - - const data = { - categories: [new CategoryDoc()], - } - - const result = await realInterceptor['applyTranslations']( - data, - [ - { - keyPath: 'category.name', - path: 'categories[].name', - idField: '_id', - }, - ], - 'en', - ) - - expect(result.categories[0].name).toBe('Frontend') - expect(result.categories[0]._id).toBe('id-1') - }) - - it('should preserve non-structured-cloneable payloads when translating', async () => { - const translationEntryService = { - getTranslationsBatch: vi.fn().mockResolvedValue({ - entityMaps: new Map(), - dictMaps: new Map([['note.mood', new Map([['开心', 'Happy']])]]), - }), - } - - const realInterceptor = new TranslationEntryInterceptor( - { get: vi.fn() } as any, - translationEntryService as any, - ) - - const data = { - notes: [{ mood: '开心', meta: { formatter: () => 'ok' } }], - } - - const result = await realInterceptor['applyTranslations']( - data, - [{ keyPath: 'note.mood', path: 'notes[].mood' }], - 'en', - ) - - expect(result).toBe(data) - expect(result.notes[0].mood).toBe('Happy') - expect(typeof result.notes[0].meta.formatter).toBe('function') - expect(data.notes[0].mood).toBe('Happy') - }) - }) - - describe('toObjectScanPath', () => { - const normalize = interceptor['toObjectScanPath'].bind(interceptor) - - it('should normalize root array path', () => { - expect(normalize('[].name')).toBe('[*].name') - }) - - it('should normalize simple array path', () => { - expect(normalize('categories[].name')).toBe('categories[*].name') - }) - - it('should normalize nested array path', () => { - expect(normalize('data.notes[].mood')).toBe('data.notes[*].mood') - }) - - it('should preserve plain object path', () => { - expect(normalize('data.topic.name')).toBe('data.topic.name') - }) - - it('should preserve single field', () => { - expect(normalize('mood')).toBe('mood') - }) - }) - - describe('toScannableObject', () => { - const toScannableObject = - interceptor['toScannableObject']?.bind(interceptor) - - it('should keep plain objects by reference', () => { - const data = { notes: [{ mood: '开心' }] } - expect(toScannableObject(data)).toBe(data) - }) - - it('should keep arrays by reference', () => { - const data = [{ mood: '开心' }] - expect(toScannableObject(data)).toBe(data) - }) - - it('should recursively normalize document-like values nested under plain objects', () => { - class TopicDoc { - toJSON = vi.fn(() => ({ _id: 'topic-1', name: '近况' })) - } - - class NoteDoc { - toJSON = vi.fn(() => ({ - _id: 'note-1', - mood: '开心', - topic: new TopicDoc(), - })) - } - - const data = { docs: [new NoteDoc()] } - const result = toScannableObject(data) - - expect(result).not.toBe(data) - expect(result.docs[0]).toEqual({ - _id: 'note-1', - mood: '开心', - topic: { _id: 'topic-1', name: '近况' }, - }) - }) - }) - - describe('collectDictTexts', () => { - const collect = interceptor['collectDictTexts'].bind(interceptor) - - it('should collect texts from array items', () => { - const data = { notes: [{ mood: '开心' }, { mood: '' }, { mood: '伤心' }] } - const texts = new Set() - collect(data, 'notes[].mood', texts) - expect([...texts].sort()).toEqual(['伤心', '开心']) - }) - - it('should handle missing paths', () => { - const data = { posts: [] } - const texts = new Set() - collect(data, 'notes[].mood', texts) - expect(texts.size).toBe(0) - }) - }) - - describe('collectEntityIds', () => { - const collect = interceptor['collectEntityIds'].bind(interceptor) - - it('should collect ids from array items', () => { - const data = { - categories: [ - { _id: 'id-1', name: '前端' }, - { _id: 'id-2', name: '后端' }, - ], - } - const ids = new Set() - collect(data, 'categories[].name', '_id', ids) - expect([...ids].sort()).toEqual(['id-1', 'id-2']) - }) - }) - - describe('replaceDictValues', () => { - const replace = interceptor['replaceDictValues'].bind(interceptor) - - it('should replace matching values', () => { - const data = { - notes: [{ mood: '开心' }, { mood: '伤心' }, { mood: null }], - } - const map = new Map([['开心', 'Happy']]) - replace(data, 'notes[].mood', map) - expect(data.notes[0].mood).toBe('Happy') - expect(data.notes[1].mood).toBe('伤心') // no translation - expect(data.notes[2].mood).toBeNull() - }) - }) - - describe('replaceEntityValues', () => { - const replace = interceptor['replaceEntityValues'].bind(interceptor) - - it('should replace by entity id', () => { - const data = { - categories: [ - { _id: 'id-1', name: '前端' }, - { _id: 'id-2', name: '后端' }, - ], - } - const map = new Map([['id-1', 'Frontend']]) - replace(data, 'categories[].name', '_id', map) - expect(data.categories[0].name).toBe('Frontend') - expect(data.categories[1].name).toBe('后端') - }) - }) -}) diff --git a/apps/core/test/src/modules/auth/auth.controller.e2e-spec.ts b/apps/core/test/src/modules/auth/auth.controller.e2e-spec.ts index 723ae622c6b..dca04389480 100644 --- a/apps/core/test/src/modules/auth/auth.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/auth/auth.controller.e2e-spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppException } from '~/common/errors/exception.types' import { EventBusEvents } from '~/constants/event-bus.constant' import { AuthController } from '~/modules/auth/auth.controller' @@ -60,7 +60,7 @@ describe('AuthController', () => { authService.getAllAccessToken.mockResolvedValue([]) await expect(controller.deleteToken({ id: 'missing' })).rejects.toThrow( - BizException, + AppException, ) }) }) diff --git a/apps/core/test/src/modules/auth/auth.service.spec.ts b/apps/core/test/src/modules/auth/auth.service.spec.ts index c50dbfb6daf..6950bb441e5 100644 --- a/apps/core/test/src/modules/auth/auth.service.spec.ts +++ b/apps/core/test/src/modules/auth/auth.service.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppException } from '~/common/errors/exception.types' import { AuthService } from '~/modules/auth/auth.service' const createService = () => { @@ -68,7 +68,7 @@ describe('AuthService', () => { await expect( service.createAccessToken({ name: 'deploy' } as any), - ).rejects.toThrow(BizException) + ).rejects.toThrow(AppException) }) it('extracts API keys from current and deprecated request locations', () => { diff --git a/apps/core/test/src/modules/backup/backup.service.spec.ts b/apps/core/test/src/modules/backup/backup.service.spec.ts index 7bafbb7b3fa..e66b31119cf 100644 --- a/apps/core/test/src/modules/backup/backup.service.spec.ts +++ b/apps/core/test/src/modules/backup/backup.service.spec.ts @@ -2,7 +2,7 @@ import { rm } from 'node:fs/promises' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode } from '~/common/errors' import { BackupService } from '~/modules/backup/backup.service' vi.mock('~/constants/path.constant', () => ({ @@ -30,14 +30,14 @@ describe('BackupService path validation', () => { it('rejects traversal input when resolving backup files', () => { expect(() => service.checkBackupExist('../archive')).toThrowError( expect.objectContaining({ - bizCode: ErrorCodeEnum.InvalidParameter, + code: AppErrorCode.INVALID_PARAMETER, }), ) }) it('rejects traversal input when deleting backups', async () => { await expect(service.deleteBackup('../archive')).rejects.toMatchObject({ - bizCode: ErrorCodeEnum.InvalidParameter, + code: AppErrorCode.INVALID_PARAMETER, }) expect(rm).not.toHaveBeenCalled() diff --git a/apps/core/test/src/modules/category/category.controller.spec.ts b/apps/core/test/src/modules/category/category.controller.spec.ts new file mode 100644 index 00000000000..9a4327c97eb --- /dev/null +++ b/apps/core/test/src/modules/category/category.controller.spec.ts @@ -0,0 +1,487 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { TranslationEntryService } from '~/modules/ai/ai-translation/translation-entry.service' +import { CategoryController } from '~/modules/category/category.controller' +import type { CategoryService } from '~/modules/category/category.service' +import type { TranslationService } from '~/processors/helper/helper.translation.service' + +const makeEntryMaps = ( + entityMaps: Record> = {}, +) => ({ + entityMaps: new Map( + Object.entries(entityMaps).map(([k, v]) => [k, new Map(Object.entries(v))]), + ), + dictMaps: new Map>(), +}) + +const makeCollectResult = ( + translatedMap: Record = {}, +) => { + const results = new Map( + Object.entries(translatedMap).map(([id, fields]) => [ + id, + { isTranslated: true, title: fields.title }, + ]), + ) + const meta = new Map( + Object.entries(translatedMap).map(([id]) => [ + id, + { article: { isTranslated: true, sourceLang: 'zh', targetLang: 'en' } }, + ]), + ) + return { results, meta } +} + +const NOW = new Date('2024-01-01T00:00:00Z') + +const createController = ( + opts: { + categories?: any[] + posts?: Record + entryMaps?: ReturnType + collectResult?: ReturnType + categoryById?: any + } = {}, +) => { + const { + categories = [], + posts = {}, + entryMaps = makeEntryMaps(), + collectResult = makeCollectResult(), + categoryById = null, + } = opts + + const categoryService: Partial = { + findAllCategory: vi.fn().mockResolvedValue(categories), + getPostTagsSum: vi.fn().mockResolvedValue([]), + findById: vi.fn().mockResolvedValue(categoryById), + findBySlug: vi.fn().mockResolvedValue(categoryById), + findCategoryById: vi.fn().mockImplementation(async (id: string) => { + return categories.find((c) => String(c.id) === id) ?? null + }), + findCategoryPost: vi.fn().mockResolvedValue([]), + getCategoryTagsSum: vi.fn().mockResolvedValue([]), + findArticleWithTag: vi.fn().mockResolvedValue([]), + } + + const postService = { + listByCategory: vi.fn().mockImplementation(async (id: string) => { + return (posts[id] ?? []).map((p) => ({ ...p })) + }), + countByCategoryId: vi.fn().mockResolvedValue(0), + } + + const translationService: Partial = { + collectArticleTranslations: vi.fn().mockResolvedValue(collectResult), + } + + const translationEntryService: Partial = { + getTranslationsBatch: vi.fn().mockResolvedValue(entryMaps), + } + + const controller = new CategoryController( + categoryService as any, + postService as any, + translationService as any, + translationEntryService as any, + ) + + return { + controller, + categoryService, + postService, + translationService, + translationEntryService, + } +} + +describe('CategoryController', () => { + describe('GET / by type (no ids)', () => { + it('overwrites each category name in-place when entries exist', async () => { + const cats = [ + { id: 'cat-1', name: 'Original 1' }, + { id: 'cat-2', name: 'Original 2' }, + ] + const { controller } = createController({ + categories: cats, + entryMaps: makeEntryMaps({ + 'category.name': { 'cat-1': 'EN Name 1', 'cat-2': 'EN Name 2' }, + }), + }) + + const result = await controller.getCategories( + { type: undefined } as any, + 'en', + ) + + expect((result as any).data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'cat-1', name: 'EN Name 1' }), + expect.objectContaining({ id: 'cat-2', name: 'EN Name 2' }), + ]), + ) + }) + + it('preserves original name when no translation entry exists', async () => { + const cats = [{ id: 'cat-1', name: 'Original 1' }] + const { controller } = createController({ + categories: cats, + entryMaps: makeEntryMaps(), + }) + + const result = await controller.getCategories( + { type: undefined } as any, + 'en', + ) + + expect((result as any).data[0].name).toBe('Original 1') + }) + + it('emits no meta.translation block', async () => { + const cats = [{ id: 'cat-1', name: 'Original 1' }] + const { controller } = createController({ + categories: cats, + entryMaps: makeEntryMaps({ + 'category.name': { 'cat-1': 'EN Name 1' }, + }), + }) + + const result = await controller.getCategories( + { type: undefined } as any, + 'en', + ) + + expect((result as any).meta?.translation).toBeUndefined() + }) + + it('does not call getTranslationsBatch when lang is absent', async () => { + const { controller, translationEntryService } = createController({ + categories: [{ id: 'cat-1', name: 'X' }], + }) + + await controller.getCategories({ type: undefined } as any, undefined) + + expect( + translationEntryService.getTranslationsBatch, + ).not.toHaveBeenCalled() + }) + }) + + describe('GET /:query regular (no tag)', () => { + it('overwrites data.name and children[].title when translations exist', async () => { + const cat = { id: 'cat-1', name: 'Category 1' } + const children = [ + { id: 'post-1', title: 'Post 1', createdAt: NOW, modifiedAt: null }, + { id: 'post-2', title: 'Post 2', createdAt: NOW, modifiedAt: null }, + ] + + const { controller } = createController({ + categoryById: cat, + entryMaps: makeEntryMaps({ + 'category.name': { 'cat-1': 'EN Category' }, + }), + collectResult: makeCollectResult({ + 'post-1': { title: 'EN Post 1' }, + 'post-2': { title: 'EN Post 2' }, + }), + }) + + ;(controller as any).categoryService.findCategoryPost = vi + .fn() + .mockResolvedValue(children.map((c) => ({ ...c }))) + + const result = await controller.getCategoryById( + { query: 'cat-1' } as any, + {} as any, + 'en', + ) + + expect((result as any).data.name).toBe('EN Category') + expect((result as any).data.children[0].title).toBe('EN Post 1') + expect((result as any).data.children[1].title).toBe('EN Post 2') + }) + + it('emits meta.translation only for translated children', async () => { + const cat = { id: 'cat-1', name: 'Category 1' } + const children = [ + { id: 'post-1', title: 'Post 1', createdAt: NOW, modifiedAt: null }, + ] + + const { controller } = createController({ + categoryById: cat, + entryMaps: makeEntryMaps({ + 'category.name': { 'cat-1': 'EN Category' }, + }), + collectResult: makeCollectResult({ 'post-1': { title: 'EN Post 1' } }), + }) + + ;(controller as any).categoryService.findCategoryPost = vi + .fn() + .mockResolvedValue(children.map((c) => ({ ...c }))) + + const result = await controller.getCategoryById( + { query: 'cat-1' } as any, + {} as any, + 'en', + ) + + expect((result as any).meta?.translation).toBeDefined() + expect((result as any).meta.translation['post-1']).toBeDefined() + }) + + it('emits no meta.translation when no children are translated', async () => { + const cat = { id: 'cat-1', name: 'Category 1' } + + const { controller } = createController({ + categoryById: cat, + entryMaps: makeEntryMaps({ + 'category.name': { 'cat-1': 'EN Category' }, + }), + collectResult: makeCollectResult(), + }) + + const result = await controller.getCategoryById( + { query: 'cat-1' } as any, + {} as any, + 'en', + ) + + expect((result as any).meta?.translation).toBeUndefined() + }) + + it('preserves original name when no category.name entry exists', async () => { + const cat = { id: 'cat-1', name: 'Category 1' } + + const { controller } = createController({ + categoryById: cat, + entryMaps: makeEntryMaps(), + collectResult: makeCollectResult(), + }) + + const result = await controller.getCategoryById( + { query: 'cat-1' } as any, + {} as any, + 'en', + ) + + expect((result as any).data.name).toBe('Category 1') + }) + }) + + describe('GET /:query with tag=true', () => { + it('overwrites data[].title in-place when translations exist', async () => { + const tagPosts = [ + { id: 'post-1', title: 'Tag Post 1', createdAt: NOW, modifiedAt: null }, + { id: 'post-2', title: 'Tag Post 2', createdAt: NOW, modifiedAt: null }, + ] + + const { controller } = createController({ + collectResult: makeCollectResult({ + 'post-1': { title: 'EN Tag Post 1' }, + 'post-2': { title: 'EN Tag Post 2' }, + }), + }) + + ;(controller as any).categoryService.findArticleWithTag = vi + .fn() + .mockResolvedValue(tagPosts.map((p) => ({ ...p }))) + + const result = await controller.getCategoryById( + { query: 'mytag' } as any, + { tag: true } as any, + 'en', + ) + + expect((result as any).data.data[0].title).toBe('EN Tag Post 1') + expect((result as any).data.data[1].title).toBe('EN Tag Post 2') + }) + + it('emits meta.translation only for translated items', async () => { + const tagPosts = [ + { id: 'post-1', title: 'Tag Post 1', createdAt: NOW, modifiedAt: null }, + ] + + const { controller } = createController({ + collectResult: makeCollectResult({ + 'post-1': { title: 'EN Tag Post 1' }, + }), + }) + + ;(controller as any).categoryService.findArticleWithTag = vi + .fn() + .mockResolvedValue(tagPosts.map((p) => ({ ...p }))) + + const result = await controller.getCategoryById( + { query: 'mytag' } as any, + { tag: true } as any, + 'en', + ) + + expect((result as any).meta?.translation?.['post-1']).toBeDefined() + }) + + it('emits no meta.translation when no translations exist', async () => { + const tagPosts = [ + { id: 'post-1', title: 'Tag Post 1', createdAt: NOW, modifiedAt: null }, + ] + + const { controller } = createController({ + collectResult: makeCollectResult(), + }) + + ;(controller as any).categoryService.findArticleWithTag = vi + .fn() + .mockResolvedValue(tagPosts.map((p) => ({ ...p }))) + + const result = await controller.getCategoryById( + { query: 'mytag' } as any, + { tag: true } as any, + 'en', + ) + + expect((result as any).meta?.translation).toBeUndefined() + }) + + it('does not call collectArticleTranslations when lang is absent', async () => { + const { controller, translationService } = createController() + + ;(controller as any).categoryService.findArticleWithTag = vi + .fn() + .mockResolvedValue([ + { id: 'post-1', title: 'T', createdAt: NOW, modifiedAt: null }, + ]) + + await controller.getCategoryById( + { query: 'mytag' } as any, + { tag: true } as any, + undefined, + ) + + expect( + translationService.collectArticleTranslations, + ).not.toHaveBeenCalled() + }) + }) + + describe('GET / with ids query', () => { + it('translates children titles per-category and aggregates meta', async () => { + const posts: Record = { + 'cat-a': [ + { id: 'p1', title: 'Post A1', createdAt: NOW, modifiedAt: null }, + ], + 'cat-b': [ + { id: 'p2', title: 'Post B1', createdAt: NOW, modifiedAt: null }, + ], + } + const categories = [ + { id: 'cat-a', name: 'Cat A' }, + { id: 'cat-b', name: 'Cat B' }, + ] + + const { controller } = createController({ + categories, + posts, + entryMaps: makeEntryMaps({ + 'category.name': { 'cat-a': 'EN Cat A', 'cat-b': 'EN Cat B' }, + }), + collectResult: makeCollectResult({ + p1: { title: 'EN Post A1' }, + p2: { title: 'EN Post B1' }, + }), + }) + + const result = await controller.getCategories( + { ids: ['cat-a', 'cat-b'], joint: false } as any, + 'en', + ) + + const entries = (result as any).data.entries + expect(entries['cat-a'].children[0].title).toBe('EN Post A1') + expect(entries['cat-b'].children[0].title).toBe('EN Post B1') + }) + + it('also overwrites category name in non-joint mode', async () => { + const posts: Record = { + 'cat-a': [ + { id: 'p1', title: 'Post A1', createdAt: NOW, modifiedAt: null }, + ], + } + const categories = [{ id: 'cat-a', name: 'Cat A' }] + + const { controller } = createController({ + categories, + posts, + entryMaps: makeEntryMaps({ + 'category.name': { 'cat-a': 'EN Cat A' }, + }), + collectResult: makeCollectResult({ p1: { title: 'EN Post A1' } }), + }) + + const result = await controller.getCategories( + { ids: ['cat-a'], joint: false } as any, + 'en', + ) + + const entry = (result as any).data.entries['cat-a'] + expect(entry.name).toBe('EN Cat A') + }) + + it('aggregates meta.translation across all category children', async () => { + const posts: Record = { + 'cat-a': [ + { id: 'p1', title: 'Post A1', createdAt: NOW, modifiedAt: null }, + ], + 'cat-b': [ + { id: 'p2', title: 'Post B1', createdAt: NOW, modifiedAt: null }, + ], + } + const categories = [ + { id: 'cat-a', name: 'Cat A' }, + { id: 'cat-b', name: 'Cat B' }, + ] + + const { controller } = createController({ + categories, + posts, + entryMaps: makeEntryMaps(), + collectResult: makeCollectResult({ + p1: { title: 'EN Post A1' }, + p2: { title: 'EN Post B1' }, + }), + }) + + const result = await controller.getCategories( + { ids: ['cat-a', 'cat-b'], joint: false } as any, + 'en', + ) + + const translation = (result as any).meta?.translation + expect(translation).toBeDefined() + expect(translation['p1']).toBeDefined() + expect(translation['p2']).toBeDefined() + }) + + it('does not call translation services when lang is absent', async () => { + const posts: Record = { + 'cat-a': [ + { id: 'p1', title: 'Post A1', createdAt: NOW, modifiedAt: null }, + ], + } + + const { controller, translationService, translationEntryService } = + createController({ + categories: [{ id: 'cat-a', name: 'Cat A' }], + posts, + }) + + await controller.getCategories({ ids: ['cat-a'] } as any, undefined) + + expect( + translationService.collectArticleTranslations, + ).not.toHaveBeenCalled() + expect( + translationEntryService.getTranslationsBatch, + ).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/core/test/src/modules/category/category.repository.spec.ts b/apps/core/test/src/modules/category/category.repository.spec.ts index 629fafc68e5..df89438037c 100644 --- a/apps/core/test/src/modules/category/category.repository.spec.ts +++ b/apps/core/test/src/modules/category/category.repository.spec.ts @@ -1,13 +1,13 @@ -import { Pool } from 'pg' +import type { Pool } from 'pg' +import { + createPgTestDatabase, + type PgTestDatabase, +} from 'test/helper/pg-verify-url' import { posts } from '~/database/schema' import { CategoryType } from '~/modules/category/category.enum' import { CategoryRepository } from '~/modules/category/category.repository' import { SnowflakeService } from '~/shared/id/snowflake.service' -import { - createPgTestDatabase, - type PgTestDatabase, -} from 'test/helper/pg-verify-url' describe('CategoryRepository', () => { let context: PgTestDatabase diff --git a/apps/core/test/src/modules/comment/comment.controller.spec.ts b/apps/core/test/src/modules/comment/comment.controller.spec.ts index df9d752a48d..003dafd7f41 100644 --- a/apps/core/test/src/modules/comment/comment.controller.spec.ts +++ b/apps/core/test/src/modules/comment/comment.controller.spec.ts @@ -4,9 +4,8 @@ import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { RequestContext } from '~/common/contexts/request.context' -import type { BizException } from '~/common/exceptions/biz.exception' +import { AppException } from '~/common/errors/exception.types' import { AuthGuard } from '~/common/guards/auth.guard' -import { ErrorCodeEnum } from '~/constants/error-code.constant' import { AuthService } from '~/modules/auth/auth.service' import { CommentController } from '~/modules/comment/comment.controller' import { CommentLifecycleService } from '~/modules/comment/comment.lifecycle.service' @@ -114,9 +113,7 @@ describe('CommentController permission gating', () => { {} as any, { ref: undefined } as any, ), - ).rejects.toMatchObject>({ - bizCode: ErrorCodeEnum.CommentForbidden, - }) + ).rejects.toBeInstanceOf(AppException) expect(mockCommentService.createComment).not.toHaveBeenCalled() }) @@ -131,9 +128,7 @@ describe('CommentController permission gating', () => { 'reader-1', {} as any, ), - ).rejects.toMatchObject>({ - bizCode: ErrorCodeEnum.CommentForbidden, - }) + ).rejects.toBeInstanceOf(AppException) expect(mockCommentService.replyComment).not.toHaveBeenCalled() }) @@ -155,9 +150,7 @@ describe('CommentController permission gating', () => { { ref: undefined } as any, ), ), - ).resolves.toMatchObject({ - id: 'comment-created', - }) + ).resolves.toMatchObject({ id: 'comment-created' }) expect(mockCommentService.createComment).toHaveBeenCalled() }) @@ -178,9 +171,7 @@ describe('CommentController permission gating', () => { {} as any, ), ), - ).resolves.toMatchObject({ - id: 'reply-created', - }) + ).resolves.toMatchObject({ id: 'reply-created' }) expect(mockCommentService.replyComment).toHaveBeenCalled() }) diff --git a/apps/core/test/src/modules/draft/draft.service.spec.ts b/apps/core/test/src/modules/draft/draft.service.spec.ts index dcda546054a..ba9139abbbf 100644 --- a/apps/core/test/src/modules/draft/draft.service.spec.ts +++ b/apps/core/test/src/modules/draft/draft.service.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { createPgRepositoryMock, now } from '@/helper/pg-repository-mock' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppException } from '~/common/errors/exception.types' import { DraftRefType } from '~/modules/draft/draft.enum' import type { DraftRepository, @@ -130,6 +130,6 @@ describe('DraftService', () => { await expect( service.update('missing', { text: 'x' } as any), - ).rejects.toThrow(BizException) + ).rejects.toThrow(AppException) }) }) diff --git a/apps/core/test/src/modules/enrichment/enrichment-admin.controller.e2e-spec.ts b/apps/core/test/src/modules/enrichment/enrichment-admin.controller.e2e-spec.ts index dd85768c9ea..7d62904156d 100644 --- a/apps/core/test/src/modules/enrichment/enrichment-admin.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/enrichment/enrichment-admin.controller.e2e-spec.ts @@ -45,12 +45,12 @@ const baseCaptureRow = { interface ConfigState { fetchMode: 'fetch' | 'browser' - screenshotEnabled: boolean + captureEnabled: boolean } const configState: ConfigState = { fetchMode: 'fetch', - screenshotEnabled: false, + captureEnabled: false, } const enrichmentRepositoryMock = { @@ -86,7 +86,7 @@ const configsServiceMock = { openGraph: { fetchMode: configState.fetchMode, screenshot: { - enabled: configState.screenshotEnabled, + enabled: configState.captureEnabled, maxItems: 500, maxTotalBytes: 100 * 1024 * 1024, }, @@ -108,7 +108,8 @@ const providers = [ }), defineProvider({ provide: EnrichmentCaptureRepository, - useValue: captureRepositoryMock as unknown as EnrichmentCaptureRepository, + useValue: + captureRepositoryMock as unknown as EnrichmentCaptureRepository, }), defineProvider({ provide: CaptureStorageService, @@ -129,10 +130,12 @@ describe('EnrichmentController admin endpoints (e2e)', () => { beforeEach(() => { vi.clearAllMocks() configState.fetchMode = 'fetch' - configState.screenshotEnabled = false + configState.captureEnabled = false enrichmentRepositoryMock.findById.mockResolvedValue(baseRow) enrichmentRepositoryMock.clearCapture.mockResolvedValue(undefined) - captureRepositoryMock.findByEnrichmentId.mockResolvedValue(baseCaptureRow) + captureRepositoryMock.findByEnrichmentId.mockResolvedValue( + baseCaptureRow, + ) captureRepositoryMock.getQuotaUsage.mockResolvedValue({ count: 3, totalBytes: 4096, @@ -213,9 +216,11 @@ describe('EnrichmentController admin endpoints (e2e)', () => { }) expect(res.statusCode).toBe(200) const body = res.json() - expect(body.id).toBe('row-1') - expect(body.capture).toBeTruthy() - expect(body.capture.object_key).toBe('enrichment-captures/row-1.webp') + expect(body.data.id).toBe('row-1') + expect(body.data.capture).toBeTruthy() + expect(body.data.capture.object_key).toBe( + 'enrichment-captures/row-1.webp', + ) }) test('GET admin/by-id/:id 404 when missing', async () => { @@ -240,7 +245,7 @@ describe('EnrichmentController admin endpoints (e2e)', () => { expect(body.data[0].public_url).toBe( 'https://cdn.example.test/enrichment-captures/row-1.webp', ) - expect(body.pagination.total).toBe(1) + expect(body.meta.pagination.total).toBe(1) }) test('GET admin/captures returns publicUrl empty when storage unconfigured', async () => { @@ -258,7 +263,7 @@ describe('EnrichmentController admin endpoints (e2e)', () => { test('GET admin/captures/quota reflects config + usage', async () => { configState.fetchMode = 'browser' - configState.screenshotEnabled = true + configState.captureEnabled = true const res = await proxy.app.inject({ method: 'GET', url: `${apiRoutePrefix}/enrichment/admin/captures/quota`, @@ -266,11 +271,11 @@ describe('EnrichmentController admin endpoints (e2e)', () => { }) expect(res.statusCode).toBe(200) const body = res.json() - expect(body.used.count).toBe(3) - expect(body.used.total_bytes).toBe(4096) - expect(body.cap.max_items).toBe(500) - expect(body.enabled).toBe(true) - expect(body.fetch_mode).toBe('browser') + expect(body.data.used.count).toBe(3) + expect(body.data.used.total_bytes).toBe(4096) + expect(body.data.cap.max_items).toBe(500) + expect(body.data.enabled).toBe(true) + expect(body.data.fetch_mode).toBe('browser') }) test('DELETE admin/captures/:id 204 even when not present', async () => { @@ -281,12 +286,14 @@ describe('EnrichmentController admin endpoints (e2e)', () => { }) expect(res.statusCode).toBe(204) expect(captureStorageMock.delete).toHaveBeenCalledWith('row-1') - expect(enrichmentRepositoryMock.clearCapture).toHaveBeenCalledWith('row-1') + expect(enrichmentRepositoryMock.clearCapture).toHaveBeenCalledWith( + 'row-1', + ) }) test('POST admin/captures/:id/recapture 409 when fetchMode != browser', async () => { configState.fetchMode = 'fetch' - configState.screenshotEnabled = true + configState.captureEnabled = true const res = await proxy.app.inject({ method: 'POST', url: `${apiRoutePrefix}/enrichment/admin/captures/row-1/recapture`, @@ -294,12 +301,12 @@ describe('EnrichmentController admin endpoints (e2e)', () => { }) expect(res.statusCode).toBe(409) const body = res.json() - expect(body.message?.code ?? body.code).toBe('browser_mode_required') + expect(body.error.code).toBe('ENRICHMENT_BROWSER_MODE_REQUIRED') }) - test('POST admin/captures/:id/recapture 409 when screenshot disabled', async () => { + test('POST admin/captures/:id/recapture 409 when capture disabled', async () => { configState.fetchMode = 'browser' - configState.screenshotEnabled = false + configState.captureEnabled = false const res = await proxy.app.inject({ method: 'POST', url: `${apiRoutePrefix}/enrichment/admin/captures/row-1/recapture`, @@ -307,7 +314,7 @@ describe('EnrichmentController admin endpoints (e2e)', () => { }) expect(res.statusCode).toBe(409) const body = res.json() - expect(body.message?.code ?? body.code).toBe('screenshot_disabled') + expect(body.error.code).toBe('ENRICHMENT_SCREENSHOT_DISABLED') }) test('POST admin/captures/:id/recapture 404 for unknown id', async () => { @@ -320,9 +327,9 @@ describe('EnrichmentController admin endpoints (e2e)', () => { expect(res.statusCode).toBe(404) }) - test('POST admin/captures/:id/recapture happy path returns captureImage', async () => { + test('POST admin/captures/:id/recapture happy path returns capture', async () => { configState.fetchMode = 'browser' - configState.screenshotEnabled = true + configState.captureEnabled = true const captureImage = { url: 'https://cdn.example.test/enrichment-captures/row-1.webp', width: 1280, @@ -349,7 +356,7 @@ describe('EnrichmentController admin endpoints (e2e)', () => { { url: 'https://example.com/post' }, ) const body = res.json() - expect(body.url).toBe(captureImage.url) + expect(body.data.url).toBe(captureImage.url) }) test('POST admin/probe forwards useCache=true and returns result', async () => { @@ -370,8 +377,8 @@ describe('EnrichmentController admin endpoints (e2e)', () => { true, ) const body = res.json() - expect(body.cached).toBe(true) - expect(body.result.title).toBe('cached') + expect(body.data.cached).toBe(true) + expect(body.data.result.title).toBe('cached') }) test('POST admin/probe with useCache=false default', async () => { @@ -392,6 +399,6 @@ describe('EnrichmentController admin endpoints (e2e)', () => { 'https://example.com', false, ) - expect(res.json().error.code).toBe('unknown_provider') + expect(res.json().data.error.code).toBe('unknown_provider') }) }) diff --git a/apps/core/test/src/modules/file/file.controller.spec.ts b/apps/core/test/src/modules/file/file.controller.spec.ts index b20cf1b44fa..6275577827b 100644 --- a/apps/core/test/src/modules/file/file.controller.spec.ts +++ b/apps/core/test/src/modules/file/file.controller.spec.ts @@ -2,7 +2,7 @@ import { Readable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppException } from '~/common/errors/exception.types' import { FileController } from '~/modules/file/file.controller' describe('FileController', () => { @@ -92,9 +92,7 @@ describe('FileController', () => { await expect( controller.upload({ type: 'image' } as any, {} as any), - ).rejects.toMatchObject({ - bizCode: ErrorCodeEnum.ImageStorageNotConfigured, - }) + ).rejects.toThrow(AppException) expect(getAndValidMultipartField).not.toHaveBeenCalled() expect(writeFile).not.toHaveBeenCalled() diff --git a/apps/core/test/src/modules/file/file.service.spec.ts b/apps/core/test/src/modules/file/file.service.spec.ts index 3c8b1f89533..ed1c1c184b7 100644 --- a/apps/core/test/src/modules/file/file.service.spec.ts +++ b/apps/core/test/src/modules/file/file.service.spec.ts @@ -2,7 +2,7 @@ import { copyFile, mkdir, unlink } from 'node:fs/promises' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ErrorCodeEnum } from '~/constants/error-code.constant' +import { AppErrorCode } from '~/common/errors' import { FileService } from '~/modules/file/file.service' vi.mock('~/constants/path.constant', () => { @@ -84,7 +84,7 @@ describe('FileService.deleteFile', () => { await expect( service.deleteFile('image' as any, '../secret.txt'), ).rejects.toMatchObject({ - bizCode: ErrorCodeEnum.InvalidParameter, + code: AppErrorCode.INVALID_PARAMETER, }) expect(mkdir).not.toHaveBeenCalled() diff --git a/apps/core/test/src/modules/link/link.controller.e2e-spec.ts b/apps/core/test/src/modules/link/link.controller.e2e-spec.ts index 0f563c13bdd..74f5d2455bb 100644 --- a/apps/core/test/src/modules/link/link.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/link/link.controller.e2e-spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppException } from '~/common/errors/exception.types' import { LinkController, LinkControllerCrud, @@ -21,7 +21,7 @@ describe('LinkController', () => { name: 'Example', author: 'Alice', } as any), - ).rejects.toThrow(BizException) + ).rejects.toThrow(AppException) expect(service.applyForLink).not.toHaveBeenCalled() }) @@ -47,16 +47,12 @@ describe('LinkControllerCrud', () => { const repository = { list: vi.fn().mockResolvedValue({ data: [{ id: '1', email: 'owner@example.com' }], - total: 1, + pagination: { total: 1, currentPage: 1, totalPage: 1, size: 10 }, }), } const controller = new LinkControllerCrud(repository as any, {} as any) - await expect( - controller.gets({ page: 1, size: 10 } as any, false), - ).resolves.toEqual({ - data: [{ id: '1', email: null }], - total: 1, - }) + const result = await controller.gets({ page: 1, size: 10 } as any, false) + expect(result.data).toEqual([{ id: '1', email: null }]) }) }) diff --git a/apps/core/test/src/modules/note/note.controller.e2e-spec.ts b/apps/core/test/src/modules/note/note.controller.e2e-spec.ts index 1ed1dd7d79c..46a303f0d63 100644 --- a/apps/core/test/src/modules/note/note.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/note/note.controller.e2e-spec.ts @@ -2,22 +2,135 @@ import { describe, expect, it, vi } from 'vitest' import { NoteController } from '~/modules/note/note.controller' -const createController = () => { +const makeNote = (overrides: Record = {}) => ({ + id: 'note-1', + title: 'Original Title', + text: 'Original text', + content: null, + contentFormat: null, + nid: 1, + slug: null, + mood: 'happy', + weather: 'sunny', + isPublished: true, + createdAt: new Date('2026-01-01'), + modifiedAt: null, + location: null, + coordinates: null, + password: null, + topic: null, + meta: null, + ...overrides, +}) + +const buildTranslationResult = (overrides: Record = {}) => ({ + isTranslated: true, + title: 'Translated Title', + text: 'Translated text', + content: null, + contentFormat: null, + sourceLang: 'zh', + translationMeta: { + sourceLang: 'zh', + targetLang: 'en', + translatedAt: new Date('2026-01-02'), + model: 'claude-haiku', + }, + availableTranslations: ['en', 'ja'], + ...overrides, +}) + +const createController = ( + overrides: { + noteService?: Record + translationService?: Record + translationEntryService?: Record + enrichmentService?: Record + countingService?: Record + aiInsightsService?: Record + } = {}, +) => { const noteService = { create: vi.fn().mockResolvedValue({ id: 'note-1' }), updateById: vi.fn(), findOneByIdOrNid: vi.fn().mockResolvedValue({ id: 'note-1' }), deleteById: vi.fn(), publicNoteQueryCondition: { isPublished: true }, + findById: vi.fn().mockResolvedValue(makeNote()), + checkNoteIsSecret: vi.fn().mockReturnValue(false), + checkPasswordToAccess: vi.fn().mockResolvedValue(true), + findByCreatedWindow: vi.fn().mockResolvedValue([]), + getLatestOne: vi.fn().mockResolvedValue(null), + listPaginated: vi.fn().mockResolvedValue({ + data: [], + pagination: { currentPage: 1, size: 10, total: 0, totalPage: 1 }, + }), + getNotePaginationByTopicId: vi.fn().mockResolvedValue({ + data: [], + pagination: { currentPage: 1, size: 10, total: 0, totalPage: 1 }, + }), + ...overrides.noteService, + } + + const translationService = { + translateArticle: vi.fn().mockResolvedValue({ + isTranslated: false, + title: '', + text: '', + }), + translateArticleList: vi.fn().mockResolvedValue(new Map()), + collectArticleTranslations: vi.fn().mockResolvedValue({ + results: new Map(), + meta: new Map(), + }), + ...overrides.translationService, + } + + const translationEntryService = { + getTranslationsBatch: vi.fn().mockResolvedValue({ + entityMaps: new Map(), + dictMaps: new Map(), + }), + ...overrides.translationEntryService, } + + const enrichmentService = { + attachEnrichments: vi + .fn() + .mockImplementation((note: any) => + Promise.resolve({ ...note, enrichments: {} }), + ), + ...overrides.enrichmentService, + } + + const countingService = { + getThisRecordIsLiked: vi.fn().mockResolvedValue(false), + ...overrides.countingService, + } + + const aiInsightsService = { + hasInsightsInLang: vi.fn().mockResolvedValue(false), + ...overrides.aiInsightsService, + } + const controller = new NoteController( noteService as any, + countingService as any, + translationService as any, {} as any, + aiInsightsService as any, {} as any, - {} as any, - {} as any, + enrichmentService as any, + translationEntryService as any, ) - return { controller, noteService } + + return { + controller, + noteService, + translationService, + translationEntryService, + enrichmentService, + } } describe('NoteController', () => { @@ -26,9 +139,7 @@ describe('NoteController', () => { await expect( controller.create({ title: 'Note', text: 'body' } as any), - ).resolves.toEqual({ - id: 'note-1', - }) + ).resolves.toEqual({ id: 'note-1' }) expect(noteService.create).toHaveBeenCalledWith({ title: 'Note', text: 'body', @@ -61,4 +172,299 @@ describe('NoteController', () => { isPublished: false, }) }) + + describe('GET /nid/:nid?lang=en — translation in place', () => { + it('overwrites title/text/mood/weather in data and emits slim meta for current + next', async () => { + const currentNote = makeNote({ + id: 'note-current', + title: 'Original Title', + text: 'Original text', + mood: 'happy', + weather: 'sunny', + topic: { + id: 'topic-1', + name: 'Original Topic', + introduce: '', + description: '', + }, + }) + const nextNote = makeNote({ + id: 'note-next', + title: 'Next Original', + text: 'Next text', + mood: 'sad', + weather: 'rainy', + topic: { + id: 'topic-1', + name: 'Original Topic', + introduce: '', + description: '', + }, + }) + + const currentTranslationResult = buildTranslationResult({ + title: 'Translated Title', + text: 'Translated text', + }) + + const moodDictMap = new Map([['happy', 'Happy (EN)']]) + const topicNameEntityMap = new Map([['topic-1', 'Translated Topic']]) + + const { controller, enrichmentService } = createController({ + noteService: { + findByNid: vi.fn().mockResolvedValue(currentNote), + checkNoteIsSecret: vi.fn().mockReturnValue(false), + checkPasswordToAccess: vi.fn().mockResolvedValue(true), + findByCreatedWindow: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([nextNote]), + }, + translationService: { + translateArticle: vi.fn().mockResolvedValue(currentTranslationResult), + translateArticleList: vi.fn().mockResolvedValue( + new Map([ + [ + 'note-next', + { + isTranslated: true, + title: 'Next Translated', + text: 'Next translated text', + content: null, + contentFormat: null, + sourceLang: 'zh', + translationMeta: { + sourceLang: 'zh', + targetLang: 'en', + translatedAt: new Date('2026-01-02'), + model: 'claude-haiku', + }, + availableTranslations: ['en'], + }, + ], + ]), + ), + }, + translationEntryService: { + getTranslationsBatch: vi.fn().mockResolvedValue({ + entityMaps: new Map([ + ['topic.name', topicNameEntityMap], + ['topic.introduce', new Map()], + ['topic.description', new Map()], + ]), + dictMaps: new Map([ + ['note.mood', moodDictMap], + ['note.weather', new Map()], + ]), + }), + }, + }) + + const response = await controller.getNoteByNid( + { nid: 1 } as any, + false, + {} as any, + 'fake-ip', + 'en', + ) + + expect(response.data.title).toBe('Translated Title') + expect(response.data.text).toBe('Translated text') + expect(response.data.mood).toBe('Happy (EN)') + expect(response.data.topic.name).toBe('Translated Topic') + + expect(response.data.next.title).toBe('Next Translated') + + expect( + response.meta.translation['note-current'].article.isTranslated, + ).toBe(true) + expect(response.meta.translation['note-current'].article.sourceLang).toBe( + 'zh', + ) + expect(response.meta.translation['note-current'].article.targetLang).toBe( + 'en', + ) + + expect(response.meta.translation['note-next'].article.isTranslated).toBe( + true, + ) + + expect( + response.meta.translation['note-current'].article, + ).not.toHaveProperty('title') + expect( + response.meta.translation['note-current'].article, + ).not.toHaveProperty('text') + + const enrichedArg = (enrichmentService.attachEnrichments as any).mock + .calls[0][0] + expect(enrichedArg.title).toBe('Translated Title') + }) + + it('enrichment is called with already-translated data (pipeline order)', async () => { + const note = makeNote({ id: 'note-order', title: 'ZH', text: 'ZH text' }) + const translationResult = buildTranslationResult({ + title: 'EN Title', + text: 'EN text', + }) + + const { controller, enrichmentService } = createController({ + noteService: { + findByNid: vi.fn().mockResolvedValue(note), + checkNoteIsSecret: vi.fn().mockReturnValue(false), + checkPasswordToAccess: vi.fn().mockResolvedValue(true), + findByCreatedWindow: vi.fn().mockResolvedValue([]), + }, + translationService: { + translateArticle: vi.fn().mockResolvedValue(translationResult), + translateArticleList: vi.fn().mockResolvedValue(new Map()), + }, + }) + + await controller.getNoteByNid( + { nid: 1 } as any, + false, + {} as any, + 'fake-ip', + 'en', + ) + + const enrichedNote = (enrichmentService.attachEnrichments as any).mock + .calls[0][0] + expect(enrichedNote.title).toBe('EN Title') + expect(enrichedNote.text).toBe('EN text') + }) + }) + + describe('GET /latest?lang=en — translation in place for latest + next', () => { + it('translates latest and next in place with slim meta for both', async () => { + const latestNote = makeNote({ + id: 'note-latest', + title: 'Latest ZH', + text: 'latest text', + }) + const nextNote = makeNote({ + id: 'note-next-latest', + title: 'Next ZH', + text: 'next text', + }) + + const latestTranslation = buildTranslationResult({ + title: 'Latest EN', + text: 'latest translated', + }) + const nextTranslation = buildTranslationResult({ + title: 'Next EN', + text: 'next translated', + }) + + const { controller } = createController({ + noteService: { + getLatestOne: vi + .fn() + .mockResolvedValue({ latest: latestNote, next: nextNote }), + checkNoteIsSecret: vi.fn().mockReturnValue(false), + }, + translationService: { + translateArticle: vi + .fn() + .mockResolvedValueOnce(latestTranslation) + .mockResolvedValueOnce(nextTranslation), + }, + }) + + const response = await controller.getLatestOne(false, 'en') + + expect(response.data.title).toBe('Latest EN') + expect(response.data.text).toBe('latest translated') + expect(response.data.next.title).toBe('Next EN') + expect(response.data.next.text).toBe('next translated') + + expect( + response.meta.translation['note-latest'].article.isTranslated, + ).toBe(true) + expect( + response.meta.translation['note-next-latest'].article.isTranslated, + ).toBe(true) + + expect( + response.meta.translation['note-latest'].article, + ).not.toHaveProperty('title') + }) + }) + + describe('GET /?lang=en — list translation in place', () => { + it('emits meta.translation only for translated items', async () => { + const doc1 = makeNote({ id: 'list-1', title: 'ZH 1', text: 'text 1' }) + const doc2 = makeNote({ id: 'list-2', title: 'ZH 2', text: 'text 2' }) + const doc3 = makeNote({ id: 'list-3', title: 'ZH 3', text: 'text 3' }) + + const translationMeta = new Map([ + [ + 'list-1', + { + article: { + isTranslated: true, + sourceLang: 'zh', + targetLang: 'en', + availableTranslations: [], + }, + }, + ], + [ + 'list-2', + { + article: { + isTranslated: true, + sourceLang: 'zh', + targetLang: 'en', + availableTranslations: [], + }, + }, + ], + ]) + + const translationResults = new Map([ + [ + 'list-1', + { isTranslated: true, title: 'EN 1', text: 'translated text 1' }, + ], + [ + 'list-2', + { isTranslated: true, title: 'EN 2', text: 'translated text 2' }, + ], + ['list-3', { isTranslated: false, title: 'ZH 3', text: 'text 3' }], + ]) + + const { controller } = createController({ + noteService: { + listPaginated: vi.fn().mockResolvedValue({ + data: [doc1, doc2, doc3], + pagination: { currentPage: 1, size: 10, total: 3, totalPage: 1 }, + }), + }, + translationService: { + collectArticleTranslations: vi.fn().mockResolvedValue({ + results: translationResults, + meta: translationMeta, + }), + }, + }) + + const response = await controller.getNotes( + false, + { page: 1, size: 10 } as any, + 'en', + ) + + expect(response.data[0].title).toBe('EN 1') + expect(response.data[1].title).toBe('EN 2') + expect(response.data[2].title).toBe('ZH 3') + + expect(Object.keys(response.meta.translation)).toHaveLength(2) + expect(response.meta.translation['list-1']).toBeDefined() + expect(response.meta.translation['list-2']).toBeDefined() + expect(response.meta.translation['list-3']).toBeUndefined() + }) + }) }) diff --git a/apps/core/test/src/modules/note/note.repository.spec.ts b/apps/core/test/src/modules/note/note.repository.spec.ts index b626d3133f9..12cf3fd0602 100644 --- a/apps/core/test/src/modules/note/note.repository.spec.ts +++ b/apps/core/test/src/modules/note/note.repository.spec.ts @@ -1,13 +1,13 @@ -import { Pool } from 'pg' +import type { Pool } from 'pg' +import { + createPgTestDatabase, + type PgTestDatabase, +} from 'test/helper/pg-verify-url' import { notes } from '~/database/schema' import { NoteRepository } from '~/modules/note/note.repository' import { SnowflakeService } from '~/shared/id/snowflake.service' import { ContentFormat } from '~/shared/types/content-format.type' -import { - createPgTestDatabase, - type PgTestDatabase, -} from 'test/helper/pg-verify-url' describe('NoteRepository', () => { let context: PgTestDatabase diff --git a/apps/core/test/src/modules/options/options.controller.e2e-spec.ts b/apps/core/test/src/modules/options/options.controller.e2e-spec.ts index cf7db145a3b..4ca096e3397 100644 --- a/apps/core/test/src/modules/options/options.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/options/options.controller.e2e-spec.ts @@ -1,9 +1,10 @@ -import { apiRoutePrefix } from '~/common/decorators/api-controller.decorator' -import { BaseOptionController } from '~/modules/option/controllers/base.option.controller' import { createE2EApp } from 'test/helper/create-e2e-app' import { authPassHeader } from 'test/mock/guard/auth.guard' import { configProvider } from 'test/mock/modules/config.mock' +import { apiRoutePrefix } from '~/common/decorators/api-controller.decorator' +import { BaseOptionController } from '~/modules/option/controllers/base.option.controller' + describe('OptionController (e2e)', () => { const proxy = createE2EApp({ controllers: [BaseOptionController], @@ -22,8 +23,12 @@ describe('OptionController (e2e)', () => { expect(res.statusCode).toBe(200) const json = res.json() - expect(typeof json.groups === 'object' && json.groups).toBeTruthy() - expect(typeof json.defaults === 'object' && json.defaults).toBeTruthy() + expect( + typeof json.data.groups === 'object' && json.data.groups, + ).toBeTruthy() + expect( + typeof json.data.defaults === 'object' && json.data.defaults, + ).toBeTruthy() }) }) }) diff --git a/apps/core/test/src/modules/page/page.controller.spec.ts b/apps/core/test/src/modules/page/page.controller.spec.ts new file mode 100644 index 00000000000..23cfa01aeb6 --- /dev/null +++ b/apps/core/test/src/modules/page/page.controller.spec.ts @@ -0,0 +1,370 @@ +import { describe, expect, it, vi } from 'vitest' + +import { PageController } from '~/modules/page/page.controller' + +const makePage = (overrides: Record = {}) => ({ + id: '8000000000000001001', + title: 'Original title', + text: 'original body', + subtitle: 'original subtitle', + content: null, + contentFormat: null, + meta: null, + modifiedAt: null, + createdAt: new Date('2024-01-01'), + slug: 'test-page', + order: 1, + ...overrides, +}) + +const makeTranslationResult = (overrides: Record = {}) => ({ + isTranslated: false, + title: 'Original title', + text: 'original body', + subtitle: 'original subtitle', + sourceLang: 'zh', + availableTranslations: ['en'], + ...overrides, +}) + +type CreateControllerOptions = { + pages?: Record[] + translateArticleFn?: () => Promise> + collectArticleTranslationsFn?: () => Promise<{ + results: Map + meta: Map + }> + attachEnrichmentsFn?: ( + doc: Record, + ) => Promise<{ enrichments: Record }> +} + +const createController = (opts: CreateControllerOptions = {}) => { + const { + pages = [], + translateArticleFn = async () => makeTranslationResult(), + collectArticleTranslationsFn = async () => ({ + results: new Map(), + meta: new Map(), + }), + attachEnrichmentsFn = async (doc: Record) => ({ + enrichments: {}, + ...doc, + }), + } = opts + + const pageService = { + listPaginated: vi.fn(async () => ({ + data: pages, + pagination: { + total: pages.length, + currentPage: 1, + totalPage: 1, + size: 10, + }, + })), + findBySlug: vi.fn(async () => pages[0] ?? null), + findById: vi.fn(async () => pages[0] ?? null), + } + + const translationService = { + translateArticle: vi.fn(translateArticleFn), + collectArticleTranslations: vi.fn(collectArticleTranslationsFn), + } + + const enrichmentService = { + attachEnrichments: vi.fn(attachEnrichmentsFn), + } + + const controller = new PageController( + pageService as any, + translationService as any, + enrichmentService as any, + ) + + return { + controller, + pageService, + translationService, + enrichmentService, + } +} + +describe('PageController.getPageBySlug', () => { + it('overwrites title, text, subtitle, content in place and emits slim translation meta', async () => { + const page = makePage() + const pageId = String(page.id) + + const translationResult = { + isTranslated: true, + title: 'Translated title', + text: 'translated body', + subtitle: 'translated subtitle', + content: 'translated content', + contentFormat: 'markdown', + sourceLang: 'zh', + translationMeta: { + sourceLang: 'zh', + targetLang: 'en', + translatedAt: new Date('2024-06-01'), + model: 'claude-haiku', + }, + availableTranslations: ['en'], + } + + const { controller } = createController({ + pages: [page], + translateArticleFn: async () => translationResult, + }) + + const res = await controller.getPageBySlug('test-page', {} as any, 'en') + + expect(res.data.title).toBe('Translated title') + expect(res.data.text).toBe('translated body') + expect((res.data as any).subtitle).toBe('translated subtitle') + expect((res.data as any).content).toBe('translated content') + + const tm = res.meta?.translation as any + const pageTm = tm?.[pageId] + expect(pageTm?.article?.isTranslated).toBe(true) + expect(pageTm?.article?.sourceLang).toBe('zh') + expect(pageTm?.article?.targetLang).toBe('en') + expect(pageTm?.article?.title).toBeUndefined() + expect(pageTm?.article?.text).toBeUndefined() + expect(pageTm?.article?.subtitle).toBeUndefined() + expect(pageTm?.article?.content).toBeUndefined() + expect(pageTm?.article?.contentFormat).toBeUndefined() + }) + + it('always emits meta.translation even when not translated', async () => { + const page = makePage() + const pageId = String(page.id) + + const { controller } = createController({ + pages: [page], + translateArticleFn: async () => + makeTranslationResult({ isTranslated: false }), + }) + + const res = await controller.getPageBySlug('test-page', {} as any, 'en') + + const tm = res.meta?.translation as any + expect(tm?.[pageId]).toBeDefined() + expect(tm?.[pageId]?.article?.isTranslated).toBe(false) + }) + + it('calls enrichmentService with already-translated content', async () => { + const page = makePage() + + const translationResult = { + isTranslated: true, + title: 'Translated', + text: 'translated body for enrichment check', + subtitle: 'translated subtitle', + content: 'translated content for enrichment check', + contentFormat: 'markdown', + sourceLang: 'zh', + translationMeta: { + sourceLang: 'zh', + targetLang: 'en', + translatedAt: new Date(), + model: 'claude-haiku', + }, + availableTranslations: ['en'], + } + + const attachEnrichmentsSpy = vi.fn( + async (doc: Record) => ({ + enrichments: {}, + ...doc, + }), + ) + + const { controller } = createController({ + pages: [page], + translateArticleFn: async () => translationResult, + attachEnrichmentsFn: attachEnrichmentsSpy, + }) + + await controller.getPageBySlug('test-page', {} as any, 'en') + + expect(attachEnrichmentsSpy).toHaveBeenCalledTimes(1) + const calledWith = attachEnrichmentsSpy.mock.calls[0][0] as Record< + string, + unknown + > + expect(calledWith.text).toBe('translated body for enrichment check') + expect(calledWith.content).toBe('translated content for enrichment check') + }) +}) + +describe('PageController.getPagesSummary', () => { + it('overwrites title, text, subtitle, content per item when translated', async () => { + const page = makePage() + const pageId = String(page.id) + + const translatedResult = { + isTranslated: true, + title: 'Translated title', + text: 'translated body', + subtitle: 'translated subtitle', + content: 'translated content', + contentFormat: 'markdown', + sourceLang: 'zh', + translationMeta: { + sourceLang: 'zh', + targetLang: 'en', + translatedAt: new Date('2024-06-01'), + model: 'claude-haiku', + }, + availableTranslations: ['en'], + } + + const translationResults = new Map([[pageId, translatedResult]]) + const translationMeta = new Map([ + [ + pageId, + { + article: { + isTranslated: true, + sourceLang: 'zh', + targetLang: 'en', + translatedAt: new Date('2024-06-01'), + model: 'claude-haiku', + availableTranslations: ['en'], + }, + }, + ], + ]) + + const { controller } = createController({ + pages: [page], + collectArticleTranslationsFn: async () => ({ + results: translationResults as any, + meta: translationMeta as any, + }), + }) + + const res = await controller.getPagesSummary({} as any, 'en') + + expect(res.data[0].title).toBe('Translated title') + expect(res.data[0].text).toBe('translated body') + expect((res.data[0] as any).subtitle).toBe('translated subtitle') + expect((res.data[0] as any).content).toBe('translated content') + + const tm = res.meta?.translation as any + expect(tm?.[pageId]?.article?.isTranslated).toBe(true) + expect(tm?.[pageId]?.article?.title).toBeUndefined() + expect(tm?.[pageId]?.article?.text).toBeUndefined() + expect(tm?.[pageId]?.article?.subtitle).toBeUndefined() + expect(tm?.[pageId]?.article?.content).toBeUndefined() + }) + + it('emits meta.translation only for translated items in mixed list', async () => { + const translatedPage = makePage({ id: '8000000000000001001', title: 'A' }) + const untranslatedPage = makePage({ id: '8000000000000001002', title: 'B' }) + + const translationResults = new Map([ + [ + String(translatedPage.id), + { + isTranslated: true, + title: 'A translated', + text: 'body', + subtitle: 'sub', + sourceLang: 'zh', + availableTranslations: [], + }, + ], + [ + String(untranslatedPage.id), + { + isTranslated: false, + title: 'B', + text: 'body', + subtitle: 'sub', + sourceLang: 'zh', + availableTranslations: [], + }, + ], + ]) + + const translationMeta = new Map([ + [ + String(translatedPage.id), + { + article: { + isTranslated: true, + sourceLang: 'zh', + targetLang: 'en', + availableTranslations: [], + }, + }, + ], + ]) + + const { controller } = createController({ + pages: [translatedPage, untranslatedPage], + collectArticleTranslationsFn: async () => ({ + results: translationResults as any, + meta: translationMeta as any, + }), + }) + + const res = await controller.getPagesSummary({} as any, 'en') + + const tm = res.meta?.translation as any + expect(tm?.[String(translatedPage.id)]).toBeDefined() + expect(tm?.[String(untranslatedPage.id)]).toBeUndefined() + + expect(res.data[0].title).toBe('A translated') + expect(res.data[1].title).toBe('B') + }) + + it('no title/text/content/subtitle appear at any meta path', async () => { + const page = makePage() + const pageId = String(page.id) + + const translationMeta = new Map([ + [ + pageId, + { + article: { + isTranslated: true, + sourceLang: 'zh', + targetLang: 'en', + availableTranslations: [], + }, + }, + ], + ]) + + const { controller } = createController({ + pages: [page], + collectArticleTranslationsFn: async () => ({ + results: new Map([ + [ + pageId, + { + isTranslated: true, + title: 'T', + text: 't', + subtitle: 's', + sourceLang: 'zh', + availableTranslations: [], + }, + ], + ]) as any, + meta: translationMeta as any, + }), + }) + + const res = await controller.getPagesSummary({} as any, 'en') + + const metaStr = JSON.stringify(res.meta?.translation ?? {}) + expect(metaStr).not.toContain('"title"') + expect(metaStr).not.toContain('"text"') + expect(metaStr).not.toContain('"content"') + expect(metaStr).not.toContain('"subtitle"') + }) +}) diff --git a/apps/core/test/src/modules/post/post.controller.e2e-spec.ts b/apps/core/test/src/modules/post/post.controller.e2e-spec.ts index d6edad9ff01..234233365ff 100644 --- a/apps/core/test/src/modules/post/post.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/post/post.controller.e2e-spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { CannotFindException } from '~/common/exceptions/cant-find.exception' +import { AppException } from '~/common/errors/exception.types' import { PostController } from '~/modules/post/post.controller' const createController = () => { @@ -38,7 +38,7 @@ describe('PostController', () => { postService.findById.mockResolvedValue({ id: 'post-1', isPublished: false }) await expect(controller.getById({ id: 'post-1' }, false)).rejects.toThrow( - CannotFindException, + AppException, ) }) diff --git a/apps/core/test/src/modules/post/post.controller.spec.ts b/apps/core/test/src/modules/post/post.controller.spec.ts index e21bf2783be..b8c30c6a778 100644 --- a/apps/core/test/src/modules/post/post.controller.spec.ts +++ b/apps/core/test/src/modules/post/post.controller.spec.ts @@ -4,34 +4,127 @@ import { PostController } from '~/modules/post/post.controller' const lexicalContent = JSON.stringify({ root: { children: [] } }) -const createController = (posts: Record[]) => { +const makePost = (overrides: Record = {}) => ({ + id: '7000000000000000060', + title: 'Original title', + text: 'original body', + content: lexicalContent, + contentFormat: 'lexical', + summary: null, + tags: [], + meta: null, + modifiedAt: null, + createdAt: new Date('2024-01-01'), + category: { id: '9000000000000000001', name: 'Tech', slug: 'tech', type: 1 }, + isPublished: true, + ...overrides, +}) + +const makeTranslationResult = (overrides: Record = {}) => ({ + isTranslated: false, + title: 'Original title', + text: 'original body', + summary: null, + tags: [], + sourceLang: 'zh', + availableTranslations: ['en', 'ja'], + ...overrides, +}) + +const makeEmptyEntryMaps = () => ({ + entityMaps: new Map(), + dictMaps: new Map(), +}) + +type CreateControllerOptions = { + posts?: Record[] + translateArticleFn?: () => Promise> + collectArticleTranslationsFn?: () => Promise<{ + results: Map + meta: Map + }> + attachEnrichmentsFn?: ( + doc: Record, + ) => Promise<{ enrichments: Record }> + getTranslationsBatchFn?: () => Promise> + getCachedTitlesFn?: () => Promise> +} + +const createController = (opts: CreateControllerOptions = {}) => { + const { + posts = [], + translateArticleFn = async () => makeTranslationResult(), + collectArticleTranslationsFn = async () => ({ + results: new Map(), + meta: new Map(), + }), + attachEnrichmentsFn = async (doc: Record) => ({ + enrichments: {}, + ...doc, + }), + getTranslationsBatchFn = async () => makeEmptyEntryMaps(), + getCachedTitlesFn = async () => new Map(), + } = opts + const postService = { listPaginated: vi.fn(async () => ({ data: posts, - pagination: { total: posts.length }, + pagination: { + total: posts.length, + currentPage: 1, + totalPage: 1, + size: 10, + }, })), + getPostBySlug: vi.fn(async () => posts[0] ?? null), + findById: vi.fn(async () => posts[0] ?? null), + } + + const translationService = { + translateArticle: vi.fn(translateArticleFn), + collectArticleTranslations: vi.fn(collectArticleTranslationsFn), + getCachedTitles: vi.fn(getCachedTitlesFn), + } + + const enrichmentService = { + attachEnrichments: vi.fn(attachEnrichmentsFn), + } + + const translationEntryService = { + getTranslationsBatch: vi.fn(getTranslationsBatchFn), + } + + const countingService = { + getThisRecordIsLiked: vi.fn(async () => false), + } + + const aiInsightsService = { + hasInsightsInLang: vi.fn(async () => false), } + const controller = new PostController( postService as any, - {} as any, - {} as any, - {} as any, - {} as any, + countingService as any, + translationService as any, + aiInsightsService as any, + enrichmentService as any, + translationEntryService as any, ) - return { controller } + + return { + controller, + postService, + translationService, + enrichmentService, + translationEntryService, + } } describe('PostController.getPaginate', () => { it('drops lexical content and truncates text when truncate is set', async () => { - const { controller } = createController([ - { - id: '7000000000000000060', - title: 'P', - text: 'x'.repeat(500), - content: lexicalContent, - contentFormat: 'lexical', - }, - ]) + const { controller } = createController({ + posts: [makePost({ text: 'x'.repeat(500), content: lexicalContent })], + }) const res = await controller.getPaginate({ truncate: 150 } as any, false) @@ -40,19 +133,260 @@ describe('PostController.getPaginate', () => { }) it('keeps content intact when truncate is absent', async () => { - const { controller } = createController([ - { - id: '7000000000000000060', - title: 'P', - text: 'short body', - content: lexicalContent, - contentFormat: 'lexical', - }, - ]) + const { controller } = createController({ + posts: [makePost()], + }) const res = await controller.getPaginate({} as any, false) - expect(res.data[0].text).toBe('short body') + expect(res.data[0].text).toBe('original body') expect(res.data[0].content).toBe(lexicalContent) }) + + it('overwrites title, text, content, category.name in place when translated', async () => { + const post = makePost() + + const translatedResult = { + isTranslated: true, + title: 'Translated title', + text: 'translated body', + content: 'translated content', + contentFormat: 'markdown', + summary: null, + tags: [], + sourceLang: 'zh', + translationMeta: { + sourceLang: 'zh', + targetLang: 'en', + translatedAt: new Date('2024-06-01'), + model: 'claude-haiku', + }, + availableTranslations: ['en'], + } + + const translationResults = new Map([[String(post.id), translatedResult]]) + const translationMeta = new Map([ + [ + String(post.id), + { + article: { + isTranslated: true, + sourceLang: 'zh', + targetLang: 'en', + translatedAt: new Date('2024-06-01'), + model: 'claude-haiku', + availableTranslations: ['en'], + }, + }, + ], + ]) + + const entityMap = new Map([[String(post.category!.id), 'Technology']]) + + const { controller, enrichmentService } = createController({ + posts: [post], + collectArticleTranslationsFn: async () => ({ + results: translationResults as any, + meta: translationMeta as any, + }), + getTranslationsBatchFn: async () => ({ + entityMaps: new Map([['category.name', entityMap]]), + dictMaps: new Map(), + }), + }) + + const res = await controller.getPaginate({} as any, false, 'en') + + expect(res.data[0].title).toBe('Translated title') + expect(res.data[0].text).toBe('translated body') + expect(res.data[0].content).toBe('translated content') + expect((res.data[0] as any).category.name).toBe('Technology') + + expect(res.meta?.translation).toBeDefined() + const translationBlock = (res.meta?.translation as any)?.[String(post.id)] + expect(translationBlock?.article?.isTranslated).toBe(true) + expect(translationBlock?.article?.sourceLang).toBe('zh') + expect(translationBlock?.article?.targetLang).toBe('en') + expect(translationBlock?.article?.title).toBeUndefined() + expect(translationBlock?.article?.text).toBeUndefined() + expect(translationBlock?.article?.content).toBeUndefined() + + expect(enrichmentService.attachEnrichments).not.toHaveBeenCalled() + }) + + it('emits meta.translation only for translated items', async () => { + const translatedPost = makePost({ id: '7000000000000000061', title: 'A' }) + const untranslatedPost = makePost({ id: '7000000000000000062', title: 'B' }) + + const translationResults = new Map([ + [ + String(translatedPost.id), + { + isTranslated: true, + title: 'A translated', + text: 'body', + sourceLang: 'zh', + availableTranslations: [], + }, + ], + [ + String(untranslatedPost.id), + { + isTranslated: false, + title: 'B', + text: 'body', + sourceLang: 'zh', + availableTranslations: [], + }, + ], + ]) + + const translationMeta = new Map([ + [ + String(translatedPost.id), + { + article: { + isTranslated: true, + sourceLang: 'zh', + targetLang: 'en', + availableTranslations: [], + }, + }, + ], + ]) + + const { controller } = createController({ + posts: [translatedPost, untranslatedPost], + collectArticleTranslationsFn: async () => ({ + results: translationResults as any, + meta: translationMeta as any, + }), + }) + + const res = await controller.getPaginate({} as any, false, 'en') + + const tm = res.meta?.translation as any + expect(tm?.[String(translatedPost.id)]).toBeDefined() + expect(tm?.[String(untranslatedPost.id)]).toBeUndefined() + + expect(res.data[0].title).toBe('A translated') + expect(res.data[1].title).toBe('B') + }) +}) + +describe('PostController.getByCateAndSlug', () => { + it('overwrites title, text, content, category.name in place and emits slim meta', async () => { + const post = makePost() + + const translationResult = { + isTranslated: true, + title: 'Translated post title', + text: 'translated post body', + content: 'translated post content', + contentFormat: 'markdown', + summary: null, + tags: [], + sourceLang: 'zh', + translationMeta: { + sourceLang: 'zh', + targetLang: 'en', + translatedAt: new Date('2024-06-01'), + model: 'claude-haiku', + }, + availableTranslations: ['en'], + } + + const entityMap = new Map([[String(post.category!.id), 'Technology']]) + + const attachEnrichmentsSpy = vi.fn( + async (doc: Record) => ({ + enrichments: {}, + ...doc, + }), + ) + + const { controller } = createController({ + posts: [post], + translateArticleFn: async () => translationResult, + getTranslationsBatchFn: async () => ({ + entityMaps: new Map([['category.name', entityMap]]), + dictMaps: new Map(), + }), + attachEnrichmentsFn: attachEnrichmentsSpy, + }) + + const res = await controller.getByCateAndSlug( + { category: 'tech', slug: 'post' }, + {} as any, + { ip: '127.0.0.1' } as any, + false, + 'en', + ) + + expect(res.data.title).toBe('Translated post title') + expect(res.data.text).toBe('translated post body') + expect(res.data.content).toBe('translated post content') + expect((res.data as any).category?.name).toBe('Technology') + + const tm = res.meta?.translation as any + const postTm = tm?.[String(post.id)] + expect(postTm?.article?.isTranslated).toBe(true) + expect(postTm?.article?.sourceLang).toBe('zh') + expect(postTm?.article?.targetLang).toBe('en') + expect(postTm?.article?.title).toBeUndefined() + expect(postTm?.article?.text).toBeUndefined() + expect(postTm?.article?.content).toBeUndefined() + expect(postTm?.article?.contentFormat).toBeUndefined() + }) + + it('calls enrichmentService.attachEnrichments with already-translated content', async () => { + const post = makePost() + + const translationResult = { + isTranslated: true, + title: 'Translated', + text: 'translated body for enrichment check', + content: 'translated content for enrichment check', + contentFormat: 'markdown', + summary: null, + tags: [], + sourceLang: 'zh', + translationMeta: { + sourceLang: 'zh', + targetLang: 'en', + translatedAt: new Date(), + model: 'claude-haiku', + }, + availableTranslations: ['en'], + } + + const attachEnrichmentsSpy = vi.fn( + async (doc: Record) => ({ + enrichments: {}, + ...doc, + }), + ) + + const { controller } = createController({ + posts: [post], + translateArticleFn: async () => translationResult, + attachEnrichmentsFn: attachEnrichmentsSpy, + }) + + await controller.getByCateAndSlug( + { category: 'tech', slug: 'post' }, + {} as any, + { ip: '127.0.0.1' } as any, + false, + 'en', + ) + + expect(attachEnrichmentsSpy).toHaveBeenCalledTimes(1) + const calledWith = attachEnrichmentsSpy.mock.calls[0][0] as Record< + string, + unknown + > + expect(calledWith.text).toBe('translated body for enrichment check') + expect(calledWith.content).toBe('translated content for enrichment check') + }) }) diff --git a/apps/core/test/src/modules/post/post.service.spec.ts b/apps/core/test/src/modules/post/post.service.spec.ts index c3432028193..6c48ea51b99 100644 --- a/apps/core/test/src/modules/post/post.service.spec.ts +++ b/apps/core/test/src/modules/post/post.service.spec.ts @@ -1,10 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { createPgRepositoryMock, now } from '@/helper/pg-repository-mock' -import { - BizException, - BusinessException, -} from '~/common/exceptions/biz.exception' +import { AppException } from '~/common/errors/exception.types' import { ArticleTypeEnum } from '~/constants/article.constant' import { CATEGORY_SERVICE_TOKEN, @@ -138,7 +135,7 @@ describe('PostService', () => { text: 'body', categoryId: 'cat-1', } as any), - ).rejects.toThrow(BusinessException) + ).rejects.toThrow(AppException) expect(repository.create).not.toHaveBeenCalled() }) @@ -214,6 +211,6 @@ describe('PostService', () => { await expect( service.checkRelated({ relatedId: ['missing'] } as any), - ).rejects.toThrow(BizException) + ).rejects.toThrow(AppException) }) }) diff --git a/apps/core/test/src/modules/recently/recently.controller.e2e-spec.ts b/apps/core/test/src/modules/recently/recently.controller.e2e-spec.ts index 54fc4ff6432..49201333eb3 100644 --- a/apps/core/test/src/modules/recently/recently.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/recently/recently.controller.e2e-spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppException } from '~/common/errors/exception.types' import { RecentlyController } from '~/modules/recently/recently.controller' const createController = () => { @@ -23,7 +23,7 @@ describe('RecentlyController', () => { await expect( controller.getList({ before: 'b', after: 'a', size: 10 } as any), - ).rejects.toThrow(BizException) + ).rejects.toThrow(AppException) expect(service.getOffset).not.toHaveBeenCalled() }) diff --git a/apps/core/test/src/modules/search/search.controller.spec.ts b/apps/core/test/src/modules/search/search.controller.spec.ts new file mode 100644 index 00000000000..48a06c2b9f8 --- /dev/null +++ b/apps/core/test/src/modules/search/search.controller.spec.ts @@ -0,0 +1,296 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { TranslationEntryService } from '~/modules/ai/ai-translation/translation-entry.service' +import { SearchController } from '~/modules/search/search.controller' +import type { SearchService } from '~/modules/search/search.service' + +const makeEntryMaps = ( + entityMaps: Record> = {}, +) => ({ + entityMaps: new Map( + Object.entries(entityMaps).map(([k, v]) => [k, new Map(Object.entries(v))]), + ), + dictMaps: new Map>(), +}) + +const makePagination = (total = 0) => ({ + total, + currentPage: 1, + totalPage: 1, + size: 10, + hasNextPage: false, + hasPrevPage: false, +}) + +const createController = ( + opts: { + searchResult?: { data: any[]; pagination: any } + entryMaps?: ReturnType + } = {}, +) => { + const { + searchResult = { data: [], pagination: makePagination() }, + entryMaps = makeEntryMaps(), + } = opts + + const searchService: Partial = { + search: vi.fn().mockResolvedValue({ ...searchResult }), + searchPost: vi.fn().mockResolvedValue({ ...searchResult }), + searchNote: vi.fn().mockResolvedValue({ ...searchResult }), + searchPage: vi.fn().mockResolvedValue({ ...searchResult }), + } + + const translationEntryService: Partial = { + getTranslationsBatch: vi.fn().mockResolvedValue(entryMaps), + } + + const controller = new SearchController( + searchService as any, + translationEntryService as any, + ) + + return { controller, searchService, translationEntryService } +} + +describe('SearchController', () => { + describe('GET /', () => { + it('translates category.name on post items when lang is set', async () => { + const items = [ + { + type: 'post', + id: 'p1', + title: 'Post 1', + category: { id: 'cat-1', name: 'Original' }, + }, + { type: 'note', id: 'n1', title: 'Note 1' }, + { type: 'page', id: 'pg1', title: 'Page 1' }, + { type: 'post', id: 'p2', title: 'Post 2', category: null }, + ] + + const { controller } = createController({ + searchResult: { + data: items.map((i) => ({ + ...i, + category: i.category ? { ...i.category } : i.category, + })), + pagination: makePagination(4), + }, + entryMaps: makeEntryMaps({ + 'category.name': { 'cat-1': 'EN Category' }, + }), + }) + + const result = await controller.search({} as any, 'en') + + expect((result as any).data[0].category.name).toBe('EN Category') + }) + + it('leaves note and page items untouched', async () => { + const items = [ + { + type: 'post', + id: 'p1', + title: 'Post', + category: { id: 'cat-1', name: 'Original' }, + }, + { type: 'note', id: 'n1', title: 'Note' }, + { type: 'page', id: 'pg1', title: 'Page' }, + ] + + const { controller } = createController({ + searchResult: { + data: items.map((i) => ({ ...i })), + pagination: makePagination(3), + }, + entryMaps: makeEntryMaps({ + 'category.name': { 'cat-1': 'EN Category' }, + }), + }) + + const result = await controller.search({} as any, 'en') + + expect((result as any).data[1]).not.toHaveProperty('category') + expect((result as any).data[2]).not.toHaveProperty('category') + }) + + it('leaves items without category untouched', async () => { + const items = [ + { type: 'post', id: 'p1', title: 'Post no cat' }, + { type: 'post', id: 'p2', title: 'Post null cat', category: null }, + ] + + const { controller } = createController({ + searchResult: { + data: items.map((i) => ({ ...i })), + pagination: makePagination(2), + }, + entryMaps: makeEntryMaps(), + }) + + const result = await controller.search({} as any, 'en') + + expect((result as any).data[0].title).toBe('Post no cat') + expect((result as any).data[1].category).toBeNull() + }) + + it('emits no meta.translation block', async () => { + const items = [ + { + type: 'post', + id: 'p1', + title: 'Post', + category: { id: 'cat-1', name: 'Original' }, + }, + ] + + const { controller } = createController({ + searchResult: { + data: items.map((i) => ({ ...i, category: { ...i.category } })), + pagination: makePagination(1), + }, + entryMaps: makeEntryMaps({ + 'category.name': { 'cat-1': 'EN Category' }, + }), + }) + + const result = await controller.search({} as any, 'en') + + expect((result as any).meta?.translation).toBeUndefined() + }) + + it('does not call getTranslationsBatch when lang is absent', async () => { + const items = [ + { + type: 'post', + id: 'p1', + title: 'Post', + category: { id: 'cat-1', name: 'Original' }, + }, + ] + + const { controller, translationEntryService } = createController({ + searchResult: { + data: items.map((i) => ({ ...i })), + pagination: makePagination(1), + }, + }) + + await controller.search({} as any, undefined) + + expect( + translationEntryService.getTranslationsBatch, + ).not.toHaveBeenCalled() + }) + }) + + describe('GET /:type', () => { + it('translates category.name on post items when type=post and lang is set', async () => { + const items = [ + { + id: 'p1', + title: 'Post 1', + category: { id: 'cat-1', name: 'Original' }, + }, + { id: 'p2', title: 'Post 2' }, + ] + + const { controller } = createController({ + searchResult: { + data: items.map((i) => ({ + ...i, + category: i.category ? { ...i.category } : undefined, + })), + pagination: makePagination(2), + }, + entryMaps: makeEntryMaps({ + 'category.name': { 'cat-1': 'EN Category' }, + }), + }) + + const result = await controller.searchByType({} as any, 'post', 'en') + + expect((result as any).data[0].category.name).toBe('EN Category') + }) + + it('does not translate for note type', async () => { + const items = [{ id: 'n1', title: 'Note 1' }] + + const { controller, translationEntryService } = createController({ + searchResult: { + data: items.map((i) => ({ ...i })), + pagination: makePagination(1), + }, + }) + + await controller.searchByType({} as any, 'note', 'en') + + expect( + translationEntryService.getTranslationsBatch, + ).not.toHaveBeenCalled() + }) + + it('does not translate for page type', async () => { + const items = [{ id: 'pg1', title: 'Page 1' }] + + const { controller, translationEntryService } = createController({ + searchResult: { + data: items.map((i) => ({ ...i })), + pagination: makePagination(1), + }, + }) + + await controller.searchByType({} as any, 'page', 'en') + + expect( + translationEntryService.getTranslationsBatch, + ).not.toHaveBeenCalled() + }) + + it('emits no meta.translation block', async () => { + const items = [ + { + id: 'p1', + title: 'Post 1', + category: { id: 'cat-1', name: 'Original' }, + }, + ] + + const { controller } = createController({ + searchResult: { + data: items.map((i) => ({ ...i, category: { ...i.category } })), + pagination: makePagination(1), + }, + entryMaps: makeEntryMaps({ + 'category.name': { 'cat-1': 'EN Category' }, + }), + }) + + const result = await controller.searchByType({} as any, 'post', 'en') + + expect((result as any).meta?.translation).toBeUndefined() + }) + + it('does not call getTranslationsBatch when lang is absent', async () => { + const items = [ + { + id: 'p1', + title: 'Post', + category: { id: 'cat-1', name: 'Original' }, + }, + ] + + const { controller, translationEntryService } = createController({ + searchResult: { + data: items.map((i) => ({ ...i })), + pagination: makePagination(1), + }, + }) + + await controller.searchByType({} as any, 'post', undefined) + + expect( + translationEntryService.getTranslationsBatch, + ).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/core/test/src/modules/snippet/snippet.controller.e2e-spec.ts b/apps/core/test/src/modules/snippet/snippet.controller.e2e-spec.ts index b2463349db5..7bdc9b003fb 100644 --- a/apps/core/test/src/modules/snippet/snippet.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/snippet/snippet.controller.e2e-spec.ts @@ -1,12 +1,32 @@ import { describe, expect, it, vi } from 'vitest' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppException } from '~/common/errors/exception.types' import { SnippetController } from '~/modules/snippet/snippet.controller' const createController = () => { const repository = { - list: vi.fn().mockResolvedValue({ data: [{ id: '1' }], total: 1 }), - listGrouped: vi.fn().mockResolvedValue({ data: [], total: 0 }), + list: vi.fn().mockResolvedValue({ + data: [{ id: '1' }], + pagination: { + currentPage: 1, + totalPage: 1, + total: 1, + size: 10, + hasNextPage: false, + hasPrevPage: false, + }, + }), + listGrouped: vi.fn().mockResolvedValue({ + data: [], + pagination: { + currentPage: 1, + totalPage: 1, + total: 0, + size: 30, + hasNextPage: false, + hasPrevPage: false, + }, + }), findAll: vi.fn().mockResolvedValue([{ id: '1' }]), } const service = { @@ -30,11 +50,12 @@ describe('SnippetController', () => { it('maps PG repository list rows through the service transformer', async () => { const { controller, service } = createController() - await expect( - controller.getList({ page: 1, size: 10 } as any), - ).resolves.toEqual({ - data: ['1'], + const result = await controller.getList({ page: 1, size: 10 } as any) + expect(result.data).toEqual(['1']) + expect(result.meta.pagination).toMatchObject({ + page: 1, total: 1, + size: 10, }) expect(service.transformLeanSnippetList).toHaveBeenCalledWith([{ id: '1' }]) @@ -43,7 +64,7 @@ describe('SnippetController', () => { it('rejects the removed aggregate endpoint in PG mode', async () => { const { controller } = createController() - await expect(controller.aggregate()).rejects.toThrow(BizException) + await expect(controller.aggregate()).rejects.toThrow(AppException) }) it('uses public snippet lookup when cache misses', async () => { diff --git a/apps/core/test/src/modules/snippet/snippet.service.spec.ts b/apps/core/test/src/modules/snippet/snippet.service.spec.ts index b77b03eadd1..adaf85810f7 100644 --- a/apps/core/test/src/modules/snippet/snippet.service.spec.ts +++ b/apps/core/test/src/modules/snippet/snippet.service.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { createPgRepositoryMock, now } from '@/helper/pg-repository-mock' -import { BizException } from '~/common/exceptions/biz.exception' +import { AppException } from '~/common/errors/exception.types' import type { SnippetRepository, SnippetRow, @@ -92,7 +92,7 @@ describe('SnippetService', () => { raw: '{}', name: 'feature-flags', }), - ).rejects.toThrow(BizException) + ).rejects.toThrow(AppException) expect(repository.create).not.toHaveBeenCalled() }) @@ -108,7 +108,7 @@ describe('SnippetService', () => { name: 'handler', reference: 'system', }), - ).rejects.toThrow(BizException) + ).rejects.toThrow(AppException) repository.create.mockResolvedValue( createSnippet({ diff --git a/apps/core/test/src/modules/topic/topic.controller.e2e-spec.ts b/apps/core/test/src/modules/topic/topic.controller.e2e-spec.ts index 6ca6b24b26b..37fd27553ca 100644 --- a/apps/core/test/src/modules/topic/topic.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/topic/topic.controller.e2e-spec.ts @@ -1,17 +1,63 @@ import { describe, expect, it, vi } from 'vitest' -import { CannotFindException } from '~/common/exceptions/cant-find.exception' +import { AppException } from '~/common/errors/exception.types' +import type { TranslationEntryService } from '~/modules/ai/ai-translation/translation-entry.service' import { TopicBaseController } from '~/modules/topic/topic.controller' -const createController = () => { +const makeEntryMaps = ( + entityMaps: Record> = {}, +) => ({ + entityMaps: new Map( + Object.entries(entityMaps).map(([k, v]) => [k, new Map(Object.entries(v))]), + ), + dictMaps: new Map(), +}) + +const createController = ( + entryMapsOverride?: ReturnType, +) => { + const defaultEntryMaps = makeEntryMaps() + const translationEntryService: Pick< + TranslationEntryService, + 'getTranslationsBatch' + > = { + getTranslationsBatch: vi + .fn() + .mockResolvedValue(entryMapsOverride ?? defaultEntryMaps), + } + + const topics = [ + { + id: 'topic-1', + name: 'Original Name 1', + introduce: 'Intro 1', + description: 'Desc 1', + }, + { + id: 'topic-2', + name: 'Original Name 2', + introduce: 'Intro 2', + description: 'Desc 2', + }, + ] + const repository = { - findAll: vi.fn().mockResolvedValue([{ id: 'topic-1' }]), - findBySlug: vi.fn().mockResolvedValue({ id: 'topic-1', slug: 'hello' }), - findById: vi.fn().mockResolvedValue({ id: 'topic-1' }), + findAll: vi.fn().mockResolvedValue(topics.map((t) => ({ ...t }))), + findBySlug: vi.fn().mockResolvedValue({ ...topics[0], slug: 'hello' }), + findById: vi.fn().mockResolvedValue({ ...topics[0] }), + list: vi.fn().mockResolvedValue({ data: [], pagination: {} }), + create: vi.fn(), + update: vi.fn(), + deleteById: vi.fn(), } + return { - controller: new TopicBaseController(repository as any, {} as any), + controller: new TopicBaseController( + repository as any, + translationEntryService as unknown as TranslationEntryService, + ), repository, + translationEntryService, } } @@ -21,7 +67,7 @@ describe('TopicBaseController', () => { await expect( controller.getTopicByTopic({ slug: 'Hello Topic' } as any), - ).resolves.toEqual({ id: 'topic-1', slug: 'hello' }) + ).resolves.toMatchObject({ id: 'topic-1', slug: 'hello' }) expect(repository.findBySlug).toHaveBeenCalledWith('Hello-Topic') }) @@ -32,6 +78,130 @@ describe('TopicBaseController', () => { await expect( controller.getTopicByTopic({ slug: 'missing' } as any), - ).rejects.toThrow(CannotFindException) + ).rejects.toThrow(AppException) + }) + + describe('GET /all with lang', () => { + it('overwrites name/introduce/description in place for each topic', async () => { + const entryMaps = makeEntryMaps({ + 'topic.name': { + 'topic-1': 'Translated Name 1', + 'topic-2': 'Translated Name 2', + }, + 'topic.introduce': { 'topic-1': 'Translated Intro 1' }, + 'topic.description': { 'topic-2': 'Translated Desc 2' }, + }) + const { controller } = createController(entryMaps) + + const result = await controller.getAll('en') + + expect(result).toEqual([ + expect.objectContaining({ + id: 'topic-1', + name: 'Translated Name 1', + introduce: 'Translated Intro 1', + description: 'Desc 1', + }), + expect.objectContaining({ + id: 'topic-2', + name: 'Translated Name 2', + introduce: 'Intro 2', + description: 'Translated Desc 2', + }), + ]) + }) + + it('preserves original values when no entry exists', async () => { + const { controller } = createController(makeEntryMaps()) + + const result = await controller.getAll('en') + + expect((result as any[])[0]).toMatchObject({ + name: 'Original Name 1', + introduce: 'Intro 1', + description: 'Desc 1', + }) + }) + + it('returns no meta.translation block', async () => { + const { controller } = createController() + const result = await controller.getAll('en') + + expect(result).not.toHaveProperty('meta') + expect(Array.isArray(result)).toBe(true) + }) + + it('does not call getTranslationsBatch when lang is absent', async () => { + const { controller, translationEntryService } = createController() + + await controller.getAll(undefined) + + expect( + translationEntryService.getTranslationsBatch, + ).not.toHaveBeenCalled() + }) + }) + + describe('GET /:id with lang', () => { + it('overwrites name/introduce/description in place', async () => { + const entryMaps = makeEntryMaps({ + 'topic.name': { 'topic-1': 'EN Name' }, + 'topic.introduce': { 'topic-1': 'EN Intro' }, + 'topic.description': { 'topic-1': 'EN Desc' }, + }) + const { controller } = createController(entryMaps) + + const result = await controller.get({ id: 'topic-1' } as any, 'en') + + expect(result).toMatchObject({ + name: 'EN Name', + introduce: 'EN Intro', + description: 'EN Desc', + }) + }) + + it('preserves original value when entry is missing', async () => { + const { controller } = createController(makeEntryMaps()) + + const result = await controller.get({ id: 'topic-1' } as any, 'en') + + expect(result).toMatchObject({ name: 'Original Name 1' }) + }) + + it('does not call getTranslationsBatch when lang is absent', async () => { + const { controller, translationEntryService } = createController() + + await controller.get({ id: 'topic-1' } as any, undefined) + + expect( + translationEntryService.getTranslationsBatch, + ).not.toHaveBeenCalled() + }) + }) + + describe('GET /slug/:slug with lang', () => { + it('overwrites fields in place', async () => { + const entryMaps = makeEntryMaps({ + 'topic.name': { 'topic-1': 'EN Slug Name' }, + }) + const { controller } = createController(entryMaps) + + const result = await controller.getTopicByTopic( + { slug: 'hello' } as any, + 'en', + ) + + expect(result).toMatchObject({ name: 'EN Slug Name' }) + }) + + it('does not call getTranslationsBatch when lang is absent', async () => { + const { controller, translationEntryService } = createController() + + await controller.getTopicByTopic({ slug: 'hello' } as any, undefined) + + expect( + translationEntryService.getTranslationsBatch, + ).not.toHaveBeenCalled() + }) }) }) diff --git a/apps/core/test/src/processors/helper/helper.translation.service.spec.ts b/apps/core/test/src/processors/helper/helper.translation.service.spec.ts index f4bd8f1b1dc..59a6d507e7b 100644 --- a/apps/core/test/src/processors/helper/helper.translation.service.spec.ts +++ b/apps/core/test/src/processors/helper/helper.translation.service.spec.ts @@ -2,8 +2,17 @@ import { Test } from '@nestjs/testing' import { AiTranslationService } from '~/modules/ai/ai-translation/ai-translation.service' import { TranslationEntryService } from '~/modules/ai/ai-translation/translation-entry.service' -import type { ArticleTranslationInput } from '~/processors/helper/helper.translation.service' -import { TranslationService } from '~/processors/helper/helper.translation.service' +import type { + ArticleTranslationInput, + EntryMaps, + TranslationResult, +} from '~/processors/helper/helper.translation.service' +import { + applyArticleTranslationInPlace, + applyTranslationEntriesInPlace, + buildArticleTranslationMeta, + TranslationService, +} from '~/processors/helper/helper.translation.service' const createMockAiTranslationService = () => ({ getAvailableLanguagesForArticle: vi.fn(), @@ -700,4 +709,371 @@ describe('TranslationService', () => { expect(item.title).toBe('T') }) }) + + describe('collectArticleTranslations', () => { + const articles: ArticleTranslationInput[] = [ + { id: 'a1', title: 'Title 1', text: 'Text 1' }, + { id: 'a2', title: 'Title 2', text: 'Text 2' }, + ] + + it('returns slim meta — no title/text/content in meta map', async () => { + const translatedAt = new Date() + mockAiTranslationService.getValidTranslationsForArticles.mockResolvedValue( + { + validTranslations: new Map([ + [ + 'a1', + { + title: 'T1', + text: 'X1', + content: 'C1', + contentFormat: 'markdown', + sourceLang: 'zh', + lang: 'en', + createdAt: translatedAt, + aiModel: 'gpt-4', + }, + ], + ]), + staleRefIds: [], + }, + ) + + const { meta } = await service.collectArticleTranslations({ + articles, + targetLang: 'en', + fields: ['title', 'text', 'content'], + }) + + expect(meta.size).toBe(1) + const entry = meta.get('a1')! + expect(entry.article).toBeDefined() + expect(entry.article!.isTranslated).toBe(true) + expect(entry.article!.sourceLang).toBe('zh') + expect(entry.article!.targetLang).toBe('en') + expect(entry.article!.translatedAt).toEqual(translatedAt) + expect(entry.article!.model).toBe('gpt-4') + expect((entry.article as any).title).toBeUndefined() + expect((entry.article as any).text).toBeUndefined() + expect((entry.article as any).content).toBeUndefined() + expect((entry.article as any).contentFormat).toBeUndefined() + }) + + it('returns per-id TranslationResult in results map', async () => { + mockAiTranslationService.getValidTranslationsForArticles.mockResolvedValue( + { + validTranslations: new Map([ + [ + 'a1', + { + title: 'T1', + text: 'X1', + sourceLang: 'zh', + lang: 'en', + createdAt: new Date(), + }, + ], + ]), + staleRefIds: [], + }, + ) + + const { results } = await service.collectArticleTranslations({ + articles, + targetLang: 'en', + fields: ['title', 'text'], + }) + + expect(results.size).toBe(2) + const r1 = results.get('a1')! + expect(r1.isTranslated).toBe(true) + expect(r1.title).toBe('T1') + const r2 = results.get('a2')! + expect(r2.isTranslated).toBe(false) + }) + + it('omits untranslated items from meta map', async () => { + mockAiTranslationService.getValidTranslationsForArticles.mockResolvedValue( + { + validTranslations: new Map(), + staleRefIds: [], + }, + ) + + const { meta } = await service.collectArticleTranslations({ + articles, + targetLang: 'en', + fields: ['title'], + }) + + expect(meta.size).toBe(0) + }) + }) +}) + +describe('buildArticleTranslationMeta', () => { + it('untranslated result — no content keys, availableTranslations empty array', () => { + const result: TranslationResult = { + title: 'T', + text: 'X', + isTranslated: false, + sourceLang: 'zh', + availableTranslations: [], + } + + const meta = buildArticleTranslationMeta(result, 'en') + + expect(meta.isTranslated).toBe(false) + expect(meta.sourceLang).toBe('zh') + expect(meta.availableTranslations).toEqual([]) + expect(meta).not.toHaveProperty('title') + expect(meta).not.toHaveProperty('text') + expect(meta).not.toHaveProperty('content') + expect(meta).not.toHaveProperty('contentFormat') + expect(meta).not.toHaveProperty('subtitle') + expect(meta).not.toHaveProperty('summary') + expect(meta).not.toHaveProperty('tags') + }) + + it('translated result with full translationMeta', () => { + const translatedAt = new Date() + const result: TranslationResult = { + title: 'T', + text: 'X', + isTranslated: true, + sourceLang: 'zh', + translationMeta: { + sourceLang: 'zh', + targetLang: 'en', + translatedAt, + model: 'claude-haiku', + }, + availableTranslations: ['en', 'ja'], + } + + const meta = buildArticleTranslationMeta(result, 'en') + + expect(meta.isTranslated).toBe(true) + expect(meta.sourceLang).toBe('zh') + expect(meta.targetLang).toBe('en') + expect(meta.translatedAt).toBe(translatedAt) + expect(meta.model).toBe('claude-haiku') + expect(meta.availableTranslations).toEqual(['en', 'ja']) + expect(meta).not.toHaveProperty('title') + expect(meta).not.toHaveProperty('text') + expect(meta).not.toHaveProperty('content') + }) + + it('translated result without translationMeta — targetLang falls back to lang param', () => { + const result: TranslationResult = { + title: 'T', + text: 'X', + isTranslated: true, + sourceLang: 'zh', + } + + const meta = buildArticleTranslationMeta(result, 'ja') + + expect(meta.targetLang).toBe('ja') + expect(meta.translatedAt).toBeUndefined() + expect(meta.model).toBeUndefined() + }) +}) + +describe('applyArticleTranslationInPlace', () => { + const makeResult = ( + overrides: Partial = {}, + ): TranslationResult => ({ + title: 'Translated Title', + text: 'Translated Text', + subtitle: 'Translated Subtitle', + summary: 'Translated Summary', + tags: ['tag-en'], + content: 'Translated Content', + contentFormat: 'markdown', + isTranslated: true, + sourceLang: 'zh', + ...overrides, + }) + + it('untranslated result — no-op', () => { + const target = { title: 'Original', text: 'Original Text' } + const result = makeResult({ isTranslated: false }) + + applyArticleTranslationInPlace(target, result) + + expect(target.title).toBe('Original') + expect(target.text).toBe('Original Text') + }) + + it('translated result overwrites all seven fields by default', () => { + const target: any = { + title: 'O', + text: 'O', + subtitle: 'O', + summary: 'O', + tags: ['orig'], + content: 'O', + contentFormat: 'O', + } + + applyArticleTranslationInPlace(target, makeResult()) + + expect(target.title).toBe('Translated Title') + expect(target.text).toBe('Translated Text') + expect(target.subtitle).toBe('Translated Subtitle') + expect(target.summary).toBe('Translated Summary') + expect(target.tags).toEqual(['tag-en']) + expect(target.content).toBe('Translated Content') + expect(target.contentFormat).toBe('markdown') + }) + + it('empty-string text still overwrites (nullish, not truthy check)', () => { + const target: any = { title: 'O', text: 'O' } + + applyArticleTranslationInPlace(target, makeResult({ text: '' })) + + expect(target.text).toBe('') + }) + + it('content present but contentFormat null — contentFormat retained from target', () => { + const target: any = { content: 'O', contentFormat: 'lexical' } + + applyArticleTranslationInPlace( + target, + makeResult({ content: 'New Content', contentFormat: undefined }), + ) + + expect(target.content).toBe('New Content') + expect(target.contentFormat).toBe('lexical') + }) + + it('content null but contentFormat present — neither overwritten', () => { + const target: any = { content: 'O', contentFormat: 'lexical' } + + applyArticleTranslationInPlace( + target, + makeResult({ content: undefined, contentFormat: 'markdown' }), + ) + + expect(target.content).toBe('O') + expect(target.contentFormat).toBe('lexical') + }) + + it('opts.fields = ["title"] — only title overwritten', () => { + const target: any = { + title: 'O', + text: 'O', + summary: 'O', + } + + applyArticleTranslationInPlace(target, makeResult(), { fields: ['title'] }) + + expect(target.title).toBe('Translated Title') + expect(target.text).toBe('O') + expect(target.summary).toBe('O') + }) +}) + +describe('applyTranslationEntriesInPlace', () => { + const makeEmptyMaps = (): EntryMaps => ({ + entityMaps: new Map(), + dictMaps: new Map(), + }) + + it('entity mode — hit overwrites leaf', () => { + const target: any = { topic: { id: 'topic-1', name: 'Original Name' } } + const maps = makeEmptyMaps() + maps.entityMaps.set('topic.name', new Map([['topic-1', 'Translated Name']])) + + applyTranslationEntriesInPlace(target, maps, [ + { + path: 'topic.name', + keyPath: 'topic.name', + mode: 'entity', + idField: 'id', + }, + ]) + + expect(target.topic.name).toBe('Translated Name') + }) + + it('entity mode — miss retains leaf', () => { + const target: any = { topic: { id: 'topic-2', name: 'Original Name' } } + const maps = makeEmptyMaps() + maps.entityMaps.set('topic.name', new Map([['topic-1', 'Translated Name']])) + + applyTranslationEntriesInPlace(target, maps, [ + { + path: 'topic.name', + keyPath: 'topic.name', + mode: 'entity', + idField: 'id', + }, + ]) + + expect(target.topic.name).toBe('Original Name') + }) + + it('entity mode — undefined intermediate path is no-op', () => { + const target: any = { topic: undefined } + + applyTranslationEntriesInPlace(target, makeEmptyMaps(), [ + { + path: 'topic.name', + keyPath: 'topic.name', + mode: 'entity', + idField: 'id', + }, + ]) + + expect(target.topic).toBeUndefined() + }) + + it('dict mode — hit overwrites leaf', () => { + const target: any = { mood: 'happy' } + const maps = makeEmptyMaps() + maps.dictMaps.set('note.mood', new Map([['happy', 'Glücklich']])) + + applyTranslationEntriesInPlace(target, maps, [ + { path: 'mood', keyPath: 'note.mood', mode: 'dict' }, + ]) + + expect(target.mood).toBe('Glücklich') + }) + + it('dict mode — miss retains leaf', () => { + const target: any = { mood: 'sad' } + const maps = makeEmptyMaps() + maps.dictMaps.set('note.mood', new Map([['happy', 'Glücklich']])) + + applyTranslationEntriesInPlace(target, maps, [ + { path: 'mood', keyPath: 'note.mood', mode: 'dict' }, + ]) + + expect(target.mood).toBe('sad') + }) + + it('multiple rules on same target — all applied independently', () => { + const target: any = { + topic: { id: 'topic-1', name: 'OrigName' }, + mood: 'happy', + } + const maps = makeEmptyMaps() + maps.entityMaps.set('topic.name', new Map([['topic-1', 'NewName']])) + maps.dictMaps.set('note.mood', new Map([['happy', 'Fröhlich']])) + + applyTranslationEntriesInPlace(target, maps, [ + { + path: 'topic.name', + keyPath: 'topic.name', + mode: 'entity', + idField: 'id', + }, + { path: 'mood', keyPath: 'note.mood', mode: 'dict' }, + ]) + + expect(target.topic.name).toBe('NewName') + expect(target.mood).toBe('Fröhlich') + }) }) diff --git a/apps/core/test/src/shared/dto/pager.dto.spec.ts b/apps/core/test/src/shared/dto/pager.dto.spec.ts new file mode 100644 index 00000000000..415eda0eacb --- /dev/null +++ b/apps/core/test/src/shared/dto/pager.dto.spec.ts @@ -0,0 +1,47 @@ +import { createPagerSchema } from '~/shared/dto/pager.dto' + +describe('createPagerSchema', () => { + const schema = createPagerSchema(['createdAt', 'title']) + + test('applies defaults when the query is empty', () => { + expect(schema.parse({})).toMatchObject({ + page: 1, + size: 10, + sortOrder: 'desc', + }) + }) + + test('coerces string page and size from the query string', () => { + const result = schema.parse({ page: '3', size: '25' }) + + expect(result.page).toBe(3) + expect(result.size).toBe(25) + }) + + test('accepts a sortBy key declared in the factory', () => { + expect(schema.parse({ sortBy: 'title' }).sortBy).toBe('title') + }) + + test('rejects a sortBy key not declared in the factory', () => { + expect(schema.safeParse({ sortBy: 'slug' }).success).toBe(false) + }) + + test('rejects a size above the 100 cap', () => { + expect(schema.safeParse({ size: '500' }).success).toBe(false) + }) + + test.each([ + ['asc', 'asc'], + ['desc', 'desc'], + ['1', 'asc'], + ['-1', 'desc'], + [1, 'asc'], + [-1, 'desc'], + ])('coerces sortOrder=%s to %s', (input, expected) => { + expect(schema.parse({ sortOrder: input }).sortOrder).toBe(expected) + }) + + test('rejects an unknown sortOrder value', () => { + expect(schema.safeParse({ sortOrder: 'sideways' }).success).toBe(false) + }) +}) diff --git a/apps/core/test/src/utils/case.util.spec.ts b/apps/core/test/src/utils/case.util.spec.ts deleted file mode 100644 index 692145b7e26..00000000000 --- a/apps/core/test/src/utils/case.util.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { snakecaseKeysWithCompat } from '~/utils/case.util' - -describe('snakecaseKeysWithCompat', () => { - test('should convert plain object keys to snake_case', () => { - const obj = { - a: 1, - bA: 2, - } - expect(snakecaseKeysWithCompat(obj)).toEqual({ a: 1, b_a: 2 }) - }) - - test('should handle nested objects', () => { - const obj = { - outerKey: { - innerKey: 1, - }, - } - expect(snakecaseKeysWithCompat(obj)).toEqual({ - outer_key: { - inner_key: 1, - }, - }) - }) -}) diff --git a/docs/superpowers/specs/2026-05-15-v2-api-migration-reference.md b/docs/superpowers/specs/2026-05-15-v2-api-migration-reference.md new file mode 100644 index 00000000000..5b143ac483e --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-v2-api-migration-reference.md @@ -0,0 +1,728 @@ +# V2 API Migration — Reference Task List + +**Companion to:** [`2026-05-15-v2-api-response-design.md`](./2026-05-15-v2-api-response-design.md) +**Date:** 2026-05-15 +**Purpose:** Enumerate every module, file, and concern that the V2 response-layer refactor touches, organized into actionable tasks. Each task is sized to fit in a single PR. + +This document is a **map**, not a plan. It catalogs the work; the order of execution and PR boundaries are decided in the implementation plan (writing-plans skill output). + +## Scan Summary + +- **45 modules** under `apps/core/src/modules/` +- **~55 controller files** across those modules (several modules expose multiple controllers — `ai/` has 8, `auth/` has 2, `option/` has 2, `file/` has 2, `snippet/` has 2), **~250+ endpoints** +- **Drizzle schemas:** centralized in `packages/db-schema/src/schema/` (not in `apps/core`) +- **Zod schemas:** distributed under `apps/core/src/modules//.schema.ts` +- `@TranslateFields` decorator: **6 modules** — post, note, category, aggregate, topic, search +- `@HTTPDecorators.Bypass`: **22 controller files**, ~29 decorated endpoints +- `paginate()` / `listPaginated()`: post, note, page, draft, webhook, reader (via shared `PagerDto`) + +## How to Read This Document + +Each section is one **phase**. Inside a phase, each `### Task N.M` is one PR-sized unit of work. Tasks have: + +- **Touches:** files/dirs to change +- **Deletes:** files/utilities removed +- **Notes:** module-specific gotchas +- **Depends on:** other tasks that must land first + +A task may produce multiple commits but should be reviewable as one PR. + +--- + +# Phase 0 — Infrastructure + +New common machinery. No existing controllers change. + +## Task 0.1: New `src/common/response/` foundation + +**Touches:** + +``` +apps/core/src/common/response/ + envelope.types.ts (NEW) + meta.types.ts (NEW) + meta-builder.ts (NEW) + error.types.ts (NEW) + app-exception.filter.ts (NEW) + raw-response.decorator.ts (NEW) + response.interceptor.ts (NEW — rewritten copy; old kept for now) +``` + +**Notes:** + +- New interceptor `ResponseInterceptorV2` lives alongside the old; not yet wired into the global pipeline. Wired in Task 0.3. +- `AppException` extends `HttpException`. Define `PostNotFoundException`, `AuthSessionExpiredException`, etc. in their own modules (added per-module in Phase 2). +- `ResponseMetaSchema` keys: `pagination | view | translation | interaction | enrichments | related`. Sub-schemas (`PaginationSchema`, `ArticleTranslationSchema`, `EntryTranslationSchema`, `InteractionMetaSchema`, `EnrichmentEntrySchema`, `RelatedRefSchema`) live in `meta.types.ts`. +- `EnrichmentEntrySchema` mirrors the current `EnrichmentEntry` shape in `apps/core/src/modules/enrichment/`. Pull it across, do not redefine. +- `MetaObjectBuilder` accepts both `Map` and `Record` for the map-shaped meta keys; normalizes internally. + +> ✅ **Design gaps resolved (2026-05-15).** The two design-spec inconsistencies below are now resolved — see `2026-05-15-v2-api-response-design.md` → "Resolved Decisions". This task can be planned against the current spec. +> +> 1. **`translation` meta shape → nested `{ article, fields }`.** `TranslationMetaSchema` is replaced by `ArticleTranslationSchema` (translated body) + `EntryTranslationSchema` (`{ article?, fields? }`). `ResponseMetaSchema.translation` is `EntryTranslation` (detail) or `Record` (list); `EntryTranslationSchema`/`InteractionMetaSchema` are `.strict()` for union disambiguation. Task 2.1's "Meta concerns" (`translation.article`, `translation.fields[...]`) match this shape as-is. +> 2. **`meta.count` → carried in `data`.** Bulk-write responses return `{ data: { count, ids? } }`. `count` is not added to `ResponseMetaSchema`; the closed six-key set stands. + +**Depends on:** — + +## Task 0.2: Shared `View` helper types + +**Touches:** + +``` +apps/core/src/common/views/ + view.types.ts (NEW) +``` + +**Contents:** + +- `type ViewDef = S` +- `type ViewMap> = { ... }` +- `type ViewOf = z.infer` +- Helper `parseView(view: K, viewMap, row)` for runtime parsing with friendly errors. + +**Depends on:** 0.1 + +## Task 0.3: Wire `ResponseInterceptorV2` + `AppExceptionFilter` globally + +**Touches:** + +``` +apps/core/src/app.module.ts (or wherever APP_INTERCEPTOR/APP_FILTER tokens are wired) +``` + +**Notes:** + +- Both the old `ResponseInterceptor` and the new `ResponseInterceptorV2` run for the **entire** transitional period — the old one cannot be removed until Phase 3 (Task 3.1) because un-migrated controllers still depend on it. New runs first. +- `ResponseInterceptorV2` is idempotent: it accepts both pre-wrapped `{ data, meta }` and bare values. To avoid double-wrap, confirm the old `ResponseInterceptor` passes an already-`{ data }`-shaped object through untouched (it only wraps bare arrays). If it does not, that fix belongs in this task. +- The safety net against double-wrap is the new interceptor's idempotence plus the E2E snapshot tests — not early removal of the old interceptor. +- `AppExceptionFilter` registered as global filter via `APP_FILTER`. + +**Depends on:** 0.1, 0.2 + +## Task 0.4: Shared `PagerDto` rewrite + +**Touches:** + +``` +apps/core/src/shared/dto/pager.dto.ts (REWRITE) +``` + +**Notes:** + +- Replace current `PagerSchema` with a factory `createPagerSchema(sortKeys)` that returns a Zod schema with `page`, `size`, `view`, `sort_by`, `sort_order`, `year`. +- Keep `PagerDto` and `PagerSchema` exports for backwards compatibility during migration. Mark deprecated. +- All module-specific pagers (`PostPagerSchema`, `NoteQuerySchema`, `DraftPagerSchema`) will be migrated to the factory in Phase 2. + +**Depends on:** 0.1 + +--- + +# Phase 1 — Schema-Layer snake_case + +> Superseded. This phase was applied and then reversed. The schema layer and +> all business code are camelCase; `ResponseInterceptorV2` converts to +> snake_case at the wire boundary. See `§2` of the design spec. The Phase 1 +> tasks below are kept only as a record of the original plan. + +DB columns are already snake_case in the database. What changes is the **TS property name** on Drizzle column definitions, plus Zod DTO field names, plus every reference site. + +## Task 1.1: `packages/db-schema/src/schema/columns.ts` rename helpers + +**Touches:** + +``` +packages/db-schema/src/schema/columns.ts +``` + +**Notes:** + +- Current helpers: `createdAt()`, `updatedAt()`, `tsCol()`, etc. Rename to `created_at()`, `updated_at()`, `ts_col()`. +- These helpers return a Drizzle column with mapping to the snake_case DB column. Rename the TS function names; the underlying SQL column names are unchanged. +- All importers in `packages/db-schema/src/schema/*.ts` update. + +**Depends on:** — + +## Task 1.2: `packages/db-schema/src/schema/*` rename TS prop names + +**Touches:** + +``` +packages/db-schema/src/schema/ai.ts +packages/db-schema/src/schema/auth.ts +packages/db-schema/src/schema/content.ts (categories, topics, posts, notes, pages, comments, drafts, ...) +packages/db-schema/src/schema/enrichment.ts +packages/db-schema/src/schema/migration.ts +packages/db-schema/src/schema/ops.ts +packages/db-schema/src/schema/search.ts +packages/db-schema/src/schema/index.ts +``` + +**Notes:** + +- One PR per file is too granular; one PR per logical group is too coarse. Recommended: **one PR per schema file**. +- Inferred types (`InferSelectModel`) auto-update. +- No SQL migration is required (the rename is TS-only). But run `pnpm -C apps/core run lint:migrations` and `pnpm -C apps/core run typecheck` after each file. +- ESLint rule `@typescript-eslint/naming-convention` may need adjustment to allow snake_case object keys. + +**Depends on:** 1.1 + +## Task 1.3: Ripple rename to `apps/core` services / controllers / tests + +**Touches:** the entire `apps/core/src/modules/` tree, plus `apps/core/test/`. + +**Approach:** + +- One PR per module (e.g., one for `post`, one for `note`). Smaller modules can be batched. +- Search-and-replace `\.createdAt\b` → `\.created_at\b`, etc., but verify by inspection — there are some genuine camelCase identifiers (variables, function names) that must NOT change. +- Update Zod DTO schemas in `apps/core/src/modules//.schema.ts` to use snake_case keys. +- Each ripple PR includes regenerating any DTO-derived types and re-running affected tests. + +**Phase 1.3 ↔ Phase 2 overlap:** This task and the Phase 2 controller rewrite touch the *same* controller/service files for a given module, in two separate passes. Two PRs per module doubles the diff surface and invites merge conflicts during the weeks the phases overlap. **Consider merging them per module** — one PR that does the snake_case rename *and* the V2 controller rewrite. The design spec (`§11`) keeps them split; the planner should decide explicitly. If kept split, finish a module's 1.3 before starting its Phase 2 task and never run the two concurrently. + +**Depends on:** 1.2 (for the same schema file). Modules unblocked once their schema file is renamed. + +## Task 1.4 — removed (folded into Task 3.1) + +Deleting the case-conversion utilities (`case.util.ts`, `json-transform.interceptor.ts`) depends on **all of Phase 2** landing, so it is a Phase 3 task, not a Phase 1 one. Those deletions are already covered by **Task 3.1** — listing them here as well would delete the same files in two tasks. There is intentionally no separate Task 1.4. + +--- + +# Phase 2 — Per-Module Migration + +Each module's controller is rewritten to: + +1. Return `{ data, meta? }` (or bare `T` for envelope auto-wrap). +2. Define `*.views.ts` with named views (`card`, `summary`, `detail`, or module-specific names). +3. Use `MetaObjectBuilder` for pagination, translation, interaction, enrichment, related. +4. Replace `@TranslateFields` decorator with explicit translation calls. +5. Replace `throw new HttpException` / `CannotFindException` with `AppException` subclasses bearing stable codes. +6. Replace per-module `PagerDto` with the shared factory. + +Modules are grouped below by **migration weight** (rough effort estimate): + +- **Heavy** — translation + paginate + complex meta. Expect a large PR. +- **Medium** — paginate or write-heavy, but no translation. Moderate PR. +- **Light** — few endpoints, mostly CRUD. Small PR. +- **Bypass-only** — non-JSON content. Only `@HTTPDecorators.Bypass` → `@RawResponse` rename plus exception handling. +- **Special** — auth, configs, or edge behavior; handled individually. + +## Task 2.1 — Heavy module: `post` + +**Touches:** + +``` +apps/core/src/modules/post/ + post.controller.ts (10 endpoints, 364 LOC, 4× @TranslateFields, 2× paginate) + post.service.ts + post.schema.ts + post.views.ts (NEW) + post.exceptions.ts (NEW) +apps/core/test/src/modules/post/ + post.controller.spec.ts +``` + +**Endpoints to migrate:** + +- `GET /` (list, paginated, `?select` to remove, `?view` introduced) +- `GET /:id` +- `GET /:category/:slug` +- `GET /latest` +- `GET /get-url/:slug` +- `POST /` +- `PATCH /:id` +- `PUT /:id` +- `DELETE /:id` +- `POST /:id/publish` (and similar admin actions) + +**Views to define:** + +- `card`: id, title, slug, summary, category, created_at, cover +- `summary`: card + tags, modified_at +- `detail`: full PostModel + +**Meta concerns:** + +- `translation.article` (detail) / `translation` map (list) +- `translation.fields['category.name']` for category translations +- `interaction.is_liked` (per-item map in list, single in detail) +- `enrichments` (detail only) +- `related` (detail only — translated titles already; now in `meta.related`) +- `pagination` (list) +- `view` (always) + +**Removals:** + +- `?select` query parameter handling (lines 107-124 of current controller) +- `applyContentPreference` call (let consumer choose) +- All `@TranslateFields` decorators +- In-line spread of `is_translated`, `translation_meta`, `is_liked` onto `doc` + +**Exceptions:** + +- `PostNotFoundException` (replaces `CannotFindException`) +- `PostUnpublishedException` (currently silently 404s for non-authenticated; consider explicit code) + +**Depends on:** Phase 0, Task 1.3 for `post` module. + +## Task 2.2 — Heavy module: `note` + +**Touches:** + +``` +apps/core/src/modules/note/ + note.controller.ts (13 endpoints, 803 LOC, 7× @TranslateFields, 1× paginate) + note.service.ts + note.schema.ts + note.views.ts (NEW) + note.exceptions.ts (NEW) +apps/core/test/src/modules/note/ + note.controller.spec.ts +``` + +**Notes:** + +- Largest controller in the codebase (803 LOC). Consider extracting helpers (e.g., topic/related/translation orchestration) into separate files at the same time, since the file is already past the 500-line guidance. +- Note topic endpoints (`NoteTopicPagerDto`) — separate Zod schema, migrate to shared factory. +- Has the most diverse `@TranslateFields` paths (data[].topic, data[].related, etc.). Each must become an explicit translation call in the controller. + +**Views:** + +- `card`, `summary`, `detail`, plus `topic_summary` for topic-grouped listings. + +**Exceptions:** + +- `NoteNotFoundException`, `NotePasswordRequiredException` (current note has password-gated reads). + +**Depends on:** Phase 0, Task 1.3 for `note` module. + +## Task 2.3 — Heavy module: `page` + +**Touches:** + +``` +apps/core/src/modules/page/ + page.controller.ts (8 endpoints, 212 LOC, 1× paginate) + page.service.ts + page.schema.ts + page.views.ts (NEW) + page.exceptions.ts (NEW) +``` + +**Notes:** + +- No `@TranslateFields` but uses `applyContentPreference` and likely some translation injection in `getPagesSummary`. Audit and migrate to meta. +- Views: `card`, `summary`, `detail`. Pages have unique field set (subtitle, order, etc.) — define explicitly. + +**Depends on:** Phase 0, Task 1.3 for `page` module. + +## Task 2.4 — Heavy module: `comment` + +**Touches:** + +``` +apps/core/src/modules/comment/ + comment.controller.ts (14 endpoints, 446 LOC, 0× translate, 0× paginate) + comment.service.ts + comment.schema.ts + comment.views.ts (NEW) + comment.exceptions.ts (NEW) + comment.interceptor.ts (audit — module-local interceptor, may merge into meta builder) +``` + +**Notes:** + +- Comments have nested `replies` — decide if replies live in `data` (nested tree) or `meta.children` (flat with parent_id). Existing shape: nested tree. Keep nested for V2 unless YAGNI says otherwise. +- `comment.interceptor.ts` injects something — audit and migrate. +- Has its own filter logic for spam/moderation status; ensure error codes are stable. + +**Views:** `card` (id, author, content, created_at), `detail` (with replies, source, ip masked). + +**Exceptions:** `CommentNotFoundException`, `CommentSpamException`, `CommentNotAllowedException`. + +**Depends on:** Phase 0, Task 1.3 for `comment` module. + +## Task 2.5 — Heavy module: `aggregate` + +**Touches:** + +``` +apps/core/src/modules/aggregate/ + aggregate.controller.ts (17 endpoints, 379 LOC, 3× @TranslateFields) + aggregate.service.ts + aggregate.schema.ts + aggregate.views.ts (NEW) +``` + +**Notes:** + +- Aggregate composes data from multiple resources (post + note + page + recent). Each sub-resource's view must be selected per call. +- Translation paths are deep (`data.posts[].category.name`, `data.notes[].topic.name`). Each becomes an explicit translation call; the orchestration of N parallel translations is non-trivial — consider helper service. +- Likely the trickiest controller after `note`. Plan extra time. + +**Depends on:** Phase 0, Task 1.3, **AND** Tasks 2.1, 2.2, 2.3 (post/note/page must be migrated first since aggregate uses their views). + +## Task 2.6 — Heavy module: `category` + +**Touches:** + +``` +apps/core/src/modules/category/ + category.controller.ts (6 endpoints, 207 LOC, 2× @TranslateFields) + category.service.ts + category.schema.ts + category.views.ts (NEW) +``` + +**Notes:** + +- Categories embed posts in some endpoints. Decide nesting policy: include post `card` view, or `meta.related` with IDs. +- Translation: `category.name` only. + +**Depends on:** Phase 0, Task 1.3. + +## Task 2.7 — Medium: `topic` + +**Touches:** `topic.controller.ts` (3 endpoints, 65 LOC, 3× @TranslateFields). + +**Notes:** Similar to category — small but translation-heavy. + +**Depends on:** Phase 0, Task 1.3. + +## Task 2.8 — Medium: `search` + +**Touches:** `search.controller.ts` (5 endpoints, 81 LOC, 2× @TranslateFields). + +**Notes:** Search returns heterogeneous results (post/note/page). Use a discriminated union view (`{ type: 'post', data: ... } | { type: 'note', data: ... }`) or per-type pagination with `meta.type`. + +**Depends on:** Phase 0, Task 1.3, 2.1-2.3. + +## Task 2.9 — Medium: `draft` + +**Touches:** `draft.controller.ts` (10 endpoints, 122 LOC, 1× paginate via DraftPagerDto). + +**Notes:** Has its own pager schema — migrate to shared factory. + +**Depends on:** Phase 0, Task 1.3. + +## Task 2.10 — Medium: `recently` + +**Touches:** `recently.controller.ts` (8 endpoints, 97 LOC). + +**Depends on:** Phase 0, Task 1.3. + +## Task 2.11 — Medium: `link` + +**Touches:** `link.controller.ts` (9 endpoints, 115 LOC). + +**Depends on:** Phase 0, Task 1.3. + +## Task 2.12 — Medium: `activity` + +**Touches:** `activity.controller.ts` (14 endpoints, 408 LOC, 1× Bypass). + +**Notes:** Many endpoints. The `Bypass` is likely SSE or websocket — verify; may need `@RawResponse`. + +**Depends on:** Phase 0, Task 1.3. + +## Task 2.13 — Medium: `analyze` + +**Touches:** `analyze.controller.ts` (8 endpoints, 215 LOC). + +**Depends on:** Phase 0, Task 1.3. + +## Task 2.14 — Medium: `ai/*` (5 sub-controllers) + +**Touches:** + +``` +apps/core/src/modules/ai/ + ai.controller.ts (5 endpoints, 326 LOC) + ai-agent/ai-agent.controller.ts + ai-insights/ai-insights.controller.ts + ai-summary/ai-summary.controller.ts + ai-task/ai-task.controller.ts + ai-translation/ai-translation.controller.ts + ai-translation/translation-entry.controller.ts + ai-writer/ai-writer.controller.ts +``` + +**Notes:** + +- Multiple sub-modules. Group into 2-3 PRs by sub-feature (agent+task, insights+summary, translation+writer). +- AI streaming endpoints (if any) need `@RawResponse`. + +**Depends on:** Phase 0, Task 1.3. + +## Task 2.15 — Medium: `auth` + +**Touches:** + +``` +apps/core/src/modules/auth/ + auth.controller.ts (6 endpoints, 124 LOC) + device.controller.ts (2 endpoints, 225 LOC, 2× Bypass) +``` + +**Notes:** + +- Sessions/tokens are core. Error codes are critical (`AUTH_INVALID_CREDENTIALS`, `AUTH_SESSION_EXPIRED`, `AUTH_DEVICE_FLOW_PENDING`). +- `device.controller.ts` Bypass usages are likely device-flow polling — verify if they actually need raw or can be envelope-wrapped (probably yes). + +**Depends on:** Phase 0, Task 1.3. + +## Task 2.16 — Light: `enrichment` + +**Touches:** `enrichment.controller.ts` (12 endpoints, 272 LOC). + +**Notes:** Enrichment results are themselves a meta concern elsewhere — but this controller manages CRUD over enrichment entries. Distinct concern. + +**Depends on:** Phase 0, Task 1.3. + +## Task 2.17 — Light: `file` + +**Touches:** + +``` +apps/core/src/modules/file/ + file.controller.ts (12 endpoints, 360 LOC, 1× Bypass for download) + comment-upload.controller.ts (2 endpoints, 33 LOC) +``` + +**Notes:** Upload responses fit envelope. Download endpoint stays `@RawResponse` (binary stream). + +**Depends on:** Phase 0, Task 1.3. + +## Task 2.18 — Light: smaller resource modules + +**Touches (one PR each, can be parallelized):** + +``` +ack/ack.controller.ts (1 endpoint, 55 LOC) +say/say.controller.ts (1 endpoint, 16 LOC) +project/project.controller.ts (0 endpoints, 7 LOC — likely scaffold; verify) +init/init.controller.ts (5 endpoints, 82 LOC) +reader/reader.controller.ts (3 endpoints, 29 LOC; uses PagerDto) +meta-preset/meta-preset.controller.ts (6 endpoints, 81 LOC) +webhook/webhook.controller.ts (8 endpoints, 63 LOC; uses PagerDto) +helper/helper.controller.ts (2 endpoints, 95 LOC) +markdown/markdown.controller.ts (3 endpoints, 162 LOC, 1× Bypass) +poll/poll.controller.ts (3 endpoints, 58 LOC, 1× Bypass) +option/controllers/base.option.controller.ts (1× Bypass) +option/controllers/email.option.controller.ts +configs/ (no controller file — verify if it has one) +subscribe/subscribe.controller.ts (5 endpoints, 77 LOC, 2× Bypass) +``` + +**Notes:** + +- Each gets the same treatment as a heavy module, but the work per file is small. Several can be combined into a single PR ("Light modules batch A/B/C"). + +**Depends on:** Phase 0, Task 1.3. + +## Task 2.19 — Bypass-only modules (eliminate or rename) + +**Touches:** modules whose endpoints only exist because of non-JSON content. Replace `@HTTPDecorators.Bypass` with `@RawResponse`. + +``` +feed/feed.controller.ts (1× Bypass — RSS XML) +pageproxy/pageproxy.controller.ts (3× Bypass — HTML render) +render/render.controller.ts (1× Bypass — HTML render) +sitemap/sitemap.controller.ts (1× Bypass — XML) +snippet/snippet-route.controller.ts (1× Bypass — user-defined content type) +snippet/snippet.controller.ts (1× Bypass on one endpoint; rest are JSON CRUD — verify & migrate JSON ones) +serverless/serverless.controller.ts (4× Bypass — user-defined functions; keep raw) +``` + +**Notes:** + +- All-Bypass controllers: just rename the decorator and ensure `AppExceptionFilter` still emits JSON error envelopes on throw. +- Mixed (`snippet.controller.ts` has CRUD + one raw endpoint): split work — JSON endpoints follow Phase 2 standard, raw endpoints get `@RawResponse`. + +**Depends on:** Phase 0. + +## Task 2.20 — Bypass that should be removed entirely + +**Touches:** endpoints whose response can fit `{ data }` envelope and currently use Bypass only for historical reasons. + +``` +health/health.controller.ts (1× Bypass — /ping → return { ok: true }) +server-time/server-time.controller.ts (1× Bypass — single number → { timestamp }) +update/update.controller.ts (1× Bypass — verify response shape) +debug/debug.controller.ts (1× Bypass — likely JSON output) +dependency/dependency.controller.ts (1× Bypass — verify) +cron-task/cron-task.controller.ts (1× Bypass — verify; admin endpoint) +backup/backup.controller.ts (2× Bypass — one is download stream, keep raw; one likely status JSON) +owner/owner.controller.ts (1× Bypass — verify) +auth/device.controller.ts (2× Bypass — verify if polling responses can be enveloped) +``` + +**Notes:** Audit each before deciding. Some "verify" cases may legitimately need `@RawResponse`. + +**Depends on:** Phase 0. + +## Task 2.21 — `app.controller.ts` (root) + +**Touches:** `apps/core/src/app.controller.ts` (1× Bypass). + +**Notes:** Likely a `/api` root or version endpoint. Verify and migrate. + +**Depends on:** Phase 0. + +--- + +# Phase 3 — Cleanup + +After every controller is migrated and Yohaku/admin-vue3 are updated to consume the new shape. + +## Task 3.1: Delete legacy interceptors and decorators + +**Deletes:** + +``` +apps/core/src/common/interceptors/json-transform.interceptor.ts +apps/core/src/common/interceptors/response.interceptor.ts (the OLD one; V2 stays under same name after rename) +apps/core/src/common/interceptors/translation-entry.interceptor.ts +apps/core/src/common/decorators/translate-fields.decorator.ts +apps/core/src/utils/case.util.ts +``` + +**Renames:** + +``` +apps/core/src/common/response/response.interceptor.ts → src/common/interceptors/response.interceptor.ts +``` + +**Touches:** + +- `app.module.ts` / global pipeline wiring — remove old interceptor providers. +- Any remaining importers (should be zero by this point — grep to confirm). + +**Depends on:** All of Phase 2. + +## Task 3.2: Delete `Bypass` alias + +**Touches:** `apps/core/src/common/decorators/http.decorator.ts` + +- Remove `Bypass` export; keep `RawResponse`. +- Verify zero importers of `HTTPDecorators.Bypass`. + +**Depends on:** All of Phase 2. + +## Task 3.3: Audit and migrate generic exceptions + +**Touches:** + +``` +apps/core/src/common/exceptions/ + cant-find.exception.ts (rewrite to extend AppException with code='NOT_FOUND') + ban-in-demo.exception.ts (rewrite) + biz.exception.ts (rewrite) + no-content-canbe-modified.exception.ts (rewrite) +``` + +**Notes:** + +- Each existing generic exception gets a stable code. Per-module exceptions added in Phase 2 already do this. +- The `AppExceptionFilter` already handles bare `HttpException`s, but per-module typed exceptions are preferable for monitoring. + +**Depends on:** All of Phase 2. + +## Task 3.4: Documentation update + +**Touches:** + +``` +apps/core/CLAUDE.md +README files +docs/superpowers/specs/2026-05-15-v2-api-response-design.md (mark Status: Implemented) +``` + +**Notes:** + +- Update the "API Response Rules" section in `apps/core/CLAUDE.md` (currently describes the old wrapping rules). +- Add a short "Writing a new endpoint" guide pointing at envelope + views + meta builder + AppException. +- Update OpenAPI generation if it exists. + +**Depends on:** All of Phase 2 + 3.1-3.3. + +--- + +# Cross-Cutting Concerns Checklist + +These don't fit neatly in a phase but must be tracked. + +- [ ] **`pnpm -C apps/core run lint`** and **`pnpm -C apps/core run typecheck`** must pass on every PR. +- [ ] **E2E snapshot tests** for `/posts/:id`, `/posts`, `/notes/:id`, `/notes`, `/comments`, `/auth/login`, `/file/upload`, `/aggregate/top` — added during Phase 0 (against the old shape), updated during Phase 2 (against the new shape) per module. +- [ ] **Yohaku frontend** + **admin-vue3** need a coordinated update. Track separately; cleanup PR (Phase 3.1) cannot land until both consumers have migrated. +- [ ] **API client packages** (`packages/api-client`, `packages/webhook`) — regenerate types after Phase 2; bump versions. +- [ ] **OpenAPI / Swagger** generation — verify the new envelope is correctly described. If using nestjs-zod or zod-to-openapi, the envelope wrapping must be reflected. +- [ ] **Webhook payload shape** — verify whether external subscribers depend on the current shape. If yes, the webhook controller is a special case (keep old shape or version webhook payloads). +- [ ] **RSS / sitemap feed** consumers are XML; no change needed beyond `@RawResponse` rename. +- [ ] **GraphQL** — none in this codebase, but confirm. +- [ ] **WebSocket / SSE gateways** (`apps/core/src/processors/gateway/`) emit events, not HTTP responses — out of scope for the envelope refactor. Confirm no gateway payload depends on case conversion that the deleted `JSONTransformInterceptor` was performing; if one does, that conversion must be reapplied at the gateway. + +## Per-Module Stats Table + +| Module | Endpoints | LOC | Bypass | Translate | Paginate | Weight | +|---|---|---|---|---|---|---| +| note | 13 | 803 | 0 | 7 | 1 | Heavy | +| comment | 14 | 446 | 0 | 0 | 0 | Heavy | +| activity | 14 | 408 | 1 | 0 | 0 | Medium | +| aggregate | 17 | 379 | 0 | 3 | 0 | Heavy | +| post | 10 | 364 | 0 | 4 | 2 | Heavy | +| file | 12 | 360 | 1 | 0 | 0 | Light | +| ai | 5 | 326 | 0 | 0 | 0 | Medium | +| enrichment | 12 | 272 | 0 | 0 | 0 | Light | +| auth/device | 2 | 225 | 2 | 0 | 0 | Medium | +| analyze | 8 | 215 | 0 | 0 | 0 | Medium | +| page | 8 | 212 | 0 | 0 | 1 | Heavy | +| category | 6 | 207 | 0 | 2 | 0 | Heavy | +| serverless | 5 | 168 | 4 | 0 | 0 | Bypass | +| markdown | 3 | 162 | 1 | 0 | 0 | Light | +| pageproxy | 3 | 161 | 3 | 0 | 0 | Bypass | +| render | 2 | 142 | 1 | 0 | 0 | Bypass | +| snippet-route | 0 | 139 | 1 | 0 | 0 | Bypass | +| snippet | 10 | 130 | 1 | 0 | 0 | Mixed | +| auth | 6 | 124 | 0 | 0 | 0 | Medium | +| draft | 10 | 122 | 0 | 0 | 0 (DraftPagerDto) | Medium | +| update | 0 | 119 | 1 | 0 | 0 | Bypass-audit | +| backup | 8 | 115 | 2 | 0 | 0 | Mixed | +| link | 9 | 115 | 0 | 0 | 0 | Medium | +| feed | 1 | 101 | 1 | 0 | 0 | Bypass | +| recently | 8 | 97 | 0 | 0 | 0 | Medium | +| helper | 2 | 95 | 0 | 0 | 0 | Light | +| debug | 3 | 86 | 1 | 0 | 0 | Bypass-audit | +| init | 5 | 82 | 0 | 0 | 0 | Light | +| meta-preset | 6 | 81 | 0 | 0 | 0 | Light | +| search | 5 | 81 | 0 | 2 | 0 | Medium | +| subscribe | 5 | 77 | 2 | 0 | 0 | Mixed | +| topic | 3 | 65 | 0 | 3 | 0 | Medium | +| webhook | 8 | 63 | 0 | 0 | 0 (PagerDto) | Light | +| cron-task | 3 | 61 | 1 | 0 | 0 | Bypass-audit | +| owner | 4 | 61 | 1 | 0 | 0 | Bypass-audit | +| poll | 3 | 58 | 1 | 0 | 0 | Bypass-audit | +| dependency | 1 | 56 | 1 | 0 | 0 | Bypass-audit | +| ack | 1 | 55 | 0 | 0 | 0 | Light | +| comment-upload (file) | 2 | 33 | 0 | 0 | 0 | Light | +| sitemap | 1 | 35 | 1 | 0 | 0 | Bypass | +| health | 2 | 30 | 1 | 0 | 0 | Bypass-audit | +| reader | 3 | 29 | 0 | 0 | 0 (PagerDto) | Light | +| say | 1 | 16 | 0 | 0 | 0 | Light | +| server-time | 1 | 13 | 1 | 0 | 0 | Bypass-audit | +| project | 0 | 7 | 0 | 0 | 0 | Scaffold (verify) | + +> **Table is incomplete.** The 7 `ai/` sub-controllers (`ai-agent`, `ai-insights`, `ai-summary`, `ai-task`, `ai-translation`, `translation-entry`, `ai-writer`) and the 2 `option/` controllers (`base.option`, `email.option`) are *not* listed as rows — the `ai` row covers only `ai.controller.ts`. Their endpoints and LOC are excluded from the totals. Task 2.14 and Task 2.18 do enumerate the files; the planner should fill in per-controller stats before sizing those PRs. All LOC figures were captured from an earlier snapshot and have drifted slightly (e.g. `note` is 799, not 803; `activity` is 406, not 408) — re-scan before relying on them for sizing. + +(Total endpoints: ~250, **excluding the AI sub-controllers and `option` controllers**. Total LOC across controllers: ~7,000, same caveat.) + +## Estimated Sequencing + +A reasonable execution order (subject to writing-plans refinement): + +1. **Week 1:** Phase 0 (0.1 - 0.4) — infrastructure, no controller churn. +2. **Week 2:** Phase 1.1 - 1.2 — `packages/db-schema` rename per file. +3. **Week 2-4:** Phase 1.3 — `apps/core` ripple, one module per PR. +4. **Week 3-6:** Phase 2 — module migrations. Light/bypass modules can run in parallel; heavy modules sequentially because they share the meta builder and may reveal needed builder methods. +5. **Week 6:** Consumer-side (Yohaku, admin) catch-up. +6. **Week 7:** Phase 3 — cleanup. + +Heavy modules in execution order: **post → page → note → comment → category → aggregate** (aggregate last because it depends on post/note/page views). diff --git a/docs/superpowers/specs/2026-05-15-v2-api-response-design.md b/docs/superpowers/specs/2026-05-15-v2-api-response-design.md new file mode 100644 index 00000000000..60e8dfbc2e1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-v2-api-response-design.md @@ -0,0 +1,586 @@ +# V2 API Response Architecture — Design Spec + +**Status:** Implemented — Phase 0–3 complete as of 2026-05-16 +**Author:** Innei (with brainstorming assistance) +**Date:** 2026-05-15 +**Scope:** Breaking refactor of the mx-core HTTP API response layer. No backwards compatibility with the current shape is required. + +## Motivation + +The current API response layer has accumulated four structural problems that hurt every consumer (Yohaku frontend, admin-vue3, future SDKs): + +1. **Inconsistent envelope.** `ResponseInterceptor` wraps arrays as `{ data: [...] }` but lets plain objects pass through untouched. Paginated endpoints return `{ data, pagination }` directly. Whether a response carries a `data` wrapper depends on the controller's return type, so consumers cannot rely on a single shape. +2. **Schema-extrinsic fields mixed into the entity.** `is_liked`, `is_translated`, `translation_meta`, `enrichments`, `related`, etc. are spread onto the entity at the same level as its real columns. The entity's TypeScript type has to enumerate every cross-cutting decoration; consumers cannot distinguish "what the post is" from "what the server appended for this request". +3. **Field selection breaks shape.** The `select` query parameter strips fields after the service returns, yielding a `Partial` that no downstream code can statically depend on. +4. **Layered transforms.** `ResponseInterceptor` (envelope), `JSONTransformInterceptor` (snake_case + `toJSON` recursion + `__v` deletion), and per-controller manual mapping (translation/enrichment spread) compound. A request travels through three serialization layers and consumers cannot reason about the final shape without running the code. + +This document specifies a redesigned response architecture that fixes all four. It is a breaking refactor — no V1 namespace, no compatibility shim. The existing routes are rewritten in place. + +## Goals + +- Every successful JSON response has the same outer shape: `{ data, meta? }`. +- Every error JSON response has the same outer shape: `{ error: { code, message, details? } }`. +- The `data` field always carries the resource as defined by its schema. No injected derived fields. +- Cross-cutting / per-request data (pagination, translation, enrichment, interaction state) lives in `meta` with a stable, typed shape. +- Field selection is expressed as **named views** whose shapes are statically known and Zod-validated. There is no `Partial` leak. +- Code is camelCase end to end (Drizzle column TS prop names, Zod DTOs, services). The response interceptor converts `data`/`meta` to snake_case at the wire boundary; the wire format stays snake_case. +- Non-JSON responses (streams, HTML, redirects) opt out cleanly via a single decorator. + +## Non-Goals + +- GraphQL or JSON:API style resource normalization. The few consumers we have do not benefit enough to justify the payload and client-side overhead. +- Backwards compatibility with V1. Consumers will be updated in lockstep. +- A formal API versioning scheme. There is no `/api/v2` namespace — the existing routes are rewritten. + +## Design + +### §1. Response Envelope + +Success and error responses are disjoint: + +```jsonc +// success +{ "data": T, "meta"?: ResponseMeta } + +// error +{ "error": { "code": "STABLE_CODE", "message": "...", "details"?: {...} } } +``` + +Consumers branch on `'data' in res`. HTTP status codes remain semantically correct (2xx success, 4xx client error, 5xx server error); the envelope is in addition to, not in place of, status codes. + +`ResponseInterceptor` is rewritten so that: + +- A controller returning a value `T` is wrapped as `{ data: T }`. +- A controller returning `{ data, meta }` (the builder output) is passed through. +- A controller returning `undefined` produces `204 No Content` with no body. +- A `throw` is caught by the global exception filter and emitted as an error envelope. + +`JSONTransformInterceptor` is removed — its `toJSON` recursion and `__v` deletion are obsolete (the Drizzle data layer returns plain objects, and `__v` was a Mongoose remnant). Its third job, camelCase → snake_case conversion, is kept but folded into `ResponseInterceptorV2` (see §2). + +### §2. Case Convention: camelCase in Code, snake_case on the Wire + +> Revised. An earlier draft of this section moved snake_case onto the schema layer (Drizzle column TS prop names + Zod DTOs). That forced every service, repository, and controller to spell DB fields in snake_case — too much friction in business code — so the decision was reversed: the code is camelCase end to end and a single interceptor converts at the boundary. + +The codebase is camelCase: Drizzle column TS property names, Zod DTOs, service and controller code. The DB column names stay snake_case (each column keeps its explicit name string, e.g. `contentFormat: text('content_format')`). + +```ts +posts = pgTable('posts', { + createdAt: createdAt(), + contentFormat: text('content_format').notNull(), + isPublished: boolean('is_published').notNull().default(true), +}) +``` + +`ResponseInterceptorV2` converts the response to snake_case at the wire boundary. After envelope shaping it runs `transformResponseCase` over `data` and `meta`: + +- Object keys shaped like a camelCase identifier are converted (`createdAt` → `created_at`). Keys that are not identifiers — numeric ids, URLs, dotted paths — are left verbatim, so `meta.enrichments` (URL-keyed) and id-keyed `meta.interaction` / `meta.translation` maps survive untouched. +- `Date` instances and primitives pass through unchanged. +- The error envelope is produced by `AppExceptionFilter` and is not case-transformed; its `code` is already `SCREAMING_SNAKE`. + +A handler opts a field subtree out of conversion with `@BypassCaseTransform([paths])` — paths root at `data`, use dotted segments, and `[]` marks an array level (`'items[].rawPayload'`). The matched subtree is emitted verbatim. Use it for free-form maps (user-defined JSON columns, snippet payloads) whose keys must reach the client unchanged. + +Consequences: + +- Service, repository, and controller code refers to `.createdAt`, `.isPublished` directly — camelCase throughout. +- Zod DTOs are camelCase. +- There is no per-controller `snakeCaseKeys` call and no `case.util.ts`; the single interceptor owns wire casing. + +### §3. Named Views + +Field selection is replaced with a finite set of named views per resource. Each view is a Zod schema picked from the full resource schema; consumers select a view by name. + +```ts +// src/modules/post/post.views.ts +import { PostSchema } from './post.schema' + +export const PostViews = { + card: PostSchema.pick({ + id: true, title: true, slug: true, summary: true, + category: true, created_at: true, cover: true, + }), + summary: PostSchema.pick({ + id: true, title: true, slug: true, summary: true, tags: true, + category: true, created_at: true, modified_at: true, + }), + detail: PostSchema, +} as const + +export type PostView = keyof typeof PostViews +export type PostOf = z.infer<(typeof PostViews)[V]> +``` + +Controller usage: + +```ts +@Get('/') +async list( + @Query('view') view: PostView = 'card', + @Query() query: PostPagerDto, +) { + const rows = await this.postService.listPaginated({ ...query }) + const schema = PostViews[view] + const data = rows.data.map((row) => schema.parse(row)) + return { + data, + meta: new MetaObjectBuilder() + .view(view) + .pagination(rows.pagination) + .build(), + } +} +``` + +Rules: + +- The query parameter is `?view=`. Unrecognized names produce a 400 `INVALID_VIEW` error. +- Every endpoint declares a default view (list endpoints default to `card`, detail endpoints default to `detail`). +- New fields added to a resource must be explicitly assigned to a view; the default is "not included anywhere". This prevents accidental leaks. +- The response carries `meta.view: ''` so consumers can statically narrow the type. +- Server-side, the view name may be forwarded to the service for DB-level `SELECT` projection. This is an optional follow-up optimization, not required by the initial migration. + +### §4. Meta Convention and `MetaObjectBuilder` + +The `meta` object is the home for everything that is not part of the resource schema: pagination, translation, interaction, enrichment, related references. Its top-level keys form a closed set, validated by Zod. + +```ts +// src/common/response/meta.types.ts +export const PaginationSchema = z.object({ + page: z.number().int().positive(), + size: z.number().int().positive(), + total: z.number().int().nonnegative(), + total_pages: z.number().int().nonnegative(), +}) + +export const ArticleTranslationSchema = z.object({ + is_translated: z.boolean(), + source_lang: z.string().optional(), + target_lang: z.string().optional(), + model: z.string().optional(), + translated_at: z.date().optional(), + title: z.string().optional(), + text: z.string().optional(), + subtitle: z.string().nullable().optional(), + summary: z.string().nullable().optional(), + tags: z.array(z.string()).optional(), + content: z.string().optional(), + content_format: z.string().optional(), + available_translations: z.array(z.string()).optional(), +}) + +// One resource's translation: its article body (`article`) plus translated +// referenced-entity fields keyed by dotted path (`fields`, e.g. `category.name`). +// `.strict()` lets the union in ResponseMetaSchema tell a single entry apart +// from an id-keyed map — snowflake ids never collide with `article`/`fields`. +export const EntryTranslationSchema = z + .object({ + article: ArticleTranslationSchema.optional(), + fields: z.record(z.string(), z.string()).optional(), + }) + .strict() + +export const InteractionMetaSchema = z + .object({ + is_liked: z.boolean().optional(), + like_count: z.number().int().nonnegative().optional(), + read_count: z.number().int().nonnegative().optional(), + }) + .strict() + +export const ResponseMetaSchema = z.object({ + pagination: PaginationSchema.optional(), + view: z.string().optional(), + translation: z + .union([ + EntryTranslationSchema, + z.record(z.string(), EntryTranslationSchema), + ]) + .optional(), + interaction: z + .union([ + InteractionMetaSchema, + z.record(z.string(), InteractionMetaSchema), + ]) + .optional(), + enrichments: z.record(z.string().url(), EnrichmentEntrySchema).optional(), + related: z.array(RelatedRefSchema).optional(), +}) + +export type ResponseMeta = z.infer +export type ArticleTranslation = z.infer +export type EntryTranslation = z.infer +export type InteractionMeta = z.infer +``` + +`translation` carries two concerns. `article` is the resource's own body re-rendered in the target language — the output of `TranslationService.translateArticle()`. `fields` is a flat map of dotted paths to translated strings for *referenced* entities the resource embeds (`category.name`, `topic.name`); it replaces the in-place rewrite that `@TranslateFields` performed. A detail response carries one `EntryTranslation`; a list carries `Record`. + +Per-item derived data in list responses uses a `Record` map shape, not per-item spread. Example: + +```jsonc +{ + "data": [{ "id": "1", ... }, { "id": "2", ... }], + "meta": { + "view": "card", + "pagination": { ... }, + // map keys below ("1", "2") are item ids — they match data[].id + "interaction": { + "1": { "is_liked": true }, + "2": { "is_liked": false } + }, + "translation": { + "1": { + "article": { "is_translated": true, "title": "...", "target_lang": "en" }, + "fields": { "category.name": "..." } + } + } + } +} +``` + +The builder constructs and validates `meta`: + +```ts +// src/common/response/meta-builder.ts +export class MetaObjectBuilder { + private meta: Partial = {} + + pagination(p: z.input): this { + this.meta.pagination = p + return this + } + view(name: string): this { + this.meta.view = name + return this + } + translation(t: EntryTranslation | Map): this { + this.meta.translation = t instanceof Map ? Object.fromEntries(t) : t + return this + } + interaction(i: InteractionMeta | Map): this { + this.meta.interaction = i instanceof Map ? Object.fromEntries(i) : i + return this + } + enrichments(e: Record): this { + this.meta.enrichments = e + return this + } + related(r: RelatedRef[]): this { + this.meta.related = r + return this + } + + build(): ResponseMeta { + return ResponseMetaSchema.parse(this.meta) + } +} +``` + +Adding a new top-level meta key requires editing both `ResponseMetaSchema` and adding a builder method. This is intentional: it forces a code review for every new cross-cutting concern. + +### §5. Error Envelope + +Errors carry a stable machine code, a human-readable message, and optional structured details. + +```ts +// src/common/response/error.types.ts +export const ErrorResponseSchema = z.object({ + error: z.object({ + code: z.string(), + message: z.string(), + details: z.record(z.string(), z.unknown()).optional(), + }), +}) +``` + +Error codes follow `SCREAMING_SNAKE_CASE`, grouped by domain: + +``` +POST_NOT_FOUND +POST_UNPUBLISHED +AUTH_INVALID_CREDENTIALS +AUTH_SESSION_EXPIRED +VALIDATION_FAILED // details: { issues: ZodIssue[] } +RATE_LIMITED +INTERNAL_ERROR +HTTP_ERROR // catch-all for HttpException without an explicit code +``` + +A typed exception class carries the code: + +```ts +export class AppException extends HttpException { + constructor( + public readonly code: string, + message: string, + status: number, + public readonly details?: Record, + ) { + super({ code, message, details }, status) + } +} + +export class PostNotFoundException extends AppException { + constructor(id?: string) { + super('POST_NOT_FOUND', 'Post not found', 404, id ? { id } : undefined) + } +} +``` + +A global `AppExceptionFilter` maps every thrown error to the envelope: + +```ts +@Catch() +export class AppExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + const res = host.switchToHttp().getResponse() + if (exception instanceof AppException) { + return res + .status(exception.getStatus()) + .send({ + error: { + code: exception.code, + message: exception.message, + details: exception.details, + }, + }) + } + if (exception instanceof ZodError) { + return res.status(400).send({ + error: { + code: 'VALIDATION_FAILED', + message: 'Validation failed', + details: { issues: exception.issues }, + }, + }) + } + if (exception instanceof HttpException) { + return res.status(exception.getStatus()).send({ + error: { code: 'HTTP_ERROR', message: exception.message }, + }) + } + return res.status(500).send({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + } +} +``` + +Existing custom exceptions (`CannotFindException`, etc.) are migrated to `AppException` subclasses with explicit codes. + +### §6. Write-Method Response + +POST, PATCH, PUT, and DELETE always return the affected entity inside `{ data }`. Consumers do not need a separate refetch to update their UI. + +```ts +@Post('/') +async create(@Body() dto: PostDto) { + const created = await this.postService.create(dto) + return { data: PostViews.detail.parse(created) } // 201 +} + +@Patch('/:id') +async update(@Param() { id }: EntityIdDto, @Body() dto: PartialPostDto) { + const updated = await this.postService.update(id, dto) + return { data: PostViews.detail.parse(updated) } // 200 +} + +@Delete('/:id') +async remove(@Param() { id }: EntityIdDto) { + const deleted = await this.postService.delete(id) + return { data: PostViews.card.parse(deleted) } // 200, card view enough for cache invalidation +} +``` + +Conventions: + +- POST → 201, body = `{ data: }` +- PATCH/PUT → 200, body = `{ data: }` +- DELETE → 200, body = `{ data: }` +- Bulk write (e.g., batch delete) → `{ data: { count: N, ids?: string[] } }`. The affected count is the operation's primary result, not cross-cutting request metadata, so it stays in `data` and out of `meta`. Bulk delete does not echo full deleted entities. +- Long-running operations (build trigger, queued job) → `{ data: { task_id, status } }`; consider `@RawResponse` only if streaming progress. + +### §7. `@RawResponse` Decorator (replaces `@HTTPDecorators.Bypass`) + +Bypass was originally an escape hatch from the `ResponseInterceptor` + `JSONTransformInterceptor` stack. `@RawResponse` opts a route out of the whole envelope-and-casing pipeline; its only purpose now is **the response body is not JSON** (`@BypassCaseTransform` handles the narrower case of skipping casing for a JSON subtree): + +- binary streams (file download, image proxy) +- HTML (pageproxy, render controllers) +- redirects (shortlinks) +- RSS / XML feeds (different content type) +- snippet routes (user-defined content types) + +The decorator is renamed `@RawResponse` to make this semantic explicit. Several existing `Bypass` usages can be removed entirely because their responses can fit inside the envelope: + +- `/ping` health → `{ data: { ok: true } }` +- `/server-time` → `{ data: }` +- webhooks → unless a third party requires a specific shape, follow the envelope + +Error handling rule: even on a `@RawResponse` route, the `AppExceptionFilter` still applies. A thrown `AppException` emits a JSON error envelope (with `Content-Type: application/json`), regardless of what the success response would have looked like. This keeps error shape uniform for monitoring/Sentry. + +### §8. Translation, Enrichment, and Interaction Integration + +The current `@TranslateFields` decorator + `translation-entry.interceptor.ts` mutates `data` in place along JSONPath-like strings (`data[].category.name`). This is the main source of "schema-extrinsic data mixed in". It is removed. + +In the new design, cross-cutting per-request data is collected by the controller and pushed into `meta` via the builder. Consumers learn about translation/interaction state by reading `meta`, not by detecting injected fields. + +```ts +@Get('/:id') +async getById( + @Param() { id }: EntityIdDto, + @Lang() lang?: string, + @Ip() ip?: string, +) { + const post = await this.postService.findById(id) + if (!post) throw new PostNotFoundException(id) + + const [article, enrichments, isLiked, categoryName] = await Promise.all([ + lang + ? this.translationService.translateArticle({ + articleId: post.id, + targetLang: lang, + originalData: { + title: post.title, + text: post.text, + summary: post.summary, + tags: post.tags, + }, + }) + : null, + this.enrichmentService.collect(post), + this.countingService.isLiked(post.id, ip), + lang + ? this.translationService.translateField({ + key: post.category.name, + lang, + }) + : null, + ]) + + const data = PostViews.detail.parse(post) + + const meta = new MetaObjectBuilder() + .view('detail') + .translation({ + article: article ? toArticleTranslation(article) : undefined, + fields: categoryName ? { 'category.name': categoryName } : undefined, + }) + .enrichments(enrichments) + .interaction({ is_liked: isLiked }) + .related(post.related) + .build() + + return { data, meta } +} +``` + +The `article` passed to `.translation()` must match `ArticleTranslationSchema`. `translateArticle()` returns the internal `TranslationResult` (nested `translation_meta`, camelCase flags); a thin `toArticleTranslation()` mapper flattens it into `ArticleTranslation` before it reaches the builder. `EntryTranslationSchema.parse()` inside `build()` is the final guard. + +Consumer responsibility: when rendering translated content, prefer `meta.translation.article.title`; fall back to `data.title`. The fallback is explicit, not server-side magic. + +The `applyContentPreference` helper (currently mutates `data` based on a language preference) is removed. The choice of which language to render is now the client's, given the same set of inputs in `data` + `meta`. + +### §9. Pagination DTO Unification + +A single shared pagination DTO factory replaces per-module `PostPagerDto`, `NotePagerDto`, etc.: + +```ts +// src/shared/dto/pager.dto.ts +export const createPagerSchema = ( + sortKeys: TSort, +) => + z.object({ + page: z.coerce.number().int().positive().default(1), + size: z.coerce.number().int().positive().max(100).default(10), + view: z.string().optional(), + sort_by: z.enum(sortKeys).optional(), + sort_order: z.enum(['asc', 'desc']).default('desc'), + year: z.coerce.number().int().optional(), + }) + +export const PostPagerSchema = createPagerSchema([ + 'created_at', + 'modified_at', + 'title', +]).extend({ + category_ids: z.array(z.string()).optional(), + truncate: z.coerce.number().int().optional(), +}) +``` + +Service-layer pagination return shape is also unified: + +```ts +interface PaginateResult { + data: T[] + pagination: Pagination +} +``` + +The service returns `{ data, pagination }`; the controller passes `pagination` to the builder rather than letting it leak into the response root. + +### §10. File and Module Organization + +``` +src/common/response/ + envelope.types.ts # success/error envelope unions + meta.types.ts # ResponseMetaSchema + sub schemas + meta-builder.ts # MetaObjectBuilder + error.types.ts # ErrorResponseSchema, AppException + app-exception.filter.ts # AppExceptionFilter + raw-response.decorator.ts # @RawResponse + response.interceptor.ts # rewritten envelope interceptor + +src/common/views/ + view.types.ts # ViewDef, ViewOf helpers + +src/modules// + .schema.ts # Zod + Drizzle inferred + .views.ts # per-resource view definitions + .controller.ts # uses Views + MetaObjectBuilder + .service.ts # returns raw rows + pagination +``` + +Layering rules: + +- The **service** layer returns raw rows (or `PaginateResult`). It does not know about views or meta. +- The **controller** layer parses raw rows through a view, builds meta, and returns `{ data, meta }`. +- The **interceptor** layer handles envelope wrapping (a bare `T` becomes `{ data: T }`) and content-type handling for `@RawResponse`. + +### §11. Migration Strategy + +Although the rewrite is breaking from the consumer perspective, the server-side refactor is staged to keep PRs reviewable: + +1. **Infrastructure PR.** Add `src/common/response/*` (envelope types, meta types, builder, error types, filter, `@RawResponse`, rewritten interceptor). Both `JSONTransformInterceptor` and the new interceptor coexist temporarily. No controller is migrated yet. +2. **Schema rename PRs (per module).** One PR per resource (post, note, page, comment, ...) that renames Drizzle column TS props and Zod DTO keys to snake_case and ripples the change through services, controllers, and tests. No DB SQL migration is required since only TS prop names change. +3. **Module migration PRs (per module).** Define `*.views.ts`, rewrite the controller to use `MetaObjectBuilder`, replace `@TranslateFields` usages with explicit translation calls, and switch exceptions to `AppException` subclasses. +4. **Cleanup PR.** Remove `JSONTransformInterceptor`, `@TranslateFields`, `translation-entry.interceptor.ts`, `snakecaseKeys*` utilities, and the legacy `Bypass` alias. + +Each stage is independently mergeable and independently testable. The consumer-side refactor (Yohaku, admin) is coordinated against the cleanup PR. + +### §12. Testing + +- **Envelope shape (e2e).** A sampling test covering one endpoint per resource asserts the success shape is `{ data, meta? }` and the error shape is `{ error: { code, message } }`. A small set of canonical error cases (not found, unauthorized, validation) is included. +- **View parsing (unit).** For each view definition, `safeParse` a full row to confirm the view picks exactly the intended fields, and `safeParse` an under-populated row to confirm required fields fail correctly. +- **Meta builder (unit).** A test per builder method asserts that the resulting `build()` validates against `ResponseMetaSchema` and that omitted keys are absent from the output. +- **Migration regression (e2e snapshots).** For 5–10 critical endpoints (post detail, post list, post create, post update, auth login, file upload, comment create), capture a snapshot of the response shape and diff before/after migration. This catches shape regressions that unit tests miss. + +## Resolved Decisions + +Both blocking design questions were resolved on 2026-05-15; the design above already reflects them. + +### `translation` meta shape (§4 vs §8) → nested `{ article, fields }` + +`TranslationMetaSchema` is removed. Translation meta is modeled by two schemas: `ArticleTranslationSchema` (the translated article body — output of `TranslationService.translateArticle()`) and `EntryTranslationSchema` = `{ article?: ArticleTranslation; fields?: Record }`. `ResponseMetaSchema.translation` is `EntryTranslation` for a detail response and `Record` for a list. The flat `title`/`summary`/`text`/`tags` fields from the old schema move under `article`; translated referenced-entity fields (`category.name`, `topic.name`) live in `fields`. `EntryTranslationSchema` and `InteractionMetaSchema` are `.strict()` so the `single | id-keyed-map` union is unambiguous — snowflake ids never collide with the literal keys `article`/`fields`/`is_liked`. `MetaObjectBuilder.translation()` accepts `EntryTranslation | Map`, matching the `§8` controller usage. + +*Rejected:* a separate id-keyed `entities` meta key for referenced-entity translations. It would break the closed six-key set and force every consumer into an id join to save a few short duplicated strings — not worth it (YAGNI). + +### `meta.count` (§6) → carried in `data`, not `meta` + +Bulk-write endpoints return `{ data: { count: N, ids?: string[] } }`. The affected count is the operation's primary result, not cross-cutting request metadata, so it has no place in `meta`; the closed six-key `ResponseMetaSchema` set is unchanged. `§6`'s earlier `{ data: entity[], meta: { count } }` was doubly wrong — verified against the codebase, bulk delete never returned an `entity[]` (`comment.batchDelete` returns no body, `subscribe.unsubscribeBatch` returns `{ deletedCount }`). + +## Open Questions + +All remaining items are non-blocking and deferred to implementation: + +- Exact list of named views per resource. The default trio (`card`, `summary`, `detail`) covers the common cases; uncommon resources may add or omit views. +- Whether to forward the view name to the service for DB-level `SELECT` projection. This is an optimization; the initial migration keeps the projection at the controller layer. +- Whether to add an `ApiResponse` OpenAPI decorator generator that infers the envelope + view from controller types. Useful for SDK generation, but not on the critical path. diff --git a/docs/superpowers/specs/2026-05-15-v2-phase2-module-recipe.md b/docs/superpowers/specs/2026-05-15-v2-phase2-module-recipe.md new file mode 100644 index 00000000000..2abb45b6a68 --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-v2-phase2-module-recipe.md @@ -0,0 +1,106 @@ +# V2 API — Phase 2 Per-Module Migration Recipe + +Companion to `2026-05-15-v2-api-response-design.md` and `...-migration-reference.md`. +This is the concrete, repeatable recipe for migrating one module's controller(s) +to the V2 response layer. Phases 0, 1 and Task 0.3 are already done and committed. + +## What already exists (do NOT rebuild — import and use) + +- `~/common/response/envelope.types.ts` — `SuccessEnvelope`, `ErrorEnvelope`. +- `~/common/response/meta.types.ts` — `ResponseMetaSchema` + sub-schemas + types. +- `~/common/response/meta-builder.ts` — `MetaObjectBuilder` (chainable; `.build()` validates). +- `~/common/response/error.types.ts` — `AppException` base class, `ErrorCodes`. +- `~/common/response/app-exception.filter.ts` — `AppExceptionFilter`. +- `~/common/response/raw-response.decorator.ts` — `RawResponse` (non-JSON opt-out). +- `~/common/response/v2-controller.decorator.ts` — **`ResponseV2()`** — apply on a + migrated controller class. It makes legacy interceptors skip the route and + attaches `ResponseInterceptorV2` + `AppExceptionFilter`. One decorator opts in. +- `~/common/views/view.types.ts` — `parseView`, `ViewMap`, `ViewOf`. +- `~/shared/dto/pager.dto.ts` — `createPagerSchema(sortKeys)` factory. + +## Per-module steps + +For module `` (files under `apps/core/src/modules//`): + +1. **`.schema.ts`** — convert every Zod object key for resources/DTOs to + `snake_case` (Phase 1 already made the Drizzle columns snake_case, but the + repository `mapRow` still emits camelCase domain models — so also make the + repository emit snake_case for this module, OR have the controller parse the + raw snake_case row through the view; pick whichever is least invasive and + keep it consistent within the module). + +2. **`.views.ts`** (NEW) — named views per design §3: + ```ts + export const Views = { + card: Schema.pick({ ... }), + summary: Schema.pick({ ... }), + detail: Schema, + } as const + export type View = keyof typeof Views + ``` + List endpoints default to `card`, detail endpoints to `detail`. A resource + with few fields may have fewer views. + +3. **`.exceptions.ts`** (NEW) — typed exceptions extending `AppException` + with stable `SCREAMING_SNAKE_CASE` codes, e.g. + ```ts + export class NotFoundException extends AppException { + constructor(id?: string) { + super('_NOT_FOUND', ' not found', 404, id ? { id } : undefined) + } + } + ``` + Replace `CannotFindException` / `BusinessException` / `throw new HttpException` + in this module's controller+service with these. + +4. **`.service.ts` / `.repository.ts`** — the service returns plain rows + (or `{ data, pagination }` for lists). It does not build envelopes or meta. + +5. **`.controller.ts`** — rewrite every endpoint: + - Add `@ResponseV2()` to the controller class. + - Success: return `{ data }` or `{ data, meta }`. A bare value also works + (the interceptor wraps it) but prefer explicit `{ data }`. + - Parse rows through a view: `parseView(view, Views, row)` or + `Views[view].parse(row)`. + - Build `meta` with `new MetaObjectBuilder().view(v).pagination(p)...build()`. + - Lists: `?view=` query param (default `card`); pagination via + `createPagerSchema([...])`. Per-item derived data (interaction, translation) + goes in `meta` as `Record`, never spread onto items. + - Detail: single `meta.interaction` / `meta.translation` etc. + - Replace every `@TranslateFields` decorator with explicit translation calls + in the handler, pushed into `meta.translation` (design §8): article body + via `translateArticle()` flattened to `ArticleTranslationSchema` shape; + referenced-entity fields (`category.name`) into `translation.fields`. + - Remove `?select` handling and `applyContentPreference`. + - Write methods (design §6): POST → 201 `{ data: detail }`, PATCH/PUT → 200 + `{ data: detail }`, DELETE → 200 `{ data: card }`, bulk → `{ data: { count, ids? } }`. + - Throw the `.exceptions.ts` classes. + - Non-JSON routes (streams, HTML, redirects, XML/RSS): use `@RawResponse()` + on the method instead of the legacy `@HTTPDecorators.Bypass`. `AppExceptionFilter` + still applies, so thrown errors stay JSON-enveloped. + +6. **Tests** — update this module's specs to assert `{ data, meta? }` / + `{ error: { code, message } }`. Keep coverage; fix, don't delete. + +## Bypass-only / non-JSON modules + +For modules that exist only to serve non-JSON (feed RSS, pageproxy/render HTML, +sitemap XML, snippet user-defined types, serverless functions): just add +`@ResponseV2()` to the class and `@RawResponse()` to each non-JSON method, and +migrate exceptions to `AppException`. No views/meta needed. + +## Do NOT + +- Do not edit `~/common/response/*`, `~/common/views/*`, `pager.dto.ts`. +- Do not delete `JSONTransformInterceptor`, legacy `ResponseInterceptor`, + `@TranslateFields`, `translation-entry.interceptor.ts`, `case.util.ts`, + the `Bypass` alias — that is Phase 3. +- Do not touch modules outside your assigned batch beyond the MINIMAL change + needed to keep them compiling. + +## Success criteria (every batch, before returning) + +- `pnpm -C apps/core run typecheck` exits 0. +- `pnpm -C apps/core run lint` exits 0. +- `cd apps/core && NODE_ENV=development pnpm exec vitest run` ends with 0 failed. +- Commit on the worktree branch: `refactor(api): Phase 2 — migrate `. diff --git a/docs/superpowers/specs/2026-05-15-v2-phase3-cleanup-plan.md b/docs/superpowers/specs/2026-05-15-v2-phase3-cleanup-plan.md new file mode 100644 index 00000000000..955a86c5678 --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-v2-phase3-cleanup-plan.md @@ -0,0 +1,79 @@ +# V2 API — Phase 3 Cleanup Plan + +Companion to `2026-05-15-v2-api-response-design.md` §11 and the migration +reference Tasks 3.1–3.4. Executable only AFTER all Phase 2 module batches are +merged and `pnpm -C apps/core run typecheck` + the full test suite are green on +master. Each task below is build-green-verifiable on its own. + +## Pre-flight (before starting Phase 3) + +Confirm zero remaining usages, module by module: + +``` +grep -rn "@TranslateFields\|HTTPDecorators.Bypass\|\\bBypass\\b" apps/core/src/modules +grep -rn "JSONTransformInterceptor\|snakecaseKeys\|case.util" apps/core/src +grep -rn "TranslationEntryInterceptor\|translation-entry" apps/core/src +``` + +Any hit = that module was not fully migrated in Phase 2; fix it before Phase 3. + +## Task 3.1 — delete legacy interceptors + decorator, promote V2 interceptor + +Delete: +- `apps/core/src/common/interceptors/json-transform.interceptor.ts` +- `apps/core/src/common/interceptors/response.interceptor.ts` (the LEGACY one) +- `apps/core/src/common/interceptors/translation-entry.interceptor.ts` +- `apps/core/src/common/decorators/translate-fields.decorator.ts` +- `apps/core/src/utils/case.util.ts` + +Then make `ResponseInterceptorV2` the single, unconditional response interceptor: +- It currently lives at `apps/core/src/common/response/response.interceptor.ts` + and is attached per-controller by `@ResponseV2()`. With every controller + migrated, drop the per-controller gating: register `ResponseInterceptorV2` + once as a global `APP_INTERCEPTOR` in `app.module.ts`, and remove the + `UseInterceptors(ResponseInterceptorV2)` line from `v2-controller.decorator.ts`. +- Optionally move the file to `common/interceptors/response.interceptor.ts` + (the legacy name is now free) and update imports. +- `app.module.ts`: remove the `JSONTransformInterceptor`, legacy + `ResponseInterceptor`, and `TranslationEntryInterceptor` `APP_INTERCEPTOR` + providers and their imports. + +`@ResponseV2()` becomes either a no-op kept temporarily or is removed from all +controllers along with `RESPONSE_V2_METADATA`. Decide based on whether the +legacy filter still needs the marker — once `AllExceptionsFilter` is replaced +(Task 3.3 path) the marker can go. + +## Task 3.2 — remove the `Bypass` alias + +In `apps/core/src/common/decorators/http.decorator.ts`: remove the `Bypass` +export and the `HTTPDecorators.Bypass` entry; keep `RawResponse` as the only +non-JSON opt-out. Confirm zero importers of `Bypass` first. + +## Task 3.3 — migrate generic exceptions to `AppException` + +Rewrite to extend `AppException` with stable codes: +- `apps/core/src/common/exceptions/cant-find.exception.ts` → code `NOT_FOUND` (404) +- `apps/core/src/common/exceptions/ban-in-demo.exception.ts` → code `DEMO_FORBIDDEN` (403) +- `apps/core/src/common/exceptions/biz.exception.ts` → keep `BusinessException` + but make it carry a stable code; map `ErrorCodeEnum` values to SCREAMING_SNAKE codes +- `apps/core/src/common/exceptions/no-content-canbe-modified.exception.ts` → code `NO_CONTENT_MODIFIABLE` (400) + +Then `AppExceptionFilter` can be promoted to the global `APP_FILTER`, replacing +`AllExceptionsFilter` — but preserve `AllExceptionsFilter`'s side effects +(Bark push on throttle, `EventBusEvents.SystemException` broadcast, +`uncaughtException`/`unhandledRejection` hooks): fold them into +`AppExceptionFilter` or a thin wrapper before deleting `AllExceptionsFilter`. + +## Task 3.4 — documentation + +- `apps/core/CLAUDE.md` — rewrite the "API Response Rules" section to describe + the `{ data, meta? }` / `{ error }` envelope, named views, `MetaObjectBuilder`, + `AppException`, `@ResponseV2`, `@RawResponse`. Add a short "writing a new + endpoint" guide. +- `2026-05-15-v2-api-response-design.md` — set `Status: Implemented`. +- Regenerate `packages/api-client` types and bump versions if applicable. + +## Success criteria + +`pnpm -C apps/core run typecheck` exits 0; `pnpm -C apps/core run lint` exits 0; +full vitest suite ends with 0 failed. Commit per task. diff --git a/docs/superpowers/specs/2026-05-16-v2-admin-migration-design.md b/docs/superpowers/specs/2026-05-16-v2-admin-migration-design.md new file mode 100644 index 00000000000..5d94b0d6c0e --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-v2-admin-migration-design.md @@ -0,0 +1,360 @@ +# V2 API — admin-vue3 Migration Design Spec + +**Status:** Draft — pending review +**Author:** Innei (with brainstorming assistance) +**Date:** 2026-05-16 +**Scope:** Migrating the admin-vue3 dashboard (`../admin-vue3`, Vue 3 + TSX) onto +the V2 API response envelope. This is workstream 4 of +`2026-05-16-v2-downstream-integration-design.md` (the parent spec), expanded into +a complete, executable plan. Execution happens in the `admin-vue3` repo; +`apps/core` backend prerequisites (parent spec §0) must land first. + +## Context + +The parent spec splits every success response into `{ data, meta }`: `data` is +the flat resource schema, `meta` carries per-request / cross-cutting data +(`pagination`, `interaction`, `translation`, `enrichments`, `related`, `view`, +`insights`). It rejected non-enumerable `$`-getters (lost on spread / JSON / +TanStack persistence) in favor of an explicit `metaFor(item, meta)` helper plus +one normalization chokepoint per frontend. + +admin-vue3 already has partial V2 plumbing in `apps/admin/src/utils/request.ts`: +its `ofetch` wrapper unwraps `{ data, meta }`, runs `simpleCamelcaseKeys`, parses +the error envelope (`data.error.code/message` → `BusinessError`), and returns +`{ data, pagination }` when `meta.pagination` is present. It **discards all other +`meta`**. + +## Survey — what admin actually consumes + +A grep across `.tsx` / `.vue` / `.ts` (admin uses Vue 3 **TSX**, not SFCs, for +most views) shows admin's per-request `meta` consumption is small and specific: + +| Field | Where | V2 source | +| --- | --- | --- | +| `readCount`, `likeCount` | `views/manage-posts/list.tsx` (table columns), `views/manage-notes/list.tsx` (table columns), `views/manage-posts/category.tsx` (post card) | `meta.interactionById[id]` (list) | +| `related` | `views/manage-posts/write.tsx` (editor's related-post picker) | `meta.related` (detail) | +| `enrichments` | `views/shorthand/index.tsx` (recently/shorthand cards) | `meta.enrichments` (response-level, URL-keyed) | + +admin does **not** display `isLiked`, `isTranslated`, or `translationMeta` on any +entity. (admin's AI-translation management views — `api/ai.ts`, `views/ai/` — are +a separate CRUD surface over translation *entities*, unrelated to response +`meta.translation`.) `pagination` is already handled. + +So the migration is contained: three consumption sites, plus model/type +alignment and named-view adoption. + +## Goals + +- admin renders `readCount` / `likeCount` / `related` / `enrichments` from V2 + `meta` with no regression. +- Per-request `meta` reaches views through the `api/*.ts` layer as plain merged + objects — view/component code stays largely unchanged, and merged objects + survive TanStack Query persistence (`apps/admin/src/lib/query-client.ts`). +- **Full static type safety**: every step of request → mapper → view-model is + statically checked against authoritative types; no `any` in any touched file; + the wire shape and the view-model shape are distinct, named types. +- List endpoints request the correct named `view`. + +## Non-Goals + +- A *runtime* dependency on `@mx-space/api-client`. admin keeps its `ofetch` + wrapper (it owns auth redirects, toast, cache-busting) and bundles no + api-client code. A **type-only** devDependency is in scope (see §0). +- Changing the `use-memo-fetch-data-list.ts` list hook or the list table + components. The normalization happens upstream in `api/*.ts`. +- Touching the AI-translation management views. + +## Design + +The unifying idea: **the `api/*.ts` method is the normalization chokepoint.** Each +method that needs `meta` calls `request.getWithMeta`, merges the required meta +back onto `data` as plain fields via `metaFor`, and returns the *same shape it +returns today*. Views, list hooks, and table components are untouched. + +### §0. Type architecture (foundational) + +Type safety hinges on one rule: **the wire `data` shape and the view-model shape +are distinct types, and neither is hand-maintained in admin.** + +**Wire types.** The flat V2 view shapes (`PostSummaryView`, `PostDetailView`, +`NoteSummaryView`, …) and the meta types (`ResponseMeta`, `InteractionMeta`, +`EntryTranslation`, `EnrichmentEntry`, `RelatedRef`) are imported **type-only** +from `@mx-space/api-client`: + +```ts +import type { PostSummaryView, ResponseMeta } from '@mx-space/api-client' +``` + +`import type` is fully erased at compile time: admin gains a `devDependency` on +api-client **for types only** — no runtime import, no bundle cost, no runtime +coupling. This honors the parent spec §5 intent (admin keeps its own `ofetch` +wrapper and bundles no api-client code) while making the types authoritative. +api-client is the single source of truth for V2 wire shapes — its exported view +types are the `z.infer` of the backend `apps/core/src/modules/*/*.views.ts` +schemas (produced by the api-client workstream, parent spec §6). Hand-mirroring +wire types in admin is **forbidden** — it is the exact drift hazard the parent +spec rejected for `metaFor`. + +The backend view schemas and `meta.types.ts` are **camelCase**; admin's +`simpleCamelcaseKeys` converts the snake_case wire before any typed code observes +the value, so the declared camelCase type matches the runtime value exactly. + +**Prerequisite:** `@mx-space/api-client` must export the V2 view types and meta +types from its public entry point. If the api-client workstream has not yet done +so, that export is a hard precondition for admin's typed work (§3 onward). + +**View-model types.** Defined in admin `~/models/*`, each as +`WireType & `: + +```ts +export type PostListItem = PostSummaryView & InteractionMeta +export type PostDetail = PostDetailView & { related?: RelatedRef[] } +``` + +`InteractionMeta`'s fields are all optional, so the intersection is exact and the +table's `row.readCount ?? 0` stays valid. The view-model type is what every +`api/*.ts` method returns and what views consume. + +**No `any`.** Every file touched by this migration is free of `any` in the +request → mapper → view-model chain. Pre-existing `any` in a touched file +(`models/post.ts` `meta?: any`, `write.tsx` `(r: any)`) is tightened to a real +type as part of the change. + +### §1. `request.getWithMeta` + +`apps/admin/src/utils/request.ts`. `transformResponse` currently discards `meta`. +Changing the existing `request.get`/`post`/… to return `{ data, meta }` would +break every call site that expects bare `data`. Instead, add parallel, +fully-generic methods: + +```ts +import type { ResponseMeta } from '@mx-space/api-client' + +interface WithMeta { data: T; meta: ResponseMeta } + +request.getWithMeta(url: string, options?: RequestOptions): Promise> +// postWithMeta / putWithMeta added only if a write endpoint needs meta +``` + +`T` is always a **wire view type** (§0), never a view-model type. `getWithMeta` +runs the same `simpleCamelcaseKeys` + envelope detection, returns +`{ data, meta }`, and defaults `meta` to `{}` (a valid `ResponseMeta` — every key +is optional) when the response is not an envelope. The existing bare-`data` +methods are unchanged; only api modules that need `meta` switch to `*WithMeta`. +The `T as` cast inside `transformResponse` is replaced with a checked narrowing +so no `any` leaks into the generic boundary. + +### §2. `metaFor` (mirrored runtime, imported types) + +Per parent spec §5 the runtime `metaFor` is **mirrored** in admin (no runtime +import). Its types, however, are imported (§0) — so the signature cannot drift, +only behavior can, which fixtures cover. Add `apps/admin/src/utils/meta-for.ts`: + +```ts +import type { ResponseMeta, InteractionMeta, EntryTranslation } + from '@mx-space/api-client' + +export function metaFor( + item: { id: string }, + meta: ResponseMeta | undefined, +): { interaction?: InteractionMeta; translation?: EntryTranslation } { + if (!meta) return {} + return { + interaction: meta.interactionById?.[item.id] ?? meta.interaction, + translation: meta.translationById?.[item.id] ?? meta.translation, + } +} +``` + +The same input/output JSON fixtures used by the api-client copy are committed +under `apps/admin/src/utils/__tests__/meta-for.fixtures/` so the two +implementations cannot drift in behavior. + +### §3. Model realignment + +Each model in `~/models/*` is recast as a view-model type per §0: +`WireType & `, with the wire type imported from api-client. +Hand-maintained field lists are deleted, not edited. + +- `models/post.ts` — `PostListItem = PostSummaryView & InteractionMeta`; + `PostDetail = PostDetailView & { related?: RelatedRef[] }`. The old + hand-written `PostModel` interface (and its `meta?: any`, inline `related`, + `Category`) is removed. +- `models/note.ts` — `NoteListItem = NoteSummaryView & InteractionMeta`; + `NoteDetail = NoteDetailView`. +- `models/recently.ts` — `RecentlyItem = RecentlyView & { enrichments?: Record }`. +- `models/base.ts` — `PaginateResult` keeps its shape; `Pager` aligns to the + V2 `pagination` type. `BaseModel.created` (a pre-V2 remnant) is removed once + its call sites are confirmed dead. +- Other models (`page`, `comment`, `draft`, `category`, `topic`, …) — replace + the hand-written interface with the imported wire view type; these carry no + per-request meta. + +After realignment, `tsc` is the audit: any call site reading a field absent from +`WireView & Meta` is a compile error and is fixed at that site. + +### §4. Named views (`?view=`) + +V2 list endpoints accept `?view=` and default to `card`. admin's post list +(`list.tsx`) reads `category`, `tags`, `modifiedAt`, `pinAt`, `summary`, +`isPublished` — the `summary` view (`card` + `tags` + `modifiedAt`) covers all. +The post editor needs the full `detail` view. + +Update `api/*.ts` methods to pass `view`, and type the call against the matching +wire type: + +- `postsApi.getList` → `?view=summary`, `getWithMeta`; + `postsApi.getById` → `?view=detail`, `getWithMeta`. +- `notesApi.getList` → `?view=summary`; `notesApi.getById` → `?view=detail`. +- `pagesApi` similarly. + +The view name passed and the wire type parameter must agree — a mismatch is the +one place type safety cannot catch, so the pairing is kept on one line and +covered by a test asserting the returned shape. + +### §5. List normalization — posts & notes + +`postsApi.getList` and `notesApi.getList` become typed normalizers: + +```ts +import type { PostSummaryView } from '@mx-space/api-client' +import type { PaginateResult } from '~/models/base' +import type { PostListItem } from '~/models/post' + +getList: async (params): Promise> => { + const { data, meta } = await request.getWithMeta('/posts', { + params: { ...params, view: 'summary' }, + }) + return { + data: data.map((row): PostListItem => ({ + ...row, + ...metaFor(row, meta).interaction, + })), + pagination: meta.pagination!, + } +} +``` + +`getWithMeta` types `data` as `PostSummaryView[]`; +`metaFor(row, meta).interaction` is `InteractionMeta | undefined` (spreading +`undefined` is a typed no-op); the `: PostListItem` return annotation makes TS +verify the merge produces exactly the view-model. The return type stays +`PaginateResult`, so `use-memo-fetch-data-list.ts`, +`useMemoPostList` / `useMemoNoteList`, and the `list.tsx` table columns are +**unchanged** — each row carries the counts as plain enumerable fields. +`views/manage-posts/category.tsx` likewise unaffected. + +### §6. Post editor — `related` + +`views/manage-posts/write.tsx` reads `postData.related` to seed the related-post +picker. `postsApi.getById` merges `meta.related` onto the returned post: + +```ts +getById: async (id: string): Promise => { + const { data, meta } = await request.getWithMeta( + `/posts/${id}`, + { params: { view: 'detail' } }, + ) + return { ...data, related: meta.related } +} +``` + +`meta.related` is `RelatedRef[] | undefined`, matching `PostDetail['related']`. +`write.tsx` is retyped to `PostDetail`, and its `(r: any)` callback becomes +`(r: RelatedRef)`. `usePostQuery` caches a plain object — TanStack-safe. + +### §7. Recently / shorthand — `enrichments` + +The trickiest site. `views/shorthand/index.tsx` reads `item.enrichments` as a +per-item `Record` and iterates it. V2 returns +`meta.enrichments` as a **response-level** URL-keyed map shared across all items. + +`recentlyApi.getAll` reconstructs a per-item slice — for each recently item, +extract the URLs referenced in `item.content` and pick those entries out of +`meta.enrichments`: + +```ts +getAll: async (): Promise => { + const { data, meta } = await request.getWithMeta('/recently/all') + const all = meta.enrichments ?? {} + return data.map((item): RecentlyItem => ({ + ...item, + enrichments: pickEnrichmentsForContent(item.content, all), + })) +} +``` + +`pickEnrichmentsForContent(content: string, all: Record): +Record` lives next to the recently api module, is fully +typed, and is unit-tested. `shorthand/index.tsx` line 348 also *writes* +`enrichments` optimistically after a probe — that local-state update keeps +working since `enrichments` is a plain typed field on `RecentlyItem`. + +### §8. Error envelope & casing — verify only + +`request.ts` already maps `data.error.code/message` → `BusinessError` and runs +`simpleCamelcaseKeys`. Parent spec §0.1 makes `meta` camelCase in `apps/core` +code; the wire stays snake_case, so `simpleCamelcaseKeys` output is unchanged and +`Pager` still resolves. No change — add a regression test only. + +## File change inventory + +| File | Change | +| --- | --- | +| `apps/admin/package.json` | Add `@mx-space/api-client` as a **devDependency** (type-only) | +| `apps/admin/src/utils/request.ts` | Add typed `getWithMeta` (+ `postWithMeta`/`putWithMeta` if needed) | +| `apps/admin/src/utils/meta-for.ts` | New — mirrored `metaFor`, imported types | +| `apps/admin/src/utils/__tests__/meta-for.*` | New — fixture-shared tests | +| `apps/admin/src/api/posts.ts` | `getList`/`getById` typed normalizers + `view` param | +| `apps/admin/src/api/notes.ts` | `getList`/`getById` same | +| `apps/admin/src/api/pages.ts` | `view` param; meta merge if any | +| `apps/admin/src/api/recently.ts` | `getAll` enrichment reconstruction (§7) | +| `apps/admin/src/models/post.ts`, `note.ts`, `recently.ts`, `base.ts`, … | Recast as view-model types (§3) | +| `views/manage-posts/list.tsx`, `manage-notes/list.tsx`, `manage-posts/category.tsx`, `manage-posts/write.tsx`, `shorthand/index.tsx` | Mostly unchanged; retype `write.tsx` (§6); fix any `tsc` errors surfaced by §3 | + +## Sequencing + +1. **Prerequisites:** + - parent spec §0 lands in `apps/core` (camelCase `meta`, `*ById` keys, flat + `data`); + - `@mx-space/api-client` exports V2 view types + meta types (§0 prerequisite). +2. **§0 + §1 + §2** — devDependency, `getWithMeta`, `metaFor` mirror. No behavior + change yet. +3. **§3** — model recast. `tsc` drives the call-site fixes. +4. **§4 + §5 + §6** — posts/notes api normalization + named views. Depends on + §0–§3. +5. **§7** — recently / shorthand enrichments. Depends on §1/§2. +6. **§8** — verification test. + +## Testing + +- `metaFor` — unit tests, shared fixtures (list lookup by id, detail direct, + absent meta, absent entry). +- `postsApi.getList` / `notesApi.getList` — mocked `getWithMeta`, assert each row + carries `readCount`/`likeCount` and `pagination` is correct. +- `postsApi.getById` — assert `related` merged from `meta.related`. +- `pickEnrichmentsForContent` — unit tests for URL extraction + slicing. +- `request.getWithMeta` — envelope with/without `meta`; error envelope still maps + to `BusinessError`. +- **`tsc --noEmit` is the type-safety gate** — the migration is not done until it + passes with no `any` introduced. +- Manual smoke: post list / note list count columns, post editor related picker, + shorthand enrichment cards. + +## Risks + +- **api-client has not exported V2 types.** §0's `import type` precondition. If + the api-client workstream lags, admin's typed work (§3+) is blocked. Mitigation: + prioritize the type-export slice of the api-client workstream; it is small and + independent of api-client's runtime changes. +- **Backend §0 not landed.** admin against a pre-§0 build sees snake_case `meta` + and the old union keys — `metaFor` breaks. Hard ordering dependency. +- **§4 view/type pairing.** The `?view=` string and the `getWithMeta` type + parameter are coupled by convention, not by the compiler. Covered by a + shape-assertion test per endpoint. +- **`meta.enrichments` reconstruction (§7).** URL extraction from `content` is + heuristic. If the backend can return recently enrichments per-item instead, §7 + collapses to a trivial merge — confirm with the `apps/core` recently controller + before implementing. +- **Mirrored `metaFor` behavior drift.** Mitigated by shared fixtures; the + signature cannot drift since types are imported. diff --git a/docs/superpowers/specs/2026-05-16-v2-downstream-integration-design.md b/docs/superpowers/specs/2026-05-16-v2-downstream-integration-design.md new file mode 100644 index 00000000000..4ecf0031253 --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-v2-downstream-integration-design.md @@ -0,0 +1,228 @@ +# V2 API Downstream Integration — Design Spec + +**Status:** Draft — pending review +**Author:** Innei (with brainstorming assistance + Codex review) +**Date:** 2026-05-16 +**Scope:** How the two downstream consumers (Yohaku, admin-vue3) integrate the V2 +response envelope (`{ data, meta }`), and the small backend cleanup that +integration depends on. No new endpoints. Builds on +`2026-05-15-v2-api-response-design.md`. + +## Motivation + +V2 moved per-request derived fields (`isLiked`, `isTranslated`, `translationMeta`, +`enrichments`, `related`) off the entity and into a separate `meta` object. The +wire envelope work is done; both consumers already unwrap `{ data, meta }`, +camelCase the body, and parse the error envelope. What is missing is a coherent, +low-churn way for per-request `meta` to reach the UI. + +An earlier draft proposed attaching non-enumerable `$interaction` / `$translation` +getters to each entity. That approach was rejected after review: non-enumerable +getters are silently lost on `{...spread}`, `JSON.stringify`, deep clone, and — +critically — TanStack Query dehydration/persistence, which admin-vue3 already uses +(`apps/admin/src/lib/query-client.ts`). Cached entities round-trip through JSON, +so the getters would vanish in production. Getter magic is also undebuggable: a +logged or copied entity shows no sign of the meta-derived fields the UI renders. + +This spec adopts the reviewed alternative: a plain, explicit `metaFor(item, meta)` +helper plus one normalization chokepoint per frontend. + +## Goals + +- Per-request `meta` reaches the UI through an explicit, testable, serializable + path — no hidden descriptors. +- Call-site churn in each frontend is contained to one normalization layer, not + spread across every component. +- `data` is genuinely flat (the resource schema, nothing injected), so the + integration is not built on a contract the backend silently violates. +- `meta` is camelCase in code, consistent with the rest of the codebase. +- list vs detail `meta` shape is unambiguous — no object-shape sniffing. + +## Non-Goals + +- Migrating admin-vue3 onto `@mx-space/api-client`. Its `ofetch` wrapper owns + auth redirects, toast behavior, and cache-busting; replacing it is out of scope. +- Backwards compatibility with the V1 entity shape. Consumers update in lockstep. +- Re-merging `meta` back onto the entity. The whole point of V2 is the split; + the helper keeps it explicit. + +## Design + +### §0. Backend prerequisites + +Downstream integration assumes `data` is flat and `meta` is camelCase. Three +backend debts must be paid first, in `apps/core`. + +**§0.1 — `meta.types.ts` to camelCase.** `src/common/response/meta.types.ts` +currently declares schema fields in snake_case (`is_liked`, `total_pages`, +`source_lang`, `available_translations`, …). This contradicts the V2 convention +("code is camelCase end to end; `ResponseInterceptorV2` snake_cases at the wire +boundary"). Convert every field in `meta.types.ts` to camelCase and update +`MetaObjectBuilder` call sites in controllers accordingly (e.g. +`.interaction({ is_liked })` → `.interaction({ isLiked })`). The wire output is +unchanged — the interceptor still produces snake_case. + +**§0.2 — Explicit list vs detail meta keys.** `ResponseMetaSchema` currently +unions a single object with an id-keyed record for `interaction` and +`translation`, and relies on `EntryTranslationSchema.strict()` so snowflake ids +cannot collide with the `article`/`fields` keys. This is fragile (empty objects, +future non-snowflake ids). Replace the union with two distinct keys: + +```ts +// detail responses +interaction: InteractionMetaSchema.optional(), +translation: EntryTranslationSchema.optional(), +// list responses +interactionById: z.record(z.string(), InteractionMetaSchema).optional(), +translationById: z.record(z.string(), EntryTranslationSchema).optional(), +``` + +`MetaObjectBuilder` gets paired methods: `interaction(single)` / +`interactionById(map)`, `translation(single)` / `translationById(map)`. The +`.strict()` snowflake-collision workaround is removed. + +**§0.3 — Pay down the flat-data debt.** `data` still carries injected derived +fields in detail endpoints: + +- `post.controller.ts` detail builds `baseDoc = { ...postDocument, + hasInsightsInLocale, related: translatedRelated }`, then + `attachEnrichments(baseDoc)` adds enrichments onto `data`. +- `note.controller.ts` detail builds `currentData = { ...current, + hasInsightsInLocale }`. + +Move these out of `data`: + +- `related` → `meta.related` (key already exists). `meta.related` is itself + per-request, so the translated title is baked directly into each + `meta.related` item when the meta is built — the controller no longer mutates + `related` items spread on `data`. +- enrichments → `meta.enrichments` (key already exists); the controller stops + also spreading them onto `data`. +- `hasInsightsInLocale` → a new closed meta key `meta.insights: { hasInLocale: + boolean }` for detail responses. Adding it follows the §4 rule from the V2 + spec (edit `ResponseMetaSchema` + add a builder method). + +After §0.3, `data` for a detail response equals the resource's `detail` view and +nothing else. + +### §1. Client envelope contract (both consumers) + +Both consumers keep `data` and `meta`; neither discards `meta`. + +**api-client.** `core/client.ts` already unwraps the envelope and exposes a +`$meta` getter via `extractResponseMeta`. Bug: `extractResponseMeta` returns the +raw adapter response body's `meta`, which is **never** run through +`camelcaseKeys` — so `data` is camelCase while `$meta` is snake_case. Fix: +`$meta` must return camelCased meta, consistent with `data`. + +**admin-vue3.** `apps/admin/src/utils/request.ts` `transformResponse` currently +returns the bare unwrapped `data`, or `{ data, pagination }` when +`meta.pagination` is present, and discards the rest of `meta`. Changing it to +always return `{ data, meta }` would break every existing call site that expects +bare `data`. Instead, leave `request.get`/`post`/… unchanged and add a parallel +`request.getWithMeta` (and siblings as needed) that returns `{ data, meta }`. +Only the resources that consume per-request meta (posts, pages, notes) switch to +the `*WithMeta` variant; all other call sites are untouched. The returned +`{ data, meta }` is a plain serializable object, so it survives TanStack Query +persistence. + +### §2. The `metaFor` helper + +A plain, pure function — no descriptors, fully serializable, unit-testable: + +```ts +function metaFor(item: { id: string }, meta: ResponseMeta | undefined): { + interaction?: InteractionMeta + translation?: EntryTranslation +} { + if (!meta) return {} + return { + interaction: meta.interactionById?.[item.id] ?? meta.interaction, + translation: meta.translationById?.[item.id] ?? meta.translation, + } +} +``` + +Because §0.2 made the keys distinct, the helper needs no `Array.isArray` check +and no object-shape sniffing: list responses populate `*ById`, detail responses +populate the singular keys. Response-level meta (`enrichments`, `pagination`, +`view`, `related`, `insights`) is read directly off `meta`, not through +`metaFor`. + +### §3. Yohaku integration + +Yohaku already has a normalization layer in +`apps/web/app/data/content.server.ts` (4200+ lines): `normalizeGenericItems`, +`normalizeArticleTranslationMeta`, `normalizeArticleEnrichmentMap`, +`normalizeThinkingEnrichment`, and the `GenericItem` type. This layer is the +single chokepoint. + +The work: change these functions to source per-request fields from `meta` via +`metaFor` instead of from fields previously spread on the entity. `GenericItem`'s +shape (`isTranslated`, `translationMeta`, `enrichments`, `related`, …) does not +change — only where the normalizer reads them from. Components downstream of the +normalizer are untouched. While editing, remove stale snake_case reads (e.g. +`post.translation_meta` in `content.server.ts`). + +### §4. admin-vue3 integration + +`request.ts` carries `meta` per §1. Per resource that needs per-request meta +(posts, pages, notes), add a composable or API-layer mapper that calls `metaFor` +and exposes `interaction` / `translation` to the view. The `api/*.ts` modules and +their model types (`~/models/*`) are realigned to the flat `data` views; derived +fields are no longer on the model type. + +### §5. Helper distribution + +`@mx-space/api-client` exports `metaFor` and the meta types from a +framework-agnostic entry point (no axios/fetch/Vue/React dependency). Yohaku +imports it directly. admin-vue3 does **not** add an `@mx-space/api-client` +dependency; it **mirrors** `metaFor` in its own repo. Both copies are covered by +the same fixture set (identical input/output JSON fixtures committed to each +repo) so the implementations cannot drift. + +### §6. Types + +`ModelWithLiked` and `ModelWithTranslation` in `packages/api-client` are removed. +A `data` item is exactly its view type (e.g. `PostOf<'card'>`). Per-request meta +is obtained explicitly through `metaFor`, whose return type is independent of the +entity type. No wrapper type fuses entity and meta. + +## Sequencing + +The work splits into ordered workstreams: + +1. **Backend (§0)** — `apps/core`. Must land first; §1–§4 depend on flat `data` + and camelCase `meta`. Independently shippable. +2. **api-client (§1, §2, §5, §6)** — `packages/api-client`. Depends on §0. +3. **Yohaku (§3)** — depends on §2 (`metaFor` published). +4. **admin-vue3 (§1, §4, §5)** — depends on §0; mirrors `metaFor`. + +Workstreams 3 and 4 are independent of each other and can proceed in parallel +once their dependencies land. + +## Testing + +- **Backend:** existing V2 response e2e tests updated for the camelCase `meta` + and the `*ById` keys. New assertions that detail `data` carries no + `hasInsightsInLocale` / `related` / enrichments. +- **`metaFor`:** unit tests for list lookup by string id, detail direct object, + absent `meta`, and absent per-item entry. The same fixtures run against both + the api-client copy and the admin-vue3 copy (§5). +- **Yohaku:** the `content.server.ts` normalizers get fixture-based tests for the + new `meta`-sourced path. +- **admin-vue3:** `transformResponse` test confirming `meta` is no longer + dropped. + +## Risks + +- **Backend §0 is a breaking wire change.** Acceptable: V2 is unreleased and + consumers update in lockstep. The §0.2 rename and §0.1 casing change must land + before any consumer is pointed at the new build. +- **Mirrored `metaFor` drift.** Mitigated by the shared fixture set (§5). If + drift becomes a real problem, the fallback is admin-vue3 taking the + api-client dependency after all. +- **`content.server.ts` size.** The file is already 4200+ lines. This spec does + not require splitting it, but the normalizer edits are a reasonable occasion to + extract the normalization functions into their own module if it can be done + without disturbing unrelated code. diff --git a/docs/superpowers/specs/2026-05-16-v2-yohaku-integration-design.md b/docs/superpowers/specs/2026-05-16-v2-yohaku-integration-design.md new file mode 100644 index 00000000000..92bfb249de8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-v2-yohaku-integration-design.md @@ -0,0 +1,482 @@ +# V2 API — Yohaku Integration Design Spec + +**Status:** Draft — pending review +**Author:** Innei (with brainstorming assistance + Codex review) +**Date:** 2026-05-16 +**Scope:** Migrating the Yohaku frontend (`../Yohaku`, Next.js app under +`apps/web/src/`) onto the V2 API response envelope. This is workstream 3 of +`2026-05-16-v2-downstream-integration-design.md` (the parent spec), expanded into +a complete, executable plan. Execution happens in the Yohaku repo; the `apps/core` +backend prerequisites (parent spec §0) and a new `@mx-space/api-client` release +must land first. + +## Context + +The parent spec splits every success response into `{ data, meta }`: `data` is +the flat resource schema, `meta` carries per-request / cross-cutting data +(`pagination`, `interaction`, `translation`, `enrichments`, `related`, `view`, +`insights`). It rejected non-enumerable `$`-getters (lost on spread / JSON / +TanStack persistence) in favor of an explicit `metaFor(item, meta)` helper. + +**The parent spec §3 describes Yohaku incorrectly.** It assumes a 4200-line +`apps/web/app/data/content.server.ts` chokepoint with `normalizeGenericItems`, +`normalizeArticleTranslationMeta`, `normalizeArticleEnrichmentMap`, +`normalizeThinkingEnrichment`, and a `GenericItem` type. **None of these exist.** +The real Yohaku is a Next.js app rooted at `apps/web/src/`. It has **no +normalization layer**: it consumes the published npm package +`@mx-space/api-client@4.2.0` directly, and reads per-request fields +(`isTranslated`, `translationMeta`, `related`, `enrichments`, `hasInsightsInLocale`, +pagination) off entities at roughly 50 scattered component/page sites. So the +Yohaku integration is a **distributed migration**. This spec supersedes parent +spec §3. + +### Design stance — `data` and `meta` stay separate + +The admin spec (`2026-05-16-v2-admin-migration-design.md` §0/§5/§6) merges the +needed `meta` fields back onto `data` as plain fields so view code stays +unchanged. **This spec deliberately rejects that for Yohaku.** Re-merging +recreates the V1 fused entity at the frontend layer, pollutes the resource schema +with non-schema fields, and makes the backend's V2 `data`/`meta` split +meaningless at the point of consumption. Yohaku keeps `data` and `meta` as two +separate, plain, pure values end to end — from `queryFn`, through the TanStack +cache, to the component. No per-request field is ever written onto `data`. + +(If this principle is accepted, the admin spec should be revised to match — that +is a separate task, out of scope here.) + +### Verified backend behavior + +Three facts were verified against the current `apps/core` controllers and they +shape this spec: + +1. **Backend §0 (parent spec §0.1 / §0.2 / §0.3) is not yet done.** + `meta.types.ts` is still snake_case; `ResponseMetaSchema.translation` / + `.interaction` are still unions (single object for detail, id-keyed record for + list); post and note detail `data` still carries injected `hasInsightsInLocale`, + `related`, and enrichments. This spec is written against the **post-§0** backend + and lists §0 as a hard prerequisite (see §0 below and Risks). + +2. **Counts do not move into `meta`.** `post.controller.ts` detail sets only + `meta.interaction = { isLiked }`; list endpoints set no `interaction` at all. + `likeCount` / `readCount` remain on `data` (the entity's `count` field). + Combined with the decision to ignore `isLiked` (see Goals), **`meta.interaction` + is entirely unused by Yohaku** — there is no count migration. + +3. **`data` carries the original content; `meta.translation.article` carries the + translation.** Under V1 the backend swapped `text` / `content` server-side when + the request passed `?lang=`. Under V2 it does not: `data` is always the original, + and the translated `title` / `text` / `content` / `summary` / `tags` live in + `meta.translation.article`. **Reconciling this — without writing the translation + back onto `data` — is the substantive work of the Yohaku migration.** + +## Survey — what Yohaku consumes + +A grep across `apps/web/src` (`.ts` / `.tsx`) shows Yohaku's per-request field +consumption: + +| Field | Where (representative) | V2 source | +| --- | --- | --- | +| `isTranslated` / `translationMeta` | `posts/(post-detail)/[category]/[slug]/pageExtra.tsx`, `PostLexicalRenderer.tsx`, `notes/(note-detail)/detail-page.tsx`, `NoteLexicalRenderer.tsx`, `NoteHeaderNoticeCard.tsx`, `PostFeaturedCard.tsx`, `PostListItem.tsx`, `lib/og-renderer.tsx`, `lib/helper.server.ts` (~15+ sites) | `meta.translation` (detail) / `meta.translationById` (list) | +| translated post/note content (`title`/`text`/`content`/`summary`/`tags`) | rendered directly off the entity by `*LexicalRenderer` / markdown components | `meta.translation.article` | +| `related` | `posts/(post-detail)/[category]/[slug]/pageExtra.tsx` (related-post NoticeCard) | `meta.related` (detail) | +| `enrichments` | `posts/.../page.tsx`, `notes/.../detail-page.tsx`, `thinking/item.tsx`, `(page-detail)/[slug]/layout.tsx` — all feed `EnrichmentMapProvider` | `meta.enrichments` (response-level) | +| `hasInsightsInLocale` | `pageExtra.tsx`, `NoteHeaderNoticeCard.tsx` | `meta.insights.hasInLocale` (detail) | +| `pagination` | `posts/page.tsx`, `notes/page.tsx`, `notes/(topic-detail)/series/[slug]/page.tsx` (infinite) | `meta.pagination` | +| `likeCount` / `readCount` | `PostActionAside.tsx` / `NoteActionAside.tsx` (`initialCount`) | **stays on `data.count` — no migration** | +| liked boolean | `useLikeAction.ts` via the `mx-like` cookie (`isLikedBefore(id)`) | **client-side cookie — not migrated** | + +Yohaku has no normalization functions to refactor. Data flows +`api-client → camelcaseKeysWithUrlSkip → React component` with no intermediate +transform. TanStack Query is used with IndexedDB persistence +(`providers/root/react-query-provider.tsx`, `idb-keyval`). + +## Goals + +- Yohaku renders translated articles, related posts, enrichments, and AI-insight + availability from V2 `meta` with no regression. +- `data` stays **exactly the V2 view type** end to end. No per-request field is + ever written onto it. `meta` is threaded as a separate, plain value through the + existing providers; components consume per-request fields from `meta` directly. +- Translated content is resolved by a derived, ephemeral view-model computed at + read time. The TanStack cache holds only pure, plain `{ data, meta }`; the + derived localized entry is never cached or persisted. +- `meta.interaction.isLiked` is intentionally **not** consumed. Per Codex review: + counts are cache-safe aggregate metadata and stay on `data`; `isLiked` is + visitor-specific (backend derives it from request IP, Yohaku from a browser + cookie), it is cache-hostile in a CDN-fronted public blog, and the two identity + models legitimately disagree. Adopting server `isLiked` is a separate + interaction-identity design, out of scope here. + +## Non-Goals + +- Backend changes. Parent spec §0 is a prerequisite, not part of this workstream. +- `@mx-space/api-client` internal changes. Publishing a V2 release of api-client + (with `metaFor`, V2 meta types, V2-shaped controllers, removed `ModelWithLiked` / + `ModelWithTranslation`) is the api-client workstream (parent spec §1/§2/§5/§6). + This spec only **consumes** that release. +- Adopting server-side `isLiked` / changing the like cookie mechanism. +- Migrating count display — `likeCount` / `readCount` stay on `data.count`. +- Merging any `meta` field onto `data` to spare component churn. The component + sites that read per-request fields are migrated honestly to read from `meta`. + +## Design + +Each per-resource `queryFn` (and each list fetcher) returns a plain +`{ data, meta }` pair: `data` is exactly the V2 view type, `meta` is the +camelCased `ResponseMeta`. Both are plain enumerable objects, so the pair survives +TanStack Query IndexedDB persistence. Nothing is merged. + +Components consume per-request fields from `meta`, threaded through the existing +`Current{Post,Note,Page}DataProvider`s. The one genuinely derived concern — +which language's content to render — is handled by a read-time view-model +(`localizeEntry`), never written back to the cache. + +### §0. Prerequisites + +Two things land before any Yohaku work starts — neither in the Yohaku repo: + +1. **Backend §0.3 lands in `apps/core`** — post and note detail controllers stop + injecting `related`, `hasInsightsInLocale`, and enrichments onto `data` and + move them into `meta` (`meta.related`, a new closed `meta.insights` key, and + `meta.enrichments`). After §0.3 a detail `data` equals the resource's detail + view and nothing else — the precondition for the data-purity stance above. + Parent spec §0.1 and §0.2 are deliberately **not** done: §0.1 (camelCase + `meta.types.ts`) is wire-invariant — the wire stays snake_case and api-client + camelCases it, so Yohaku cannot observe it; §0.2 keeps the current union shape + for `meta.translation` / `meta.interaction` (a single object on detail, an + id-keyed record on list), which `metaFor` resolves by shape-sniffing (§3). +2. **`@mx-space/api-client` ships a V2 release.** The envelope unwrap and the + `$meta` getter already exist on the `refactor/v2-api-response` branch + (commit `5254ae37`) but are unpublished, and the package version is still + `4.2.0` — identical to the stale npm release Yohaku currently pins. The + release must: + - bump the version and publish; + - fix `extractResponseMeta` so `$meta` is camelCased, consistent with `data` + (parent spec §1); + - provide `metaFor(item, meta)` — written against the §0.2 union shape + (shape-sniff detail single object vs list id-keyed record) — and the V2 meta + types (`ResponseMeta`, `EntryTranslation`, `ArticleTranslation`, + `InteractionMeta`, `EnrichmentEntry`, `RelatedRef`). `metaFor` and the types + may instead be written locally in Yohaku if the api-client release is to + stay minimal; + - ideally remove `ModelWithLiked` / `ModelWithTranslation` (parent spec §6), + though Yohaku only needs the flat types. + +Both are hard prerequisites; Yohaku's typed work (§3 onward) cannot begin until +they land. + +### §1. api-client version bump and the fetch adapter + +`apps/web/package.json` — bump `@mx-space/api-client` from `4.2.0` to the V2 +release. + +`apps/web/src/lib/fetch/shared.ts` currently configures the client with +`getDataFromResponse: (response) => response as any`, passing the entire HTTP body +through. With the V2 api-client doing envelope unwrapping internally, this hook +and the `$meta` access path must be re-verified: confirm that after the bump the +api-client result resolves to the unwrapped `data` and that `$meta` is reachable +on the result. Adjust `getDataFromResponse` / the adapter wrapper only as needed +so that: + +```ts +const result = await apiClient.post.getPost(category, slug, { lang }) +// result === flat post view; result.$meta === camelCased ResponseMeta +``` + +`camelcaseKeysWithUrlSkip` (`lib/camelcase.ts`) is unchanged — the wire stays +snake_case, the keys still camelCase, and the URL-shaped enrichment-map keys are +still skipped. + +### §2. The chokepoint — `queryFn` returns `{ data, meta }` + +Every per-resource `queryFn` reads `$meta` synchronously off the api-client +result and returns a plain pair. It does **not** merge. + +```ts +queryFn: async () => { + const result = await apiClient.post.getPost(category, slug, { lang }) + return { data: { ...result }, meta: result.$meta ?? {} } +} +``` + +The spread `{ ...result }` drops the non-enumerable `$meta` / `$raw` getters, +leaving a plain `data`; `result.$meta` is already a plain `ResponseMeta` object +(api-client §0 prerequisite). The returned `{ data, meta }` is fully enumerable +and therefore TanStack-persistence-safe. `data` is exactly the V2 view type — no +field added. + +| File | Query | Change | +| --- | --- | --- | +| `queries/definition/post.ts` | `post.bySlug` | `queryFn` returns `{ data, meta }` | +| `queries/definition/note.ts` | `note.byNid`, `note.bySlugDate` | same | +| `queries/definition/page.ts` | `page.bySlug` | same | + +`$meta` is read **inside** the `queryFn`, before the plain pair is returned — the +non-enumerable getter never reaches the cache. + +### §3. `localizeEntry` — the translated-content view-model + +New module `apps/web/src/lib/api/localized-entry.ts`. Pure functions plus thin +hook wrappers; imports `metaFor` and the V2 meta types from `@mx-space/api-client`. + +```ts +import { metaFor } from '@mx-space/api-client' +import type { ResponseMeta, EntryTranslation } from '@mx-space/api-client' + +// pure — returns a copy with translated content fields resolved. +// adds NO fields: the result is still exactly the input view type. +function localizeEntry( + entry: T, + translation: EntryTranslation | undefined, +): T + +// React hook — derived, memoized, never cached. +function useLocalizedEntry( + entry: T, + meta: ResponseMeta | undefined, +): T + +// React hook — list variant, per-item localization. +function useLocalizedList( + items: T[], + meta: ResponseMeta | undefined, +): T[] + +// pure — flatten meta.translation into the legacy translationMeta prop shape, +// so badge / notice components keep their existing prop type. +function selectTranslationMeta( + meta: ResponseMeta | undefined, + id?: string, +): { isTranslated: boolean; sourceLang?: string; targetLang?: string + translatedAt?: string; availableTranslations?: string[] } | undefined +``` + +- `localizeEntry` — when `translation?.article?.isTranslated`, returns + `{ ...entry, }` overlaying only fields the `article` + actually carries (`title` / `text` / `subtitle` / `summary` / `tags` / + `content` / `contentFormat`; `content` and `contentFormat` always copied + together). It assumes no field is present — post-list translations omit + `content`, note-list translations include it; "present → overlay" covers both. + When not translated it returns `entry` unchanged. The result is the **same view + type** — no `isTranslated` / `translationMeta` field is added; only content + field *values* differ. +- `useLocalizedEntry` = + `useMemo(() => localizeEntry(entry, metaFor(entry, meta).translation), [entry, meta])`. +- `useLocalizedList` maps `localizeEntry` over the list, each item resolved via + `metaFor(item, meta)`, which shape-sniffs the union `meta.translation` (a + single object on detail vs an id-keyed record on list — §0.2 is not done). +- `selectTranslationMeta` exposes the translation *flags* (never content) in the + shape Yohaku's existing `TranslatedBadge` / `NoteHeaderNoticeCard` / + `TranslationLanguageSwitcher` / `TranslationNoticeContent` already accept, so + those leaf components keep their prop type unchanged. + +The localized entry is computed at read time and lives only for the render. The +cache and the providers hold pure `data` + `meta`. + +`og-renderer.tsx` / `helper.server.ts` are server-side and cannot use hooks; they +call the pure `localizeEntry` / `selectTranslationMeta` directly on the fetched +`{ data, meta }`. + +### §4. Threading `meta` — provider extensions + +Yohaku already wraps each detail page in `CurrentPostDataProvider` / +`CurrentNoteDataProvider` / `CurrentPageDataProvider` +(`providers/{post,note,page}/`). Extend each to hold `meta` alongside the entity: + +- The provider's value becomes `{ data, meta }` (or the existing entity value + plus a sibling `meta`). +- Add `useCurrentPostMeta()` / `useCurrentNoteMeta()` / `useCurrentPageMeta()` + returning `ResponseMeta | undefined`. +- Add a convenience `useCurrentLocalizedPost()` (and note/page siblings) = + `useLocalizedEntry(useCurrentPostData(), useCurrentPostMeta())`, for the content + renderers. +- `useCurrentPostData()` and siblings are unchanged — every existing consumer of + the raw entity (slug, category, id, dates, counts) keeps working untouched. + +Every place that constructs a `Current*DataProvider` (the page-level `api.tsx` / +`detail-page.tsx` fetch sites) now passes `meta` in addition to `data`. + +### §5. Detail pages — wiring + +Per detail page, the per-request reads move to `meta`: + +- **Translated content** (`PostLexicalRenderer`, `NoteLexicalRenderer`, the + rendered `

` title, summary/description) — consume `useCurrentLocalizedPost()` + (note/page siblings) instead of the raw entity. Roughly 5–8 content mount points. +- **Translation flags** (`pageExtra.tsx`, `detail-page.tsx`, `NoteHeaderNoticeCard`, + `PostFeaturedCard`, `PostListItem`) — the container reads + `selectTranslationMeta(useCurrentPostMeta())` and passes the result as the + `translationMeta` prop the leaf components already accept; `isTranslated` is + `Boolean(selectTranslationMeta(meta)?.isTranslated)`. Leaf badge / notice / switcher + components keep their prop types — only the prop *source* changes, at the + container. +- **`related`** — `pageExtra.tsx` reads `useCurrentPostMeta()?.related ?? []` for + the related-post NoticeCard. The backend bakes translated related-titles into + `meta.related`, so no extra work. +- **`hasInsightsInLocale`** — `pageExtra.tsx` / `NoteHeaderNoticeCard` read + `useCurrentPostMeta()?.insights?.hasInLocale ?? false`. + +### §6. List pages and pagination + +`posts/page.tsx` and `notes/page.tsx` fetch lists and destructure +`{ data, pagination }` today. The list fetchers return the plain pair, pagination +sourced from `meta`: + +```ts +const result = await apiClient.post.getList(page, size, { lang, ... }) +const meta = result.$meta ?? {} +return { data: [...result], meta } +``` + +The page component then derives display items with `useLocalizedList(data, meta)` +(translated titles for `PostFeaturedCard` / `PostListItem`), passes +`selectTranslationMeta(meta, item.id)` as each card's `translationMeta` prop, and +reads `meta.pagination` for `PostSortBar` / `PostPagination` / `NoteListPagination`. +The exact api-client V2 list return shape (a bare `T[]` carrying `$meta` vs. a +reconstructed `PaginateResult`) is settled by the api-client workstream; the +fetcher adapts, but pagination ultimately comes from `meta.pagination`. + +The infinite-query case (`notes/(topic-detail)/series/[slug]/page.tsx`, +`useInfiniteQuery`) takes the same treatment per page: each page's `queryFn` +returns `{ data, meta }`; `getNextPageParam` reads `meta.pagination`; the rendered +list runs through `useLocalizedList`. + +`thinking` (recently) list — same: `{ data, meta }` from the fetcher, +`useLocalizedList` at render if recently carries translation (a typed no-op if it +does not). + +### §7. Enrichments + +`enrichments` is consumed through `EnrichmentMapProvider` +(`components/ui/link-card/EnrichmentMapContext.tsx`), which already takes a +`Record` and looks entries up by URL via +`useEnrichmentForUrl`. V2 `meta.enrichments` is a response-level URL-keyed map. + +The provider call sites change their `value` source from `data.enrichments` to +the threaded `meta.enrichments`: + +| File | Change | +| --- | --- | +| `posts/(post-detail)/[category]/[slug]/page.tsx` | `value={useCurrentPostMeta()?.enrichments ?? null}` | +| `notes/(note-detail)/detail-page.tsx` | `value={useCurrentNoteMeta()?.enrichments ?? null}` | +| `(page-detail)/[slug]/layout.tsx` | `value={useCurrentPageMeta()?.enrichments ?? null}` | +| `thinking/item.tsx` | `value` from the thinking response `meta.enrichments` — the response-level map is shared across items; lookup is by URL, so no per-item slicing is needed | + +`meta.enrichments` is read straight off `meta` — it is never merged onto `data`. +The `use-inline-link-enrichment` client-side path (URLs discovered after render) +is unaffected. + +### §8. Aggregate endpoint + +`app/[locale]/api.tsx` `fetchAggregationData` uses `fetchServerApiJson('aggregate')` +— a raw fetch bypassing api-client. The V2 aggregate response is enveloped, so the +raw fetcher must unwrap `{ data }`. The aggregate endpoint carries no per-request +`meta` Yohaku needs (home cards show no translation/related), so only the envelope +unwrap is required. Verify the aggregate controller during implementation; if it +does emit `meta` (e.g. translation for top posts), thread it the same way. + +### §9. Types + +After the api-client V2 release removes `ModelWithLiked` / `ModelWithTranslation`: + +- The query result type is `{ data: , meta: ResponseMeta }`, where + `` is the V2 view type imported from `@mx-space/api-client`. No + wrapper type fuses entity and meta. +- `localizeEntry` / `useLocalizedEntry` are typed `(, …) → ` — the + localized entry is the *same* type as the input, since it adds no field. +- `models/*` and `queries/definition/*` drop the `ModelWith*` wrappers; their + hand-maintained translation fields are deleted, not edited. +- `tsc` is the audit: any component reading `isTranslated` / `translationMeta` / + `related` / `hasInsightsInLocale` off the raw entity becomes a compile error and + is moved to the `meta` source at that site. + +## File change inventory + +| File | Change | +| --- | --- | +| `apps/web/package.json` | Bump `@mx-space/api-client` `4.2.0` → V2 release | +| `apps/web/src/lib/api/localized-entry.ts` | **New** — `localizeEntry`, `useLocalizedEntry`, `useLocalizedList`, `selectTranslationMeta` | +| `apps/web/src/lib/api/__tests__/localized-entry.*` | **New** — fixture-based unit tests | +| `apps/web/src/lib/fetch/shared.ts` | Verify/adjust `getDataFromResponse` + `$meta` access for the V2 api-client | +| `apps/web/src/queries/definition/post.ts`, `note.ts`, `page.ts` | `queryFn` returns plain `{ data, meta }`; drop `ModelWith*` types | +| `apps/web/src/providers/post/CurrentPostDataProvider.tsx`, `providers/note/…`, `providers/page/…` | Hold `meta`; add `useCurrent*Meta()` + `useCurrentLocalized*()` | +| `apps/web/src/app/[locale]/posts/(post-detail)/[category]/[slug]/api.tsx`, `notes/(note-detail)/[id]/api.tsx`, `notes/(note-detail)/slug-api.ts` | Pass `meta` into the providers | +| `apps/web/src/app/[locale]/posts/page.tsx`, `notes/page.tsx` | List fetcher returns `{ data, meta }`; `useLocalizedList` + `meta.pagination` at render | +| `apps/web/src/app/[locale]/(note-topic)/notes/(topic-detail)/series/[slug]/page.tsx` | Infinite-query per-page `{ data, meta }` + `useLocalizedList` + pagination | +| `apps/web/src/app/[locale]/thinking/*` | Fetcher returns `{ data, meta }`; `useLocalizedList` if translated | +| `*LexicalRenderer.tsx`, markdown body, detail `

` / summary sites | Consume `useCurrentLocalized*()` for translated content | +| `pageExtra.tsx`, `detail-page.tsx`, `NoteHeaderNoticeCard.tsx`, `PostFeaturedCard.tsx`, `PostListItem.tsx` | Read translation flags / `related` / `insights` from `meta` via `selectTranslationMeta` / `useCurrent*Meta()` | +| `posts/(post-detail)/[category]/[slug]/page.tsx`, `notes/(note-detail)/detail-page.tsx`, `(page-detail)/[slug]/layout.tsx`, `thinking/item.tsx` | `EnrichmentMapProvider value` ← `meta.enrichments` | +| `apps/web/src/app/[locale]/api.tsx` | `fetchAggregationData` unwraps the `{ data }` envelope | +| `apps/web/src/lib/og-renderer.tsx`, `apps/web/src/lib/helper.server.ts` | Use pure `localizeEntry` / `selectTranslationMeta` on the fetched `{ data, meta }` | +| `apps/web/src/models/*` | Drop `ModelWith*`-based types; align to imported V2 view types | + +## Sequencing + +1. **Prerequisites (§0):** backend §0.3 lands in `apps/core`; `@mx-space/api-client` + V2 release published. +2. **§1 + §3** — version bump, `shared.ts` adapter verify, `localized-entry.ts` + + tests. No behavior change in components yet. +3. **§9** — drop `ModelWith*` types; `tsc` produces the call-site error list. +4. **§2 + §4** — detail `queryFn`s return `{ data, meta }`; provider extensions + + meta hooks. +5. **§5** — detail page wiring (content via `useCurrentLocalized*`, flags via + `meta`). +6. **§6** — list + infinite-query fetchers; `useLocalizedList`; pagination. +7. **§7 + §8** — `EnrichmentMapProvider` sources; aggregate envelope unwrap. + +§5–§7 are independent of each other once §2/§3/§4/§9 land and can proceed in +parallel. + +## Testing + +- **`localized-entry`** — unit tests with committed JSON fixtures: detail + translated / not translated, list per-item translation, post-list translation + (no `content`), note-list translation (with `content`), absent `meta`, absent + per-item entry, `selectTranslationMeta` flag flattening. +- **Detail `queryFn`s** — mocked api-client result with `$meta`; assert the + returned `{ data, meta }` is fully enumerable (JSON round-trip equality) and + `data` carries no `isTranslated` / `translationMeta` / `related` field. +- **`useLocalizedEntry`** — renders translated `text` / `content` when + `meta.translation.article.isTranslated`, original otherwise; the input entry is + not mutated. +- **List fetchers** — `{ data, meta }` shape; `useLocalizedList` translates each + card title; `pagination` comes from `meta.pagination`. +- **`tsc --noEmit`** — the type gate; the migration is not done until it passes + with no new `any`. +- **Manual smoke:** a translated post and a translated note (content, title, + translation notice, language switcher), the related-post card, enrichment link + cards on post / note / page / thinking, post and note list pages with + pagination, an AI-insights badge. + +## Risks + +- **Backend §0.3 not landed.** Without it, `related` / `enrichments` / + `hasInsightsInLocale` stay on `data`, the §5 / §7 `meta` reads find nothing, and + `data` is not the pure view type the design depends on. Hard prerequisite. + §0.1 / §0.2 are intentionally skipped — `metaFor` is written against the union + `meta.translation` shape, so they are not blockers. +- **api-client V2 release not published.** Yohaku consumes `metaFor`, the V2 meta + types, and the V2-shaped controllers from the release. §0 precondition. If the + api-client workstream lags, Yohaku's typed work (§3+) is blocked. +- **Provider contract change.** Extending `Current*DataProvider` to carry `meta` + changes its construction contract — every site that mounts a provider must now + supply `meta`. `tsc` catches omissions; the §4 edit and the page-fetch edits + must land together. +- **`availableTranslations` only present when translated.** Viewing an + original-language article yields no `meta.translation`, so the language switcher + cannot list alternatives. A backend follow-up should always emit + `availableTranslations`; until then `selectTranslationMeta` returns `undefined` + and the switcher must tolerate it. +- **Translated Lexical `content` shape.** `meta.translation.article.content` must + be valid Lexical JSON in the same dialect as the original, with `contentFormat` + alongside it. If the translation pipeline ever returns markdown there while the + entity is Lexical, the renderer breaks — covered by the translated-detail smoke + test. +- **`$meta` reachability after the api-client bump.** `lib/fetch/shared.ts`'s + `getDataFromResponse` was written for the V1 client. §1 is a verify-and-adjust + step; if `$meta` is not reachable on the api-client result, the `queryFn`s have + nothing to read. Validate first, before §2. +- **Aggregate envelope.** `fetchServerApiJson` is a raw fetch; if the aggregate + response shape is mis-assumed, the home page breaks. §8 must verify against the + live `apps/core` aggregate controller. diff --git a/docs/superpowers/specs/2026-05-22-translation-inplace-overwrite-design.md b/docs/superpowers/specs/2026-05-22-translation-inplace-overwrite-design.md new file mode 100644 index 00000000000..8badb4023e2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-translation-inplace-overwrite-design.md @@ -0,0 +1,663 @@ +# Translation In-Place Overwrite Design + +**Date:** 2026-05-22 +**Status:** Approved (pending implementation plan) +**Branch:** refactor/v2-api-response +**Author:** Innei + +## Problem + +After the V3 response envelope refactor (commit 5543aa47), per-request +translation was moved out of `data` and into `meta.translation..article`. +For an article with id `X`, the wire looked like: + +```jsonc +{ + "data": { "id": "X", "title": "代码与多巴胺…", "text": "…", … }, + "meta": { + "translation": { + "X": { + "article": { + "is_translated": true, + "source_lang": "zh", + "target_lang": "en", + "title": "Code & Dopamine…", + "text": "…", + "content": "…", + "content_format": "lexical", + "available_translations": ["ko", "ja", "en"] + } + } + } + } +} +``` + +This duplicates the translatable fields (`title`, `text`, `content`, +`content_format`, etc.) — the canonical value lives in `data` in the source +language, and a translated copy lives in `meta`. Consumers must either +re-merge `meta` back into `data` (which the user has rejected as a +compatibility shim) or carry a parallel `data + meta` model through every +layer. + +The user’s constraint: **no data redundancy**. When a translation exists, +it should overwrite the original field in place — wherever that field +lives, `data` or nested, it gets replaced. `meta` should only carry +information that has no `data` counterpart. + +## Goals + +1. Eliminate the duplicate carrying of translated content in both `data` + and `meta`. +2. Restore the pre-V3 behaviour where `?lang=xx` produces a response whose + `data.title`, `data.text`, etc., are already in the requested language. +3. Cover every endpoint that V1 translated, including dictionary-mode + value mappings (`note.mood`, `note.weather`) and entity-mode lookups + (`topic.*`, `category.name`). +4. Keep a slim, well-typed `meta.translation` block for the metadata that + has no `data` counterpart (`is_translated`, `source_lang`, + `target_lang`, `available_translations`, `translated_at`, `model`). +5. Restore V1 wire parity through the api-client legacy adapter without + the adapter having to re-translate. + +## Non-Goals + +- No DB schema change. `ai_translations` and `translation_entries` keep + their current shape. +- No change to the AI translation pipeline, hashing, cache invalidation, + or background materialisation. +- No change to the envelope contract itself (`{data, meta?}` for success, + `{error}` for errors). Only the per-endpoint payload shape changes. +- Front-end consumption refactoring (Yohaku, admin-vue3) is tracked as a + follow-up PR, not part of this spec. + +## Core Principles + +1. **In-place overwrite.** When a translation exists for a field, it + overwrites the original at the field’s position in the response — + `data.title`, `data.topic.name`, `data.next.mood`, `data.related[].title`, + `data[].topic.name`, all overwritten where the original sits. +2. **No redundancy.** A translated value never appears in both `data` and + `meta`. If a field is overwritten in `data`, it is not echoed in `meta`. +3. **`meta.translation` is metadata only.** It carries the bookkeeping + about the translation (was it translated, from what source, what + target, when, by which model, what other targets are available) — never + the translated text. +4. **Dict ≠ entity.** `translation_entries` supports two lookup modes + that must remain distinct end-to-end: + - **Entity mode:** `lookupKey = entityId`. Used for + `topic.name/introduce/description`, `category.name`. The same id maps + 1:1 to a translated value per lang. + - **Dict mode:** `lookupKey = hash(sourceValue)`. Used for + `note.mood`, `note.weather`. The same source value maps 1:1 to a + translated value per lang, regardless of which note it appeared in. + +## Wire Contract + +### Success envelope (article detail) + +```jsonc +{ + "data": { + "id": "X", + "title": "Code & Dopamine…", // translated in place + "text": "…", // translated in place + "content": "…", // translated in place (if present) + "content_format": "lexical", + "topic": { + "id": "Y", + "name": "Current Update", // entry-translated in place + "introduce": "…", // entry-translated in place + "description": "…" + }, + "mood": "Happy", // dict-translated in place + "weather": "Sunny", // dict-translated in place + "next": { + "id": "Z", + "title": "…", // translated in place + "topic": { "name": "…" }, // entry-translated in place + "mood": "…", "weather": "…" // dict-translated in place + }, + "prev": { /* … */ } + }, + "meta": { + "view": "detail", + "translation": { + "X": { + "article": { + "is_translated": true, + "source_lang": "zh", + "target_lang": "en", + "translated_at": "2026-05-22T…Z", + "model": "claude-haiku-4-5", + "available_translations": ["ko", "ja", "en"] + } + }, + "Z": { "article": { /* same shape for next */ } } + }, + // insights, enrichments, interaction, related etc. unchanged + } +} +``` + +### Success envelope (list / aggregate / activity) + +Each item carries its translated fields in place. `meta.translation` +carries one slim metadata block **per actually-translated item id**; +items where `isTranslated === false` are omitted entirely (this matches +the semantics of the existing `collectArticleTranslations` helper). + +```jsonc +{ + "data": [ + { "id": "X1", "title": "…(translated)", "topic": { "name": "…(translated)" }, … }, + { "id": "X2", "title": "…(translated)", … } + // X3 (untranslated) is in `data` but produces no `meta.translation` entry + ], + "meta": { + "translation": { + "X1": { "article": { "is_translated": true, "source_lang": "zh", "target_lang": "en", … } }, + "X2": { "article": { "is_translated": true, "source_lang": "zh", "target_lang": "en", … } } + } + } +} +``` + +### Removed fields + +- `meta.translation..article.title` +- `meta.translation..article.text` +- `meta.translation..article.subtitle` +- `meta.translation..article.summary` +- `meta.translation..article.tags` +- `meta.translation..article.content` +- `meta.translation..article.content_format` +- `meta.translation..fields` (entry/dict translations now overwrite + data — there is no `fields` block any more) + +**Meta-emission rules:** +- *Sub-entity entry translations* (`topic.*`, `category.name`, `note.mood`, + `note.weather`) overwrite their nested data location but never emit a + `meta.translation` entry — they have no per-row metadata to expose. +- *Article-row translations* (anything sourced from `ai_translations`) + may emit a slim `.article` block when that row’s metadata + is available (`sourceLang/targetLang/translatedAt/model/…`). This + includes aggregate / activity / category list items where the + underlying lookup goes through `ai_translations` even if only the + `title` field is consumed. +- Pure cached-title overlays that have no per-row metadata available at + the call site (e.g. legacy `getCachedTitles` returning `Map` + without the row object) skip the meta entry. Where the controller can + cheaply produce both the translated title *and* the metadata block, + prefer doing so for consistency. + +## Zod Schema Changes + +`apps/core/src/common/response/meta.types.ts`: + +```ts +export const ArticleTranslationSchema = z + .object({ + isTranslated: z.boolean(), + sourceLang: z.string().nullable().optional(), + targetLang: z.string().nullable().optional(), + translatedAt: z.date().optional(), + model: z.string().optional(), + availableTranslations: z.array(z.string()).optional(), + }) + .strict() // ← must be strict at the article level too +// Removed: title, text, subtitle, summary, tags, content, contentFormat + +export const EntryTranslationSchema = z + .object({ article: ArticleTranslationSchema.optional() }) + .strict() +// Removed: fields +``` + +Both `ArticleTranslationSchema` and `EntryTranslationSchema` are +`.strict()`. Outer-only `.strict()` on `EntryTranslationSchema` would +let removed inner fields (`title`, `text`, etc.) be silently stripped +rather than rejected — that would let a regression slip past +`MetaObjectBuilder.build()`. Tests must assert the *removed* fields +produce a Zod parse error, not a stripped output. + +## Helpers — `helper.translation.service.ts` + +### 1. `buildArticleTranslationMeta` (slim) + +Drops `title/text/subtitle/summary/tags/content/contentFormat`. Returns +only the metadata shape above. The `extras` parameter is removed. + +`translatedAt`, `model`, and `targetLang` are read from +`result.translationMeta.*`, **not** from `result.*` — the +`TranslationResult` type nests those fields: + +```ts +{ + isTranslated: result.isTranslated, + sourceLang: result.sourceLang ?? null, + targetLang: result.translationMeta?.targetLang ?? lang ?? null, + translatedAt: result.translationMeta?.translatedAt, + model: result.translationMeta?.model, + availableTranslations: result.availableTranslations ?? [], +} +``` + +### 2. `applyArticleTranslationInPlace(target, result, opts?)` + +```ts +applyArticleTranslationInPlace>( + target: T, + result: TranslationResult, + opts?: { + fields?: Array<'title'|'text'|'content'|'contentFormat'|'subtitle'|'summary'|'tags'> + }, +): T +``` + +- Default `fields` covers all seven. +- Overwrites `target[field] = result[field]` only when + `result.isTranslated === true` **and** `result[field] != null`. This + preserves the original `content` when a markdown-only article has no + lexical translation, and preserves original `summary/tags` when the + translation row doesn’t carry them. +- **`content` and `contentFormat` are paired**: `contentFormat` is only + overwritten when `content` is also being overwritten in the same call. + Writing `contentFormat` without `content` would leave the document in + an inconsistent state (e.g. `format='lexical'` over markdown body). + Internally: + + ```ts + if (translatedContent != null) { + target.content = translatedContent + target.contentFormat = translatedContentFormat ?? target.contentFormat + } + ``` + (Use a nullish check, not a truthy check — an empty-string translated + body is still a legitimate overwrite candidate.) +- Mutates `target` and returns it (for spread-readability at call sites). + +### 3. `applyTranslationEntriesInPlace(target, maps, rules)` + +```ts +type EntryMaps = { + entityMaps: Map> + dictMaps: Map> +} + +type EntryRule = + | { path: string; keyPath: TranslationEntryKeyPath; mode: 'dict' } + | { path: string; keyPath: TranslationEntryKeyPath; mode: 'entity'; idField: string } + +applyTranslationEntriesInPlace(target, maps, rules: EntryRule[]): void +``` + +- `path` is a dotted path against `target` (e.g. `'topic.name'`, `'mood'`). + No `[]` wildcards — call sites pass a concrete object (single article, + or one list item). For lists, the caller iterates. +- Entity mode: reads `target.` from the parent of `path` and + looks up `entityMaps.get(keyPath).get(id)`. +- Dict mode: reads the current value at `path` and looks up + `dictMaps.get(keyPath).get(sourceValue)`. (Hashing is done inside the + map builder, see §4 — the call site passes the raw source value.) +- Overwrites only on hit. No-op otherwise. + +### 4. `TranslationEntryService.fetchByLookups` (batched) + +Existing two-mode lookup endpoint, re-exposed with a stable signature: + +```ts +fetchByLookups(lang, { + entityLookups: Array<{ keyPath: TranslationEntryKeyPath; lookupKeys: string[] }>, + dictLookups: Array<{ keyPath: TranslationEntryKeyPath; sourceTexts: string[] }>, +}): Promise +``` + +Each controller assembles all entity ids and dict source values across +detail + next + prev + list items, calls once, gets +`{ entityMaps, dictMaps }`, threads to `applyTranslationEntriesInPlace`. + +The work is bounded by the number of lookup groups (one per `keyPath`), +not by per-id round-trips. Current `ai-translation.repository.ts` +issues one query per group; collapsing to a single `or(...)` SQL across +all groups is an internal optimisation that may follow but is not +required by this spec. + +### 5. Removed + +- `EntryTranslationSchema.fields` and any code that builds + `translation..fields` blocks. +- `getTopicTranslationFields` and any helper that returned + `Map>` for meta — replaced by the + `entityMaps` returned from `fetchByLookups`. + +## Controller Pipeline Order (mandatory) + +Each affected controller MUST execute its data-building pipeline in this +order: + +``` +1. Fetch source row(s) from repository +2. applyArticleTranslationInPlace(...) ← title/text/content/... +3. applyTranslationEntriesInPlace(...) ← mood/weather/topic.*/category.name +4. enrichmentService.attachEnrichments(...) +5. metaBuilder.translation(...) / withMeta(...) +``` + +Translation MUST run before enrichment. `EnrichmentService` scans +`content`/`text` to extract link cards, image enrichments, etc. If +enrichment runs against the source-language body and translation +overwrites afterwards, link-card placements and URL anchors will be +based on the source body while the visible prose is the target body — +producing visibly misaligned enrichments. Running translation first +keeps enrichment-extracted URLs in lock-step with the translated body. + +## Per-Endpoint Changes + +For each endpoint, list (a) fields overwritten in `data`, (b) entry +lookups required, (c) whether a `meta.translation` entry is emitted. + +**List `meta.translation` policy:** for list, aggregate, and activity +endpoints, emit a slim `meta.translation..article` entry **only +for items that were actually translated** (`isTranslated === true`). +Items with `isTranslated: false` are omitted — this matches the +semantics of the existing `collectArticleTranslations` helper and keeps +per-item payload bounded for large pages. + +For detail endpoints, always emit a slim entry for the primary id (and +for `next`/`prev` when those adjacent rows exist), even when +`isTranslated === false`, so that consumers can read +`sourceLang` / `availableTranslations` for the article they are +viewing without a second roundtrip. + +### post.controller.ts + +The "Overwritten in response" column lists fields that the controller +translates in place — most are in `data`, but `related[].title` is the +canonical title inside `meta.related` (which is itself a meta field +already; translating it there is the in-place action and does not +duplicate anything). + +| Endpoint | Overwritten in response | Entry lookups | `meta.translation` | +|----------|-------------------------|---------------|---------------------| +| `GET /:category/:slug` | `data.{title,text,content,contentFormat,summary,tags}`; `data.category.name`; **`meta.related[].title`** | `category.name` (entity, by post.category.id) | `.article` slim | +| `GET /:id` (auth) | same | same | same | +| `GET /` | item `data[].{title,text,content,contentFormat}`; item `data[].category.name` | `category.name` (entity, ids from items) | translated items only | +| `GET /latest` | item same | same | translated items only | + +Existing `withMeta(...)` call sites stay; `buildArticleTranslationMeta` +returns the slim shape. + +### note.controller.ts + +| Endpoint | Overwritten in `data` | Entry lookups | `meta.translation` | +|----------|-----------------------|---------------|---------------------| +| `buildPublicNoteResponse` (nid, slug-date) | `title/text/content/contentFormat`; `mood/weather` (dict); `topic.{name,introduce,description}` (entity); `next/prev` same | `note.mood/weather` (dict, source values across current+next+prev); `topic.*` (entity, ids across current+next+prev) | `.article` slim; `.article`, `.article` slim when adjacent exists | +| `GET /:id` (admin, also a detail route) | same as `buildPublicNoteResponse` | same | same | +| `GET /list/:id` (admin, ranged list) | item `title/text/content/contentFormat`; item `mood/weather/topic.*` | per-batch | translated items only | +| `GET /latest` | `latest` + `next` same as detail | same | `` + `` (both always emitted) | +| `GET /` | item `title/text/content/contentFormat` (or summary when `withSummary`); item `mood/weather/topic.*` | per-page batch | translated items only | +| `GET /topics/:id` | item `title`; item `mood/weather/topic.*` (entity lookups extended beyond V1 — V1 only covered `mood/weather`; this is an intentional expansion) | per-page batch | translated items only | + +### page.controller.ts + +| Endpoint | Overwritten | Entry lookups | meta | +|----------|-------------|---------------|------| +| `GET /slug/:slug` (public detail) | `title/text/subtitle/content/contentFormat` | — | `.article` slim | +| `GET /:id` (auth detail) | same | — | same | +| `GET /` (list) | item same | — | translated items only | + +### topic.controller.ts + +| Endpoint | Overwritten | Entry lookups | meta | +|----------|-------------|---------------|------| +| `GET /all` | `[].name/introduce/description` | `topic.*` (entity, all topic ids) | none | +| `GET /:id`, `GET /slug/:slug` | `name/introduce/description` | `topic.*` (entity, single id) | none | + +### category.controller.ts + +| Endpoint | Overwritten | Entry lookups | meta | +|----------|-------------|---------------|------| +| `GET /` (with `ids`) | `entries[].children[].title` (via post list) | — | translated children only | +| `GET /` (by type, no `ids`) | `[].name` | `category.name` (entity, all category ids) | none | +| `GET /:query` (regular branch) | `data.name`; `children[].title` | `category.name` (entity, this id) | translated children only | +| `GET /:query` (with `tag=true`) | `data[].title` (post list under the tag) — response shape `{ tag, data }` carries no `name`; only the post titles translate | — | translated items only | + +### search.controller.ts + +| Endpoint | Overwritten | Entry lookups | meta | +|----------|-------------|---------------|------| +| `GET /` | `data[].category.name` (only on post items) | `category.name` (entity, ids from post items) | none | +| `GET /:type` | same as `GET /` | same | none | + +### aggregate.controller.ts + +| Endpoint | Overwritten | Entry lookups | meta | +|----------|-------------|---------------|------| +| `GET /top` | `notes[]/posts[].title`; `notes[].mood/weather` | `note.mood/weather` (dict) | translated items only | +| `GET /latest` (combined/separate) | items `title`; `notes[].mood/weather` | dict | translated items only | +| `GET /timeline` | `data.notes[]/posts[].title`; `notes[].mood/weather`; `posts[].category.name` | dict + `category.name` (entity) | translated items only | +| `GET /stat/top-articles` | item `title` | — | translated items only | + +### activity.controller.ts + +| Endpoint | Overwritten | Entry lookups | meta | +|----------|-------------|---------------|------| +| `GET /rooms` | `objects[][].title` | — | translated items only | +| `GET /reading/top`, `GET /reading/rank` | `[].ref.title` | — | translated items only, **keyed by `refId`** (see Adapter §3 below for the corresponding legacy rule) | +| `GET /recent` | likes/comments/recentPost/recentNote item `title` | — | translated items only | +| `GET /last-year/publication` | item `title` | — | translated items only | + +## api-client Legacy Adapter + +`packages/api-client/legacy/response-adapter.ts`: + +```ts +function buildTranslationFlat(translation: any): Record { + if (!translation?.article) return {} + const a = translation.article + const out: Record = {} + if ('isTranslated' in a) out.isTranslated = a.isTranslated ?? false + if (a.sourceLang != null) out.sourceLang = a.sourceLang + if ('availableTranslations' in a) out.availableTranslations = a.availableTranslations ?? [] + if (a.isTranslated) { + out.translationMeta = { + sourceLang: a.sourceLang, + targetLang: a.targetLang, + translatedAt: a.translatedAt, + model: a.model, + } + } + return out +} +``` + +Changes: +1. Drop the `translation.article ?? translation` fallback — the V3 contract + guarantees `article` is nested. +2. `translationMeta` becomes the slim metadata-only shape, which is what + V1’s `TranslationMeta` type already was. +3. All `fields`-handling code paths are removed. +4. The rest of `flattenMetaIntoItem` (interaction, insights, enrichments, + related) is untouched. +5. `noteDetailRule`, `noteMiddleListRule`, `noteTopicListRule`, + `aggregateTopRule`, etc. continue to drive the V1 wire shape; the only + inside-rule change is the slimmer translation block. +6. Existing adapter tests at + `packages/api-client/__tests__/legacy/response-adapter.test.ts:73` + assert that `translationMeta` carries `title/text/content` etc. + Those expectations are updated: the translated display fields now + live in `data` (inline), and `translationMeta` carries only + `sourceLang/targetLang/translatedAt/model`. Add an extra assertion + that the *translated* `data.title/text/content` are present. + +### Nested-array adapter rules (aggregate / activity / etc.) + +The generic flattening in `transformLegacyData` only walks three shapes: +a bare array, `{ data: [...] }`, and a single item. Many V3 endpoints +return *nested* arrays at non-`data` keys — `notes[]`, `posts[]`, +`combined[]`, `data.notes[]`, `data.posts[]`, `objects[][]`, +`like[]`, `comment[]`, `recentPost[]`, `recentNote[]`. The existing +`aggregateTopRule` already inspects these arrays to strip bodies but +ignores `ctx.meta`, so per-item `translationMeta` flatten is currently +lost on those endpoints. + +Add a small adapter helper and a dedicated rule per shape: + +```ts +function flattenNestedArrays(raw: any, meta: any, keys: string[]) { + if (!isPlainObject(raw)) return raw + const out = { ...raw } + for (const key of keys) { + if (Array.isArray(out[key])) { + out[key] = out[key].map((it: any) => flattenMetaIntoItem(it, meta)) + } + } + return out +} +``` + +Then add rules for every endpoint that returns nested item arrays +where each item is article-row translated: + +| Endpoint (regex) | Arrays to flatten | Item id field | +|---|---|---| +| `/aggregate/top` | `notes`, `posts` | `id` | +| `/aggregate/latest` (separate mode) | `notes`, `posts`; combined mode already produces a bare array | `id` | +| `/aggregate/timeline` | `data.notes`, `data.posts` | `id` | +| `/aggregate/stat/top-articles` | `data` (top-level array — already covered by generic flow) | `id` | +| `/activity/rooms` | `objects.` for each type | `id` | +| `/activity/recent` | `like`, `comment`, `recentPost`, `recentNote` | `id` (each item is an article-shaped record with `id`) | +| `/activity/reading/top`, `/activity/reading/rank` | `data` | **`refId`** (use a synthesized-`id` walker; see below) | +| `/activity/last-year/publication` | per shape returned by `getRecentActivities`-style aggregation | `id` | + +`/activity/reading/{top,rank}` items use `refId` (not `id`) as their +stable identifier ([`activity.controller.ts:225`](../../apps/core/src/modules/activity/activity.controller.ts)). +Use a synthesized-id wrapper: + +```ts +const READING_RANK_REGEX = /^\/activity\/reading\/(top|rank)$/ +const readingRankRule: Rule = { + match: READING_RANK_REGEX, + fn: (raw, ctx) => { + const items: any[] = Array.isArray(raw?.data) ? raw.data : [] + return { + ...raw, + data: items.map((it) => + flattenMetaIntoItem({ ...it, id: it.refId }, ctx.meta), + ).map(({ id: _drop, ...rest }) => rest), // strip the synthesized id + } + }, +} +``` + +For the remaining endpoints, define one rule per regex and dispatch to +`flattenNestedArrays(raw, ctx.meta, [...])`. Update the existing +`aggregateTopRule` to compose with the new helper rather than ignore +`ctx.meta`. + +This closes the V1-wire parity gap that smoke-diff would otherwise +expose on every nested-array endpoint. + +### smoke-diff harness + +The smoke-diff harness lives at +`packages/api-client/scripts/smoke-diff.mjs` but is **not** currently +declared as a package script. Either: + +- (a) Add a `"smoke:diff": "node scripts/smoke-diff.mjs"` entry to + `packages/api-client/package.json`, or +- (b) Invoke directly: `node packages/api-client/scripts/smoke-diff.mjs`. + +The spec assumes (a). Smoke-diff against Yohaku’s call surface must +report 0 diffs across all touched endpoints. + +api-client version: bump to `5.0.2-next.9`. + +## Backward-Compatibility & Migration + +- Old V3 consumers reading `meta.translation..article.title` etc. + will see those keys disappear. Yohaku and admin-vue3 are tracked as + follow-up consumer-side PRs — neither has shipped against the V3 meta + yet (this branch is pre-merge), so no live consumer is broken. +- Front-end memory feedback [[frontend-no-merge-v2-meta]] applies to + other meta blocks (`pagination`, `related`, `interaction`, `insights`, + `enrichments`) but **carves out translation**: translated content is + threaded inline in `data`, not merged from `meta`. + +## Testing + +### Unit tests (helper) + +- `applyArticleTranslationInPlace` with all field combinations, including + partial translation (no `content`, no `summary`, no `tags`). +- `applyTranslationEntriesInPlace` for both dict and entity modes; hit, + miss, nested target object, undefined intermediate paths. +- `buildArticleTranslationMeta` slim shape — never includes content + fields. + +### Controller tests + +Each affected controller gets an explicit `?lang=en` test that asserts: +1. The data side carries the translated value at each path in the table + above. +2. The meta side carries the slim metadata for each id (or no entry, + per the table). +3. The meta side **does not** carry `title/text/content/contentFormat/ + subtitle/summary/tags/fields`. + +For note detail: assert `next/prev` translations also overwrite in place. + +### Schema parsing + +- `ResponseMetaSchema.parse(...)` rejects payloads that include any of + the removed fields in `meta.translation.*.article` or + `meta.translation.*.fields`. The test must assert that the Zod parse + throws (or `safeParse` returns `success: false`), not that the + offending key was silently stripped — see Schema section above for + why `ArticleTranslationSchema` itself must be `.strict()`. + +### Smoke-diff (api-client) + +- Run `pnpm -F @mx-space/api-client smoke:diff` against a V2 (legacy + adapter) and V3 (current) server and assert 0 diffs across the Yohaku + call surface. + +## Rollout + +1. Single PR on `refactor/v2-api-response`: + - `apps/core/src/common/response/meta.types.ts` — slim schemas + - `apps/core/src/processors/helper/helper.translation.service.ts` — + new helpers + - `apps/core/src/modules/ai/ai-translation/translation-entry.service.ts` + — expose `fetchByLookups` (or rename existing entry point) + - all 8 affected controllers (post, note, page, topic, category, search, aggregate, activity) + - `packages/api-client/legacy/response-adapter.ts` + - bump `@mx-space/api-client` to `5.0.2-next.9` + - new tests + updated snapshots +2. Run `pnpm -C apps/core lint` and `pnpm -C apps/core test` against + touched files only. +3. Smoke-diff harness against staging. +4. Merge. +5. Follow-up PR on Yohaku / admin-vue3 to drop dual-read of + `meta.translation..article.title` and friends. + +## Rollback + +- `git revert` of the single PR restores V3 meta-only behaviour. +- No DB schema migration is involved, so no data rollback is required. + +## Open Risks + +1. **Live consumers reading slim fields.** If any external consumer + already depends on `meta.translation..article.title` being + present (e.g. a downstream service we don’t own), they will break. + Mitigation: this branch hasn’t been released; the V3 wire has not + shipped to production consumers. +2. **List meta cost.** Slim entries are emitted only for items that were + actually translated (see policy above). For a 20-item page where 18 + items have a translation, this adds ~1.5 KB of JSON; the worst case + is bounded by `size`. +3. **`mood/weather` dict misses.** When a value has no entry for a lang, + the original is retained. Front-end must not assume `mood` is in the + requested lang. Documented behaviour, matches V1. diff --git a/package.json b/package.json index 5f651de5737..59c73e3ffd3 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "author": "Innei ", "scripts": { "check:ai-translation-hash": "pnpm -C apps/core exec tsx scripts/check-ai-translation-hash.ts", + "check:controller-response-envelope": "pnpm -C apps/core exec tsx ../../scripts/check-controller-response-envelope.ts", "format": "prettier --write \"apps/**/*.ts\"", "lexical-live": "pnpm -C apps/core exec node scripts/lexical-translation-live.mjs", "prepare": "simple-git-hooks", @@ -21,7 +22,7 @@ "bundle": "pnpm -C \"apps/core\" run bundle", "test": "pnpm -C \"apps/core\" run test", "lint": "pnpm -C \"apps/core\" run lint", - "typecheck": "pnpm -C \"apps/core\" run typecheck", + "typecheck": "pnpm check:controller-response-envelope && pnpm -C \"apps/core\" run typecheck", "publish:core": "cd apps/core && npm run publish" }, "devDependencies": { diff --git a/packages/api-client/.gitignore b/packages/api-client/.gitignore index 5a67e6609c4..42a76eee1c6 100644 --- a/packages/api-client/.gitignore +++ b/packages/api-client/.gitignore @@ -4,3 +4,5 @@ dist build types + +scripts/smoke-diff-report.json diff --git a/packages/api-client/__tests__/controllers/aggregate.test.ts b/packages/api-client/__tests__/controllers/aggregate.test.ts index 4f93f4953fa..194e73e2ea6 100644 --- a/packages/api-client/__tests__/controllers/aggregate.test.ts +++ b/packages/api-client/__tests__/controllers/aggregate.test.ts @@ -47,7 +47,7 @@ describe('test aggregate client', () => { }, ) const data = await client.aggregate.getAggregateData() - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) expect(data.user.name).toEqual(mocked.user.name) expect(data.url.webUrl).toEqual(mocked.url.web_url) expect(data.commentOptions.disableComment).toBe(false) @@ -209,7 +209,7 @@ describe('test aggregate client', () => { ) const data = await client.aggregate.getTop() - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) expect(data.posts[0].title).toEqual( '终于可以使用 Docker 托管整个 Mix Space 了', ) @@ -240,7 +240,7 @@ describe('test aggregate client', () => { const data = await client.aggregate.getSiteMetadata() - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) expect(data.user.socialIds?.x).toBe('__oQuery') expect(data.url.webUrl).toBe('https://innei.in') expect(data.seo.iconDark).toBe('/favicon-dark.svg') @@ -286,7 +286,7 @@ describe('test aggregate client', () => { }) const data = await client.aggregate.getTimeline() - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) expect(data.data.posts?.[0].url).toEqual(mocked.data.posts[0].url) expect(data.data.notes).toBeDefined() }) @@ -309,7 +309,7 @@ describe('test aggregate client', () => { const data = await client.aggregate.getTimeline({ type: TimelineType.Note, }) - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) expect(data.data.notes?.[0]).toEqual(mocked.data.notes[0]) expect(data.data.posts).toBeUndefined() }) @@ -335,7 +335,7 @@ describe('test aggregate client', () => { today_ip_access_count: 138, }) const data = await client.aggregate.getStat() - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) expect(data).toEqual(camelcaseKeys(mocked)) }) }) diff --git a/packages/api-client/__tests__/controllers/category.test.ts b/packages/api-client/__tests__/controllers/category.test.ts index 06d5c0dfc66..54c820a5ad1 100644 --- a/packages/api-client/__tests__/controllers/category.test.ts +++ b/packages/api-client/__tests__/controllers/category.test.ts @@ -22,7 +22,7 @@ describe('test Category client', () => { }) const data = await client.category.getAllCategories() - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) expect(data.data).toEqual(mocked.data) }) @@ -171,7 +171,7 @@ describe('test Category client', () => { const data = await client.category.getAllCategories() expect(data.data).toEqual(mocked.data) - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) }) test('GET /categories?type=1', async () => { @@ -189,7 +189,7 @@ describe('test Category client', () => { }) const data = await client.category.getAllTags() expect(data.data).toEqual(mocked.data) - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) }) describe('GET /categories?ids=', () => { @@ -274,7 +274,7 @@ describe('test Category client', () => { ]), ) - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) }) it('should get with single id', async () => { @@ -317,7 +317,7 @@ describe('test Category client', () => { }), ) - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) }) }) }) diff --git a/packages/api-client/__tests__/controllers/link.test.ts b/packages/api-client/__tests__/controllers/link.test.ts index 80bcad6f8d4..fab5718d68c 100644 --- a/packages/api-client/__tests__/controllers/link.test.ts +++ b/packages/api-client/__tests__/controllers/link.test.ts @@ -1,40 +1,42 @@ +import camelcaseKeys from 'camelcase-keys' + import { mockRequestInstance } from '~/__tests__/helpers/instance' import { mockResponse } from '~/__tests__/helpers/response' import { LinkController } from '~/controllers' -import camelcaseKeys from 'camelcase-keys' describe('test link client, /links', () => { const client = mockRequestInstance(LinkController) test('GET /', async () => { - const mocked = mockResponse('/links?size=10&page=1', { - data: [ - { - type: 0, - state: 0, - id: '615c191ed5db15a1000e3ca6', - url: 'https://barry-flynn.github.io/', - avatar: 'https://i.loli.net/2021/09/09/5belKgmrkjN8ZQ7.jpg', - description: '星河滚烫,无问西东。', - name: '百里飞洋の博客', - created: '2021-10-05T09:21:34.257Z', - hide: false, - }, - // ... - ], - pagination: { - total: 43, - current_page: 1, - total_page: 5, - size: 10, - has_next_page: true, - has_prev_page: false, + const items = [ + { + type: 0, + state: 0, + id: '615c191ed5db15a1000e3ca6', + url: 'https://barry-flynn.github.io/', + avatar: 'https://i.loli.net/2021/09/09/5belKgmrkjN8ZQ7.jpg', + description: '星河滚烫,无问西东。', + name: '百里飞洋の博客', + created: '2021-10-05T09:21:34.257Z', + hide: false, }, + // ... + ] + const pagination = { + page: 1, + size: 10, + total: 43, + total_pages: 5, + } + mockResponse('/links?size=10&page=1', items, 'get', undefined, { + pagination, }) const data = await client.link.getAllPaginated(1, 10) - expect(data.$raw.data).toEqual(mocked) - expect(data).toEqual(camelcaseKeys(mocked, { deep: true })) + expect(data.$raw.data.data).toEqual(items) + expect(data).toEqual( + camelcaseKeys({ data: items, pagination }, { deep: true }), + ) }) it('should `friend` == `link`', () => { @@ -47,7 +49,7 @@ describe('test link client, /links', () => { }) const data = await client.link.getAll() - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) expect(data).toEqual(camelcaseKeys(mocked, { deep: true })) }) @@ -63,7 +65,7 @@ describe('test link client, /links', () => { state: 0, }) const data = await client.link.getById('5eaabe10cd5bca719652179d') - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) expect(data).toEqual(camelcaseKeys(mocked, { deep: true })) }) diff --git a/packages/api-client/__tests__/controllers/note.test.ts b/packages/api-client/__tests__/controllers/note.test.ts index 00cb9510bf2..b255981bc55 100644 --- a/packages/api-client/__tests__/controllers/note.test.ts +++ b/packages/api-client/__tests__/controllers/note.test.ts @@ -22,14 +22,17 @@ describe('test note client', () => { }) it('should get post list filter filed', async () => { - const mocked = mockResponse('/notes?page=1&size=1&select=createdAt+title', { - data: [{}], + const items = [{}] + const pagination = { page: 1, size: 1, total: 1, total_pages: 1 } + mockResponse('/notes?page=1&size=1', items, 'get', undefined, { + pagination, }) - const data = await client.note.getList(1, 1, { - select: ['createdAt', 'title'], + const data = await client.note.getList(1, 1) + expect(data).toEqual({ + data: items, + pagination: { page: 1, size: 1, total: 1, totalPages: 1 }, }) - expect(data).toEqual(mocked) }) it('should get latest note', async () => { diff --git a/packages/api-client/__tests__/controllers/owner.test.ts b/packages/api-client/__tests__/controllers/owner.test.ts index 4ad45697d35..eeaad5a1345 100644 --- a/packages/api-client/__tests__/controllers/owner.test.ts +++ b/packages/api-client/__tests__/controllers/owner.test.ts @@ -1,7 +1,8 @@ +import camelcaseKeys from 'camelcase-keys' + import { mockRequestInstance } from '~/__tests__/helpers/instance' import { mockResponse } from '~/__tests__/helpers/response' import { UserController } from '~/controllers' -import camelcaseKeys from 'camelcase-keys' describe('test owner client', () => { const client = mockRequestInstance(UserController) @@ -27,7 +28,7 @@ describe('test owner client', () => { const data = await client.owner.getOwnerInfo() expect(data.id).toEqual(mocked.id) expect(data).toEqual(camelcaseKeys(mocked, { deep: true })) - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) }) test('POST /auth/sign-in/username', async () => { @@ -47,7 +48,7 @@ describe('test owner client', () => { ) const data = await client.owner.login('test', 'test') expect(data.token).toEqual(mocked.token) - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) }) test('GET /owner/check_logged', async () => { diff --git a/packages/api-client/__tests__/controllers/page.test.ts b/packages/api-client/__tests__/controllers/page.test.ts index 4e9643e52c7..c2f1c15da61 100644 --- a/packages/api-client/__tests__/controllers/page.test.ts +++ b/packages/api-client/__tests__/controllers/page.test.ts @@ -15,14 +15,17 @@ describe('test page client', () => { }) it('should get post list filter filed', async () => { - const mocked = mockResponse('/pages?page=1&size=1&select=createdAt+title', { - data: [{}], + const items = [{}] + const pagination = { page: 1, size: 1, total: 1, total_pages: 1 } + mockResponse('/pages?page=1&size=1', items, 'get', undefined, { + pagination, }) - const data = await client.page.getList(1, 1, { - select: ['createdAt', 'title'], + const data = await client.page.getList(1, 1) + expect(data).toEqual({ + data: items, + pagination: { page: 1, size: 1, total: 1, totalPages: 1 }, }) - expect(data).toEqual(mocked) }) it('should get single page', async () => { diff --git a/packages/api-client/__tests__/controllers/post.test.ts b/packages/api-client/__tests__/controllers/post.test.ts index 831e7b0c6c8..1485a3cde71 100644 --- a/packages/api-client/__tests__/controllers/post.test.ts +++ b/packages/api-client/__tests__/controllers/post.test.ts @@ -13,27 +13,30 @@ describe('test post client', () => { }) it('should get post list filter filed', async () => { - const mocked = mockResponse('/posts?page=1&size=1&select=createdAt+title', { - data: [ - { - id: '61586f7e769f07b6852f3da0', - title: '终于可以使用 Docker 托管整个 Mix Space 了', - createdAt: '2021-10-02T14:41:02.742Z', - category: null, - }, - { - id: '614c539cfdf566c5d93a383f', - title: '再遇 Docker,容器化 Node 应用', - createdAt: '2021-09-23T10:14:52.491Z', - category: null, - }, - ], + const items = [ + { + id: '61586f7e769f07b6852f3da0', + title: '终于可以使用 Docker 托管整个 Mix Space 了', + createdAt: '2021-10-02T14:41:02.742Z', + category: null, + }, + { + id: '614c539cfdf566c5d93a383f', + title: '再遇 Docker,容器化 Node 应用', + createdAt: '2021-09-23T10:14:52.491Z', + category: null, + }, + ] + const pagination = { page: 1, size: 1, total: 2, total_pages: 2 } + mockResponse('/posts?page=1&size=1', items, 'get', undefined, { + pagination, }) - const data = await client.post.getList(1, 1, { - select: ['createdAt', 'title'], + const data = await client.post.getList(1, 1) + expect(data).toEqual({ + data: items, + pagination: { page: 1, size: 1, total: 2, totalPages: 2 }, }) - expect(data).toEqual(mocked) }) it('should get latest post', async () => { diff --git a/packages/api-client/__tests__/controllers/say.test.ts b/packages/api-client/__tests__/controllers/say.test.ts index b7121fb925f..e2ae6cadfc0 100644 --- a/packages/api-client/__tests__/controllers/say.test.ts +++ b/packages/api-client/__tests__/controllers/say.test.ts @@ -1,7 +1,8 @@ +import camelcaseKeys from 'camelcase-keys' + import { mockRequestInstance } from '~/__tests__/helpers/instance' import { mockResponse } from '~/__tests__/helpers/response' import { SayController } from '~/controllers/say' -import camelcaseKeys from 'camelcase-keys' describe('test say client', () => { const client = mockRequestInstance(SayController) @@ -26,67 +27,66 @@ describe('test say client', () => { ], }) const data = await client.say.getAll() - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) expect(data.data).toEqual(mocked.data) expect(data.data[0].text).toEqual('找不到路,就自己走一条出来。') }) describe('GET /says', () => { it('should get without page and size', async () => { - const mocked = mockResponse('/says', { - data: [ - { - id: '61397d9892992823d7329bc9', - text: '每位师傅各有所长,我都会一点点。', - author: '陆沉', - source: '', - created: '2021-09-09T03:20:56.769Z', - }, - { - id: '60853492fbfab397775cc12d', - text: '我不是一个优秀的人,只是我们观测的角度不同。', - created: '2021-04-25T09:21:22.115Z', - }, - ], - pagination: { - total: 26, - current_page: 1, - total_page: 3, - size: 10, - has_next_page: true, - has_prev_page: false, + const items = [ + { + id: '61397d9892992823d7329bc9', + text: '每位师傅各有所长,我都会一点点。', + author: '陆沉', + source: '', + created: '2021-09-09T03:20:56.769Z', }, - }) + { + id: '60853492fbfab397775cc12d', + text: '我不是一个优秀的人,只是我们观测的角度不同。', + created: '2021-04-25T09:21:22.115Z', + }, + ] + const pagination = { + page: 1, + size: 10, + total: 26, + total_pages: 3, + } + mockResponse('/says', items, 'get', undefined, { pagination }) const data = await client.say.getAllPaginated() - expect(data.$raw.data).toEqual(mocked) - expect(data.data).toEqual(camelcaseKeys(mocked.data, { deep: true })) + expect(data.$raw.data.data).toEqual(items) + expect(data.data).toEqual(camelcaseKeys(items, { deep: true })) expect(data.data[0].text).toEqual('每位师傅各有所长,我都会一点点。') }) it('should with page and size', async () => { - const mocked = await mockResponse('/says?size=1&page=1', { - data: [ - { - id: '61397d9892992823d7329bc9', - text: '每位师傅各有所长,我都会一点点。', - author: '陆沉', - source: '', - created: '2021-09-09T03:20:56.769Z', - }, - ], - pagination: { - total: 26, - current_page: 1, - total_page: 26, - size: 1, - has_next_page: true, - has_prev_page: false, + const items = [ + { + id: '61397d9892992823d7329bc9', + text: '每位师傅各有所长,我都会一点点。', + author: '陆沉', + source: '', + created: '2021-09-09T03:20:56.769Z', }, + ] + const pagination = { + page: 1, + size: 1, + total: 26, + total_pages: 26, + } + mockResponse('/says?size=1&page=1', items, 'get', undefined, { + pagination, }) const data = await client.say.getAllPaginated(1, 1) - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(items) + expect(data).toEqual( + camelcaseKeys({ data: items, pagination }, { deep: true }), + ) }) }) @@ -99,7 +99,7 @@ describe('test say client', () => { created: '2021-09-09T03:20:56.769Z', }) const data = await client.say.getById('61397d9892992823d7329bc9') - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) expect(data.id).toEqual('61397d9892992823d7329bc9') }) }) diff --git a/packages/api-client/__tests__/controllers/serverless.test.ts b/packages/api-client/__tests__/controllers/serverless.test.ts index e096cda7f56..285171fca4a 100644 --- a/packages/api-client/__tests__/controllers/serverless.test.ts +++ b/packages/api-client/__tests__/controllers/serverless.test.ts @@ -14,6 +14,6 @@ describe('test Snippet client', () => { ) expect(data).toEqual(mocked) - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) }) }) diff --git a/packages/api-client/__tests__/controllers/snippet.test.ts b/packages/api-client/__tests__/controllers/snippet.test.ts index 7b776b7290c..d41074743fe 100644 --- a/packages/api-client/__tests__/controllers/snippet.test.ts +++ b/packages/api-client/__tests__/controllers/snippet.test.ts @@ -33,6 +33,6 @@ describe('test Snippet client', () => { ) expect(data).toEqual(mocked) - expect(data.$raw.data).toEqual(mocked) + expect(data.$raw.data.data).toEqual(mocked) }) }) diff --git a/packages/api-client/__tests__/core/client.test.ts b/packages/api-client/__tests__/core/client.test.ts index 0b7796943df..596dd3a5b6d 100644 --- a/packages/api-client/__tests__/core/client.test.ts +++ b/packages/api-client/__tests__/core/client.test.ts @@ -277,6 +277,105 @@ describe('test client', () => { } }) + it('should camelCase $meta from snake_case wire response', async () => { + const client = generateClient(axiosAdaptor) + spyOn(axiosAdaptor, 'get').mockImplementation((url) => { + if (url === 'http://127.0.0.1:2323/a') { + return Promise.resolve({ + data: { + data: { id: '1' }, + meta: { + interaction: { + is_liked: true, + like_count: 3, + read_count: 7, + }, + }, + }, + status: 200, + }) + } + return Promise.resolve({ data: null }) + }) + + const res = await client.proxy.a.get() + expect(res.$meta).toBeDefined() + expect(res.$meta.interaction).toStrictEqual({ + isLiked: true, + likeCount: 3, + readCount: 7, + }) + }) + + it('should honor custom transformResponse for $meta', async () => { + const client = generateClient(axiosAdaptor) + spyOn(axiosAdaptor, 'get').mockImplementation((url) => { + if (url === 'http://127.0.0.1:2323/a') { + return Promise.resolve({ + data: { + data: { id: '1' }, + meta: { interaction: { is_liked: false } }, + }, + status: 200, + }) + } + return Promise.resolve({ data: null }) + }) + + const res = await client.proxy.a.get({ + transformResponse: (payload) => ({ transformed: true, ...payload }), + }) + expect(res.$meta).toMatchObject({ transformed: true }) + expect(res.$meta.interaction).toStrictEqual({ is_liked: false }) + }) + + it('should extract $meta when the adapter returns the envelope directly (ofetch-style)', async () => { + // ofetch's $fetch returns the parsed body as-is — no axios-style `{ data: }` wrapper. + const ofetchAdapter: IRequestAdapter = { + default: () => Promise.resolve(null), + get: () => + Promise.resolve({ + data: [{ id: '1' }, { id: '2' }], + meta: { + pagination: { page: 1, size: 10, total: 42, total_pages: 5 }, + }, + }) as any, + post: () => Promise.resolve(null) as any, + put: () => Promise.resolve(null) as any, + patch: () => Promise.resolve(null) as any, + delete: () => Promise.resolve(null) as any, + } + + const client = createClient(ofetchAdapter)('http://127.0.0.1:2323') + const res = await client.proxy.posts.get() + + expect(res.$meta).toBeDefined() + expect(res.$meta.pagination).toStrictEqual({ + page: 1, + size: 10, + total: 42, + totalPages: 5, + }) + }) + + it('should leave $meta undefined when response body has no meta key', async () => { + const client = generateClient(axiosAdaptor) + spyOn(axiosAdaptor, 'get').mockImplementation((url) => { + if (url === 'http://127.0.0.1:2323/a') { + return Promise.resolve({ + data: { + data: { id: '1' }, + }, + status: 200, + }) + } + return Promise.resolve({ data: null }) + }) + + const res = await client.proxy.a.get() + expect(res.$meta).toBeUndefined() + }) + it('should throw custom exception', async () => { class MyRequestError extends Error { constructor( diff --git a/packages/api-client/__tests__/core/meta-for.test.ts b/packages/api-client/__tests__/core/meta-for.test.ts new file mode 100644 index 00000000000..689dafeb408 --- /dev/null +++ b/packages/api-client/__tests__/core/meta-for.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' + +import { metaFor } from '~/core/meta-for' +import type { ResponseMeta } from '~/models/base' + +const item = { id: '123456789' } + +describe('metaFor', () => { + it('returns empty object when meta is undefined', () => { + expect(metaFor(item, undefined)).toStrictEqual({}) + }) + + it('returns empty object when meta has no translation or interaction', () => { + const meta: ResponseMeta = { + pagination: { page: 1, size: 10, total: 1, totalPages: 1 }, + } + expect(metaFor(item, meta)).toStrictEqual({ + translation: undefined, + interaction: undefined, + }) + }) + + it('returns single translation directly (detail shape)', () => { + const meta: ResponseMeta = { + translation: { article: { isTranslated: true, sourceLang: 'zh' } }, + } + const result = metaFor(item, meta) + expect(result.translation).toStrictEqual({ + article: { isTranslated: true, sourceLang: 'zh' }, + }) + }) + + it('returns translation by id from record (list shape)', () => { + const meta: ResponseMeta = { + translation: { + '123456789': { article: { isTranslated: false } }, + '987654321': { article: { isTranslated: true } }, + }, + } + const result = metaFor(item, meta) + expect(result.translation).toStrictEqual({ + article: { isTranslated: false }, + }) + }) + + it('returns undefined translation when id is missing in record (list shape)', () => { + const meta: ResponseMeta = { + translation: { + '987654321': { article: { isTranslated: true } }, + }, + } + const result = metaFor(item, meta) + expect(result.translation).toBeUndefined() + }) + + it('returns single interaction directly (detail shape)', () => { + const meta: ResponseMeta = { + interaction: { isLiked: true, likeCount: 5, readCount: 42 }, + } + const result = metaFor(item, meta) + expect(result.interaction).toStrictEqual({ + isLiked: true, + likeCount: 5, + readCount: 42, + }) + }) + + it('returns interaction by id from record (list shape)', () => { + const meta: ResponseMeta = { + interaction: { + '123456789': { isLiked: false, likeCount: 1 }, + '987654321': { isLiked: true, likeCount: 9 }, + }, + } + const result = metaFor(item, meta) + expect(result.interaction).toStrictEqual({ isLiked: false, likeCount: 1 }) + }) + + it('returns undefined interaction when id is missing in record (list shape)', () => { + const meta: ResponseMeta = { + interaction: { + '987654321': { isLiked: true }, + }, + } + const result = metaFor(item, meta) + expect(result.interaction).toBeUndefined() + }) + + it('handles partial interaction single shape (only some keys present)', () => { + const meta: ResponseMeta = { + interaction: { likeCount: 10 }, + } + const result = metaFor(item, meta) + expect(result.interaction).toStrictEqual({ likeCount: 10 }) + }) + + it('treats an empty translation object as undefined (neither single nor record)', () => { + const meta: ResponseMeta = { + translation: {}, + } + const result = metaFor(item, meta) + expect(result.translation).toBeUndefined() + }) +}) diff --git a/packages/api-client/__tests__/helpers/response.ts b/packages/api-client/__tests__/helpers/response.ts index 9454981d814..eb6ebbdecf3 100644 --- a/packages/api-client/__tests__/helpers/response.ts +++ b/packages/api-client/__tests__/helpers/response.ts @@ -1,18 +1,30 @@ import type { URLSearchParams } from 'node:url' import { inspect } from 'node:util' -import { axiosAdaptor } from '~/adaptors/axios' + import { isEqual } from 'es-toolkit/compat' import { vi } from 'vitest' +import { axiosAdaptor } from '~/adaptors/axios' + const { spyOn } = vi -export const buildResponseDataWrapper = (data: any) => ({ data }) +/** + * Build an adaptor response whose HTTP body is the V2 envelope + * `{ data, meta? }`. The adaptor itself wraps the body under `.data`. + */ +export const buildResponseDataWrapper = ( + payload: any, + meta?: Record, +) => ({ + data: meta === undefined ? { data: payload } : { data: payload, meta }, +}) export const mockResponse = ( path: string, data: T, method = 'get', requestBody?: any, + meta?: Record, ) => { const exceptUrlObject = new URL( path.startsWith('http') @@ -45,7 +57,7 @@ export const mockResponse = ( ) : true) ) { - return buildResponseDataWrapper(data) + return buildResponseDataWrapper(data, meta) } else { return buildResponseDataWrapper({ error: 1, diff --git a/packages/api-client/__tests__/legacy/response-adapter.test.ts b/packages/api-client/__tests__/legacy/response-adapter.test.ts new file mode 100644 index 00000000000..e3bde73f9e8 --- /dev/null +++ b/packages/api-client/__tests__/legacy/response-adapter.test.ts @@ -0,0 +1,493 @@ +import type { AxiosResponse } from 'axios' +import { vi } from 'vitest' + +import { axiosAdaptor } from '~/adaptors/axios' +import { createClient } from '~/core' +import { createLegacyApiClient, legacyResponseAdapter } from '~/legacy' + +const { spyOn } = vi + +const createTestClient = () => + createClient(axiosAdaptor)('https://example.com', { + responseAdapter: legacyResponseAdapter(), + }) + +describe('legacy response adapter', () => { + it('restores detail meta fields onto the entity', async () => { + spyOn(axiosAdaptor, 'get').mockResolvedValue({ + data: { + data: { id: '1', title: 'Code & Dopamine' }, + meta: { + interaction: { is_liked: true, like_count: 2 }, + translation: { + article: { + is_translated: true, + source_lang: 'zh', + target_lang: 'en', + translated_at: '2026-05-22T00:00:00.000Z', + model: 'claude-haiku-4-5', + available_translations: ['en', 'ko'], + }, + }, + enrichments: { + 'https://example.com': { id: 'enrichment-1' }, + }, + related: [{ id: '2', title: 'related' }], + insights: { has_in_locale: true }, + }, + }, + status: 200, + } as any) + + const data = await createTestClient().proxy.posts('1').get() + + expect(data).toMatchObject({ + id: '1', + title: 'Code & Dopamine', + isLiked: true, + likeCount: 2, + isTranslated: true, + sourceLang: 'zh', + availableTranslations: ['en', 'ko'], + translationMeta: { + sourceLang: 'zh', + targetLang: 'en', + model: 'claude-haiku-4-5', + }, + enrichments: { + 'https://example.com': { id: 'enrichment-1' }, + }, + related: [{ id: '2', title: 'related' }], + hasInsightsInLocale: true, + }) + expect((data as any).translationMeta).not.toHaveProperty('title') + expect((data as any).translationMeta).not.toHaveProperty('text') + expect((data as any).translationMeta).not.toHaveProperty('content') + expect(data.$serialized).toMatchObject({ + id: '1', + isLiked: true, + isTranslated: true, + }) + expect(data.$meta).toMatchObject({ + interaction: { isLiked: true, likeCount: 2 }, + }) + }) + + it('restores list meta fields by item id', async () => { + spyOn(axiosAdaptor, 'get').mockResolvedValue({ + data: { + data: [ + { id: '1', title: 'Translated Title' }, + { id: '2', title: 'two' }, + ], + meta: { + pagination: { page: 2, size: 2, total: 6, total_pages: 3 }, + interaction: { + '1': { is_liked: true }, + '2': { is_liked: false }, + }, + translation: { + '1': { + article: { + is_translated: true, + source_lang: 'zh', + target_lang: 'en', + model: 'claude-haiku-4-5', + available_translations: ['en'], + }, + }, + }, + }, + }, + status: 200, + } as any) + + const data = await createTestClient().proxy.posts.get() + + expect(data).toMatchObject({ + data: [ + { + id: '1', + title: 'Translated Title', + isLiked: true, + isTranslated: true, + sourceLang: 'zh', + availableTranslations: ['en'], + translationMeta: { + sourceLang: 'zh', + targetLang: 'en', + model: 'claude-haiku-4-5', + }, + }, + { id: '2', isLiked: false }, + ], + pagination: { + page: 2, + currentPage: 2, + size: 2, + total: 6, + totalPages: 3, + totalPage: 3, + hasNextPage: true, + hasPrevPage: true, + }, + }) + expect((data as any).data[0].translationMeta).not.toHaveProperty('title') + expect((data as any).data[0].translationMeta).not.toHaveProperty('text') + expect((data as any).data[0].translationMeta).not.toHaveProperty('content') + }) + + it('supports endpoint include and exclude matchers for gradual migration', async () => { + spyOn(axiosAdaptor, 'get').mockResolvedValue({ + data: { + data: { data: { title: 'wrapped' } }, + }, + status: 200, + } as any) + + const client = createClient(axiosAdaptor)( + 'https://example.com', + { + responseAdapter: legacyResponseAdapter({ + only: ['/legacy'], + except: ['GET /legacy-disabled'], + }), + }, + ) + + await expect(client.proxy.legacy.get()).resolves.toEqual({ + title: 'wrapped', + }) + await expect(client.proxy.next.get()).resolves.toEqual({ + data: { title: 'wrapped' }, + }) + await expect(client.proxy('legacy-disabled').get()).resolves.toEqual({ + data: { title: 'wrapped' }, + }) + }) + + it('strips body off aggregate/top items even when the envelope is passed through', async () => { + spyOn(axiosAdaptor, 'get').mockResolvedValue({ + data: { + data: { + notes: [{ id: '1', title: 'Translated Note', text: 'body' }], + posts: [{ id: '2', title: 'post', content: 'body' }], + says: [{ id: '3', text: 'say' }], + recently: [{ id: '4', content: 'recent' }], + }, + meta: { + translation: { + '1': { + article: { + is_translated: true, + source_lang: 'zh', + target_lang: 'en', + model: 'claude-haiku-4-5', + available_translations: ['en'], + }, + }, + }, + }, + }, + status: 200, + } as any) + + const client = createClient(axiosAdaptor)( + 'https://example.com', + { + responseAdapter: legacyResponseAdapter(), + getDataFromResponse: (res: any) => res?.data, + }, + ) + + const data = await client.proxy.aggregate.top.get() + + expect(data).toMatchObject({ + notes: [{ id: '1', title: 'Translated Note' }], + posts: [{ id: '2', title: 'post' }], + says: [{ id: '3', text: 'say' }], + recently: [{ id: '4', content: 'recent' }], + }) + expect(data.notes[0]).not.toHaveProperty('text') + expect(data.posts[0]).not.toHaveProperty('content') + expect(data.notes[0]).toMatchObject({ + isTranslated: true, + sourceLang: 'zh', + translationMeta: { + sourceLang: 'zh', + targetLang: 'en', + model: 'claude-haiku-4-5', + }, + }) + expect(data.notes[0].translationMeta).not.toHaveProperty('title') + }) + + it('creates a client with the legacy adapter preconfigured', async () => { + spyOn(axiosAdaptor, 'get').mockResolvedValue({ + data: { + data: { data: { title: 'wrapped' } }, + }, + status: 200, + } as any) + + const client = createLegacyApiClient(axiosAdaptor)( + 'https://example.com', + ) + + await expect(client.proxy.posts.latest.get()).resolves.toEqual({ + title: 'wrapped', + }) + }) + + it('flattens translation meta onto nested arrays in activity/rooms', async () => { + spyOn(axiosAdaptor, 'get').mockResolvedValue({ + data: { + data: { + rooms: ['room-1'], + roomCount: { 'room-1': 2 }, + objects: { + post: [{ id: '10', title: 'Translated Post Title' }], + note: [{ id: '20', title: 'Note Title' }], + }, + }, + meta: { + translation: { + '10': { + article: { + is_translated: true, + source_lang: 'zh', + target_lang: 'en', + model: 'claude-haiku-4-5', + available_translations: ['en'], + }, + }, + }, + }, + }, + status: 200, + } as any) + + const client = createClient(axiosAdaptor)( + 'https://example.com', + { + responseAdapter: legacyResponseAdapter(), + getDataFromResponse: (res: any) => res?.data, + }, + ) + + const data = await client.proxy.activity.rooms.get() + + expect(data.objects.post[0]).toMatchObject({ + id: '10', + title: 'Translated Post Title', + isTranslated: true, + sourceLang: 'zh', + translationMeta: { + sourceLang: 'zh', + targetLang: 'en', + model: 'claude-haiku-4-5', + }, + }) + expect(data.objects.post[0].translationMeta).not.toHaveProperty('title') + expect(data.objects.note[0]).not.toHaveProperty('translationMeta') + expect(data.objects.note[0]).not.toHaveProperty('isTranslated') + }) + + it('rewrites legacy sortBy aliases on outgoing requests', async () => { + const get = spyOn(axiosAdaptor, 'get').mockResolvedValue({ + data: { data: { data: [], pagination: {} } }, + status: 200, + } as any) + get.mockClear() + + const client = createLegacyApiClient(axiosAdaptor)( + 'https://example.com', + ) + + await client.proxy.notes.topics('topic-1').get({ + params: { sortBy: 'created', sortOrder: -1 }, + }) + expect(get.mock.calls[0][0]).toContain('sortBy=createdAt') + expect(get.mock.calls[0][0]).toContain('sortOrder=-1') + expect(get.mock.calls[0][0]).not.toMatch(/sortBy=created(?!At)/) + + await client.proxy.notes.topics('topic-1').get({ + params: { sortBy: 'modified', sortOrder: 1 }, + }) + expect(get.mock.calls[1][0]).toContain('sortBy=modifiedAt') + + await client.proxy.notes.topics('topic-1').get({ + params: { sortBy: 'title' }, + }) + expect(get.mock.calls[2][0]).toContain('sortBy=title') + + const postSpy = spyOn(axiosAdaptor, 'post').mockResolvedValue({ + data: { data: {} }, + status: 200, + } as any) + postSpy.mockClear() + await client.proxy.search.post({ + params: { sortBy: 'modified' }, + data: { q: 'x' }, + }) + // POST keeps params on the options object — rewrite still applies. + expect(postSpy.mock.calls[0][1]?.params).toMatchObject({ + sortBy: 'modifiedAt', + }) + }) + + it('wraps bare list payloads into envelope shape when no pagination meta is present', async () => { + spyOn(axiosAdaptor, 'get').mockResolvedValue({ + data: { + data: [ + { id: '1', title: 'first' }, + { id: '2', title: 'second' }, + ], + }, + status: 200, + } as any) + + const client = createLegacyApiClient(axiosAdaptor)( + 'https://example.com', + ) + + const data = await client.proxy.recently.get() + + expect(data).toEqual({ + data: [ + { id: '1', title: 'first' }, + { id: '2', title: 'second' }, + ], + }) + }) + + it('rewraps aggregate/timeline payload into the envelope shape', async () => { + spyOn(axiosAdaptor, 'get').mockResolvedValue({ + data: { + data: { + posts: [{ id: '1', title: 'Translated Post' }], + notes: [{ id: '2', title: 'note' }], + }, + meta: { + translation: { + '1': { + article: { + is_translated: true, + source_lang: 'zh', + target_lang: 'en', + model: 'claude-haiku-4-5', + available_translations: ['en'], + }, + }, + }, + }, + }, + status: 200, + } as any) + + const client = createLegacyApiClient(axiosAdaptor)( + 'https://example.com', + ) + + const data = await client.proxy.aggregate.timeline.get() + + expect(data).toMatchObject({ + data: { + posts: [ + { + id: '1', + title: 'Translated Post', + isTranslated: true, + sourceLang: 'zh', + }, + ], + notes: [{ id: '2', title: 'note' }], + }, + }) + expect(data.data.posts[0].translationMeta).toMatchObject({ + sourceLang: 'zh', + targetLang: 'en', + model: 'claude-haiku-4-5', + }) + }) + + it('rewraps aggregate/timeline even when envelope is passed through', async () => { + spyOn(axiosAdaptor, 'get').mockResolvedValue({ + data: { + data: { + posts: [{ id: '1', title: 'post' }], + notes: [{ id: '2', title: 'note' }], + }, + meta: {}, + }, + status: 200, + } as any) + + const client = createClient(axiosAdaptor)( + 'https://example.com', + { + responseAdapter: legacyResponseAdapter(), + getDataFromResponse: (res: any) => res?.data, + }, + ) + + const data = await client.proxy.aggregate.timeline.get() + + expect(data).toMatchObject({ + data: { + posts: [{ id: '1', title: 'post' }], + notes: [{ id: '2', title: 'note' }], + }, + }) + }) + + it('synthesizes id from refId for reading rank/top items', async () => { + spyOn(axiosAdaptor, 'get').mockResolvedValue({ + data: { + data: { + data: [ + { refId: '100', ref: { title: 'Translated Ref Title' }, count: 5 }, + { refId: '200', ref: { title: 'Other Ref' }, count: 3 }, + ], + }, + meta: { + translation: { + '100': { + article: { + is_translated: true, + source_lang: 'zh', + target_lang: 'en', + model: 'claude-haiku-4-5', + available_translations: ['en'], + }, + }, + }, + }, + }, + status: 200, + } as any) + + const client = createClient(axiosAdaptor)( + 'https://example.com', + { + responseAdapter: legacyResponseAdapter(), + getDataFromResponse: (res: any) => res?.data, + }, + ) + + const data = await client.proxy.activity.reading.top.get() + + expect(data.data[0]).toMatchObject({ + refId: '100', + isTranslated: true, + translationMeta: { + sourceLang: 'zh', + targetLang: 'en', + model: 'claude-haiku-4-5', + }, + }) + expect(data.data[0]).not.toHaveProperty('id') + expect(data.data[1]).not.toHaveProperty('translationMeta') + expect(data.data[1]).not.toHaveProperty('isTranslated') + }) +}) diff --git a/packages/api-client/adaptors/fetch.ts b/packages/api-client/adaptors/fetch.ts index 0791158f4f9..d0b7a46d1af 100644 --- a/packages/api-client/adaptors/fetch.ts +++ b/packages/api-client/adaptors/fetch.ts @@ -20,8 +20,16 @@ const jsonDataAttachResponse = async (response: Response) => { } } + // `Response`'s `status`/`statusText`/`url`/`headers`/`ok` are getters, so + // `Object.assign({}, response, ...)` drops them. Copy them explicitly so + // downstream error handling (RequestError.status, etc.) can read them. const nextResponse = Object.assign({}, response, { data, + status: response.status, + statusText: response.statusText, + headers: response.headers, + url: response.url, + ok: response.ok, }) if (response.ok) { diff --git a/packages/api-client/controllers/ack.ts b/packages/api-client/controllers/ack.ts index c1a0e84346a..ab4337f2059 100644 --- a/packages/api-client/controllers/ack.ts +++ b/packages/api-client/controllers/ack.ts @@ -4,7 +4,7 @@ import type { IRequestHandler } from '~/interfaces/request' import { autoBind } from '~/utils/auto-bind' import type { HTTPClient } from '../core' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, diff --git a/packages/api-client/controllers/activity.ts b/packages/api-client/controllers/activity.ts index 4198cd36320..cf47dd9fc57 100644 --- a/packages/api-client/controllers/activity.ts +++ b/packages/api-client/controllers/activity.ts @@ -13,7 +13,7 @@ import { camelcaseKeys } from '~/utils/camelcase-keys' import type { HTTPClient } from '../core' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, diff --git a/packages/api-client/controllers/aggregate.ts b/packages/api-client/controllers/aggregate.ts index cf650a71885..dc53c262258 100644 --- a/packages/api-client/controllers/aggregate.ts +++ b/packages/api-client/controllers/aggregate.ts @@ -1,5 +1,6 @@ import type { IRequestAdapter } from '~/interfaces/adapter' import type { IController } from '~/interfaces/controller' +import type { NextFetchRequestConfig } from '~/interfaces/instance' import type { SortOrder } from '~/interfaces/options' import type { IRequestHandler, RequestProxyResult } from '~/interfaces/request' import type { @@ -17,7 +18,12 @@ import { autoBind } from '~/utils/auto-bind' import type { HTTPClient } from '../core' -declare module '../core/client' { +interface CacheableOptions { + next?: NextFetchRequestConfig + cache?: RequestCache +} + +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, @@ -41,23 +47,33 @@ export class AggregateController implements IController { */ getAggregateData( theme?: string, + options: { lang?: string } & CacheableOptions = {}, ): RequestProxyResult, ResponseWrapper> { + const { lang, next, cache } = options return this.proxy.get>({ params: { theme, + ...(lang ? { lang } : {}), }, - }) + next, + cache, + } as any) } - getSiteMetadata(): RequestProxyResult { - return this.proxy.site.get() + getSiteMetadata( + options: CacheableOptions = {}, + ): RequestProxyResult { + return this.proxy.site.get(options as any) } /** * 获取最新发布的内容 */ - getTop(size = 5) { - return this.proxy.top.get({ params: { size } }) + getTop(size = 5, options: CacheableOptions = {}) { + return this.proxy.top.get({ + params: { size }, + ...options, + } as any) } getTimeline(options?: { @@ -66,7 +82,7 @@ export class AggregateController implements IController { year?: number }) { const { sort, type, year } = options || {} - return this.proxy.timeline.get<{ data: TimelineData }>({ + return this.proxy.timeline.get({ params: { sort: sort && sortOrderToNumber(sort), type, diff --git a/packages/api-client/controllers/ai.ts b/packages/api-client/controllers/ai.ts index c58c0f89cd1..98f3565791b 100644 --- a/packages/api-client/controllers/ai.ts +++ b/packages/api-client/controllers/ai.ts @@ -11,7 +11,7 @@ import type { AITranslationModel, } from '../models/ai' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, diff --git a/packages/api-client/controllers/base.ts b/packages/api-client/controllers/base.ts index 96d7549e424..55726a975cc 100644 --- a/packages/api-client/controllers/base.ts +++ b/packages/api-client/controllers/base.ts @@ -1,6 +1,7 @@ import type { IRequestHandler, RequestProxyResult } from '~/interfaces/request' import type { PaginateResult } from '~/models/base' import { autoBind } from '~/utils/auto-bind' + import type { HTTPClient } from '../core' export type SortOptions = { @@ -21,7 +22,7 @@ export abstract class BaseCrudController { } getAll() { - return this.proxy.all.get<{ data: T[] }>() + return this.proxy.all.get() } /** * 带分页的查询 diff --git a/packages/api-client/controllers/category.ts b/packages/api-client/controllers/category.ts index 3340bab17b5..66e4abf62dd 100644 --- a/packages/api-client/controllers/category.ts +++ b/packages/api-client/controllers/category.ts @@ -19,7 +19,7 @@ import type { } from '../models/category' import { CategoryType } from '../models/category' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, @@ -39,19 +39,16 @@ export class CategoryController implements IController { return this.client.proxy(this.base) } - getAllCategories(): RequestProxyResult< - { data: CategoryModel[] }, - ResponseWrapper - > { - return this.proxy.get({ + getAllCategories(): RequestProxyResult { + return this.proxy.get({ params: { type: CategoryType.Category, }, }) } - getAllTags(): RequestProxyResult<{ data: TagModel[] }, ResponseWrapper> { - return this.proxy.get<{ data: TagModel[] }>({ + getAllTags(): RequestProxyResult { + return this.proxy.get({ params: { type: CategoryType.Tag, }, diff --git a/packages/api-client/controllers/comment.ts b/packages/api-client/controllers/comment.ts index 38253891076..29e519bb2c6 100644 --- a/packages/api-client/controllers/comment.ts +++ b/packages/api-client/controllers/comment.ts @@ -20,7 +20,7 @@ import type { ReaderCommentDto, } from '../dtos/comment' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, diff --git a/packages/api-client/controllers/index.ts b/packages/api-client/controllers/index.ts index b5a6f3b7555..b7f9d203de4 100644 --- a/packages/api-client/controllers/index.ts +++ b/packages/api-client/controllers/index.ts @@ -8,13 +8,12 @@ import { LinkController } from './link' import type { NoteMiddleListOptions, NoteTimelineItem, - NoteTopicListItem, NoteTopicListOptions, } from './note' import { NoteController } from './note' import { UserController } from './owner' import { PageController } from './page' -import type { PostListItem, PostListOptions } from './post' +import type { PostListOptions } from './post' import { PostController } from './post' import { ProjectController } from './project' import { @@ -102,10 +101,5 @@ export { UserController, } -export type { - NoteMiddleListOptions, - NoteTimelineItem, - NoteTopicListItem, - NoteTopicListOptions, -} -export type { PostListItem, PostListOptions } +export type { NoteMiddleListOptions, NoteTimelineItem, NoteTopicListOptions } +export type { PostListOptions } diff --git a/packages/api-client/controllers/link.ts b/packages/api-client/controllers/link.ts index 34ed530fc0f..a32b2f86ec2 100644 --- a/packages/api-client/controllers/link.ts +++ b/packages/api-client/controllers/link.ts @@ -4,7 +4,7 @@ import { autoBind } from '~/utils/auto-bind' import type { HTTPClient } from '../core' import { BaseCrudController } from './base' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, diff --git a/packages/api-client/controllers/note.ts b/packages/api-client/controllers/note.ts index c3b0712339b..b15b4c80a72 100644 --- a/packages/api-client/controllers/note.ts +++ b/packages/api-client/controllers/note.ts @@ -1,20 +1,14 @@ import type { IRequestAdapter } from '~/interfaces/adapter' import type { IController } from '~/interfaces/controller' import type { IRequestHandler, RequestProxyResult } from '~/interfaces/request' -import type { SelectFields } from '~/interfaces/types' -import type { PaginateResult, TranslationMeta } from '~/models/base' -import type { - NoteModel, - NoteWrappedPayload, - NoteWrappedWithLikedAndTranslationPayload, - NoteWrappedWithLikedPayload, -} from '~/models/note' +import type { PaginateResult } from '~/models/base' +import type { NoteModel, NoteWrappedPayload } from '~/models/note' import { autoBind } from '~/utils/auto-bind' import type { HTTPClient } from '../core/client' import type { SortOptions } from './base' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, @@ -24,7 +18,6 @@ declare module '../core/client' { } export type NoteListOptions = { - select?: SelectFields year?: number sortBy?: 'weather' | 'mood' | 'title' | 'createdAt' | 'modifiedAt' sortOrder?: 1 | -1 @@ -52,15 +45,7 @@ export type NoteTopicListOptions = SortOptions & { export type NoteTimelineItem = Pick< NoteModel, 'id' | 'title' | 'nid' | 'slug' | 'createdAt' | 'isPublished' -> & { - isTranslated?: boolean - translationMeta?: TranslationMeta -} - -export type NoteTopicListItem = NoteModel & { - isTranslated?: boolean - translationMeta?: TranslationMeta -} +> export class NoteController implements IController { base = 'notes' @@ -77,7 +62,7 @@ export class NoteController implements IController { * 最新日记 */ getLatest() { - return this.proxy.latest.get() + return this.proxy.latest.get() } /** @@ -105,7 +90,7 @@ export class NoteController implements IController { const [id, password = undefined, singleResult = false] = rest if (typeof id === 'number') { - return this.proxy.nid(id.toString()).get({ + return this.proxy.nid(id.toString()).get({ params: { password, single: singleResult ? '1' : undefined }, }) } else { @@ -121,10 +106,7 @@ export class NoteController implements IController { getNoteByNid( nid: number, options?: NoteByNidOptions, - ): RequestProxyResult< - NoteWrappedWithLikedAndTranslationPayload, - ResponseWrapper - > { + ): RequestProxyResult { const { password, single, lang, prefer } = options || {} return this.proxy.nid(nid.toString()).get({ params: { @@ -142,10 +124,7 @@ export class NoteController implements IController { day: number, slug: string, options?: NoteBySlugDateOptions, - ): RequestProxyResult< - NoteWrappedWithLikedAndTranslationPayload, - ResponseWrapper - > { + ): RequestProxyResult { const { password, single, lang, prefer } = options || {} return this.proxy(year.toString())(month.toString())(day.toString())( slug, @@ -164,12 +143,11 @@ export class NoteController implements IController { */ getList(page = 1, perPage = 10, options: NoteListOptions = {}) { - const { select, sortBy, sortOrder, year, lang, withSummary } = options + const { sortBy, sortOrder, year, lang, withSummary } = options return this.proxy.get>({ params: { page, size: perPage, - select: select?.join(' '), sortBy, sortOrder, year, @@ -209,7 +187,7 @@ export class NoteController implements IController { options: NoteTopicListOptions = {}, ) { const { lang, ...sortOptions } = options - return this.proxy.topics(topicId).get>({ + return this.proxy.topics(topicId).get>({ params: { page, size, lang, ...sortOptions }, }) } diff --git a/packages/api-client/controllers/owner.ts b/packages/api-client/controllers/owner.ts index 00c5c108fe4..2595c0caac7 100644 --- a/packages/api-client/controllers/owner.ts +++ b/packages/api-client/controllers/owner.ts @@ -13,7 +13,7 @@ import type { import { autoBind } from '~/utils/auto-bind' import type { HTTPClient } from '../core' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, diff --git a/packages/api-client/controllers/page.ts b/packages/api-client/controllers/page.ts index 4490b1090e3..22f9383802a 100644 --- a/packages/api-client/controllers/page.ts +++ b/packages/api-client/controllers/page.ts @@ -1,14 +1,13 @@ import type { IRequestAdapter } from '~/interfaces/adapter' import type { IController } from '~/interfaces/controller' import type { IRequestHandler } from '~/interfaces/request' -import type { SelectFields } from '~/interfaces/types' import type { PaginateResult } from '~/models/base' import type { PageModel } from '~/models/page' import { autoBind } from '~/utils/auto-bind' import type { HTTPClient } from '../core' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, @@ -18,7 +17,6 @@ declare module '../core/client' { } export type PageListOptions = { - select?: SelectFields sortBy?: 'order' | 'subtitle' | 'title' | 'createdAt' | 'modifiedAt' sortOrder?: 1 | -1 } @@ -36,12 +34,11 @@ export class PageController implements IController { * 页面列表 */ getList(page = 1, perPage = 10, options: PageListOptions = {}) { - const { select, sortBy, sortOrder } = options + const { sortBy, sortOrder } = options return this.proxy.get>({ params: { page, size: perPage, - select: select?.join(' '), sortBy, sortOrder, }, diff --git a/packages/api-client/controllers/post.ts b/packages/api-client/controllers/post.ts index 2bde2f439b4..68b43985974 100644 --- a/packages/api-client/controllers/post.ts +++ b/packages/api-client/controllers/post.ts @@ -1,19 +1,13 @@ import type { IRequestAdapter } from '~/interfaces/adapter' import type { IController } from '~/interfaces/controller' import type { IRequestHandler, RequestProxyResult } from '~/interfaces/request' -import type { SelectFields } from '~/interfaces/types' -import type { - ModelWithLiked, - ModelWithTranslation, - PaginateResult, - TranslationMeta, -} from '~/models/base' +import type { PaginateResult } from '~/models/base' import type { PostModel } from '~/models/post' import { autoBind } from '~/utils/auto-bind' import type { HTTPClient } from '../core/client' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, @@ -23,7 +17,6 @@ declare module '../core/client' { } export type PostListOptions = { - select?: SelectFields year?: number sortBy?: 'categoryId' | 'title' | 'createdAt' | 'modifiedAt' | 'pinAt' sortOrder?: 1 | -1 @@ -32,12 +25,6 @@ export type PostListOptions = { lang?: string } -/** 文章列表项,可能包含翻译信息 */ -export type PostListItem = PostModel & { - isTranslated?: boolean - translationMeta?: TranslationMeta -} - export class PostController implements IController { constructor(private client: HTTPClient) { autoBind(this) @@ -51,20 +38,12 @@ export class PostController implements IController { return this.client.proxy(this.base) } - /** - * 获取文章列表分页 - * @param page - * @param perPage - * @param options 可选参数,包含 lang 用于获取翻译版本 - * @returns 当传入 lang 时,返回的文章可能包含 isTranslated 和 translationMeta 字段 - */ getList(page = 1, perPage = 10, options: PostListOptions = {}) { - const { select, sortBy, sortOrder, year, truncate, lang } = options - return this.proxy.get>({ + const { sortBy, sortOrder, year, truncate, lang } = options + return this.proxy.get>({ params: { page, size: perPage, - select: select?.join(' '), sortBy, sortOrder, year, @@ -74,24 +53,11 @@ export class PostController implements IController { }) } - /** - * 根据分类和路径查找文章 - * @param categoryName - * @param slug - * @param options 可选参数,包含 lang 用于获取翻译版本 - */ getPost( categoryName: string, slug: string, options?: { lang?: string; prefer?: 'lexical' }, - ): RequestProxyResult< - ModelWithLiked>, - ResponseWrapper - > - /** - * 根据 ID 查找文章 - * @param id - */ + ): RequestProxyResult getPost(id: string): RequestProxyResult getPost( idOrCategoryName: string, @@ -110,11 +76,8 @@ export class PostController implements IController { } } - /** - * 获取最新的文章 - */ getLatest() { - return this.proxy.latest.get>() + return this.proxy.latest.get() } getFullUrl(slug: string) { diff --git a/packages/api-client/controllers/project.ts b/packages/api-client/controllers/project.ts index 2d9a3046e3c..e4b5b87efc0 100644 --- a/packages/api-client/controllers/project.ts +++ b/packages/api-client/controllers/project.ts @@ -4,7 +4,7 @@ import { autoBind } from '~/utils/auto-bind' import type { HTTPClient } from '../core' import { BaseCrudController } from './base' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, diff --git a/packages/api-client/controllers/recently.ts b/packages/api-client/controllers/recently.ts index 468f63c5410..1b8252289e1 100644 --- a/packages/api-client/controllers/recently.ts +++ b/packages/api-client/controllers/recently.ts @@ -6,7 +6,7 @@ import { autoBind } from '~/utils/auto-bind' import type { HTTPClient } from '../core' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, @@ -45,7 +45,7 @@ export class RecentlyController implements IController { } getAll() { - return this.proxy.all.get<{ data: RecentlyModel[] }>() + return this.proxy.all.get() } getList({ @@ -57,7 +57,7 @@ export class RecentlyController implements IController { after?: string | undefined size?: number | number } = {}) { - return this.proxy.get<{ data: RecentlyModel[] }>({ + return this.proxy.get({ params: { before, after, diff --git a/packages/api-client/controllers/say.ts b/packages/api-client/controllers/say.ts index 14f0770a473..0b48cdf22b5 100644 --- a/packages/api-client/controllers/say.ts +++ b/packages/api-client/controllers/say.ts @@ -3,10 +3,11 @@ import type { IController } from '~/interfaces/controller' import type { IRequestHandler } from '~/interfaces/request' import type { SayModel } from '~/models/say' import { autoBind } from '~/utils/auto-bind' + import type { HTTPClient } from '../core' import { BaseCrudController } from './base' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, @@ -35,6 +36,6 @@ export class SayController * 获取随机一条 */ getRandom() { - return this.proxy.random.get<{ data: SayModel | null }>() + return this.proxy.random.get() } } diff --git a/packages/api-client/controllers/search.ts b/packages/api-client/controllers/search.ts index 51c32fcc05b..bdf027c4fb9 100644 --- a/packages/api-client/controllers/search.ts +++ b/packages/api-client/controllers/search.ts @@ -9,7 +9,7 @@ import { autoBind } from '~/utils/auto-bind' import type { HTTPClient } from '../core' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, diff --git a/packages/api-client/controllers/severless.ts b/packages/api-client/controllers/severless.ts index 05b5cd4a669..f719d7412ad 100644 --- a/packages/api-client/controllers/severless.ts +++ b/packages/api-client/controllers/severless.ts @@ -4,7 +4,7 @@ import type { IRequestHandler } from '~/interfaces/request' import { autoBind } from '~/utils/auto-bind' import type { HTTPClient } from '../core' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, diff --git a/packages/api-client/controllers/snippet.ts b/packages/api-client/controllers/snippet.ts index f4d335ec745..c9c29cf2c7f 100644 --- a/packages/api-client/controllers/snippet.ts +++ b/packages/api-client/controllers/snippet.ts @@ -4,7 +4,7 @@ import type { IRequestHandler } from '~/interfaces/request' import { autoBind } from '~/utils/auto-bind' import type { HTTPClient } from '../core' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, diff --git a/packages/api-client/controllers/subscribe.ts b/packages/api-client/controllers/subscribe.ts index 97af6a6f6b1..fed1f8ca846 100644 --- a/packages/api-client/controllers/subscribe.ts +++ b/packages/api-client/controllers/subscribe.ts @@ -5,7 +5,7 @@ import type { SubscribeType } from '~/models/subscribe' import { autoBind } from '~/utils/auto-bind' import type { HTTPClient } from '../core' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, diff --git a/packages/api-client/controllers/topic.ts b/packages/api-client/controllers/topic.ts index b68eddba14c..260c5ed106f 100644 --- a/packages/api-client/controllers/topic.ts +++ b/packages/api-client/controllers/topic.ts @@ -6,7 +6,7 @@ import { autoBind } from '~/utils/auto-bind' import type { HTTPClient } from '../core' import { BaseCrudController } from './base' -declare module '../core/client' { +declare module '@mx-space/api-client' { interface HTTPClient< T extends IRequestAdapter = IRequestAdapter, ResponseWrapper = unknown, diff --git a/packages/api-client/core/client.ts b/packages/api-client/core/client.ts index 19e780260d5..017ee896023 100644 --- a/packages/api-client/core/client.ts +++ b/packages/api-client/core/client.ts @@ -2,12 +2,16 @@ import type { IAdaptorRequestResponseType, IRequestAdapter, } from '~/interfaces/adapter' -import type { ClientOptions } from '~/interfaces/client' +import type { + ClientOptions, + ResponseAdapter, + ResponseAdapterContext, +} from '~/interfaces/client' import type { IController } from '~/interfaces/controller' import type { RequestOptions } from '~/interfaces/instance' import type { IRequestHandler, Method } from '~/interfaces/request' import type { Class } from '~/interfaces/types' -import { isPlainObject } from '~/utils' +import { isPlainObject, isResponseEnvelope } from '~/utils' import { camelcaseKeys } from '~/utils/camelcase-keys' import { resolveFullPath } from '~/utils/path' @@ -16,6 +20,74 @@ import { attachRequestMethod } from './attach-request' import { RequestError } from './error' const methodPrefix = '_$' + +// Adapter responses come in two shapes: +// - axios-style: `{ data: , status, headers, ... }` — body lives in `res.data`. +// - ofetch-style: the parsed body itself (i.e. `res === `). +// Probe the axios shape first because both shapes can pass the `isResponseEnvelope` +// key-set check (an axios wrapper with only a `data` key is structurally a subset +// of an envelope). The deciding test is whether the inner `res.data` is itself +// envelope-shaped — if so, treat `res` as the axios wrapper. +function pickResponseBody(res: any) { + if (isPlainObject(res) && isResponseEnvelope(res.data)) { + return res.data + } + if (isResponseEnvelope(res)) { + return res + } + return res?.data +} + +function defaultGetDataFromResponse(res: any) { + const body = pickResponseBody(res) + if (!isResponseEnvelope(body)) { + return body + } + if (body.meta && isPlainObject(body.meta.pagination)) { + return { data: body.data, pagination: body.meta.pagination } + } + return body.data +} + +function extractResponseMeta(res: any): Record | undefined { + const body = pickResponseBody(res) + return isResponseEnvelope(body) ? body.meta : undefined +} + +function normalizeResponseAdapters( + responseAdapter: ClientOptions['responseAdapter'], +): ResponseAdapter[] { + if (!responseAdapter) return [] + return Array.isArray(responseAdapter) ? responseAdapter : [responseAdapter] +} + +function applyResponseMetaAdapters( + meta: Record | undefined, + adapters: ResponseAdapter[], + context: ResponseAdapterContext, +) { + return adapters.reduce | undefined>( + (nextMeta, adapter) => { + return adapter.transformMeta + ? adapter.transformMeta(nextMeta, { ...context, meta: nextMeta }) + : nextMeta + }, + meta, + ) +} + +function applyResponseDataAdapters( + data: T, + adapters: ResponseAdapter[], + context: ResponseAdapterContext, +) { + return adapters.reduce((nextData, adapter) => { + return adapter.transformData + ? adapter.transformData(nextData, context) + : nextData + }, data) +} + export type { HTTPClient } class HTTPClient< T extends IRequestAdapter = IRequestAdapter, @@ -32,7 +104,7 @@ class HTTPClient< this._proxy = this.buildRoute(this)() options.transformResponse ||= (data) => camelcaseKeys(data) - options.getDataFromResponse ||= (res: any) => res.data + options.getDataFromResponse ||= defaultGetDataFromResponse this.initGetClient() @@ -142,7 +214,8 @@ class HTTPClient< } if (methods.includes(name)) { return async (options: RequestOptions = {}) => { - const url = resolveFullPath(that.endpoint, route.join('/')) + const path = route.join('/') + const url = resolveFullPath(that.endpoint, path) route.length = 0 const { transformResponse: perRequestTransformResponse, @@ -157,14 +230,26 @@ class HTTPClient< }) } catch (error: any) { let message = error.message - let code = - error.code || + const status: number = error.status || error.statusCode || error.response?.status || error.response?.statusCode || error.response?.code || 500 + let code: string | number = error.code || status + let details: unknown + + const errorBody = error?.response?.data ?? error?.data + const v2Error = + errorBody && typeof errorBody === 'object' + ? (errorBody as { error?: any }).error + : undefined + if (v2Error && typeof v2Error === 'object') { + if (v2Error.code != null) code = v2Error.code + if (v2Error.message) message = v2Error.message + details = v2Error.details + } if (that.options.getCodeMessageFromException) { const errorInfo = @@ -175,7 +260,7 @@ class HTTPClient< throw that.options.customThrowResponseError ? that.options.customThrowResponseError(error) - : new RequestError(message, code, url, error) + : new RequestError(message, status, url, error, code, details) } const data = that.options.getDataFromResponse!(res) @@ -194,12 +279,39 @@ class HTTPClient< ? responseTransformer(data) : data - let nextObject: any = cameledObject + const rawMeta = extractResponseMeta(res) + const transformedMeta = + rawMeta !== undefined && responseTransformer + ? responseTransformer(rawMeta) + : rawMeta + const responseAdapters = normalizeResponseAdapters( + that.options.responseAdapter, + ) + const context: ResponseAdapterContext = { + url, + path, + method: name, + options, + response: res, + meta: transformedMeta, + } + const adaptedMeta = applyResponseMetaAdapters( + transformedMeta, + responseAdapters, + context, + ) + const adaptedObject = applyResponseDataAdapters( + cameledObject, + responseAdapters, + { ...context, meta: adaptedMeta }, + ) - if (cameledObject && typeof cameledObject === 'object') { - nextObject = Array.isArray(cameledObject) - ? [...cameledObject] - : { ...cameledObject } + let nextObject: any = adaptedObject + + if (adaptedObject && typeof adaptedObject === 'object') { + nextObject = Array.isArray(adaptedObject) + ? [...adaptedObject] + : { ...adaptedObject } Object.defineProperty(nextObject, '$raw', { get() { return res @@ -208,7 +320,6 @@ class HTTPClient< configurable: false, }) - // attach request config onto response Object.defineProperty(nextObject, '$request', { get() { return { @@ -222,8 +333,15 @@ class HTTPClient< Object.defineProperty(nextObject, '$serialized', { get() { - return cameledObject + return adaptedObject + }, + }) + + Object.defineProperty(nextObject, '$meta', { + get() { + return adaptedMeta }, + enumerable: false, }) } diff --git a/packages/api-client/core/error.ts b/packages/api-client/core/error.ts index 33e80d33b3c..961f31adf00 100644 --- a/packages/api-client/core/error.ts +++ b/packages/api-client/core/error.ts @@ -1,10 +1,11 @@ -/* eslint-disable unicorn/custom-error-definition */ export class RequestError extends Error { constructor( message: string, public status: number, public path: string, public raw: any, + public code?: string | number, + public details?: unknown, ) { super(message) } diff --git a/packages/api-client/core/meta-for.ts b/packages/api-client/core/meta-for.ts new file mode 100644 index 00000000000..756efec02dc --- /dev/null +++ b/packages/api-client/core/meta-for.ts @@ -0,0 +1,50 @@ +import type { + EntryTranslation, + InteractionMeta, + ResponseMeta, +} from '~/models/base' + +const TRANSLATION_KEYS = new Set(['article', 'fields']) +const INTERACTION_KEYS = new Set([ + 'isLiked', + 'likeCount', + 'readCount', +]) + +// The key-subset test is the distinguisher: single-shape objects only have +// domain-specific keys (e.g. 'article', 'isLiked'), while record shapes have +// snowflake id strings as keys. Snowflake ids are long numeric strings that +// never collide with the known domain key names. +function resolveMetaField( + value: T | Record | undefined, + id: string, + allowedKeys: Set, +): T | undefined { + if (value === undefined || value === null) return undefined + const keys = Object.keys(value as object) + if (keys.length === 0) return undefined + if (keys.every((k) => allowedKeys.has(k as keyof T))) { + return value as T + } + return (value as Record)[id] +} + +export function metaFor( + item: { id: string }, + meta: ResponseMeta | undefined, +): { interaction?: InteractionMeta; translation?: EntryTranslation } { + if (!meta) return {} + + const translation = resolveMetaField( + meta.translation, + item.id, + TRANSLATION_KEYS, + ) + const interaction = resolveMetaField( + meta.interaction, + item.id, + INTERACTION_KEYS, + ) + + return { translation, interaction } +} diff --git a/packages/api-client/index.ts b/packages/api-client/index.ts index 68fcb8e045c..6ceee2c1374 100644 --- a/packages/api-client/index.ts +++ b/packages/api-client/index.ts @@ -1,11 +1,11 @@ import { createClient } from './core' export * from './controllers' -export * from './models' -export * from './dtos' - -export { createClient, RequestError } from './core' export type { HTTPClient } from './core' +export { createClient, RequestError } from './core' +export { metaFor } from './core/meta-for' +export * from './dtos' +export * from './models' export { camelcaseKeys as simpleCamelcaseKeys } from './utils/camelcase-keys' export default createClient diff --git a/packages/api-client/interfaces/client.ts b/packages/api-client/interfaces/client.ts index b0cb05c3e1c..248bb808562 100644 --- a/packages/api-client/interfaces/client.ts +++ b/packages/api-client/interfaces/client.ts @@ -1,13 +1,30 @@ import type { IController } from './controller' import type { Class } from './types' +export interface ResponseAdapterContext { + url: string + path: string + method: string + options: Record + response: unknown + meta?: Record +} + +export interface ResponseAdapter { + transformData?: (data: T, context: ResponseAdapterContext) => T + transformMeta?: ( + meta: T | undefined, + context: ResponseAdapterContext, + ) => T | undefined +} + interface IClientOptions { controllers: Class[] getCodeMessageFromException: ( error: T, ) => { message?: string | undefined | null - code?: number | undefined | null + code?: string | number | undefined | null } customThrowResponseError: (err: any) => T transformResponse: (data: any) => T @@ -16,5 +33,6 @@ interface IClientOptions { * @default (res) => res.data */ getDataFromResponse: (response: unknown) => T + responseAdapter: ResponseAdapter | ResponseAdapter[] } export type ClientOptions = Partial diff --git a/packages/api-client/interfaces/instance.ts b/packages/api-client/interfaces/instance.ts index f4cd3092961..798a2ca457d 100644 --- a/packages/api-client/interfaces/instance.ts +++ b/packages/api-client/interfaces/instance.ts @@ -1,9 +1,30 @@ +/** + * Subset of Next.js's `RequestInit['next']` augmentation, declared structurally + * so the SDK does not pull in `next` as a runtime / type dependency. Adapters + * that don't recognise it (axios, umi) will simply ignore the field. + */ +export interface NextFetchRequestConfig { + revalidate?: number | false + tags?: string[] +} + export interface RequestOptions { method?: string data?: Record params?: Record | URLSearchParams headers?: Record transformResponse?: false | ((data: any) => T) + /** + * Caching hint forwarded verbatim to the underlying adapter. Recognised by + * the Next.js `fetch` / `ofetch` adapter for `revalidate` + cache `tags`; + * other adapters drop it. + */ + next?: NextFetchRequestConfig + /** + * Caching hint forwarded verbatim to the underlying adapter. Mirrors the + * standard `RequestInit['cache']` enum. + */ + cache?: 'default' | 'force-cache' | 'no-cache' | 'no-store' | 'only-if-cached' | 'reload' [key: string]: any } diff --git a/packages/api-client/interfaces/request.ts b/packages/api-client/interfaces/request.ts index 0dd1ae88a63..d52a36ef89b 100644 --- a/packages/api-client/interfaces/request.ts +++ b/packages/api-client/interfaces/request.ts @@ -1,3 +1,5 @@ +import type { ResponseMeta } from '~/models/base' + import type { RequestOptions } from './instance' type NoStringIndex = { @@ -74,6 +76,8 @@ type ResponseWrapperType = { } $serialized: T + + $meta?: ResponseMeta } export type ResponseProxyExtraRaw< diff --git a/packages/api-client/interfaces/types.ts b/packages/api-client/interfaces/types.ts index 97548fd4885..83c00e0554d 100644 --- a/packages/api-client/interfaces/types.ts +++ b/packages/api-client/interfaces/types.ts @@ -1,3 +1 @@ export type Class = new (...args: any[]) => T - -export type SelectFields = `${'+' | '-' | ''}${T}`[] diff --git a/packages/api-client/legacy/index.ts b/packages/api-client/legacy/index.ts new file mode 100644 index 00000000000..e1586e0aa5e --- /dev/null +++ b/packages/api-client/legacy/index.ts @@ -0,0 +1,103 @@ +import type { HTTPClient } from '~/index' +import { createClient } from '~/index' +import type { IRequestAdapter } from '~/interfaces/adapter' +import type { ClientOptions } from '~/interfaces/client' +import { isPlainObject } from '~/utils' + +import { + legacyResponseAdapter, + type LegacyResponseAdapterOptions, +} from './response-adapter' + +export { legacyResponseAdapter } +export type { + LegacyResponseAdapterMatcher, + LegacyResponseAdapterOptions, +} from './response-adapter' +export type { LegacyPager, LegacyPaginateResult } from './types' + +// V1 → V3 alias map for `sortBy` query params. V1 callers used the short +// `created` / `modified` names; V3 backends only accept the `*At` variants. +// Translating here keeps host code untouched during the migration. +const SORT_BY_LEGACY_ALIAS: Record = { + created: 'createdAt', + modified: 'modifiedAt', +} + +interface RequestEnvelope { + url: string + method?: string + data?: unknown + params?: unknown + [k: string]: unknown +} + +function rewriteLegacyRequestEnvelope( + options: RequestEnvelope, +): RequestEnvelope { + const params = options.params + if (!isPlainObject(params)) return options + const sortBy = (params as Record).sortBy + if (typeof sortBy !== 'string' || !(sortBy in SORT_BY_LEGACY_ALIAS)) { + return options + } + return { + ...options, + params: { + ...(params as Record), + sortBy: SORT_BY_LEGACY_ALIAS[sortBy], + }, + } +} + +export function createLegacyApiClient( + adapter: T, + legacyOptions?: LegacyResponseAdapterOptions, +): < + ResponseWrapper = T extends { responseWrapper: infer Type } + ? Type extends undefined + ? unknown + : Type + : unknown, +>( + endpoint: string, + options?: ClientOptions, +) => HTTPClient { + const create = createClient(adapter) + + return < + ResponseWrapper = T extends { responseWrapper: infer Type } + ? Type extends undefined + ? unknown + : Type + : unknown, + >( + endpoint: string, + options: ClientOptions = {}, + ) => { + const responseAdapter = options.responseAdapter + ? [ + legacyResponseAdapter(legacyOptions), + ...(Array.isArray(options.responseAdapter) + ? options.responseAdapter + : [options.responseAdapter]), + ] + : legacyResponseAdapter(legacyOptions) + + const client = create(endpoint, { + ...options, + responseAdapter, + }) + + // Rewrite outgoing request params at the manager.request boundary — + // `attach-request`'s $$get strips `params` into the URL query before + // calling the host adapter, so wrapping the adapter itself can't see + // GET params. Patching `request` keeps the hook in one place for all + // methods (GET/POST/PUT/PATCH/DELETE). + const origRequest = client.request.bind(client) + ;(client as { request: (o: RequestEnvelope) => unknown }).request = (o) => + origRequest(rewriteLegacyRequestEnvelope(o) as any) + + return client + } +} diff --git a/packages/api-client/legacy/response-adapter.ts b/packages/api-client/legacy/response-adapter.ts new file mode 100644 index 00000000000..1c92fa69606 --- /dev/null +++ b/packages/api-client/legacy/response-adapter.ts @@ -0,0 +1,576 @@ +import type { + ResponseAdapter, + ResponseAdapterContext, +} from '~/interfaces/client' +import { + attachRawFromOneToAnthor, + destructureData, + isPlainObject, + isResponseEnvelope, +} from '~/utils' + +export type LegacyResponseAdapterMatcher = + | string + | RegExp + | ((context: ResponseAdapterContext) => boolean) + +export interface LegacyResponseAdapterOptions { + /** + * Apply legacy conversion only to matched request paths. + * String matchers compare against both `/path` and `METHOD /path`. + */ + only?: LegacyResponseAdapterMatcher[] + /** + * Skip legacy conversion for matched request paths. + * Useful when a downstream app migrates one endpoint at a time. + */ + except?: LegacyResponseAdapterMatcher[] +} + +const toMatcherList = (value: LegacyResponseAdapterMatcher[] | undefined) => + value?.length ? value : undefined + +function normalizePath(path: string) { + return path.startsWith('/') ? path : `/${path}` +} + +function matches( + context: ResponseAdapterContext, + matcher: LegacyResponseAdapterMatcher, +) { + if (typeof matcher === 'function') return matcher(context) + + const path = normalizePath(context.path) + const methodPath = `${context.method.toUpperCase()} ${path}` + if (typeof matcher === 'string') { + return matcher === path || matcher === methodPath + } + + return matcher.test(path) || matcher.test(methodPath) +} + +function shouldTransform( + context: ResponseAdapterContext, + options: LegacyResponseAdapterOptions, +) { + const only = toMatcherList(options.only) + if (only && !only.some((matcher) => matches(context, matcher))) return false + + const except = toMatcherList(options.except) + if (except?.some((matcher) => matches(context, matcher))) return false + + return true +} + +// --- helpers --------------------------------------------------------------- +// All field names here are camelCase — the client's default transformResponse +// runs camelcaseKeys on the raw snake_case wire payload before it reaches this +// adapter. So `is_liked` arrives as `isLiked`, `source_lang` as `sourceLang`, etc. + +const stripPath = (p: string) => p.split('?')[0].replace(/\/+$/, '') || '/' + +function pickRecordEntry( + value: T | Record | undefined, + id: string | undefined, + knownKeys: string[], +): T | undefined { + if (!value || typeof value !== 'object') return undefined + const keys = Object.keys(value as object) + if (keys.length === 0) return undefined + // Heuristic: a record keyed by snowflake id (long numeric strings) vs a + // flat shape whose keys are known field names. + const looksKeyed = keys.every((k) => /^\d{8,}$/.test(k)) + if (looksKeyed) { + return id ? (value as Record)[id] : undefined + } + if (knownKeys.some((k) => keys.includes(k))) return value as T + return id ? (value as Record)[id] : undefined +} + +function buildTranslationFlat(translation: any): Record { + if (!translation?.article) return {} + const a = translation.article + const out: Record = {} + if ('isTranslated' in a) out.isTranslated = a.isTranslated ?? false + if (a.sourceLang != null) out.sourceLang = a.sourceLang + if ('availableTranslations' in a) + out.availableTranslations = a.availableTranslations ?? [] + if (a.isTranslated) { + out.translationMeta = { + sourceLang: a.sourceLang, + targetLang: a.targetLang, + translatedAt: a.translatedAt, + model: a.model, + } + } + return out +} + +function buildInteractionFlat(interaction: any): Record { + if (!interaction || typeof interaction !== 'object') return {} + const out: Record = {} + if ('isLiked' in interaction) out.isLiked = !!interaction.isLiked + if ('likeCount' in interaction) out.likeCount = interaction.likeCount + if ('readCount' in interaction) out.readCount = interaction.readCount + return out +} + +function buildInsightsFlat(insights: any): Record { + if (!insights || typeof insights !== 'object') return {} + const out: Record = {} + if ('hasInLocale' in insights) + out.hasInsightsInLocale = !!insights.hasInLocale + return out +} + +function flattenMetaIntoItem(item: any, meta: any): any { + if (!isPlainObject(item) || typeof (item as any).id !== 'string') return item + if (!meta || typeof meta !== 'object') return item + + const id = (item as any).id + const translation = pickRecordEntry(meta.translation, id, ['article']) + const interaction = pickRecordEntry(meta.interaction, id, [ + 'isLiked', + 'likeCount', + 'readCount', + ]) + + const tFlat = buildTranslationFlat(translation) + const iFlat = buildInteractionFlat(interaction) + const insightsFlat = buildInsightsFlat(meta.insights) + + const extras: Record = {} + if (meta.enrichments !== undefined) extras.enrichments = meta.enrichments + if (meta.related !== undefined) extras.related = meta.related + + if ( + Object.keys(tFlat).length === 0 && + Object.keys(iFlat).length === 0 && + Object.keys(insightsFlat).length === 0 && + Object.keys(extras).length === 0 + ) { + return item + } + + const next: Record = { + ...(item as Record), + ...tFlat, + ...iFlat, + ...insightsFlat, + ...extras, + } + attachRawFromOneToAnthor(item, next) + return next +} + +function flattenNestedArrays(raw: any, meta: any, keys: string[]) { + if (!isPlainObject(raw)) return raw + const out = { ...raw } + for (const key of keys) { + if (Array.isArray(out[key])) { + out[key] = out[key].map((it: any) => flattenMetaIntoItem(it, meta)) + } + } + return out +} + +// Pagination shape normalization (camelCase after default camelcaseKeys): +// V2: { page, size, total, totalPages } +// Legacy adapters also expose hasNextPage/hasPrevPage as convenience flags. +function remapPagination(pg: any): any { + if (!pg || typeof pg !== 'object') return pg + const page = pg.page ?? pg.currentPage + const size = pg.size + const total = pg.total + const totalPages = pg.totalPages ?? pg.totalPage + const hasNextPage = + pg.hasNextPage ?? + (typeof page === 'number' && typeof totalPages === 'number' + ? page < totalPages + : undefined) + const hasPrevPage = + pg.hasPrevPage ?? (typeof page === 'number' ? page > 1 : undefined) + + // V2 wire used `currentPage` / `totalPage`; V3 wire uses `page` / `totalPages`. + // Emit both so consumers migrating field-by-field don't break — the typed + // `Pager` interface exposes the V3 names plus the V2 aliases as optional. + const out: Record = { + page, + currentPage: page, + total, + size, + totalPages, + totalPage: totalPages, + hasNextPage, + hasPrevPage, + } + for (const k of Object.keys(out)) if (out[k] === undefined) delete out[k] + return out +} + +// --- per-endpoint rules ---------------------------------------------------- + +interface RuleContext { + path: string + method: string + meta: any +} + +interface Rule { + match: RegExp | ((path: string, method: string) => boolean) + fn: (data: any, ctx: RuleContext) => any +} + +const COMMENT_LIST_REGEX = /^\/comments\/ref\/[^/]+$/ +const COMMENT_THREAD_REGEX = /^\/comments\/thread\/[^/]+$/ +const NOTE_DETAIL_REGEX = + /^\/notes\/(?:nid\/\d+|latest|\d{4}(?:\/\d{1,2}){2}\/[^/]+)$/ +const COMMENT_UPLOAD_CONFIG_REGEX = /^\/comments\/uploads\/config$/ +const ACTIVITY_PRESENCE_REGEX = /^\/activity\/presence$/ +const NOTE_MIDDLE_LIST_REGEX = /^\/notes\/list\/[^/]+$/ +const NOTE_TOPIC_LIST_REGEX = /^\/notes\/topics\/[^/]+$/ + +function unwrapInnerEnvelope(data: any) { + if ( + isPlainObject(data) && + 'data' in data && + Array.isArray((data as any).data) && + isPlainObject((data as any).meta) + ) { + return data + } + return undefined +} + +const commentListRule: Rule = { + match: COMMENT_LIST_REGEX, + fn: (raw, ctx) => { + const inner = unwrapInnerEnvelope(raw) ?? raw + const items: any[] = Array.isArray(inner.data) + ? inner.data + : Array.isArray(raw?.data) + ? raw.data + : [] + const pagination = remapPagination( + inner.meta?.pagination ?? ctx.meta?.pagination ?? raw?.pagination, + ) + + const readers: Record = isPlainObject(raw?.readers) + ? { ...(raw.readers as Record) } + : {} + const cleanedItems = items.map((it) => { + const readerId = + (it && (it.readerId ?? it.reader_id)) ?? it?.reader?.id ?? null + if (it?.reader && readerId) readers[readerId] = it.reader + if (it && 'reader' in it) { + const { reader: _drop, ...rest } = it + return rest + } + return it + }) + + const out: Record = { data: cleanedItems } + if (pagination !== undefined) out.pagination = pagination + out.readers = readers + return out + }, +} + +const commentThreadRule: Rule = { + match: COMMENT_THREAD_REGEX, + fn: (raw) => { + if (!isPlainObject(raw)) return raw + // V1 returned { replies, remaining, done }; V2 adds nextCursor. + const { nextCursor: _drop, ...rest } = raw as any + return rest + }, +} + +const noteDetailRule: Rule = { + match: NOTE_DETAIL_REGEX, + fn: (raw, ctx) => { + if (!isPlainObject(raw)) return raw + // V1 already wraps the model under `data`; pass through (with meta flatten). + if ( + isPlainObject((raw as any).data) && + typeof (raw as any).data?.id === 'string' + ) { + const wrapped = raw as Record + const flat = flattenMetaIntoItem(wrapped.data, ctx.meta) + return { ...wrapped, data: flat } + } + // V2: top-level is the NoteModel itself, with next/prev as siblings. + const { next, prev, ...note } = raw as any + const flat = flattenMetaIntoItem(note, ctx.meta) + const out: Record = { data: flat } + if (next !== undefined && next !== null) out.next = next + if (prev !== undefined && prev !== null) out.prev = prev + return out + }, +} + +const AGGREGATE_TOP_REGEX = /^\/aggregate\/top$/ +const aggregateTopRule: Rule = { + match: AGGREGATE_TOP_REGEX, + fn: (raw, ctx) => { + if (!isPlainObject(raw)) return raw + const stripBody = (it: any) => { + if (!isPlainObject(it)) return it + const { text: _t, content: _c, ...rest } = it as any + return rest + } + const r = raw as any + const withMeta = flattenNestedArrays(r, ctx.meta, ['notes', 'posts']) + const out: Record = { ...withMeta } + if (Array.isArray(out.notes)) + out.notes = (out.notes as any[]).map(stripBody) + if (Array.isArray(out.posts)) + out.posts = (out.posts as any[]).map(stripBody) + return out + }, +} + +const AGGREGATE_LATEST_REGEX = /^\/aggregate\/latest$/ +const aggregateLatestRule: Rule = { + match: AGGREGATE_LATEST_REGEX, + fn: (raw, ctx) => { + if (Array.isArray(raw)) { + return raw.map((it) => flattenMetaIntoItem(it, ctx.meta)) + } + if (!isPlainObject(raw)) return raw + return flattenNestedArrays(raw, ctx.meta, ['notes', 'posts']) + }, +} + +const AGGREGATE_TIMELINE_REGEX = /^\/aggregate\/timeline$/ +const aggregateTimelineRule: Rule = { + match: AGGREGATE_TIMELINE_REGEX, + fn: (raw, ctx) => { + if (!isPlainObject(raw)) return raw + const r = raw as any + if (isPlainObject(r.data)) { + const inner = flattenNestedArrays(r.data, ctx.meta, ['notes', 'posts']) + return { ...r, data: inner } + } + const inner = flattenNestedArrays(r, ctx.meta, ['notes', 'posts']) + return { data: inner } + }, +} + +const ACTIVITY_ROOMS_REGEX = /^\/activity\/rooms$/ +const activityRoomsRule: Rule = { + match: ACTIVITY_ROOMS_REGEX, + fn: (raw, ctx) => { + if (!isPlainObject(raw)) return raw + const r = raw as any + if (!isPlainObject(r.objects)) return raw + const objects: Record = { ...r.objects } + for (const type of Object.keys(objects)) { + if (Array.isArray(objects[type])) { + objects[type] = (objects[type] as any[]).map((it) => + flattenMetaIntoItem(it, ctx.meta), + ) + } + } + return { ...r, objects } + }, +} + +const ACTIVITY_RECENT_REGEX = /^\/activity\/recent$/ +const activityRecentRule: Rule = { + match: ACTIVITY_RECENT_REGEX, + fn: (raw, ctx) => { + if (!isPlainObject(raw)) return raw + return flattenNestedArrays(raw, ctx.meta, [ + 'like', + 'comment', + 'recentPost', + 'recentNote', + 'post', + 'note', + ]) + }, +} + +const READING_RANK_REGEX = /^\/activity\/reading\/(top|rank)$/ +const readingRankRule: Rule = { + match: READING_RANK_REGEX, + fn: (raw, ctx) => { + const items: any[] = Array.isArray(raw?.data) ? raw.data : [] + return { + ...raw, + data: items + .map((it) => flattenMetaIntoItem({ ...it, id: it.refId }, ctx.meta)) + .map((it: any) => { + const { id: _drop, ...rest } = it + return rest + }), + } + }, +} + +const ACTIVITY_LAST_YEAR_REGEX = /^\/activity\/last-year\/publication$/ +const activityLastYearRule: Rule = { + match: ACTIVITY_LAST_YEAR_REGEX, + fn: (raw, ctx) => { + if (!isPlainObject(raw)) return raw + return flattenNestedArrays(raw, ctx.meta, ['posts', 'notes']) + }, +} + +const commentUploadConfigRule: Rule = { + match: COMMENT_UPLOAD_CONFIG_REGEX, + fn: (raw) => { + if (!isPlainObject(raw)) return raw + // V2 emits `single_file_size_m_b` → camelcase → `singleFileSizeMB`; + // V1 emitted `single_file_size_mb` → `singleFileSizeMb`. + const r = raw as any + if (r.singleFileSizeMB === undefined) return raw + const { singleFileSizeMB, ...rest } = r + return { ...rest, singleFileSizeMb: singleFileSizeMB } + }, +} + +const activityPresenceRule: Rule = { + match: ACTIVITY_PRESENCE_REGEX, + fn: (raw) => { + if (!isPlainObject(raw)) return raw + const r = raw as any + // V2 inner: { presence, readers }; V1: { data, readers } + if ('presence' in r) { + const { presence, ...rest } = r + return { data: presence, ...rest } + } + return raw + }, +} + +const noteMiddleListRule: Rule = { + match: NOTE_MIDDLE_LIST_REGEX, + fn: (raw) => { + // V1: { data: [...], size } + // V2 (after envelope unwrap): bare [...] array + if (Array.isArray(raw)) return { data: raw, size: raw.length } + return raw + }, +} + +const noteTopicListRule: Rule = { + match: NOTE_TOPIC_LIST_REGEX, + fn: (raw, ctx) => { + // Generic list flow, then strip `text` field from each note (V1 card view omitted it). + if (!isPlainObject(raw)) return raw + const r = raw as any + if (!Array.isArray(r.data)) return raw + const data = r.data.map((it: any) => { + const flat = flattenMetaIntoItem(it, ctx.meta) + if (flat && 'text' in flat) { + const { text: _drop, ...rest } = flat as any + return rest + } + return flat + }) + const next: Record = { ...r, data } + if (next.pagination) next.pagination = remapPagination(next.pagination) + else if (ctx.meta?.pagination) + next.pagination = remapPagination(ctx.meta.pagination) + return next + }, +} + +const RULES: Rule[] = [ + commentListRule, + commentThreadRule, + noteDetailRule, + commentUploadConfigRule, + activityPresenceRule, + noteMiddleListRule, + noteTopicListRule, + aggregateTopRule, + aggregateLatestRule, + aggregateTimelineRule, + activityRoomsRule, + activityRecentRule, + readingRankRule, + activityLastYearRule, +] + +function applyRule(path: string, method: string, raw: any, meta: any) { + for (const rule of RULES) { + const ok = + typeof rule.match === 'function' + ? rule.match(path, method) + : rule.match.test(path) + if (ok) return rule.fn(raw, { path, method, meta }) + } + return undefined +} + +// --- top-level transform --------------------------------------------------- + +function transformLegacyData( + data: T, + meta: any, + ctx: ResponseAdapterContext, +): T { + // `defaultGetDataFromResponse` normally peels the V3 `{ data, meta }` + // envelope before the adapter runs, so rules can assume inner-data. When + // a consumer ships their own pass-through `getDataFromResponse` (common + // with ofetch stacks that already deliver the parsed body), the envelope + // arrives intact and rules like `aggregateTopRule` would otherwise read + // `notes`/`posts` off the wrapper and miss them. Strip it here so the + // rules see the same shape either way. + const unwrapped: any = + isPlainObject(data) && isResponseEnvelope(data) ? (data as any).data : data + const normalizedData: any = destructureData(unwrapped) + const path = stripPath(normalizePath(ctx.path)) + const method = ctx.method.toUpperCase() + + // Endpoint-specific rule first. + const ruled = applyRule(path, method, normalizedData, meta) + if (ruled !== undefined) return ruled as T + + // Generic: bare array → flatten meta into each item, wrap into envelope so + // the runtime shape matches the controller method's default typed return + // (`{ data: T[], ... }`). Callers throughout the host app destructure + // `.data` off list responses (matching the type declaration); returning + // a bare array here would silently make `.data` undefined. + if (Array.isArray(normalizedData)) { + const items = normalizedData.map((it) => flattenMetaIntoItem(it, meta)) + if (meta?.pagination) { + return { + data: items, + pagination: remapPagination(meta.pagination), + } as T + } + return { data: items } as T + } + + // Generic: object with array `data` → flatten meta into items, remap pagination. + if (isPlainObject(normalizedData) && Array.isArray(normalizedData.data)) { + const next: Record = { + ...(normalizedData as Record), + data: (normalizedData as any).data.map((it: any) => + flattenMetaIntoItem(it, meta), + ), + } + if (next.pagination) next.pagination = remapPagination(next.pagination) + else if (meta?.pagination) + next.pagination = remapPagination(meta.pagination) + return next as T + } + + // Generic: single item → flatten meta into it. + return flattenMetaIntoItem(normalizedData, meta) as T +} + +export function legacyResponseAdapter( + options: LegacyResponseAdapterOptions = {}, +): ResponseAdapter { + return { + transformData(data, context) { + if (!shouldTransform(context, options)) return data + return transformLegacyData(data, context.meta, context) + }, + } +} diff --git a/packages/api-client/legacy/types.ts b/packages/api-client/legacy/types.ts new file mode 100644 index 00000000000..d145190ab48 --- /dev/null +++ b/packages/api-client/legacy/types.ts @@ -0,0 +1,33 @@ +import type { PaginateResult, Pager } from '~/models/base' + +/** + * Pagination shape emitted by {@link legacyResponseAdapter}. + * + * The adapter unconditionally derives all six fields from the V3 + * `{ page, size, total, totalPages }` envelope meta, so consumers can read + * them without optional-chaining. Both the V3 names (`page`, `totalPages`, + * `hasNextPage`, `hasPrevPage`) and the V2 aliases (`currentPage`, + * `totalPage`) are present. + * + * Import this from `@mx-space/api-client/legacy` when a list endpoint is + * being consumed through the legacy adapter — the SDK's bare {@link Pager} + * leaves `hasNextPage` / `hasPrevPage` optional and has no V2 aliases. + * When the legacy adapter is eventually retired this file goes with it and + * `Pager` stays clean. + */ +export interface LegacyPager extends Omit { + hasNextPage: boolean + hasPrevPage: boolean + /** V2 wire alias for {@link Pager.page}. */ + currentPage: number + /** V2 wire alias for {@link Pager.totalPages}. */ + totalPage: number +} + +/** + * Paginated list response as observed through the legacy adapter — same shape + * as {@link PaginateResult} but with `pagination` typed as {@link LegacyPager}. + */ +export interface LegacyPaginateResult extends PaginateResult { + pagination: LegacyPager +} diff --git a/packages/api-client/models/base.ts b/packages/api-client/models/base.ts index cc28cf1367b..057c38cdf86 100644 --- a/packages/api-client/models/base.ts +++ b/packages/api-client/models/base.ts @@ -1,3 +1,5 @@ +import type { EnrichmentResult } from './enrichment' + export interface Image { height: number width: number @@ -8,12 +10,12 @@ export interface Image { } export interface Pager { - total: number + page: number size: number - currentPage: number - totalPage: number - hasPrevPage: boolean - hasNextPage: boolean + total: number + totalPages: number + hasNextPage?: boolean + hasPrevPage?: boolean } export interface PaginateResult { @@ -21,25 +23,59 @@ export interface PaginateResult { pagination: Pager } -export type ModelWithLiked = T & { - liked: boolean +export interface PaginationMeta { + page: number + size: number + total: number + totalPages: number } -export interface TranslationMeta { - sourceLang: string - targetLang: string - translatedAt: string +export interface InteractionMeta { + isLiked?: boolean + likeCount?: number + readCount?: number } -export type ModelWithTranslation = T & { +export interface ArticleTranslation { isTranslated: boolean - translationMeta?: TranslationMeta + sourceLang?: string + targetLang?: string + model?: string + translatedAt?: string + title?: string + text?: string + subtitle?: string | null + summary?: string | null + tags?: string[] + content?: string + contentFormat?: string availableTranslations?: string[] - /** - * Whether AI insights are available in the caller's requested locale. - * Independent from `translationMeta` because insights maintain their own - * translation pipeline — the article may be translated without insights, - * and vice versa. Absent (undefined) on endpoints that don't surface it. - */ - hasInsightsInLocale?: boolean +} + +export interface EntryTranslation { + article?: ArticleTranslation + fields?: Record +} + +export interface RelatedRef { + id: string + title: string + slug?: string + nid?: number + type?: string +} + +export interface InsightsMeta { + hasInLocale: boolean +} + +export interface ResponseMeta { + pagination?: PaginationMeta + view?: string + translation?: EntryTranslation | Record + interaction?: InteractionMeta | Record + enrichments?: Record + related?: RelatedRef[] + articles?: Record + insights?: InsightsMeta } diff --git a/packages/api-client/models/enrichment.ts b/packages/api-client/models/enrichment.ts new file mode 100644 index 00000000000..96ce975ec1b --- /dev/null +++ b/packages/api-client/models/enrichment.ts @@ -0,0 +1,37 @@ +export interface EnrichmentImage { + url: string + width?: number + height?: number + alt?: string + blurhash?: string + palette?: EnrichmentImagePalette +} + +export interface EnrichmentAttribute { + key: string + value: string | number | boolean + label?: string + format?: 'number' | 'rating' | 'date' | 'percent' | 'text' | 'duration' +} + +export interface EnrichmentImagePalette { + dominant: string + swatches?: string[] +} + +export interface EnrichmentResult { + id?: string + title: string + description?: string + thumbnailImage?: EnrichmentImage + previewImage?: EnrichmentImage + url: string + category: string + subtype?: string + publishedAt?: string + fetchedAt: string + attributes?: EnrichmentAttribute[] + color?: string + links?: Array<{ rel: string; url: string; label?: string }> + captureImage?: EnrichmentImage +} diff --git a/packages/api-client/models/index.ts b/packages/api-client/models/index.ts index 4dfa1e4e86f..c3ef77a73c5 100644 --- a/packages/api-client/models/index.ts +++ b/packages/api-client/models/index.ts @@ -5,6 +5,7 @@ export * from './auth' export * from './base' export * from './category' export * from './comment' +export * from './enrichment' export * from './link' export * from './note' export * from './page' diff --git a/packages/api-client/models/note.ts b/packages/api-client/models/note.ts index e10aac705dd..cbab590aad4 100644 --- a/packages/api-client/models/note.ts +++ b/packages/api-client/models/note.ts @@ -1,5 +1,5 @@ -import type { Image, ModelWithLiked, ModelWithTranslation } from './base' -import type { EnrichmentResult } from './recently' +import type { Image } from './base' +import type { EnrichmentResult } from './enrichment' import type { TopicModel } from './topic' export type NoteEnrichmentMap = Record @@ -56,20 +56,7 @@ export interface Coordinate { longitude: number } -export interface NoteWrappedPayload { - data: NoteModel - next?: Partial - prev?: Partial -} - -export interface NoteWrappedWithLikedPayload { - data: ModelWithLiked - next?: Partial - prev?: Partial -} - -export interface NoteWrappedWithLikedAndTranslationPayload { - data: ModelWithLiked> - next?: Partial - prev?: Partial +export type NoteWrappedPayload = NoteModel & { + next?: Partial | null + prev?: Partial | null } diff --git a/packages/api-client/models/page.ts b/packages/api-client/models/page.ts index c43f18c7e65..a898a0c2de8 100644 --- a/packages/api-client/models/page.ts +++ b/packages/api-client/models/page.ts @@ -1,5 +1,5 @@ import type { Image } from './base' -import type { EnrichmentResult } from './recently' +import type { EnrichmentResult } from './enrichment' export type PageEnrichmentMap = Record diff --git a/packages/api-client/models/post.ts b/packages/api-client/models/post.ts index 7158e21468c..01e52db8bee 100644 --- a/packages/api-client/models/post.ts +++ b/packages/api-client/models/post.ts @@ -1,6 +1,6 @@ import type { Image } from './base' import type { CategoryModel } from './category' -import type { EnrichmentResult } from './recently' +import type { EnrichmentResult } from './enrichment' export type PostContentFormat = 'markdown' | 'lexical' diff --git a/packages/api-client/models/recently.ts b/packages/api-client/models/recently.ts index ff33923173f..90af593cd2f 100644 --- a/packages/api-client/models/recently.ts +++ b/packages/api-client/models/recently.ts @@ -30,45 +30,6 @@ export enum RecentlyTypeEnum { Link = 'link', } -export interface EnrichmentImagePalette { - dominant: string - swatches?: string[] -} - -export interface EnrichmentImage { - url: string - width?: number - height?: number - alt?: string - blurhash?: string - palette?: EnrichmentImagePalette -} - -export interface EnrichmentAttribute { - key: string - value: string | number | boolean - label?: string - format?: 'number' | 'rating' | 'date' | 'percent' | 'text' | 'duration' -} - -export interface EnrichmentResult { - id?: string - - title: string - description?: string - thumbnailImage?: EnrichmentImage - previewImage?: EnrichmentImage - url: string - category: string - subtype?: string - publishedAt?: string - fetchedAt: string - attributes?: EnrichmentAttribute[] - color?: string - links?: Array<{ rel: string; url: string; label?: string }> - captureImage?: EnrichmentImage -} - /** * 创建/更新 shorthand 时的轻量元数据。仅 url 必需, * 服务端据此匹配 provider 并落地至 enrichment_cache。 diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 7a26eece82b..560f421c5ff 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@mx-space/api-client", - "version": "4.4.0", + "version": "5.0.2-next.10", "description": "A api client for mx-space server@next", "type": "module", "engines": { @@ -20,6 +20,11 @@ "require": "./dist/index.cjs", "import": "./dist/index.mjs" }, + "./legacy": { + "types": "./dist/legacy/index.d.mts", + "require": "./dist/legacy/index.cjs", + "import": "./dist/legacy/index.mjs" + }, "./dist/*": { "require": "./dist/*.cjs", "import": "./dist/*.mjs" @@ -47,7 +52,8 @@ "build": "npm run package", "prepackage": "rm -rf dist", "test": "vitest", - "dev": "vitest" + "dev": "vitest", + "smoke:diff": "node scripts/smoke-diff.mjs" }, "devDependencies": { "@types/cors": "2.8.19", diff --git a/packages/api-client/readme.md b/packages/api-client/readme.md index ad09207e283..3322c4e5e28 100644 --- a/packages/api-client/readme.md +++ b/packages/api-client/readme.md @@ -169,9 +169,49 @@ client.injectControllers(allControllers) | `controllers` | Array of controller classes to inject immediately. | | `transformResponse` | `(data) => transformed`. Default: camelCase keys. | | `getDataFromResponse` | `(response) => data`. Default: `(res) => res.data`. | +| `responseAdapter` | Post-transform response adapter or adapter array. Use this for compatibility layers such as `@mx-space/api-client/legacy`. | | `getCodeMessageFromException` | `(error) => { message?, code? }` for custom error parsing. | | `customThrowResponseError` | `(error) => Error` to throw a custom error type. | +### Legacy V2 Compatibility + +The V2 response shape keeps resource data flat and moves request-derived fields +to `$meta`. During downstream migrations, import the removable legacy layer from +the isolated `legacy` entry: + +```ts +import { createClient } from '@mx-space/api-client' +import { axiosAdaptor } from '@mx-space/api-client/adaptors/axios' +import { legacyResponseAdapter } from '@mx-space/api-client/legacy' + +const client = createClient(axiosAdaptor)('https://api.example.com/v2', { + responseAdapter: legacyResponseAdapter(), +}) +``` + +For a fast full-client opt-in: + +```ts +import { createLegacyApiClient } from '@mx-space/api-client/legacy' + +const client = createLegacyApiClient(axiosAdaptor)('https://api.example.com/v2') +``` + +For gradual migration, scope the adapter by path: + +```ts +const client = createClient(axiosAdaptor)('https://api.example.com/v2', { + responseAdapter: legacyResponseAdapter({ + only: ['/posts', 'GET /notes/latest'], + except: ['/posts/latest'], + }), +}) +``` + +The legacy entry is intentionally separate from the main client. Once downstream +apps finish migration, remove imports from `@mx-space/api-client/legacy` and use +the default V2 shape directly. + --- ## Proxy API diff --git a/packages/api-client/scripts/smoke-diff.mjs b/packages/api-client/scripts/smoke-diff.mjs new file mode 100644 index 00000000000..8b234901396 --- /dev/null +++ b/packages/api-client/scripts/smoke-diff.mjs @@ -0,0 +1,326 @@ +// Smoke-diff harness: compare every Yohaku-used endpoint between +// :2333 → V2 envelope, normalized by createLegacyApiClient +// :3333 → V1-style envelope, returned as-is +// Reports endpoints whose normalized payloads diverge. +// +// Run: node packages/api-client/scripts/smoke-diff.mjs [endpointGrep] + +import { + allControllers, + createClient, +} from '../dist/index.mjs' +import { fetchAdaptor } from '../dist/adaptors/fetch.mjs' +import { createLegacyApiClient } from '../dist/legacy/index.mjs' + +const NEW_PORT = process.env.NEW_PORT ?? '2333' +const OLD_PORT = process.env.OLD_PORT ?? '3333' +const filter = process.argv[2] ? new RegExp(process.argv[2], 'i') : null + +const newClient = createLegacyApiClient(fetchAdaptor)(`http://127.0.0.1:${NEW_PORT}`, { + controllers: allControllers, +}) + +// 3333 may run a build that already wraps responses in { data, meta }. +// Use the same legacy adapter to normalize both ends; differences then +// reflect server-side data divergence rather than envelope shape. +const oldClient = createLegacyApiClient(fetchAdaptor)(`http://127.0.0.1:${OLD_PORT}`, { + controllers: allControllers, +}) + +const cases = [ + ['aggregate.getAggregateData("shiro")', (c) => c.aggregate.getAggregateData('shiro')], + ['aggregate.getTop(5)', (c) => c.aggregate.getTop(5)], + ['aggregate.getTimeline()', (c) => c.aggregate.getTimeline?.()], + ['aggregate.getStat()', (c) => c.aggregate.getStat?.()], + ['post.getList(1,3)', (c) => c.post.getList(1, 3)], + ['post.getLatest()', (c) => c.post.getLatest?.()], + ['note.getList(1,3)', (c) => c.note.getList(1, 3)], + ['note.getLatest()', (c) => c.note.getLatest()], + ['page.getList(1,50)', (c) => c.page.getList(1, 50)], + ['category.getAllCategories()', (c) => c.category.getAllCategories()], + ['category.getAllTags()', (c) => c.category.getAllTags()], + ['recently.getList({size:5})', (c) => c.recently.getList({ size: 5 })], + ['say.getAllPaginated(1,5)', (c) => c.say.getAllPaginated(1, 5)], + ['link.getAll()', (c) => c.link.getAll()], + ['link.canApplyLink()', (c) => c.link.canApplyLink()], + ['project.getAll()', (c) => c.project.getAll()], + ['topic.getAll()', (c) => c.topic.getAll()], + ['activity.getLastYearPublication()', (c) => c.activity.getLastYearPublication()], + ['activity.getRecentActivities()', (c) => c.activity.getRecentActivities()], + ['activity.getRoomsInfo()', (c) => c.activity.getRoomsInfo()], + ['search.searchAll("ai")', (c) => c.search.searchAll('ai', { type: 'POST' })], + ['comment.getUploadConfig()', (c) => c.comment.getUploadConfig()], + + // Sampled (depend on existence of seed content) + ['post.getPost (sampled)', async (c) => { + const list = await c.post.getList(1, 1) + const item = (list?.data ?? list)?.[0] + if (!item) return null + return c.post.getPost(item.category?.slug ?? item.category, item.slug) + }], + ['note.getNoteByNid (sampled)', async (c) => { + const latest = await c.note.getLatest() + const nid = latest?.data?.nid ?? latest?.nid + if (!nid) return null + return c.note.getNoteByNid(+nid) + }], + ['page.getBySlug (sampled)', async (c) => { + const list = await c.page.getList(1, 1) + const item = (list?.data ?? list)?.[0] + if (!item) return null + return c.page.getBySlug(item.slug) + }], + ['category.getCategoryByIdOrSlug (sampled)', async (c) => { + const cats = await c.category.getAllCategories() + const slug = (cats?.data ?? cats)?.[0]?.slug + if (!slug) return null + return c.category.getCategoryByIdOrSlug(slug) + }], + ['topic.getTopicBySlug (sampled)', async (c) => { + const list = await c.topic.getAll() + const slug = (list?.data ?? list)?.[0]?.slug + if (!slug) return null + return c.topic.getTopicBySlug(slug) + }], + ['comment.getByRefId (sampled)', async (c) => { + const list = await c.post.getList(1, 1) + const item = (list?.data ?? list)?.[0] + if (!item) return null + return c.comment.getByRefId(item.id, {}) + }], + ['recently.getById (sampled)', async (c) => { + const list = await c.recently.getList({ size: 1 }) + const id = (list?.data ?? list)?.[0]?.id + if (!id) return null + return c.recently.getById(id) + }], + ['recently.getLatestOne()', (c) => c.recently.getLatestOne?.()], + ['recently.getAll()', (c) => c.recently.getAll?.()], + ['note.getMiddleList (sampled)', async (c) => { + const list = await c.note.getList(1, 1) + const id = (list?.data ?? list)?.[0]?.id + if (!id) return null + return c.note.getMiddleList?.(id, 5) + }], + ['note.getNoteBySlugDate (sampled)', async (c) => { + const list = await c.note.getList(1, 1) + const item = (list?.data ?? list)?.[0] + if (!item?.slug) return null + const d = new Date(item.created_at ?? item.createdAt ?? Date.now()) + return c.note.getNoteBySlugDate(item.slug, d.getFullYear(), d.getMonth() + 1, d.getDate()) + }], + ['note.getTopicRecentUpdate (sampled)', async (c) => { + const topics = await c.topic.getAll() + const id = (topics?.data ?? topics)?.[0]?.id + if (!id) return null + return c.note.getTopicRecentUpdate(id) + }], + ['note.getNoteByTopicId (sampled)', async (c) => { + const topics = await c.topic.getAll() + const id = (topics?.data ?? topics)?.[0]?.id + if (!id) return null + return c.note.getNoteByTopicId?.(id) + }], + ['category.getTagByName (sampled)', async (c) => { + const tags = await c.category.getAllTags() + const name = (tags?.data ?? tags)?.[0]?.name + if (!name) return null + return c.category.getTagByName(name) + }], + ['comment.getThreadReplies (sampled)', async (c) => { + const posts = await c.post.getList(1, 1) + const refId = (posts?.data ?? posts)?.[0]?.id + if (!refId) return null + const list = await c.comment.getByRefId(refId, {}) + const cmtId = (list?.data ?? list)?.[0]?.id + if (!cmtId) return null + return c.comment.getThreadReplies(cmtId, {}) + }], + ['comment.getById (sampled)', async (c) => { + const posts = await c.post.getList(1, 1) + const refId = (posts?.data ?? posts)?.[0]?.id + if (!refId) return null + const list = await c.comment.getByRefId(refId, {}) + const id = (list?.data ?? list)?.[0]?.id + if (!id) return null + return c.comment.getById(id) + }], + ['ai.getSummary (sampled)', async (c) => { + const list = await c.post.getList(1, 1) + const id = (list?.data ?? list)?.[0]?.id + if (!id) return null + return c.ai.getSummary?.({ refId: id, type: 'POST' }) + }], + ['ai.getInsights (sampled)', async (c) => { + const list = await c.post.getList(1, 1) + const id = (list?.data ?? list)?.[0]?.id + if (!id) return null + return c.ai.getInsights?.({ refId: id, type: 'POST' }) + }], + ['activity.getPresence (sampled)', (c) => c.activity.getPresence('home')], + ['subscribe.check', (c) => c.subscribe.check?.()], + ['aggregate.getLatest()', (c) => c.aggregate.getLatest?.({})], + ['aggregate.getSiteMetadata()', (c) => c.aggregate.getSiteMetadata?.()], + ['page.getById (sampled)', async (c) => { + const list = await c.page.getList(1, 1) + const id = (list?.data ?? list)?.[0]?.id + if (!id) return null + return c.page.getById(id) + }], + // --- Proxy GET endpoints (data diff) --- + ['aggregate.proxy.site_info.get', (c) => c.aggregate.proxy.site_info.get()], + ['proxy.fn.shiro.status.get', (c) => c.proxy.fn.shiro.status.get()], + ['proxy("like_this").get', (c) => c.proxy('like_this').get()], + ['proxy.auth.providers.get', (c) => + c.proxy.auth.providers.get({ params: { type: 'social' } }), + ], + + // --- Write-class & dynamic-proxy endpoints (status-only contract check) --- + // For these we only check that V1 and V2 agree on HTTP status (endpoint exists / same auth gate). + ['WRITE: activity.likeIt (sampled)', async (c) => { + const list = await c.post.getList(1, 1) + const id = (list?.data ?? list)?.[0]?.id + if (!id) throw new Error('no post') + return c.activity.likeIt('Post', id) + }, 'status'], + ['WRITE: activity.updatePresence', (c) => + c.activity.updatePresence({ roomName: 'home', position: 0, ts: Date.now() }), + 'status', + ], + ['WRITE: ack.read (sampled)', async (c) => { + const list = await c.post.getList(1, 1) + const id = (list?.data ?? list)?.[0]?.id + if (!id) throw new Error('no post') + return c.ack.read('post', id) + }, 'status'], + ['WRITE: owner.logout', (c) => c.owner.logout(), 'status'], + ['WRITE: subscribe.subscribe', (c) => + c.subscribe.subscribe?.({ email: 'smoke@example.com', types: 0 }), + 'status', + ], + ['WRITE: comment.uploadImage (skipped)', () => Promise.resolve(null), 'status'], + + // Dynamic proxy paths used by Yohaku + ['PROXY GET: /serverless/shiro/status (admin)', (c) => + c.serverless.proxy.shiro.status.get(), + 'status', + ], + ['PROXY GET: /auth/session', (c) => c.proxy.auth.session.get(), 'status'], + ['PROXY GET: /note/proxy/list (sampled)', async (c) => { + const latest = await c.note.getLatest() + const id = latest?.data?.id ?? latest?.id + if (!id) return null + return c.note.proxy.list(id).get() + }], +] + +// Random / non-deterministic endpoints: skip (different ends pick different rows). +const NON_DETERMINISTIC = new Set(['say.getRandom()']) + +const VOLATILE_KEY = /(read_count|like|comments_index|view|count|last_login_time|updated_at|modified_at|presence|online|ts|timestamp|nonce)/i + +function normalize(v) { + if (Array.isArray(v)) return v.map(normalize) + if (v && typeof v === 'object') { + const out = {} + for (const k of Object.keys(v).sort()) out[k] = normalize(v[k]) + return out + } + return v +} + +function diff(a, b, p = '') { + if (a === b) return [] + if (VOLATILE_KEY.test(p)) return [] + if (typeof a !== typeof b) return [{ p, k: 'typeMismatch', a: typeof a, b: typeof b }] + if (a == null || b == null) return [{ p, k: 'nullDiff', a, b }] + if (Array.isArray(a) !== Array.isArray(b)) return [{ p, k: 'arrayMismatch' }] + if (Array.isArray(a)) { + const out = [] + if (a.length !== b.length) out.push({ p, k: 'arrLen', a: a.length, b: b.length }) + for (let i = 0; i < Math.min(a.length, b.length, 3); i++) out.push(...diff(a[i], b[i], `${p}[${i}]`)) + return out + } + if (typeof a === 'object') { + const ka = new Set(Object.keys(a)) + const kb = new Set(Object.keys(b)) + const out = [] + for (const k of ka) if (!kb.has(k)) out.push({ p: `${p}.${k}`, k: 'onlyInNew' }) + for (const k of kb) if (!ka.has(k)) out.push({ p: `${p}.${k}`, k: 'onlyInOld' }) + for (const k of ka) if (kb.has(k)) out.push(...diff(a[k], b[k], `${p}.${k}`)) + return out + } + return [{ p, k: 'value', a, b }] +} + +const SUMMARY = { ok: 0, diff: 0, err: 0, skip: 0 } +const DIFFS = [] + +async function runCaseValue(fn, client) { + try { + return { ok: true, value: await fn(client) } + } catch (e) { + return { ok: false, status: e?.status ?? null, message: e?.message ?? String(e) } + } +} + +for (const [name, fn, mode] of cases) { + if (filter && !filter.test(name)) continue + if (NON_DETERMINISTIC.has(name)) { + console.log(`SKIP ${name} (non-deterministic)`) + SUMMARY.skip++ + continue + } + + if (mode === 'status') { + // Compare only HTTP status (endpoint contract / auth gate). + const [a, b] = await Promise.all([ + runCaseValue(fn, newClient), + runCaseValue(fn, oldClient), + ]) + const sa = a.ok ? 200 : a.status + const sb = b.ok ? 200 : b.status + if (sa === sb) { + console.log(`OK ${name} (status=${sa})`) + SUMMARY.ok++ + } else { + console.log(`DIFF ${name} V2 status=${sa} V1 status=${sb}`) + console.log(` V2: ${a.ok ? 'ok' : a.message?.slice(0, 100)}`) + console.log(` V1: ${b.ok ? 'ok' : b.message?.slice(0, 100)}`) + SUMMARY.diff++ + DIFFS.push({ name, mode: 'status', v2: sa, v1: sb }) + } + continue + } + + try { + const [na, ob] = await Promise.all([fn(newClient), fn(oldClient)]) + if (na == null && ob == null) { + console.log(`SKIP ${name} (no seed data)`) + SUMMARY.skip++ + continue + } + const d = diff(normalize(na), normalize(ob)) + if (d.length === 0) { + console.log(`OK ${name}`) + SUMMARY.ok++ + } else { + console.log(`DIFF ${name} (${d.length} differences)`) + d.slice(0, 10).forEach((x) => console.log(' ', JSON.stringify(x).slice(0, 240))) + SUMMARY.diff++ + DIFFS.push({ name, diffs: d }) + } + } catch (e) { + console.log(`ERR ${name}: ${(e?.message ?? e)?.toString().slice(0, 220)}`) + SUMMARY.err++ + } +} + +console.log('\n=== SUMMARY ===') +console.log(SUMMARY) +if (DIFFS.length) { + const reportPath = new URL('./smoke-diff-report.json', import.meta.url) + const { writeFileSync } = await import('node:fs') + writeFileSync(reportPath, JSON.stringify(DIFFS, null, 2)) + console.log(`Full report written: ${reportPath.pathname}`) +} diff --git a/packages/api-client/tsdown.config.ts b/packages/api-client/tsdown.config.ts index 4877351c9fe..d343b4a3e74 100644 --- a/packages/api-client/tsdown.config.ts +++ b/packages/api-client/tsdown.config.ts @@ -12,7 +12,11 @@ const adaptorNames = readdirSync(path.resolve(__dirname, './adaptors')).map( export default defineConfig({ clean: true, target: 'es2020', - entry: ['index.ts', ...adaptorNames.map((name) => `adaptors/${name}.ts`)], + entry: [ + 'index.ts', + 'legacy/index.ts', + ...adaptorNames.map((name) => `adaptors/${name}.ts`), + ], external: adaptorNames, // `eager: true` runs the TypeScript compiler directly to emit declarations // instead of relying on rolldown-plugin-dts's bundler. The bundler chokes @@ -23,22 +27,59 @@ export default defineConfig({ dts: { eager: true }, format: ['cjs', 'esm'], onSuccess() { - // Replace declare module '../core/client' with declare module '@mx-space/api-client' + // Module augmentations for HTTPClient must live in the root entry d.ts so + // TypeScript merges them with the re-exported class. When rolldown-plugin-dts + // splits declarations into chunks, augmentations land in chunk files where + // they never reach consumer projects. We: + // 1. Rewrite augment target `'../core/client'` → `''` everywhere. + // 2. Strip the augmentation blocks from chunk files. + // 3. Append them to the root index.d.cts / index.d.mts so consumers see them. const PKG = JSON.parse( readFileSync(path.resolve(__dirname, './package.json'), 'utf-8'), ) - const dts = path.resolve(__dirname, './dist/index.d.cts') - const dtsm = path.resolve(__dirname, './dist/index.d.mts') - const content = readFileSync(dts, 'utf-8') - - for (const file of [dts, dtsm]) { - writeFileSync( - file, - content.replaceAll( - /declare module '..\/core\/client'/g, - `declare module '${PKG.name}'`, - ), + const distRoot = path.resolve(__dirname, './dist') + const walk = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const resolved = path.join(dir, entry.name) + if (entry.isDirectory()) return walk(resolved) + return /\.d\.(c|m)?ts$/.test(entry.name) ? [resolved] : [] + }) + + const augmentPattern = new RegExp( + `declare module ['"](?:\\.\\./core/client|${PKG.name.replace(/[/\\\\^$*+?.()|[\\]{}]/g, '\\$&')})['"]\\s*\\{[\\s\\S]*?^\\}`, + 'gm', + ) + const collected = new Set() + + for (const file of walk(distRoot)) { + const isRootEntry = + file === path.join(distRoot, 'index.d.cts') || + file === path.join(distRoot, 'index.d.mts') + let content = readFileSync(file, 'utf-8') + + content = content.replaceAll( + /declare module ['"]\.\.\/core\/client['"]/g, + `declare module '${PKG.name}'`, ) + + if (!isRootEntry) { + const matches = content.match(augmentPattern) + if (matches?.length) { + matches.forEach((m) => collected.add(m.trim())) + content = content.replaceAll(augmentPattern, '').replace(/\n{3,}/g, '\n\n') + } + } + + writeFileSync(file, content) + } + + if (collected.size === 0) return + + for (const root of ['index.d.cts', 'index.d.mts']) { + const file = path.join(distRoot, root) + const content = readFileSync(file, 'utf-8') + const block = ['', '// --- HTTPClient augmentations (inlined) ---', ...collected].join('\n') + writeFileSync(file, content.trimEnd() + '\n' + block + '\n') } }, }) diff --git a/packages/api-client/utils/index.ts b/packages/api-client/utils/index.ts index c77d1e47468..27886b967e8 100644 --- a/packages/api-client/utils/index.ts +++ b/packages/api-client/utils/index.ts @@ -14,6 +14,19 @@ export const sortOrderToNumber = (order: SortOrder) => { ) } const isObject = (obj: any) => obj && typeof obj === 'object' + +export interface ResponseEnvelope { + data: T + meta?: Record +} + +export const isResponseEnvelope = (body: any): body is ResponseEnvelope => { + if (!isPlainObject(body) || !('data' in body)) { + return false + } + return Object.keys(body).every((key) => key === 'data' || key === 'meta') +} + export const destructureData = (payload: any) => { if (typeof payload !== 'object') { return payload diff --git a/packages/cli/README.md b/packages/cli/README.md index d2a8535922a..cfe10c3d39d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -243,7 +243,7 @@ URL follows the admin-vue3 hash-router convention | Flag | Field | | ---------------------- | ---------------------------------------------------------------------- | -| `--title ` | `title`; defaults to `无题` for create payloads. | +| `--title ` | `title`; defaults to `无题` ("Untitled") for create payloads. | | `--slug ` | `slug` | | `--topic ` | Resolved to `topicId` by id, slug, or name. | | `--content ` | Body source. | diff --git a/packages/cli/docs/specs/2026-05-21-v2-v3-envelope-compat-layer.md b/packages/cli/docs/specs/2026-05-21-v2-v3-envelope-compat-layer.md new file mode 100644 index 00000000000..0ca45701473 --- /dev/null +++ b/packages/cli/docs/specs/2026-05-21-v2-v3-envelope-compat-layer.md @@ -0,0 +1,344 @@ +# V2/V3 Envelope Compatibility Layer + +**Status:** Draft +**Author:** CLI maintainer +**Date:** 2026-05-21 +**Target:** `@mx-space/cli` ≥ 0.6.0 +**Scope:** `packages/cli` + +## 1. Context + +`mx-core` shipped a wire-format change ("V3 envelope") that the CLI has not yet +adopted. Production servers run `API_VERSION = 3` +(`apps/core/src/app.config.ts:216`) and route under `/api/v3/*`. The CLI defaults +to `api_version = 2` (`src/services/Config.ts:599`) and parses responses +assuming the V2 shape. + +The wire-format diff that matters to the CLI: + +| Aspect | V2 wire | V3 wire | +| ----------- | --------------------------------------------- | -------------------------------------------------------------------- | +| Success | `{ data, pagination? }` (root-level) | `{ data, meta? }`; `pagination` lives at `meta.pagination` | +| Error | `{ message, code?, details? }` (root-level) | `{ error: { code, message, details? } }` | +| Case | mixed (resource-dependent) | snake_case at the wire boundary | +| Error codes | free-form strings or HTTP-derived | stable `SCREAMING_SNAKE` `AppErrorCode` enum | +| URL prefix | `/api/v2/` | `/api/v3/` | + +A user-deployed `mx-core` may be either V2 (older deployments) or V3 (current). +The CLI has to work against both for the duration of the migration window — +without forking the codebase. Once all known deployments are on V3, the +compatibility shim must come out cleanly, with no permanent V2 residue. + +## 2. Goals + +1. The same CLI binary works against a V2 or V3 server with no user-visible + regression in `mxs list/get/create/update/delete` output. +2. `pagination`, `error.code`, and `error.message` reach the renderer + regardless of which wire the server emits. +3. The compat code is **isolated to a single module** with a documented + deletion checklist. After V2 support is dropped, removing the module is a + mechanical, grep-driven change set. +4. Existing V2 integration tests keep passing; new V3 integration tests cover + the same surface against V3 fixtures. + +## 3. Non-goals + +- Changing the URL routing strategy. `apiBase = ${apiUrl}/api/v${apiVersion}` + stays as-is. The user (or profile config) is still authoritative for which + prefix to hit. Auto-probing is described in §5.4 as optional follow-up. +- Migrating CLI commands to typed `@mx-space/api-client` calls. The Effect-TS + `Api` service stays low-level; the adapter operates on the parsed body. +- Touching the renderer's view registry or output modes. The adapter normalizes + to V3 shape; renderers see only one shape. +- Server-side changes. + +## 4. Design summary + +A single file — `packages/cli/src/domain/envelope-compat.ts` — exports two +pure functions and one type: + +```ts +export type WireVersion = 'v2' | 'v3' + +export const detectWireVersion: (body: unknown) => WireVersion | null +export const normalizeSuccessBody: (body: unknown) => unknown +export const normalizeErrorBody: (body: unknown) => { + code?: string + message?: string + details?: unknown +} +``` + +The `Api` service (`src/services/Api.ts`) calls `normalizeSuccessBody` on the +parsed 2xx body before handing it to schema decoding or the caller, and calls +`normalizeErrorBody` on the parsed non-2xx body before mapping to +`TaggedError`s. Both calls are marked with a `// COMPAT:envelope` comment so +the deletion step is grep-driven. + +`detectWireVersion` is exposed for the Api service to cache a best-effort +verdict per `ApiService` instance (`Ref`), used only for +observability (verbose log line) — the normalizers themselves are shape-tolerant +and do not require the verdict to be correct. + +The two views that read root-level `pagination` (`cli/post/view.ts:126` and +`cli/comment/view.ts:137`) drop their V2 fallback and read `meta.pagination` +only. After normalization, the V2 wire is indistinguishable from V3 to the +renderer. + +## 5. Detailed design + +### 5.1 File layout + +``` +packages/cli/src/domain/envelope-compat.ts ← new +packages/cli/test/domain/envelope-compat.test.ts ← new +packages/cli/test/integration/cli-post-list.test.ts ← extend with V3 fixture +``` + +### 5.2 `envelope-compat.ts` contract + +```ts +/** + * @deprecated Compat shim for the V2 → V3 mx-core wire-format transition. + * + * Remove this file once all deployments referenced by the CLI's user base + * are confirmed V3 (`API_VERSION = 3` in mx-core). Removal checklist: + * + * 1. Delete this file (`envelope-compat.ts`) and its test. + * 2. In `src/services/Api.ts`, search for `// COMPAT:envelope` and delete + * the two normalizer calls + the `Ref` field. + * 3. In `src/cli/post/view.ts` and `src/cli/comment/view.ts`, the + * `meta.pagination` reads stay (they are V3 shape, not compat code). + * 4. In `src/services/Config.ts`, change the `api_version` default from + * `2` to `3`. + * 5. In `test/integration/cli-post-list.test.ts`, drop the V2 fixture + * branch; keep only the V3 mock. + * 6. Run `pnpm typecheck && pnpm test` — no references should remain. + */ + +export type WireVersion = 'v2' | 'v3' + +/** + * Sniff the wire version from a parsed response body. Returns `null` if the + * body is too small (e.g. raw scalar, FormData echo, dry-run synthetic) to + * decide. Callers must tolerate `null`. + * + * V3 markers (any one suffices): + * - body is an object with a `meta` key, OR + * - body is an object with `error.code` (string) or `error.message` (string), + * + * V2 markers (any one): + * - body is an object with a root-level `pagination` key, OR + * - body is an object with a root-level `message: string` and no `error` key. + * + * When both V2 and V3 markers are present (defensive), V3 wins. + */ +export declare const detectWireVersion: (body: unknown) => WireVersion | null + +/** + * Normalize a 2xx response body to the V3 shape `{ data, meta? }`. + * + * - V3 body (`{ data, meta? }`) → returned as-is + * - V2 paginated (`{ data, pagination }`) → `{ data, meta: { pagination } }` + * - V2 bare (`{ data }`) → returned as-is + * - Anything else (arrays, scalars, non-objects, already-unwrapped) → returned as-is + * + * The function is **shape-tolerant**: if the body does not match a known + * envelope shape, it is returned unchanged. This is important because callers + * sometimes pass already-unwrapped documents (e.g. from `--dry-run` synthetic + * envelopes) and the renderer's `unwrapDocument` re-runs further down. + */ +export declare const normalizeSuccessBody: (body: unknown) => unknown + +/** + * Normalize a non-2xx response body to a flat `{ code?, message?, details? }` + * tuple. + * + * - V3 body (`{ error: { code, message, details? } }`) → unwrapped + * - V2 body (`{ message: string | string[], code?, details? }`) → flattened + * - `message: string[]` is joined with `'; '` (matches the legacy + * `extractMessage` helper in api-envelope.ts) + * - Non-object body → `{}` + * + * The function never throws and never mutates the input. + */ +export declare const normalizeErrorBody: (body: unknown) => { + code?: string + message?: string + details?: unknown +} +``` + +### 5.3 `Api.ts` integration + +Two surgical edits, both tagged for grep: + +1. **Success path** (`src/services/Api.ts`, around line 326): + + ```ts + const body = yield* parseBody(res) + // COMPAT:envelope — drop with envelope-compat.ts when V2 support ends + const normalized = normalizeSuccessBody(body) + if (res.status >= 200 && res.status < 300) { + if (!options.schema) return normalized as A + return yield* decodeWithSchema(options.schema, normalized) + } + ``` + +2. **Error path** (`src/services/Api.ts`, around line 405 — `mapHttpStatusToError`): + + ```ts + function mapHttpStatusToError(status: number, body: unknown): ApiError { + // COMPAT:envelope — drop with envelope-compat.ts when V2 support ends + const { code, message, details } = normalizeErrorBody(body) + // ...existing status switch, but use `message` / `code` / `details` instead + // of `body.message` etc. The status-derived fallbacks remain unchanged. + } + ``` + + The status switch additionally prefers `code` over status-derived tags for + `ResourceNotFound` / `ValidationFailed` mapping when the V3 code is one of + the stable SCREAMING_SNAKE values (`NOT_FOUND`, `VALIDATION_FAILED`, + `_NOT_FOUND`). Status-only mapping is the fallback. + +3. **Optional verbose log enrichment** — add a `Ref` to the + `ApiService` closure, populate it via `detectWireVersion` on the first + parsed body, and append `[wire=v2]` / `[wire=v3]` to the verbose + `METHOD URL → STATUS (Xms)` line. This is purely diagnostic; the normalizers + do not consume it. + +### 5.4 URL prefix selection (out of scope, possible follow-up) + +The `api_version` profile field decides the URL prefix. The compat layer does +**not** auto-probe `/api/v3/aggregate` vs `/api/v2/aggregate`. A user with a V3 +server must set `api_version: 3` in their profile (or pass `--api-url +https://example.com/api/v3`). + +If we later want auto-detection, the touch points are: + +- `parseApiUrl` in `Config.ts` already extracts `apiVersion` from explicit URLs + like `https://example.com/api/v3` — that path needs no change. +- Add a one-shot `HEAD /api/v3/aggregate` probe to `Config.resolve` when + `api_version` is unset, with the result cached in the profile config file. + This is intentionally **not** part of this spec; the compat layer is + orthogonal to URL routing. + +### 5.5 View cleanup + +Two files lose their V2 fallback: + +```ts +// cli/post/view.ts:126 (and analogous in cli/comment/view.ts:137) +// Before: +const pagination = asRecord(first(payload, 'pagination')) +// After: +const pagination = asRecord(first(asRecord(first(payload, 'meta')), 'pagination')) +``` + +Adapter normalizes V2 → V3 before reaching the renderer, so the V2 read path +becomes dead code. Removing it now (rather than at deletion time) keeps the +view layer ignorant of compat concerns. + +## 6. Test plan + +### 6.1 Unit (`test/domain/envelope-compat.test.ts`) + +- `detectWireVersion`: + - `{ data: [], meta: {} }` → `'v3'` + - `{ error: { code: 'X', message: 'y' } }` → `'v3'` + - `{ data: [], pagination: { page: 1, size: 10, total: 0 } }` → `'v2'` + - `{ message: 'oops' }` → `'v2'` + - `{ message: ['a', 'b'] }` → `'v2'` + - `null` / `42` / `'plain text'` / `[1, 2, 3]` → `null` + - Mixed: `{ data, pagination, meta }` → `'v3'` +- `normalizeSuccessBody`: + - V3 paginated → unchanged + - V2 paginated → `pagination` moved under `meta` + - V2 bare `{ data }` → unchanged + - Scalar `42` / `null` / array → unchanged + - Already-unwrapped document (no `data` key) → unchanged +- `normalizeErrorBody`: + - V3 → unwrapped + - V2 string `message` → flat + - V2 array `message` → joined with `'; '` + - V2 with `code` → preserved + - Empty / null → `{}` + +### 6.2 Integration (`test/integration/cli-post-list.test.ts`) + +Split the existing test into two `it()` blocks against the same `runMxs` +harness: + +- "lists posts against a V2 server" — keep the existing fixture (`/api/v2`, + root `pagination`). +- "lists posts against a V3 server" — new fixture mocking `/api/v3` with + `{ data, meta: { pagination } }`. Assertions on stdout are identical. + +Same split for `cli-error-envelope.test.ts`: add a V3 error fixture +(`{ error: { code, message } }`) and assert the same `--json` `{ ok: false }` +output as the V2 case. + +### 6.3 Manual smoke (post-merge) + +Against a real V3 deployment (or local dev): + +``` +pnpm -C packages/cli dev -- --api-url http://localhost:2333/api/v3 post list +pnpm -C packages/cli dev -- --api-url http://localhost:2333/api/v3 note list +pnpm -C packages/cli dev -- --api-url http://localhost:2333/api/v3 post get +``` + +Verify `count` / `page` / `total` headers appear and the documents render. + +## 7. Rollout + +1. Land the compat layer + tests as **non-breaking**. Default + `api_version` stays `2`. Users on V3 servers set `api_version: 3` (or + `--api-url .../api/v3`). +2. Update `README.md` (`mxs auth login` section and "Configuration" + reference) to note the V2/V3 split and how to choose. +3. Cut a `0.6.0-next.x` release for early validation against both wires. +4. Once consumers confirm V3 servers work, flip `Config.ts` default to + `api_version = 3` in a follow-up release — still keeps the compat layer + for the long-tail V2 deployments. +5. Eventually execute the deletion checklist in §5.2 and ship a major bump + (`0.7.0` or `1.0.0`) with the V2 path gone. + +## 8. Risks and mitigations + +| Risk | Mitigation | +| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Adapter misclassifies a borderline body (e.g. a resource literally named `data`) | Normalizers are shape-tolerant — unchanged-on-mismatch. Unit tests cover every documented shape. | +| Schema decoding in `Api.request` sees a different shape pre/post adapter | Adapter runs **before** `decodeWithSchema`. Existing schemas (`paginatedEnvelopeSchema` etc.) already accept both V2-aliased and V3 pagination keys (per §6.2). | +| Some endpoint emits a custom non-envelope payload (e.g. `/options/` raw value) | `normalizeSuccessBody` returns scalars / arrays unchanged. The shape-tolerance is the contract. | +| User confusion about which `api_version` to set | README update in step 2 of rollout. CLI verbose log prints `[wire=v2]` / `[wire=v3]` for self-diagnosis. | +| Future divergence between V2 and V3 (e.g. server adds a new V3-only field) | Out of scope — the adapter normalizes shape, not semantics. New fields propagate through `data`/`meta` unchanged. | + +## 9. Open questions + +1. Should the verbose log line gain `[wire=v2|v3]` annotation (§5.3.3)? — Nice + to have, but not required for the compat semantics. Default: **yes**, small + diff, big diagnostic win. +2. Do we want a `mxs doctor` subcommand to print the detected wire version? + — Not in this spec. If we add one, it consumes `detectWireVersion` and + `Ref` from the Api service. +3. Should the View cleanup (§5.5) be a separate PR? — No, same change set: + the adapter is meaningless without the view also reading `meta.pagination`. + +## 10. Deletion checklist (canonical) + +When V2 support ends, in order: + +- [ ] `rm packages/cli/src/domain/envelope-compat.ts` +- [ ] `rm packages/cli/test/domain/envelope-compat.test.ts` +- [ ] `grep -n 'COMPAT:envelope' packages/cli/src` → remove the two call sites + in `Api.ts` (success path + error path) +- [ ] Remove the `Ref` field and verbose log annotation in + `Api.ts` +- [ ] `packages/cli/src/services/Config.ts:599` — change + `parsed.apiVersion ?? 2` to `parsed.apiVersion ?? 3` +- [ ] `packages/cli/test/integration/cli-post-list.test.ts` — drop the V2 `it` + block; rename the V3 block back to a single test +- [ ] `packages/cli/test/integration/cli-error-envelope.test.ts` — same +- [ ] `pnpm -C packages/cli typecheck && pnpm -C packages/cli test` +- [ ] Bump `packages/cli/package.json` major (or pre-1.0 minor) and ship diff --git a/packages/cli/src/cli/comment/view.ts b/packages/cli/src/cli/comment/view.ts index 6cbe74f57f2..fa9fbe13f1b 100644 --- a/packages/cli/src/cli/comment/view.ts +++ b/packages/cli/src/cli/comment/view.ts @@ -134,15 +134,16 @@ const renderListHeader = ( rowCount: number, color: boolean, ): string => { - const pagination = asRecord(first(payload, 'pagination')) + const envelopeMeta = asRecord(first(payload, 'meta')) + const pagination = asRecord(first(envelopeMeta, 'pagination')) const page = first(pagination, 'page', 'currentPage') const size = first(pagination, 'size', 'pageSize') - const total = first(pagination, 'total', 'totalCount') + const total = first(pagination, 'total', 'totalCount', 'total_count') - const meta: string[] = [`count: ${rowCount}`] - if (page !== undefined) meta.push(`page: ${formatScalar(page)}`) - if (size !== undefined) meta.push(`size: ${formatScalar(size)}`) - if (total !== undefined) meta.push(`total: ${formatScalar(total)}`) + const headerParts: string[] = [`count: ${rowCount}`] + if (page !== undefined) headerParts.push(`page: ${formatScalar(page)}`) + if (size !== undefined) headerParts.push(`size: ${formatScalar(size)}`) + if (total !== undefined) headerParts.push(`total: ${formatScalar(total)}`) const heading = wrap(ANSI.bold, 'Comments', color) const ruleLen = Math.min( @@ -150,7 +151,7 @@ const renderListHeader = ( SEPARATOR_WIDTH, ) const rule = wrap(ANSI.dim, '─'.repeat(ruleLen), color) - const metaLine = wrap(ANSI.dim, meta.join(' · '), color) + const metaLine = wrap(ANSI.dim, headerParts.join(' · '), color) return `${heading}\n${rule}\n${metaLine}` } @@ -162,7 +163,8 @@ const renderListItem = ( const author = authorOf(doc) const id = firstString(doc, 'id') const stateBadge = - colorForCommentState(first(doc, 'state'), color) ?? stateLabel(first(doc, 'state')) + colorForCommentState(first(doc, 'state'), color) ?? + stateLabel(first(doc, 'state')) const ref = refLabel(doc) const created = first(doc, 'created_at', 'createdAt') diff --git a/packages/cli/src/cli/note/view.ts b/packages/cli/src/cli/note/view.ts index ef6d3ffa5b6..1feb1074731 100644 --- a/packages/cli/src/cli/note/view.ts +++ b/packages/cli/src/cli/note/view.ts @@ -3,6 +3,7 @@ import { contentFormat, first, firstString, + pickArticleTranslationMeta, publishState, relationLabel, relationSlugOrLabel, @@ -17,7 +18,9 @@ const normalize = (data: unknown): Record => const collectFields = ( doc: Record, + articleMeta?: Record, ): Array<[string, unknown]> => { + const meta = articleMeta ?? {} const fields: Array<[string, unknown]> = [ ['id', first(doc, 'id')], ['slug', first(doc, 'slug')], @@ -30,8 +33,16 @@ const collectFields = ( ['bookmark', first(doc, 'bookmark')], ['created_at', first(doc, 'created_at', 'createdAt')], ['modified_at', first(doc, 'modified_at', 'modifiedAt')], - ['source_lang', first(doc, 'source_lang', 'sourceLang')], - ['translated', first(doc, 'is_translated', 'isTranslated')], + [ + 'source_lang', + first(doc, 'source_lang', 'sourceLang') ?? + first(meta, 'source_lang', 'sourceLang'), + ], + [ + 'translated', + first(doc, 'is_translated', 'isTranslated') ?? + first(meta, 'is_translated', 'isTranslated'), + ], ] return fields.filter( ([key, value]) => @@ -68,13 +79,14 @@ export const noteView: View = { modes: new Set(['readable', 'llm', 'xml']), readable: (data, ctx) => { const doc = normalize(data) + const articleMeta = pickArticleTranslationMeta(data, first(doc, 'id')) const content = renderContent(doc) const title = firstString(doc, 'title') return renderMetadataBlock( { title: title || undefined, kind: 'note', - fields: collectFields(doc), + fields: collectFields(doc, articleMeta), summary: pickSummary(doc), body: content.body || undefined, bodyFormat: content.format, @@ -84,11 +96,12 @@ export const noteView: View = { }, llm: (data) => { const doc = normalize(data) + const articleMeta = pickArticleTranslationMeta(data, first(doc, 'id')) const content = renderContent(doc, true) const title = firstString(doc, 'title') const fm = frontmatter({ title: title || undefined, - fields: collectFields(doc), + fields: collectFields(doc, articleMeta), summary: pickSummary(doc), }) return content.body ? `${fm}\n\n${content.body}` : fm diff --git a/packages/cli/src/cli/page/view.ts b/packages/cli/src/cli/page/view.ts index 37cfca1e6af..920ec0833c6 100644 --- a/packages/cli/src/cli/page/view.ts +++ b/packages/cli/src/cli/page/view.ts @@ -3,6 +3,7 @@ import { contentFormat, first, firstString, + pickArticleTranslationMeta, renderContent, unwrapDocument, } from '../../services/Renderer/content' @@ -14,7 +15,9 @@ const normalize = (data: unknown): Record => const collectFields = ( doc: Record, + articleMeta?: Record, ): Array<[string, unknown]> => { + const meta = articleMeta ?? {} const fields: Array<[string, unknown]> = [ ['id', first(doc, 'id')], ['slug', first(doc, 'slug')], @@ -22,8 +25,16 @@ const collectFields = ( ['order', first(doc, 'order')], ['created_at', first(doc, 'created_at', 'createdAt')], ['modified_at', first(doc, 'modified_at', 'modifiedAt')], - ['source_lang', first(doc, 'source_lang', 'sourceLang')], - ['translated', first(doc, 'is_translated', 'isTranslated')], + [ + 'source_lang', + first(doc, 'source_lang', 'sourceLang') ?? + first(meta, 'source_lang', 'sourceLang'), + ], + [ + 'translated', + first(doc, 'is_translated', 'isTranslated') ?? + first(meta, 'is_translated', 'isTranslated'), + ], ] return fields.filter( ([key, value]) => @@ -55,13 +66,14 @@ export const pageView: View = { modes: new Set(['readable', 'llm', 'xml']), readable: (data, ctx) => { const doc = normalize(data) + const articleMeta = pickArticleTranslationMeta(data, first(doc, 'id')) const content = renderContent(doc) const title = firstString(doc, 'title') return renderMetadataBlock( { title: title || undefined, kind: 'page', - fields: collectFields(doc), + fields: collectFields(doc, articleMeta), summary: pickSummary(doc), body: content.body || undefined, bodyFormat: content.format, @@ -71,11 +83,12 @@ export const pageView: View = { }, llm: (data) => { const doc = normalize(data) + const articleMeta = pickArticleTranslationMeta(data, first(doc, 'id')) const content = renderContent(doc, true) const title = firstString(doc, 'title') const fm = frontmatter({ title: title || undefined, - fields: collectFields(doc), + fields: collectFields(doc, articleMeta), summary: pickSummary(doc), }) return content.body ? `${fm}\n\n${content.body}` : fm diff --git a/packages/cli/src/cli/post/view.ts b/packages/cli/src/cli/post/view.ts index c7c87e2af7d..3de25ebbe85 100644 --- a/packages/cli/src/cli/post/view.ts +++ b/packages/cli/src/cli/post/view.ts @@ -4,6 +4,7 @@ import { first, firstString, formatScalar, + pickArticleTranslationMeta, publishState, relationLabel, relationSlugOrLabel, @@ -19,7 +20,9 @@ const normalize = (data: unknown): Record => const collectFields = ( doc: Record, + articleMeta?: Record, ): Array<[string, unknown]> => { + const meta = articleMeta ?? {} const fields: Array<[string, unknown]> = [ ['id', first(doc, 'id')], ['slug', first(doc, 'slug')], @@ -29,8 +32,16 @@ const collectFields = ( ['pin', first(doc, 'pin')], ['created_at', first(doc, 'created_at', 'createdAt')], ['modified_at', first(doc, 'modified_at', 'modifiedAt')], - ['source_lang', first(doc, 'source_lang', 'sourceLang')], - ['translated', first(doc, 'is_translated', 'isTranslated')], + [ + 'source_lang', + first(doc, 'source_lang', 'sourceLang') ?? + first(meta, 'source_lang', 'sourceLang'), + ], + [ + 'translated', + first(doc, 'is_translated', 'isTranslated') ?? + first(meta, 'is_translated', 'isTranslated'), + ], ] return fields.filter( ([key, value]) => @@ -64,13 +75,14 @@ export const postView: View = { modes: new Set(['readable', 'llm', 'xml']), readable: (data, ctx) => { const doc = normalize(data) + const articleMeta = pickArticleTranslationMeta(data, first(doc, 'id')) const content = renderContent(doc) const title = firstString(doc, 'title') return renderMetadataBlock( { title: title || undefined, kind: 'post', - fields: collectFields(doc), + fields: collectFields(doc, articleMeta), summary: pickSummary(doc), body: content.body || undefined, bodyFormat: content.format, @@ -80,11 +92,12 @@ export const postView: View = { }, llm: (data) => { const doc = normalize(data) + const articleMeta = pickArticleTranslationMeta(data, first(doc, 'id')) const content = renderContent(doc, true) const title = firstString(doc, 'title') const fm = frontmatter({ title: title || undefined, - fields: collectFields(doc), + fields: collectFields(doc, articleMeta), summary: pickSummary(doc), }) return content.body ? `${fm}\n\n${content.body}` : fm @@ -102,19 +115,31 @@ export const postView: View = { const collectListItemFields = ( doc: Record, -): Array<[string, unknown]> => [ - ['id', first(doc, 'id')], - ['title', first(doc, 'title')], - ['slug', first(doc, 'slug')], - ['state', publishState(doc)], - ['category', relationLabel(first(doc, 'category'))], - ['tags', first(doc, 'tags')], - ['summary', first(doc, 'summary')], - ['created_at', first(doc, 'created_at', 'createdAt')], - ['modified_at', first(doc, 'modified_at', 'modifiedAt')], - ['source_lang', first(doc, 'source_lang', 'sourceLang')], - ['translated', first(doc, 'is_translated', 'isTranslated')], -] + articleMeta?: Record, +): Array<[string, unknown]> => { + const meta = articleMeta ?? {} + return [ + ['id', first(doc, 'id')], + ['title', first(doc, 'title')], + ['slug', first(doc, 'slug')], + ['state', publishState(doc)], + ['category', relationLabel(first(doc, 'category'))], + ['tags', first(doc, 'tags')], + ['summary', first(doc, 'summary')], + ['created_at', first(doc, 'created_at', 'createdAt')], + ['modified_at', first(doc, 'modified_at', 'modifiedAt')], + [ + 'source_lang', + first(doc, 'source_lang', 'sourceLang') ?? + first(meta, 'source_lang', 'sourceLang'), + ], + [ + 'translated', + first(doc, 'is_translated', 'isTranslated') ?? + first(meta, 'is_translated', 'isTranslated'), + ], + ] +} const renderPostListReadable = (data: unknown): string => { const payload = asRecord(data) @@ -123,20 +148,22 @@ const renderPostListReadable = (data: unknown): string => { : Array.isArray(data) ? (data as unknown[]) : [] - const pagination = asRecord(first(payload, 'pagination')) + const meta = asRecord(first(payload, 'meta')) + const pagination = asRecord(first(meta, 'pagination')) const lines = ['posts'] if (rows.length > 0) lines.push(`count: ${rows.length}`) else lines.push('count: 0') const page = first(pagination, 'page', 'currentPage') const size = first(pagination, 'size', 'pageSize') - const total = first(pagination, 'total', 'totalCount') + const total = first(pagination, 'total', 'totalCount', 'total_count') if (page !== undefined) lines.push(`page: ${formatScalar(page)}`) if (size !== undefined) lines.push(`size: ${formatScalar(size)}`) if (total !== undefined) lines.push(`total: ${formatScalar(total)}`) rows.forEach((row, index) => { const doc = asRecord(row) + const articleMeta = pickArticleTranslationMeta(data, first(doc, 'id')) lines.push('', `post ${index + 1}:`) - const fields = collectListItemFields(doc) + const fields = collectListItemFields(doc, articleMeta) for (const [key, value] of fields) { if (value === undefined || value === null || value === '') continue const rendered = diff --git a/packages/cli/src/domain/envelope-compat.ts b/packages/cli/src/domain/envelope-compat.ts new file mode 100644 index 00000000000..30cd482fec7 --- /dev/null +++ b/packages/cli/src/domain/envelope-compat.ts @@ -0,0 +1,139 @@ +/** + * @deprecated Compat shim for the V2 → V3 mx-core wire-format transition. + * + * Remove this file once all deployments referenced by the CLI's user base are + * confirmed V3 (`API_VERSION = 3` in mx-core). Removal checklist: + * + * 1. Delete this file (`envelope-compat.ts`) and its test + * (`test/domain/envelope-compat.test.ts`). + * 2. In `src/services/Api.ts`, `grep -n 'COMPAT:envelope'` and delete the + * two normalizer calls + the `Ref` field + verbose log + * annotation. + * 3. In `src/services/Config.ts`, change `parsed.apiVersion ?? 2` to + * `?? 3`. + * 4. In `test/integration/cli-post-list.test.ts` and + * `test/integration/cli-error-envelope.test.ts`, drop the V2 `it` + * block; keep only the V3 mock. + * 5. Run `pnpm -C packages/cli typecheck && pnpm -C packages/cli test` — + * no references should remain. + * + * Full design: `packages/cli/docs/specs/2026-05-21-v2-v3-envelope-compat-layer.md`. + */ + +export type WireVersion = 'v2' | 'v3' + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +/** + * Sniff the wire version from a parsed response body. Returns `null` if the + * body is too small (raw scalar, FormData echo, dry-run synthetic) to decide. + * Callers must tolerate `null`. + * + * V3 markers (any one suffices): + * - object has a `meta` key, OR + * - object has `error.code` (string) or `error.message` (string). + * + * V2 markers (any one): + * - object has a root-level `pagination` key, OR + * - object has a root-level `message: string | string[]` and no `error` key. + * + * When both V2 and V3 markers are present, V3 wins. + */ +export const detectWireVersion = (body: unknown): WireVersion | null => { + if (!isRecord(body)) return null + + // V3 markers win. + if ('meta' in body) return 'v3' + if (isRecord(body.error)) { + const err = body.error + if (typeof err.code === 'string' || typeof err.message === 'string') { + return 'v3' + } + } + + // V2 markers. + if ('pagination' in body) return 'v2' + if ( + !('error' in body) && + (typeof body.message === 'string' || + (Array.isArray(body.message) && + body.message.every((m) => typeof m === 'string'))) + ) { + return 'v2' + } + + return null +} + +/** + * Normalize a 2xx response body to the V3 shape `{ data, meta? }`. + * + * - V3 (`{ data, meta? }`) → returned as-is + * - V2 paginated (`{ data, pagination }`) → `{ data, meta: { pagination } }` + * - V2 bare (`{ data }`) → returned as-is + * - Anything else (arrays, scalars, non-object, already-unwrapped doc) → unchanged + * + * Shape-tolerant: unrecognized inputs pass through unchanged. Never mutates + * the input. + */ +export const normalizeSuccessBody = (body: unknown): unknown => { + if (!isRecord(body)) return body + + // Already V3 (or carries a meta block) — pass through. + if ('meta' in body) return body + + // V2 paginated: lift root `pagination` under `meta`. + if ('data' in body && 'pagination' in body) { + const { pagination, ...rest } = body + return { ...rest, meta: { pagination } } + } + + return body +} + +export interface NormalizedError { + code?: string + message?: string + details?: unknown +} + +/** + * Normalize a non-2xx response body to a flat `{ code?, message?, details? }` + * tuple. + * + * - V3 (`{ error: { code, message, details? } }`) → unwrapped + * - V2 (`{ message: string | string[], code?, details? }`) → flattened. + * Array messages join with `'; '` to match the legacy `extractMessage` + * helper in `api-envelope.ts`. + * - Non-object body → `{}` + * + * Never throws, never mutates. + */ +export const normalizeErrorBody = (body: unknown): NormalizedError => { + if (!isRecord(body)) return {} + + // V3 shape. + if (isRecord(body.error)) { + const err = body.error + return { + code: typeof err.code === 'string' ? err.code : undefined, + message: typeof err.message === 'string' ? err.message : undefined, + details: err.details, + } + } + + // V2 shape — flat. + const rawMsg = body.message + let message: string | undefined + if (typeof rawMsg === 'string') { + message = rawMsg + } else if ( + Array.isArray(rawMsg) && + rawMsg.every((m) => typeof m === 'string') + ) { + message = (rawMsg as string[]).join('; ') + } + const code = typeof body.code === 'string' ? body.code : undefined + return { code, message, details: body.details } +} diff --git a/packages/cli/src/services/Api.ts b/packages/cli/src/services/Api.ts index e783369899e..dd0ead74114 100644 --- a/packages/cli/src/services/Api.ts +++ b/packages/cli/src/services/Api.ts @@ -3,6 +3,12 @@ import { HttpClient, HttpClientRequest } from '@effect/platform' import type { Schema } from 'effect' import { Context, Effect, Layer, Ref } from 'effect' +import { + detectWireVersion, + normalizeErrorBody, + normalizeSuccessBody, + type WireVersion, +} from '../domain/envelope-compat' import { AuthDenied, AuthExpired, @@ -20,7 +26,6 @@ import { type HttpMethod, type ResolvedGateInput, } from '../domain/gate' -import { extractServerMessage } from '../domain/schema/api-envelope' import { USER_AGENT } from '../domain/version' import { Auth } from './Auth' import { Config, type ResolvedConfig, type StoreOverrides } from './Config' @@ -140,6 +145,9 @@ function makeApiService( ): Effect.Effect { return Effect.gen(function* () { const bannerEmitted = yield* Ref.make(false) + // COMPAT:envelope — diagnostic-only cache of the detected wire version. + // Used to enrich the `--verbose` log line. Drop with envelope-compat.ts. + const detectedWire = yield* Ref.make(null) /** * Resolved config is cached per `ApiService` instance. This matches @@ -324,9 +332,26 @@ function makeApiService( // ---- 4. parse + status mapping --------------------------------- const body = yield* parseBody(res) + + // COMPAT:envelope — sniff wire version once per ApiService instance + // for verbose diagnostics. Normalizers below are shape-tolerant and + // do not consume this verdict. Drop with envelope-compat.ts. + if (opts.verbose) { + const cached = yield* Ref.get(detectedWire) + if (!cached) { + const sniffed = detectWireVersion(body) + if (sniffed) { + yield* Ref.set(detectedWire, sniffed) + process.stderr.write(`mxs: detected wire version: ${sniffed}\n`) + } + } + } + if (res.status >= 200 && res.status < 300) { - if (!options.schema) return body as A - return yield* decodeWithSchema(options.schema, body) + // COMPAT:envelope — lift V2 root `pagination` to V3 `meta.pagination`. + const normalized = normalizeSuccessBody(body) + if (!options.schema) return normalized as A + return yield* decodeWithSchema(options.schema, normalized) } return yield* Effect.fail(mapHttpStatusToError(res.status, body)) }) @@ -403,38 +428,66 @@ function serializeParseError(err: unknown): unknown { } function mapHttpStatusToError(status: number, body: unknown): ApiError { + // COMPAT:envelope — flatten V2/V3 error body shapes to one tuple. Drop with + // envelope-compat.ts when V2 support ends; replace with direct + // `body.error?.message` reads. + const { + code, + message: serverMsg, + details: serverDetails, + } = normalizeErrorBody(body) + const details = serverDetails ?? body + + // Code-driven mapping wins over status-derived classification when the V3 + // server emits a stable SCREAMING_SNAKE error code. + if (code === 'NOT_FOUND' || code?.endsWith('_NOT_FOUND')) { + return new ResourceNotFound({ + message: serverMsg ?? 'resource not found', + details, + }) + } + if (code === 'VALIDATION_FAILED') { + return new ValidationFailed({ + message: serverMsg ?? 'validation failed', + details, + }) + } + if (status === 401) { return new AuthExpired({ - message: 'authentication required', + message: serverMsg ?? 'authentication required', hint: 'run `mxs auth login`', - details: body, + details, }) } if (status === 403) { - return new AuthDenied({ message: 'permission denied', details: body }) + return new AuthDenied({ + message: serverMsg ?? 'permission denied', + details, + }) } if (status === 404) { return new ResourceNotFound({ - message: 'resource not found', - details: body, + message: serverMsg ?? 'resource not found', + details, }) } if (status === 400 || status === 422) { return new ValidationFailed({ - message: extractServerMessage(body) ?? 'validation failed', - details: body, + message: serverMsg ?? 'validation failed', + details, }) } if (status >= 500) { return new ServerError({ status, - message: extractServerMessage(body) ?? `server error (${status})`, - details: body, + message: serverMsg ?? `server error (${status})`, + details, }) } return new Generic({ - message: extractServerMessage(body) ?? `request failed (${status})`, - details: body, + message: serverMsg ?? `request failed (${status})`, + details, }) } diff --git a/packages/cli/src/services/Renderer/content.ts b/packages/cli/src/services/Renderer/content.ts index 3731994c6b1..b0aa1086289 100644 --- a/packages/cli/src/services/Renderer/content.ts +++ b/packages/cli/src/services/Renderer/content.ts @@ -76,6 +76,20 @@ export const unwrapDocument = (data: unknown): unknown => { return data } +export const pickArticleTranslationMeta = ( + payload: unknown, + docId: unknown, +): Record | undefined => { + const translation = asRecord(asRecord(asRecord(payload).meta).translation) + if (Object.keys(translation).length === 0) return undefined + const direct = asRecord(translation.article) + if (Object.keys(direct).length > 0) return direct + if (docId === undefined || docId === null) return undefined + const entry = asRecord(translation[String(docId)]) + const article = asRecord(entry.article) + return Object.keys(article).length > 0 ? article : undefined +} + // Synchronous Lexical → LiteXML helper kept inline so the Renderer can match // legacy `document-output.ts` behaviour without depending on the Lexical // service. The Lexical service still owns Markdown derivation (`--output llm`) diff --git a/packages/cli/test/domain/envelope-compat.test.ts b/packages/cli/test/domain/envelope-compat.test.ts new file mode 100644 index 00000000000..ffc677fe10e --- /dev/null +++ b/packages/cli/test/domain/envelope-compat.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest' + +import { + detectWireVersion, + normalizeErrorBody, + normalizeSuccessBody, +} from '../../src/domain/envelope-compat' + +describe('detectWireVersion', () => { + it('classifies V3 success envelope with meta', () => { + expect(detectWireVersion({ data: [], meta: {} })).toBe('v3') + }) + + it('classifies V3 error envelope by error.code', () => { + expect(detectWireVersion({ error: { code: 'POST_NOT_FOUND' } })).toBe('v3') + }) + + it('classifies V3 error envelope by error.message', () => { + expect(detectWireVersion({ error: { message: 'not found' } })).toBe('v3') + }) + + it('classifies V2 paginated body via root pagination', () => { + expect( + detectWireVersion({ + data: [], + pagination: { page: 1, size: 10, total: 0 }, + }), + ).toBe('v2') + }) + + it('classifies V2 error by root-level string message', () => { + expect(detectWireVersion({ message: 'oops' })).toBe('v2') + }) + + it('classifies V2 error by root-level array message', () => { + expect(detectWireVersion({ message: ['a', 'b'] })).toBe('v2') + }) + + it('returns null for scalar / null / array / non-object', () => { + expect(detectWireVersion(null)).toBeNull() + expect(detectWireVersion(42)).toBeNull() + expect(detectWireVersion('plain text')).toBeNull() + expect(detectWireVersion([1, 2, 3])).toBeNull() + expect(detectWireVersion(undefined)).toBeNull() + }) + + it('prefers V3 when both V2 and V3 markers are present', () => { + expect( + detectWireVersion({ + data: [], + pagination: { page: 1 }, + meta: { pagination: { page: 1 } }, + }), + ).toBe('v3') + }) + + it('returns null for an empty object', () => { + expect(detectWireVersion({})).toBeNull() + }) +}) + +describe('normalizeSuccessBody', () => { + it('passes V3 envelope through unchanged', () => { + const input = { data: [{ id: '1' }], meta: { pagination: { page: 1 } } } + expect(normalizeSuccessBody(input)).toBe(input) + }) + + it('lifts V2 root pagination under meta', () => { + const input = { + data: [{ id: '1' }], + pagination: { page: 1, size: 10, total: 5 }, + } + expect(normalizeSuccessBody(input)).toEqual({ + data: [{ id: '1' }], + meta: { pagination: { page: 1, size: 10, total: 5 } }, + }) + }) + + it('passes a bare V2 { data } envelope through unchanged', () => { + const input = { data: { id: '1', title: 'hi' } } + expect(normalizeSuccessBody(input)).toBe(input) + }) + + it('passes a scalar / null / array through unchanged', () => { + expect(normalizeSuccessBody(42)).toBe(42) + expect(normalizeSuccessBody(null)).toBe(null) + const arr = [1, 2, 3] + expect(normalizeSuccessBody(arr)).toBe(arr) + }) + + it('passes an already-unwrapped document (no data key) through unchanged', () => { + const input = { id: '1', title: 'already unwrapped' } + expect(normalizeSuccessBody(input)).toBe(input) + }) + + it('does not mutate the input', () => { + const input = { data: [], pagination: { page: 1 } } + const snapshot = JSON.parse(JSON.stringify(input)) + normalizeSuccessBody(input) + expect(input).toEqual(snapshot) + }) +}) + +describe('normalizeErrorBody', () => { + it('unwraps a V3 error envelope', () => { + expect( + normalizeErrorBody({ + error: { + code: 'POST_NOT_FOUND', + message: 'post not found', + details: { id: 'p1' }, + }, + }), + ).toEqual({ + code: 'POST_NOT_FOUND', + message: 'post not found', + details: { id: 'p1' }, + }) + }) + + it('flattens a V2 string-message error', () => { + expect(normalizeErrorBody({ message: 'oops', code: 'BAD' })).toEqual({ + code: 'BAD', + message: 'oops', + details: undefined, + }) + }) + + it('joins a V2 array-message error with "; "', () => { + expect(normalizeErrorBody({ message: ['a', 'b'] })).toEqual({ + code: undefined, + message: 'a; b', + details: undefined, + }) + }) + + it('returns empty object for non-object body', () => { + expect(normalizeErrorBody(null)).toEqual({}) + expect(normalizeErrorBody(42)).toEqual({}) + expect(normalizeErrorBody('plain')).toEqual({}) + expect(normalizeErrorBody([1, 2, 3])).toEqual({}) + }) + + it('preserves V2 details if present', () => { + expect( + normalizeErrorBody({ message: 'x', details: { field: 'y' } }), + ).toEqual({ code: undefined, message: 'x', details: { field: 'y' } }) + }) + + it('ignores non-string V2 message', () => { + expect(normalizeErrorBody({ message: 42 })).toEqual({ + code: undefined, + message: undefined, + details: undefined, + }) + }) + + it('ignores non-string V3 error.code / error.message', () => { + expect( + normalizeErrorBody({ error: { code: 42, message: { x: 1 } } }), + ).toEqual({ code: undefined, message: undefined, details: undefined }) + }) +}) diff --git a/packages/cli/test/integration/cli-error-envelope.test.ts b/packages/cli/test/integration/cli-error-envelope.test.ts index 6df6288e40d..560a6b023a1 100644 --- a/packages/cli/test/integration/cli-error-envelope.test.ts +++ b/packages/cli/test/integration/cli-error-envelope.test.ts @@ -46,13 +46,14 @@ interface MockServerHandle { const startMockServer = ( handler: (req: IncomingMessage, res: ServerResponse) => void, + prefix: 'v2' | 'v3' = 'v2', ): Promise => new Promise((resolve) => { const server: Server = createServer(handler) server.listen(0, '127.0.0.1', () => { const addr = server.address() as AddressInfo resolve({ - url: `http://127.0.0.1:${addr.port}/api/v2`, + url: `http://127.0.0.1:${addr.port}/api/${prefix}`, stop: () => new Promise((r) => server.close(() => r())), }) }) @@ -72,7 +73,7 @@ describe('cli --json error envelope', () => { }) it( - 'emits a wire-format error envelope when the server returns 500', + 'emits a wire-format error envelope when a V2 server returns 500', async () => { server = await startMockServer((req, res) => { if (req.url === '/api/v2' || req.url === '/api/v2/') { @@ -104,6 +105,96 @@ describe('cli --json error envelope', () => { expect(envelope.ok).toBe(false) expect(envelope.code).toBe('server.error') expect(typeof envelope.message).toBe('string') + expect(envelope.message).toContain('boom') + }, + 30_000, + ) + + it( + 'unwraps a V3 error envelope on 500', + async () => { + server = await startMockServer((req, res) => { + if (req.url === '/api/v3' || req.url === '/api/v3/') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end( + JSON.stringify({ + data: { + name: 'mx-server', + version: '3.0.0', + auth_client: 'better-auth', + }, + }), + ) + return + } + res.writeHead(500, { 'content-type': 'application/json' }) + res.end( + JSON.stringify({ + error: { code: 'INTERNAL_ERROR', message: 'boom v3' }, + }), + ) + }, 'v3') + + const res = await runMxs([ + '--api-url', + server.url, + '--json', + 'post', + 'list', + ]) + + expect(res.code).toBe(6) + const envelope = JSON.parse(res.stdout) + expect(envelope.ok).toBe(false) + expect(envelope.code).toBe('server.error') + // Adapter unwraps `error.message`, which then surfaces in the renderer. + expect(envelope.message).toContain('boom v3') + }, + 30_000, + ) + + it( + 'maps a V3 404 with _NOT_FOUND code to ResourceNotFound', + async () => { + server = await startMockServer((req, res) => { + if (req.url === '/api/v3' || req.url === '/api/v3/') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end( + JSON.stringify({ + data: { + name: 'mx-server', + version: '3.0.0', + auth_client: 'better-auth', + }, + }), + ) + return + } + res.writeHead(404, { 'content-type': 'application/json' }) + res.end( + JSON.stringify({ + error: { + code: 'CATEGORY_NOT_FOUND', + message: 'category not found', + }, + }), + ) + }, 'v3') + + const res = await runMxs([ + '--api-url', + server.url, + '--json', + 'category', + 'get', + 'missing', + ]) + + expect(res.code).toBe(7) + const envelope = JSON.parse(res.stdout) + expect(envelope.ok).toBe(false) + expect(envelope.code).toBe('resource.not_found') + expect(envelope.message).toContain('category not found') }, 30_000, ) diff --git a/packages/cli/test/integration/cli-post-list.test.ts b/packages/cli/test/integration/cli-post-list.test.ts index 9cf1479bacb..35d05fc23b5 100644 --- a/packages/cli/test/integration/cli-post-list.test.ts +++ b/packages/cli/test/integration/cli-post-list.test.ts @@ -51,6 +51,7 @@ interface MockServerHandle { const startMockServer = ( handler: (req: IncomingMessage, res: ServerResponse) => void, + prefix: 'v2' | 'v3' = 'v2', ): Promise => new Promise((resolve) => { const requests: MockServerHandle['requests'] = [] as any @@ -65,7 +66,7 @@ const startMockServer = ( server.listen(0, '127.0.0.1', () => { const addr = server.address() as AddressInfo resolve({ - url: `http://127.0.0.1:${addr.port}/api/v2`, + url: `http://127.0.0.1:${addr.port}/api/${prefix}`, stop: () => new Promise((r) => server.close(() => r())), requests, }) @@ -86,7 +87,7 @@ describe('cli post list — end-to-end pipeline', () => { }) it( - 'fetches /posts via --api-url override and emits JSON', + 'fetches /posts against a V2 server and emits JSON', async () => { server = await startMockServer((req, res) => { // Probe (auth detection) → reply with a v2 root document @@ -141,4 +142,64 @@ describe('cli post list — end-to-end pipeline', () => { }, 30_000, ) + + it( + 'fetches /posts against a V3 server and emits JSON', + async () => { + server = await startMockServer((req, res) => { + if (req.url === '/api/v3/' || req.url === '/api/v3') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end( + JSON.stringify({ + data: { + name: 'mx-server', + version: '3.0.0', + auth_client: 'better-auth', + }, + }), + ) + return + } + if (req.url?.startsWith('/api/v3/posts')) { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end( + JSON.stringify({ + data: [ + { + id: 'p1', + title: 'Hello', + slug: 'hello', + is_published: true, + created_at: '2026-05-18T00:00:00Z', + }, + ], + meta: { pagination: { page: 1, size: 10, total: 1 } }, + }), + ) + return + } + res.writeHead(404, { 'content-type': 'application/json' }) + res.end( + JSON.stringify({ error: { code: 'NOT_FOUND', message: 'not found' } }), + ) + }, 'v3') + + const res = await runMxs([ + '--api-url', + server.url, + '--json', + 'post', + 'list', + ]) + + expect(res.code).toBe(0) + expect(res.stdout).toContain('"ok":true') + expect(res.stdout).toContain('"title":"Hello"') + const postCalls = server.requests.filter((r) => + r.url.startsWith('/api/v3/posts'), + ) + expect(postCalls.length).toBeGreaterThan(0) + }, + 30_000, + ) }) diff --git a/packages/cli/test/services/Renderer.test.ts b/packages/cli/test/services/Renderer.test.ts index c43f35f5d59..3409666d26b 100644 --- a/packages/cli/test/services/Renderer.test.ts +++ b/packages/cli/test/services/Renderer.test.ts @@ -432,7 +432,7 @@ describe('Renderer — emit(postListView)', () => { const renderer = yield* Renderer yield* renderer.emit(postListView, { data: [{ id: '1', title: 'a', slug: 'a' }], - pagination: { page: 1, size: 10, total: 1 }, + meta: { pagination: { page: 1, size: 10, total: 1 } }, }) }), ).pipe( @@ -464,7 +464,7 @@ describe('Renderer — emit(postListView)', () => { summary: 'Short summary.', }, ], - pagination: { page: 1, size: 10, total: 1 }, + meta: { pagination: { page: 1, size: 10, total: 1 } }, }) }), ).pipe( @@ -806,7 +806,8 @@ describe('Renderer — pure document renderers', () => { created_at: new Date('2026-01-01T00:00:00Z'), }, ], - pagination: { currentPage: 2, pageSize: 5, totalCount: 9 }, + // V3 envelope shape (post envelope-compat normalization). + meta: { pagination: { page: 2, size: 5, total: 9 } }, }, viewCtx, ) @@ -816,4 +817,69 @@ describe('Renderer — pure document renderers', () => { expect(list).toContain('category: tech') expect(list).toContain('state: draft') }) + + it('reads source_lang and translated from meta.translation when the doc itself omits them', () => { + const envelope = { + data: { + id: 'p1', + title: 'Translated title', + slug: 'translated', + is_published: true, + contentFormat: 'markdown', + content: 'body', + text: 'body', + }, + meta: { + translation: { + p1: { + article: { + is_translated: true, + source_lang: 'zh', + target_lang: 'en', + }, + }, + }, + }, + } + const readable = postView.readable!(envelope, viewCtx) + expect(readable).toMatch(/source_lang\s+zh/) + expect(readable).toMatch(/translated\s+true/) + + const noteEnvelope = { + ...envelope, + data: { ...envelope.data, topic: 'life' }, + } + const noteReadable = noteView.readable!(noteEnvelope, viewCtx) + expect(noteReadable).toMatch(/source_lang\s+zh/) + expect(noteReadable).toMatch(/translated\s+true/) + + const pageReadable = pageView.readable!(envelope, viewCtx) + expect(pageReadable).toMatch(/source_lang\s+zh/) + expect(pageReadable).toMatch(/translated\s+true/) + }) + + it('falls back to meta.translation per-row for the post list view', () => { + const list = postListView.readable( + { + data: [ + { id: 'p1', title: 'A' }, + { id: 'p2', title: 'B' }, + ], + meta: { + translation: { + p1: { + article: { + is_translated: true, + source_lang: 'zh', + target_lang: 'en', + }, + }, + }, + }, + }, + viewCtx, + ) + expect(list).toContain('source_lang: zh') + expect(list).toContain('translated: true') + }) }) diff --git a/packages/db-schema/src/schema/auth.ts b/packages/db-schema/src/schema/auth.ts index af2f1c3a0da..b4fe6127c09 100644 --- a/packages/db-schema/src/schema/auth.ts +++ b/packages/db-schema/src/schema/auth.ts @@ -1,3 +1,4 @@ +// Tables readers, accounts, sessions, apiKeys, passkeys, verifications intentionally keep camelCase property names for Better Auth drizzle-adapter compatibility. import { sql } from 'drizzle-orm' import { boolean, diff --git a/packages/db-schema/src/schema/ops.ts b/packages/db-schema/src/schema/ops.ts index 495cf644da0..7356e2d069c 100644 --- a/packages/db-schema/src/schema/ops.ts +++ b/packages/db-schema/src/schema/ops.ts @@ -183,7 +183,10 @@ export const fileReferences = pgTable( uploadedBy: text('uploaded_by'), mimeType: text('mime_type'), byteSize: bigint('byte_size', { mode: 'number' }), - detachedAt: timestamp('detached_at', { withTimezone: false, mode: 'date' }), + detachedAt: timestamp('detached_at', { + withTimezone: false, + mode: 'date', + }), }, (table) => [ index('file_references_file_url_idx').on(table.fileUrl), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 239c12ef5bf..bc7d5b4371c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,10 +96,10 @@ importers: version: 7.29.0 '@better-auth/api-key': specifier: ^1.6.9 - version: 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(better-auth@1.6.11(@cloudflare/workers-types@4.20260517.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260517.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0))(mongodb@7.2.0(@noble/hashes@2.2.0))(pg@8.20.0)(vitest@4.1.6)) + version: 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(better-auth@1.6.11(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0))(mongodb@7.2.0(@noble/hashes@2.2.0))(pg@8.20.0)(vitest@4.1.6)) '@better-auth/passkey': specifier: ^1.6.9 - version: 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.11(@cloudflare/workers-types@4.20260517.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260517.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0))(mongodb@7.2.0(@noble/hashes@2.2.0))(pg@8.20.0)(vitest@4.1.6))(better-call@1.3.5(zod@4.4.3))(nanostores@1.3.0) + version: 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.11(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0))(mongodb@7.2.0(@noble/hashes@2.2.0))(pg@8.20.0)(vitest@4.1.6))(better-call@1.3.5(zod@4.4.3))(nanostores@1.3.0) '@fastify/cookie': specifier: 11.0.2 version: 11.0.2 @@ -195,7 +195,7 @@ importers: version: 3.0.3 better-auth: specifier: ^1.6.9 - version: 1.6.11(@cloudflare/workers-types@4.20260517.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260517.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0))(mongodb@7.2.0(@noble/hashes@2.2.0))(pg@8.20.0)(vitest@4.1.6) + version: 1.6.11(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0))(mongodb@7.2.0(@noble/hashes@2.2.0))(pg@8.20.0)(vitest@4.1.6) blurhash: specifier: 2.0.5 version: 2.0.5 @@ -219,7 +219,7 @@ importers: version: 13.0.0 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@cloudflare/workers-types@4.20260517.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0) + version: 0.45.2(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0) ejs: specifier: 5.0.2 version: 5.0.2 @@ -334,9 +334,6 @@ importers: slugify: specifier: 1.6.9 version: 1.6.9 - snakecase-keys: - specifier: 9.0.2 - version: 9.0.2 source-map-support: specifier: ^0.5.21 version: 0.5.21 @@ -355,10 +352,10 @@ importers: devDependencies: '@nestjs/cli': specifier: 11.0.21 - version: 11.0.21(@swc/cli@0.8.1(@swc/core@1.15.33)(chokidar@4.0.3))(@swc/core@1.15.33)(@types/node@25.8.0)(esbuild@0.28.0)(prettier@3.8.3) + version: 11.0.21(@swc/cli@0.8.1(@swc/core@1.15.33)(chokidar@4.0.3))(@swc/core@1.15.33)(@types/node@25.8.0)(esbuild@0.28.0) '@nestjs/schematics': specifier: 11.1.0 - version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@6.0.3) + version: 11.1.0(chokidar@4.0.3)(typescript@6.0.3) '@nestjs/testing': specifier: 11.1.21 version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21) @@ -6347,10 +6344,6 @@ packages: make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - map-obj@5.0.2: - resolution: {integrity: sha512-K6K2NgKnTXimT3779/4KxSvobxOtMmx1LBZ3NwRxT/MDIR3Br/fQ4Q+WCX5QxjyUR8zg5+RV9Tbf2c5pAWTD2A==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - map-obj@6.0.0: resolution: {integrity: sha512-PwDvwt/tK70+luLw5k9ySLtzLAzwf7tZTY9GBj63Y010nHRPjwHcQTpTd5JwQqITC2ty7prtxBo71iwyYY0TAg==} engines: {node: '>=20'} @@ -7361,10 +7354,6 @@ packages: resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} engines: {node: '>=8.0.0'} - snakecase-keys@9.0.2: - resolution: {integrity: sha512-Tr4gONsDj1Pa6HJH9D3b411r6tuRyCGgb1l7YpzDFp/thjVSWs7rcbNjyTyRqJi5SUV23sFpzf9epIJRbLR6Yw==} - engines: {node: '>=22'} - socket.io-adapter@2.5.6: resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} @@ -8543,11 +8532,11 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth/api-key@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(better-auth@1.6.11(@cloudflare/workers-types@4.20260517.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260517.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0))(mongodb@7.2.0(@noble/hashes@2.2.0))(pg@8.20.0)(vitest@4.1.6))': + '@better-auth/api-key@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(better-auth@1.6.11(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0))(mongodb@7.2.0(@noble/hashes@2.2.0))(pg@8.20.0)(vitest@4.1.6))': dependencies: - '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - better-auth: 1.6.11(@cloudflare/workers-types@4.20260517.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260517.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0))(mongodb@7.2.0(@noble/hashes@2.2.0))(pg@8.20.0)(vitest@4.1.6) + better-auth: 1.6.11(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0))(mongodb@7.2.0(@noble/hashes@2.2.0))(pg@8.20.0)(vitest@4.1.6) zod: 4.4.3 '@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': @@ -8564,6 +8553,18 @@ snapshots: optionalDependencies: '@cloudflare/workers-types': 4.20260517.1 + '@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': + dependencies: + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + '@opentelemetry/semantic-conventions': 1.40.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.3.5(zod@4.4.3) + jose: 6.2.3 + kysely: 0.28.17 + nanostores: 1.3.0 + zod: 4.4.3 + '@better-auth/drizzle-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260517.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0))': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) @@ -8571,6 +8572,13 @@ snapshots: optionalDependencies: drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260517.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0) + '@better-auth/drizzle-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0))': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + optionalDependencies: + drizzle-orm: 0.45.2(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0) + '@better-auth/kysely-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) @@ -8578,11 +8586,23 @@ snapshots: optionalDependencies: kysely: 0.28.17 + '@better-auth/kysely-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + optionalDependencies: + kysely: 0.28.17 + '@better-auth/memory-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 + '@better-auth/memory-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + '@better-auth/mongo-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(mongodb@7.2.0(@noble/hashes@2.2.0))': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) @@ -8590,14 +8610,21 @@ snapshots: optionalDependencies: mongodb: 7.2.0(@noble/hashes@2.2.0) - '@better-auth/passkey@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.11(@cloudflare/workers-types@4.20260517.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260517.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0))(mongodb@7.2.0(@noble/hashes@2.2.0))(pg@8.20.0)(vitest@4.1.6))(better-call@1.3.5(zod@4.4.3))(nanostores@1.3.0)': + '@better-auth/mongo-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(mongodb@7.2.0(@noble/hashes@2.2.0))': dependencies: - '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + optionalDependencies: + mongodb: 7.2.0(@noble/hashes@2.2.0) + + '@better-auth/passkey@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.11(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0))(mongodb@7.2.0(@noble/hashes@2.2.0))(pg@8.20.0)(vitest@4.1.6))(better-call@1.3.5(zod@4.4.3))(nanostores@1.3.0)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 '@simplewebauthn/browser': 13.3.0 '@simplewebauthn/server': 13.3.0 - better-auth: 1.6.11(@cloudflare/workers-types@4.20260517.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260517.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0))(mongodb@7.2.0(@noble/hashes@2.2.0))(pg@8.20.0)(vitest@4.1.6) + better-auth: 1.6.11(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0))(mongodb@7.2.0(@noble/hashes@2.2.0))(pg@8.20.0)(vitest@4.1.6) better-call: 1.3.5(zod@4.4.3) nanostores: 1.3.0 zod: 4.4.3 @@ -8607,12 +8634,23 @@ snapshots: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 + '@better-auth/prisma-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + '@better-auth/telemetry@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260517.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 + '@better-auth/telemetry@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + '@better-auth/utils@0.4.0': dependencies: '@noble/hashes': 2.2.0 @@ -10056,13 +10094,13 @@ snapshots: keyv: 5.6.0 rxjs: 7.8.2 - '@nestjs/cli@11.0.21(@swc/cli@0.8.1(@swc/core@1.15.33)(chokidar@4.0.3))(@swc/core@1.15.33)(@types/node@25.8.0)(esbuild@0.28.0)(prettier@3.8.3)': + '@nestjs/cli@11.0.21(@swc/cli@0.8.1(@swc/core@1.15.33)(chokidar@4.0.3))(@swc/core@1.15.33)(@types/node@25.8.0)(esbuild@0.28.0)': dependencies: '@angular-devkit/core': 19.2.24(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3) '@angular-devkit/schematics-cli': 19.2.24(@types/node@25.8.0)(chokidar@4.0.3) '@inquirer/prompts': 7.10.1(@types/node@25.8.0) - '@nestjs/schematics': 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@6.0.3) + '@nestjs/schematics': 11.1.0(chokidar@4.0.3)(typescript@6.0.3) ansis: 4.2.0 chokidar: 4.0.3 cli-table3: 0.6.5 @@ -10158,7 +10196,7 @@ snapshots: '@nestjs/core': 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2) cron: 4.4.0 - '@nestjs/schematics@11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@6.0.3)': + '@nestjs/schematics@11.1.0(chokidar@4.0.3)(typescript@6.0.3)': dependencies: '@angular-devkit/core': 19.2.24(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3) @@ -10166,8 +10204,6 @@ snapshots: jsonc-parser: 3.3.1 pluralize: 8.0.0 typescript: 6.0.3 - optionalDependencies: - prettier: 3.8.3 transitivePeerDependencies: - chokidar @@ -11706,6 +11742,35 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' + better-auth@1.6.11(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0))(mongodb@7.2.0(@noble/hashes@2.2.0))(pg@8.20.0)(vitest@4.1.6): + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0)) + '@better-auth/kysely-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) + '@better-auth/memory-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/mongo-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(mongodb@7.2.0(@noble/hashes@2.2.0)) + '@better-auth/prisma-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/telemetry': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + '@noble/ciphers': 2.2.0 + '@noble/hashes': 2.2.0 + better-call: 1.3.5(zod@4.4.3) + defu: 6.1.7 + jose: 6.2.3 + kysely: 0.28.17 + nanostores: 1.3.0 + zod: 4.4.3 + optionalDependencies: + drizzle-kit: 0.31.10 + drizzle-orm: 0.45.2(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0) + mongodb: 7.2.0(@noble/hashes@2.2.0) + pg: 8.20.0 + vitest: 4.1.6(@types/node@25.8.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.9.0)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(terser@5.47.1)(tsx@4.22.1)(yaml@2.9.0)) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + better-call@1.3.5(zod@4.4.3): dependencies: '@better-auth/utils': 0.4.0 @@ -12232,6 +12297,12 @@ snapshots: kysely: 0.28.17 pg: 8.20.0 + drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0): + optionalDependencies: + '@types/pg': 8.20.0 + kysely: 0.28.17 + pg: 8.20.0 + dts-resolver@3.0.0: {} dunder-proto@1.0.1: @@ -14065,8 +14136,6 @@ snapshots: make-error@1.3.6: {} - map-obj@5.0.2: {} - map-obj@6.0.0: {} marked@18.0.3: {} @@ -15109,12 +15178,6 @@ snapshots: slugify@1.6.9: {} - snakecase-keys@9.0.2: - dependencies: - change-case: 5.4.4 - map-obj: 5.0.2 - type-fest: 4.41.0 - socket.io-adapter@2.5.6: dependencies: debug: 4.4.3(supports-color@5.5.0) diff --git a/scripts/check-controller-response-envelope.ts b/scripts/check-controller-response-envelope.ts new file mode 100644 index 00000000000..dec9b10afe0 --- /dev/null +++ b/scripts/check-controller-response-envelope.ts @@ -0,0 +1,93 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import ts from 'typescript' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const modulesDir = join(repoRoot, 'apps/core/src/modules') + +const walk = (dir: string, files: string[] = []) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const file = join(dir, entry.name) + if (entry.isDirectory()) { + walk(file, files) + } else if (entry.isFile() && entry.name.endsWith('controller.ts')) { + files.push(file) + } + } + return files +} + +const propertyName = (property: ts.ObjectLiteralElementLike) => { + if (ts.isShorthandPropertyAssignment(property)) return property.name.text + if (!ts.isPropertyAssignment(property)) return undefined + + const { name } = property + if ( + ts.isIdentifier(name) || + ts.isStringLiteral(name) || + ts.isNumericLiteral(name) + ) { + return name.text + } +} + +const unwrapExpression = (expression: ts.Expression): ts.Expression => { + let current = expression + while (ts.isParenthesizedExpression(current)) current = current.expression + return current +} + +const violations: string[] = [] + +for (const file of walk(modulesDir)) { + const sourceText = readFileSync(file, 'utf8') + const source = ts.createSourceFile( + file, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ) + + const visit = (node: ts.Node) => { + if (!ts.isReturnStatement(node) || !node.expression) { + ts.forEachChild(node, visit) + return + } + + const expression = unwrapExpression(node.expression) + if (!ts.isObjectLiteralExpression(expression)) { + ts.forEachChild(node, visit) + return + } + + if (expression.properties.some(ts.isSpreadAssignment)) { + ts.forEachChild(node, visit) + return + } + + const names = expression.properties.map(propertyName) + const hasDataKey = names.includes('data') + + if (hasDataKey) { + const { line, character } = source.getLineAndCharacterOfPosition( + expression.getStart(source), + ) + violations.push( + `${relative(repoRoot, file)}:${line + 1}:${character + 1} return raw data directly, or use withMeta(data, meta) for response metadata. A literal containing a 'data' key will be double-wrapped by ResponseInterceptorV2.`, + ) + } + + ts.forEachChild(node, visit) + } + + visit(source) +} + +if (violations.length > 0) { + console.error('Controller response envelope returns are not allowed:') + for (const violation of violations) console.error(` ${violation}`) + process.exit(1) +} diff --git a/scripts/workflow/test-docker.sh b/scripts/workflow/test-docker.sh index 8273e5c999a..bb7f7664d6c 100644 --- a/scripts/workflow/test-docker.sh +++ b/scripts/workflow/test-docker.sh @@ -33,7 +33,7 @@ RETRY=0 do_request() { docker ps -a - curl -f -m 10 localhost:2333/api/v2 -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36' + curl -f -m 10 localhost:2333/api/v3/health -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36' } diff --git a/scripts/workflow/test-server.sh b/scripts/workflow/test-server.sh index 3e77a7dfa38..f90fc1a5909 100755 --- a/scripts/workflow/test-server.sh +++ b/scripts/workflow/test-server.sh @@ -30,7 +30,7 @@ fi RETRY=0 do_request() { - curl -f -m 10 localhost:2333/api/v2 -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36' + curl -f -m 10 localhost:2333/api/v3/health -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36' }