fix(admin-service): 本地磁盘存储 + Docker volume 解决 EACCES 权限问题
回退 MinIO 方案,改用本地磁盘存储(与 git 历史及 rwadurian 一致)。 根本原因:容器内无权在 ./uploads 创建目录(EACCES)。 修复方案: - UPLOAD_DIR 默认改为 /app/uploads(明确绝对路径) - docker-compose 添加命名卷 admin-uploads 挂载到 /app/uploads - 挂载卷由 Docker 管理,容器拥有写权限 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b9d2393aa1
commit
7bb89b87ae
|
|
@ -361,6 +361,9 @@ services:
|
||||||
- MINIO_BUCKET=app-releases
|
- MINIO_BUCKET=app-releases
|
||||||
- OSS_BASE_URL=https://oss.gogenex.com
|
- OSS_BASE_URL=https://oss.gogenex.com
|
||||||
- JWT_ACCESS_SECRET=dev-access-secret-change-in-production
|
- JWT_ACCESS_SECRET=dev-access-secret-change-in-production
|
||||||
|
- UPLOAD_DIR=/app/uploads
|
||||||
|
volumes:
|
||||||
|
- admin-uploads:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -559,6 +562,7 @@ volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
kafka_data:
|
kafka_data:
|
||||||
|
admin-uploads:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
genex-network:
|
genex-network:
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,19 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import * as crypto from 'crypto';
|
import * as fs from 'fs/promises';
|
||||||
|
import * as fsSync from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { Client as MinioClient } from 'minio';
|
import * as crypto from 'crypto';
|
||||||
import { Readable } from 'stream';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FileStorageService {
|
export class FileStorageService {
|
||||||
private readonly logger = new Logger(FileStorageService.name);
|
private readonly logger = new Logger(FileStorageService.name);
|
||||||
private readonly minio: MinioClient;
|
private readonly uploadDir: string;
|
||||||
private readonly bucket: string;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.bucket = process.env.MINIO_BUCKET || 'app-releases';
|
this.uploadDir = process.env.UPLOAD_DIR || '/app/uploads';
|
||||||
this.minio = new MinioClient({
|
|
||||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
|
||||||
port: parseInt(process.env.MINIO_PORT || '9000', 10),
|
|
||||||
useSSL: process.env.MINIO_USE_SSL === 'true',
|
|
||||||
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
|
|
||||||
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generate a unique object key for a new upload */
|
/** Generate a unique filename for a new upload */
|
||||||
generateObjectName(originalName: string, platform: string, versionName: string): string {
|
generateObjectName(originalName: string, platform: string, versionName: string): string {
|
||||||
const ext = originalName.split('.').pop() || 'bin';
|
const ext = originalName.split('.').pop() || 'bin';
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
|
@ -29,24 +21,20 @@ export class FileStorageService {
|
||||||
return `${platform}-${versionName}-${timestamp}-${random}.${ext}`;
|
return `${platform}-${versionName}-${timestamp}-${random}.${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Upload file buffer to MinIO, return size and sha256 */
|
/** Save file buffer to local disk, return size and sha256 */
|
||||||
async saveFile(buffer: Buffer, objectName: string): Promise<{ size: number; sha256: string }> {
|
async saveFile(buffer: Buffer, filename: string): Promise<{ size: number; sha256: string }> {
|
||||||
|
await fs.mkdir(this.uploadDir, { recursive: true });
|
||||||
|
const filePath = path.join(this.uploadDir, filename);
|
||||||
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
|
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
|
||||||
const ext = path.extname(objectName).toLowerCase();
|
await fs.writeFile(filePath, buffer);
|
||||||
const contentType = ext === '.apk'
|
this.logger.log(`Saved file: ${filename} (${buffer.length} bytes, sha256: ${sha256})`);
|
||||||
? 'application/vnd.android.package-archive'
|
|
||||||
: 'application/octet-stream';
|
|
||||||
|
|
||||||
await this.minio.putObject(this.bucket, objectName, buffer, buffer.length, {
|
|
||||||
'Content-Type': contentType,
|
|
||||||
});
|
|
||||||
this.logger.log(`Uploaded to MinIO: ${this.bucket}/${objectName} (${buffer.length} bytes)`);
|
|
||||||
return { size: buffer.length, sha256 };
|
return { size: buffer.length, sha256 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stream a MinIO object to a writable (e.g. Express Response) */
|
/** Stream local file to a writable (e.g. Express Response) */
|
||||||
async streamFile(objectName: string, destination: NodeJS.WritableStream): Promise<void> {
|
async streamFile(filename: string, destination: NodeJS.WritableStream): Promise<void> {
|
||||||
const stream: Readable = await this.minio.getObject(this.bucket, path.basename(objectName));
|
const filePath = path.join(this.uploadDir, path.basename(filename));
|
||||||
|
const stream = fsSync.createReadStream(filePath);
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
stream.pipe(destination as any);
|
stream.pipe(destination as any);
|
||||||
stream.on('end', resolve);
|
stream.on('end', resolve);
|
||||||
|
|
@ -54,10 +42,11 @@ export class FileStorageService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get object size and content-type from MinIO */
|
/** Get file size and content-type from local disk */
|
||||||
async statFile(objectName: string): Promise<{ size: number; contentType: string }> {
|
async statFile(filename: string): Promise<{ size: number; contentType: string }> {
|
||||||
const stat = await this.minio.statObject(this.bucket, path.basename(objectName));
|
const filePath = path.join(this.uploadDir, path.basename(filename));
|
||||||
const ext = path.extname(objectName).toLowerCase();
|
const stat = await fs.stat(filePath);
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
const contentType = ext === '.apk'
|
const contentType = ext === '.apk'
|
||||||
? 'application/vnd.android.package-archive'
|
? 'application/vnd.android.package-archive'
|
||||||
: 'application/octet-stream';
|
: 'application/octet-stream';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue