web端打通oss资源上传
This commit is contained in:
@@ -23,7 +23,7 @@ WX_MCH_PRIVATE_KEY=
|
||||
WX_API_V3_KEY=
|
||||
WX_PAY_NOTIFY_URL=https://your-domain.com/api/v1/callbacks/wechat/pay
|
||||
|
||||
# 阿里云 OSS(OSS_ENABLED=true 且下方密钥齐全时生效;否则 Mock 占位 URL)
|
||||
# 阿里云 OSS(ali-oss@6.x;OSS_ENABLED=true 且下方密钥齐全时生效;否则 Mock 占位 URL)
|
||||
# RAM 用户需具备 PutObject 权限;可在控制台 Bucket 授权策略中为该 RAM UID 授予读写
|
||||
# 文档:https://help.aliyun.com/zh/oss/user-guide/use-bucket-policy-to-grant-permission-to-access-oss/
|
||||
OSS_ENABLED=false
|
||||
@@ -31,6 +31,10 @@ OSS_ACCESS_KEY_ID=
|
||||
OSS_ACCESS_KEY_SECRET=
|
||||
OSS_BUCKET=
|
||||
OSS_REGION=oss-cn-hangzhou
|
||||
# 可选:自定义 Endpoint(如内网 oss-cn-hangzhou-internal.aliyuncs.com)
|
||||
OSS_ENDPOINT=
|
||||
# 可选:开启 V4 签名(需同步调整前端 PostObject 字段,默认 false 使用 V1)
|
||||
OSS_AUTHORIZATION_V4=false
|
||||
# 公网访问域名:自定义 CDN 或 https://{bucket}.{region}.aliyuncs.com
|
||||
OSS_CDN_BASE=
|
||||
# 直传对象前缀(默认 uploads)
|
||||
@@ -38,4 +42,6 @@ OSS_UPLOAD_PREFIX=uploads
|
||||
# 直传凭证有效期(秒,默认 900)
|
||||
OSS_UPLOAD_EXPIRE_SECONDS=900
|
||||
# 单文件大小上限(字节,默认 10MB)
|
||||
OSS_MAX_UPLOAD_BYTES=10485760
|
||||
OSS_MAX_UPLOAD_BYTES=10485760
|
||||
# 浏览器直传 OSS 时需配置 Bucket CORS;运行 pnpm oss:cors 或控制台手动添加
|
||||
# OSS_CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,https://your-domain.com
|
||||
@@ -24,6 +24,7 @@
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"@prisma/client": "^5.18.0",
|
||||
"ali-oss": "^6.23.0",
|
||||
"bullmq": "^5.12.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
@@ -35,7 +36,9 @@
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@nestjs/schematics": "^10.1.0",
|
||||
"@types/ali-oss": "^6.23.3",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"prisma": "^5.18.0",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import OSS = require('ali-oss');
|
||||
|
||||
export type AliyunOssClientConfig = {
|
||||
accessKeyId: string;
|
||||
accessKeySecret: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
endpoint?: string;
|
||||
authorizationV4?: boolean;
|
||||
};
|
||||
|
||||
export function createAliyunOssClient(config: AliyunOssClientConfig): OSS {
|
||||
return new OSS({
|
||||
region: config.region,
|
||||
accessKeyId: config.accessKeyId,
|
||||
accessKeySecret: config.accessKeySecret,
|
||||
bucket: config.bucket,
|
||||
...(config.endpoint ? { endpoint: config.endpoint } : {}),
|
||||
...(config.authorizationV4 ? { authorizationV4: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveOssUploadHost(bucket: string, region: string): string {
|
||||
return `https://${bucket}.${region}.aliyuncs.com`;
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { createHmac } from 'crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { IOssProvider, OssUploadTokenInput, OssUploadTokenResult } from './oss.interface';
|
||||
import type {
|
||||
IOssProvider,
|
||||
OssPutObjectInput,
|
||||
OssPutObjectResult,
|
||||
OssUploadTokenInput,
|
||||
OssUploadTokenResult,
|
||||
} from './oss.interface';
|
||||
import { buildOssObjectKey } from './oss.key.util';
|
||||
import { createAliyunOssClient, resolveOssUploadHost } from './oss.aliyun.client';
|
||||
|
||||
const DEFAULT_EXPIRE_SECONDS = 15 * 60;
|
||||
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
||||
@@ -13,43 +19,62 @@ export class OssAliyunProvider implements IOssProvider {
|
||||
private readonly bucket = process.env.OSS_BUCKET ?? '';
|
||||
private readonly region = process.env.OSS_REGION ?? 'oss-cn-hangzhou';
|
||||
private readonly cdnBase = process.env.OSS_CDN_BASE ?? '';
|
||||
private readonly endpoint = process.env.OSS_ENDPOINT ?? '';
|
||||
private readonly uploadPrefix = (process.env.OSS_UPLOAD_PREFIX ?? 'uploads').replace(/\/$/, '');
|
||||
private readonly expireSeconds = Number(process.env.OSS_UPLOAD_EXPIRE_SECONDS ?? DEFAULT_EXPIRE_SECONDS);
|
||||
private readonly maxUploadBytes = Number(process.env.OSS_MAX_UPLOAD_BYTES ?? DEFAULT_MAX_BYTES);
|
||||
private readonly authorizationV4 = process.env.OSS_AUTHORIZATION_V4 === 'true';
|
||||
private client: ReturnType<typeof createAliyunOssClient> | null = null;
|
||||
|
||||
isEnabled() {
|
||||
return !!(this.accessKeyId && this.accessKeySecret && this.bucket);
|
||||
}
|
||||
|
||||
buildPublicUrl(ossKey: string) {
|
||||
const key = ossKey.replace(/^\//, '');
|
||||
if (this.cdnBase) {
|
||||
return `${this.cdnBase.replace(/\/$/, '')}/${key}`;
|
||||
}
|
||||
return `https://${this.bucket}.${this.region}.aliyuncs.com/${key}`;
|
||||
}
|
||||
|
||||
getUploadToken(dto: OssUploadTokenInput): OssUploadTokenResult {
|
||||
private getClient() {
|
||||
if (!this.isEnabled()) {
|
||||
throw new Error('OSS credentials are not configured');
|
||||
}
|
||||
if (!this.client) {
|
||||
this.client = createAliyunOssClient({
|
||||
accessKeyId: this.accessKeyId,
|
||||
accessKeySecret: this.accessKeySecret,
|
||||
bucket: this.bucket,
|
||||
region: this.region,
|
||||
endpoint: this.endpoint || undefined,
|
||||
authorizationV4: this.authorizationV4,
|
||||
});
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
const ext = dto.fileName.includes('.') ? dto.fileName.split('.').pop() : 'bin';
|
||||
const dir = `${this.uploadPrefix}/${dto.bizType.toLowerCase()}/`;
|
||||
const ossKey = `${dir}${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`;
|
||||
buildPublicUrl(ossKey: string) {
|
||||
const key = ossKey.replace(/^\//, '');
|
||||
const client = this.isEnabled() ? this.getClient() : null;
|
||||
if (client) {
|
||||
return client.generateObjectUrl(key, this.cdnBase || undefined);
|
||||
}
|
||||
if (this.cdnBase) {
|
||||
return `${this.cdnBase.replace(/\/$/, '')}/${key}`;
|
||||
}
|
||||
return `${resolveOssUploadHost(this.bucket, this.region)}/${key}`;
|
||||
}
|
||||
|
||||
getUploadToken(dto: OssUploadTokenInput): OssUploadTokenResult {
|
||||
const client = this.getClient();
|
||||
const ossKey = buildOssObjectKey(this.uploadPrefix, dto.bizType, dto.fileName);
|
||||
const expireAt = new Date(Date.now() + this.expireSeconds * 1000);
|
||||
const host = `https://${this.bucket}.${this.region}.aliyuncs.com`;
|
||||
const host = resolveOssUploadHost(this.bucket, this.region);
|
||||
|
||||
const policy = {
|
||||
expiration: expireAt.toISOString(),
|
||||
conditions: [
|
||||
['content-length-range', 0, this.maxUploadBytes],
|
||||
['eq', '$bucket', this.bucket],
|
||||
['starts-with', '$key', dir],
|
||||
['starts-with', '$key', `${this.uploadPrefix}/${dto.bizType.toLowerCase()}/`],
|
||||
],
|
||||
};
|
||||
const policyBase64 = Buffer.from(JSON.stringify(policy)).toString('base64');
|
||||
const signature = createHmac('sha1', this.accessKeySecret).update(policyBase64).digest('base64');
|
||||
|
||||
const signed = client.calculatePostSignature(policy);
|
||||
|
||||
return {
|
||||
bucket: this.bucket,
|
||||
@@ -61,9 +86,24 @@ export class OssAliyunProvider implements IOssProvider {
|
||||
mediaType: dto.mediaType,
|
||||
bizType: dto.bizType,
|
||||
host,
|
||||
policy: policyBase64,
|
||||
signature,
|
||||
accessKeyId: this.accessKeyId,
|
||||
policy: signed.policy,
|
||||
signature: signed.Signature,
|
||||
accessKeyId: signed.OSSAccessKeyId,
|
||||
};
|
||||
}
|
||||
|
||||
async putObject(input: OssPutObjectInput): Promise<OssPutObjectResult> {
|
||||
const client = this.getClient();
|
||||
const ossKey = buildOssObjectKey(this.uploadPrefix, input.bizType, input.fileName);
|
||||
await client.put(ossKey, input.buffer, {
|
||||
mime: input.mimeType || 'application/octet-stream',
|
||||
});
|
||||
return {
|
||||
bucket: this.bucket,
|
||||
region: this.region,
|
||||
ossKey,
|
||||
url: this.buildPublicUrl(ossKey),
|
||||
mock: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,25 @@ export interface OssUploadTokenResult {
|
||||
accessKeyId?: string;
|
||||
}
|
||||
|
||||
export interface OssPutObjectInput {
|
||||
bizType: string;
|
||||
mediaType: string;
|
||||
fileName: string;
|
||||
buffer: Buffer;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface OssPutObjectResult {
|
||||
bucket: string;
|
||||
region: string;
|
||||
ossKey: string;
|
||||
url: string;
|
||||
mock: boolean;
|
||||
}
|
||||
|
||||
export interface IOssProvider {
|
||||
isEnabled(): boolean;
|
||||
getUploadToken(input: OssUploadTokenInput): OssUploadTokenResult;
|
||||
putObject(input: OssPutObjectInput): Promise<OssPutObjectResult>;
|
||||
buildPublicUrl(ossKey: string): string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export function buildOssObjectKey(uploadPrefix: string, bizType: string, fileName: string): string {
|
||||
const ext = fileName.includes('.') ? fileName.split('.').pop() : 'bin';
|
||||
const dir = `${uploadPrefix.replace(/\/$/, '')}/${bizType.toLowerCase()}/`;
|
||||
return `${dir}${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`;
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { IOssProvider, OssUploadTokenInput, OssUploadTokenResult } from './oss.interface';
|
||||
import type {
|
||||
IOssProvider,
|
||||
OssPutObjectInput,
|
||||
OssPutObjectResult,
|
||||
OssUploadTokenInput,
|
||||
OssUploadTokenResult,
|
||||
} from './oss.interface';
|
||||
import { buildOssObjectKey } from './oss.key.util';
|
||||
|
||||
@Injectable()
|
||||
export class OssMockProvider implements IOssProvider {
|
||||
@@ -17,8 +23,7 @@ export class OssMockProvider implements IOssProvider {
|
||||
}
|
||||
|
||||
getUploadToken(dto: OssUploadTokenInput): OssUploadTokenResult {
|
||||
const ext = dto.fileName.includes('.') ? dto.fileName.split('.').pop() : 'bin';
|
||||
const key = `uploads/${dto.bizType.toLowerCase()}/${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`;
|
||||
const key = buildOssObjectKey('uploads', dto.bizType, dto.fileName);
|
||||
return {
|
||||
bucket: this.bucket,
|
||||
region: this.region,
|
||||
@@ -30,4 +35,15 @@ export class OssMockProvider implements IOssProvider {
|
||||
bizType: dto.bizType,
|
||||
};
|
||||
}
|
||||
|
||||
async putObject(input: OssPutObjectInput): Promise<OssPutObjectResult> {
|
||||
const ossKey = buildOssObjectKey('uploads', input.bizType, input.fileName);
|
||||
return {
|
||||
bucket: this.bucket,
|
||||
region: this.region,
|
||||
ossKey,
|
||||
url: this.buildPublicUrl(ossKey),
|
||||
mock: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,16 @@ export class UploadTokenDto {
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export class UploadFileDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
bizType: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['IMAGE', 'VIDEO', 'FILE'])
|
||||
mediaType: string;
|
||||
}
|
||||
|
||||
export class RegisterResourceDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { ResourceService } from './resource.service';
|
||||
import { ResourceListQueryDto } from './dto/common-query.dto';
|
||||
import { RegisterResourceDto, UpdateResourceDto, UploadTokenDto } from './dto/common-mutate.dto';
|
||||
import { RegisterResourceDto, UpdateResourceDto, UploadFileDto, UploadTokenDto } from './dto/common-mutate.dto';
|
||||
|
||||
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
@Controller('common/resources')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -14,6 +29,16 @@ export class ResourceController {
|
||||
return this.service.getUploadToken(dto);
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: Number(process.env.OSS_MAX_UPLOAD_BYTES ?? DEFAULT_MAX_BYTES) },
|
||||
}),
|
||||
)
|
||||
upload(@UploadedFile() file: Express.Multer.File, @Body() dto: UploadFileDto) {
|
||||
return this.service.uploadFile(file, dto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
register(@Body() dto: RegisterResourceDto) {
|
||||
return this.service.register(dto);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
@@ -6,7 +6,9 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
||||
import { OSS_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
||||
import type { ResourceListQueryDto } from './dto/common-query.dto';
|
||||
import type { RegisterResourceDto, UpdateResourceDto, UploadTokenDto } from './dto/common-mutate.dto';
|
||||
import type { RegisterResourceDto, UpdateResourceDto, UploadFileDto, UploadTokenDto } from './dto/common-mutate.dto';
|
||||
|
||||
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
@Injectable()
|
||||
export class ResourceService {
|
||||
@@ -19,6 +21,23 @@ export class ResourceService {
|
||||
return this.oss.getUploadToken(dto);
|
||||
}
|
||||
|
||||
async uploadFile(file: Express.Multer.File | undefined, dto: UploadFileDto) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('请选择要上传的文件');
|
||||
}
|
||||
const maxUploadBytes = Number(process.env.OSS_MAX_UPLOAD_BYTES ?? DEFAULT_MAX_BYTES);
|
||||
if (file.size > maxUploadBytes) {
|
||||
throw new BadRequestException(`文件不能超过 ${Math.floor(maxUploadBytes / 1024 / 1024)}MB`);
|
||||
}
|
||||
return this.oss.putObject({
|
||||
bizType: dto.bizType,
|
||||
mediaType: dto.mediaType,
|
||||
fileName: file.originalname || 'upload.bin',
|
||||
buffer: file.buffer,
|
||||
mimeType: file.mimetype,
|
||||
});
|
||||
}
|
||||
|
||||
async register(dto: RegisterResourceDto) {
|
||||
const resource = await this.prisma.commonResource.create({
|
||||
data: {
|
||||
|
||||
Reference in New Issue
Block a user