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_PASSWORD=""
|
||||||
REDIS_KEY_PREFIX="sysmgr:"
|
REDIS_KEY_PREFIX="sysmgr:"
|
||||||
|
|
||||||
# MinIO(对象存储,供 elder 模块上传评估结论副本)
|
# 腾讯云 COS(对象存储)
|
||||||
MINIO_ENDPOINT="localhost"
|
COS_SECRET_ID=""
|
||||||
MINIO_PORT=9010
|
COS_SECRET_KEY=""
|
||||||
MINIO_ACCESS_KEY="admin"
|
COS_REGION="ap-guangzhou"
|
||||||
MINIO_SECRET_KEY="admin111"
|
COS_BUCKET="care-demo-1311120189"
|
||||||
MINIO_BUCKET="carelink"
|
|
||||||
|
|||||||
@ -42,9 +42,9 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
"cos-nodejs-sdk-v5": "^3.0.0",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"ioredis": "^6.0.0",
|
"ioredis": "^6.0.0",
|
||||||
"minio": "^8.0.7",
|
|
||||||
"nestjs-pino": "^5.3.1",
|
"nestjs-pino": "^5.3.1",
|
||||||
"pino": "^10.4.0",
|
"pino": "^10.4.0",
|
||||||
"pino-pretty": "^13.2.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 { describe, it, expect, vi } from 'vitest';
|
||||||
import { StorageService } from './storage.service.js';
|
import { StorageService } from './storage.service.js';
|
||||||
|
|
||||||
|
const BUCKET = 'care-demo-1311120189';
|
||||||
|
const REGION = 'ap-guangzhou';
|
||||||
|
|
||||||
describe('StorageService', () => {
|
describe('StorageService', () => {
|
||||||
it('upload 写桶并返回 key', async () => {
|
it('upload 写桶并返回 key', async () => {
|
||||||
const putObject = vi.fn().mockResolvedValue({ etag: 'x' });
|
const putObject = vi.fn().mockResolvedValue({ ETag: 'x' });
|
||||||
const svc = new StorageService();
|
const svc = new StorageService();
|
||||||
(svc as any).client = { putObject };
|
(svc as any).cos = { putObject };
|
||||||
(svc as any).bucket = 'carelink';
|
(svc as any).bucket = BUCKET;
|
||||||
|
(svc as any).region = REGION;
|
||||||
const key = await svc.upload(Buffer.from('x'), 'elder/evidence/a.jpg');
|
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');
|
expect(key).toBe('elder/evidence/a.jpg');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -25,12 +34,39 @@ describe('StorageService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('presignGet 生成 GET 预签名 URL(默认 1 小时)', async () => {
|
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();
|
const svc = new StorageService();
|
||||||
(svc as any).client = { presignedGetObject };
|
(svc as any).cos = { getObjectUrl };
|
||||||
(svc as any).bucket = 'carelink';
|
(svc as any).bucket = BUCKET;
|
||||||
const url = await svc.presignGet('record/T001/a.jpg');
|
(svc as any).region = REGION;
|
||||||
expect(presignedGetObject).toHaveBeenCalledWith('carelink', 'record/T001/a.jpg', 3600);
|
const result = await svc.presignGet('record/T001/a.jpg');
|
||||||
expect(url).toBe('http://minio/carelink/record/T001/a.jpg?x=1');
|
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 { 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()
|
@Injectable()
|
||||||
export class StorageService {
|
export class StorageService {
|
||||||
private readonly client: Client;
|
private readonly cos: COS;
|
||||||
private readonly bucket: string;
|
private readonly bucket: string;
|
||||||
|
private readonly region: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// new Client() 只是构造客户端对象、不建立连接;putObject 时才连。
|
// new COS() 只是构造客户端对象、不建立连接;首次 putObject/getObjectUrl 时才用。
|
||||||
// 测试里直接替换 (svc as any).client / .bucket 为 mock。
|
// 测试里直接替换 (svc as any).cos / .bucket / .region 为 mock。
|
||||||
this.client = new Client({
|
this.cos = new COS({
|
||||||
endPoint: process.env.MINIO_ENDPOINT ?? 'localhost',
|
SecretId: process.env.COS_SECRET_ID ?? '',
|
||||||
port: Number(process.env.MINIO_PORT ?? 9000),
|
SecretKey: process.env.COS_SECRET_KEY ?? '',
|
||||||
useSSL: false,
|
|
||||||
accessKey: process.env.MINIO_ACCESS_KEY ?? 'minioadmin',
|
|
||||||
secretKey: process.env.MINIO_SECRET_KEY ?? 'minioadmin',
|
|
||||||
});
|
});
|
||||||
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 {
|
buildKey(tenantCode: string, filename: string, prefix = 'elder'): string {
|
||||||
@ -24,17 +28,42 @@ export class StorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async upload(buffer: Buffer, key: string): Promise<string> {
|
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;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 生成 GET 预签名 URL(默认 1 小时有效),供前端临时预览/下载。 */
|
/** 生成 GET 预签名 URL(默认 1 小时有效),供前端临时预览/下载。 */
|
||||||
async presignGet(key: string, expiry = 3600): Promise<string> {
|
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> {
|
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