flnxi-Wu/premium-modules/paid-core/template-validation.mjs
2026-06-03 18:41:52 +08:00

2470 lines
74 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const UPLOAD_TEMPLATE_FIELD_LIMITS = {
maxFields: 80,
maxKeyLength: 64,
maxLabelLength: 80,
maxOptions: 200,
maxOptionLength: 80,
};
const DEFAULT_MIGRATION_SAFE_LIMIT = 200;
const MAX_MIGRATION_FORCE_LIMIT = 1000;
const ALLOWED_TEMPLATE_STATUSES = new Set(['draft', 'active', 'archived']);
const ALLOWED_MATERIAL_KINDS = new Set(['IMAGE', 'VIDEO', 'AUDIO', 'OTHER']);
const ALLOWED_FIELD_TYPES = new Set([
'text',
'textarea',
'number',
'select',
'checkbox',
'radio',
'date',
'uppercase',
]);
const OPTION_FIELD_TYPES = new Set(['select', 'radio']);
const CONDITION_OPERATORS = new Set(['equals', 'notEquals', 'contains', 'notEmpty', 'isEmpty']);
const CONDITION_OPERATORS_REQUIRING_VALUE = new Set(['equals', 'notEquals', 'contains']);
const STANDARD_MATERIAL_FIELDS = new Set([
'productModel',
'brandName',
'seriesName',
'skuCode',
'materialTexture',
'categoryName',
'sceneName',
'materialType',
'materialKind',
'renderer',
'renderTime',
'imageResolution',
'colorMode',
'authorName',
'platformName',
]);
const MIGRATION_BACKUP_STANDARD_FIELDS = [
'productModel',
'brandName',
'seriesName',
'skuCode',
'materialTexture',
'categoryName',
'sceneName',
'materialType',
'materialKind',
'renderer',
'renderTime',
'imageResolution',
'colorMode',
'authorName',
'platformName',
'audioDuration',
'audioCategory',
'audioScene',
];
const CLEARABLE_NON_TARGET_FIELD_DEFAULTS = {
brandName: null,
seriesName: null,
skuCode: null,
materialTexture: null,
platformName: '',
renderTime: null,
imageResolution: null,
colorMode: null,
audioDuration: null,
audioCategory: null,
audioScene: null,
};
const AUDIO_MATERIAL_FIELDS = ['audioDuration', 'audioCategory', 'audioScene'];
const STANDARD_FIELD_LABELS = {
productModel: '产品型号',
brandName: '品牌',
seriesName: '系列',
skuCode: 'SKU',
materialTexture: '材质纹理',
categoryName: '分类',
sceneName: '场景',
materialType: '素材类型',
materialKind: '素材大类',
renderer: '渲染器',
renderTime: '渲染时间',
imageResolution: '图片分辨率',
colorMode: '色彩模式',
authorName: '作者',
platformName: '平台',
audioDuration: '音频时长',
audioCategory: '音频分类',
audioScene: '音频场景',
};
const MATERIAL_HOME_FILTER_COLUMN_FIELDS = new Set([
'categoryName',
'sceneName',
'platformName',
'materialType',
'renderer',
'brandName',
'colorMode',
'renderTime',
'imageResolution',
'seriesName',
'skuCode',
'materialTexture',
'authorName',
]);
const MATERIAL_LIST_SEARCH_COLUMN_FIELDS = [
'productModel',
'brandName',
'seriesName',
'skuCode',
'materialTexture',
'categoryName',
'sceneName',
'authorName',
];
const MATERIAL_LIST_HOME_FILTER_COLUMN_FIELDS = new Set([
'renderer',
'brandName',
'seriesName',
'skuCode',
'materialTexture',
'renderTime',
'imageResolution',
'colorMode',
'productModel',
'platformName',
'categoryName',
'sceneName',
'authorName',
]);
const departmentCollator = new Intl.Collator('zh-Hans-CN-u-co-pinyin', {
numeric: true,
sensitivity: 'base',
});
function hasOwn(value, key) {
return Object.prototype.hasOwnProperty.call(value, key);
}
function validationError(error, field, details) {
return {
valid: false,
error,
field,
details,
};
}
function parseFieldConditions(conditionsJson) {
if (!conditionsJson) return null;
if (typeof conditionsJson === 'string' && conditionsJson.trim() === '') return null;
try {
const parsed = typeof conditionsJson === 'string' ? JSON.parse(conditionsJson) : conditionsJson;
if (!parsed || typeof parsed !== 'object') return null;
if (!Array.isArray(parsed.conditions)) return null;
return {
logic: parsed.logic === 'OR' ? 'OR' : 'AND',
conditions: parsed.conditions.filter((condition) => (
condition &&
typeof condition === 'object' &&
condition.fieldKey &&
condition.operator
)),
};
} catch {
return null;
}
}
function detectCircularDependencies(fields) {
const dependencyMap = new Map();
for (const field of fields) {
const conditions = parseFieldConditions(field.conditions);
if (!conditions) continue;
dependencyMap.set(field.key, new Set(conditions.conditions.map((condition) => condition.fieldKey)));
}
const visited = new Set();
const recursionStack = new Set();
const circularFields = [];
function dfs(fieldKey) {
if (recursionStack.has(fieldKey)) return true;
if (visited.has(fieldKey)) return false;
visited.add(fieldKey);
recursionStack.add(fieldKey);
const deps = dependencyMap.get(fieldKey);
if (deps) {
for (const dep of deps) {
if (dfs(dep)) {
circularFields.push(fieldKey);
return true;
}
}
}
recursionStack.delete(fieldKey);
return false;
}
for (const field of fields) {
if (!visited.has(field.key)) dfs(field.key);
}
return Array.from(new Set(circularFields));
}
function parseValidationRules(validationsJson) {
if (!validationsJson || String(validationsJson).trim() === '') return null;
try {
const parsed = JSON.parse(validationsJson);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
return parsed;
} catch {
return null;
}
}
function validateValidationRules(rules) {
if (typeof rules.minLength === 'number' && typeof rules.maxLength === 'number') {
if (rules.minLength > rules.maxLength) {
return { valid: false, error: '最小长度不能大于最大长度' };
}
}
if (typeof rules.min === 'number' && typeof rules.max === 'number') {
if (rules.min > rules.max) {
return { valid: false, error: '最小值不能大于最大值' };
}
}
if (rules.pattern) {
try {
new RegExp(rules.pattern);
} catch {
return { valid: false, error: '正则表达式格式无效' };
}
}
return { valid: true };
}
function getSupportedValidations(fieldType) {
const common = ['required'];
switch (fieldType) {
case 'text':
case 'textarea':
return [...common, 'minLength', 'maxLength', 'pattern'];
case 'number':
return [...common, 'min', 'max'];
case 'select':
case 'checkbox':
case 'radio':
case 'date':
case 'file':
case 'image':
default:
return common;
}
}
function normalizeStaticOptions(value) {
if (value === null || value === undefined) {
return { value: null, invalid: false };
}
if (Array.isArray(value)) {
const options = value.map((option) => String(option ?? '').trim()).filter(Boolean);
return {
value: options.length > 0 ? JSON.stringify(Array.from(new Set(options))) : null,
invalid: false,
};
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return { value: null, invalid: false };
try {
return normalizeStaticOptions(JSON.parse(trimmed));
} catch {
const options = trimmed.split('\n').map((option) => option.trim()).filter(Boolean);
return {
value: options.length > 0 ? JSON.stringify(Array.from(new Set(options))) : null,
invalid: false,
};
}
}
return { value: null, invalid: true };
}
function parseNormalizedOptions(value) {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed)
? parsed.map((option) => String(option ?? '').trim()).filter(Boolean)
: [];
} catch {
return [];
}
}
function normalizeJsonValue(value) {
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return '';
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
return value ?? null;
}
function stableStringify(value) {
const normalized = normalizeJsonValue(value);
if (Array.isArray(normalized)) {
return JSON.stringify(normalized.map((item) => stableStringify(item)));
}
if (normalized && typeof normalized === 'object') {
const entries = Object.entries(normalized)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => [key, stableStringify(item)]);
return JSON.stringify(entries);
}
return JSON.stringify(normalized);
}
function parseStaticOptions(value) {
const normalized = normalizeJsonValue(value);
if (!Array.isArray(normalized)) return [];
return normalized
.filter((item) => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean);
}
export function detectDangerousTemplateChanges(args) {
const existingTemplate = args?.existingTemplate;
const nextMaterialType = args?.nextMaterialType;
const nextMaterialKind = args?.nextMaterialKind;
const nextFields = args?.nextFields;
const changes = [];
if (!existingTemplate || !Array.isArray(existingTemplate.fields)) {
return changes;
}
if (nextMaterialType && nextMaterialType !== existingTemplate.materialType) {
changes.push({
type: 'materialTypeChanged',
before: existingTemplate.materialType,
after: nextMaterialType,
});
}
if (nextMaterialKind && nextMaterialKind !== existingTemplate.materialKind) {
changes.push({
type: 'materialKindChanged',
before: existingTemplate.materialKind,
after: nextMaterialKind,
});
}
if (!Array.isArray(nextFields)) {
return changes;
}
const incomingById = new Map(
nextFields
.filter((field) => field.id)
.map((field) => [field.id, field])
);
for (const existingField of existingTemplate.fields) {
const incomingField = incomingById.get(existingField.id);
if (!incomingField) {
changes.push({
type: 'fieldDeleted',
fieldId: existingField.id,
fieldKey: existingField.key,
fieldLabel: existingField.label,
});
continue;
}
if (incomingField.key !== existingField.key) {
changes.push({
type: 'fieldKeyChanged',
fieldId: existingField.id,
before: existingField.key,
after: incomingField.key,
fieldLabel: existingField.label,
});
}
if (incomingField.type !== existingField.type) {
changes.push({
type: 'fieldTypeChanged',
fieldId: existingField.id,
fieldKey: existingField.key,
fieldLabel: existingField.label,
before: existingField.type,
after: incomingField.type,
});
}
if (existingField.addToCategory && incomingField.addToCategory === false) {
changes.push({
type: 'fieldCategoryDisabled',
fieldId: existingField.id,
fieldKey: existingField.key,
fieldLabel: existingField.label,
});
}
if (existingField.isSearchable && incomingField.isSearchable === false) {
changes.push({
type: 'fieldSearchDisabled',
fieldId: existingField.id,
fieldKey: existingField.key,
fieldLabel: existingField.label,
});
}
if (!existingField.isReadOnly && incomingField.isReadOnly === true) {
changes.push({
type: 'fieldMadeReadOnly',
fieldId: existingField.id,
fieldKey: existingField.key,
fieldLabel: existingField.label,
});
}
if (!existingField.required && incomingField.required === true) {
changes.push({
type: 'fieldMadeRequired',
fieldId: existingField.id,
fieldKey: existingField.key,
fieldLabel: existingField.label,
});
}
if (stableStringify(incomingField.validations) !== stableStringify(existingField.validations)) {
changes.push({
type: 'fieldValidationChanged',
fieldId: existingField.id,
fieldKey: existingField.key,
fieldLabel: existingField.label,
});
}
const existingOptions = parseStaticOptions(existingField.optionsStatic);
if (existingOptions.length > 0) {
const incomingOptions = new Set(parseStaticOptions(incomingField.optionsStatic));
const removedOptions = existingOptions.filter((option) => !incomingOptions.has(option));
if (removedOptions.length > 0) {
changes.push({
type: 'fieldOptionsRemoved',
fieldId: existingField.id,
fieldKey: existingField.key,
fieldLabel: existingField.label,
removedOptions,
});
}
}
if (stableStringify(incomingField.conditions) !== stableStringify(existingField.conditions)) {
changes.push({
type: 'fieldConditionsChanged',
fieldId: existingField.id,
fieldKey: existingField.key,
fieldLabel: existingField.label,
});
}
}
return changes;
}
function normalizeValidations(value, fieldType) {
if (value === null || value === undefined) return { value: null };
if (typeof value === 'string' && value.trim() === '') return { value: null };
let raw = value;
if (typeof value === 'string') {
try {
raw = JSON.parse(value);
} catch {
return { value: null, invalid: '验证规则必须是合法 JSON 对象' };
}
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return { value: null, invalid: '验证规则必须是对象' };
}
const parsedRules = parseValidationRules(JSON.stringify(raw));
if (!parsedRules) {
return { value: null, invalid: '验证规则必须是对象' };
}
const validation = validateValidationRules(parsedRules);
if (!validation.valid) {
return { value: null, invalid: validation.error || '验证规则配置无效' };
}
const supportedValidations = new Set(getSupportedValidations(fieldType));
const unsupportedRules = [
['minLength', parsedRules.minLength],
['maxLength', parsedRules.maxLength],
['pattern', parsedRules.pattern],
['min', parsedRules.min],
['max', parsedRules.max],
]
.filter(([ruleKey, ruleValue]) => ruleValue !== undefined && !supportedValidations.has(String(ruleKey)))
.map(([ruleKey]) => String(ruleKey));
if (unsupportedRules.length > 0) {
return {
value: null,
invalid: `字段类型 ${fieldType} 不支持验证规则:${unsupportedRules.join(', ')}`,
};
}
return {
value: JSON.stringify(parsedRules),
rules: parsedRules,
};
}
function normalizeConditions(value) {
if (value === null || value === undefined) return { value: null, invalid: false };
if (typeof value === 'string' && value.trim() === '') return { value: null, invalid: false };
let raw;
try {
raw = typeof value === 'string' ? JSON.parse(value) : value;
} catch {
return { value: null, invalid: true };
}
if (!raw || typeof raw !== 'object') return { value: null, invalid: true };
if (raw.logic !== undefined && raw.logic !== 'AND' && raw.logic !== 'OR') {
return { value: null, invalid: true };
}
if (!Array.isArray(raw.conditions)) return { value: null, invalid: true };
if (raw.conditions.length === 0) return { value: null, invalid: false };
for (const condition of raw.conditions) {
if (!condition || typeof condition !== 'object') return { value: null, invalid: true };
if (!condition.fieldKey || !condition.operator) return { value: null, invalid: true };
}
const parsed = parseFieldConditions(raw);
if (!parsed || parsed.conditions.length !== raw.conditions.length) {
return { value: null, invalid: true };
}
return { value: JSON.stringify(parsed), invalid: false, conditions: parsed };
}
export function validateAndFormatUploadTemplateFields(fields) {
if (!Array.isArray(fields)) {
return {
valid: false,
error: '字段列表必须是数组',
field: 'fields',
};
}
if (fields.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxFields) {
return {
valid: false,
error: `单个模板最多支持 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxFields} 个字段,请拆分模板或减少字段数量`,
field: 'fields',
details: {
maxFields: UPLOAD_TEMPLATE_FIELD_LIMITS.maxFields,
currentFields: fields.length,
},
};
}
const fieldKeys = new Set();
const duplicateKeys = [];
const formattedFields = [];
for (let index = 0; index < fields.length; index += 1) {
const field = fields[index] || {};
const key = String(field.key || '').trim();
if (!key) {
return {
valid: false,
error: '字段key不能为空',
field: 'fields',
};
}
if (fieldKeys.has(key)) {
duplicateKeys.push(key);
} else {
fieldKeys.add(key);
}
const label = String(field.label || '').trim() || `字段${index + 1}`;
const type = String(field.type || 'text').trim() || 'text';
const addToCategory = !!field.addToCategory;
const normalizedOptions = normalizeStaticOptions(field.optionsStatic);
const optionsStatic = normalizedOptions.value;
const normalizedValidations = normalizeValidations(field.validations, type);
const normalizedConditions = normalizeConditions(field.conditions);
const conditions = normalizedConditions.value;
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key)) {
return {
valid: false,
error: `字段“${label}”的 key 必须以字母开头,并且只能包含字母、数字和下划线`,
field: 'fields',
details: { fieldKey: key },
};
}
if (key.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxKeyLength) {
return {
valid: false,
error: `字段“${label}”的 key 不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxKeyLength} 个字符`,
field: 'fields',
details: {
fieldKey: key,
maxKeyLength: UPLOAD_TEMPLATE_FIELD_LIMITS.maxKeyLength,
},
};
}
if (label.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxLabelLength) {
return {
valid: false,
error: `字段“${label.slice(0, 20)}...”的名称不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxLabelLength} 个字符`,
field: 'fields',
details: {
fieldKey: key,
maxLabelLength: UPLOAD_TEMPLATE_FIELD_LIMITS.maxLabelLength,
},
};
}
if (!ALLOWED_FIELD_TYPES.has(type)) {
return {
valid: false,
error: `字段“${label}”的类型不支持:${type}`,
field: 'fields',
details: {
fieldKey: key,
fieldType: type,
allowedTypes: Array.from(ALLOWED_FIELD_TYPES),
},
};
}
if (OPTION_FIELD_TYPES.has(type)) {
if (normalizedOptions.invalid) {
return {
valid: false,
error: `字段“${label}”的选项必须是数组或每行一个选项`,
field: 'fields',
details: { fieldKey: key },
};
}
const options = parseNormalizedOptions(optionsStatic);
if (options.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptions) {
return {
valid: false,
error: `字段“${label}”的选项不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptions} 个`,
field: 'fields',
details: {
fieldKey: key,
maxOptions: UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptions,
currentOptions: options.length,
},
};
}
const tooLongOption = options.find((option) => option.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptionLength);
if (tooLongOption) {
return {
valid: false,
error: `字段“${label}”的单个选项不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptionLength} 个字符`,
field: 'fields',
details: {
fieldKey: key,
maxOptionLength: UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptionLength,
sample: tooLongOption.slice(0, 20),
},
};
}
if (field.required && !addToCategory && options.length === 0) {
return {
valid: false,
error: `字段“${label}”是必填${type === 'radio' ? '单选' : '下拉'}字段,至少需要配置一个选项`,
field: 'fields',
details: { fieldKey: key },
};
}
}
if (normalizedValidations.invalid) {
return {
valid: false,
error: `字段“${label}”的验证规则不正确:${normalizedValidations.invalid}`,
field: 'fields',
details: { fieldKey: key },
};
}
if (normalizedConditions.invalid) {
return {
valid: false,
error: `字段“${label}”的显示条件格式不正确`,
field: 'fields',
details: { fieldKey: key },
};
}
formattedFields.push({
id: field.id ?? null,
key,
label,
type,
required: !!field.required,
orderIndex: field.orderIndex ?? index,
layoutWidth: field.layoutWidth || 'full',
validations: normalizedValidations.value,
optionsSource: field.optionsSource === null || field.optionsSource === undefined ? null : String(field.optionsSource),
optionsStatic,
allowedExtensions: field.allowedExtensions === null || field.allowedExtensions === undefined ? null : String(field.allowedExtensions),
visibleOnHomeFilter: !!field.visibleOnHomeFilter,
isSearchable: !!field.isSearchable,
isReadOnly: !!field.isReadOnly,
addToCategory,
conditions,
});
}
if (duplicateKeys.length > 0) {
return {
valid: false,
error: `字段key重复:${duplicateKeys.join(', ')}`,
field: 'fields',
details: { duplicateKeys },
};
}
const availableKeys = new Set(formattedFields.map((field) => field.key));
for (const field of formattedFields) {
const conditions = parseFieldConditions(field.conditions);
if (!conditions) continue;
for (const condition of conditions.conditions) {
if (!CONDITION_OPERATORS.has(condition.operator)) {
return {
valid: false,
error: `字段“${field.label}”的显示条件包含不支持的判断方式`,
field: 'fields',
details: { fieldKey: field.key, operator: condition.operator },
};
}
if (!availableKeys.has(condition.fieldKey)) {
return {
valid: false,
error: `字段“${field.label}”的显示条件引用了不存在的字段:${condition.fieldKey}`,
field: 'fields',
details: { fieldKey: field.key, dependencyKey: condition.fieldKey },
};
}
if (condition.fieldKey === field.key) {
return {
valid: false,
error: `字段“${field.label}”的显示条件不能引用自己`,
field: 'fields',
details: { fieldKey: field.key },
};
}
if (
CONDITION_OPERATORS_REQUIRING_VALUE.has(condition.operator) &&
String(condition.value ?? '').trim() === ''
) {
return {
valid: false,
error: `字段“${field.label}”的显示条件缺少比较值`,
field: 'fields',
details: { fieldKey: field.key, operator: condition.operator },
};
}
}
}
const circularFields = detectCircularDependencies(formattedFields);
if (circularFields.length > 0) {
return {
valid: false,
error: `字段显示条件存在循环依赖:${circularFields.join(', ')}`,
field: 'fields',
details: { circularFields },
};
}
return {
valid: true,
fields: formattedFields,
};
}
function evaluateSubmittedFieldCondition(condition, fieldValues) {
const fieldValue = fieldValues[condition.fieldKey];
const stringValue = fieldValue === null || fieldValue === undefined
? ''
: String(fieldValue).trim();
switch (condition.operator) {
case 'equals':
return stringValue === (condition.value || '');
case 'notEquals':
return stringValue !== (condition.value || '');
case 'contains':
return stringValue.includes(condition.value || '');
case 'notEmpty':
return stringValue !== '';
case 'isEmpty':
return stringValue === '';
default:
return true;
}
}
function isSubmittedFieldVisible(field, fieldValues) {
const conditions = parseFieldConditions(field.conditions);
if (!conditions || !conditions.conditions || conditions.conditions.length === 0) {
return true;
}
const results = conditions.conditions.map((condition) => evaluateSubmittedFieldCondition(condition, fieldValues));
return conditions.logic === 'OR'
? results.some((result) => result)
: results.every((result) => result);
}
function getVisibleSubmittedTemplateFields(fields, fieldValues) {
if (!fields.length) return [];
let visibleKeys = new Set();
for (let pass = 0; pass < fields.length; pass += 1) {
const activeValues = {};
for (const field of fields) {
if (visibleKeys.has(field.key)) {
activeValues[field.key] = fieldValues[field.key];
}
}
const nextVisibleKeys = new Set();
for (const field of fields) {
if (isSubmittedFieldVisible(field, activeValues)) {
nextVisibleKeys.add(field.key);
}
}
const sameSize = nextVisibleKeys.size === visibleKeys.size;
const sameKeys = sameSize && Array.from(nextVisibleKeys).every((key) => visibleKeys.has(key));
visibleKeys = nextVisibleKeys;
if (sameKeys) break;
}
return fields.filter((field) => visibleKeys.has(field.key));
}
function filterVisibleSubmittedFieldValues(fields, fieldValues) {
const visibleKeys = new Set(
getVisibleSubmittedTemplateFields(fields, fieldValues).map((field) => field.key)
);
return Object.fromEntries(
Object.entries(fieldValues).filter(([key]) => visibleKeys.has(key))
);
}
function hasSubmittedFieldValue(value, type) {
if (type === 'checkbox') {
return value !== undefined && value !== null;
}
if (value === undefined || value === null) {
return false;
}
if (typeof value === 'string') {
return value.trim().length > 0;
}
if (Array.isArray(value)) {
return value.length > 0;
}
return true;
}
function validateSubmittedFieldValue(value, fieldType, rules, fieldLabel) {
const errors = [];
const label = fieldLabel || '此字段';
if (!rules) {
return { valid: true, errors: [] };
}
const stringValue = value === null || value === undefined ? '' : String(value).trim();
const isEmpty = stringValue === '';
if (rules.required && isEmpty) {
errors.push(rules.requiredMessage || `${label}不能为空`);
return { valid: false, errors };
}
if (isEmpty) {
return { valid: true, errors: [] };
}
if (fieldType === 'text' || fieldType === 'textarea') {
if (typeof rules.minLength === 'number' && stringValue.length < rules.minLength) {
errors.push(rules.minLengthMessage || `${label}长度不能少于${rules.minLength}个字符`);
}
if (typeof rules.maxLength === 'number' && stringValue.length > rules.maxLength) {
errors.push(rules.maxLengthMessage || `${label}长度不能超过${rules.maxLength}个字符`);
}
}
if (fieldType === 'number') {
const numValue = Number.parseFloat(stringValue);
if (Number.isNaN(numValue)) {
errors.push(`${label}必须是有效的数字`);
} else {
if (typeof rules.min === 'number' && numValue < rules.min) {
errors.push(rules.minMessage || `${label}不能小于${rules.min}`);
}
if (typeof rules.max === 'number' && numValue > rules.max) {
errors.push(rules.maxMessage || `${label}不能大于${rules.max}`);
}
}
}
if (rules.pattern) {
try {
const regex = new RegExp(rules.pattern);
if (!regex.test(stringValue)) {
errors.push(rules.patternMessage || `${label}格式不正确`);
}
} catch {
// 模板保存时已经校验过正则;运行期忽略坏规则,避免扩大故障面。
}
}
return {
valid: errors.length === 0,
errors,
};
}
export function validateSubmittedTemplateFieldsRequest(args) {
const body = args && typeof args === 'object' ? args : {};
const templateId = typeof body.templateId === 'string' ? body.templateId : null;
const templateFields = body.templateFields && typeof body.templateFields === 'object' && !Array.isArray(body.templateFields)
? body.templateFields
: {};
const allowUnknownKeys = body.allowUnknownKeys !== false;
const fields = Array.isArray(body.fields) ? body.fields : [];
const templateName = typeof body.templateName === 'string' ? body.templateName : undefined;
if (!templateId) {
return {
valid: true,
templateFields,
visibleFieldKeys: Object.keys(templateFields),
};
}
const normalizedFields = fields
.filter((field) => field && typeof field === 'object' && typeof field.key === 'string' && field.key.trim())
.map((field) => ({
key: field.key.trim(),
label: typeof field.label === 'string' ? field.label : field.key.trim(),
type: typeof field.type === 'string' && field.type ? field.type : 'text',
required: field.required === true,
conditions: field.conditions ?? null,
validations: field.validations ?? null,
}));
const fieldMap = new Map(normalizedFields.map((field) => [field.key, field]));
const visibleFields = getVisibleSubmittedTemplateFields(normalizedFields, templateFields);
const visibleTemplateFields = filterVisibleSubmittedFieldValues(normalizedFields, templateFields);
if (!allowUnknownKeys) {
const unknownKeys = Object.keys(templateFields).filter((key) => !fieldMap.has(key));
if (unknownKeys.length > 0) {
return {
valid: false,
error: `提交了模板中不存在的字段:${unknownKeys.join(', ')}`,
errorCode: 'UNKNOWN_TEMPLATE_FIELDS',
details: {
unknownKeys,
templateId,
},
};
}
}
const missingRequiredFields = visibleFields
.filter((field) => field.required)
.filter((field) => !hasSubmittedFieldValue(visibleTemplateFields[field.key], field.type))
.map((field) => ({
key: field.key,
label: field.label || field.key,
type: field.type || 'text',
}));
if (missingRequiredFields.length > 0) {
return {
valid: false,
error: `模板必填字段未填写:${missingRequiredFields.map((field) => field.label).join('、')}`,
errorCode: 'TEMPLATE_REQUIRED_FIELDS_MISSING',
details: {
templateId,
missingRequiredFields,
},
};
}
const invalidFields = visibleFields
.map((field) => {
const result = validateSubmittedFieldValue(
visibleTemplateFields[field.key],
field.type || 'text',
parseValidationRules(field.validations),
field.label || field.key
);
return {
key: field.key,
label: field.label || field.key,
type: field.type || 'text',
errors: result.errors,
};
})
.filter((field) => field.errors.length > 0);
if (invalidFields.length > 0) {
return {
valid: false,
error: `模板字段填写不符合规则:${invalidFields.map((field) => `${field.label}:${field.errors.join('、')}`).join(';')}`,
errorCode: 'TEMPLATE_FIELD_VALIDATION_FAILED',
details: {
templateId,
invalidFields,
},
};
}
return {
valid: true,
templateName,
templateFields: visibleTemplateFields,
visibleFieldKeys: visibleFields.map((field) => field.key),
};
}
function separateSubmittedMaterialFields(templateFields) {
const standardData = {};
const customFields = {};
for (const [key, value] of Object.entries(templateFields)) {
if (STANDARD_MATERIAL_FIELDS.has(key)) {
standardData[key] = value === null || value === undefined ? '' : String(value);
} else {
customFields[key] = value;
}
}
return { standardData, customFields };
}
function parseExistingMetadata(existingMetadata) {
if (!existingMetadata) return {};
try {
const parsed = JSON.parse(existingMetadata);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function buildSubmittedMaterialMetadata(customFields, templateId, previewUrls, existingMetadata) {
const metadata = parseExistingMetadata(existingMetadata);
if (templateId) {
metadata.templateId = templateId;
}
if (Object.keys(customFields).length > 0) {
metadata.templateFields = {
...(metadata.templateFields || {}),
...customFields,
};
}
if (Array.isArray(previewUrls) && previewUrls.length > 0) {
metadata.previewUrls = previewUrls;
}
return Object.keys(metadata).length === 0 ? null : JSON.stringify(metadata);
}
function compressSubmittedMaterialMetadata(metadata) {
if (!metadata) return null;
try {
const parsed = JSON.parse(metadata);
if (parsed.templateFields) {
const cleanedFields = {};
for (const [key, value] of Object.entries(parsed.templateFields)) {
if (value !== null && value !== undefined && value !== '') {
cleanedFields[key] = value;
}
}
if (Object.keys(cleanedFields).length > 0) {
parsed.templateFields = cleanedFields;
} else {
delete parsed.templateFields;
}
}
if (Array.isArray(parsed.previewUrls) && parsed.previewUrls.length === 0) {
delete parsed.previewUrls;
}
return Object.keys(parsed).length === 0 ? null : JSON.stringify(parsed);
} catch {
return metadata;
}
}
function getUtf8Size(value) {
return value ? Buffer.byteLength(value, 'utf8') : 0;
}
function getRecordValue(value) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value;
}
return {};
}
function hasBackupValue(value) {
return value !== undefined && value !== null && value !== '';
}
function getExistingMigrationBackups(metadata) {
return Array.isArray(metadata.templateMigrationBackups)
? metadata.templateMigrationBackups
: [];
}
function buildDroppedStandardFields(standardFields, targetFieldKeys) {
const droppedFields = {};
for (const key of MIGRATION_BACKUP_STANDARD_FIELDS) {
const value = standardFields[key];
if (!targetFieldKeys.has(key) && hasBackupValue(value)) {
droppedFields[key] = value;
}
}
return droppedFields;
}
function normalizePreviewField(field) {
const item = field && typeof field === 'object' ? field : {};
const key = typeof item.key === 'string' ? item.key.trim() : '';
if (!key) return null;
return {
key,
label: typeof item.label === 'string' && item.label.trim() ? item.label.trim() : key,
type: typeof item.type === 'string' && item.type.trim() ? item.type.trim() : undefined,
required: item.required === true,
};
}
function buildPreviewFieldSummary(key, sourceTemplateFieldsByKey, source) {
const templateField = sourceTemplateFieldsByKey.get(key);
return {
key,
label: templateField?.label || STANDARD_FIELD_LABELS[key] || key,
type: templateField?.type,
source,
};
}
function addPreviewFieldImpact(impacts, field) {
const mapKey = `${field.source}:${field.key}`;
const existing = impacts.get(mapKey);
if (existing) {
existing.affectedCount += 1;
return;
}
impacts.set(mapKey, {
...field,
affectedCount: 1,
});
}
function normalizePreviewKeys(value) {
if (Array.isArray(value)) {
return Array.from(new Set(value.map((key) => String(key || '').trim()).filter(Boolean)));
}
if (value && typeof value === 'object') {
return Object.keys(value).filter(Boolean);
}
return [];
}
function normalizePublicTemplate(template) {
const item = template && typeof template === 'object' ? template : {};
const id = typeof item.id === 'string' ? item.id.trim() : '';
if (!id) return null;
return {
...item,
id,
name: typeof item.name === 'string' ? item.name.trim() : '',
};
}
function isBuiltinTemplate(template) {
return typeof template?.id === 'string' && template.id.startsWith('builtin-');
}
function isActiveCustomTemplate(template) {
return (
template &&
!isBuiltinTemplate(template) &&
template.status === 'active'
);
}
function normalizeBuiltinTemplates(templates) {
return (Array.isArray(templates) ? templates : [])
.map(normalizePublicTemplate)
.filter(Boolean)
.map((template) => ({ ...template, isBuiltin: true }));
}
function normalizeCustomTemplates(templates) {
return (Array.isArray(templates) ? templates : [])
.map(normalizePublicTemplate)
.filter(isActiveCustomTemplate)
.map((template) => ({ ...template, isBuiltin: false }));
}
function filterWritableTemplateFields(fields) {
return (Array.isArray(fields) ? fields : []).filter((field) => (
field &&
typeof field === 'object' &&
field.isReadOnly !== true &&
field.key !== 'materialType'
));
}
function attachMaterialCounts(templates, materialCounts) {
const counts = materialCounts && typeof materialCounts === 'object' && !Array.isArray(materialCounts)
? materialCounts
: {};
return templates.map((template) => ({
...template,
materialCount: Number.isFinite(counts[template.id]) ? counts[template.id] : 0,
}));
}
function stripTemplateMaterialSuffix(name) {
const stripped = String(name || '').trim().replace(/\s*素材模板\s*$/g, '').trim();
return stripped || String(name || '').trim();
}
function buildCustomTemplateIdentifier(template) {
const rawName = typeof template.name === 'string' ? template.name.trim() : '';
if (!rawName) return null;
const templateName = stripTemplateMaterialSuffix(rawName) || `模板-${template.id.substring(0, 8)}`;
return `template:${template.id}:${templateName}`;
}
function projectTemplateList(builtinTemplates, customTemplates) {
return [
...builtinTemplates.map((template) => ({
id: template.id,
name: template.name,
isBuiltin: true,
})),
...customTemplates.map((template) => ({
id: template.id,
name: template.name,
isBuiltin: false,
})),
];
}
function projectUploadTemplateList(builtinTemplates, customTemplates, materialCounts) {
const writableCustomTemplates = customTemplates.map((template) => ({
...template,
fields: filterWritableTemplateFields(template.fields),
}));
return attachMaterialCounts([
...builtinTemplates,
...writableCustomTemplates,
], materialCounts);
}
function projectMaterialTypes(builtinMaterialTypes, customTemplates) {
const typeSet = new Set(
(Array.isArray(builtinMaterialTypes) ? builtinMaterialTypes : [])
.map((type) => String(type || '').trim())
.filter(Boolean)
);
for (const template of customTemplates) {
const identifier = buildCustomTemplateIdentifier(template);
if (identifier) typeSet.add(identifier);
}
return Array.from(typeSet).sort();
}
function hasFilledTemplateFieldValue(value) {
if (value === null || value === undefined) return false;
if (typeof value === 'string') return value.trim() !== '';
if (Array.isArray(value)) return value.length > 0;
return true;
}
function normalizeOptionalMaterialColumnValue(value) {
if (value === undefined || value === null || value === '') {
return null;
}
return String(value);
}
export function prepareTemplateMigrationMetadataRequest(args) {
const body = args && typeof args === 'object' ? args : {};
const targetTemplateId = typeof body.targetTemplateId === 'string' && body.targetTemplateId
? body.targetTemplateId
: null;
const sourceTemplateId = typeof body.sourceTemplateId === 'string' ? body.sourceTemplateId : '';
const sourceTemplateName = typeof body.sourceTemplateName === 'string' ? body.sourceTemplateName : '';
const targetTemplateName = typeof body.targetTemplateName === 'string' ? body.targetTemplateName : '';
const migratedAt = typeof body.migratedAt === 'string' && body.migratedAt
? body.migratedAt
: new Date().toISOString();
const targetFieldKeys = new Set(
Array.isArray(body.targetFieldKeys)
? body.targetFieldKeys.filter((key) => typeof key === 'string' && key)
: []
);
const targetFields = Array.isArray(body.targetFields)
? body.targetFields
.filter((field) => field && typeof field === 'object' && typeof field.key === 'string' && field.key)
.map((field) => ({
key: field.key,
label: typeof field.label === 'string' && field.label ? field.label : field.key,
type: typeof field.type === 'string' && field.type ? field.type : 'text',
required: field.required === true,
}))
: [];
const materialFieldValues = body.materialFieldValues && typeof body.materialFieldValues === 'object' && !Array.isArray(body.materialFieldValues)
? body.materialFieldValues
: {};
const templateFields = body.templateFields && typeof body.templateFields === 'object' && !Array.isArray(body.templateFields)
? body.templateFields
: Object.fromEntries(
targetFields
.filter((field) => materialFieldValues[field.key] !== undefined)
.map((field) => [field.key, materialFieldValues[field.key]])
);
const materialStandardFields = body.materialStandardFields && typeof body.materialStandardFields === 'object' && !Array.isArray(body.materialStandardFields)
? body.materialStandardFields
: {};
const existingMetadata = typeof body.existingMetadata === 'string' ? body.existingMetadata : null;
const previewUrls = Array.isArray(body.previewUrls) ? body.previewUrls.filter((url) => typeof url === 'string') : [];
const maxSizeBytes = Number.isFinite(body.maxSizeBytes) && body.maxSizeBytes > 0
? Math.floor(body.maxSizeBytes)
: 65535;
if (!targetTemplateId) {
return {
valid: false,
error: '缺少目标模板 ID',
errorCode: 'MISSING_TARGET_TEMPLATE_ID',
details: {
sourceTemplateId,
},
};
}
const missingRequiredFields = targetFields
.filter((field) => field.required)
.filter((field) => !hasSubmittedFieldValue(templateFields[field.key], field.type))
.map((field) => field.label || field.key);
if (missingRequiredFields.length > 0) {
return {
valid: false,
error: '目标模板存在无法从旧素材自动补齐的必填字段',
errorCode: 'TARGET_TEMPLATE_FIELDS_UNRESOLVED',
details: {
missingRequiredFields,
targetTemplateId,
},
};
}
const existingMetadataObject = parseExistingMetadata(existingMetadata);
const { standardData, customFields } = separateSubmittedMaterialFields(templateFields);
for (const key of AUDIO_MATERIAL_FIELDS) {
if (hasOwn(templateFields, key)) {
standardData[key] = normalizeOptionalMaterialColumnValue(templateFields[key]);
}
}
for (const [key, emptyValue] of Object.entries(CLEARABLE_NON_TARGET_FIELD_DEFAULTS)) {
if (!targetFieldKeys.has(key)) {
standardData[key] = emptyValue;
}
}
const targetMetadata = buildSubmittedMaterialMetadata(
customFields,
targetTemplateId,
previewUrls,
null
);
const targetMetadataObject = parseExistingMetadata(targetMetadata);
const existingTemplateFields = getRecordValue(existingMetadataObject.templateFields);
const targetTemplateFields = getRecordValue(targetMetadataObject.templateFields);
const droppedTemplateFields = {};
const droppedStandardFields = buildDroppedStandardFields(materialStandardFields, targetFieldKeys);
for (const [key, value] of Object.entries(existingTemplateFields)) {
if (!targetFieldKeys.has(key) && hasBackupValue(value)) {
droppedTemplateFields[key] = value;
}
}
const nextMetadata = {
...existingMetadataObject,
...targetMetadataObject,
templateId: targetTemplateId,
};
if (Object.keys(targetTemplateFields).length > 0) {
nextMetadata.templateFields = targetTemplateFields;
} else {
delete nextMetadata.templateFields;
}
const droppedTemplateFieldCount = Object.keys(droppedTemplateFields).length;
const droppedStandardFieldCount = Object.keys(droppedStandardFields).length;
if (droppedTemplateFieldCount > 0 || droppedStandardFieldCount > 0) {
const backup = {
sourceTemplateId,
sourceTemplateName,
targetTemplateId,
targetTemplateName,
migratedAt,
};
if (droppedTemplateFieldCount > 0) {
backup.droppedTemplateFields = droppedTemplateFields;
}
if (droppedStandardFieldCount > 0) {
backup.droppedStandardFields = droppedStandardFields;
}
nextMetadata.templateMigrationBackups = [
...getExistingMigrationBackups(existingMetadataObject),
backup,
];
}
const metadata = compressSubmittedMaterialMetadata(JSON.stringify(nextMetadata));
const size = getUtf8Size(metadata);
if (size > maxSizeBytes) {
return {
valid: false,
error: `模板迁移后 metadata 数据过大,当前约 ${size} 字节,最大允许 ${maxSizeBytes} 字节。请先减少字段内容后再迁移。`,
errorCode: 'METADATA_TOO_LARGE',
details: {
size,
maxSize: maxSizeBytes,
},
};
}
return {
valid: true,
standardData,
metadata,
metadataSize: {
ok: true,
size,
maxSize: maxSizeBytes,
},
templateFields,
droppedTemplateFieldCount,
droppedStandardFieldCount,
droppedTemplateFields,
droppedStandardFields,
};
}
export function buildTemplateMigrationPreviewRequest(args) {
const body = args && typeof args === 'object' ? args : {};
const totalMaterialCount = Number.isFinite(body.totalMaterialCount)
? Math.max(0, Math.floor(body.totalMaterialCount))
: 0;
const migratableCount = Number.isFinite(body.migratableCount)
? Math.max(0, Math.floor(body.migratableCount))
: 0;
const previewOnly = body.previewOnly === true;
const previewLimit = previewOnly && Number.isFinite(body.previewLimit)
? Math.max(0, Math.floor(body.previewLimit))
: undefined;
const targetFields = Array.isArray(body.targetFields)
? body.targetFields.map(normalizePreviewField).filter(Boolean)
: [];
const sourceTemplateFieldsByKey = new Map(
(Array.isArray(body.sourceTemplateFields) ? body.sourceTemplateFields : [])
.map(normalizePreviewField)
.filter(Boolean)
.map((field) => [field.key, field])
);
const impacts = Array.isArray(body.impacts) ? body.impacts : [];
let materialsWithDroppedFields = 0;
let droppedTemplateFieldCount = 0;
let droppedStandardFieldCount = 0;
const previewFieldImpacts = new Map();
const samples = [];
for (const rawImpact of impacts) {
const impact = rawImpact && typeof rawImpact === 'object' ? rawImpact : {};
const droppedTemplateKeys = normalizePreviewKeys(
impact.droppedTemplateFieldKeys || impact.droppedTemplateFields
);
const droppedStandardKeys = normalizePreviewKeys(
impact.droppedStandardFieldKeys || impact.droppedStandardFields
);
if (droppedTemplateKeys.length === 0 && droppedStandardKeys.length === 0) {
continue;
}
materialsWithDroppedFields += 1;
droppedTemplateFieldCount += droppedTemplateKeys.length;
droppedStandardFieldCount += droppedStandardKeys.length;
const droppedTemplateFields = droppedTemplateKeys.map((key) =>
buildPreviewFieldSummary(key, sourceTemplateFieldsByKey, 'template')
);
const droppedStandardFields = droppedStandardKeys.map((key) =>
buildPreviewFieldSummary(key, sourceTemplateFieldsByKey, 'standard')
);
droppedTemplateFields.forEach((field) => addPreviewFieldImpact(previewFieldImpacts, field));
droppedStandardFields.forEach((field) => addPreviewFieldImpact(previewFieldImpacts, field));
if (samples.length < 10) {
samples.push({
materialId: typeof impact.materialId === 'string' ? impact.materialId : '',
productModel:
impact.productModel === undefined || impact.productModel === null || impact.productModel === ''
? null
: String(impact.productModel),
droppedTemplateFields,
droppedStandardFields,
});
}
}
return {
valid: true,
migrationBackup: {
materialsWithDroppedFields,
droppedTemplateFieldCount,
droppedStandardFieldCount,
},
migrationPreview: {
totalMaterialCount,
totalMaterials: migratableCount,
previewLimited: previewOnly && totalMaterialCount > migratableCount,
previewLimit,
targetCurrentFields: targetFields.map((field) => ({
key: field.key,
label: field.label || field.key,
type: field.type,
required: field.required,
})),
backupFields: {
templateFields: Array.from(previewFieldImpacts.values()).filter((field) => field.source === 'template'),
standardFields: Array.from(previewFieldImpacts.values()).filter((field) => field.source === 'standard'),
},
samples,
},
};
}
export function projectPublicTemplateOptionsRequest(args) {
const body = args && typeof args === 'object' ? args : {};
const builtinTemplates = normalizeBuiltinTemplates(body.builtinTemplates);
const customTemplates = normalizeCustomTemplates(body.customTemplates);
const materialCounts = body.materialCounts && typeof body.materialCounts === 'object' && !Array.isArray(body.materialCounts)
? body.materialCounts
: {};
return {
valid: true,
templates: projectTemplateList(builtinTemplates, customTemplates),
uploadTemplates: projectUploadTemplateList(builtinTemplates, customTemplates, materialCounts),
materialTypes: projectMaterialTypes(body.builtinMaterialTypes, customTemplates),
};
}
function normalizeFieldKeyList(value) {
if (!Array.isArray(value)) return [];
const keys = [];
const seen = new Set();
for (const item of value) {
const rawKey = typeof item === 'string' ? item : item?.key;
const key = typeof rawKey === 'string' ? rawKey.trim() : '';
if (!key || seen.has(key)) continue;
seen.add(key);
keys.push(key);
}
return keys;
}
export function planMaterialCustomSearchRequest(args) {
const body = args && typeof args === 'object' ? args : {};
const searchableFieldKeys = normalizeFieldKeyList(body.searchableFields);
const customFieldKeys = searchableFieldKeys.filter((key) => !STANDARD_MATERIAL_FIELDS.has(key));
const search = typeof body.search === 'string' ? body.search.trim() : '';
return {
valid: true,
search,
customFieldKeys,
};
}
export function planHomeFilterCustomFieldCountsRequest(args) {
const body = args && typeof args === 'object' ? args : {};
const fieldKeys = normalizeFieldKeyList(body.fields);
const customFieldKeys = fieldKeys.filter((key) => !MATERIAL_HOME_FILTER_COLUMN_FIELDS.has(key));
return {
valid: true,
customFieldKeys,
};
}
export function planHomeFilterFieldCountsRequest(args) {
const body = args && typeof args === 'object' ? args : {};
const fieldKeys = normalizeFieldKeyList(body.fields);
return {
valid: true,
fieldPlans: fieldKeys.map((key) => ({
key,
source: MATERIAL_HOME_FILTER_COLUMN_FIELDS.has(key) ? 'column' : 'metadata',
})),
};
}
function normalizeFilterList(value) {
if (!Array.isArray(value)) return [];
const filters = [];
const seen = new Set();
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const key = typeof item.key === 'string' ? item.key.trim() : '';
const rawValue = item.value;
const filterValue = typeof rawValue === 'string' ? rawValue.trim() : '';
if (!key || !filterValue) continue;
const dedupeKey = `${key}\u0000${filterValue}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
filters.push({ key, value: filterValue });
}
return filters;
}
export function planMaterialListQueryRequest(args) {
const body = args && typeof args === 'object' ? args : {};
const search = typeof body.search === 'string' ? body.search.trim() : '';
const searchableFieldKeys = normalizeFieldKeyList(body.searchableFields);
const enabledHomeFilterKeys = new Set(normalizeFieldKeyList(body.enabledHomeFilterFields));
const homeFieldFilters = normalizeFilterList(body.homeFieldFilters);
return {
valid: true,
search,
standardSearchFields: search ? MATERIAL_LIST_SEARCH_COLUMN_FIELDS : [],
customSearchFieldKeys: search
? searchableFieldKeys.filter((key) => !STANDARD_MATERIAL_FIELDS.has(key))
: [],
standardHomeFilters: homeFieldFilters.filter((filter) =>
MATERIAL_LIST_HOME_FILTER_COLUMN_FIELDS.has(filter.key)
),
customHomeFilters: homeFieldFilters.filter((filter) =>
!MATERIAL_LIST_HOME_FILTER_COLUMN_FIELDS.has(filter.key) && enabledHomeFilterKeys.has(filter.key)
),
};
}
function normalizeDepartmentName(name) {
return typeof name === 'string' ? name.trim() : '';
}
function buildDepartmentDescendantSet(rawDepartments) {
const names = Array.isArray(rawDepartments)
? rawDepartments
.map((department) => (
typeof department === 'string'
? normalizeDepartmentName(department)
: normalizeDepartmentName(department?.name)
))
.filter(Boolean)
: [];
const childrenMap = new Map();
const ensureNode = (name) => {
if (!childrenMap.has(name)) {
childrenMap.set(name, []);
}
};
names.forEach((name) => ensureNode(name));
names.forEach((name) => {
const lastDashIndex = name.lastIndexOf('-');
if (lastDashIndex > 0) {
const parent = name.substring(0, lastDashIndex);
ensureNode(parent);
childrenMap.get(parent).push(name);
}
});
childrenMap.forEach((children) => children.sort((a, b) => departmentCollator.compare(a, b)));
const descendantMap = new Map();
const visit = (name) => {
if (descendantMap.has(name)) return descendantMap.get(name);
const descendants = new Set([name]);
for (const child of childrenMap.get(name) || []) {
for (const descendant of visit(child)) {
descendants.add(descendant);
}
}
descendantMap.set(name, descendants);
return descendants;
};
childrenMap.forEach((_children, name) => visit(name));
return descendantMap;
}
export function planDepartmentScopeRequest(args) {
const body = args && typeof args === 'object' ? args : {};
const scopeDepartment = normalizeDepartmentName(body.scopeDepartment);
const targetDepartment = normalizeDepartmentName(body.targetDepartment);
const descendantMap = buildDepartmentDescendantSet(body.departments);
const scopedDepartments = scopeDepartment
? Array.from(descendantMap.get(scopeDepartment) || new Set([scopeDepartment]))
: [];
scopedDepartments.sort((a, b) => departmentCollator.compare(a, b));
return {
valid: true,
scopedDepartments,
withinScope: Boolean(
scopeDepartment &&
targetDepartment &&
(
targetDepartment === scopeDepartment ||
scopedDepartments.includes(targetDepartment) ||
targetDepartment.startsWith(`${scopeDepartment}-`)
)
),
};
}
function normalizeDepartmentRecords(rawDepartments) {
if (!Array.isArray(rawDepartments)) return [];
return rawDepartments
.map((department) => ({
id: typeof department?.id === 'string' ? department.id : '',
name: normalizeDepartmentName(department?.name),
}))
.filter((department) => department.id && department.name);
}
function normalizeDepartmentUsers(rawUsers) {
if (!Array.isArray(rawUsers)) return [];
return rawUsers
.map((user) => ({
id: typeof user?.id === 'string' ? user.id : '',
department: typeof user?.department === 'string' ? user.department : '',
managedDepartment:
typeof user?.managedDepartment === 'string' && user.managedDepartment
? user.managedDepartment
: null,
}))
.filter((user) => user.id);
}
function denyDepartmentStructureChange(statusCode, error, errorCode, details) {
return {
valid: true,
allowed: false,
statusCode,
error,
errorCode,
details,
};
}
function renameDepartmentValue(value, oldName, nextName) {
if (!value) return value;
const oldPrefix = `${oldName}-`;
const nextPrefix = `${nextName}-`;
if (value === oldName) return nextName;
if (value.startsWith(oldPrefix)) {
return `${nextPrefix}${value.slice(oldPrefix.length)}`;
}
return value;
}
export function planDepartmentStructureChangeRequest(args) {
const body = args && typeof args === 'object' ? args : {};
const action = typeof body.action === 'string' ? body.action.trim() : '';
const target = body.target && typeof body.target === 'object' ? body.target : {};
const departments = normalizeDepartmentRecords(body.departments);
const users = normalizeDepartmentUsers(body.users);
const targetId = typeof target.id === 'string' ? target.id : '';
const nextName = normalizeDepartmentName(target.name);
if (!['create', 'rename', 'delete'].includes(action)) {
return denyDepartmentStructureChange(400, '部门操作类型无效', 'INVALID_DEPARTMENT_ACTION');
}
if (action === 'create') {
if (!nextName) {
return denyDepartmentStructureChange(400, '部门名称不能为空', 'DEPARTMENT_NAME_REQUIRED');
}
if (departments.some((department) => department.name === nextName)) {
return denyDepartmentStructureChange(400, '部门已存在', 'DEPARTMENT_NAME_EXISTS', {
name: nextName,
});
}
return {
valid: true,
allowed: true,
action,
departmentOps: [{ type: 'create', data: { name: nextName } }],
userOps: [],
summary: { createdName: nextName },
};
}
if (!targetId) {
return denyDepartmentStructureChange(400, '缺少部门ID', 'DEPARTMENT_ID_REQUIRED');
}
const targetDepartment = departments.find((department) => department.id === targetId);
if (!targetDepartment) {
return denyDepartmentStructureChange(404, '未找到对应的部门', 'DEPARTMENT_NOT_FOUND', {
id: targetId,
});
}
if (action === 'rename') {
if (!nextName) {
return denyDepartmentStructureChange(400, '部门名称不能为空', 'DEPARTMENT_NAME_REQUIRED');
}
if (targetDepartment.name === nextName) {
return {
valid: true,
allowed: false,
statusCode: 200,
response: targetDepartment,
};
}
const oldName = targetDepartment.name;
const oldPrefix = `${oldName}-`;
const nextPrefix = `${nextName}-`;
const renameItems = departments
.filter((department) => department.id === targetId || department.name.startsWith(oldPrefix))
.map((department) => ({
id: department.id,
oldName: department.name,
newName: department.id === targetId
? nextName
: `${nextPrefix}${department.name.slice(oldPrefix.length)}`,
}));
const renameIds = new Set(renameItems.map((item) => item.id));
const nextNames = new Set(renameItems.map((item) => item.newName));
const conflictNames = departments
.filter((department) => nextNames.has(department.name) && !renameIds.has(department.id))
.map((department) => department.name);
if (conflictNames.length > 0) {
return denyDepartmentStructureChange(400, `存在同名分支:${conflictNames.join('、')}`, 'DEPARTMENT_NAME_CONFLICT', {
conflictNames,
});
}
const userOps = users
.map((user) => {
const nextDepartment = renameDepartmentValue(user.department, oldName, nextName) || '';
const nextManagedDepartment = renameDepartmentValue(user.managedDepartment, oldName, nextName) || null;
const data = {};
if (nextDepartment !== user.department) data.department = nextDepartment;
if (nextManagedDepartment !== user.managedDepartment) data.managedDepartment = nextManagedDepartment;
return Object.keys(data).length > 0 ? { type: 'update', id: user.id, data } : null;
})
.filter(Boolean);
return {
valid: true,
allowed: true,
action,
departmentOps: renameItems.map((item) => ({
type: 'rename',
id: item.id,
oldName: item.oldName,
newName: item.newName,
})),
userOps,
summary: {
oldName,
newName: nextName,
renamedDepartmentCount: renameItems.length,
updatedUserCount: userOps.length,
},
};
}
const deletePrefix = `${targetDepartment.name}-`;
const deleteItems = departments.filter((department) =>
department.id === targetId || department.name.startsWith(deletePrefix)
);
const targetIds = deleteItems.map((department) => department.id);
const targetNames = deleteItems.map((department) => department.name);
return {
valid: true,
allowed: true,
action,
departmentOps: [{ type: 'deleteMany', ids: targetIds, names: targetNames }],
userOps: [
{ type: 'clearDepartmentIn', names: targetNames },
{ type: 'clearManagedDepartmentIn', names: targetNames },
],
summary: {
removedDepartmentCount: targetIds.length,
removedDepartments: targetNames,
},
};
}
export function calculateTemplateFieldStatsRequest(args) {
const body = args && typeof args === 'object' ? args : {};
const fields = Array.isArray(body.fields)
? body.fields
.filter((field) => field && typeof field === 'object' && typeof field.key === 'string' && field.key)
.map((field) => ({
key: field.key,
label: typeof field.label === 'string' && field.label ? field.label : field.key,
type: typeof field.type === 'string' && field.type ? field.type : 'text',
}))
: [];
const materials = Array.isArray(body.materials) ? body.materials : [];
if (materials.length === 0) {
return {
valid: true,
fieldStats: null,
};
}
const totalMaterials = materials.length;
const stats = fields.map((field) => {
let filledCount = 0;
for (const material of materials) {
const metadata = parseExistingMetadata(material?.metadata);
const templateFields = getRecordValue(metadata.templateFields);
if (hasFilledTemplateFieldValue(templateFields[field.key])) {
filledCount += 1;
}
}
const fillRate = totalMaterials > 0 ? (filledCount / totalMaterials) * 100 : 0;
return {
fieldKey: field.key,
fieldLabel: field.label,
fieldType: field.type,
filledCount,
totalCount: totalMaterials,
fillRate: Math.round(fillRate * 100) / 100,
isLowFillRate: fillRate < 30,
};
});
stats.sort((a, b) => a.fillRate - b.fillRate);
return {
valid: true,
fieldStats: {
totalMaterials,
fields: stats,
lowFillRateFields: stats.filter((field) => field.isLowFillRate),
},
};
}
export function prepareMaterialMetadataRequest(args) {
const body = args && typeof args === 'object' ? args : {};
const templateFields = body.templateFields && typeof body.templateFields === 'object' && !Array.isArray(body.templateFields)
? body.templateFields
: {};
const templateId = typeof body.templateId === 'string' && body.templateId ? body.templateId : null;
const previewUrls = Array.isArray(body.previewUrls) ? body.previewUrls.filter((url) => typeof url === 'string') : [];
const existingMetadata = typeof body.existingMetadata === 'string' ? body.existingMetadata : null;
const maxSizeBytes = Number.isFinite(body.maxSizeBytes) && body.maxSizeBytes > 0
? Math.floor(body.maxSizeBytes)
: 65535;
const { standardData, customFields } = separateSubmittedMaterialFields(templateFields);
const rawMetadata = buildSubmittedMaterialMetadata(customFields, templateId, previewUrls, existingMetadata);
const metadata = compressSubmittedMaterialMetadata(rawMetadata);
const size = getUtf8Size(metadata);
if (size > maxSizeBytes) {
return {
valid: false,
error: `自定义字段数据过大,当前约 ${size} 字节,最大允许 ${maxSizeBytes} 字节。请减少字段内容后重试。`,
errorCode: 'METADATA_TOO_LARGE',
details: {
size,
maxSize: maxSizeBytes,
},
};
}
return {
valid: true,
standardData,
metadata,
metadataSize: {
ok: true,
size,
maxSize: maxSizeBytes,
},
};
}
export function normalizeUploadTemplateSaveRequest(payload, mode = 'create') {
const body = payload && typeof payload === 'object' ? payload : {};
const isCreate = mode !== 'update';
const template = {};
if (isCreate || hasOwn(body, 'name')) {
const name = typeof body.name === 'string' ? body.name.trim() : '';
if (!name) {
return validationError('模板名称不能为空', 'name');
}
template.name = name;
}
if (hasOwn(body, 'description')) {
template.description = body.description ?? null;
} else if (isCreate) {
template.description = null;
}
const rawStatus = hasOwn(body, 'status') ? body.status : 'draft';
if (isCreate || hasOwn(body, 'status')) {
if (typeof rawStatus !== 'string' || !ALLOWED_TEMPLATE_STATUSES.has(rawStatus)) {
return validationError('模板状态必须是 draft、active 或 archived', 'status');
}
template.status = rawStatus;
}
if (isCreate || hasOwn(body, 'materialType')) {
const materialType = typeof body.materialType === 'string' ? body.materialType.trim() : '';
if (!materialType) {
return validationError('素材类型不能为空', 'materialType');
}
template.materialType = materialType;
}
if (isCreate || hasOwn(body, 'materialKind')) {
const materialKind = typeof body.materialKind === 'string' ? body.materialKind.trim().toUpperCase() : '';
if (!materialKind || !ALLOWED_MATERIAL_KINDS.has(materialKind)) {
return validationError(
`素材大类必须是以下之一:${Array.from(ALLOWED_MATERIAL_KINDS).join(', ')}`,
'materialKind'
);
}
template.materialKind = materialKind;
}
if (hasOwn(body, 'fieldGroups')) {
template.fieldGroups = body.fieldGroups ?? null;
} else if (isCreate) {
template.fieldGroups = null;
}
if (hasOwn(body, 'displayConfig')) {
template.displayConfig = body.displayConfig ?? null;
} else if (isCreate) {
template.displayConfig = null;
}
const fieldsProvided = isCreate || hasOwn(body, 'fields');
if (fieldsProvided) {
const formattedFieldsResult = validateAndFormatUploadTemplateFields(
hasOwn(body, 'fields') ? body.fields : []
);
if (!formattedFieldsResult.valid) {
return formattedFieldsResult;
}
template.fields = formattedFieldsResult.fields;
}
return {
valid: true,
mode: isCreate ? 'create' : 'update',
fieldsProvided,
template,
};
}
export function planUploadTemplateDeleteRequest(payload) {
const body = payload && typeof payload === 'object' ? payload : {};
const templateName =
typeof body.templateName === 'string' && body.templateName.trim()
? body.templateName.trim()
: '未命名模板';
const materialCount = Number.isFinite(Number(body.materialCount))
? Math.max(0, Math.trunc(Number(body.materialCount)))
: 0;
if (materialCount > 0) {
return {
valid: true,
allowed: false,
error: `无法删除模板"${templateName}",因为有 ${materialCount} 个素材正在使用该模板。请先删除或迁移这些素材。`,
errorCode: 'TEMPLATE_IN_USE',
details: {
templateName,
materialCount,
},
};
}
return {
valid: true,
allowed: true,
templateName,
materialCount,
};
}
function parsePositiveInteger(value) {
if (value === undefined || value === null || value === '') {
return null;
}
const parsed = Number.parseInt(String(value), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return parsed;
}
export function planTemplateMigrationBatchRequest(payload) {
const body = payload && typeof payload === 'object' ? payload : {};
const request = body.request && typeof body.request === 'object' ? body.request : {};
const sourceTemplateId = typeof body.sourceTemplateId === 'string' ? body.sourceTemplateId.trim() : '';
const targetTemplateId = typeof body.targetTemplateId === 'string' ? body.targetTemplateId.trim() : '';
const sourceTemplateName =
typeof body.sourceTemplateName === 'string' && body.sourceTemplateName.trim()
? body.sourceTemplateName.trim()
: '源模板';
const targetTemplateName =
typeof body.targetTemplateName === 'string' && body.targetTemplateName.trim()
? body.targetTemplateName.trim()
: '目标模板';
const totalMaterialCount = Number.isFinite(Number(body.totalMaterialCount))
? Math.max(0, Math.trunc(Number(body.totalMaterialCount)))
: 0;
const previewOnly = request.previewOnly === true;
const forceLargeMigration = request.forceLargeMigration === true;
const updateMaterialType = request.updateMaterialType !== false;
const migrationLimit = Math.min(
parsePositiveInteger(request.migrationLimit) ?? DEFAULT_MIGRATION_SAFE_LIMIT,
MAX_MIGRATION_FORCE_LIMIT
);
if (!targetTemplateId) {
return {
valid: true,
allowed: false,
statusCode: 400,
error: '请选择目标模板',
errorCode: 'MISSING_TARGET',
};
}
if (sourceTemplateId && sourceTemplateId === targetTemplateId) {
return {
valid: true,
allowed: false,
statusCode: 400,
error: '源模板和目标模板不能相同',
errorCode: 'SAME_TEMPLATE',
};
}
if (totalMaterialCount === 0) {
return {
valid: true,
allowed: false,
statusCode: 200,
response: {
success: true,
migratedCount: 0,
message: '没有需要迁移的素材',
},
};
}
if (!previewOnly && totalMaterialCount > DEFAULT_MIGRATION_SAFE_LIMIT && !forceLargeMigration) {
return {
valid: true,
allowed: false,
statusCode: 409,
error: `该模板当前有 ${totalMaterialCount} 个素材,超过默认安全迁移数量 ${DEFAULT_MIGRATION_SAFE_LIMIT}。请先预览迁移影响,确认备份后再执行。`,
errorCode: 'MIGRATION_CONFIRM_REQUIRED',
details: {
totalMaterialCount,
safeLimit: DEFAULT_MIGRATION_SAFE_LIMIT,
migrationLimit,
maxBatchLimit: MAX_MIGRATION_FORCE_LIMIT,
sourceTemplateId,
targetTemplateId,
nextStep: '使用 previewOnly=true 查看迁移影响;确认备份后传入 forceLargeMigration=true,系统会按批迁移直到完成。',
},
};
}
const batchLimited = previewOnly || (forceLargeMigration && totalMaterialCount > migrationLimit);
return {
valid: true,
allowed: true,
sourceTemplateId,
targetTemplateId,
sourceTemplateName,
targetTemplateName,
totalMaterialCount,
previewOnly,
forceLargeMigration,
updateMaterialType,
migrationLimit,
safeLimit: DEFAULT_MIGRATION_SAFE_LIMIT,
maxBatchLimit: MAX_MIGRATION_FORCE_LIMIT,
batchLimited,
materialTake: batchLimited ? migrationLimit : null,
migratedAt: new Date().toISOString(),
};
}
function buildUploadTemplateUpdateData(template, nextVersion) {
const updateData = {};
if (template.name !== undefined) updateData.name = template.name;
if (template.description !== undefined) updateData.description = template.description;
if (template.status !== undefined) updateData.status = template.status;
if (template.fieldGroups !== undefined) updateData.fieldGroups = template.fieldGroups;
if (template.displayConfig !== undefined) updateData.displayConfig = template.displayConfig;
if (template.materialType !== undefined) updateData.materialType = template.materialType;
if (template.materialKind !== undefined) updateData.materialKind = template.materialKind;
updateData.version = nextVersion;
return updateData;
}
export function planUploadTemplateUpdateRequest(payload) {
const body = payload && typeof payload === 'object' ? payload : {};
const existing = body.existingTemplate && typeof body.existingTemplate === 'object'
? body.existingTemplate
: {};
const updatePayload = body.payload && typeof body.payload === 'object' ? body.payload : {};
const normalizedResult = normalizeUploadTemplateSaveRequest(updatePayload, 'update');
if (!existing.id) {
return {
valid: false,
error: '缺少现有模板信息',
errorCode: 'EXISTING_TEMPLATE_REQUIRED',
field: 'existingTemplate',
statusCode: 400,
};
}
const existingVersion = existing.version || 1;
const clientVersion = hasOwn(updatePayload, 'version') ? updatePayload.version : undefined;
if (clientVersion !== undefined && clientVersion !== existing.version) {
return {
valid: false,
error: '模板已被其他人修改,请刷新后重试',
errorCode: 'VERSION_CONFLICT',
field: 'version',
statusCode: 409,
details: {
templateName: existing.name,
clientVersion,
serverVersion: existing.version,
lastUpdated: existing.updatedAt,
},
};
}
if (!normalizedResult.valid) {
return {
valid: false,
error: normalizedResult.error,
errorCode: 'VALIDATION_ERROR',
field: normalizedResult.field,
statusCode: 400,
details: normalizedResult.details,
};
}
const nextVersion = existingVersion + 1;
const normalized = normalizedResult.template;
const updateData = buildUploadTemplateUpdateData(normalized, nextVersion);
const formattedFields = normalizedResult.fieldsProvided ? (normalized.fields || []) : null;
if (formattedFields) {
const existingFieldIds = new Set(
(Array.isArray(existing.fields) ? existing.fields : [])
.map((field) => field && field.id)
.filter(Boolean)
);
const foreignField = formattedFields.find((field) => field.id && !existingFieldIds.has(field.id));
if (foreignField) {
return {
valid: false,
error: '字段不属于当前模板,请刷新后重试',
errorCode: 'FIELD_NOT_IN_TEMPLATE',
field: 'fields',
statusCode: 400,
details: {
fieldId: foreignField.id,
fieldKey: foreignField.key,
},
};
}
}
const materialCount = Number.isFinite(Number(existing.materialCount))
? Math.max(0, Math.trunc(Number(existing.materialCount)))
: 0;
if (materialCount > 0) {
const dangerousChanges = detectDangerousTemplateChanges({
existingTemplate: {
materialType: existing.materialType,
materialKind: existing.materialKind,
fields: Array.isArray(existing.fields) ? existing.fields : [],
},
nextMaterialType: updateData.materialType,
nextMaterialKind: updateData.materialKind,
nextFields: formattedFields || undefined,
});
if (dangerousChanges.length > 0) {
return {
valid: false,
error: `模板“${existing.name}”已有 ${materialCount} 个素材在使用,不能直接修改素材大类、素材类型,也不能删除字段、修改字段 key、修改字段类型、取消已加入分类的字段、关闭已启用搜索的字段、把可填写字段改为只读、把字段改为必填、修改校验规则、删除选项或修改显示条件。请新建模板后迁移素材。`,
errorCode: 'TEMPLATE_STRUCTURE_LOCKED',
field: 'template',
statusCode: 409,
details: {
templateName: existing.name,
materialCount,
dangerousChanges,
},
};
}
}
return {
valid: true,
updateData,
fieldsProvided: normalizedResult.fieldsProvided,
fields: formattedFields,
nextVersion,
};
}