feat(storage): 对象存储 MinIO → 腾讯云 COS(cos-nodejs-sdk-v5)
- StorageService 换 COS SDK,配置从 COS_SECRET_ID/KEY/REGION/BUCKET 环境变量读取 - upload/presignGet/delete/buildKey 签名不变,key 前缀(elder/abnormal)组织子目录 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d665f7665c
commit
4d2d43b03a
11
.env.example
11
.env.example
@ -33,9 +33,8 @@ REDIS_DB="0"
|
||||
REDIS_PASSWORD=""
|
||||
REDIS_KEY_PREFIX="sysmgr:"
|
||||
|
||||
# MinIO(对象存储,供 elder 模块上传评估结论副本)
|
||||
MINIO_ENDPOINT="localhost"
|
||||
MINIO_PORT=9010
|
||||
MINIO_ACCESS_KEY="admin"
|
||||
MINIO_SECRET_KEY="admin111"
|
||||
MINIO_BUCKET="carelink"
|
||||
# 腾讯云 COS(对象存储)
|
||||
COS_SECRET_ID=""
|
||||
COS_SECRET_KEY=""
|
||||
COS_REGION="ap-guangzhou"
|
||||
COS_BUCKET="care-demo-1311120189"
|
||||
|
||||
@ -42,9 +42,9 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cos-nodejs-sdk-v5": "^3.0.0",
|
||||
"helmet": "^8.3.0",
|
||||
"ioredis": "^6.0.0",
|
||||
"minio": "^8.0.7",
|
||||
"nestjs-pino": "^5.3.1",
|
||||
"pino": "^10.4.0",
|
||||
"pino-pretty": "^13.2.0",
|
||||
|
||||
483
pnpm-lock.yaml
generated
483
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,14 +1,23 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { StorageService } from './storage.service.js';
|
||||
|
||||
const BUCKET = 'care-demo-1311120189';
|
||||
const REGION = 'ap-guangzhou';
|
||||
|
||||
describe('StorageService', () => {
|
||||
it('upload 写桶并返回 key', async () => {
|
||||
const putObject = vi.fn().mockResolvedValue({ etag: 'x' });
|
||||
const putObject = vi.fn().mockResolvedValue({ ETag: 'x' });
|
||||
const svc = new StorageService();
|
||||
(svc as any).client = { putObject };
|
||||
(svc as any).bucket = 'carelink';
|
||||
(svc as any).cos = { putObject };
|
||||
(svc as any).bucket = BUCKET;
|
||||
(svc as any).region = REGION;
|
||||
const key = await svc.upload(Buffer.from('x'), 'elder/evidence/a.jpg');
|
||||
expect(putObject).toHaveBeenCalledWith('carelink', 'elder/evidence/a.jpg', expect.any(Buffer), expect.any(Number));
|
||||
expect(putObject).toHaveBeenCalledWith({
|
||||
Bucket: BUCKET,
|
||||
Region: REGION,
|
||||
Key: 'elder/evidence/a.jpg',
|
||||
Body: expect.any(Buffer),
|
||||
});
|
||||
expect(key).toBe('elder/evidence/a.jpg');
|
||||
});
|
||||
|
||||
@ -25,12 +34,39 @@ describe('StorageService', () => {
|
||||
});
|
||||
|
||||
it('presignGet 生成 GET 预签名 URL(默认 1 小时)', async () => {
|
||||
const presignedGetObject = vi.fn().mockResolvedValue('http://minio/carelink/record/T001/a.jpg?x=1');
|
||||
const url = 'https://care-demo-1311120189.cos.ap-guangzhou.myqcloud.com/record/T001/a.jpg?sign=1';
|
||||
const getObjectUrl = vi.fn((_params: unknown, cb: (err: null, data: { Url: string }) => void) =>
|
||||
cb(null, { Url: url }),
|
||||
);
|
||||
const svc = new StorageService();
|
||||
(svc as any).client = { presignedGetObject };
|
||||
(svc as any).bucket = 'carelink';
|
||||
const url = await svc.presignGet('record/T001/a.jpg');
|
||||
expect(presignedGetObject).toHaveBeenCalledWith('carelink', 'record/T001/a.jpg', 3600);
|
||||
expect(url).toBe('http://minio/carelink/record/T001/a.jpg?x=1');
|
||||
(svc as any).cos = { getObjectUrl };
|
||||
(svc as any).bucket = BUCKET;
|
||||
(svc as any).region = REGION;
|
||||
const result = await svc.presignGet('record/T001/a.jpg');
|
||||
expect(getObjectUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
Bucket: BUCKET,
|
||||
Region: REGION,
|
||||
Key: 'record/T001/a.jpg',
|
||||
Sign: true,
|
||||
Expires: 3600,
|
||||
}),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(result).toBe(url);
|
||||
});
|
||||
|
||||
it('delete 调用 deleteObject(对象不存在不抛错)', async () => {
|
||||
const deleteObject = vi.fn().mockResolvedValue({});
|
||||
const svc = new StorageService();
|
||||
(svc as any).cos = { deleteObject };
|
||||
(svc as any).bucket = BUCKET;
|
||||
(svc as any).region = REGION;
|
||||
await svc.delete('elder/evidence/a.jpg');
|
||||
expect(deleteObject).toHaveBeenCalledWith({
|
||||
Bucket: BUCKET,
|
||||
Region: REGION,
|
||||
Key: 'elder/evidence/a.jpg',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,22 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Client } from 'minio';
|
||||
import COS from 'cos-nodejs-sdk-v5';
|
||||
|
||||
/**
|
||||
* 对象存储:腾讯云 COS。
|
||||
* 配置从环境变量读取:COS_SECRET_ID / COS_SECRET_KEY / COS_REGION / COS_BUCKET。
|
||||
* Key 用前缀组织子目录(elder/{tenantCode}/…、abnormal/…),COS 首次上传即建虚拟目录。
|
||||
*/
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private readonly client: Client;
|
||||
private readonly cos: COS;
|
||||
private readonly bucket: string;
|
||||
private readonly region: string;
|
||||
|
||||
constructor() {
|
||||
// new Client() 只是构造客户端对象、不建立连接;putObject 时才连。
|
||||
// 测试里直接替换 (svc as any).client / .bucket 为 mock。
|
||||
this.client = new Client({
|
||||
endPoint: process.env.MINIO_ENDPOINT ?? 'localhost',
|
||||
port: Number(process.env.MINIO_PORT ?? 9000),
|
||||
useSSL: false,
|
||||
accessKey: process.env.MINIO_ACCESS_KEY ?? 'minioadmin',
|
||||
secretKey: process.env.MINIO_SECRET_KEY ?? 'minioadmin',
|
||||
// new COS() 只是构造客户端对象、不建立连接;首次 putObject/getObjectUrl 时才用。
|
||||
// 测试里直接替换 (svc as any).cos / .bucket / .region 为 mock。
|
||||
this.cos = new COS({
|
||||
SecretId: process.env.COS_SECRET_ID ?? '',
|
||||
SecretKey: process.env.COS_SECRET_KEY ?? '',
|
||||
});
|
||||
this.bucket = process.env.MINIO_BUCKET ?? 'carelink';
|
||||
this.bucket = process.env.COS_BUCKET ?? 'care-demo-1311120189';
|
||||
this.region = process.env.COS_REGION ?? 'ap-guangzhou';
|
||||
}
|
||||
|
||||
buildKey(tenantCode: string, filename: string, prefix = 'elder'): string {
|
||||
@ -24,17 +28,42 @@ export class StorageService {
|
||||
}
|
||||
|
||||
async upload(buffer: Buffer, key: string): Promise<string> {
|
||||
await this.client.putObject(this.bucket, key, buffer, buffer.length);
|
||||
await this.cos.putObject({
|
||||
Bucket: this.bucket,
|
||||
Region: this.region,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
});
|
||||
return key;
|
||||
}
|
||||
|
||||
/** 生成 GET 预签名 URL(默认 1 小时有效),供前端临时预览/下载。 */
|
||||
async presignGet(key: string, expiry = 3600): Promise<string> {
|
||||
return this.client.presignedGetObject(this.bucket, key, expiry);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.cos.getObjectUrl(
|
||||
{
|
||||
Bucket: this.bucket,
|
||||
Region: this.region,
|
||||
Key: key,
|
||||
Sign: true,
|
||||
Expires: expiry,
|
||||
},
|
||||
(err, data) => {
|
||||
if (err) return reject(err);
|
||||
resolve(data.Url);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除对象(幂等:对象不存在不抛错)。 */
|
||||
async delete(key: string): Promise<void> {
|
||||
await this.client.removeObject(this.bucket, key).catch(() => undefined);
|
||||
await this.cos
|
||||
.deleteObject({
|
||||
Bucket: this.bucket,
|
||||
Region: this.region,
|
||||
Key: key,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user