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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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