ai-query对接新版freesun-agent接口的分支
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

522 lines
20 KiB

'use strict';
const { hasOwn, toInt, parsePagination, ensureCreatedBy, ensureObjectPayload, ensureNonEmptyString, normalizeNullableInt } = require('./helpers');
const { normalizeTemplateBlockConfigOverrides, ensureActiveTemplate, ensureTemplateBatchDimension, getTemplateBlockBatchBindingMap, cloneTemplateToInstance } = require('./repository');
const {
resolveBlockPayload,
finalizeResolvedBlockPayload,
} = require('./blockPayloadResolver');
const { sanitizeQueryBlockConfig } = require('./chartSnapshot');
const TASK_STATUSES = new Set(['pending', 'running', 'partial_success', 'success', 'failed']);
const ITEM_STATUSES = {
pending: 'pending',
running: 'running',
success: 'success',
failed: 'failed',
};
const normalizeOptionalString = value => {
const normalized = String(value || '').trim();
return normalized || null;
};
const ensureValidDateInput = (value, label) => {
const normalized = ensureNonEmptyString(value, label);
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) throw `invalid param: ${label}`;
return normalized;
};
const deepCloneJson = value => {
if (value === null || value === undefined) return value;
return JSON.parse(JSON.stringify(value));
};
const renderNamePattern = (pattern, templateName, dimensionItem) => {
const dimensionKey = String(dimensionItem.key || '').trim();
const dimensionLabel = String(dimensionItem.label || dimensionKey).trim() || dimensionKey;
const rawPattern = String(pattern || '').trim() || '{{templateName}}-{{dimensionLabel}}';
const rendered = rawPattern
.replace(/\{\{\s*templateName\s*\}\}/g, templateName)
.replace(/\{\{\s*dimensionKey\s*\}\}/g, dimensionKey)
.replace(/\{\{\s*dimensionLabel\s*\}\}/g, dimensionLabel);
return rendered.trim() || `${templateName}-${dimensionLabel || dimensionKey}`;
};
const extractQueryHolder = config => {
if (config.query && typeof config.query === 'object' && !Array.isArray(config.query)) {
return config.query;
}
return config;
};
const extractBlockQueryTable = config => {
const safeConfig = ensureObjectPayload(config || {}, 'config');
const queryConfig = extractQueryHolder(safeConfig);
return String(
queryConfig.table
|| queryConfig.tableId
|| queryConfig.tableName
|| safeConfig.table
|| safeConfig.tableId
|| safeConfig.tableName
|| safeConfig.previewQuery?.table
|| ''
).trim();
};
const normalizeConfiguredBinding = (config = {}, currentTable = '') => {
const binding = ensureObjectPayload(config?.templateBatchBinding || {}, 'config.templateBatchBinding');
const targetField = String(binding.targetField || '').trim();
if (!targetField) return null;
const bindingType = String(binding.bindingType || '').trim().toLowerCase();
if (!bindingType) return null;
const targetTable = String(binding.targetTable || currentTable || '').trim() || null;
return {
bindingType,
targetTable,
targetField,
filterOperator: String(binding.filterOperator || '').trim() || null,
valueSource: String(binding.valueSource || '').trim() || null,
};
};
const collectConfiguredFilterFields = (config = {}) => {
const fieldSet = new Set();
const safeConfig = deepCloneJson(config) || {};
const filterLists = [
safeConfig?.filters,
safeConfig?.query?.filters,
safeConfig?.previewQuery?.filters,
];
filterLists.forEach(filters => {
(Array.isArray(filters) ? filters : []).forEach(item => {
const field = String(item?.field || '').trim();
if (field) fieldSet.add(field);
});
});
return fieldSet;
};
const collectConfiguredTimeFields = (config = {}) => {
const result = [];
const seen = new Set();
const safeConfig = deepCloneJson(config) || {};
const ranges = [
safeConfig?.timeRange,
safeConfig?.query?.timeRange,
safeConfig?.previewQuery?.timeRange,
];
ranges.forEach(item => {
const field = String(item?.field || '').trim();
if (!field || seen.has(field)) return;
seen.add(field);
result.push(field);
});
return result;
};
const deriveImplicitBatchBinding = (config = {}, dimension = null) => {
if (!dimension || !dimension.sourceTable) return null;
const currentTable = extractBlockQueryTable(config);
if (!currentTable || currentTable !== String(dimension.sourceTable).trim()) {
return null;
}
if (String(dimension.dimensionType || '').trim() === 'time') {
const configuredTimeFields = collectConfiguredTimeFields(config);
const dimensionTimeField = String(dimension.timeField || '').trim();
const targetField =
(dimensionTimeField && configuredTimeFields.includes(dimensionTimeField))
? dimensionTimeField
: (configuredTimeFields[0] || dimensionTimeField || String(dimension.keyField || '').trim());
if (!targetField) return null;
return {
bindingType: 'time_range',
targetTable: currentTable,
targetField,
filterOperator: null,
valueSource: null,
};
}
const configuredFilterFields = collectConfiguredFilterFields(config);
const dimensionKeyField = String(dimension.keyField || '').trim();
const dimensionLabelField = String(dimension.labelField || '').trim();
let targetField = '';
let valueSource = 'key';
if (
dimensionLabelField &&
dimensionLabelField !== dimensionKeyField &&
configuredFilterFields.has(dimensionLabelField)
) {
targetField = dimensionLabelField;
valueSource = 'label';
} else if (dimensionKeyField && configuredFilterFields.has(dimensionKeyField)) {
targetField = dimensionKeyField;
valueSource = 'key';
} else if (dimensionLabelField && dimensionLabelField !== dimensionKeyField) {
targetField = dimensionLabelField;
valueSource = 'label';
} else {
targetField = dimensionKeyField;
valueSource = 'key';
}
if (!targetField) return null;
return {
bindingType: 'filter_eq',
targetTable: currentTable,
targetField,
filterOperator: '=',
valueSource,
};
};
const upsertFilter = (queryConfig, targetField, operator, value) => {
const nextFilters = Array.isArray(queryConfig.filters)
? queryConfig.filters.filter(item => String(item?.field || '').trim() !== targetField)
: [];
nextFilters.push({ field: targetField, operator, value });
queryConfig.filters = nextFilters;
};
const applyTimeRange = (queryConfig, targetField, timeStart, timeEnd) => {
queryConfig.timeRange = {
field: targetField,
start: timeStart,
end: timeEnd,
};
};
const upsertMetricFilter = (metric = {}, targetField, operator, value) => {
const nextMetric = deepCloneJson(metric) || {};
const nextFilters = Array.isArray(nextMetric.filters)
? nextMetric.filters.filter(item => String(item?.field || '').trim() !== targetField)
: [];
nextFilters.push({ field: targetField, operator, value });
nextMetric.filters = nextFilters;
return nextMetric;
};
const applyMetricTimeRange = (metric = {}, targetField, timeStart, timeEnd) => {
const nextMetric = deepCloneJson(metric) || {};
nextMetric.timeRange = {
field: targetField,
start: timeStart,
end: timeEnd,
};
return nextMetric;
};
const injectBatchBindingIntoConfig = (config, binding, dimensionItem) => {
const nextConfig = deepCloneJson(config) || {};
const queryConfig = extractQueryHolder(nextConfig);
const metricList = Array.isArray(nextConfig.kpiMetrics) ? nextConfig.kpiMetrics : [];
if (binding.bindingType === 'filter_eq') {
const sourceValue = binding.valueSource === 'label'
? String(dimensionItem.label || dimensionItem.key || '')
: String(dimensionItem.key || '');
upsertFilter(queryConfig, binding.targetField, binding.filterOperator || '=', sourceValue);
if (nextConfig.previewQuery && typeof nextConfig.previewQuery === 'object' && !Array.isArray(nextConfig.previewQuery)) {
upsertFilter(nextConfig.previewQuery, binding.targetField, binding.filterOperator || '=', sourceValue);
}
if (metricList.length) {
nextConfig.kpiMetrics = metricList.map(metric =>
upsertMetricFilter(metric, binding.targetField, binding.filterOperator || '=', sourceValue)
);
}
} else {
applyTimeRange(queryConfig, binding.targetField, dimensionItem.timeStart, dimensionItem.timeEnd);
if (nextConfig.previewQuery && typeof nextConfig.previewQuery === 'object' && !Array.isArray(nextConfig.previewQuery)) {
applyTimeRange(nextConfig.previewQuery, binding.targetField, dimensionItem.timeStart, dimensionItem.timeEnd);
}
if (metricList.length) {
nextConfig.kpiMetrics = metricList.map(metric =>
applyMetricTimeRange(metric, binding.targetField, dimensionItem.timeStart, dimensionItem.timeEnd)
);
}
}
return sanitizeQueryBlockConfig(nextConfig);
};
const normalizeDimensionItems = (dimension, rawItems) => {
if (!Array.isArray(rawItems) || !rawItems.length) throw 'invalid param: dimensionItems';
const uniqueKeySet = new Set();
return rawItems.map((item, index) => {
const row = ensureObjectPayload(item, `dimensionItems[${index}]`);
const key = ensureNonEmptyString(row.key, `dimensionItems[${index}].key`);
if (uniqueKeySet.has(key)) throw `duplicate param: dimensionItems.key (${key})`;
uniqueKeySet.add(key);
const label = normalizeOptionalString(row.label) || key;
if (dimension.dimensionType === 'time') {
const timeStart = ensureValidDateInput(row.timeStart, `dimensionItems[${index}].timeStart`);
const timeEnd = ensureValidDateInput(row.timeEnd, `dimensionItems[${index}].timeEnd`);
if (new Date(timeStart).getTime() > new Date(timeEnd).getTime()) {
throw `invalid param: dimensionItems[${index}].timeStart`;
}
return { key, label, timeStart, timeEnd };
}
return {
key,
label,
timeStart: null,
timeEnd: null,
};
});
};
const resolveBatchStatus = (successCount, failedCount) => {
if (!failedCount) return 'success';
if (!successCount) return 'failed';
return 'partial_success';
};
const loadBatchDetail = async (ctx, batchId) => {
const { models } = ctx.app.fs.dc;
const parsedBatchId = toInt(batchId);
if (!parsedBatchId) throw '缺少参数: batchId';
const batch = await models.ReportGenerationBatch.findByPk(parsedBatchId, {
include: [
{ model: models.ReportTemplate, required: false },
{ model: models.ReportTemplateBatchDimension, required: false },
],
});
if (!batch) throw '批量生成任务不存在';
const items = await models.ReportGenerationBatchItem.findAll({
where: { batchId: parsedBatchId },
include: [{ model: models.ReportInstance, required: false }],
order: [['itemIndex', 'ASC'], ['id', 'ASC']],
});
return {
...batch.toJSON(),
items: items.map(item => item.toJSON()),
};
};
module.exports.createBatchInstancesFromTemplate = async (ctx, next) => {
const transaction = await ctx.app.fs.dc.orm.transaction();
try {
const { models } = ctx.app.fs.dc;
const template = await ensureActiveTemplate(ctx, ctx.params.templateId, transaction);
if (!template.dataSourceId) throw '模板未绑定数据源';
const dimension = await ensureTemplateBatchDimension(ctx, template.id, transaction);
const body = ensureObjectPayload(ctx.request.body || {}, 'body');
const createdBy = ensureCreatedBy(body);
const departmentId = hasOwn(body, 'departmentId')
? normalizeNullableInt(body.departmentId, 'departmentId')
: normalizeNullableInt(template.departmentId, 'departmentId');
const namePattern = normalizeOptionalString(body.namePattern) || '{{templateName}}-{{dimensionLabel}}';
const dimensionItems = normalizeDimensionItems(dimension, body.dimensionItems);
const blockConfigOverrides = hasOwn(body, 'blockConfigOverrides')
? await normalizeTemplateBlockConfigOverrides(
ctx,
template.id,
body.blockConfigOverrides,
transaction
)
: new Map();
const bindingMap = await getTemplateBlockBatchBindingMap(ctx, template.id, transaction);
const createdBatch = await models.ReportGenerationBatch.create({
templateId: template.id,
templateBatchDimensionId: dimension.id,
createdBy,
namePattern,
status: 'pending',
totalCount: dimensionItems.length,
successCount: 0,
failedCount: 0,
requestPayload: {
...body,
templateId: template.id,
templateName: template.name,
templateBatchDimensionId: dimension.id,
createdBy,
departmentId,
namePattern,
dimensionItems,
},
}, {
transaction,
returning: true,
});
const createdItems = await models.ReportGenerationBatchItem.bulkCreate(
dimensionItems.map((item, index) => ({
batchId: createdBatch.id,
itemIndex: index + 1,
dimensionKey: item.key,
dimensionLabel: item.label,
timeStart: item.timeStart ? new Date(item.timeStart) : null,
timeEnd: item.timeEnd ? new Date(item.timeEnd) : null,
status: ITEM_STATUSES.pending,
})),
{ transaction, returning: true }
);
await models.ReportGenerationBatch.update({
status: 'running',
startedAt: new Date(),
}, {
where: { id: createdBatch.id },
transaction,
});
await transaction.commit();
let successCount = 0;
let failedCount = 0;
for (let index = 0; index < dimensionItems.length; index += 1) {
const dimensionItem = dimensionItems[index];
const itemRow = createdItems[index];
await models.ReportGenerationBatchItem.update({
status: ITEM_STATUSES.running,
}, {
where: { id: itemRow.id },
});
const itemTransaction = await ctx.app.fs.dc.orm.transaction();
try {
const createdInstance = await models.ReportInstance.create({
templateId: template.id,
generationBatchId: createdBatch.id,
schemaVersionId: template.schemaVersionId,
name: renderNamePattern(namePattern, template.name, dimensionItem),
type: String(template.type || 'general'),
dataSourceId: toInt(template.dataSourceId),
createdBy,
departmentId,
batchDimensionKey: dimensionItem.key,
batchDimensionLabel: dimensionItem.label,
batchTimeStart: dimensionItem.timeStart ? new Date(dimensionItem.timeStart) : null,
batchTimeEnd: dimensionItem.timeEnd ? new Date(dimensionItem.timeEnd) : null,
}, {
transaction: itemTransaction,
returning: true,
});
await cloneTemplateToInstance(
ctx,
template.id,
createdInstance.id,
blockConfigOverrides,
itemTransaction,
{
resolveBlockPayload: async (templateBlock, payload) => {
const { config, contentSnapshot } = payload || {};
const resolvedConfigSource = deepCloneJson(config) || {};
const currentTable = extractBlockQueryTable(resolvedConfigSource);
const binding =
bindingMap.get(Number(templateBlock.id))
|| normalizeConfiguredBinding(resolvedConfigSource, currentTable)
|| deriveImplicitBatchBinding(resolvedConfigSource, dimension);
const resolvedConfig = binding
? injectBatchBindingIntoConfig(resolvedConfigSource, binding, dimensionItem)
: resolvedConfigSource;
return resolveBlockPayload(ctx, {
blockType: templateBlock?.blockType,
dataSourceId: template.dataSourceId,
config: resolvedConfig,
contentSnapshot: deepCloneJson(contentSnapshot) || null,
transaction: itemTransaction,
});
},
finalizeCreatedBlock: async (templateBlock, createdPayload, helpers) => {
return finalizeResolvedBlockPayload(ctx, {
blockType: templateBlock?.blockType,
config: createdPayload?.config || {},
contentSnapshot: createdPayload?.contentSnapshot || null,
blockIdMap: helpers?.blockIdMap,
getCreatedBlockStateByInstanceId: helpers?.getCreatedStateByInstanceId,
});
},
}
);
await models.ReportGenerationBatchItem.update({
status: ITEM_STATUSES.success,
reportInstanceId: createdInstance.id,
finishedAt: new Date(),
errorMessage: null,
}, {
where: { id: itemRow.id },
transaction: itemTransaction,
});
await itemTransaction.commit();
successCount += 1;
} catch (error) {
await itemTransaction.rollback();
failedCount += 1;
await models.ReportGenerationBatchItem.update({
status: ITEM_STATUSES.failed,
finishedAt: new Date(),
errorMessage: typeof error === 'string' ? error : (error?.message || '批量生成实例失败'),
}, {
where: { id: itemRow.id },
});
}
}
await models.ReportGenerationBatch.update({
status: resolveBatchStatus(successCount, failedCount),
successCount,
failedCount,
finishedAt: new Date(),
}, {
where: { id: createdBatch.id },
});
ctx.body = await loadBatchDetail(ctx, createdBatch.id);
ctx.status = 200;
} catch (error) {
if (!transaction.finished) await transaction.rollback();
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '批量生成报表实例失败' };
}
};
module.exports.getGenerationBatches = async (ctx, next) => {
try {
const { models } = ctx.app.fs.dc;
const { offset, limit } = parsePagination(ctx.request.query);
const where = {};
const templateId = toInt(ctx.request.query.templateId);
const createdBy = normalizeOptionalString(ctx.request.query.createdBy);
const status = normalizeOptionalString(ctx.request.query.status);
if (templateId) where.templateId = templateId;
if (createdBy) where.createdBy = createdBy;
if (status) {
if (!TASK_STATUSES.has(status)) throw 'invalid param: status';
where.status = status;
}
const rows = await models.ReportGenerationBatch.findAndCountAll({
where,
include: [
{ model: models.ReportTemplate, required: false },
{ model: models.ReportTemplateBatchDimension, required: false },
],
order: [['id', 'DESC']],
offset,
limit,
});
ctx.body = {
count: rows.count,
rows: rows.rows,
};
ctx.status = 200;
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '获取批量生成任务列表失败' };
}
};
module.exports.getGenerationBatchDetail = async (ctx, next) => {
try {
ctx.body = await loadBatchDetail(ctx, ctx.params.batchId);
ctx.status = 200;
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '获取批量生成任务详情失败' };
}
};