|
|
@@ -21,7 +21,10 @@ const READ_ONLY_CLASSES = new Set(['_Role','Permission','PayPlat','ThirdPlatInfo
|
|
|
const SYSTEM_FIELDS = new Set(['objectId','createdAt','updatedAt','ACL','company','password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','adminRoleKey','isAdmin','roles','role','isDisabled','legacyUserId','legacyGroupId','legacyUserData','groupId']);
|
|
|
const HIDDEN_FIELDS = new Set(['password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','apiKey','appSecret','secret','token']);
|
|
|
const GENERIC_WRITE_TYPES = new Set(['String','Number','Boolean','Date','Pointer','Object','Array']);
|
|
|
-const CLASS_SYSTEM_FIELDS = { CommonModel: new Set(['generalId','modelId','nodeId','itemId','tableName','status','isDeleted','sourceKey','orderId']) };
|
|
|
+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'])
|
|
|
+};
|
|
|
|
|
|
function inputOf(request) {
|
|
|
const body = request.body || {};
|
|
|
@@ -311,6 +314,108 @@ async function handler(request, response) {
|
|
|
await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-group' : 'create-group', 'Group', target.id);
|
|
|
return response.json({ success: true, data: serializeObject(target) });
|
|
|
}
|
|
|
+ if (operation === 'saveNode') {
|
|
|
+ const objectId = String(input.objectId || '');
|
|
|
+ const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
|
|
|
+ const nodeName = String(payload.nodeName || '').trim();
|
|
|
+ const nodeDir = String(payload.nodeDir || '').trim();
|
|
|
+ const parentId = Number(input.parentId == null ? payload.parentId || 0 : input.parentId);
|
|
|
+ if (!nodeName || nodeName.length > 100) fail(400, '栏目名称长度应为 1 至 100 位');
|
|
|
+ if (!Number.isInteger(parentId) || parentId < 0) fail(400, '父节点编号无效');
|
|
|
+ let companyId = pointerId(context.company);
|
|
|
+ if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
|
|
|
+ if (!companyId) fail(400, '栏目必须指定帐套');
|
|
|
+ const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
|
|
|
+ const fields = await schemaFor('Node');
|
|
|
+ let target;
|
|
|
+ let currentNodeId = 0;
|
|
|
+ if (objectId) {
|
|
|
+ const query = new Parse.Query('Node'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
|
|
|
+ companyId = pointerId(target.get('company')) || companyId;
|
|
|
+ currentNodeId = Number(target.get('nodeId')) || 0;
|
|
|
+ } else target = new Parse.Object('Node');
|
|
|
+ let parentDepth = 0;
|
|
|
+ if (parentId > 0) {
|
|
|
+ const parent = await Psql.oneOrNone('SELECT "nodeId","depth" FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 AND COALESCE("zstatus",99)<>-2 LIMIT 1', [companyId, parentId]);
|
|
|
+ if (!parent) fail(404, '父节点不存在、已回收或不属于当前帐套');
|
|
|
+ parentDepth = Number(parent.depth) || 0;
|
|
|
+ if (currentNodeId) {
|
|
|
+ const cycle = await Psql.oneOrNone('WITH RECURSIVE chain AS (SELECT "nodeId","parentId",ARRAY["nodeId"::text] AS path FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 UNION ALL SELECT n."nodeId",n."parentId",chain.path||n."nodeId"::text FROM "Node" n JOIN chain ON n."nodeId"=chain."parentId" WHERE n."company"=$1 AND NOT n."nodeId"::text=ANY(chain.path)) SELECT 1 AS found FROM chain WHERE "nodeId"=$3 LIMIT 1', [companyId, parentId, currentNodeId]);
|
|
|
+ if (cycle) fail(409, '不能把栏目移动到自身或其下级栏目');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "Node" WHERE "company"=$1 AND COALESCE("parentId",0)=$2 AND "objectId"<>$3 AND COALESCE("zstatus",99)<>-2 AND (LOWER(TRIM(COALESCE("nodeName",\'\')))=LOWER($4) OR ($5<>\'\' AND LOWER(TRIM(COALESCE("nodeDir",\'\')))=LOWER($5))) LIMIT 1', [companyId, parentId, objectId, nodeName, nodeDir]);
|
|
|
+ if (duplicate) fail(409, '同一父栏目下的栏目名称或目录名不能重复');
|
|
|
+ for (const [name, value] of Object.entries(payload)) {
|
|
|
+ if (!fields[name] || isSystemField('Node', name)) continue;
|
|
|
+ if (!GENERIC_WRITE_TYPES.has(fields[name].type)) fail(400, '字段不允许编辑: ' + name);
|
|
|
+ if (value === null) target.unset(name); else target.set(name, toParseValue(fields[name], value));
|
|
|
+ }
|
|
|
+ target.set('nodeName', nodeName); target.set('nodeDir', nodeDir); target.set('parentId', parentId); target.set('depth', parentDepth + 1); target.set('company', company);
|
|
|
+ if (!objectId) {
|
|
|
+ target.set('sourceKey', 'cloud:admin-node:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10));
|
|
|
+ target.set('zstatus', 99); target.set('child', 0); target.set('cdate', new Date());
|
|
|
+ if (fields.cuser) target.set('cuser', Number(context.current.get('legacyUserId')) || 0);
|
|
|
+ if (fields.cuname) target.set('cuname', String(context.current.get('username') || ''));
|
|
|
+ }
|
|
|
+ if (fields.editDate) target.set('editDate', new Date());
|
|
|
+ try { await target.save(null, { useMasterKey: true }); }
|
|
|
+ catch (error) { fail(422, '栏目初始写入失败: ' + String(error && error.message || error)); }
|
|
|
+ try {
|
|
|
+ if (!objectId) {
|
|
|
+ const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-node-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("nodeId"),0)+1 AS id FROM "Node",lock_row WHERE "company"=$1 AND COALESCE("nodeId",0)>0), next_order AS MATERIALIZED (SELECT COALESCE(MAX("orderId"),0)+1 AS id FROM "Node",lock_row WHERE "company"=$1 AND COALESCE("parentId",0)=$3 AND "objectId"<>$2) UPDATE "Node" SET "nodeId"=next_id.id,"orderId"=next_order.id,"sourceKey"=\'[["NodeID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id,next_order WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id, parentId]);
|
|
|
+ currentNodeId = Number(rows[0] && rows[0].id) || 0;
|
|
|
+ if (!currentNodeId) throw new Error('无法分配栏目编号');
|
|
|
+ }
|
|
|
+ await Psql.query('WITH RECURSIVE tree AS (SELECT "objectId","nodeId",$3::numeric AS depth FROM "Node" WHERE "company"=$1 AND "objectId"=$2 UNION ALL SELECT n."objectId",n."nodeId",tree.depth+1 FROM "Node" n JOIN tree ON n."parentId"=tree."nodeId" WHERE n."company"=$1) UPDATE "Node" n SET "depth"=tree.depth,"updatedAt"=NOW() FROM tree WHERE n."objectId"=tree."objectId"', [companyId, target.id, parentDepth + 1]);
|
|
|
+ } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '栏目结构写入失败: ' + String(error && error.message || error)); }
|
|
|
+ await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-node' : 'create-node', 'Node', target.id);
|
|
|
+ return response.json({ success: true, data: serializeObject(target) });
|
|
|
+ }
|
|
|
+ if (operation === 'nodeBatch') {
|
|
|
+ const action = String(input.action || '');
|
|
|
+ if (!['recycle','recover','move','purge'].includes(action)) fail(400, '不支持的栏目批量操作');
|
|
|
+ const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
|
|
|
+ const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
|
|
|
+ if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个栏目');
|
|
|
+ const fields = await schemaFor('Node');
|
|
|
+ const query = new Parse.Query('Node'); 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 companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
|
|
|
+ if (companyIds.length !== 1) fail(400, '批量操作的栏目必须属于同一帐套');
|
|
|
+ const companyId = companyIds[0];
|
|
|
+ const nodeIds = targets.map((target) => Number(target.get('nodeId')) || 0);
|
|
|
+ if (nodeIds.some((nodeId) => nodeId < 1)) fail(409, '栏目缺少有效旧系统编号');
|
|
|
+ let parentId = null;
|
|
|
+ let parentDepth = 0;
|
|
|
+ if (action === 'move') {
|
|
|
+ parentId = Number(input.parentId);
|
|
|
+ if (!Number.isInteger(parentId) || parentId < 0) fail(400, '目标父节点编号无效');
|
|
|
+ if (parentId > 0) {
|
|
|
+ const parent = await Psql.oneOrNone('SELECT "nodeId","depth" FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 AND COALESCE("zstatus",99)<>-2 LIMIT 1', [companyId, parentId]);
|
|
|
+ if (!parent) fail(404, '目标父节点不存在、已回收或不属于当前帐套');
|
|
|
+ parentDepth = Number(parent.depth) || 0;
|
|
|
+ for (const nodeId of nodeIds) {
|
|
|
+ const cycle = await Psql.oneOrNone('WITH RECURSIVE chain AS (SELECT "nodeId","parentId",ARRAY["nodeId"::text] AS path FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 UNION ALL SELECT n."nodeId",n."parentId",chain.path||n."nodeId"::text FROM "Node" n JOIN chain ON n."nodeId"=chain."parentId" WHERE n."company"=$1 AND NOT n."nodeId"::text=ANY(chain.path)) SELECT 1 AS found FROM chain WHERE "nodeId"=$3 LIMIT 1', [companyId, parentId, nodeId]);
|
|
|
+ if (cycle) fail(409, '不能把栏目移动到自身或其下级栏目');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for (const target of targets) { target.set('parentId', parentId); target.set('depth', parentDepth + 1); if (fields.editDate) target.set('editDate', new Date()); }
|
|
|
+ await Parse.Object.saveAll(targets, { useMasterKey: true });
|
|
|
+ for (const target of targets) await Psql.query('WITH RECURSIVE tree AS (SELECT "objectId","nodeId",$3::numeric AS depth FROM "Node" WHERE "company"=$1 AND "objectId"=$2 UNION ALL SELECT n."objectId",n."nodeId",tree.depth+1 FROM "Node" n JOIN tree ON n."parentId"=tree."nodeId" WHERE n."company"=$1) UPDATE "Node" n SET "depth"=tree.depth,"updatedAt"=NOW() FROM tree WHERE n."objectId"=tree."objectId"', [companyId, target.id, parentDepth + 1]);
|
|
|
+ } else if (action === 'purge') {
|
|
|
+ const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "CommonModel" WHERE "company"=$1 AND "nodeId"=ANY($2::numeric[])) AS contents,(SELECT COUNT(*)::int FROM "Node" WHERE "company"=$1 AND "parentId"=ANY($2::numeric[]) AND NOT ("nodeId"=ANY($2::numeric[]))) AS children,(SELECT COUNT(*)::int FROM "NodeAuth" WHERE "nodeId"=ANY($2::numeric[])) AS auth,(SELECT COUNT(*)::int FROM "NodeModelTemplate" WHERE "nodeId"=ANY($2::numeric[])) AS templates', [companyId, nodeIds]);
|
|
|
+ if (Number(refs.contents) || Number(refs.children) || Number(refs.auth) || Number(refs.templates)) fail(409, '栏目仍被内容、下级栏目、权限或模板引用,不能永久删除');
|
|
|
+ await Parse.Object.destroyAll(targets, { useMasterKey: true });
|
|
|
+ } else {
|
|
|
+ const status = action === 'recycle' ? -2 : 99;
|
|
|
+ for (const target of targets) { target.set('zstatus', status); if (fields.editDate) target.set('editDate', new Date()); }
|
|
|
+ await Parse.Object.saveAll(targets, { useMasterKey: true });
|
|
|
+ }
|
|
|
+ for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'node-' + action, 'Node', target.id);
|
|
|
+ return response.json({ success: true, data: { action, updated: targets.length, parentId, results: action === 'purge' ? [] : targets.map(serializeObject) } });
|
|
|
+ }
|
|
|
if (operation === 'contentBatch') {
|
|
|
const action = String(input.action || '');
|
|
|
if (!['status','recycle','recover','move'].includes(action)) fail(400, '不支持的内容批量操作');
|
|
|
@@ -333,7 +438,7 @@ async function handler(request, response) {
|
|
|
if (!Number.isInteger(nodeId) || nodeId < 1) fail(400, '请选择有效目标节点');
|
|
|
const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
|
|
|
if (companyIds.length !== 1) fail(400, '批量移动的内容必须属于同一帐套');
|
|
|
- const node = await Psql.oneOrNone('SELECT "objectId" FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 LIMIT 1', [companyIds[0], nodeId]);
|
|
|
+ const node = await Psql.oneOrNone('SELECT "objectId" FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 AND COALESCE("zstatus",99)<>-2 LIMIT 1', [companyIds[0], nodeId]);
|
|
|
if (!node) fail(404, '目标节点不存在或不属于当前帐套');
|
|
|
}
|
|
|
for (const target of targets) {
|
|
|
@@ -356,7 +461,7 @@ async function handler(request, response) {
|
|
|
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','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','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);
|
|
|
@@ -371,6 +476,7 @@ async function handler(request, response) {
|
|
|
if (!classWritable) fail(403, '该系统类不允许通用编辑');
|
|
|
if (className === '_User' && !objectId) fail(400, '新增用户必须走专用开户流程');
|
|
|
if (className === 'Group') fail(400, '用户组必须走专用保存流程');
|
|
|
+ if (className === 'Node') 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);
|
|
|
@@ -385,6 +491,7 @@ async function handler(request, response) {
|
|
|
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 } }); }
|
|
|
+ if (className === 'Node') { object.set('zstatus', -2); if (fields.editDate) object.set('editDate', new Date()); await object.save(null, { useMasterKey: true }); await audit(context, 'node-recycle', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: true, zstatus: -2 } }); }
|
|
|
if (fields.isDeleted) { object.set('isDeleted', true); await object.save(null, { useMasterKey: true }); } else await object.destroy({ useMasterKey: true });
|
|
|
if (className === '_User') await revokeSessions([object]);
|
|
|
await audit(context, 'delete', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: Boolean(fields.isDeleted) } });
|