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:
liuyun 2026-10-21 15:53:10 +08:00
parent d665f7665c
commit 4d2d43b03a
5 changed files with 372 additions and 237 deletions

View File

@ -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"

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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',
});
});
});

View File

@ -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);
}
}