|
|
@@ -24,7 +24,9 @@ const GENERIC_WRITE_TYPES = new Set(['String','Number','Boolean','Date','Pointer
|
|
|
const CLASS_SYSTEM_FIELDS = {
|
|
|
CommonModel: new Set(['generalId','modelId','nodeId','itemId','tableName','status','isDeleted','sourceKey','orderId']),
|
|
|
Node: new Set(['nodeId','parentId','depth','child','orderId','zstatus','sourceKey','cuser','cuname','editDate']),
|
|
|
- Special: new Set(['specId','pid','orderId','sourceKey','cuser','editDate'])
|
|
|
+ Special: new Set(['specId','pid','orderId','sourceKey','cuser','editDate']),
|
|
|
+ Model: new Set(['modelId','modelType','tableName','sourceKey','nodeId','fromModel','multiFlag','sysModel']),
|
|
|
+ ModelField: new Set(['fieldId','modelId','fieldName','fieldType','sourceKey','sysType','orderId'])
|
|
|
};
|
|
|
|
|
|
function inputOf(request) {
|
|
|
@@ -496,6 +498,52 @@ async function handler(request, response) {
|
|
|
for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'special-move', 'Special', target.id);
|
|
|
return response.json({ success: true, data: { action, updated: targets.length, pid, results: targets.map(serializeObject) } });
|
|
|
}
|
|
|
+ if (operation === 'saveModelMetadata' || operation === 'saveModelFieldMetadata') {
|
|
|
+ const className = operation === 'saveModelMetadata' ? 'Model' : 'ModelField';
|
|
|
+ const objectId = String(input.objectId || '');
|
|
|
+ if (!objectId) fail(501, 'migration_blocked: 新增模型或字段需要同步创建 PostgreSQL 物理表/列及旧模板文件,托管云函数中未开放此 DDL 流程');
|
|
|
+ const fields = await schemaFor(className);
|
|
|
+ const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
|
|
|
+ const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
|
|
|
+ const allowed = className === 'Model'
|
|
|
+ ? new Set(['modelName','itemName','itemUnit','itemIcon','description','islotsize','contentTemplate','thumbnail'])
|
|
|
+ : new Set(['fieldAlias','fieldTips','description','isNotNull','isSearchForm','content','isShow','isView','showList','showWidth','isCopy','islotsize','isChain']);
|
|
|
+ if (className === 'Model') {
|
|
|
+ const modelName = String(payload.modelName == null ? target.get('modelName') || '' : payload.modelName).trim();
|
|
|
+ if (!modelName || modelName.length > 100) fail(400, '模型名称长度应为 1 至 100 位');
|
|
|
+ const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "Model" WHERE "company"=$1 AND "objectId"<>$2 AND LOWER(TRIM(COALESCE("modelName",\'\')))=LOWER($3) LIMIT 1', [pointerId(target.get('company')), objectId, modelName]);
|
|
|
+ if (duplicate) fail(409, '模型名称已存在');
|
|
|
+ target.set('modelName', modelName);
|
|
|
+ } else {
|
|
|
+ const fieldAlias = String(payload.fieldAlias == null ? target.get('fieldAlias') || '' : payload.fieldAlias).trim();
|
|
|
+ if (!fieldAlias || fieldAlias.length > 100) fail(400, '字段别名长度应为 1 至 100 位');
|
|
|
+ target.set('fieldAlias', fieldAlias);
|
|
|
+ }
|
|
|
+ for (const [name, value] of Object.entries(payload)) {
|
|
|
+ if (!allowed.has(name) || !fields[name]) continue;
|
|
|
+ if (value === null) target.unset(name); else target.set(name, toParseValue(fields[name], value));
|
|
|
+ }
|
|
|
+ await target.save(null, { useMasterKey: true }); await audit(context, className === 'Model' ? 'update-model-metadata' : 'update-model-field-metadata', className, objectId);
|
|
|
+ return response.json({ success: true, data: serializeObject(target) });
|
|
|
+ }
|
|
|
+ if (operation === 'modelFieldOrder') {
|
|
|
+ const items = Array.isArray(input.items) ? input.items : [];
|
|
|
+ if (!items.length || items.length > 100) fail(400, '每次请提交 1 至 100 个字段顺序');
|
|
|
+ const objectIds = items.map((item) => String(item && item.objectId || '').trim());
|
|
|
+ const orderIds = items.map((item) => Number(item && item.orderId));
|
|
|
+ if (objectIds.some((id) => !id) || new Set(objectIds).size !== objectIds.length || orderIds.some((id) => !Number.isInteger(id) || id < 0) || new Set(orderIds).size !== orderIds.length) fail(400, '字段或顺序参数无效/重复');
|
|
|
+ const fields = await schemaFor('ModelField');
|
|
|
+ const query = new Parse.Query('ModelField'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
|
|
|
+ const targets = await query.find({ useMasterKey: true });
|
|
|
+ if (targets.length !== objectIds.length) fail(404, '部分模型字段不存在或不属于当前帐套');
|
|
|
+ const modelIds = [...new Set(targets.map((target) => Number(target.get('modelId')) || 0))];
|
|
|
+ if (modelIds.length !== 1) fail(400, '只能对同一模型的字段排序');
|
|
|
+ const targetMap = new Map(targets.map((target) => [target.id, target]));
|
|
|
+ for (const item of items) targetMap.get(String(item.objectId)).set('orderId', Number(item.orderId));
|
|
|
+ await Parse.Object.saveAll(targets, { useMasterKey: true });
|
|
|
+ for (const target of targets) await audit(context, 'order-model-field', 'ModelField', target.id);
|
|
|
+ return response.json({ success: true, data: { updated: targets.length, modelId: modelIds[0], results: targets.map(serializeObject) } });
|
|
|
+ }
|
|
|
if (operation === 'contentBatch') {
|
|
|
const action = String(input.action || '');
|
|
|
if (!['status','recycle','recover','move'].includes(action)) fail(400, '不支持的内容批量操作');
|
|
|
@@ -535,13 +583,13 @@ async function handler(request, response) {
|
|
|
const classWritable = !READ_ONLY_CLASSES.has(className);
|
|
|
if (operation === 'schema') {
|
|
|
const fieldList = Object.entries(fields).filter(([name]) => !HIDDEN_FIELDS.has(name) && !/(?:password|secret|sessiontoken|masterkey|privatekey)/i.test(name)).map(([name, field]) => ({ name, type: field.type, targetClass: field.targetClass, required: field.required === true, writable: classWritable && !isSystemField(className, name) && GENERIC_WRITE_TYPES.has(field.type) }));
|
|
|
- return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, creatable: classWritable && className !== 'CommonModel', supportsSoftDelete: Boolean(fields.isDeleted) } });
|
|
|
+ return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, creatable: classWritable && !['CommonModel','Model','ModelField'].includes(className), supportsSoftDelete: Boolean(fields.isDeleted) } });
|
|
|
}
|
|
|
if (operation === 'list') {
|
|
|
const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20));
|
|
|
let query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId);
|
|
|
const search = String(input.search || '').trim();
|
|
|
- if (search) { const searchField = ['name','title','nodeName','specName','groupName','username','realName','mobile','sourceKey'].find((name) => fields[name] && fields[name].type === 'String'); if (searchField) query.matches(searchField, escapeRegex(search), 'i'); }
|
|
|
+ if (search) { const searchField = ['name','title','nodeName','specName','modelName','fieldName','fieldAlias','groupName','username','realName','mobile','sourceKey'].find((name) => fields[name] && fields[name].type === 'String'); if (searchField) query.matches(searchField, escapeRegex(search), 'i'); }
|
|
|
const sort = fields[input.sort] ? String(input.sort) : fields.updatedAt ? 'updatedAt' : 'createdAt'; if (input.order === 'asc') query.ascending(sort); else query.descending(sort);
|
|
|
const total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize);
|
|
|
const results = (await query.find({ useMasterKey: true })).filter(isVisible);
|
|
|
@@ -558,6 +606,7 @@ async function handler(request, response) {
|
|
|
if (className === 'Group') fail(400, '用户组必须走专用保存流程');
|
|
|
if (className === 'Node') fail(400, '栏目必须走专用保存流程');
|
|
|
if (className === 'Special') fail(400, '专题必须走专用保存流程');
|
|
|
+ if (className === 'Model' || className === 'ModelField') fail(400, '模型结构必须走专用元数据流程');
|
|
|
if (className === 'CommonModel' && !objectId) fail(400, '内容新增必须同时写入模型附表,不能走通用保存');
|
|
|
let object;
|
|
|
if (objectId) { const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); object = await query.get(objectId, { useMasterKey: true }); } else object = new Parse.Object(className);
|
|
|
@@ -569,6 +618,7 @@ async function handler(request, response) {
|
|
|
}
|
|
|
if (operation === 'delete') {
|
|
|
if (!classWritable || !objectId) fail(400, '该记录不允许删除'); const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); const object = await query.get(objectId, { useMasterKey: true });
|
|
|
+ if (className === 'Model' || className === 'ModelField') fail(501, 'migration_blocked: 删除模型或字段需要同步删除 PostgreSQL 物理表/列并验证历史数据,托管云函数中未开放此 DDL 流程');
|
|
|
if (className === '_User') assertCanManageUser(context, object, 'lock');
|
|
|
if (className === 'Group') { const groupId = Number(object.get('groupId')) || 0; const companyId = pointerId(object.get('company')); const users = await Psql.one('SELECT COUNT(*)::int AS count FROM "_User" WHERE "company"=$1 AND COALESCE("legacyGroupId",0)=$2 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE)', [companyId, groupId]); const children = await Psql.one('SELECT COUNT(*)::int AS count FROM "Group" WHERE "company"=$1 AND COALESCE("parentGroupId",0)=$2', [companyId, groupId]); if (Number(users.count) > 0) fail(409, '该用户组仍有用户,不能删除'); if (Number(children.count) > 0) fail(409, '该用户组仍有下级组,不能删除'); }
|
|
|
if (className === 'CommonModel') { object.set('status', -2); await object.save(null, { useMasterKey: true }); await audit(context, 'content-recycle', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: true, status: -2 } }); }
|