feat: 支持一键发布服务端

This commit is contained in:
richarjiang
2026-04-04 15:39:26 +08:00
parent 2b3b636c54
commit 982e569fa3
45 changed files with 852 additions and 34 deletions

View File

@@ -36,7 +36,7 @@ export class AuthController {
@Body() bindPhoneDto: BindPhoneDto,
): Promise<User> {
return this.authService.bindPhone(
req.user.userId,
req.user.sub,
bindPhoneDto.encryptedData,
bindPhoneDto.iv,
)

View File

@@ -6,7 +6,7 @@ import { UserRole } from '@mp-pilates/shared'
import { JwtPayload } from './auth.service'
export interface AuthenticatedUser {
userId: string
sub: string
role: UserRole
}
@@ -20,9 +20,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
})
}
validate(payload: JwtPayload): AuthenticatedUser {
validate(payload: JwtPayload): { sub: string; role: UserRole } {
return {
userId: payload.sub,
sub: payload.sub,
role: payload.role,
}
}

View File

@@ -0,0 +1,68 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common'
import type { Request, Response } from 'express'
import type { ApiResponse } from '@mp-pilates/shared'
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(ApiExceptionFilter.name)
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp()
const request = ctx.getRequest<Request>()
const response = ctx.getResponse<Response>()
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR
const message =
exception instanceof HttpException
? this.extractMessage(exception)
: '服务器内部错误'
// Log all server errors (5xx) with full stack; log 4xx at warn level
if (status >= 500) {
this.logger.error(
`${request.method} ${request.originalUrl}${String(status)} ${message}`,
exception instanceof Error ? exception.stack : undefined,
)
} else if (status >= 400) {
this.logger.warn(
`${request.method} ${request.originalUrl}${String(status)} ${message}`,
)
}
const body: ApiResponse<null> = {
success: false,
data: null,
message,
}
response.status(status).json(body)
}
private extractMessage(exception: HttpException): string {
const response = exception.getResponse()
if (typeof response === 'string') {
return response
}
if (typeof response === 'object' && response !== null) {
const res = response as Record<string, unknown>
if (typeof res.message === 'string') {
return res.message
}
if (Array.isArray(res.message)) {
return res.message.join('; ')
}
}
return exception.message
}
}

View File

@@ -0,0 +1,26 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common'
import { Observable, map } from 'rxjs'
import type { ApiResponse } from '@mp-pilates/shared'
@Injectable()
export class ApiResponseInterceptor<T>
implements NestInterceptor<T, ApiResponse<T>>
{
intercept(
_context: ExecutionContext,
next: CallHandler<T>,
): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({
success: true,
data: data ?? null,
message: null,
})),
)
}
}

View File

@@ -0,0 +1,76 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common'
import { Observable, tap } from 'rxjs'
import type { Request, Response } from 'express'
/** Fields stripped from logged request bodies to avoid leaking secrets. */
const SENSITIVE_FIELDS: ReadonlySet<string> = new Set([
'password',
'token',
'secret',
'code',
'sessionKey',
'encryptedData',
'iv',
])
function sanitizeBody(
body: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!body || typeof body !== 'object') return undefined
return Object.fromEntries(
Object.entries(body).map(([key, value]) =>
SENSITIVE_FIELDS.has(key) ? [key, '***'] : [key, value],
),
)
}
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP')
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest<Request>()
const { method, originalUrl } = req
const start = Date.now()
return next.handle().pipe(
tap({
next: () => {
const res = context.switchToHttp().getResponse<Response>()
const duration = Date.now() - start
const bodyLog = this.formatBody(method, req.body as Record<string, unknown>)
this.logger.log(
`${method} ${originalUrl}${String(res.statusCode)} (${String(duration)}ms)${bodyLog}`,
)
},
error: (err: unknown) => {
const duration = Date.now() - start
const status =
err instanceof Object && 'getStatus' in err
? String((err as { getStatus: () => number }).getStatus())
: '500'
const bodyLog = this.formatBody(method, req.body as Record<string, unknown>)
this.logger.error(
`${method} ${originalUrl}${status} (${String(duration)}ms)${bodyLog}`,
)
},
}),
)
}
private formatBody(
method: string,
body: Record<string, unknown> | undefined,
): string {
if (!['POST', 'PUT', 'PATCH'].includes(method)) return ''
const sanitized = sanitizeBody(body)
if (!sanitized || Object.keys(sanitized).length === 0) return ''
return ` body=${JSON.stringify(sanitized)}`
}
}

View File

@@ -0,0 +1,70 @@
import * as winston from 'winston'
import 'winston-daily-rotate-file'
import type { WinstonModuleOptions } from 'nest-winston'
const { combine, timestamp, printf, colorize, errors } = winston.format
/** Shared log line format: `[timestamp] [LEVEL] [context] message` */
const logPrint = printf(({ timestamp, level, context, message, stack }) => {
const ctx = context ? `[${context}] ` : ''
const msg = stack ?? message
return `${timestamp as string} [${level}] ${ctx}${msg as string}`
})
function buildTransports(): winston.transport[] {
const transports: winston.transport[] = []
const isProduction = process.env.NODE_ENV === 'production'
// Console — always enabled; colorized in dev, plain in prod
transports.push(
new winston.transports.Console({
format: combine(
...(isProduction ? [] : [colorize({ all: true })]),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
errors({ stack: true }),
logPrint,
),
}),
)
// File transports — always enabled so logs persist even in dev
// App log: all levels, 14-day retention, 20 MB max per file
transports.push(
new winston.transports.DailyRotateFile({
dirname: 'logs',
filename: 'app-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxSize: '20m',
maxFiles: '14d',
format: combine(
timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }),
errors({ stack: true }),
logPrint,
),
}),
)
// Error log: error-level only, 30-day retention
transports.push(
new winston.transports.DailyRotateFile({
dirname: 'logs',
filename: 'error-%DATE%.log',
datePattern: 'YYYY-MM-DD',
level: 'error',
maxSize: '20m',
maxFiles: '30d',
format: combine(
timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }),
errors({ stack: true }),
logPrint,
),
}),
)
return transports
}
export const loggerConfig: WinstonModuleOptions = {
transports: buildTransports(),
}

View File

@@ -1,10 +1,17 @@
import { NestFactory } from '@nestjs/core'
import { ValidationPipe } from '@nestjs/common'
import { Logger, ValidationPipe } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { WinstonModule } from 'nest-winston'
import { AppModule } from './app.module'
import { loggerConfig } from './common/logger/logger.config'
import { ApiResponseInterceptor } from './common/interceptors/api-response.interceptor'
import { LoggingInterceptor } from './common/interceptors/logging.interceptor'
import { ApiExceptionFilter } from './common/filters/api-exception.filter'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
const logger = WinstonModule.createLogger(loggerConfig)
const app = await NestFactory.create(AppModule, { logger })
const configService = app.get(ConfigService)
app.setGlobalPrefix('api')
@@ -15,10 +22,15 @@ async function bootstrap() {
transform: true,
}),
)
app.useGlobalInterceptors(
new LoggingInterceptor(),
new ApiResponseInterceptor(),
)
app.useGlobalFilters(new ApiExceptionFilter())
app.enableCors()
const port = configService.get<number>('PORT', 3000)
await app.listen(port)
console.log(`Server running on http://localhost:${port}`)
new Logger('Bootstrap').log(`Server running on http://localhost:${port}`)
}
bootstrap()