diff --git a/docs/STUDIO_COS_SETUP.md b/docs/STUDIO_COS_SETUP.md new file mode 100644 index 0000000..4cb3dc4 --- /dev/null +++ b/docs/STUDIO_COS_SETUP.md @@ -0,0 +1,338 @@ +# 工作室画廊 COS 接入配置说明 + +本文档对应当前仓库当前实现。 + +现在已经不再使用 STS `AssumeRole`。 +当前方案改为: + +- 服务端使用长期密钥直接签发 COS POST Policy +- 管理中心小程序拿到表单签名后直传 COS +- 工作室配置中的 `logo`、`bannerUrl`、`photos` 保存最终可访问 URL + +当前实现代码入口: + +- `packages/server/src/studio/studio-upload.service.ts` +- `packages/server/src/studio/studio.controller.ts` +- `packages/app/src/utils/studio-upload.ts` +- `packages/app/src/pages/admin/studio.vue` + +## 一、整体链路 + +1. 管理中心点击上传图片。 +2. 小程序请求服务端 `POST /api/admin/studio/upload-credentials`。 +3. 服务端用 `COS_SECRET_ID`、`COS_SECRET_KEY` 直接生成一组 POST Policy 表单字段。 +4. 服务端把 `uploadUrl`、`key`、`formData`、`fileUrl`、`expiresAt` 返回给小程序。 +5. 小程序使用 `uni.uploadFile` 直接上传到 COS。 +6. 上传成功后,把 URL 保存到工作室配置,再调用 `PUT /api/admin/studio/info` 落库。 + +这个方案没有临时密钥,也没有角色扮演。 +安全边界来自两层: + +- 服务端只为单个对象 key 签发一次表单策略 +- 表单策略有明确过期时间,过期后自动失效 + +## 二、这个方案的本质 + +你现在选的是“服务端代签名”的直传方案。 +它和 STS 的差别是: + +- STS:给前端一段时间内可用的短期密钥 +- 当前方案:不给前端密钥,只给前端一个短时有效的上传表单签名 + +所以结论很直接: + +- 仍然有有效期 +- 但有效期作用在 POST Policy 上,不是作用在临时密钥上 + +当前代码里默认有效期是 `1800` 秒。 +环境变量: + +- `COS_UPLOAD_DURATION_SECONDS` + +当前实现限制范围: + +- 最短 `300` 秒 +- 最长 `7200` 秒 + +## 三、你现在真正需要准备的东西 + +先确认下面几个信息: + +- COS Bucket 名称,例如 `plates-1251306435` +- COS 所在地域,例如 `ap-guangzhou` +- 服务端使用的 COS 长期密钥 `SecretId` / `SecretKey` +- 图片上传前缀,例如 `mp/studio` +- 图片访问域名 + +建议约定: + +- Bucket:`plates-1251306435` +- Region:`ap-guangzhou` +- Prefix:`mp/studio` + +## 四、COS 控制台配置 + +### 1. 创建或确认 Bucket + +控制台路径:`对象存储 COS` + +建议: + +- 地域选 `广州` 或你当前实际地域 +- 存储类型标准存储即可 +- Bucket 名称和环境变量保持完全一致 + +### 2. 图片访问方式 + +当前实现保存的是直接图片 URL。 +所以图片必须能被小程序和前台直接访问。 + +你有两种方式: + +1. 直接使用 COS 源站并允许读 +2. 配 CDN / 自定义域名并让这个域名可直接访问图片 + +如果你什么都不配,上传成功后图片可能打不开。 + +最直接做法: + +- 让这个图片 Bucket 对外可读 + +更稳妥做法: + +- 单独图片 Bucket +- 用 CDN 域名做 `COS_PUBLIC_BASE_URL` + +### 3. 微信小程序合法域名 + +微信公众平台需要补白名单: + +- `request 合法域名`:你的后端 API 域名 +- `uploadFile 合法域名`:`https://.cos..myqcloud.com` +- `downloadFile 合法域名`:图片访问域名 + +如果图片访问也走 COS 源站,那么 `downloadFile 合法域名` 同样加: + +- `https://.cos..myqcloud.com` + +例如: + +- `https://focus.richarjiang.com` +- `https://plates-1251306435.cos.ap-guangzhou.myqcloud.com` + +## 五、服务端账号需要什么权限 + +现在已经不需要: + +- STS +- CAM 角色 +- `AssumeRole` +- 角色信任策略 +- `COS_UPLOAD_ROLE_ARN` + +现在服务端只需要一对可以给目标 Bucket 生成上传签名的长期密钥。 + +最简单的做法是: + +- 用你的主账号密钥 + +但生产上更合理的是: + +- 建一个专用 CAM 用户,只给这个 Bucket 上传相关权限 + +### 推荐 CAM 用户权限策略 + +如果你要建专用 CAM 用户,给它绑定下面这类策略即可。 + +把下面真实值替换成你的实际资源: + +- 地域:`ap-guangzhou` +- AppId:`1251306435` +- Bucket:`plates-1251306435` +- Prefix:`mp/studio` + +```json +{ + "version": "2.0", + "statement": [ + { + "effect": "allow", + "action": [ + "name/cos:PutObject", + "name/cos:PostObject" + ], + "resource": [ + "qcs::cos:ap-guangzhou:uid/1251306435:plates-1251306435/mp/studio/*" + ] + } + ] +} +``` + +如果你后续还要服务端删除对象,再补: + +- `name/cos:DeleteObject` + +当前仓库实现不需要删除对象,所以先不要额外放大权限。 + +## 六、服务端环境变量 + +把下面变量配置到 `packages/server/.env` 或线上环境: + +```env +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 +``` + +各字段含义: + +- `COS_SECRET_ID`:用于签发 POST Policy 的长期密钥 ID +- `COS_SECRET_KEY`:用于签发 POST Policy 的长期密钥 Key +- `COS_BUCKET`:上传目标 Bucket +- `COS_REGION`:Bucket 地域 +- `COS_PUBLIC_BASE_URL`:最终展示图片的访问域名 +- `COS_UPLOAD_PREFIX`:统一对象前缀 +- `COS_UPLOAD_DURATION_SECONDS`:Policy 有效期秒数 + +现在可以删除或忽略这些旧配置: + +- `COS_UPLOAD_ROLE_ARN` +- `COS_APP_ID` +- `COS_UPLOAD_ROLE_SESSION_NAME` + +它们对当前实现已经没用。 + +## 七、控制台操作清单 + +按这个顺序做: + +1. 确认 COS Bucket 已存在。 +2. 确认图片访问域名对外可读。 +3. 在微信公众平台加好 `request` / `uploadFile` / `downloadFile` 合法域名。 +4. 准备一对 COS 长期密钥。 +5. 把 `COS_SECRET_ID`、`COS_SECRET_KEY`、`COS_BUCKET`、`COS_REGION`、`COS_PUBLIC_BASE_URL`、`COS_UPLOAD_PREFIX` 配到服务端。 +6. 重启服务端。 +7. 在管理中心上传一张图片测试。 + +## 八、接口返回内容说明 + +请求: + +```http +POST /api/admin/studio/upload-credentials +Content-Type: application/json +Authorization: Bearer + +{ + "fileName": "demo.jpg", + "contentType": "image/jpeg", + "assetType": "gallery" +} +``` + +正常返回会包含: + +- `uploadUrl` +- `fileUrl` +- `key` +- `assetType` +- `expiresAt` +- `formData` + +`formData` 里会有这些字段: + +- `key` +- `policy` +- `success_action_status` +- `Content-Type` +- `q-sign-algorithm` +- `q-ak` +- `q-key-time` +- `q-sign-time` +- `q-signature` + +这就是小程序直传需要的全部内容。 + +## 九、怎么验证是否配置正确 + +### 1. 接口层验证 + +调用 `POST /api/admin/studio/upload-credentials`。 + +如果成功,说明: + +- 服务端长期密钥有效 +- 服务端已经能正确签发 policy + +### 2. 上传层验证 + +在管理中心上传一张图,检查: + +1. COS Bucket 下是否出现对象 +2. 返回的 `fileUrl` 浏览器是否能直接访问 +3. 保存工作室设置后首页是否显示该图 + +### 3. 失败时怎么定位 + +如果 `upload-credentials` 接口失败,优先检查: + +- `COS_SECRET_ID` / `COS_SECRET_KEY` 是否正确 +- `COS_BUCKET` / `COS_REGION` 是否正确 +- 服务端是否已经加载最新环境变量 + +如果接口成功但上传失败,优先检查: + +- 小程序 `uploadFile 合法域名` 是否正确 +- Bucket 权限策略是否允许当前长期密钥上传到该前缀 +- `Content-Type` 是否被策略条件限制住 + +如果上传成功但图片打不开,优先检查: + +- Bucket 或图片域名是否可公网访问 +- `COS_PUBLIC_BASE_URL` 是否正确 +- 小程序 `downloadFile 合法域名` 是否正确 + +## 十、当前实现的边界 + +当前仓库实现边界如下: + +- 只支持 `jpg`、`jpeg`、`png`、`webp`、`heic`、`heif` +- 单次上传大小上限 `10MB` +- 只支持普通表单直传,不支持分片上传 +- 删除工作室图片时,只会从数据库配置里移除 URL,不会删除 COS 历史对象 + +最后一条是故意保守设计。 +原因很简单: + +- 先保证配置删除安全 +- 避免误删真实文件 + +如果以后要做“删配置时同步删对象”,那时再单独加 `DeleteObject` 权限。 + +## 十一、初始化工作室画廊 + +如果你要把现在手工写死的图片 URL 一次性写入数据库,执行: + +```bash +pnpm --filter @mp-pilates/server studio:seed-gallery +``` + +脚本文件: + +- `packages/server/prisma/update-studio-gallery.ts` + +## 十二、建议的生产做法 + +如果你后面要长期维护,建议: + +1. 图片单独放一个 Bucket。 +2. 长期密钥不要直接用主账号,换成专用 CAM 用户。 +3. 对专用 CAM 用户只给 `mp/studio/*` 前缀上传权限。 +4. 用 CDN 域名作为 `COS_PUBLIC_BASE_URL`。 + +这样后面扩展、迁移、审计都会更稳。 diff --git a/packages/app/src/components/BrandBanner.vue b/packages/app/src/components/BrandBanner.vue index 551949f..3695c46 100644 --- a/packages/app/src/components/BrandBanner.vue +++ b/packages/app/src/components/BrandBanner.vue @@ -14,27 +14,36 @@ + + {{ studioName.slice(0, 1) || 'F' }} + - Focus Core + {{ studioName }} diff --git a/packages/app/src/stores/admin.ts b/packages/app/src/stores/admin.ts index 1a6c8c6..44e6fde 100644 --- a/packages/app/src/stores/admin.ts +++ b/packages/app/src/stores/admin.ts @@ -18,6 +18,8 @@ import type { FlashSaleAdminItem, CreateFlashSaleDto, UpdateFlashSaleDto, + CreateStudioUploadCredentialDto, + StudioUploadCredential, } from '@mp-pilates/shared' interface LegacyPaginatedData { @@ -141,6 +143,15 @@ export const useAdminStore = defineStore('admin', () => { return data } + async function createStudioUploadCredential( + dto: CreateStudioUploadCredentialDto, + ): Promise { + return post( + '/admin/studio/upload-credentials', + dto as unknown as Record, + ) + } + // ── Orders ─────────────────────────────────────────────────────── async function fetchAdminOrders(params: { page?: number @@ -278,6 +289,7 @@ export const useAdminStore = defineStore('admin', () => { // Studio fetchStudioConfig, saveStudioConfig, + createStudioUploadCredential, // Orders fetchAdminOrders, // Bookings diff --git a/packages/app/src/utils/studio-upload.ts b/packages/app/src/utils/studio-upload.ts new file mode 100644 index 0000000..5e3813e --- /dev/null +++ b/packages/app/src/utils/studio-upload.ts @@ -0,0 +1,72 @@ +import type { + CreateStudioUploadCredentialDto, + StudioAssetType, + StudioUploadCredential, +} from '@mp-pilates/shared' +import type { useAdminStore } from '../stores/admin' + +type AdminStore = ReturnType + +function inferContentType(fileName: string): string | undefined { + const extension = fileName.split('.').pop()?.toLowerCase() + + if (extension === 'jpg' || extension === 'jpeg') { + return 'image/jpeg' + } + if (extension === 'png') { + return 'image/png' + } + if (extension === 'webp') { + return 'image/webp' + } + if (extension === 'heic') { + return 'image/heic' + } + if (extension === 'heif') { + return 'image/heif' + } + + return undefined +} + +function uploadToCos(filePath: string, credential: StudioUploadCredential): Promise { + return new Promise((resolve, reject) => { + uni.uploadFile({ + url: credential.uploadUrl, + filePath, + name: 'file', + formData: credential.formData as unknown as Record, + success: (result) => { + if (result.statusCode >= 200 && result.statusCode < 300) { + resolve() + return + } + + const body = typeof result.data === 'string' ? result.data : JSON.stringify(result.data) + const code = body.match(/([^<]+)<\/Code>/)?.[1] + const message = body.match(/([^<]+)<\/Message>/)?.[1] + const detail = code || message ? `${code ?? 'COS'}: ${message ?? body}` : body + reject(new Error(`COS 上传失败 (${result.statusCode}) ${detail}`)) + }, + fail: (error) => { + reject(new Error(error.errMsg || 'COS 上传失败')) + }, + }) + }) +} + +export async function uploadStudioAsset(params: { + adminStore: AdminStore + filePath: string + fileName: string + assetType: StudioAssetType +}): Promise { + const payload: CreateStudioUploadCredentialDto = { + fileName: params.fileName, + contentType: inferContentType(params.fileName), + assetType: params.assetType, + } + const credential = await params.adminStore.createStudioUploadCredential(payload) + await uploadToCos(params.filePath, credential) + return credential.fileUrl +} diff --git a/packages/server/.env b/packages/server/.env index 2e925ed..9e5be8b 100644 --- a/packages/server/.env +++ b/packages/server/.env @@ -20,4 +20,16 @@ API_BASE_URL=https://focus.richarjiang.com/ # Server PORT=3000 -WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII \ No newline at end of file +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 diff --git a/packages/server/.env.example b/packages/server/.env.example new file mode 100644 index 0000000..0fd20d9 --- /dev/null +++ b/packages/server/.env.example @@ -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 diff --git a/packages/server/package.json b/packages/server/package.json index 05b8464..5f4aa59 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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": { diff --git a/packages/server/prisma/update-studio-gallery.ts b/packages/server/prisma/update-studio-gallery.ts new file mode 100644 index 0000000..f7ff8bb --- /dev/null +++ b/packages/server/prisma/update-studio-gallery.ts @@ -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() + }) diff --git a/packages/server/src/studio/dto/create-studio-upload-credential.dto.ts b/packages/server/src/studio/dto/create-studio-upload-credential.dto.ts new file mode 100644 index 0000000..e61a7d3 --- /dev/null +++ b/packages/server/src/studio/dto/create-studio-upload-credential.dto.ts @@ -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 +} diff --git a/packages/server/src/studio/studio-upload.service.ts b/packages/server/src/studio/studio-upload.service.ts new file mode 100644 index 0000000..59ab23e --- /dev/null +++ b/packages/server/src/studio/studio-upload.service.ts @@ -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 = { + 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 { + 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 { + 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('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('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('COS_UPLOAD_PREFIX')?.trim() || 'mp/studio') + .replace(/^\/+|\/+$/g, '') + } + + private getRequiredConfig(key: string): string { + const value = this.configService.get(key)?.trim() + + if (!value) { + throw new InternalServerErrorException(`${key} 未配置`) + } + + return value + } +} diff --git a/packages/server/src/studio/studio.controller.ts b/packages/server/src/studio/studio.controller.ts index faeaca9..9372268 100644 --- a/packages/server/src/studio/studio.controller.ts +++ b/packages/server/src/studio/studio.controller.ts @@ -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) + } } diff --git a/packages/server/src/studio/studio.module.ts b/packages/server/src/studio/studio.module.ts index e297b57..461d92f 100644 --- a/packages/server/src/studio/studio.module.ts +++ b/packages/server/src/studio/studio.module.ts @@ -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 {} diff --git a/packages/server/src/studio/studio.service.ts b/packages/server/src/studio/studio.service.ts index 68c6604..bb7338a 100644 --- a/packages/server/src/studio/studio.service.ts +++ b/packages/server/src/studio/studio.service.ts @@ -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 { + 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 { - 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() + + value.forEach((item) => { + if (typeof item !== 'string') { + return + } + + const trimmed = item.trim() + if (trimmed) { + deduped.add(trimmed) + } + }) + + return [...deduped] } } diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 00ea0a1..3ef5bf1 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,6 +1,14 @@ /** 默认免费取消截止小时数 */ export const DEFAULT_CANCEL_HOURS_LIMIT = 2 +/** 默认工作室画廊图片 */ +export const DEFAULT_STUDIO_GALLERY_PHOTOS = [ + 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_1.jpg', + 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_2.jpg', + 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_3.jpg', + 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_4.jpg', +] as const + /** 默认时段容量(私教 = 1) */ export const DEFAULT_SLOT_CAPACITY = 1 diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cf16b28..ae69583 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -15,6 +15,7 @@ export { // Constants export { DEFAULT_CANCEL_HOURS_LIMIT, + DEFAULT_STUDIO_GALLERY_PHOTOS, DEFAULT_SLOT_CAPACITY, SLOT_GENERATION_DAYS, TIME_PERIODS, @@ -53,7 +54,10 @@ export type { PaymentParams, CreateOrderResponse, StudioConfig, + StudioAssetType, UpdateStudioConfigDto, + CreateStudioUploadCredentialDto, + StudioUploadCredential, ApiResponse, PaginatedData, PaginatedResponse, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 4827c65..c86f510 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -13,7 +13,13 @@ export type { WeekTemplate, WeekTemplateInput } from './week-template' export type { TimeSlot, TimeSlotWithBookingStatus, CreateManualSlotDto, ScheduleSlotPreview, PublishDaySlotItem, PublishDaySlotsDto } from './time-slot' export type { Booking, BookingWithDetails, BookingWithUser, BookingStatusHistory, CreateBookingDto } from './booking' export type { Order, OrderWithDetails, CreateOrderDto, PaymentParams, CreateOrderResponse } from './order' -export type { StudioConfig, UpdateStudioConfigDto } from './studio' +export type { + StudioConfig, + StudioAssetType, + UpdateStudioConfigDto, + CreateStudioUploadCredentialDto, + StudioUploadCredential, +} from './studio' export type { ApiResponse, PaginatedData, PaginatedResponse, PaginationQuery } from './api' export type { FlashSale, diff --git a/packages/shared/src/types/studio.ts b/packages/shared/src/types/studio.ts index f25fc93..66108f4 100644 --- a/packages/shared/src/types/studio.ts +++ b/packages/shared/src/types/studio.ts @@ -12,6 +12,8 @@ export interface StudioConfig { readonly updatedAt: string } +export type StudioAssetType = 'gallery' | 'logo' | 'banner' + export interface UpdateStudioConfigDto { readonly name?: string readonly logo?: string @@ -23,3 +25,20 @@ export interface UpdateStudioConfigDto { readonly cancelHoursLimit?: number readonly photos?: string[] } + +export interface CreateStudioUploadCredentialDto { + readonly fileName: string + readonly contentType?: string + readonly assetType?: StudioAssetType +} + +export interface StudioUploadCredential { + readonly bucket: string + readonly region: string + readonly key: string + readonly uploadUrl: string + readonly fileUrl: string + readonly assetType: StudioAssetType + readonly expiresAt: number + readonly formData: Readonly> +}