feat: 支持画廊图片更新
This commit is contained in:
338
docs/STUDIO_COS_SETUP.md
Normal file
338
docs/STUDIO_COS_SETUP.md
Normal file
@@ -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://<bucket>.cos.<region>.myqcloud.com`
|
||||
- `downloadFile 合法域名`:图片访问域名
|
||||
|
||||
如果图片访问也走 COS 源站,那么 `downloadFile 合法域名` 同样加:
|
||||
|
||||
- `https://<bucket>.cos.<region>.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 <admin-token>
|
||||
|
||||
{
|
||||
"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`。
|
||||
|
||||
这样后面扩展、迁移、审计都会更稳。
|
||||
@@ -14,27 +14,36 @@
|
||||
<!-- Circular logo -->
|
||||
<view class="logo-circle">
|
||||
<image
|
||||
v-if="logoImage"
|
||||
class="logo-img"
|
||||
:src="logoImage"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="logo-placeholder">
|
||||
<text>{{ studioName.slice(0, 1) || 'F' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Studio name -->
|
||||
<text class="studio-name">Focus Core</text>
|
||||
<text class="studio-name">{{ studioName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { StudioConfig } from '@mp-pilates/shared'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
studioInfo: StudioConfig | null
|
||||
}>()
|
||||
|
||||
const bannerImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/bannerBg.jpg'
|
||||
const logoImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/logo.jpg'
|
||||
const fallbackBannerImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/bannerBg.jpg'
|
||||
const fallbackLogoImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/logo.jpg'
|
||||
|
||||
const bannerImage = computed(() => props.studioInfo?.bannerUrl || fallbackBannerImage)
|
||||
const logoImage = computed(() => props.studioInfo?.logo || fallbackLogoImage)
|
||||
const studioName = computed(() => props.studioInfo?.name || 'Focus Core')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -94,10 +103,16 @@ const logoImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/im
|
||||
}
|
||||
|
||||
.logo-placeholder {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 50%;
|
||||
font-size: 64rpx;
|
||||
font-weight: 800;
|
||||
color: #333;
|
||||
letter-spacing: 4rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.studio-name {
|
||||
|
||||
@@ -37,22 +37,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { StudioConfig } from '@mp-pilates/shared'
|
||||
import {
|
||||
DEFAULT_STUDIO_GALLERY_PHOTOS,
|
||||
type StudioConfig,
|
||||
} from '@mp-pilates/shared'
|
||||
|
||||
const props = defineProps<{
|
||||
studioInfo: StudioConfig | null
|
||||
}>()
|
||||
|
||||
const defaultGalleryPhotos = [
|
||||
'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',
|
||||
]
|
||||
|
||||
const galleryPhotos = computed(() => {
|
||||
const photos = props.studioInfo?.photos?.filter(Boolean) ?? []
|
||||
return photos.length ? photos : defaultGalleryPhotos
|
||||
return photos.length ? photos : [...DEFAULT_STUDIO_GALLERY_PHOTOS]
|
||||
})
|
||||
|
||||
function previewPhoto(index: number) {
|
||||
|
||||
@@ -316,7 +316,7 @@ async function loadData() {
|
||||
adminStore.fetchFlashSales(),
|
||||
adminStore.fetchCardTypes(),
|
||||
])
|
||||
items.value = [...salesResult.data]
|
||||
items.value = [...salesResult.items]
|
||||
total.value = salesResult.total
|
||||
cardTypes.value = [...cardTypesResult]
|
||||
} catch {
|
||||
@@ -329,7 +329,7 @@ async function loadData() {
|
||||
async function reloadSales() {
|
||||
try {
|
||||
const result = await adminStore.fetchFlashSales()
|
||||
items.value = [...result.data]
|
||||
items.value = [...result.items]
|
||||
total.value = result.total
|
||||
} catch {
|
||||
// silent
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,8 @@ import type {
|
||||
FlashSaleAdminItem,
|
||||
CreateFlashSaleDto,
|
||||
UpdateFlashSaleDto,
|
||||
CreateStudioUploadCredentialDto,
|
||||
StudioUploadCredential,
|
||||
} from '@mp-pilates/shared'
|
||||
|
||||
interface LegacyPaginatedData<T> {
|
||||
@@ -141,6 +143,15 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
return data
|
||||
}
|
||||
|
||||
async function createStudioUploadCredential(
|
||||
dto: CreateStudioUploadCredentialDto,
|
||||
): Promise<StudioUploadCredential> {
|
||||
return post<StudioUploadCredential>(
|
||||
'/admin/studio/upload-credentials',
|
||||
dto as unknown as Record<string, unknown>,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Orders ───────────────────────────────────────────────────────
|
||||
async function fetchAdminOrders(params: {
|
||||
page?: number
|
||||
@@ -278,6 +289,7 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
// Studio
|
||||
fetchStudioConfig,
|
||||
saveStudioConfig,
|
||||
createStudioUploadCredential,
|
||||
// Orders
|
||||
fetchAdminOrders,
|
||||
// Bookings
|
||||
|
||||
72
packages/app/src/utils/studio-upload.ts
Normal file
72
packages/app/src/utils/studio-upload.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type {
|
||||
CreateStudioUploadCredentialDto,
|
||||
StudioAssetType,
|
||||
StudioUploadCredential,
|
||||
} from '@mp-pilates/shared'
|
||||
import type { useAdminStore } from '../stores/admin'
|
||||
|
||||
type AdminStore = ReturnType<typeof useAdminStore>
|
||||
|
||||
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<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: credential.uploadUrl,
|
||||
filePath,
|
||||
name: 'file',
|
||||
formData: credential.formData as unknown as Record<string, string>,
|
||||
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>([^<]+)<\/Code>/)?.[1]
|
||||
const message = body.match(/<Message>([^<]+)<\/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<string> {
|
||||
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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Record<string, string>>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user