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.
 
 
 

517 lines
18 KiB

'use strict';
const { getDataSourceTableColumns, previewDataSourceQuery } = require('./dataSourceQuery');
const { hasOwn, parsePagination, ensureNonEmptyString, ensureObjectPayload } = require('./helpers');
const {
ensureActiveTemplate,
ensureTemplateBlock,
getTemplateBatchDimension,
ensureTemplateBatchDimension,
getTemplateBlockBatchBinding,
} = require('./repository');
const SUPPORTED_DIMENSION_TYPES = new Set(['enum', 'time']);
const SUPPORTED_TIME_GRANULARITIES = new Set(['day', 'week', 'month']);
const SUPPORTED_BINDING_TYPES = new Set(['filter_eq', 'time_range']);
const SUPPORTED_VALUE_SOURCES = new Set(['key', 'label']);
const normalizeOptionalString = value => {
const normalized = String(value || '').trim();
return normalized || null;
};
const formatDateTime = date => {
const safeDate = date instanceof Date ? date : new Date(date);
const pad = value => String(value).padStart(2, '0');
return `${safeDate.getFullYear()}-${pad(safeDate.getMonth() + 1)}-${pad(safeDate.getDate())} `
+ `${pad(safeDate.getHours())}:${pad(safeDate.getMinutes())}:${pad(safeDate.getSeconds())}`;
};
const formatDateOnly = date => formatDateTime(date).slice(0, 10);
const parseDateValue = (value, label) => {
const normalized = ensureNonEmptyString(value, label);
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) {
throw `invalid param: ${label}`;
}
return parsed;
};
const startOfDay = value => {
const date = new Date(value);
date.setHours(0, 0, 0, 0);
return date;
};
const endOfDay = value => {
const date = new Date(value);
date.setHours(23, 59, 59, 999);
return date;
};
const startOfWeek = value => {
const date = startOfDay(value);
const day = date.getDay() || 7;
date.setDate(date.getDate() - day + 1);
return date;
};
const endOfWeek = value => {
const date = startOfWeek(value);
date.setDate(date.getDate() + 6);
return endOfDay(date);
};
const startOfMonth = value => {
const date = startOfDay(value);
date.setDate(1);
return date;
};
const endOfMonth = value => {
const date = startOfMonth(value);
date.setMonth(date.getMonth() + 1);
date.setDate(0);
return endOfDay(date);
};
const addGranularity = (value, granularity) => {
const date = new Date(value);
if (granularity === 'day') {
date.setDate(date.getDate() + 1);
return date;
}
if (granularity === 'week') {
date.setDate(date.getDate() + 7);
return date;
}
date.setMonth(date.getMonth() + 1);
return date;
};
const buildTimePreviewRows = (start, end, granularity) => {
const normalizedStart = startOfDay(start);
const normalizedEnd = endOfDay(end);
if (normalizedStart.getTime() > normalizedEnd.getTime()) {
throw 'invalid param: start must be earlier than or equal to end';
}
const rows = [];
let cursor = new Date(normalizedStart);
while (cursor.getTime() <= normalizedEnd.getTime()) {
let bucketStart = null;
let bucketEnd = null;
let label = '';
let key = '';
if (granularity === 'day') {
bucketStart = startOfDay(cursor);
bucketEnd = endOfDay(cursor);
key = formatDateOnly(bucketStart);
label = key;
} else if (granularity === 'week') {
bucketStart = startOfWeek(cursor);
bucketEnd = endOfWeek(cursor);
key = formatDateOnly(bucketStart);
label = `${formatDateOnly(bucketStart)} ~ ${formatDateOnly(bucketEnd)}`;
} else {
bucketStart = startOfMonth(cursor);
bucketEnd = endOfMonth(cursor);
key = `${bucketStart.getFullYear()}-${String(bucketStart.getMonth() + 1).padStart(2, '0')}`;
label = key;
}
const clippedStart = bucketStart.getTime() < normalizedStart.getTime()
? new Date(normalizedStart)
: bucketStart;
const clippedEnd = bucketEnd.getTime() > normalizedEnd.getTime()
? new Date(normalizedEnd)
: bucketEnd;
rows.push({
key,
label,
timeStart: formatDateTime(clippedStart),
timeEnd: formatDateTime(clippedEnd),
});
cursor = addGranularity(bucketStart, granularity);
}
return rows;
};
const normalizeDimensionType = value => {
const normalized = ensureNonEmptyString(value, 'dimensionType').toLowerCase();
if (!SUPPORTED_DIMENSION_TYPES.has(normalized)) throw 'invalid param: dimensionType';
return normalized;
};
const normalizeTimeGranularity = (value, required = false) => {
const normalized = normalizeOptionalString(value);
if (!normalized) {
if (required) throw 'missing param: timeGranularity';
return null;
}
if (!SUPPORTED_TIME_GRANULARITIES.has(normalized)) throw 'invalid param: timeGranularity';
return normalized;
};
const normalizeBindingType = value => {
const normalized = ensureNonEmptyString(value, 'bindingType').toLowerCase();
if (!SUPPORTED_BINDING_TYPES.has(normalized)) throw 'invalid param: bindingType';
return normalized;
};
const normalizeValueSource = (value, bindingType) => {
const normalized = normalizeOptionalString(value) || 'key';
if (bindingType !== 'filter_eq') return 'key';
if (!SUPPORTED_VALUE_SOURCES.has(normalized)) throw 'invalid param: valueSource';
return normalized;
};
const extractBlockQueryConfig = config => {
const safeConfig = ensureObjectPayload(config || {}, 'config');
if (safeConfig.query && typeof safeConfig.query === 'object' && !Array.isArray(safeConfig.query)) {
return safeConfig.query;
}
return safeConfig;
};
const extractBlockQueryTable = config => {
const safeConfig = ensureObjectPayload(config || {}, 'config');
const queryConfig = extractBlockQueryConfig(safeConfig);
return String(
queryConfig.table
|| queryConfig.tableId
|| queryConfig.tableName
|| safeConfig.table
|| safeConfig.tableId
|| safeConfig.tableName
|| safeConfig.previewQuery?.table
|| ''
).trim();
};
const getFieldSetForTable = async (ctx, dataSourceId, tableId, transaction = null) => {
const payload = await getDataSourceTableColumns(ctx, dataSourceId, tableId, transaction);
return new Set((payload.rows || []).map(item => String(item.name || '').trim()).filter(Boolean));
};
const validateDimensionSourceAndFields = async (ctx, template, payload, transaction = null) => {
const sourceTable = ensureNonEmptyString(payload.sourceTable, 'sourceTable');
const fieldSet = await getFieldSetForTable(ctx, template.dataSourceId, sourceTable, transaction);
const keyField = ensureNonEmptyString(payload.keyField, 'keyField');
if (!fieldSet.has(keyField)) throw `invalid param: keyField (${keyField})`;
if (payload.labelField && !fieldSet.has(payload.labelField)) {
throw `invalid param: labelField (${payload.labelField})`;
}
if (payload.timeField && !fieldSet.has(payload.timeField)) {
throw `invalid param: timeField (${payload.timeField})`;
}
return sourceTable;
};
const loadTemplateAndDimension = async (ctx, templateId, transaction = null) => {
const template = await ensureActiveTemplate(ctx, templateId, transaction);
if (!template.dataSourceId) throw '模板未绑定数据源';
const dimension = await getTemplateBatchDimension(ctx, templateId, transaction);
return { template, dimension };
};
module.exports.getTemplateBatchDimension = async (ctx, next) => {
try {
await ensureActiveTemplate(ctx, ctx.params.templateId);
const row = await getTemplateBatchDimension(ctx, ctx.params.templateId);
ctx.body = row || null;
ctx.status = 200;
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '获取模板主批量维度失败' };
}
};
module.exports.upsertTemplateBatchDimension = 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 body = ensureObjectPayload(ctx.request.body || {}, 'body');
const dimensionType = normalizeDimensionType(body.dimensionType);
const timeGranularity = normalizeTimeGranularity(
body.timeGranularity,
dimensionType === 'time'
);
if (dimensionType === 'enum' && normalizeOptionalString(body.timeField)) {
throw 'invalid param: timeField';
}
if (dimensionType === 'time' && !normalizeOptionalString(body.timeField)) {
throw 'missing param: timeField';
}
const payload = {
name: ensureNonEmptyString(body.name, 'name'),
dimensionType,
sourceTable: ensureNonEmptyString(body.sourceTable, 'sourceTable'),
keyField: ensureNonEmptyString(body.keyField, 'keyField'),
labelField: normalizeOptionalString(body.labelField),
timeField: normalizeOptionalString(body.timeField),
timeGranularity,
config: hasOwn(body, 'config')
? ensureObjectPayload(body.config, 'config')
: {},
};
payload.sourceTable = await validateDimensionSourceAndFields(
ctx,
template,
payload,
transaction
);
const existing = await getTemplateBatchDimension(ctx, template.id, transaction);
if (existing) {
await models.ReportTemplateBatchDimension.update({
...payload,
updatedAt: new Date(),
}, {
where: { id: existing.id },
transaction,
});
} else {
await models.ReportTemplateBatchDimension.create({
templateId: template.id,
...payload,
updatedAt: new Date(),
}, { transaction });
}
const fresh = await getTemplateBatchDimension(ctx, template.id, transaction);
await transaction.commit();
ctx.body = fresh;
ctx.status = 200;
} catch (error) {
await transaction.rollback();
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '保存模板主批量维度失败' };
}
};
module.exports.deleteTemplateBatchDimension = 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);
await models.ReportTemplateBatchDimension.destroy({
where: { templateId: template.id },
transaction,
});
await transaction.commit();
ctx.status = 204;
} catch (error) {
await transaction.rollback();
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '删除模板主批量维度失败' };
}
};
module.exports.getTemplateBlockBatchBinding = async (ctx, next) => {
try {
await ensureActiveTemplate(ctx, ctx.params.templateId);
const row = await getTemplateBlockBatchBinding(
ctx,
ctx.params.templateId,
ctx.params.templateBlockId
);
ctx.body = row || null;
ctx.status = 200;
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '获取模板块批量绑定失败' };
}
};
module.exports.upsertTemplateBlockBatchBinding = 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);
const dimension = await ensureTemplateBatchDimension(ctx, template.id, transaction);
const block = await ensureTemplateBlock(
ctx,
template.id,
ctx.params.templateBlockId,
transaction
);
const body = ensureObjectPayload(ctx.request.body || {}, 'body');
const bindingType = normalizeBindingType(body.bindingType);
if (bindingType === 'filter_eq' && dimension.dimensionType !== 'enum') {
throw '枚举型主维度才能使用 filter_eq';
}
if (bindingType === 'time_range' && dimension.dimensionType !== 'time') {
throw '时间型主维度才能使用 time_range';
}
const currentTable = extractBlockQueryTable(block.defaultConfig || {});
if (!currentTable) throw '模板块未配置查询表/数据集';
const targetTable = normalizeOptionalString(body.targetTable) || currentTable;
if (targetTable !== currentTable) {
throw 'targetTable 必须与模板块当前查询表/数据集一致';
}
const targetField = ensureNonEmptyString(body.targetField, 'targetField');
const valueSource = normalizeValueSource(body.valueSource, bindingType);
const filterOperator = bindingType === 'filter_eq'
? (normalizeOptionalString(body.filterOperator) || '=')
: null;
if (bindingType === 'filter_eq' && filterOperator !== '=') {
throw 'invalid param: filterOperator';
}
const fieldSet = await getFieldSetForTable(ctx, template.dataSourceId, targetTable, transaction);
if (!fieldSet.has(targetField)) throw `invalid param: targetField (${targetField})`;
const payload = {
templateBlockId: block.id,
templateBatchDimensionId: dimension.id,
bindingType,
targetTable,
targetField,
filterOperator,
valueSource,
config: hasOwn(body, 'config')
? ensureObjectPayload(body.config, 'config')
: {},
updatedAt: new Date(),
};
const existing = await getTemplateBlockBatchBinding(
ctx,
template.id,
block.id,
transaction
);
if (existing) {
await models.ReportTemplateBlockBatchBinding.update(payload, {
where: { id: existing.id },
transaction,
});
} else {
await models.ReportTemplateBlockBatchBinding.create(payload, { transaction });
}
const fresh = await getTemplateBlockBatchBinding(
ctx,
template.id,
block.id,
transaction
);
await transaction.commit();
ctx.body = fresh;
ctx.status = 200;
} catch (error) {
await transaction.rollback();
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '保存模板块批量绑定失败' };
}
};
module.exports.deleteTemplateBlockBatchBinding = 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);
const block = await ensureTemplateBlock(
ctx,
template.id,
ctx.params.templateBlockId,
transaction
);
await models.ReportTemplateBlockBatchBinding.destroy({
where: { templateBlockId: block.id },
transaction,
});
await transaction.commit();
ctx.status = 204;
} catch (error) {
await transaction.rollback();
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '删除模板块批量绑定失败' };
}
};
module.exports.previewTemplateBatchDimensionValues = async (ctx, next) => {
try {
const { template, dimension } = await loadTemplateAndDimension(ctx, ctx.params.templateId);
if (!dimension) throw '模板未配置主批量维度';
const body = ensureObjectPayload(ctx.request.body || {}, 'body');
if (dimension.dimensionType === 'time') {
const start = parseDateValue(body.start, 'start');
const end = parseDateValue(body.end, 'end');
const granularity = normalizeTimeGranularity(
body.granularity || dimension.timeGranularity,
true
);
ctx.body = {
dimensionType: 'time',
rows: buildTimePreviewRows(start, end, granularity),
};
ctx.status = 200;
return;
}
const { offset, limit, page, pageSize } = parsePagination(body);
const keyword = normalizeOptionalString(body.keyword);
const keyField = ensureNonEmptyString(dimension.keyField, 'dimension.keyField');
const labelField = normalizeOptionalString(dimension.labelField) || keyField;
const previewLimit = page * pageSize;
const dsl = {
table: dimension.sourceTable,
selectFields: [keyField],
groupBy: [keyField],
limit: previewLimit,
};
if (keyField === labelField) {
dsl.orderBy = [{ field: keyField, direction: 'ASC' }];
} else {
dsl.aggregations = [{
func: 'any',
field: labelField,
alias: '__dimension_label__',
}];
dsl.orderBy = [
{ field: '__dimension_label__', direction: 'ASC' },
{ field: keyField, direction: 'ASC' },
];
}
if (keyword) {
dsl.filters = [{
field: labelField,
operator: 'LIKE',
value: `%${keyword}%`,
}];
}
const preview = await previewDataSourceQuery(ctx, template.dataSourceId, dsl);
const rows = (preview.rows || [])
.slice(offset, offset + limit)
.map(item => ({
key: item?.[keyField] == null ? '' : String(item[keyField]),
label: item?.[keyField === labelField ? labelField : '__dimension_label__'] == null
? ''
: String(item[keyField === labelField ? labelField : '__dimension_label__']),
}))
.filter(item => item.key);
ctx.body = {
dimensionType: 'enum',
rows,
};
ctx.status = 200;
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '预览主批量维度值失败' };
}
};