feat(cos): 接入 COS 直传预签名 + 词云结果图转存
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CosService } from './cos.service';
|
||||
|
||||
@Module({
|
||||
providers: [CosService],
|
||||
exports: [CosService],
|
||||
})
|
||||
export class CosModule {}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user