Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
5543aa4
refactor(api): V2 response envelope, snake_case schema, named views
Innei May 20, 2026
5431e1f
fix(api-client): sync dependency specs with lockfile
Innei May 21, 2026
c1c7337
refactor(api): drop no-op ResponseV2 controller decorator
Innei May 21, 2026
a5cf655
fix(ci): update v2 response test expectations
Innei May 21, 2026
a914965
fix(ci): sync remaining v2 test expectations
Innei May 21, 2026
2d658fd
fix(ci): unwrap comment contract list responses
Innei May 21, 2026
8bf61f2
chore(release): bump @mx-space/api-client to v5.0.2-next.0
Innei May 21, 2026
de846e4
fix(api-client): restore HTTPClient augmentations across bundling
Innei May 21, 2026
d96c780
fix(api-client): hoist HTTPClient augmentations from dts chunks to ro…
Innei May 21, 2026
ad8d5d4
fix(api): always emit translation meta on article detail endpoints
Innei May 21, 2026
9b2363f
fix(api-client): legacy adapter rewrite for full V1 wire parity
Innei May 21, 2026
7b0a39c
refactor(api): unify error system on AppErrorCode, replace PagerDto w…
Innei May 21, 2026
ac83cd8
fix(owner): use English literal for mocked owner name
Innei May 21, 2026
0d590c2
fix(api-client): preserve legacy adapter pagination flags
Innei May 21, 2026
7097628
fix(api): smoke-test driven server fixes for v2 API migration
Innei May 21, 2026
7baf566
chore(release): bump @mx-space/api-client to v5.0.2-next.4
Innei May 21, 2026
af70014
chore(release): bump @mx-space/api-client to v5.0.2-next.5
Innei May 21, 2026
8c829b6
fix(api-client): restore V2 pagination aliases via legacy adapter
Innei May 21, 2026
3361683
chore(release): bump @mx-space/api-client to v5.0.2-next.7
Innei May 21, 2026
1453a96
feat(cli): add V2/V3 envelope compatibility layer
Innei May 21, 2026
d997363
refactor(api): camelCase response meta schemas end-to-end
Innei May 21, 2026
77ce8dd
chore(core): translate in-source Chinese strings and comments to English
Innei May 21, 2026
2180f5b
refactor(core): drop redundant snake_case mappings in controllers
Innei May 22, 2026
ed2b8bc
chore(cli): clarify default title in README for create payloads
Innei May 22, 2026
9c240fa
refactor(core): extract collectArticleTranslations helper for list-tr…
Innei May 22, 2026
788cd79
refactor(core): regroup response/ files into common/ type dirs
Innei May 22, 2026
f159c71
fix(api-client): unwrap V3 envelope in legacy adapter when consumer p…
Innei May 22, 2026
e4fe49c
chore(release): bump @mx-space/api-client to v5.0.2-next.8
Innei May 22, 2026
c29d543
docs(spec): add translation in-place overwrite design
Innei May 22, 2026
aa63e31
docs(spec): revise translation in-place overwrite spec per codex review
Innei May 22, 2026
62555ab
docs(spec): apply codex round-2 fixes to translation in-place spec
Innei May 22, 2026
553f91e
refactor(api): slim ArticleTranslationSchema and EntryTranslationSchema
Innei May 22, 2026
c6eb940
refactor(core): slim translation meta builder and add in-place helpers
Innei May 22, 2026
d17bde5
fix(api-client): slim legacy translationMeta + nested-array flatten r…
Innei May 22, 2026
73e7ae6
refactor(core): in-place translation overwrite for note + aggregate
Innei May 22, 2026
3ad65d6
refactor(core): in-place translation overwrite for remaining controllers
Innei May 22, 2026
28278a6
fix(core): wire AiModule and EntryMaps types post-controller-migration
Innei May 22, 2026
133631e
fix(cli): read translation source_lang and translated from meta envelope
Innei May 22, 2026
485601c
refactor(core): fold RawResponse into HTTPDecorators namespace
Innei May 22, 2026
11b3a45
refactor(core): inline await-then-return in controllers
Innei May 22, 2026
72b09e8
refactor(core): inline remaining await-then-return in controllers
Innei May 22, 2026
62d3162
refactor(core): normalize request keys to camelCase before zod
Innei May 22, 2026
2c43fd0
refactor(core): move case normalization pipe to common/pipes
Innei May 22, 2026
c618198
fix(core): accept legacy 1/-1 sortOrder on the wire
Innei May 22, 2026
69b866e
fix(api-client): legacy adapter wraps bare-list and aggregate/timelin…
Innei May 22, 2026
8b92837
feat(api-client): rewrite legacy sortBy=created/modified to V3 *At names
Innei May 22, 2026
fd594e1
feat(core): honor x-skip-translation header in request-context middle…
Innei May 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
41 changes: 35 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<value>` for a bare envelope, or `withMeta(<value>, 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 `<resource>.views.ts` and parse through it before returning.

## Testing

Expand Down
1 change: 0 additions & 1 deletion apps/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/core/src/app.config.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions apps/core/src/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = {
Expand Down
5 changes: 2 additions & 3 deletions apps/core/src/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -30,7 +30,6 @@ export class AppController {

@Get('/uptime')
@HttpCache.disable
@HTTPDecorators.Bypass
async getUptime() {
const ts = (process.uptime() * 1000) | 0
return {
Expand Down Expand Up @@ -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)
}
Expand Down
18 changes: 4 additions & 14 deletions apps/core/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,15 @@ 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'
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'
Expand Down Expand Up @@ -152,25 +150,17 @@ 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,
},

{
provide: APP_FILTER,
useClass: AllExceptionsFilter,
useClass: AppExceptionFilter,
},
{
provide: APP_GUARD,
Expand Down
10 changes: 7 additions & 3 deletions apps/core/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -37,15 +38,15 @@ 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 = {
credentials: true,
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
Expand Down Expand Up @@ -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)))

Expand Down
7 changes: 4 additions & 3 deletions apps/core/src/common/adapters/fastify.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -43,15 +44,15 @@ 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)) {
const isMxSpaceClient = ua?.match('mx-space')
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',
)
Expand Down
9 changes: 6 additions & 3 deletions apps/core/src/common/controllers/base-task.controller.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
}
Expand Down
20 changes: 20 additions & 0 deletions apps/core/src/common/decorators/bypass-case-transform.decorator.ts
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 6 additions & 3 deletions apps/core/src/common/decorators/http.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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,
}
14 changes: 0 additions & 14 deletions apps/core/src/common/decorators/translate-fields.decorator.ts

This file was deleted.

Loading
Loading