|
@@ -23,7 +23,8 @@ const HIDDEN_FIELDS = new Set(['password','authData','sessionToken','legacyPassw
|
|
|
const GENERIC_WRITE_TYPES = new Set(['String','Number','Boolean','Date','Pointer','Object','Array']);
|
|
const GENERIC_WRITE_TYPES = new Set(['String','Number','Boolean','Date','Pointer','Object','Array']);
|
|
|
const CLASS_SYSTEM_FIELDS = {
|
|
const CLASS_SYSTEM_FIELDS = {
|
|
|
CommonModel: new Set(['generalId','modelId','nodeId','itemId','tableName','status','isDeleted','sourceKey','orderId']),
|
|
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'])
|
|
|
|
|
|
|
+ Node: new Set(['nodeId','parentId','depth','child','orderId','zstatus','sourceKey','cuser','cuname','editDate']),
|
|
|
|
|
+ Special: new Set(['specId','pid','orderId','sourceKey','cuser','editDate'])
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
function inputOf(request) {
|
|
function inputOf(request) {
|
|
@@ -416,6 +417,85 @@ async function handler(request, response) {
|
|
|
for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'node-' + action, 'Node', target.id);
|
|
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) } });
|
|
return response.json({ success: true, data: { action, updated: targets.length, parentId, results: action === 'purge' ? [] : targets.map(serializeObject) } });
|
|
|
}
|
|
}
|
|
|
|
|
+ if (operation === 'saveSpecial') {
|
|
|
|
|
+ const objectId = String(input.objectId || '');
|
|
|
|
|
+ const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
|
|
|
|
|
+ const specName = String(payload.specName || '').trim();
|
|
|
|
|
+ const specDir = String(payload.specDir || '').trim();
|
|
|
|
|
+ const pid = Number(input.pid == null ? payload.pid || 0 : input.pid);
|
|
|
|
|
+ if (!specName || specName.length > 100) fail(400, '专题名称长度应为 1 至 100 位');
|
|
|
|
|
+ if (!specDir || specDir.length > 100) fail(400, '专题目录长度应为 1 至 100 位');
|
|
|
|
|
+ if (!Number.isInteger(pid) || pid < 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('Special');
|
|
|
|
|
+ let target;
|
|
|
|
|
+ let currentSpecId = 0;
|
|
|
|
|
+ if (objectId) {
|
|
|
|
|
+ const query = new Parse.Query('Special'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
|
|
|
|
|
+ companyId = pointerId(target.get('company')) || companyId; currentSpecId = Number(target.get('specId')) || 0;
|
|
|
|
|
+ } else target = new Parse.Object('Special');
|
|
|
|
|
+ if (pid > 0) {
|
|
|
|
|
+ const parent = await Psql.oneOrNone('SELECT "specId" FROM "Special" WHERE "company"=$1 AND "specId"=$2 LIMIT 1', [companyId, pid]);
|
|
|
|
|
+ if (!parent) fail(404, '父专题不存在或不属于当前帐套');
|
|
|
|
|
+ if (currentSpecId) {
|
|
|
|
|
+ const cycle = await Psql.oneOrNone('WITH RECURSIVE chain AS (SELECT "specId","pid",ARRAY["specId"::text] AS path FROM "Special" WHERE "company"=$1 AND "specId"=$2 UNION ALL SELECT s."specId",s."pid",chain.path||s."specId"::text FROM "Special" s JOIN chain ON s."specId"=chain."pid" WHERE s."company"=$1 AND NOT s."specId"::text=ANY(chain.path)) SELECT 1 AS found FROM chain WHERE "specId"=$3 LIMIT 1', [companyId, pid, currentSpecId]);
|
|
|
|
|
+ if (cycle) fail(409, '不能把专题移动到自身或其下级专题');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "Special" WHERE "company"=$1 AND "objectId"<>$2 AND (LOWER(TRIM(COALESCE("specName",\'\')))=LOWER($3) OR LOWER(TRIM(COALESCE("specDir",\'\')))=LOWER($4)) LIMIT 1', [companyId, objectId, specName, specDir]);
|
|
|
|
|
+ if (duplicate) fail(409, '专题名称或目录不能重复');
|
|
|
|
|
+ for (const [name, value] of Object.entries(payload)) {
|
|
|
|
|
+ if (!fields[name] || isSystemField('Special', 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('specName', specName); target.set('specDir', specDir); target.set('pid', pid); target.set('company', company); target.set('editDate', new Date());
|
|
|
|
|
+ if (!objectId) { target.set('sourceKey', 'cloud:admin-special:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); target.set('openType', true); target.set('cdate', new Date()); target.set('cuser', String(context.current.get('username') || '')); }
|
|
|
|
|
+ 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-special-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("specId"),0)+1 AS id FROM "Special",lock_row WHERE "company"=$1 AND COALESCE("specId",0)>0), next_order AS MATERIALIZED (SELECT COALESCE(MAX("orderId"),0)+1 AS id FROM "Special",lock_row WHERE "company"=$1 AND COALESCE("pid",0)=$3 AND "objectId"<>$2) UPDATE "Special" SET "specId"=next_id.id,"orderId"=next_order.id,"sourceKey"=\'[["SpecID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id,next_order WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id, pid]);
|
|
|
|
|
+ currentSpecId = Number(rows[0] && rows[0].id) || 0;
|
|
|
|
|
+ if (!currentSpecId) throw new Error('无法分配专题编号');
|
|
|
|
|
+ }
|
|
|
|
|
+ } 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-special' : 'create-special', 'Special', target.id);
|
|
|
|
|
+ return response.json({ success: true, data: serializeObject(target) });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (operation === 'specialBatch') {
|
|
|
|
|
+ const action = String(input.action || '');
|
|
|
|
|
+ if (action === 'merge') fail(501, 'migration_blocked: CommonModel.specialId 仅存在于 Parse Schema、PostgreSQL 物理列缺失,且商品主表未迁移,无法安全合并专题关系');
|
|
|
|
|
+ if (action !== 'move') 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('Special');
|
|
|
|
|
+ const query = new Parse.Query('Special'); 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 specIds = targets.map((target) => Number(target.get('specId')) || 0);
|
|
|
|
|
+ const pid = Number(input.pid);
|
|
|
|
|
+ if (!Number.isInteger(pid) || pid < 0) fail(400, '目标父专题编号无效');
|
|
|
|
|
+ if (pid > 0) {
|
|
|
|
|
+ const parent = await Psql.oneOrNone('SELECT "specId" FROM "Special" WHERE "company"=$1 AND "specId"=$2 LIMIT 1', [companyId, pid]);
|
|
|
|
|
+ if (!parent) fail(404, '目标父专题不存在或不属于当前帐套');
|
|
|
|
|
+ for (const specId of specIds) {
|
|
|
|
|
+ const cycle = await Psql.oneOrNone('WITH RECURSIVE chain AS (SELECT "specId","pid",ARRAY["specId"::text] AS path FROM "Special" WHERE "company"=$1 AND "specId"=$2 UNION ALL SELECT s."specId",s."pid",chain.path||s."specId"::text FROM "Special" s JOIN chain ON s."specId"=chain."pid" WHERE s."company"=$1 AND NOT s."specId"::text=ANY(chain.path)) SELECT 1 AS found FROM chain WHERE "specId"=$3 LIMIT 1', [companyId, pid, specId]);
|
|
|
|
|
+ if (cycle) fail(409, '不能把专题移动到自身或其下级专题');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const target of targets) { target.set('pid', pid); 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 }, 'special-move', 'Special', target.id);
|
|
|
|
|
+ return response.json({ success: true, data: { action, updated: targets.length, pid, results: targets.map(serializeObject) } });
|
|
|
|
|
+ }
|
|
|
if (operation === 'contentBatch') {
|
|
if (operation === 'contentBatch') {
|
|
|
const action = String(input.action || '');
|
|
const action = String(input.action || '');
|
|
|
if (!['status','recycle','recover','move'].includes(action)) fail(400, '不支持的内容批量操作');
|
|
if (!['status','recycle','recover','move'].includes(action)) fail(400, '不支持的内容批量操作');
|
|
@@ -461,7 +541,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));
|
|
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);
|
|
let query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId);
|
|
|
const search = String(input.search || '').trim();
|
|
const search = String(input.search || '').trim();
|
|
|
- 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'); }
|
|
|
|
|
|
|
+ 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'); }
|
|
|
const sort = fields[input.sort] ? String(input.sort) : fields.updatedAt ? 'updatedAt' : 'createdAt'; if (input.order === 'asc') query.ascending(sort); else query.descending(sort);
|
|
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 total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize);
|
|
|
const results = (await query.find({ useMasterKey: true })).filter(isVisible);
|
|
const results = (await query.find({ useMasterKey: true })).filter(isVisible);
|
|
@@ -477,6 +557,7 @@ async function handler(request, response) {
|
|
|
if (className === '_User' && !objectId) fail(400, '新增用户必须走专用开户流程');
|
|
if (className === '_User' && !objectId) fail(400, '新增用户必须走专用开户流程');
|
|
|
if (className === 'Group') fail(400, '用户组必须走专用保存流程');
|
|
if (className === 'Group') fail(400, '用户组必须走专用保存流程');
|
|
|
if (className === 'Node') fail(400, '栏目必须走专用保存流程');
|
|
if (className === 'Node') fail(400, '栏目必须走专用保存流程');
|
|
|
|
|
+ if (className === 'Special') fail(400, '专题必须走专用保存流程');
|
|
|
if (className === 'CommonModel' && !objectId) fail(400, '内容新增必须同时写入模型附表,不能走通用保存');
|
|
if (className === 'CommonModel' && !objectId) fail(400, '内容新增必须同时写入模型附表,不能走通用保存');
|
|
|
let object;
|
|
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);
|
|
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);
|
|
@@ -492,6 +573,7 @@ async function handler(request, response) {
|
|
|
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 === '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 === '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 (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 (className === 'Special') { const specId = Number(object.get('specId')) || 0; const companyId = pointerId(object.get('company')); const children = await Psql.one('SELECT COUNT(*)::int AS count FROM "Special" WHERE "company"=$1 AND COALESCE("pid",0)=$2', [companyId, specId]); if (Number(children.count) > 0) fail(409, '该专题仍有下级专题,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-special', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
|
|
|
if (fields.isDeleted) { object.set('isDeleted', true); await object.save(null, { useMasterKey: true }); } else await object.destroy({ useMasterKey: true });
|
|
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]);
|
|
if (className === '_User') await revokeSessions([object]);
|
|
|
await audit(context, 'delete', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: Boolean(fields.isDeleted) } });
|
|
await audit(context, 'delete', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: Boolean(fields.isDeleted) } });
|