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

@@ -1,19 +0,0 @@
# Database
DATABASE_URL=postgresql://user:pass@localhost:5432/mp_pilates
# JWT
JWT_SECRET=change-me-to-a-secure-random-string
# WeChat Mini Program
WX_APPID=your-appid
WX_SECRET=your-secret
# WeChat Pay
WX_MCH_ID=your-mch-id
WX_MCH_KEY=your-mch-key
WX_MCH_SERIAL_NO=your-serial-no
WX_MCH_CERT_PATH=./certs/apiclient_cert.pem
WX_MCH_KEY_PATH=./certs/apiclient_key.pem
# Server
PORT=3000

View File

@@ -3,6 +3,7 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"tsConfigPath": "tsconfig.build.json"
}
}

View File

@@ -27,10 +27,13 @@
"@prisma/client": "^5.19.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"nest-winston": "^1.10.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
@@ -48,13 +51,21 @@
"typescript": "^5.4.0"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s", "!**/*.module.ts", "!main.ts"],
"collectCoverageFrom": [
"**/*.(t|j)s",
"!**/*.module.ts",
"!main.ts"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {

View File

@@ -3,7 +3,7 @@ generator client {
}
datasource db {
provider = "postgresql"
provider = "mysql"
url = env("DATABASE_URL")
}

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()

View File

@@ -1,7 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist"
"outDir": "dist",
"rootDir": "src",
"paths": {},
"incremental": false
},
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}

File diff suppressed because one or more lines are too long