feat: 支持一键发布服务端
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
68
packages/server/src/common/filters/api-exception.filter.ts
Normal file
68
packages/server/src/common/filters/api-exception.filter.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
}
|
||||
70
packages/server/src/common/logger/logger.config.ts
Normal file
70
packages/server/src/common/logger/logger.config.ts
Normal 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(),
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user