|
@@ -605,6 +605,78 @@ async function handler(request, response) {
|
|
|
for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'guestbook-' + action, 'Guestbook', target.id);
|
|
for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'guestbook-' + action, 'Guestbook', target.id);
|
|
|
return response.json({ success: true, data: { action, updated: targets.length, results: action === 'purge' ? [] : targets.map(serializeObject) } });
|
|
return response.json({ success: true, data: { action, updated: targets.length, results: action === 'purge' ? [] : targets.map(serializeObject) } });
|
|
|
}
|
|
}
|
|
|
|
|
+ if (operation === 'saveGuestCategory') {
|
|
|
|
|
+ const objectId = String(input.objectId || '');
|
|
|
|
|
+ const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
|
|
|
|
|
+ const catename = String(payload.catename || '').trim();
|
|
|
|
|
+ const parentId = Number(input.parentId == null ? payload.parentId || 0 : input.parentId);
|
|
|
|
|
+ let gtype = Number(input.gtype == null ? payload.gtype || 0 : input.gtype);
|
|
|
|
|
+ if (!catename || catename.length > 100) fail(400, '分类名称长度应为 1 至 100 位');
|
|
|
|
|
+ if (!Number.isInteger(parentId) || parentId < 0 || ![0,1].includes(gtype)) 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('Guestcate');
|
|
|
|
|
+ let target;
|
|
|
|
|
+ let currentCateId = 0;
|
|
|
|
|
+ if (objectId) {
|
|
|
|
|
+ const query = new Parse.Query('Guestcate'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
|
|
|
|
|
+ companyId = pointerId(target.get('company')) || companyId; currentCateId = Number(target.get('cateid')) || 0; gtype = Number(target.get('gtype')) || 0;
|
|
|
|
|
+ } else target = new Parse.Object('Guestcate');
|
|
|
|
|
+ if (gtype === 0 && parentId !== 0) fail(400, '留言分类只能建立为顶级分类');
|
|
|
|
|
+ if (parentId > 0) {
|
|
|
|
|
+ const parent = await Psql.oneOrNone('SELECT "cateid" FROM "Guestcate" WHERE "company"=$1 AND "cateid"=$2 AND COALESCE("gtype",0)=$3 LIMIT 1', [companyId, parentId, gtype]);
|
|
|
|
|
+ if (!parent) fail(404, '父分类不存在、类型不一致或不属于当前帐套');
|
|
|
|
|
+ if (currentCateId) {
|
|
|
|
|
+ const cycle = await Psql.oneOrNone('WITH RECURSIVE chain AS (SELECT "cateid","parentId",ARRAY["cateid"::text] AS path FROM "Guestcate" WHERE "company"=$1 AND "cateid"=$2 UNION ALL SELECT c."cateid",c."parentId",chain.path||c."cateid"::text FROM "Guestcate" c JOIN chain ON c."cateid"=chain."parentId" WHERE c."company"=$1 AND NOT c."cateid"::text=ANY(chain.path)) SELECT 1 AS found FROM chain WHERE "cateid"=$3 LIMIT 1', [companyId, parentId, currentCateId]);
|
|
|
|
|
+ if (cycle) fail(409, '不能把分类移动到自身或其下级分类');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "Guestcate" WHERE "company"=$1 AND "objectId"<>$2 AND COALESCE("gtype",0)=$3 AND COALESCE("parentId",0)=$4 AND LOWER(TRIM(COALESCE("catename",\'\')))=LOWER($5) LIMIT 1', [companyId, objectId, gtype, parentId, catename]);
|
|
|
|
|
+ if (duplicate) fail(409, '同一父分类下的分类名称不能重复');
|
|
|
|
|
+ const allowed = new Set(['catename','desc','status','needLog','isShowUnaudit','postAuth','zipImgSize','barImage','barOwner','permibit','sendScore','replyScore','barInfo','isPlat']);
|
|
|
|
|
+ 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));
|
|
|
|
|
+ }
|
|
|
|
|
+ target.set('catename', catename); target.set('parentId', parentId); target.set('gtype', gtype); target.set('company', company);
|
|
|
|
|
+ if (!objectId) { target.set('sourceKey', 'cloud:admin-guestcate:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); if (fields.status && payload.status == null) target.set('status', 1); }
|
|
|
|
|
+ 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-guestcate-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("cateid"),0)+1 AS id FROM "Guestcate",lock_row WHERE "company"=$1 AND COALESCE("cateid",0)>0), next_order AS MATERIALIZED (SELECT COALESCE(MAX("orderId"),0)+1 AS id FROM "Guestcate",lock_row WHERE "company"=$1 AND COALESCE("gtype",0)=$3 AND COALESCE("parentId",0)=$4 AND "objectId"<>$2) UPDATE "Guestcate" SET "cateid"=next_id.id,"orderId"=next_order.id,"sourceKey"=\'[["Cateid",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id,next_order WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id, gtype, parentId]);
|
|
|
|
|
+ currentCateId = Number(rows[0] && rows[0].id) || 0;
|
|
|
|
|
+ if (!currentCateId) 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-guest-category' : 'create-guest-category', 'Guestcate', target.id);
|
|
|
|
|
+ return response.json({ success: true, data: serializeObject(target) });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (operation === 'guestCategoryBatch') {
|
|
|
|
|
+ const action = String(input.action || '');
|
|
|
|
|
+ if (!['delete','recommend','unrecommend'].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('Guestcate');
|
|
|
|
|
+ const query = new Parse.Query('Guestcate'); 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, '部分分类不存在或不属于当前帐套');
|
|
|
|
|
+ if (action === 'delete') {
|
|
|
|
|
+ const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))]; const cateids = targets.map((target) => Number(target.get('cateid')) || 0);
|
|
|
|
|
+ if (companyIds.length !== 1 || cateids.some((id) => id < 1)) fail(409, '分类缺少有效帐套或编号');
|
|
|
|
|
+ const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "Guestcate" WHERE "company"=$1 AND "parentId"=ANY($2::numeric[]) AND NOT ("cateid"=ANY($2::numeric[]))) AS children,(SELECT COUNT(*)::int FROM "Guestbook" WHERE "company"=$1 AND "cateid"=ANY($2::numeric[])) AS messages,(SELECT COUNT(*)::int FROM "GuestBar" WHERE "company"=$1 AND "cateId"=ANY($2::numeric[])) AS posts', [companyIds[0], cateids]);
|
|
|
|
|
+ if (Number(refs.children) || Number(refs.messages) || Number(refs.posts)) fail(409, '分类仍被下级分类、留言或帖子引用,不能删除');
|
|
|
|
|
+ await Parse.Object.destroyAll(targets, { useMasterKey: true });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ for (const target of targets) { if (Number(target.get('gtype')) !== 1) fail(400, '只有贴吧分类支持推荐'); target.set('barInfo', action === 'recommend' ? 'Recommend' : ''); }
|
|
|
|
|
+ await Parse.Object.saveAll(targets, { useMasterKey: true });
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'guest-category-' + action, 'Guestcate', target.id);
|
|
|
|
|
+ return response.json({ success: true, data: { action, updated: targets.length, results: action === 'delete' ? [] : 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, '不支持的内容批量操作');
|
|
@@ -644,7 +716,7 @@ async function handler(request, response) {
|
|
|
const classWritable = !READ_ONLY_CLASSES.has(className);
|
|
const classWritable = !READ_ONLY_CLASSES.has(className);
|
|
|
if (operation === 'schema') {
|
|
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) }));
|
|
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 && !['CommonModel','Model','ModelField','Guestbook','Guestcate'].includes(className), 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','Guestbook'].includes(className), supportsSoftDelete: Boolean(fields.isDeleted) } });
|
|
|
}
|
|
}
|
|
|
if (operation === 'list') {
|
|
if (operation === 'list') {
|
|
|
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));
|
|
@@ -669,7 +741,7 @@ async function handler(request, response) {
|
|
|
if (className === 'Special') fail(400, '专题必须走专用保存流程');
|
|
if (className === 'Special') fail(400, '专题必须走专用保存流程');
|
|
|
if (className === 'Model' || className === 'ModelField') fail(400, '模型结构必须走专用元数据流程');
|
|
if (className === 'Model' || className === 'ModelField') fail(400, '模型结构必须走专用元数据流程');
|
|
|
if (className === 'Guestbook') fail(400, '留言必须走专用编辑或回复流程');
|
|
if (className === 'Guestbook') fail(400, '留言必须走专用编辑或回复流程');
|
|
|
- if (className === 'Guestcate') fail(501, 'migration_blocked: 留言/贴吧分类需要专用原子分类编号、类型与父子引用流程,通用保存已禁用');
|
|
|
|
|
|
|
+ if (className === 'Guestcate') 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);
|
|
@@ -682,7 +754,7 @@ async function handler(request, response) {
|
|
|
if (operation === 'delete') {
|
|
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 (!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 === 'Model' || className === 'ModelField') fail(501, 'migration_blocked: 删除模型或字段需要同步删除 PostgreSQL 物理表/列并验证历史数据,托管云函数中未开放此 DDL 流程');
|
|
|
- if (className === 'Guestcate') fail(501, 'migration_blocked: 分类删除前需要同时核验留言、帖子、权限与下级分类引用,通用删除已禁用');
|
|
|
|
|
|
|
+ if (className === 'Guestcate') { const cateid = Number(object.get('cateid')) || 0; const companyId = pointerId(object.get('company')); const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "Guestcate" WHERE "company"=$1 AND "parentId"=$2) AS children,(SELECT COUNT(*)::int FROM "Guestbook" WHERE "company"=$1 AND "cateid"=$2) AS messages,(SELECT COUNT(*)::int FROM "GuestBar" WHERE "company"=$1 AND "cateId"=$2) AS posts', [companyId, cateid]); if (Number(refs.children) || Number(refs.messages) || Number(refs.posts)) fail(409, '分类仍被下级分类、留言或帖子引用,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-guest-category', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
|
|
|
if (className === '_User') assertCanManageUser(context, object, 'lock');
|
|
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 === '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 } }); }
|