feat(cos): 接入 COS 直传预签名 + 词云结果图转存

This commit is contained in:
2026-08-12 19:30:30 +08:00
parent 8925e83b8f
commit 910da1c369
8 changed files with 506 additions and 18 deletions
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { CosService } from './cos.service';
@Module({
providers: [CosService],
exports: [CosService],
})
export class CosModule {}
+65
View File
@@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import COS from 'cos-nodejs-sdk-v5';
/**
* 腾讯云 COS 封装。
* 用途:
* - 给小程序签发预签名 PUT URL 直传(小程序不持有永久密钥);
* - 后端服务端写入(把 wordcloud 结果图转存到 COS)。
* 未配置(COS_SECRET_ID/KEY/BUCKET 为空)时 configured=false,调用方返回结构化“未配置”。
*/
@Injectable()
export class CosService {
private readonly client: COS | null;
constructor(private readonly config: ConfigService) {
const secretId = this.config.get<string>('cos.secretId') ?? '';
const secretKey = this.config.get<string>('cos.secretKey') ?? '';
this.client = secretId && secretKey ? new COS({ SecretId: secretId, SecretKey: secretKey }) : null;
}
private get bucketName(): string {
return this.config.get<string>('cos.bucket') ?? '';
}
private get regionName(): string {
return this.config.get<string>('cos.region') ?? 'ap-guangzhou';
}
get configured(): boolean {
return !!this.client && !!this.bucketName;
}
private bucket(): string {
if (!this.bucketName) throw new Error('COS 未配置 bucket');
return this.bucketName;
}
/** 签发小程序直传预签名 PUT URL */
getPresignedPutUrl(key: string, expires = 600): Promise<string> {
return new Promise((resolve, reject) => {
if (!this.client) return reject(new Error('COS 未配置'));
this.client.getObjectUrl(
{ Bucket: this.bucket(), Region: this.regionName, Key: key, Sign: true, Method: 'put', Expires: expires },
(err, data) => (err ? reject(err) : resolve(data.Url)),
);
});
}
/** 服务端写入对象(转存 wordcloud 结果等) */
putObject(key: string, body: Buffer, contentType: string): Promise<void> {
return new Promise((resolve, reject) => {
if (!this.client) return reject(new Error('COS 未配置'));
this.client.putObject(
{ Bucket: this.bucket(), Region: this.regionName, Key: key, Body: body, ContentType: contentType },
(err) => (err ? reject(err) : resolve()),
);
});
}
/** 公网访问地址(桶公共读时可直接访问) */
publicUrl(key: string): string {
return `https://${this.bucketName || this.bucket()}.cos.${this.regionName}.myqcloud.com/${key}`;
}
}
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
import { CosModule } from '../cos/cos.module';
@Module({
imports: [CosModule],
controllers: [UploadController],
providers: [UploadService],
})
+16 -12
View File
@@ -1,21 +1,25 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CosService } from '../cos/cos.service';
// 腾讯云 COS 上传服务(本轮仅提供签名/直传凭证占位,完整实现留后续)
// 上传凭证服务:给小程序签发 COS 直传预签名 URL;未配置时返回结构化“未配置”
@Injectable()
export class UploadService {
constructor(private readonly config: ConfigService) {}
constructor(
private readonly config: ConfigService,
private readonly cos: CosService,
) {}
/** 生成小程序直传所需的临时密钥 / 预签名 URL(占位 */
/** 小程序直传:签发 COS 预签名 PUT URL(小程序不持有永久密钥 */
async getUploadCredentials(key: string) {
// TODO: 通过 COS STS 或预签名 URL 生成临时凭证
//SDK 已移除,接入时见 docs/cos-sdk-removal.md
return {
key,
bucket: this.config.get<string>('cos.bucket'),
region: this.config.get<string>('cos.region'),
// 占位:实际应返回临时 SecretId/SecretKey/Token 或 presigned URL
credentials: null,
};
const bucket = this.config.get<string>('cos.bucket') ?? '';
const region = this.config.get<string>('cos.region') ?? 'ap-guangzhou';
if (!this.cos.configured) {
return { key, bucket, region, presignedUrl: null, configured: false };
}
const presignedUrl = await this.cos.getPresignedPutUrl(key);
return { key, bucket, region, presignedUrl, configured: true };
}
}
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { WordCloudController } from './wordcloud.controller';
import { WordCloudService } from './wordcloud.service';
import { CosModule } from '../cos/cos.module';
@Module({
imports: [CosModule],
controllers: [WordCloudController],
providers: [WordCloudService],
})
+19 -5
View File
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import ExcelJS from 'exceljs';
import { PrismaService } from '../prisma/prisma.service';
import { CosService } from '../cos/cos.service';
type RemoteStatus = 'queued' | 'running' | 'success' | 'failed';
@@ -25,6 +26,7 @@ export class WordCloudService {
constructor(
private readonly config: ConfigService,
private readonly prisma: PrismaService,
private readonly cos: CosService,
) {}
/** 词云平台是否已配置(WORDCLOUD_API_URL 非空) */
@@ -184,10 +186,22 @@ export class WordCloudService {
};
}
/** 下载 wordcloud 结果 PNG 并返回小程序可用地址(COS 转存前置,P1 实现) */
private async resolveResultImage(_remoteJobId: string): Promise<string | null> {
// TODO(P1 COS)GET /api/jobs/{id}/result → 下载 png → 写入 COS → 返回公网 URL。
// 当前 COS 写入尚未实现,结果图地址留待 P1 接入。
return null;
/** 下载 wordcloud 结果 PNG 转存 COS,返回小程序可访问的 COS 公网 URL;COS 未配置时返回空 */
private async resolveResultImage(remoteJobId: string): Promise<string | null> {
if (!this.cos.configured) {
return null;
}
try {
const res = await axios.get(`${this.apiUrl}/api/jobs/${remoteJobId}/files/png`, {
responseType: 'arraybuffer',
timeout: this.timeoutMs,
});
const key = `uploads/wordcloud/${remoteJobId}.png`;
await this.cos.putObject(key, Buffer.from(res.data), 'image/png');
return this.cos.publicUrl(key);
} catch (e) {
this.logger.warn(`转存词云结果失败: ${(e as Error).message}`);
return null;
}
}
}