feat: 支持画廊图片更新
This commit is contained in:
@@ -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
|
||||
|
||||
13
packages/server/.env.example
Normal file
13
packages/server/.env.example
Normal 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
|
||||
@@ -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": {
|
||||
|
||||
39
packages/server/prisma/update-studio-gallery.ts
Normal file
39
packages/server/prisma/update-studio-gallery.ts
Normal 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()
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
170
packages/server/src/studio/studio-upload.service.ts
Normal file
170
packages/server/src/studio/studio-upload.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user