From 23bdd05811355fba5a44e1717efcf5d7c54ec43f Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 7 Apr 2026 16:47:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E5=8D=A1=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/app/src/pages/admin/members.vue | 869 +++++++++++------- packages/app/src/pages/profile/membership.vue | 28 +- packages/app/src/utils/format.ts | 35 +- .../common/filters/api-exception.filter.ts | 8 +- .../interceptors/logging.interceptor.ts | 41 +- .../server/src/common/utils/request-log.ts | 60 ++ .../user/dto/update-user-membership.dto.ts | 2 + packages/server/src/user/user.service.ts | 53 +- 8 files changed, 667 insertions(+), 429 deletions(-) create mode 100644 packages/server/src/common/utils/request-log.ts diff --git a/packages/app/src/pages/admin/members.vue b/packages/app/src/pages/admin/members.vue index beaa605..3532ea9 100644 --- a/packages/app/src/pages/admin/members.vue +++ b/packages/app/src/pages/admin/members.vue @@ -88,10 +88,11 @@ - - - - + + + + + × @@ -104,172 +105,204 @@ :class="{ 'tab-item--active': activeTab === 'detail' }" @tap="switchTab('detail')" > - 详情 + 详情 - 编辑 + 编辑 - - - - - - - - {{ (detailMember.nickname || '?').slice(0, 1) }} - - - {{ detailMember.nickname || '未知用户' }} - - {{ detailMember.openid }} - - {{ detailMember.phone || '未绑定手机' }} - + + - - - - {{ detailMember.totalBookings }} - 总预约 - - - {{ detailMember.completedBookings }} - 已完成 - - - {{ detailMember.cancelledBookings }} - 已取消 - - - - - - - {{ detailMembership.cardTypeName }} - - {{ statusLabel(detailMembership.status) }} - - - - - 剩余次数 - {{ detailMembership.remainingTimes ?? '不限' }} - - - 开始日期 - {{ formatDate(detailMembership.startDate) }} - - - 到期日期 - {{ formatDate(detailMembership.expireDate) }} - - - - - - - 暂无会员卡 - - 去开卡 - - - - - - - 解除会员卡 - - - - - - - - - 加载中... - - - - - - - 卡类型 - - - {{ editCardTypes[editForm.cardTypeIndex]?.name || '请选择' }} - + + + + + + + + {{ (detailMember.nickname || '?').slice(0, 1) }} - + + - - - 剩余次数 - + + + + {{ detailMember.totalBookings }} + 总预约 + + + {{ detailMember.completedBookings }} + 已完成 + + + {{ detailMember.cancelledBookings }} + 已取消 + - - - 开始日期 - - - {{ editForm.startDate || '请选择' }} - + + + + + + {{ detailMembership.cardType?.name || '未知卡类型' }} + + {{ getCardTypeLabel(detailMembership.cardType?.type) }} + - + + {{ statusLabel(detailMembership.status) }} + + + + + + 剩余课时 + + {{ detailMembership.remainingTimes }} + + + + + + + + + + 已使用 {{ getMembershipUsedTimes(detailMembership) }} / {{ detailMembership.cardType.totalTimes }} 次 + + + + + 开始日期 + {{ formatDate(detailMembership.startDate) }} + + + 到期日期 + {{ formatDate(detailMembership.expireDate) }} + + + + + 解除会员卡 + - - - 到期日期 - - - {{ editForm.expireDate || '请选择' }} - - - + + + 💳 + 暂无会员卡 + 为该用户开通会员卡 + + 去开卡 + - - - - {{ editSubmitting ? '保存中...' : '保存' }} + + + + + 加载中... + + + + + + + + {{ detailMembership ? '编辑会员卡' : '开通会员卡' }} + + + + + 卡类型 + + + {{ editCardTypes[editForm.cardTypeIndex]?.name || '请选择' }} + + + + + + + + 剩余次数 + + + + + + 开始日期 + + + {{ editForm.startDate || '请选择' }} + + + + + + + + 到期日期 + + + {{ editForm.expireDate || '请选择' }} + + + + + + + + + {{ editSubmitting ? '保存中...' : '保存' }} + + - + + @@ -280,8 +313,11 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' import { onReachBottom } from '@dcloudio/uni-app' import CustomNavBar from '../../components/CustomNavBar.vue' import { getSystemLayout } from '../../utils/system' +import { getCardTypeLabel, getCardGradientClass, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format' import { useAdminStore } from '../../stores/admin' -import type { MemberSummary } from '../../stores/admin' +import type { MemberSummary, UserMembership } from '../../stores/admin' +import type { CardType } from '@mp-pilates/shared' +import { CardTypeCategory } from '@mp-pilates/shared' const adminStore = useAdminStore() @@ -300,10 +336,12 @@ const showDetail = ref(false) const detailMember = ref(null) const activeTab = ref<'detail' | 'edit'>('detail') -const detailMembership = ref(null) +type DetailMembership = NonNullable + +const detailMembership = ref(null) const editLoading = ref(false) const editSubmitting = ref(false) -const editCardTypes = ref([]) +const editCardTypes = ref([]) const formReady = ref(false) const editForm = ref({ @@ -533,9 +571,12 @@ async function onSaveMembership() { if (!userId) return editSubmitting.value = true try { + const remainingTimes = isTimeBasedCard.value + ? Number(editForm.value.remainingTimes) || 0 + : null await adminStore.updateUserMembership(userId, { cardTypeId: editForm.value.cardTypeId, - remainingTimes: editForm.value.remainingTimes, + remainingTimes, startDate: editForm.value.startDate, expireDate: editForm.value.expireDate, }) @@ -820,7 +861,7 @@ onMounted(() => loadMembers(true)) .list-footer-text { font-size: 24rpx; color: $text-hint; } -/* ── Detail modal ────────────────────────── */ +/* ── Modal shell ─────────────────────────── */ .modal-mask { position: fixed; inset: 0; @@ -832,73 +873,33 @@ onMounted(() => loadMembers(true)) .modal { width: 100%; + max-height: 80vh; background: $bg-card; border-radius: $radius-lg $radius-lg 0 0; - padding: 48rpx 32rpx 60rpx; -} - -.detail-header { display: flex; flex-direction: column; - align-items: center; - gap: 12rpx; - margin-bottom: 40rpx; -} - -.detail-avatar { - width: 120rpx; - height: 120rpx; - border-radius: 50%; overflow: hidden; - margin-bottom: 8rpx; } -.detail-name { font-size: 32rpx; font-weight: 700; color: $brand-color; } - -.detail-openid { - font-size: 22rpx; - color: $accent-color; - font-family: Menlo, Monaco, Consolas, monospace; - padding: 6rpx 16rpx; - background: rgba($accent-color, 0.08); - border-radius: 8rpx; -} - -.detail-phone { font-size: 26rpx; color: $text-secondary; } - -.detail-stats { - display: flex; - justify-content: space-around; - background: $bg-page; - border-radius: $radius-md; - padding: 28rpx; - margin-bottom: 32rpx; -} - -.detail-stat { display: flex; flex-direction: column; align-items: center; gap: 8rpx; } -.detail-stat-value { font-size: 40rpx; font-weight: 800; color: $accent-color; } -.detail-stat-label { font-size: 22rpx; color: $text-hint; } - -.modal-close { - width: 100%; - height: 88rpx; - background: $bg-page; - border-radius: 44rpx; +.modal-handle { display: flex; align-items: center; justify-content: center; + padding: 20rpx 32rpx 8rpx; + position: relative; } -.modal-close-text { font-size: 28rpx; color: $text-secondary; } - -/* ── Modal header ─────────────────────────── */ -.modal-header { - display: flex; - justify-content: flex-end; - margin-bottom: 24rpx; +.modal-handle-bar { + width: 64rpx; + height: 6rpx; + border-radius: 3rpx; + background: $border-color; } .modal-close-btn { + position: absolute; + right: 24rpx; + top: 14rpx; width: 48rpx; height: 48rpx; display: flex; @@ -909,48 +910,343 @@ onMounted(() => loadMembers(true)) } .modal-close-icon { - font-size: 36rpx; + font-size: 32rpx; color: $text-secondary; line-height: 1; } -/* ── Tab bar ──────────────────────────────── */ +/* ── Tab bar ─────────────────────────────── */ .tab-bar { display: flex; - gap: 16rpx; - margin-bottom: 32rpx; + margin: 16rpx 32rpx 0; + background: $bg-page; + border-radius: $radius-md; + padding: 6rpx; } .tab-item { flex: 1; - height: 80rpx; + height: 68rpx; display: flex; align-items: center; justify-content: center; - background: $bg-page; - border-radius: $radius-md; + border-radius: 12rpx; + transition: background 0.2s; } .tab-item--active { - background: $brand-color; + background: $bg-card; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06); } -.tab-text { - font-size: 28rpx; - color: $text-secondary; +.tab-label { + font-size: 26rpx; + color: $text-hint; font-weight: 500; } -.tab-text--active { - color: $accent-color; +.tab-label--active { + color: $brand-color; font-weight: 700; } -/* ── Tab content ──────────────────────────── */ -.tab-content { - min-height: 400rpx; +/* ── Scrollable content ──────────────────── */ +.modal-scroll { + flex: 1; + overflow: hidden; } +.tab-content { + padding: 24rpx 32rpx calc(32rpx + env(safe-area-inset-bottom)); +} + +/* ── User card (horizontal) ──────────────── */ +.user-card { + display: flex; + align-items: center; + gap: 24rpx; + margin-bottom: 24rpx; +} + +.user-avatar { + width: 100rpx; + height: 100rpx; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; +} + +.user-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 6rpx; + min-width: 0; +} + +.user-name { + font-size: 32rpx; + font-weight: 700; + color: $brand-color; +} + +.user-openid { + font-size: 20rpx; + color: $accent-color; + font-family: Menlo, Monaco, Consolas, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-phone { + font-size: 24rpx; + color: $text-hint; +} + +/* ── Stats grid ──────────────────────────── */ +.stats-grid { + display: flex; + background: $bg-page; + border-radius: $radius-md; + padding: 24rpx 0; + margin-bottom: 24rpx; +} + +.stats-cell { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 6rpx; + position: relative; + + & + & { + border-left: 1rpx solid $border-color; + } +} + +.stats-cell-value { + font-size: 40rpx; + font-weight: 800; + color: $accent-color; + line-height: 1.1; +} + +.stats-cell-label { + font-size: 22rpx; + color: $text-hint; +} + +/* ── Membership card (gradient) ──────────── */ +.mship-card { + background: $bg-card; + border-radius: 20rpx; + overflow: hidden; + box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.07); + margin-bottom: 24rpx; +} + +.mship-strip { + height: 6rpx; +} + +.mship-header { + padding: 22rpx 28rpx; + display: flex; + align-items: center; + justify-content: space-between; +} + +.gradient--times { + &.mship-strip, + &.mship-header { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); } +} + +.gradient--duration { + &.mship-strip, + &.mship-header { background: linear-gradient(90deg, #6c3483, #9b59b6); } +} + +.gradient--trial { + &.mship-strip, + &.mship-header { background: linear-gradient(90deg, #5a7a8a, $primary-dark); } +} + +.mship-header-left { + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.mship-name { + font-size: 30rpx; + font-weight: 700; + color: #fff; +} + +.mship-type-badge { + align-self: flex-start; + padding: 4rpx 14rpx; + border-radius: 12rpx; + background: rgba(255, 255, 255, 0.15); + border: 1rpx solid rgba(255, 255, 255, 0.25); +} + +.mship-type-badge-text { + font-size: 20rpx; + color: rgba(255, 255, 255, 0.85); + font-weight: 500; +} + +.mship-status-badge { + padding: 8rpx 20rpx; + border-radius: 20rpx; + border: 1rpx solid rgba(255, 255, 255, 0.35); + flex-shrink: 0; +} + +.mship-status--active { background: rgba(76, 175, 80, 0.3); } +.mship-status--expired { background: rgba(0, 0, 0, 0.2); } +.mship-status--used_up { background: rgba(0, 0, 0, 0.2); } + +.mship-status-text { + font-size: 22rpx; + color: #fff; + font-weight: 600; +} + +.mship-body { + padding: 20rpx 28rpx 24rpx; + display: flex; + flex-direction: column; + gap: 10rpx; +} + +.mship-highlight-row { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 4rpx; +} + +.mship-highlight-label { + font-size: 26rpx; + color: $text-hint; +} + +.mship-highlight-value-wrap { + display: flex; + align-items: baseline; +} + +.mship-highlight-number { + font-size: 44rpx; + font-weight: 800; + color: $accent-color; + line-height: 1; +} + +.mship-highlight-unit { + font-size: 22rpx; + color: $accent-color; + font-weight: 500; +} + +.mship-progress-wrap { + display: flex; + flex-direction: column; + gap: 8rpx; + margin-bottom: 6rpx; +} + +.mship-progress-bar { + height: 8rpx; + background: #f0f0f0; + border-radius: 4rpx; + overflow: hidden; +} + +.mship-progress-fill { + height: 100%; + background: linear-gradient(90deg, $accent-color, $primary-color); + border-radius: 4rpx; + transition: width 0.4s ease; +} + +.mship-progress-label { + font-size: 22rpx; + color: $text-hint; + text-align: right; +} + +.mship-info-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.mship-info-label { + font-size: 26rpx; + color: $text-hint; +} + +.mship-info-value { + font-size: 26rpx; + color: $text-primary; + font-weight: 500; +} + +.mship-footer { + padding: 16rpx 28rpx 20rpx; + display: flex; + justify-content: flex-end; +} + +.mship-remove-link { + font-size: 24rpx; + color: $error-color; +} + +/* ── No membership ───────────────────────── */ +.no-mship { + display: flex; + flex-direction: column; + align-items: center; + gap: 16rpx; + padding: 48rpx 0 24rpx; +} + +.no-mship-icon { + font-size: 80rpx; +} + +.no-mship-title { + font-size: 30rpx; + font-weight: 600; + color: $text-primary; +} + +.no-mship-sub { + font-size: 26rpx; + color: $text-hint; +} + +.no-mship-btn { + margin-top: 12rpx; + padding: 20rpx 56rpx; + border-radius: 44rpx; + background: $accent-color; + box-shadow: 0 4rpx 16rpx rgba(123, 165, 190, 0.35); +} + +.no-mship-btn-text { + font-size: 28rpx; + font-weight: 600; + color: #fff; +} + +/* ── Edit loading ────────────────────────── */ .edit-loading { display: flex; align-items: center; @@ -963,121 +1259,28 @@ onMounted(() => loadMembers(true)) color: $text-hint; } -/* ── Membership card ──────────────────────── */ -.membership-card { - background: $bg-page; - border-radius: $radius-md; - padding: 28rpx; +/* ── Section header ──────────────────────── */ +.section-header { + display: flex; + align-items: center; + gap: 12rpx; margin-bottom: 24rpx; } -.membership-card-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 20rpx; +.section-dot { + width: 6rpx; + height: 28rpx; + border-radius: 3rpx; + background: $accent-color; + flex-shrink: 0; } -.membership-card-name { - font-size: 32rpx; +.section-title { + font-size: 30rpx; font-weight: 700; color: $brand-color; } -.membership-card-badge { - font-size: 22rpx; - font-weight: 600; - padding: 4rpx 16rpx; - border-radius: 20rpx; -} - -.badge--active { - color: #52c41a; - background: rgba(#52c41a, 0.1); -} - -.badge--expired { - color: #999; - background: rgba(#999, 0.1); -} - -.badge--used_up { - color: #e64329; - background: rgba(#e64329, 0.1); -} - -.membership-card-info { - display: flex; - flex-direction: column; - gap: 12rpx; -} - -.membership-info-item { - display: flex; - justify-content: space-between; - align-items: center; -} - -.membership-info-label { - font-size: 26rpx; - color: $text-hint; -} - -.membership-info-value { - font-size: 26rpx; - color: $text-primary; - font-weight: 600; -} - -/* ── No membership ────────────────────────── */ -.no-membership { - display: flex; - flex-direction: column; - align-items: center; - gap: 24rpx; - padding: 48rpx 0; -} - -.no-membership-text { - font-size: 28rpx; - color: $text-hint; -} - -.no-membership-btn { - background: $brand-color; - border-radius: $radius-md; - padding: 20rpx 48rpx; -} - -.no-membership-btn-text { - font-size: 28rpx; - font-weight: 600; - color: $accent-color; -} - -/* ── Danger zone ─────────────────────────── */ -.danger-zone { - margin-top: 32rpx; - padding-top: 32rpx; - border-top: 1rpx solid $border-color; -} - -.danger-btn { - width: 100%; - height: 88rpx; - display: flex; - align-items: center; - justify-content: center; - background: rgba(#e64329, 0.08); - border-radius: $radius-md; -} - -.danger-btn-text { - font-size: 28rpx; - color: #e64329; - font-weight: 600; -} - /* ── Edit form ───────────────────────────── */ .edit-form { display: flex; @@ -1132,19 +1335,24 @@ onMounted(() => loadMembers(true)) color: $text-hint; } -/* ── Edit submit ──────────────────────────── */ +/* ── Submit button ───────────────────────── */ .edit-submit { - margin-top: 32rpx; + margin-top: 16rpx; } .submit-btn { width: 100%; height: 88rpx; - background: $brand-color; border-radius: 44rpx; + background: linear-gradient(90deg, #1a1a2e, #2d2d5e); display: flex; align-items: center; justify-content: center; + box-shadow: 0 4rpx 16rpx rgba(26, 26, 46, 0.3); + + &:active { + opacity: 0.85; + } } .submit-btn--disabled { @@ -1154,6 +1362,7 @@ onMounted(() => loadMembers(true)) .submit-btn-text { font-size: 28rpx; font-weight: 600; - color: $accent-color; + color: $primary-dark; + letter-spacing: 1rpx; } diff --git a/packages/app/src/pages/profile/membership.vue b/packages/app/src/pages/profile/membership.vue index af14a77..0c746f2 100644 --- a/packages/app/src/pages/profile/membership.vue +++ b/packages/app/src/pages/profile/membership.vue @@ -47,7 +47,7 @@ {{ m.cardType.name }} - {{ typeLabel(m.cardType.type) }} + {{ getCardTypeLabel(m.cardType.type) }} @@ -70,11 +70,11 @@ - 已使用 {{ usedTimes(m) }} / {{ m.cardType.totalTimes }} 次 + 已使用 {{ getMembershipUsedTimes(m) }} / {{ m.cardType.totalTimes }} 次 @@ -110,7 +110,7 @@ {{ m.cardType.name }} - {{ typeLabel(m.cardType.type) }} + {{ getCardTypeLabel(m.cardType.type) }} @@ -148,6 +148,7 @@ import type { MembershipWithCardType } from '@mp-pilates/shared' import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared' import { useUserStore } from '../../stores/user' import { getSystemLayout } from '../../utils/system' +import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format' import CustomNavBar from '../../components/CustomNavBar.vue' const userStore = useUserStore() @@ -170,14 +171,6 @@ const inactiveMemberships = computed(() => ) // ─── Helpers ────────────────────────────────────────────── -function typeLabel(type: CardTypeCategory): string { - const map: Record = { - [CardTypeCategory.TIMES]: '次卡', - [CardTypeCategory.DURATION]: '月卡', - [CardTypeCategory.TRIAL]: '体验卡', - } - return map[type] ?? '会员卡' -} function statusLabel(status: MembershipStatus): string { const map: Record = { @@ -206,17 +199,6 @@ function headerClass(type: CardTypeCategory): string { return 'card-header--times' } -function progressWidth(m: MembershipWithCardType): string { - if (m.remainingTimes === null || !m.cardType.totalTimes) return '0%' - const pct = (m.remainingTimes / m.cardType.totalTimes) * 100 - return `${Math.max(0, Math.min(100, pct))}%` -} - -function usedTimes(m: MembershipWithCardType): number { - if (m.remainingTimes === null || !m.cardType.totalTimes) return 0 - return m.cardType.totalTimes - m.remainingTimes -} - // ─── Data loading ───────────────────────────────────────── async function loadMemberships() { loading.value = true diff --git a/packages/app/src/utils/format.ts b/packages/app/src/utils/format.ts index fed50a4..6633b24 100644 --- a/packages/app/src/utils/format.ts +++ b/packages/app/src/utils/format.ts @@ -1,6 +1,11 @@ -import type { CardType } from '@mp-pilates/shared' import { CardTypeCategory } from '@mp-pilates/shared' +/** Minimal membership shape needed by progress/usage helpers. */ +interface MembershipLike { + readonly remainingTimes: number | null + readonly cardType: { readonly totalTimes: number | null } +} + /** 格式化金额:分 → 元 */ export function formatPrice(cents: number): string { return (cents / 100).toFixed(2) @@ -49,13 +54,13 @@ export function getDateRange(days: number): ReadonlyArray<{ readonly date: strin } /** 会员卡类型标签 */ -export function getCardTypeLabel(type: CardTypeCategory): string { - const map: Record = { +export function getCardTypeLabel(type: CardTypeCategory | string): string { + const map: Record = { [CardTypeCategory.TIMES]: '次卡', [CardTypeCategory.DURATION]: '月卡', - [CardTypeCategory.TRIAL]: '体验', + [CardTypeCategory.TRIAL]: '体验卡', } - return map[type] ?? '会员' + return map[type] ?? '会员卡' } /** 会员卡封面 CSS 类名 */ @@ -70,3 +75,23 @@ export function isSlotPast(date: string, startTime: string): boolean { const slotDateTime = new Date(`${date}T${startTime}:00`) return new Date() > slotDateTime } + +/** 会员卡渐变 CSS 类名前缀 */ +export function getCardGradientClass(type: CardTypeCategory | string): string { + if (type === CardTypeCategory.DURATION) return 'gradient--duration' + if (type === CardTypeCategory.TRIAL) return 'gradient--trial' + return 'gradient--times' +} + +/** 会员卡进度百分比(剩余 / 总次数) */ +export function getMembershipProgressWidth(membership: MembershipLike): string { + if (membership.remainingTimes === null || !membership.cardType.totalTimes) return '0%' + const pct = (membership.remainingTimes / membership.cardType.totalTimes) * 100 + return `${Math.max(0, Math.min(100, pct))}%` +} + +/** 已使用次数 */ +export function getMembershipUsedTimes(membership: MembershipLike): number { + if (membership.remainingTimes === null || !membership.cardType.totalTimes) return 0 + return membership.cardType.totalTimes - membership.remainingTimes +} diff --git a/packages/server/src/common/filters/api-exception.filter.ts b/packages/server/src/common/filters/api-exception.filter.ts index 7e12a98..b2fa4ff 100644 --- a/packages/server/src/common/filters/api-exception.filter.ts +++ b/packages/server/src/common/filters/api-exception.filter.ts @@ -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}`, ) } diff --git a/packages/server/src/common/interceptors/logging.interceptor.ts b/packages/server/src/common/interceptors/logging.interceptor.ts index 044d318..cf5b6db 100644 --- a/packages/server/src/common/interceptors/logging.interceptor.ts +++ b/packages/server/src/common/interceptors/logging.interceptor.ts @@ -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 = new Set([ - 'password', - 'token', - 'secret', - 'code', - 'sessionKey', - 'encryptedData', - 'iv', -]) - -function sanitizeBody( - body: Record | undefined, -): Record | 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() const duration = Date.now() - start - const bodyLog = this.formatBody(method, req.body as Record) + 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) + 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 | 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)}` - } } diff --git a/packages/server/src/common/utils/request-log.ts b/packages/server/src/common/utils/request-log.ts new file mode 100644 index 0000000..71c01a2 --- /dev/null +++ b/packages/server/src/common/utils/request-log.ts @@ -0,0 +1,60 @@ +import type { Request } from 'express' + +/** Fields stripped from logged request bodies to avoid leaking secrets. */ +const SENSITIVE_FIELDS: ReadonlySet = new Set([ + 'password', + 'token', + 'secret', + 'code', + 'sessionKey', + 'encryptedData', + 'iv', +]) + +const BODY_METHODS: ReadonlySet = 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 | undefined, +): Record | undefined { + if (!body || typeof body !== 'object') return undefined + const keys = Object.keys(body) + if (keys.length === 0) return undefined + + const result: Record = {} + 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) + if (sanitized) { + parts.push(`body=${truncate(JSON.stringify(sanitized))}`) + } + } + + return parts.length > 0 ? ` | ${parts.join(' ')}` : '' +} diff --git a/packages/server/src/user/dto/update-user-membership.dto.ts b/packages/server/src/user/dto/update-user-membership.dto.ts index 7bf87a1..f760302 100644 --- a/packages/server/src/user/dto/update-user-membership.dto.ts +++ b/packages/server/src/user/dto/update-user-membership.dto.ts @@ -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 diff --git a/packages/server/src/user/user.service.ts b/packages/server/src/user/user.service.ts index 504e2f0..a9f068c 100644 --- a/packages/server/src/user/user.service.ts +++ b/packages/server/src/user/user.service.ts @@ -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(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 {