feat: 支持会员卡设置
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -47,7 +47,7 @@
|
|||||||
<view class="card-header-left">
|
<view class="card-header-left">
|
||||||
<text class="card-name">{{ m.cardType.name }}</text>
|
<text class="card-name">{{ m.cardType.name }}</text>
|
||||||
<view class="card-type-badge">
|
<view class="card-type-badge">
|
||||||
<text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text>
|
<text class="card-type-badge-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="status-badge status-badge--active">
|
<view class="status-badge status-badge--active">
|
||||||
@@ -70,11 +70,11 @@
|
|||||||
<view class="progress-bar">
|
<view class="progress-bar">
|
||||||
<view
|
<view
|
||||||
class="progress-fill"
|
class="progress-fill"
|
||||||
:style="{ width: progressWidth(m) }"
|
:style="{ width: getMembershipProgressWidth(m) }"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<text class="progress-label">
|
<text class="progress-label">
|
||||||
已使用 {{ usedTimes(m) }} / {{ m.cardType.totalTimes }} 次
|
已使用 {{ getMembershipUsedTimes(m) }} / {{ m.cardType.totalTimes }} 次
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
<view class="card-header-left">
|
<view class="card-header-left">
|
||||||
<text class="card-name card-name--dim">{{ m.cardType.name }}</text>
|
<text class="card-name card-name--dim">{{ m.cardType.name }}</text>
|
||||||
<view class="card-type-badge card-type-badge--dim">
|
<view class="card-type-badge card-type-badge--dim">
|
||||||
<text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text>
|
<text class="card-type-badge-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="status-badge" :class="statusBadgeClass(m.status)">
|
<view class="status-badge" :class="statusBadgeClass(m.status)">
|
||||||
@@ -148,6 +148,7 @@ import type { MembershipWithCardType } from '@mp-pilates/shared'
|
|||||||
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
|
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -170,14 +171,6 @@ const inactiveMemberships = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────
|
||||||
function typeLabel(type: CardTypeCategory): string {
|
|
||||||
const map: Record<CardTypeCategory, string> = {
|
|
||||||
[CardTypeCategory.TIMES]: '次卡',
|
|
||||||
[CardTypeCategory.DURATION]: '月卡',
|
|
||||||
[CardTypeCategory.TRIAL]: '体验卡',
|
|
||||||
}
|
|
||||||
return map[type] ?? '会员卡'
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: MembershipStatus): string {
|
function statusLabel(status: MembershipStatus): string {
|
||||||
const map: Record<MembershipStatus, string> = {
|
const map: Record<MembershipStatus, string> = {
|
||||||
@@ -206,17 +199,6 @@ function headerClass(type: CardTypeCategory): string {
|
|||||||
return 'card-header--times'
|
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 ─────────────────────────────────────────
|
// ─── Data loading ─────────────────────────────────────────
|
||||||
async function loadMemberships() {
|
async function loadMemberships() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { CardType } from '@mp-pilates/shared'
|
|
||||||
import { CardTypeCategory } 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 {
|
export function formatPrice(cents: number): string {
|
||||||
return (cents / 100).toFixed(2)
|
return (cents / 100).toFixed(2)
|
||||||
@@ -49,13 +54,13 @@ export function getDateRange(days: number): ReadonlyArray<{ readonly date: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 会员卡类型标签 */
|
/** 会员卡类型标签 */
|
||||||
export function getCardTypeLabel(type: CardTypeCategory): string {
|
export function getCardTypeLabel(type: CardTypeCategory | string): string {
|
||||||
const map: Record<CardTypeCategory, string> = {
|
const map: Record<string, string> = {
|
||||||
[CardTypeCategory.TIMES]: '次卡',
|
[CardTypeCategory.TIMES]: '次卡',
|
||||||
[CardTypeCategory.DURATION]: '月卡',
|
[CardTypeCategory.DURATION]: '月卡',
|
||||||
[CardTypeCategory.TRIAL]: '体验',
|
[CardTypeCategory.TRIAL]: '体验卡',
|
||||||
}
|
}
|
||||||
return map[type] ?? '会员'
|
return map[type] ?? '会员卡'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 会员卡封面 CSS 类名 */
|
/** 会员卡封面 CSS 类名 */
|
||||||
@@ -70,3 +75,23 @@ export function isSlotPast(date: string, startTime: string): boolean {
|
|||||||
const slotDateTime = new Date(`${date}T${startTime}:00`)
|
const slotDateTime = new Date(`${date}T${startTime}:00`)
|
||||||
return new Date() > slotDateTime
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
import type { Request, Response } from 'express'
|
import type { Request, Response } from 'express'
|
||||||
import type { ApiResponse } from '@mp-pilates/shared'
|
import type { ApiResponse } from '@mp-pilates/shared'
|
||||||
|
import { formatRequestExtras } from '../utils/request-log'
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class ApiExceptionFilter implements ExceptionFilter {
|
export class ApiExceptionFilter implements ExceptionFilter {
|
||||||
@@ -28,15 +29,16 @@ export class ApiExceptionFilter implements ExceptionFilter {
|
|||||||
? this.extractMessage(exception)
|
? this.extractMessage(exception)
|
||||||
: '服务器内部错误'
|
: '服务器内部错误'
|
||||||
|
|
||||||
// Log all server errors (5xx) with full stack; log 4xx at warn level
|
const extras = formatRequestExtras(request)
|
||||||
|
|
||||||
if (status >= 500) {
|
if (status >= 500) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`${request.method} ${request.originalUrl} → ${String(status)} ${message}`,
|
`${request.method} ${request.originalUrl} → ${String(status)} ${message}${extras}`,
|
||||||
exception instanceof Error ? exception.stack : undefined,
|
exception instanceof Error ? exception.stack : undefined,
|
||||||
)
|
)
|
||||||
} else if (status >= 400) {
|
} else if (status >= 400) {
|
||||||
this.logger.warn(
|
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'
|
} from '@nestjs/common'
|
||||||
import { Observable, tap } from 'rxjs'
|
import { Observable, tap } from 'rxjs'
|
||||||
import type { Request, Response } from 'express'
|
import type { Request, Response } from 'express'
|
||||||
|
import { formatRequestExtras } from '../utils/request-log'
|
||||||
/** 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()
|
@Injectable()
|
||||||
export class LoggingInterceptor implements NestInterceptor {
|
export class LoggingInterceptor implements NestInterceptor {
|
||||||
@@ -44,9 +23,9 @@ export class LoggingInterceptor implements NestInterceptor {
|
|||||||
next: () => {
|
next: () => {
|
||||||
const res = context.switchToHttp().getResponse<Response>()
|
const res = context.switchToHttp().getResponse<Response>()
|
||||||
const duration = Date.now() - start
|
const duration = Date.now() - start
|
||||||
const bodyLog = this.formatBody(method, req.body as Record<string, unknown>)
|
const extras = formatRequestExtras(req)
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`${method} ${originalUrl} → ${String(res.statusCode)} (${String(duration)}ms)${bodyLog}`,
|
`${method} ${originalUrl} → ${String(res.statusCode)} (${String(duration)}ms)${extras}`,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
error: (err: unknown) => {
|
error: (err: unknown) => {
|
||||||
@@ -55,22 +34,12 @@ export class LoggingInterceptor implements NestInterceptor {
|
|||||||
err instanceof Object && 'getStatus' in err
|
err instanceof Object && 'getStatus' in err
|
||||||
? String((err as { getStatus: () => number }).getStatus())
|
? String((err as { getStatus: () => number }).getStatus())
|
||||||
: '500'
|
: '500'
|
||||||
const bodyLog = this.formatBody(method, req.body as Record<string, unknown>)
|
const extras = formatRequestExtras(req)
|
||||||
this.logger.error(
|
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 { IsDateString, IsInt, IsOptional, IsUUID, Min } from 'class-validator'
|
||||||
|
import { Type } from 'class-transformer'
|
||||||
|
|
||||||
export class UpdateUserMembershipDto {
|
export class UpdateUserMembershipDto {
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
cardTypeId!: string
|
cardTypeId!: string
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
remainingTimes?: number | null
|
remainingTimes?: number | null
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'
|
|||||||
import { MembershipStatus, BookingStatus, UserRole, CardTypeCategory } from '@mp-pilates/shared'
|
import { MembershipStatus, BookingStatus, UserRole, CardTypeCategory } from '@mp-pilates/shared'
|
||||||
import type { PaginatedData, UserProfileResponse, UserStatsResponse } from '@mp-pilates/shared'
|
import type { PaginatedData, UserProfileResponse, UserStatsResponse } from '@mp-pilates/shared'
|
||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
import { Membership, CardType, Prisma } from '@prisma/client'
|
|
||||||
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
|
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
|
||||||
|
|
||||||
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
|
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
|
||||||
@@ -236,7 +235,7 @@ export class UserService {
|
|||||||
where: { userId },
|
where: { userId },
|
||||||
include: { cardType: true },
|
include: { cardType: true },
|
||||||
})
|
})
|
||||||
return membership ? { ...membership, cardType: { ...membership.cardType } } : null
|
return { membership }
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUserMembership(userId: string, dto: UpdateUserMembershipDto) {
|
async updateUserMembership(userId: string, dto: UpdateUserMembershipDto) {
|
||||||
@@ -252,38 +251,28 @@ export class UserService {
|
|||||||
status = MembershipStatus.USED_UP
|
status = MembershipStatus.USED_UP
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await this.prisma.membership.findFirst({ where: { userId } })
|
const data = {
|
||||||
|
cardTypeId: dto.cardTypeId,
|
||||||
let membership: Membership & { cardType: CardType }
|
remainingTimes: dto.remainingTimes ?? null,
|
||||||
if (existing) {
|
startDate: new Date(dto.startDate),
|
||||||
const updated = await this.prisma.membership.update({
|
expireDate: new Date(dto.expireDate),
|
||||||
where: { id: existing.id },
|
status,
|
||||||
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 } }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
async deleteUserMembership(userId: string): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user