一、使用STS进行临时授权
OSS可以通过阿里云STS(Security Token Service)进行临时授权访问。阿里云STS是为云计算用户提供临时访问令牌的Web服务。通过STS,您可以为第三方应用或子用户(即用户身份由您自己管理的用户)颁发一个自定义时效和权限的访问凭证。关于STS的更多信息,请参见STS介绍。
官方RAM控制台:ram.console.aliyun.com/roles
获取地域信息:help.aliyun.com/zh/oss/user…
官方文档教程:参考,按照步骤完成前4步,复制以下内容
复制账号的AccessKey与AccessKey Secret
复制自定义角色的ARN
我的配置,提前创建了一个叫treasure-web的bucket,给bucket内image和file目录上传文件的权限,这里增加了删除和读的权限,后续会用到
{
"Version": "1",
"Statement": [
{
"Effect": "Allow",
"Action": [
"oss:PutObject",
"oss:DeleteObject",
"oss:GetObject"
],
"Resource": [
"acs:oss:*:*:treasure-web/image/*",
"acs:oss:*:*:treasure-web/file/*"
]
}
]
}
添加跨域规则,部署后来源填写域名即可
二、使用SDK
1. 安装依赖
# oss
npm i ali-oss
npm i --save-dev @types/ali-oss
# 上传文件组件
npm i react-dropzone
2. 环境变量
.env
添加配置,同时追加到.env.example
- AccessKeyId:子账户的密钥Id
- AccessKeySecret:子账户的密钥
- RoleArn:获取临时访问凭证的角色的ARN
- Bucket/NEXT_PUBLIC_Bucket:Bucket名称
- Region/NEXT_PUBLIC_Region:地域名称
#oss
AccessKeyId="LTA..."
AccessKeySecret="kreE..."
RoleArn="acs:ram::1...:role/..."
Bucket="..."
Region="..."
NEXT_PUBLIC_Bucket="..."
NEXT_PUBLIC_Region="..."
3. 远程模式
上传到oss的图片需要展示,所以在next.config.js配置image.remotePatterns
{
protocol: 'http',
hostname: 'treasure-web.oss-cn-beijing.aliyuncs.com',
},
4. 上传组件
上传组件使用react-dropzone,可以点击或拖放上传文件,官方示例
import React, {useCallback} from 'react'
import {useDropzone} from 'react-dropzone'
function MyDropzone() {
const onDrop = useCallback(acceptedFiles => {
// Do something with the files
}, [])
const {getRootProps, getInputProps, isDragActive} = useDropzone({onDrop})
return (
<div {...getRootProps()}>
<input {...getInputProps()} />
{
isDragActive ?
<p>Drop the files here ...</p> :
<p>Drag 'n' drop some files here, or click to select files</p>
}
</div>
)
}
按照官方示例改造UploadAvatar
组件,触发Drop后执行逻辑:
- 判空并获取第一个文件
- 文件类型大小判断
- 获取临时凭证
- 凭证正常开始上传
- 上传后得到url,我们把filePath更新到数据库
// src/components/UploadAvatar.tsx
// 以下逻辑全部为新增
// 更新图片
const { mutate: startUpdateImage, isLoading: updateImageLoading } =
trpc.updateImage.useMutation({
onSuccess: (data) => {
if (data?.image) {
setImage(data.image ?? undefined)
}
toast({
title: getMessages('10042'),
description: getMessages('10043'),
variant: 'default',
})
},
onError: (error) => {
toast({
title: getMessages('10042'),
description: error.message,
variant: 'destructive',
})
},
})
// 获取临时凭证
const { mutateAsync: asyncGetStsToken } = trpc.getStsToken.useMutation()
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) {
return
}
if (updateImageLoading || removeImageLoading) {
return
}
const file = acceptedFiles[0]
// 检查文件类型,仅接受图片
if (!file.type.startsWith('image/')) {
toast({
title: getMessages('10042'),
description: getMessages('10046'),
variant: 'destructive',
})
return
}
// 文件最大为5MB
if (file.size > 5 * 1024 * 1024) {
toast({
title: getMessages('10042'),
description: getMessages('10047'),
variant: 'destructive',
})
return
}
// 获取临时凭证
let stsToken = null
try {
const res = await asyncGetStsToken()
stsToken = res
} catch (error) {
const trpcError = error as ManualTRPCError
toast({
title: getMessages('10042'),
description: trpcError.message,
variant: 'destructive',
})
return
}
// 使用临时凭证上传文件
if (!stsToken) {
return
}
try {
// 生成随机的文件名
const randomFileName =
Math.random().toString(36).substring(2, 15) +
'.' +
file.type.split('/')[1]
const filePath = 'image/' + randomFileName
const url = await uploadOSS({
file,
filePath,
...stsToken,
})
if (url) {
startUpdateImage({ image: filePath })
}
} catch (error) {
toast({
title: getMessages('10042'),
description: getMessages('10045'),
variant: 'destructive',
})
return
}
},
[
asyncGetStsToken,
removeImageLoading,
startUpdateImage,
toast,
updateImageLoading,
]
)
const { getRootProps, getInputProps } = useDropzone({ onDrop })
// JSX,给相应的可以点击和放置的元素设置getRootProps,增加一个input元素
<div
{...getRootProps()}
className="rounded-full w-full h-full flex items-center justify-center border border-zinc-200 relative bg-zinc-50 overflow-hidden cursor-pointer"
>
//...
<input {...getInputProps()} />
</div>
// 删除图片增加判断
onClick={() => {
if (updateImageLoading || removeImageLoading) {
return
}
startRemoveImage()
}}
5. 封装oss服务端
封装三个方法:
- getStsToken:获取临时凭证
- getSignedUrl:url签名
- deleteObject:删除文件
签名和删除都将给有权限的用户操作,在STS的assumeRole
方法中,token的有效期是以秒为单位的。所以,如果你设置的有效期为3000秒,那么实际的有效期就是3000秒,也就是50分钟。
refreshSTSTokenInterval
被设置为900000毫秒,也就是15分钟。这意味着在token过期前的15分钟,refreshSTSToken
函数将被调用以刷新token。这样就能确保在当前的token过期之前,新的token已经被获取并可以使用了。
// src/lib/ossServer.ts
import OSS, { STS } from 'ali-oss'
const sts = new STS({
// 填写步骤1创建的RAM用户AccessKey。
accessKeyId: process.env.AccessKeyId!,
accessKeySecret: process.env.AccessKeySecret!,
})
export const getStsToken = async () => {
try {
// roleArn填写步骤2获取的角色ARN,例如acs:ram::175708322470****:role/ramtest。
// policy填写自定义权限策略,用于进一步限制STS临时访问凭证的权限。如果不指定Policy,则返回的STS临时访问凭证默认拥有指定角色的所有权限。
// 临时访问凭证最后获得的权限是步骤4设置的角色权限和该Policy设置权限的交集。
// expiration用于设置临时访问凭证有效时间单位为秒,最小值为900,最大值以当前角色设定的最大会话时间为准。本示例指定有效时间为3000秒。
// sessionName用于自定义角色会话名称,用来区分不同的令牌,例如填写为sessiontest。
const result = await sts.assumeRole(
process.env.RoleArn!,
``,
3000,
'sessiontest'
)
return {
AccessKeyId: result.credentials.AccessKeyId,
AccessKeySecret: result.credentials.AccessKeySecret,
SecurityToken: result.credentials.SecurityToken,
Expiration: result.credentials.Expiration,
}
} catch (err) {
return null
}
}
export const getOssClient = async () => {
const token = await getStsToken()
if (token) {
const client = new OSS({
region: process.env.Region!,
accessKeyId: token.AccessKeyId,
accessKeySecret: token.AccessKeySecret,
stsToken: token.SecurityToken,
bucket: process.env.Bucket!,
refreshSTSToken: async () => {
const newToken = await getStsToken()
if (newToken) {
return {
accessKeyId: newToken.AccessKeyId,
accessKeySecret: newToken.AccessKeySecret,
stsToken: newToken.SecurityToken,
}
} else {
throw new Error('获取临时token失败,请重试')
}
},
refreshSTSTokenInterval: 900000, // Start refreshing the token 15 minutes before it expires
})
return client
} else {
throw new Error('获取临时token失败,请重试')
}
}
export const deleteObject = async (objectKey: string) => {
const client = await getOssClient()
// 填写Object完整路径。Object完整路径中不能包含Bucket名称。
return client.delete(objectKey)
}
export const getSignedUrl = async (objectKey: string) => {
const client = await getOssClient()
return client.signatureUrl(objectKey, { expires: 3600 })
}
6. 封装oss客户端
封装上传文件的方法,由客户端执行,需要先拿到临时凭证的数据,然后上传
// src/lib/ossClient.ts
import OSS from 'ali-oss'
interface UploadProps {
AccessKeyId: string
AccessKeySecret: string
SecurityToken: string
file: File
filePath: string
}
export const uploadOSS = async (data: UploadProps) => {
const { file, AccessKeyId, AccessKeySecret, SecurityToken, filePath } = data
const client = new OSS({
// yourRegion填写Bucket所在地域。以华东1(杭州)为例,yourRegion填写为oss-cn-hangzhou。
region: process.env.NEXT_PUBLIC_Region!,
// 从STS服务获取的临时访问密钥(AccessKey ID和AccessKey Secret)。
accessKeyId: AccessKeyId,
accessKeySecret: AccessKeySecret,
// 从STS服务获取的安全令牌(SecurityToken)。
stsToken: SecurityToken,
// 填写Bucket名称。
bucket: process.env.NEXT_PUBLIC_Bucket!,
})
try {
// 填写Object完整路径。Object完整路径中不能包含Bucket名称。
// 例如example/test.txt。
const result = await client.put(filePath, file)
return result.url
} catch (err) {
return null
}
}
7. API逻辑
获取临时凭证,直接调用服务端的getStsToken
方法
// src/trpc/index.ts
getStsToken: privateProcedure.mutation(async () => {
try {
const data = await getStsToken()
if (!data) {
throw new ManualTRPCError('INTERNAL_SERVER_ERROR', getMessages('10044'))
}
return data
} catch (error) {
handleErrorforInitiative(error)
}
}),
更新图片,给客户端返回签名后的image
,否则没有权限访问
// src/trpc/index.ts
// 注意:保存到数据库的地址应该是从不包括bucket的完整路径,如上传到bucket下的image文件夹,路径就是/image/test.png
updateImage: privateProcedure
.input(z.object({ image: z.string() }))
.mutation(async ({ ctx, input }) => {
try {
const { userId } = ctx
const { image } = input
const originalUserInfo = await db.user.findUnique({
where: {
id: userId,
},
})
const originalImage = originalUserInfo?.image
if (originalImage) {
await deleteObject(originalImage)
}
const userInfo = await db.user.update({
where: {
id: userId,
},
data: {
image: image,
},
})
const signedUrl = await getSignedUrl(image)
return {
id: userInfo.id,
image: signedUrl,
}
} catch (error) {
handleErrorforInitiative(error)
}
}),
查询用户信息时修改image
// src/trpc/index.ts
if (userInfo && userInfo.image) {
userInfo.image = await getSignedUrl(userInfo.image)
}
最后补足删除图片时删除oss图片
if (originalImage) {
await deleteObject(originalImage)
}
8. 补充文案
'10044': '获取临时凭证错误',
'10045': '头像上传失败',
'10046': '图片格式不正确',
'10047': '图片大小不能超过5M',
三、进度条
参考:help.aliyun.com/zh/oss/deve…
您可以通过简单上传(即putObject方式)将File对象、Blob数据以及OSS Buffer上传到OSS文件。简单上传时不支持使用进度函数。
所以,我们接下来处理一个接口没有进度条属性的情况
# 增加shadcn进度条组件
npx shadcn-ui@latest add progress
但是progress不能根据长度设置颜色,自定义一个属性indicatorColor
// src/components/ui/progress.tsx
'use client'
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
type ProgressProps = React.ComponentPropsWithoutRef<
typeof ProgressPrimitive.Root
> & {
indicatorColor?: string
}
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
ProgressProps
>(({ className, value, indicatorColor, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn(
'h-full w-full flex-1 bg-primary transition-all',
indicatorColor
)}
style={{
transform: `translateX(-${100 - (value || 0)}%)`,
}}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
完成进度条:
- 进度为0进度条不显示
- 开始上传时开始定时任务,每500ms增加5%
- 增加到95%时不再增加,如果没有完成一直处于这个状态
- 如果提前完成了直接到100%
- 完成后做定时是为了隐藏进度条
// src/components/UploadAvatar.tsx
// 定义进度条与计时
const [uploadProgress, setUploadProgress] = useState<number>(0)
const progressRefInterval = useRef<number | null>(null)
const startSimulatedProgress = () => {
setUploadProgress(0)
const interval = setInterval(() => {
setUploadProgress((prevProgress) => {
if (prevProgress >= 95) {
clearInterval(interval)
return prevProgress
}
return prevProgress + 5
})
}, 500)
return interval as unknown as number
}
// 定义清除定时
const clearIntervals = (process: number) => {
if (progressRefInterval.current) {
clearInterval(progressRefInterval.current)
progressRefInterval.current = null
}
setUploadProgress(process)
if (process === 100) {
setTimeout(() => {
setUploadProgress(0)
}, 2000)
}
}
// 卸载清空定时
useEffect(() => {
return () => {
if (progressRefInterval.current) {
clearInterval(progressRefInterval.current)
progressRefInterval.current = null
}
}
}, [])
// 进度条组件
{uploadProgress !== 0 && (
<Progress
indicatorColor={uploadProgress === 100 ? 'bg-green-500' : ''}
value={uploadProgress}
className="h-2 w-64 mt-2 bg-zinc-200"
/>
)}
最后在关键位置开始计时与清空计时
// 获取临时凭证前开始计时
progressRefInterval.current = startSimulatedProgress()
// 最终更新成功后清除定时,1处
clearIntervals(100)
// 上传过程中的错误处理,4处(3catch,1没有获取到凭证)
clearIntervals(0)
前面处理了上传和删除的禁用,结合进度条完善禁用
// 删除/更新
if (updateImageLoading || removeImageLoading || uploadProgress !== 0) {
return
}
四、客户端刷新sts
// src/lib/ossClient.ts
import OSS from 'ali-oss'
interface UploadProps {
AccessKeyId: string
AccessKeySecret: string
SecurityToken: string
file: File
filePath: string
}
export const uploadOSS = async (
data: UploadProps,
clientGetStsToken: () => Promise<{
AccessKeyId: string
AccessKeySecret: string
SecurityToken: string
Expiration: string
} | null>
) => {
const { file, AccessKeyId, AccessKeySecret, SecurityToken, filePath } = data
const ossClient = new OSS({
region: process.env.NEXT_PUBLIC_Region!,
accessKeyId: AccessKeyId,
accessKeySecret: AccessKeySecret,
stsToken: SecurityToken,
bucket: process.env.NEXT_PUBLIC_Bucket!,
refreshSTSToken: async () => {
try {
const newToken = await clientGetStsToken()
if (newToken) {
return {
accessKeyId: newToken.AccessKeyId,
accessKeySecret: newToken.AccessKeySecret,
stsToken: newToken.SecurityToken,
}
} else {
throw new Error('Failed to refresh STS token')
}
} catch (err) {
console.error(err)
throw new Error('Failed to refresh STS token')
}
},
refreshSTSTokenInterval: 900000, // Start refreshing the token 15 minutes before it expires
})
try {
const result = await ossClient.put(filePath, file)
return result.url
} catch (err) {
return null
}
}
更新调用,增加第二个参数
const url = await uploadOSS(
{
file,
filePath,
...stsToken,
},
async () => {
const ret = await asyncGetStsToken()
if (ret) {
return ret
}
return null
}
)
五、创建迁移文件
接下来生成迁移,并提交项目到git,为部署做准备。
在开发过程中,随着数据库模式的改变,确实可能会产生许多的迁移文件
这是正常的,因为每次你修改数据库模式并运行迁移时,Prisma 都会创建一个新的迁移文件。这些文件记录了数据库结构的历史变化,可以帮助你理解数据库模式如何随时间变化。
然而,你不需要过于担心迁移文件的数量。虽然在大型项目中,迁移文件可能会变得很多,但是这些文件通常很小,并且只在运行迁移时被使用。在大多数情况下,这些文件的数量不会对你的项目或数据库性能产生负面影响。
如果你觉得迁移文件太多,难以管理,你可以考虑合并一些相关的迁移。但是,这需要谨慎处理,因为合并迁移可能会改变数据库的状态,可能会导致问题。在合并迁移之前,最好在一个安全的环境中(例如备份的数据库或开发环境)测试迁移,以确保它们的行为符合预期。
在生产环境中,你通常只会运行
prisma migrate deploy
命令,这个命令会自动处理所有未应用的迁移,所以你不需要担心手动管理迁移文件。
在进行数据库迁移时,有多种可能导致数据丢失的情况。以下是一些常见的例子
- 删除表:如果你删除了一个表,那么该表中的所有数据都会被删除。
- 删除列:如果你删除了一个列,那么该列中的所有数据都会被删除。
- 修改列类型:如果你更改了一个列的类型,例如从字符串类型更改为整数类型,那么如果原始数据无法转换为新的类型,原始数据可能会丢失。
- 添加非空字段:如果你在一个已经包含数据的表中添加了一个新的、必填(非空)的字段,并且没有为该字段提供默认值,那么在尝试插入新记录时可能会出现错误,因为新的字段没有值。
- 主键更改:如果你更改了表的主键,那么可能会导致数据丢失。这是因为主键用于唯一标识记录,如果你更改了主键,可能会导致数据的一致性问题。
- 外键约束:如果你删除或更改了外键约束,可能会导致数据丢失。例如,如果你有一个外键约束,该约束要求在父表中必须存在一个匹配的记录,那么在删除父表中的记录时,可能会导致子表中的相关记录被删除。
- 修改数据:如果你在迁移过程中执行了修改数据的操作,例如更新或删除操作,那么可能会导致数据丢失。
- 并发问题:如果你在迁移过程中没有正确处理并发问题,可能会导致数据丢失。例如,如果你在迁移过程中同时修改了同一条记录,可能会导致数据的一致性问题。
以上就是一些可能导致数据丢失的情况。在进行数据库迁移时,你应该非常小心,以避免这些问题。在实施任何可能影响到数据的更改之前,你应该备份你的数据,并在一个与生产环境相似的测试环境中测试你的迁移。
npx prisma migrate dev --name init
六、Fix
页面是通过 HTTPS 加载的,但尝试加载一个 HTTP 资源,这是不安全的。现代浏览器会阻止这种“混合内容”加载,因为它会降低整个页面的安全性。
所以在ossClient和ossServer实例化OSS的时候添加属性:
secure: true, // 确保使用 HTTPS
配置nextl.conf.js的protocol设置为https
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'treasure-web.oss-cn-beijing.aliyuncs.com',
},
],
},
升级策略,不允许使用http访问
教程示例:基于RAM Policy实现某个用户仅通过HTTPS方式访问OSS资源
{
"Version": "1",
"Statement": [
{
"Effect": "Allow",
"Action": [
"oss:PutObject",
"oss:DeleteObject",
"oss:GetObject"
],
"Resource": [
"acs:oss:*:*:treasure-web/image/*",
"acs:oss:*:*:treasure-web/file/*"
]
},
{
"Effect": "Deny",
"Action": "oss:*",
"Resource": "acs:oss:*:*:*",
"Condition": {
"Bool": {
"acs:SecureTransport": [
"false"
]
}
}
}
]
}