feat: 支持画廊图片更新

This commit is contained in:
richarjiang
2026-04-15 13:58:51 +08:00
parent 7ce7cef77c
commit 6ab16f508a
20 changed files with 1671 additions and 247 deletions

View File

@@ -20,4 +20,16 @@ API_BASE_URL=https://focus.richarjiang.com/
# Server
PORT=3000
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII
# COS upload
COS_SECRET_ID=AKIDwwulT3ub9f9bxFVdihcP4Z1S6qivMxmu
COS_SECRET_KEY=S1rrw0CY1fRQj7X7fCpjryAMwgel6drG
COS_BUCKET=plates-1251306435
COS_REGION=ap-guangzhou
COS_UPLOAD_ROLE_ARN=qcs::cam::uin/649581473:roleName/MpPilatesCosUploadRole
COS_APP_ID=1251306435
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
COS_UPLOAD_PREFIX=mp/studio
COS_UPLOAD_DURATION_SECONDS=1800
COS_UPLOAD_ROLE_SESSION_NAME=mp-pilates-studio-upload

View File

@@ -0,0 +1,13 @@
DATABASE_URL=mysql://user:password@127.0.0.1:3306/mp_pilates
JWT_SECRET=change-me
WX_APPID=your-wechat-appid
WX_SECRET=your-wechat-secret
# COS upload
COS_SECRET_ID=your-cos-secret-id
COS_SECRET_KEY=your-cos-secret-key
COS_BUCKET=plates-1251306435
COS_REGION=ap-guangzhou
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
COS_UPLOAD_PREFIX=mp/studio
COS_UPLOAD_DURATION_SECONDS=1800

View File

@@ -13,6 +13,7 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node prisma/seed.ts",
"studio:seed-gallery": "ts-node prisma/update-studio-gallery.ts",
"lint": "eslint \"{src,test}/**/*.ts\""
},
"dependencies": {

View File

@@ -0,0 +1,39 @@
import { PrismaClient } from '@prisma/client'
import { DEFAULT_STUDIO_GALLERY_PHOTOS } from '@mp-pilates/shared'
const prisma = new PrismaClient()
async function main() {
console.log('🖼️ Syncing studio gallery photos...')
const photos = [...DEFAULT_STUDIO_GALLERY_PHOTOS]
const existing = await prisma.studioConfig.findFirst({ select: { id: true } })
if (existing) {
await prisma.studioConfig.update({
where: { id: existing.id },
data: { photos },
})
console.log(` ✅ Updated existing studio config with ${photos.length} gallery images`)
} else {
await prisma.studioConfig.create({
data: {
name: '普拉提工作室',
address: '请在管理后台设置地址',
phone: '请在管理后台设置电话',
cancelHoursLimit: 2,
photos,
},
})
console.log(` ✅ Created studio config with ${photos.length} gallery images`)
}
}
main()
.catch((error) => {
console.error('❌ Studio gallery sync failed:', error)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@@ -0,0 +1,15 @@
import { IsIn, IsOptional, IsString } from 'class-validator'
import type { StudioAssetType } from '@mp-pilates/shared'
export class CreateStudioUploadCredentialDto {
@IsString()
fileName!: string
@IsOptional()
@IsString()
contentType?: string
@IsOptional()
@IsIn(['gallery', 'logo', 'banner'])
assetType?: StudioAssetType
}

View File

@@ -0,0 +1,170 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import type {
StudioAssetType,
StudioUploadCredential,
} from '@mp-pilates/shared'
import { createHash, createHmac, randomBytes } from 'crypto'
import { CreateStudioUploadCredentialDto } from './dto/create-studio-upload-credential.dto'
const ALLOWED_IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif'])
const CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
heic: 'image/heic',
heif: 'image/heif',
}
const EXTENSION_BY_CONTENT_TYPE = new Map(
Object.entries(CONTENT_TYPE_BY_EXTENSION).map(([ext, type]) => [type, ext]),
)
@Injectable()
export class StudioUploadService {
constructor(private readonly configService: ConfigService) {}
async createUploadCredential(
dto: CreateStudioUploadCredentialDto,
): Promise<StudioUploadCredential> {
const bucket = this.getRequiredConfig('COS_BUCKET')
const region = this.getRequiredConfig('COS_REGION')
const assetType = dto.assetType ?? 'gallery'
const extension = this.resolveExtension(dto.fileName, dto.contentType)
const key = this.buildObjectKey(assetType, extension)
const uploadUrl = `https://${bucket}.cos.${region}.myqcloud.com`
const fileUrl = this.buildFileUrl(key, uploadUrl)
const expiresAt = Math.floor(Date.now() / 1000) + this.getDurationSeconds()
const formData = this.buildPostPolicy({ bucket, key, expiresAt })
return {
bucket,
region,
key,
uploadUrl,
fileUrl,
assetType,
expiresAt,
formData,
}
}
private buildPostPolicy(params: {
bucket: string
key: string
expiresAt: number
}): Record<string, string> {
const secretId = this.getRequiredConfig('COS_SECRET_ID')
const secretKey = this.getRequiredConfig('COS_SECRET_KEY')
const keyTime = this.buildKeyTime(params.expiresAt)
const policy = {
expiration: new Date(params.expiresAt * 1000).toISOString(),
conditions: [
{ bucket: params.bucket },
['eq', '$key', params.key],
{ success_action_status: '200' },
{ 'q-sign-algorithm': 'sha1' },
{ 'q-ak': secretId },
{ 'q-key-time': keyTime },
{ 'q-sign-time': keyTime },
['content-length-range', 0, 10 * 1024 * 1024],
],
}
const policyJson = JSON.stringify(policy)
const policyBase64 = Buffer.from(policyJson).toString('base64')
const signKey = createHmac('sha1', secretKey)
.update(keyTime)
.digest('hex')
const stringToSign = createHash('sha1').update(policyJson).digest('hex')
const signature = createHmac('sha1', signKey)
.update(stringToSign)
.digest('hex')
return {
key: params.key,
policy: policyBase64,
success_action_status: '200',
'q-sign-algorithm': 'sha1',
'q-ak': secretId,
'q-key-time': keyTime,
'q-sign-time': keyTime,
'q-signature': signature,
}
}
private buildObjectKey(assetType: StudioAssetType, extension: string): string {
const prefix = this.getUploadPrefix()
const now = new Date()
const datePath = [
now.getUTCFullYear(),
String(now.getUTCMonth() + 1).padStart(2, '0'),
String(now.getUTCDate()).padStart(2, '0'),
].join('/')
const randomSuffix = randomBytes(8).toString('hex')
return `${prefix}/${assetType}/${datePath}/${Date.now()}-${randomSuffix}.${extension}`
}
private buildFileUrl(key: string, uploadUrl: string): string {
const publicBaseUrl = this.configService.get<string>('COS_PUBLIC_BASE_URL')?.trim()
const baseUrl = publicBaseUrl || uploadUrl
return `${baseUrl.replace(/\/$/, '')}/${key}`
}
private buildKeyTime(expiresAt: number): string {
const startTime = Math.floor(Date.now() / 1000) - 5
return `${startTime};${expiresAt}`
}
private resolveExtension(fileName: string, contentType?: string): string {
const cleanedName = fileName.trim().toLowerCase()
const fileExtension = cleanedName.includes('.')
? cleanedName.split('.').pop() ?? ''
: ''
if (ALLOWED_IMAGE_EXTENSIONS.has(fileExtension)) {
return fileExtension === 'jpeg' ? 'jpg' : fileExtension
}
if (contentType) {
const normalizedType = contentType.trim().toLowerCase()
const matchedExtension = EXTENSION_BY_CONTENT_TYPE.get(normalizedType)
if (matchedExtension) {
return matchedExtension
}
}
throw new BadRequestException('仅支持 jpg、png、webp、heic、heif 图片上传')
}
private getDurationSeconds(): number {
const configured = Number(this.configService.get<string>('COS_UPLOAD_DURATION_SECONDS') ?? 1800)
if (!Number.isFinite(configured) || configured < 300 || configured > 7200) {
return 1800
}
return Math.floor(configured)
}
private getUploadPrefix(): string {
return (this.configService.get<string>('COS_UPLOAD_PREFIX')?.trim() || 'mp/studio')
.replace(/^\/+|\/+$/g, '')
}
private getRequiredConfig(key: string): string {
const value = this.configService.get<string>(key)?.trim()
if (!value) {
throw new InternalServerErrorException(`${key} 未配置`)
}
return value
}
}

View File

@@ -2,6 +2,7 @@ import {
Body,
Controller,
Get,
Post,
Put,
UseGuards,
} from '@nestjs/common'
@@ -9,12 +10,17 @@ import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { Roles } from '../auth/roles.decorator'
import { RolesGuard } from '../auth/roles.guard'
import { CreateStudioUploadCredentialDto } from './dto/create-studio-upload-credential.dto'
import { UpdateStudioDto } from './dto/update-studio.dto'
import { StudioService } from './studio.service'
import { StudioUploadService } from './studio-upload.service'
@Controller()
export class StudioController {
constructor(private readonly studioService: StudioService) {}
constructor(
private readonly studioService: StudioService,
private readonly studioUploadService: StudioUploadService,
) {}
@Get('studio/info')
getInfo() {
@@ -27,4 +33,11 @@ export class StudioController {
updateInfo(@Body() dto: UpdateStudioDto) {
return this.studioService.updateInfo(dto)
}
@Post('admin/studio/upload-credentials')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
createUploadCredential(@Body() dto: CreateStudioUploadCredentialDto) {
return this.studioUploadService.createUploadCredential(dto)
}
}

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'
import { StudioController } from './studio.controller'
import { StudioService } from './studio.service'
import { StudioUploadService } from './studio-upload.service'
@Module({
controllers: [StudioController],
providers: [StudioService],
providers: [StudioService, StudioUploadService],
exports: [StudioService],
})
export class StudioModule {}

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'
import { StudioConfig } from '@prisma/client'
import { StudioConfig as PrismaStudioConfig } from '@prisma/client'
import { PrismaService } from '../prisma/prisma.service'
import { UpdateStudioDto } from './dto/update-studio.dto'
@@ -7,28 +7,71 @@ import { UpdateStudioDto } from './dto/update-studio.dto'
export class StudioService {
constructor(private readonly prisma: PrismaService) {}
async getInfo(): Promise<StudioConfig> {
async getInfo() {
const existing = await this.prisma.studioConfig.findFirst()
if (existing) {
return existing
return this.normalizeStudioConfig(existing)
}
return this.prisma.studioConfig.create({
const created = await this.prisma.studioConfig.create({
data: {
name: '普拉提工作室',
},
})
return this.normalizeStudioConfig(created)
}
async updateInfo(dto: UpdateStudioDto): Promise<StudioConfig> {
const existing = await this.getInfo()
const updated = await this.prisma.studioConfig.update({
where: { id: existing.id },
data: { ...dto },
async updateInfo(dto: UpdateStudioDto) {
const existing = await this.prisma.studioConfig.findFirst({
select: { id: true },
})
return { ...updated }
const data = {
...dto,
photos: dto.photos ? this.normalizePhotos(dto.photos) : undefined,
}
const record = existing
? await this.prisma.studioConfig.update({
where: { id: existing.id },
data,
})
: await this.prisma.studioConfig.create({
data: { name: '普拉提工作室', ...data },
})
return this.normalizeStudioConfig(record)
}
private normalizeStudioConfig(config: PrismaStudioConfig) {
return {
...config,
latitude: config.latitude == null ? null : Number(config.latitude),
longitude: config.longitude == null ? null : Number(config.longitude),
photos: this.normalizePhotos(config.photos),
}
}
private normalizePhotos(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
const deduped = new Set<string>()
value.forEach((item) => {
if (typeof item !== 'string') {
return
}
const trimmed = item.trim()
if (trimmed) {
deduped.add(trimmed)
}
})
return [...deduped]
}
}