feat(wordcloud): WordCloudJob 模型/迁移 + 词云生成与轮询适配器
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
type RemoteStatus = 'queued' | 'running' | 'success' | 'failed';
|
||||
|
||||
/** Buffer → 独立的 ArrayBuffer(用于 Blob 构造,规避 Buffer<ArrayBufferLike> 类型不兼容) */
|
||||
function toArrayBuffer(buf: Buffer): ArrayBuffer {
|
||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* wordcloud 平台适配器。
|
||||
* 契约:与 wordcloud 之间的接口见 docs/wordcloud-contract.md;对小程序暴露的接口见
|
||||
* docs/api-contract-v1.md §8。
|
||||
* 职责:把小程序的名字文本转成 .xlsx → 代理调 wordcloud POST /api/jobs → 落 WordCloudJob →
|
||||
* 轮询 wordcloud GET /api/jobs/{id} → 回写状态/产物。
|
||||
*/
|
||||
@Injectable()
|
||||
export class WordCloudService {
|
||||
private readonly logger = new Logger(WordCloudService.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
/** 词云平台是否已配置(WORDCLOUD_API_URL 非空) */
|
||||
get configured(): boolean {
|
||||
return !!this.config.get<string>('wordcloud.apiUrl');
|
||||
}
|
||||
|
||||
private get apiUrl(): string {
|
||||
return (this.config.get<string>('wordcloud.apiUrl') ?? '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
private get timeoutMs(): number {
|
||||
return this.config.get<number>('wordcloud.timeoutMs') ?? 30000;
|
||||
}
|
||||
|
||||
/** 名单文本 → 单列 .xlsx(wordcloud 按 DATA_COL_INDEX 读名字列,A 列 index=0) */
|
||||
private async buildNamesXlsx(names: string[]): Promise<Buffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('names');
|
||||
names.forEach((n) => sheet.addRow([{ text: n }]));
|
||||
return Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
}
|
||||
|
||||
/** 代理创建 wordcloud 任务,返回外部 job_id */
|
||||
private async createRemoteJob(
|
||||
xlsx: Buffer,
|
||||
image: { buffer: Buffer; originalname: string },
|
||||
params: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const form = new FormData();
|
||||
form.append('name_list', new Blob([toArrayBuffer(xlsx)]), 'names.xlsx');
|
||||
form.append('mask_image', new Blob([toArrayBuffer(image.buffer)]), image.originalname || 'mask.png');
|
||||
form.append('params', JSON.stringify(params));
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await axios.post(`${this.apiUrl}/api/jobs`, form, {
|
||||
timeout: this.timeoutMs,
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.error(`创建词云任务失败: ${(e as Error).message}`);
|
||||
throw new HttpException('词云平台暂不可用', HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
const jobId = res.data?.job_id;
|
||||
if (!jobId) {
|
||||
throw new HttpException('词云平台未返回任务ID', HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
return jobId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 交互式生成:小程序上传底图 + 名单文本 → 在 wordcloud 创建任务并落库,返回内部 jobId。
|
||||
*/
|
||||
async createGenerateJob(
|
||||
userId: string,
|
||||
image: { buffer: Buffer; originalname: string },
|
||||
namesText: string,
|
||||
paramsJson = '{}',
|
||||
): Promise<string> {
|
||||
if (!this.configured) {
|
||||
throw new HttpException('词云平台未配置', HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
if (!image?.buffer) {
|
||||
throw new HttpException('缺少底图', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
const names = namesText
|
||||
.split(/[\n,]|,/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!names.length) {
|
||||
throw new HttpException('名单不能为空', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
let userParams: Record<string, unknown> = {};
|
||||
if (paramsJson) {
|
||||
try {
|
||||
userParams = JSON.parse(paramsJson);
|
||||
} catch {
|
||||
throw new HttpException('params 必须是合法 JSON', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// 名单写在 xlsx A 列,DATA_COL_INDEX 强制对齐到 0
|
||||
const params: Record<string, unknown> = {
|
||||
MODE: 'IMAGE',
|
||||
DATA_COL_INDEX: 0,
|
||||
SEED: 42,
|
||||
N_REPETITIONS: 20,
|
||||
ENABLE_STROKE_WEIGHTS: false,
|
||||
FONT_COLOR: '#000000',
|
||||
...userParams,
|
||||
};
|
||||
params.DATA_COL_INDEX = 0;
|
||||
|
||||
const xlsx = await this.buildNamesXlsx(names);
|
||||
const remoteJobId = await this.createRemoteJob(xlsx, image, params);
|
||||
|
||||
const job = await this.prisma.wordCloudJob.create({
|
||||
data: { userId, remoteJobId, status: 'QUEUED' },
|
||||
});
|
||||
return job.id;
|
||||
}
|
||||
|
||||
private mapStatus(s?: string): RemoteStatus {
|
||||
if (s === 'running') return 'running';
|
||||
if (s === 'success') return 'success';
|
||||
if (s === 'failed') return 'failed';
|
||||
return 'queued';
|
||||
}
|
||||
|
||||
/** 查询本人词云任务:归属校验 → 代理轮询 wordcloud → 回写 DB → 返回小程序侧模型 */
|
||||
async getUserJob(userId: string, id: string) {
|
||||
const job = await this.prisma.wordCloudJob.findFirst({ where: { id, userId } });
|
||||
if (!job) {
|
||||
throw new HttpException('任务不存在', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (!job.remoteJobId) {
|
||||
throw new HttpException('任务尚未创建', HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
let remote: Record<string, unknown>;
|
||||
try {
|
||||
const res = await axios.get(`${this.apiUrl}/api/jobs/${job.remoteJobId}`, {
|
||||
timeout: this.timeoutMs,
|
||||
});
|
||||
remote = res.data;
|
||||
} catch (e) {
|
||||
this.logger.warn(`轮询词云任务失败: ${(e as Error).message}`);
|
||||
throw new HttpException('词云平台暂不可用', HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
const status = this.mapStatus(remote.status as string | undefined);
|
||||
const progress =
|
||||
typeof remote.progress_percent === 'number' ? remote.progress_percent : job.progress;
|
||||
|
||||
// success 时尝试解析结果图(当前 COS 写入未实现,P1 接入后返回公网 URL)
|
||||
const imageUrl = status === 'success' ? await this.resolveResultImage(job.remoteJobId) : null;
|
||||
|
||||
const updated = await this.prisma.wordCloudJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: status.toUpperCase() as 'QUEUED' | 'RUNNING' | 'SUCCESS' | 'FAILED',
|
||||
progress,
|
||||
imageUrl,
|
||||
error: (remote.error as string | null) ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
status,
|
||||
progress,
|
||||
imageUrl: updated.imageUrl ?? undefined,
|
||||
error: updated.error ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 下载 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user