feat: 支持馆主个人信息编辑

This commit is contained in:
richarjiang
2026-10-25 15:21:27 +08:00
parent 819367178a
commit 4ca32c22f9
19 changed files with 556 additions and 390 deletions

View File

@@ -132,6 +132,8 @@ const mockStudioConfig = {
longitude: null,
cancelHoursLimit: 2,
photos: [],
ownerIntroduction: '',
ownerCertifications: [],
updatedAt: new Date(),
}

View File

@@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'
import { StudioService } from '../studio.service'
import { PrismaService } from '../../prisma/prisma.service'
import { UpdateStudioDto } from '../dto/update-studio.dto'
import { DEFAULT_OWNER_INTRODUCTION, DEFAULT_OWNER_CERTIFICATIONS } from '@mp-pilates/shared'
const mockStudioConfig = {
id: 'test-id-001',
@@ -14,6 +15,8 @@ const mockStudioConfig = {
longitude: null,
cancelHoursLimit: 2,
photos: [],
ownerIntroduction: DEFAULT_OWNER_INTRODUCTION,
ownerCertifications: [...DEFAULT_OWNER_CERTIFICATIONS],
updatedAt: new Date('2024-01-01T00:00:00Z'),
}
@@ -41,6 +44,25 @@ describe('StudioService', () => {
})
describe('getInfo()', () => {
it('preserves the original introduction and credentials for an unconfigured studio', async () => {
mockPrismaService.studioConfig.findFirst.mockResolvedValue({
...mockStudioConfig,
ownerIntroduction: null,
ownerCertifications: null,
})
expect(await service.getInfo()).toEqual(mockStudioConfig)
})
it('does not restore defaults when the owner intentionally clears content', async () => {
mockPrismaService.studioConfig.findFirst.mockResolvedValue({
...mockStudioConfig,
ownerIntroduction: '',
ownerCertifications: [],
})
expect(await service.getInfo()).toMatchObject({ ownerIntroduction: '', ownerCertifications: [] })
})
it('should return existing config when one exists', async () => {
mockPrismaService.studioConfig.findFirst.mockResolvedValue(mockStudioConfig)
@@ -67,6 +89,42 @@ describe('StudioService', () => {
})
describe('updateInfo()', () => {
it('persists owner content with paragraphs and credential order, independently of studio settings', async () => {
const dto: UpdateStudioDto = {
ownerIntroduction: ' 我的教学经历\n\n我的练习理念 ',
ownerCertifications: [' 认证 B ', '', '认证 A', '认证 B'],
}
const normalized = { ownerIntroduction: '我的教学经历\n\n我的练习理念', ownerCertifications: ['认证 B', '认证 A'] }
mockPrismaService.studioConfig.findFirst.mockResolvedValue({ id: mockStudioConfig.id })
mockPrismaService.studioConfig.update.mockResolvedValue({ ...mockStudioConfig, ...normalized })
expect(await service.updateInfo(dto)).toMatchObject(normalized)
expect(mockPrismaService.studioConfig.update).toHaveBeenCalledWith({
where: { id: mockStudioConfig.id },
data: { ...normalized, photos: undefined },
})
})
it('saves empty content and does not overwrite owner content when updating other settings', async () => {
mockPrismaService.studioConfig.findFirst.mockResolvedValue({ id: mockStudioConfig.id })
mockPrismaService.studioConfig.update.mockResolvedValue({ ...mockStudioConfig, ownerIntroduction: '', ownerCertifications: [] })
await service.updateInfo({ ownerIntroduction: '', ownerCertifications: [] })
expect(mockPrismaService.studioConfig.update.mock.calls[0][0].data).toMatchObject({ ownerIntroduction: '', ownerCertifications: [] })
await service.updateInfo({ name: '新的馆名' })
const data = mockPrismaService.studioConfig.update.mock.calls[1][0].data
expect(data.ownerIntroduction).toBeUndefined()
expect(data.ownerCertifications).toBeUndefined()
})
it('creates a studio with editable owner content if no config exists yet', async () => {
const dto = { ownerIntroduction: '馆主的故事', ownerCertifications: ['认证 A'] }
mockPrismaService.studioConfig.findFirst.mockResolvedValue(null)
mockPrismaService.studioConfig.create.mockResolvedValue({ ...mockStudioConfig, ...dto })
expect(await service.updateInfo(dto)).toMatchObject(dto)
expect(mockPrismaService.studioConfig.create).toHaveBeenCalledWith({ data: { name: '普拉提工作室', ...dto, photos: undefined } })
})
it('should update and return a new config object with changes applied', async () => {
const dto: UpdateStudioDto = {
name: 'New Studio Name',

View File

@@ -0,0 +1,31 @@
import { plainToInstance } from 'class-transformer'
import { validate } from 'class-validator'
import { OWNER_INTRODUCTION_MAX_LENGTH, OWNER_CERTIFICATIONS_MAX_COUNT, OWNER_CERTIFICATION_MAX_LENGTH } from '@mp-pilates/shared'
import { UpdateStudioDto } from '../dto/update-studio.dto'
const errorsFor = (input: Record<string, unknown>) => validate(plainToInstance(UpdateStudioDto, input))
describe('UpdateStudioDto owner content', () => {
it.each([
{},
{ ownerIntroduction: '', ownerCertifications: [] },
{ ownerIntroduction: '我的介绍\n\n我的理念', ownerCertifications: ['认证 A', '认证 B'] },
{ ownerIntroduction: '字'.repeat(OWNER_INTRODUCTION_MAX_LENGTH), ownerCertifications: Array(OWNER_CERTIFICATIONS_MAX_COUNT).fill('字'.repeat(OWNER_CERTIFICATION_MAX_LENGTH)) },
])('accepts partial updates, intentional clearing and content within limits', async input => {
expect(await errorsFor(input)).toHaveLength(0)
})
it.each([
{ ownerIntroduction: null },
{ ownerIntroduction: 123 },
{ ownerIntroduction: '字'.repeat(OWNER_INTRODUCTION_MAX_LENGTH + 1) },
{ ownerCertifications: null },
{ ownerCertifications: '认证 A' },
{ ownerCertifications: [123] },
{ ownerCertifications: [null] },
{ ownerCertifications: ['字'.repeat(OWNER_CERTIFICATION_MAX_LENGTH + 1)] },
{ ownerCertifications: Array(OWNER_CERTIFICATIONS_MAX_COUNT + 1).fill('认证') },
])('rejects malformed or oversized content: %j', async input => {
expect((await errorsFor(input)).length).toBeGreaterThan(0)
})
})

View File

@@ -3,10 +3,17 @@ import {
IsNumber,
IsOptional,
IsString,
IsUrl,
ArrayMaxSize,
MaxLength,
ValidateIf,
Max,
Min,
} from 'class-validator'
import {
OWNER_INTRODUCTION_MAX_LENGTH,
OWNER_CERTIFICATIONS_MAX_COUNT,
OWNER_CERTIFICATION_MAX_LENGTH,
} from '@mp-pilates/shared'
export class UpdateStudioDto {
@IsOptional()
@@ -50,4 +57,16 @@ export class UpdateStudioDto {
@IsArray()
@IsString({ each: true })
photos?: string[]
@ValidateIf((_object, value) => value !== undefined)
@IsString()
@MaxLength(OWNER_INTRODUCTION_MAX_LENGTH)
ownerIntroduction?: string
@ValidateIf((_object, value) => value !== undefined)
@IsArray()
@ArrayMaxSize(OWNER_CERTIFICATIONS_MAX_COUNT)
@IsString({ each: true })
@MaxLength(OWNER_CERTIFICATION_MAX_LENGTH, { each: true })
ownerCertifications?: string[]
}

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'
import { StudioConfig as PrismaStudioConfig } from '@prisma/client'
import { DEFAULT_OWNER_INTRODUCTION, DEFAULT_OWNER_CERTIFICATIONS } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { UpdateStudioDto } from './dto/update-studio.dto'
@@ -30,7 +31,9 @@ export class StudioService {
const data = {
...dto,
photos: dto.photos ? this.normalizePhotos(dto.photos) : undefined,
photos: dto.photos ? this.normalizeStringList(dto.photos) : undefined,
ownerIntroduction: dto.ownerIntroduction?.trim(),
ownerCertifications: dto.ownerCertifications === undefined ? undefined : this.normalizeStringList(dto.ownerCertifications),
}
const record = existing
@@ -50,11 +53,15 @@ export class StudioService {
...config,
latitude: config.latitude == null ? null : Number(config.latitude),
longitude: config.longitude == null ? null : Number(config.longitude),
photos: this.normalizePhotos(config.photos),
photos: this.normalizeStringList(config.photos),
ownerIntroduction: config.ownerIntroduction ?? DEFAULT_OWNER_INTRODUCTION,
ownerCertifications: config.ownerCertifications == null
? [...DEFAULT_OWNER_CERTIFICATIONS]
: this.normalizeStringList(config.ownerCertifications),
}
}
private normalizePhotos(value: unknown): string[] {
private normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}