feat: 支持会员卡设置
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
} from '@nestjs/common'
|
||||
import type { Request, Response } from 'express'
|
||||
import type { ApiResponse } from '@mp-pilates/shared'
|
||||
import { formatRequestExtras } from '../utils/request-log'
|
||||
|
||||
@Catch()
|
||||
export class ApiExceptionFilter implements ExceptionFilter {
|
||||
@@ -28,15 +29,16 @@ export class ApiExceptionFilter implements ExceptionFilter {
|
||||
? this.extractMessage(exception)
|
||||
: '服务器内部错误'
|
||||
|
||||
// Log all server errors (5xx) with full stack; log 4xx at warn level
|
||||
const extras = formatRequestExtras(request)
|
||||
|
||||
if (status >= 500) {
|
||||
this.logger.error(
|
||||
`${request.method} ${request.originalUrl} → ${String(status)} ${message}`,
|
||||
`${request.method} ${request.originalUrl} → ${String(status)} ${message}${extras}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
)
|
||||
} else if (status >= 400) {
|
||||
this.logger.warn(
|
||||
`${request.method} ${request.originalUrl} → ${String(status)} ${message}`,
|
||||
`${request.method} ${request.originalUrl} → ${String(status)} ${message}${extras}`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,28 +7,7 @@ import {
|
||||
} 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],
|
||||
),
|
||||
)
|
||||
}
|
||||
import { formatRequestExtras } from '../utils/request-log'
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
@@ -44,9 +23,9 @@ export class LoggingInterceptor implements NestInterceptor {
|
||||
next: () => {
|
||||
const res = context.switchToHttp().getResponse<Response>()
|
||||
const duration = Date.now() - start
|
||||
const bodyLog = this.formatBody(method, req.body as Record<string, unknown>)
|
||||
const extras = formatRequestExtras(req)
|
||||
this.logger.log(
|
||||
`${method} ${originalUrl} → ${String(res.statusCode)} (${String(duration)}ms)${bodyLog}`,
|
||||
`${method} ${originalUrl} → ${String(res.statusCode)} (${String(duration)}ms)${extras}`,
|
||||
)
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
@@ -55,22 +34,12 @@ export class LoggingInterceptor implements NestInterceptor {
|
||||
err instanceof Object && 'getStatus' in err
|
||||
? String((err as { getStatus: () => number }).getStatus())
|
||||
: '500'
|
||||
const bodyLog = this.formatBody(method, req.body as Record<string, unknown>)
|
||||
const extras = formatRequestExtras(req)
|
||||
this.logger.error(
|
||||
`${method} ${originalUrl} → ${status} (${String(duration)}ms)${bodyLog}`,
|
||||
`${method} ${originalUrl} → ${status} (${String(duration)}ms)${extras}`,
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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)}`
|
||||
}
|
||||
}
|
||||
|
||||
60
packages/server/src/common/utils/request-log.ts
Normal file
60
packages/server/src/common/utils/request-log.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Request } 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',
|
||||
])
|
||||
|
||||
const BODY_METHODS: ReadonlySet<string> = new Set(['POST', 'PUT', 'PATCH'])
|
||||
|
||||
/** Max characters of JSON-serialised body/query included in a log line. */
|
||||
const MAX_LOG_PAYLOAD = 2048
|
||||
|
||||
function truncate(value: string): string {
|
||||
return value.length > MAX_LOG_PAYLOAD
|
||||
? `${value.slice(0, MAX_LOG_PAYLOAD)}…(truncated)`
|
||||
: value
|
||||
}
|
||||
|
||||
export function sanitizeBody(
|
||||
body: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!body || typeof body !== 'object') return undefined
|
||||
const keys = Object.keys(body)
|
||||
if (keys.length === 0) return undefined
|
||||
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const key of keys) {
|
||||
result[key] = SENSITIVE_FIELDS.has(key) ? '***' : body[key]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a human-readable suffix for a log line:
|
||||
* ` | query={…} body={…}`
|
||||
* Returns an empty string when there is nothing to append.
|
||||
*/
|
||||
export function formatRequestExtras(request: Request): string {
|
||||
const parts: string[] = []
|
||||
|
||||
const query = request.query
|
||||
if (query && Object.keys(query).length > 0) {
|
||||
parts.push(`query=${truncate(JSON.stringify(query))}`)
|
||||
}
|
||||
|
||||
if (BODY_METHODS.has(request.method)) {
|
||||
const sanitized = sanitizeBody(request.body as Record<string, unknown>)
|
||||
if (sanitized) {
|
||||
parts.push(`body=${truncate(JSON.stringify(sanitized))}`)
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? ` | ${parts.join(' ')}` : ''
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { IsDateString, IsInt, IsOptional, IsUUID, Min } from 'class-validator'
|
||||
import { Type } from 'class-transformer'
|
||||
|
||||
export class UpdateUserMembershipDto {
|
||||
@IsUUID()
|
||||
cardTypeId!: string
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
remainingTimes?: number | null
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { MembershipStatus, BookingStatus, UserRole, CardTypeCategory } from '@mp-pilates/shared'
|
||||
import type { PaginatedData, UserProfileResponse, UserStatsResponse } from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { Membership, CardType, Prisma } from '@prisma/client'
|
||||
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
|
||||
|
||||
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
|
||||
@@ -236,7 +235,7 @@ export class UserService {
|
||||
where: { userId },
|
||||
include: { cardType: true },
|
||||
})
|
||||
return membership ? { ...membership, cardType: { ...membership.cardType } } : null
|
||||
return { membership }
|
||||
}
|
||||
|
||||
async updateUserMembership(userId: string, dto: UpdateUserMembershipDto) {
|
||||
@@ -252,38 +251,28 @@ export class UserService {
|
||||
status = MembershipStatus.USED_UP
|
||||
}
|
||||
|
||||
const existing = await this.prisma.membership.findFirst({ where: { userId } })
|
||||
|
||||
let membership: Membership & { cardType: CardType }
|
||||
if (existing) {
|
||||
const updated = await this.prisma.membership.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
cardTypeId: dto.cardTypeId,
|
||||
remainingTimes: dto.remainingTimes ?? null,
|
||||
startDate: new Date(dto.startDate),
|
||||
expireDate: new Date(dto.expireDate),
|
||||
status,
|
||||
},
|
||||
include: { cardType: true },
|
||||
})
|
||||
membership = { ...updated, cardType: { ...updated.cardType } }
|
||||
} else {
|
||||
const created = await this.prisma.membership.create({
|
||||
data: {
|
||||
userId,
|
||||
cardTypeId: dto.cardTypeId,
|
||||
remainingTimes: dto.remainingTimes ?? null,
|
||||
startDate: new Date(dto.startDate),
|
||||
expireDate: new Date(dto.expireDate),
|
||||
status,
|
||||
},
|
||||
include: { cardType: true },
|
||||
})
|
||||
membership = { ...created, cardType: { ...created.cardType } }
|
||||
const data = {
|
||||
cardTypeId: dto.cardTypeId,
|
||||
remainingTimes: dto.remainingTimes ?? null,
|
||||
startDate: new Date(dto.startDate),
|
||||
expireDate: new Date(dto.expireDate),
|
||||
status,
|
||||
}
|
||||
|
||||
return membership
|
||||
const existing = await this.prisma.membership.findFirst({ where: { userId } })
|
||||
|
||||
if (existing) {
|
||||
return this.prisma.membership.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
include: { cardType: true },
|
||||
})
|
||||
}
|
||||
|
||||
return this.prisma.membership.create({
|
||||
data: { userId, ...data },
|
||||
include: { cardType: true },
|
||||
})
|
||||
}
|
||||
|
||||
async deleteUserMembership(userId: string): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user