This commit is contained in:
2026-04-24 20:08:23 +08:00
parent b7ba9a26b0
commit 86b9456ecd
18 changed files with 661 additions and 61 deletions
+16 -10
View File
@@ -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) {
+11 -25
View File
@@ -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 {
+11 -19
View File
@@ -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 {
+70
View File
@@ -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('取消选择图片')),
});
});
}
+4
View File
@@ -3,3 +3,7 @@ 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 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>
);
};
+2
View File
@@ -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",
+2
View File
@@ -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 {}
+5 -1
View File
@@ -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);
}
}
}
+31
View File
@@ -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;
}
+76
View File
@@ -518,3 +518,79 @@ async update(id, dto) {
| pending_checkin | 办理入住 |
| refunding | 通过退款、拒绝退款 |
| 其他状态 | 无操作 |
---
## 文件上传服务
### 上传接口
| 接口 | 路径 | 说明 |
| -------------- | --------------------- | ------------------ |
| 用户上传 | POST /api/upload | 需用户Token |
| 商家上传 | POST /api/seller/upload | 需商家Token |
| 管理员上传 | POST /api/admin/upload | 需管理员Token |
### 存储配置
存储方式通过 `platform_configs` 表配置,支持:
| 配置键 | 说明 |
| ------------------------ | ------------------------ |
| storage_provider | 存储方式:local / tencent_cos / aliyun_oss |
| storage_local_path | 本地存储路径(默认 ./uploads |
| storage_cos_bucket | 腾讯云COS Bucket |
| storage_cos_region | 腾讯云COS Region |
| storage_cos_secret_id | 腾讯云SecretId |
| storage_cos_secret_key | 腾讯云SecretKey |
| storage_oss_bucket | 阿里云OSS Bucket |
| storage_oss_region | 阿里云OSS Region |
| storage_oss_access_key_id | 阿里云AccessKeyId |
| storage_oss_access_key_secret | 阿里云AccessKeySecret |
### 存储配置接口
| 接口 | 路径 | 说明 |
| -------------- | ----------------------------- | ------------------ |
| 获取存储配置 | GET /api/admin/config/storage | 需管理员Token |
| 更新存储配置 | PUT /api/admin/config/storage | 需管理员Token |
### 小程序上传工具
**文件**: `apps/miniapp/src/utils/upload.ts`
```typescript
import { chooseAndUpload, uploadFile } from '@/utils/upload';
// 选择并上传图片
const urls = await chooseAndUpload({ count: 1, useSellerToken: true });
// 直接上传文件
const url = await uploadFile(filePath, { useSellerToken: true });
```
### 后端上传模块
**文件位置**: `apps/server/src/modules/upload/`
- `upload.service.ts` — 核心上传逻辑,策略模式支持本地/腾讯云/阿里云
- `upload.controller.ts` — 上传接口控制器
- `upload.module.ts` — 模块定义
### 静态文件访问
本地上传的文件通过 `/uploads/` 路径访问,例如:
- 上传返回: `{ url: "/uploads/abc123.jpg" }`
- 访问地址: `http://localhost:3000/uploads/abc123.jpg`
### OSS SDK 安装
使用腾讯云COS或阿里云OSS时,需安装对应SDK:
```bash
# 腾讯云COS
pnpm --filter @rent/server add cos-nodejs-sdk-v5
# 阿里云OSS
pnpm --filter @rent/server add ali-oss
```
+13
View File
@@ -230,6 +230,9 @@ importers:
ioredis:
specifier: ^5.4.2
version: 5.10.1
multer:
specifier: ^2.1.1
version: 2.1.1
mysql2:
specifier: ^3.12.0
version: 3.22.2(@types/node@24.12.2)
@@ -282,6 +285,9 @@ importers:
'@types/jest':
specifier: ^30.0.0
version: 30.0.0
'@types/multer':
specifier: ^2.1.0
version: 2.1.0
'@types/node':
specifier: ^24.0.0
version: 24.12.2
@@ -2625,6 +2631,9 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/multer@2.1.0':
resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==}
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
@@ -9989,6 +9998,10 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/multer@2.1.0':
dependencies:
'@types/express': 5.0.6
'@types/node@22.19.17':
dependencies:
undici-types: 6.21.0