dev
This commit is contained in:
@@ -91,6 +91,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { createMerchantRoom, updateMerchantRoom, getMerchantRoom } from '@/api/room';
|
||||
import { chooseAndUpload } from '@/utils/upload';
|
||||
|
||||
const loading = ref(false);
|
||||
const roomId = ref<number | null>(null);
|
||||
@@ -178,16 +179,21 @@ function toggleFacility(f: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function chooseImage() {
|
||||
uni.chooseImage({
|
||||
count: 20 - imageList.value.length,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
// 暂用本地路径,实际需上传到服务器
|
||||
imageList.value.push(...res.tempFilePaths);
|
||||
},
|
||||
});
|
||||
async function chooseImage() {
|
||||
const remaining = 20 - imageList.value.length;
|
||||
if (remaining <= 0) {
|
||||
uni.showToast({ title: '最多上传20张图片', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
uni.showLoading({ title: '上传中...' });
|
||||
const urls = await chooseAndUpload({ count: remaining, useSellerToken: true });
|
||||
imageList.value.push(...urls);
|
||||
} catch (err: any) {
|
||||
uni.showToast({ title: err.message || '上传失败', icon: 'none' });
|
||||
} finally {
|
||||
uni.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
|
||||
@@ -117,6 +117,7 @@ import { ref } from 'vue';
|
||||
import { applyMerchant } from '@/api/merchant';
|
||||
import { useSellerStore } from '@/store/seller';
|
||||
import RegionSelector from '@/components/RegionSelector.vue';
|
||||
import { chooseAndUpload } from '@/utils/upload';
|
||||
|
||||
const sellerStore = useSellerStore();
|
||||
|
||||
@@ -145,32 +146,17 @@ function deleteLicense() {
|
||||
form.value.businessLicense = '';
|
||||
}
|
||||
|
||||
function uploadLicense() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
const tempFilePath = res.tempFilePaths[0];
|
||||
// 模拟上传,实际需调用后端上传接口
|
||||
// 这里暂时用本地路径模拟,实际项目需要上传到服务器获取URL
|
||||
uploadImageToServer(tempFilePath);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function uploadImageToServer(filePath: string) {
|
||||
uni.showLoading({ title: '上传中...' });
|
||||
|
||||
// 实际项目中需要调用后端上传接口
|
||||
// 这里模拟上传成功,使用临时路径作为演示
|
||||
// TODO: 实现真实的文件上传接口
|
||||
setTimeout(() => {
|
||||
uni.hideLoading();
|
||||
// 模拟上传成功后的URL
|
||||
form.value.businessLicense = filePath;
|
||||
async function uploadLicense() {
|
||||
try {
|
||||
uni.showLoading({ title: '上传中...' });
|
||||
const urls = await chooseAndUpload({ count: 1, useSellerToken: true });
|
||||
form.value.businessLicense = urls[0];
|
||||
uni.showToast({ title: '上传成功', icon: 'success' });
|
||||
}, 1000);
|
||||
} catch (err: any) {
|
||||
uni.showToast({ title: err.message || '上传失败', icon: 'none' });
|
||||
} finally {
|
||||
uni.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
|
||||
@@ -116,6 +116,7 @@ import { ref, onMounted } from 'vue';
|
||||
import { updateMerchant, getMyMerchant } from '@/api/merchant';
|
||||
import { useSellerStore } from '@/store/seller';
|
||||
import RegionSelector from '@/components/RegionSelector.vue';
|
||||
import { chooseAndUpload } from '@/utils/upload';
|
||||
|
||||
const sellerStore = useSellerStore();
|
||||
|
||||
@@ -166,26 +167,17 @@ function deleteLicense() {
|
||||
form.value.businessLicense = '';
|
||||
}
|
||||
|
||||
function uploadLicense() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
const tempFilePath = res.tempFilePaths[0];
|
||||
uploadImageToServer(tempFilePath);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function uploadImageToServer(filePath: string) {
|
||||
uni.showLoading({ title: '上传中...' });
|
||||
// TODO: 实现真实的文件上传接口
|
||||
setTimeout(() => {
|
||||
uni.hideLoading();
|
||||
form.value.businessLicense = filePath;
|
||||
async function uploadLicense() {
|
||||
try {
|
||||
uni.showLoading({ title: '上传中...' });
|
||||
const urls = await chooseAndUpload({ count: 1, useSellerToken: true });
|
||||
form.value.businessLicense = urls[0];
|
||||
uni.showToast({ title: '上传成功', icon: 'success' });
|
||||
}, 1000);
|
||||
} catch (err: any) {
|
||||
uni.showToast({ title: err.message || '上传失败', icon: 'none' });
|
||||
} finally {
|
||||
uni.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
|
||||
|
||||
export interface UploadResult {
|
||||
url: string;
|
||||
filename: string;
|
||||
originalname: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export function uploadFile(filePath: string, options?: { useSellerToken?: boolean }): Promise<string> {
|
||||
const token = options?.useSellerToken
|
||||
? uni.getStorageSync('sellerToken') || ''
|
||||
: uni.getStorageSync('token') || '';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: `${BASE_URL}/${options?.useSellerToken ? 'seller/' : ''}upload`,
|
||||
filePath,
|
||||
name: 'file',
|
||||
header: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
success: (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
try {
|
||||
const data = JSON.parse(res.data);
|
||||
if (data.code >= 200 && data.code < 300 && data.data?.url) {
|
||||
resolve(data.data.url);
|
||||
} else {
|
||||
reject(new Error(data.message || '上传失败'));
|
||||
}
|
||||
} catch {
|
||||
reject(new Error('解析响应失败'));
|
||||
}
|
||||
} else if (res.statusCode === 401) {
|
||||
reject(new Error('登录已过期'));
|
||||
} else {
|
||||
reject(new Error(`上传失败 (${res.statusCode})`));
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(new Error(err.errMsg || '网络异常'));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function chooseAndUpload(options?: {
|
||||
count?: number;
|
||||
useSellerToken?: boolean;
|
||||
}): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.chooseImage({
|
||||
count: options?.count || 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: async (res) => {
|
||||
const urls: string[] = [];
|
||||
try {
|
||||
for (const path of res.tempFilePaths) {
|
||||
const url = await uploadFile(path, { useSellerToken: options?.useSellerToken });
|
||||
urls.push(url);
|
||||
}
|
||||
resolve(urls);
|
||||
} catch (err: any) {
|
||||
reject(err);
|
||||
}
|
||||
},
|
||||
fail: () => reject(new Error('取消选择图片')),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2,4 +2,8 @@ import request from '@/utils/request';
|
||||
|
||||
export const getServiceFeeConfig = () => request.get('/admin/config/service-fee');
|
||||
|
||||
export const updateServiceFeeConfig = (rate: number) => request.put('/admin/config/service-fee', { rate });
|
||||
export const updateServiceFeeConfig = (rate: number) => request.put('/admin/config/service-fee', { rate });
|
||||
|
||||
export const getStorageConfig = () => request.get('/admin/config/storage');
|
||||
|
||||
export const updateStorageConfig = (data: Record<string, string>) => request.put('/admin/config/storage', data);
|
||||
@@ -0,0 +1,134 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Form, Select, Input, Button, message, Spin, Divider } from 'antd';
|
||||
import { getStorageConfig, updateStorageConfig } from '@/api/config';
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'local', label: '本地存储' },
|
||||
{ value: 'tencent_cos', label: '腾讯云 COS' },
|
||||
{ value: 'aliyun_oss', label: '阿里云 OSS' },
|
||||
];
|
||||
|
||||
const StorageSettings: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [provider, setProvider] = useState<string>('local');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchConfig = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await getStorageConfig();
|
||||
const data = res.data || {};
|
||||
setProvider(data.storage_provider || 'local');
|
||||
form.setFieldsValue({
|
||||
storage_provider: data.storage_provider || 'local',
|
||||
storage_local_path: data.storage_local_path || './uploads',
|
||||
storage_cos_bucket: data.storage_cos_bucket || '',
|
||||
storage_cos_region: data.storage_cos_region || '',
|
||||
storage_cos_secret_id: data.storage_cos_secret_id || '',
|
||||
storage_cos_secret_key: data.storage_cos_secret_key || '',
|
||||
storage_oss_bucket: data.storage_oss_bucket || '',
|
||||
storage_oss_region: data.storage_oss_region || '',
|
||||
storage_oss_access_key_id: data.storage_oss_access_key_id || '',
|
||||
storage_oss_access_key_secret: data.storage_oss_access_key_secret || '',
|
||||
});
|
||||
} catch {
|
||||
form.setFieldsValue({ storage_provider: 'local', storage_local_path: './uploads' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchConfig(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
const data: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
data[key] = String(value);
|
||||
}
|
||||
}
|
||||
data.storage_provider = provider;
|
||||
await updateStorageConfig(data);
|
||||
message.success('存储配置已保存');
|
||||
} catch (e: any) {
|
||||
if (e?.message) message.error(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Spin size="large" style={{ display: 'block', marginTop: 100 }} />;
|
||||
|
||||
return (
|
||||
<Card title="存储配置" style={{ maxWidth: 700 }}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="存储方式" name="storage_provider" rules={[{ required: true, message: '请选择存储方式' }]}>
|
||||
<Select options={providerOptions} onChange={(v) => setProvider(v)} />
|
||||
</Form.Item>
|
||||
|
||||
{provider === 'local' && (
|
||||
<>
|
||||
<Form.Item label="存储路径" name="storage_local_path" extra="相对于服务启动目录">
|
||||
<Input placeholder="./uploads" />
|
||||
</Form.Item>
|
||||
<div style={{ color: '#888', fontSize: 13, marginBottom: 16 }}>
|
||||
文件将保存到服务器本地目录,通过 /uploads/ 路径访问。适用于开发环境或小规模部署。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{provider === 'tencent_cos' && (
|
||||
<>
|
||||
<Divider orientation="left" plain>腾讯云 COS 配置</Divider>
|
||||
<Form.Item label="Bucket" name="storage_cos_bucket" rules={[{ required: true, message: '请输入 Bucket' }]}>
|
||||
<Input placeholder="example-1250000000" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Region" name="storage_cos_region" rules={[{ required: true, message: '请输入 Region' }]}>
|
||||
<Input placeholder="ap-guangzhou" />
|
||||
</Form.Item>
|
||||
<Form.Item label="SecretId" name="storage_cos_secret_id" rules={[{ required: true, message: '请输入 SecretId' }]}>
|
||||
<Input.Password placeholder="请输入 SecretId" />
|
||||
</Form.Item>
|
||||
<Form.Item label="SecretKey" name="storage_cos_secret_key" rules={[{ required: true, message: '请输入 SecretKey' }]}>
|
||||
<Input.Password placeholder="请输入 SecretKey" />
|
||||
</Form.Item>
|
||||
<div style={{ color: '#888', fontSize: 13, marginBottom: 16 }}>
|
||||
需安装 SDK: pnpm add cos-nodejs-sdk-v5
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{provider === 'aliyun_oss' && (
|
||||
<>
|
||||
<Divider orientation="left" plain>阿里云 OSS 配置</Divider>
|
||||
<Form.Item label="Bucket" name="storage_oss_bucket" rules={[{ required: true, message: '请输入 Bucket' }]}>
|
||||
<Input placeholder="my-bucket" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Region" name="storage_oss_region" rules={[{ required: true, message: '请输入 Region' }]}>
|
||||
<Input placeholder="oss-cn-hangzhou" />
|
||||
</Form.Item>
|
||||
<Form.Item label="AccessKeyId" name="storage_oss_access_key_id" rules={[{ required: true, message: '请输入 AccessKeyId' }]}>
|
||||
<Input.Password placeholder="请输入 AccessKeyId" />
|
||||
</Form.Item>
|
||||
<Form.Item label="AccessKeySecret" name="storage_oss_access_key_secret" rules={[{ required: true, message: '请输入 AccessKeySecret' }]}>
|
||||
<Input.Password placeholder="请输入 AccessKeySecret" />
|
||||
</Form.Item>
|
||||
<div style={{ color: '#888', fontSize: 13, marginBottom: 16 }}>
|
||||
需安装 SDK: pnpm add ali-oss
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" loading={saving} onClick={handleSave}>保存配置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageSettings;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Form, InputNumber, Button, message, Spin, Divider } from 'antd';
|
||||
import { getServiceFeeConfig, updateServiceFeeConfig } from '@/api/config';
|
||||
import StorageSettings from './StorageSettings';
|
||||
|
||||
const SystemSettings: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -41,7 +42,7 @@ const SystemSettings: React.FC = () => {
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 24 }}>系统设置</h2>
|
||||
|
||||
<Card title="服务费配置" style={{ maxWidth: 600 }}>
|
||||
<Card title="服务费配置" style={{ maxWidth: 600, marginBottom: 24 }}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
label="软件服务费比例"
|
||||
@@ -81,6 +82,8 @@ const SystemSettings: React.FC = () => {
|
||||
<p>软件服务费 = ¥5.00,商家预计收入 = ¥95.00</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<StorageSettings />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"ioredis": "^5.4.2",
|
||||
"multer": "^2.1.1",
|
||||
"mysql2": "^3.12.0",
|
||||
"nestjs-rate-limiter": "^3.1.0",
|
||||
"passport": "^0.7.0",
|
||||
@@ -53,6 +54,7 @@
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ReviewModule } from './modules/review/review.module';
|
||||
import { FinanceModule } from './modules/finance/finance.module';
|
||||
import { ActivityModule } from './modules/activity/activity.module';
|
||||
import { PlatformConfigModule } from './modules/config/config.module';
|
||||
import { UploadModule } from './modules/upload/upload.module';
|
||||
import { ScheduleModule as TaskScheduleModule } from './schedule/schedule.module';
|
||||
|
||||
@Module({
|
||||
@@ -55,6 +56,7 @@ import { ScheduleModule as TaskScheduleModule } from './schedule/schedule.module
|
||||
OrderModule,
|
||||
AdminAuthModule,
|
||||
PlatformConfigModule,
|
||||
UploadModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -6,13 +6,15 @@ if (!globalThis.crypto) {
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join } from 'path';
|
||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
@@ -21,6 +23,8 @@ async function bootstrap() {
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.useStaticAssets(join(__dirname, '..', 'uploads'), { prefix: '/uploads/' });
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Controller, Get, Put, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ConfigService } from './config.service';
|
||||
import { JwtAuthGuard, RolesGuard, Roles } from '@/common';
|
||||
import { UploadService } from '../upload/upload.service';
|
||||
|
||||
@ApiTags('管理端-系统配置')
|
||||
@ApiBearerAuth()
|
||||
@@ -9,7 +10,10 @@ import { JwtAuthGuard, RolesGuard, Roles } from '@/common';
|
||||
@Roles('admin')
|
||||
@Controller('admin/config')
|
||||
export class ConfigController {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly uploadService: UploadService,
|
||||
) {}
|
||||
|
||||
@Get('service-fee')
|
||||
@ApiOperation({ summary: '获取服务费配置' })
|
||||
@@ -23,4 +27,17 @@ export class ConfigController {
|
||||
await this.configService.updateServiceFeeRate(body.rate);
|
||||
return this.configService.getServiceFeeConfig();
|
||||
}
|
||||
|
||||
@Get('storage')
|
||||
@ApiOperation({ summary: '获取存储配置' })
|
||||
async getStorageConfig() {
|
||||
return this.uploadService.getStorageConfig();
|
||||
}
|
||||
|
||||
@Put('storage')
|
||||
@ApiOperation({ summary: '更新存储配置' })
|
||||
async updateStorageConfig(@Body() body: Record<string, string>) {
|
||||
await this.uploadService.updateStorageConfig(body);
|
||||
return this.uploadService.getStorageConfig();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PlatformConfig } from '@/entities/platform-config.entity';
|
||||
import { ConfigService } from './config.service';
|
||||
import { ConfigController } from './config.controller';
|
||||
import { UploadModule } from '../upload/upload.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([PlatformConfig])],
|
||||
imports: [TypeOrmModule.forFeature([PlatformConfig]), forwardRef(() => UploadModule)],
|
||||
controllers: [ConfigController],
|
||||
providers: [ConfigService],
|
||||
exports: [ConfigService],
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes, ApiBody } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { SellerJwtAuthGuard } from '@/common/guards/seller-jwt-auth.guard';
|
||||
import { RolesGuard, Roles } from '@/common';
|
||||
import { UploadService } from './upload.service';
|
||||
|
||||
const uploadOptions = {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (_req: any, file: Express.Multer.File, cb: any) => {
|
||||
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif|webp|bmp)$/)) {
|
||||
cb(new BadRequestException('只支持图片文件'), false);
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
};
|
||||
|
||||
@ApiTags('文件上传')
|
||||
@Controller()
|
||||
export class UploadController {
|
||||
constructor(private readonly uploadService: UploadService) {}
|
||||
|
||||
@Post('upload')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '用户上传图片' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({ schema: { type: 'object', properties: { file: { type: 'string', format: 'binary' } } } })
|
||||
@UseInterceptors(FileInterceptor('file', uploadOptions))
|
||||
async uploadUser(@UploadedFile() file: Express.Multer.File) {
|
||||
if (!file) throw new BadRequestException('请选择文件');
|
||||
return this.uploadService.upload(file);
|
||||
}
|
||||
|
||||
@Post('seller/upload')
|
||||
@UseGuards(SellerJwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '商家上传图片' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({ schema: { type: 'object', properties: { file: { type: 'string', format: 'binary' } } } })
|
||||
@UseInterceptors(FileInterceptor('file', uploadOptions))
|
||||
async uploadSeller(@UploadedFile() file: Express.Multer.File) {
|
||||
if (!file) throw new BadRequestException('请选择文件');
|
||||
return this.uploadService.upload(file);
|
||||
}
|
||||
|
||||
@Post('admin/upload')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '管理员上传图片' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({ schema: { type: 'object', properties: { file: { type: 'string', format: 'binary' } } } })
|
||||
@UseInterceptors(FileInterceptor('file', uploadOptions))
|
||||
async uploadAdmin(@UploadedFile() file: Express.Multer.File) {
|
||||
if (!file) throw new BadRequestException('请选择文件');
|
||||
return this.uploadService.upload(file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PlatformConfig } from '@/entities/platform-config.entity';
|
||||
import { UploadService } from './upload.service';
|
||||
import { UploadController } from './upload.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([PlatformConfig])],
|
||||
controllers: [UploadController],
|
||||
providers: [UploadService],
|
||||
exports: [UploadService],
|
||||
})
|
||||
export class UploadModule {}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService as NestConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PlatformConfig } from '@/entities/platform-config.entity';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export type StorageProvider = 'local' | 'tencent_cos' | 'aliyun_oss';
|
||||
|
||||
export interface UploadResult {
|
||||
url: string;
|
||||
filename: string;
|
||||
originalname: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UploadService {
|
||||
private readonly logger = new Logger(UploadService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PlatformConfig)
|
||||
private configRepo: Repository<PlatformConfig>,
|
||||
private nestConfigService: NestConfigService,
|
||||
) {}
|
||||
|
||||
async getStorageProvider(): Promise<StorageProvider> {
|
||||
const config = await this.configRepo.findOne({ where: { configKey: 'storage_provider' } });
|
||||
return (config?.configValue as StorageProvider) || 'local';
|
||||
}
|
||||
|
||||
async upload(file: Express.Multer.File): Promise<UploadResult> {
|
||||
const provider = await this.getStorageProvider();
|
||||
this.logger.log(`上传文件: ${file.originalname}, 存储方式: ${provider}`);
|
||||
|
||||
switch (provider) {
|
||||
case 'tencent_cos':
|
||||
return this.uploadTencentCos(file);
|
||||
case 'aliyun_oss':
|
||||
return this.uploadAliyunOss(file);
|
||||
default:
|
||||
return this.uploadLocal(file);
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadLocal(file: Express.Multer.File): Promise<UploadResult> {
|
||||
let uploadDir = './uploads';
|
||||
const dirConfig = await this.configRepo.findOne({ where: { configKey: 'storage_local_path' } });
|
||||
if (dirConfig?.configValue) uploadDir = dirConfig.configValue;
|
||||
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const ext = path.extname(file.originalname) || '.jpg';
|
||||
const filename = `${uuidv4()}${ext}`;
|
||||
const filepath = path.join(uploadDir, filename);
|
||||
|
||||
fs.writeFileSync(filepath, file.buffer);
|
||||
|
||||
return {
|
||||
url: `/uploads/${filename}`,
|
||||
filename,
|
||||
originalname: file.originalname,
|
||||
size: file.size,
|
||||
};
|
||||
}
|
||||
|
||||
private async uploadTencentCos(file: Express.Multer.File): Promise<UploadResult> {
|
||||
try {
|
||||
// 动态导入,避免未安装时报错
|
||||
const COS = (await import('cos-nodejs-sdk-v5')).default;
|
||||
const configs = await this.getStorageConfigs('storage_cos_');
|
||||
const cos = new COS({
|
||||
SecretId: configs['secret_id'],
|
||||
SecretKey: configs['secret_key'],
|
||||
});
|
||||
|
||||
const ext = path.extname(file.originalname) || '.jpg';
|
||||
const filename = `${uuidv4()}${ext}`;
|
||||
const key = `uploads/${filename}`;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
cos.putObject(
|
||||
{
|
||||
Bucket: configs['bucket'],
|
||||
Region: configs['region'],
|
||||
Key: key,
|
||||
Body: file.buffer,
|
||||
},
|
||||
(err) => (err ? reject(err) : resolve()),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
url: `https://${configs['bucket']}.cos.${configs['region']}.myqcloud.com/${key}`,
|
||||
filename,
|
||||
originalname: file.originalname,
|
||||
size: file.size,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === 'MODULE_NOT_FOUND') {
|
||||
throw new Error('腾讯云COS SDK未安装,请运行: pnpm add cos-nodejs-sdk-v5');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadAliyunOss(file: Express.Multer.File): Promise<UploadResult> {
|
||||
try {
|
||||
const OSS = (await import('ali-oss')).default;
|
||||
const configs = await this.getStorageConfigs('storage_oss_');
|
||||
|
||||
const client = new OSS({
|
||||
region: configs['region'],
|
||||
accessKeyId: configs['access_key_id'],
|
||||
accessKeySecret: configs['access_key_secret'],
|
||||
bucket: configs['bucket'],
|
||||
});
|
||||
|
||||
const ext = path.extname(file.originalname) || '.jpg';
|
||||
const filename = `${uuidv4()}${ext}`;
|
||||
const key = `uploads/${filename}`;
|
||||
|
||||
await client.put(key, file.buffer);
|
||||
|
||||
return {
|
||||
url: `https://${configs['bucket']}.${configs['region']}.aliyuncs.com/${key}`,
|
||||
filename,
|
||||
originalname: file.originalname,
|
||||
size: file.size,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === 'MODULE_NOT_FOUND') {
|
||||
throw new Error('阿里云OSS SDK未安装,请运行: pnpm add ali-oss');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async getStorageConfigs(prefix: string): Promise<Record<string, string>> {
|
||||
const configs = await this.configRepo.find();
|
||||
const result: Record<string, string> = {};
|
||||
for (const c of configs) {
|
||||
if (c.configKey.startsWith(prefix)) {
|
||||
const key = c.configKey.replace(prefix, '');
|
||||
result[key] = c.configValue;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async getStorageConfig(): Promise<Record<string, string>> {
|
||||
const configs = await this.configRepo.find();
|
||||
const result: Record<string, string> = {};
|
||||
for (const c of configs) {
|
||||
if (c.configKey.startsWith('storage_')) {
|
||||
result[c.configKey] = c.configValue;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateStorageConfig(configs: Record<string, string>): Promise<void> {
|
||||
for (const [key, value] of Object.entries(configs)) {
|
||||
if (!key.startsWith('storage_')) continue;
|
||||
let config = await this.configRepo.findOne({ where: { configKey: key } });
|
||||
if (config) {
|
||||
config.configValue = value;
|
||||
} else {
|
||||
config = this.configRepo.create({ configKey: key, configValue: value });
|
||||
}
|
||||
await this.configRepo.save(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+31
@@ -0,0 +1,31 @@
|
||||
declare module 'cos-nodejs-sdk-v5' {
|
||||
interface CosOptions {
|
||||
SecretId: string;
|
||||
SecretKey: string;
|
||||
}
|
||||
interface PutObjectParams {
|
||||
Bucket: string;
|
||||
Region: string;
|
||||
Key: string;
|
||||
Body: any;
|
||||
}
|
||||
class COS {
|
||||
constructor(options: CosOptions);
|
||||
putObject(params: PutObjectParams, callback: (err: any, data: any) => void): void;
|
||||
}
|
||||
export default COS;
|
||||
}
|
||||
|
||||
declare module 'ali-oss' {
|
||||
interface OssOptions {
|
||||
region: string;
|
||||
accessKeyId: string;
|
||||
accessKeySecret: string;
|
||||
bucket: string;
|
||||
}
|
||||
class OSS {
|
||||
constructor(options: OssOptions);
|
||||
put(name: string, data: any): Promise<{ name: string; url: string }>;
|
||||
}
|
||||
export default OSS;
|
||||
}
|
||||
Reference in New Issue
Block a user