Forráskód Böngészése

feat: migrate admin guest categories

彭峰 2 hónapja
szülő
commit
b74781015e

+ 3 - 2
docs/migration/admin-migration-status.md

@@ -25,9 +25,10 @@
 - 旧 `NodeAdd_Submit`、`Node_API` 与 `UnionNode_Move` 的核心栏目生命周期已迁为专用 `saveNode/nodeBatch`:新增栏目以临时来源键完成 Parse 初始写入,再使用 PostgreSQL 事务锁原子分配 `nodeId/orderId` 并替换为旧 `sourceKey`;保存会校验同级名称/目录重复、父栏目帐套和回收状态,移动会阻断自身/下级循环并递归修正所有后代深度。回收、恢复和每批 1–100 个栏目迁移已接入 Angular;永久删除仅在没有内容、下级栏目、节点权限和模型模板引用时开放,通用保存与删除不能绕过。
 - 旧 `SpecialAdd_Save` 与 `UnionSpecial_Move` 已迁为专用 `saveSpecial/specialBatch`:新增专题使用临时来源键和 PostgreSQL 事务锁原子分配 `specId/orderId`,保存时校验全帐套名称/目录唯一、父专题归属与层级循环;删除会拒绝仍有下级专题的记录。旧 `UnionSpecial_Merge` 被明确列为数据阻塞:`CommonModel.specialId` 只存在于 Parse Schema、PostgreSQL 物理列缺失,商品主表也未迁移,无法安全改写内容/商品专题关系。
 - 旧 `ModelManage/Field/Order_Submit` 的安全部分已迁为 `saveModelMetadata/saveModelFieldMetadata/modelFieldOrder`:现有模型可编辑名称、条目文案、图标、说明和内容模板元数据,现有字段可编辑别名、提示、显示/搜索配置,并可在同模型内批量排序;`modelId/modelType/tableName/fieldId/modelId/fieldName/fieldType/sourceKey` 等结构字段通用只读。新增、复制、删除模型或字段会显式返回 501,因为旧实现同时执行 PostgreSQL 建表/改列、标签生成和模板文件写入,目标托管环境没有该 DDL/旧模板运行时。
-- 旧 `Guest/MsgList/ReplyGuest/MsgShow/Msg_API` 已迁为 `saveGuestbook/guestbookReply/guestbookBatch`:留言列表支持帐套分页、标题搜索和每批 1–100 条审核/取消审核/回收/恢复;详情可白名单编辑标题与正文,管理员回复继承原分类和父 `gid`,再以事务锁原子分配新 `gid/sourceKey`。永久删除会拒绝仍有回复的留言;普通留言新增继续只走已上线 H5 留言云函数。`gid/parentid/cateid/userid/status/sourceKey/gdate/ip` 均通用只读;尚未迁移的 `Guestcate` 分类编号、类型、父子和删除引用流程已先从通用创建/删除中封闭。
+- 旧 `Guest/MsgList/ReplyGuest/MsgShow/Msg_API` 已迁为 `saveGuestbook/guestbookReply/guestbookBatch`:留言列表支持帐套分页、标题搜索和每批 1–100 条审核/取消审核/回收/恢复;详情可白名单编辑标题与正文,管理员回复继承原分类和父 `gid`,再以事务锁原子分配新 `gid/sourceKey`。永久删除会拒绝仍有回复的留言;普通留言新增继续只走已上线 H5 留言云函数。`gid/parentid/cateid/userid/status/sourceKey/gdate/ip` 均通用只读。
+- 旧 `Guest/Bar` 共用的分类新增、编辑、推荐与删除已迁为 `saveGuestCategory/guestCategoryBatch`:新增分类用事务锁原子分配 `cateid/orderId/sourceKey`,`gtype=0` 留言分类固定顶级,`gtype=1` 贴吧分类支持父子结构并阻断循环;同父同类型名称防重,删除前同时检查下级分类、留言与帖子引用。贴吧发帖频率仍依赖旧 `GuestConfig` 文件,版块清空与用户级贴吧授权也尚未开放,因此对应动作保守标为部分实现。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 27 项已实现、26 项部分实现、112 项数据阻塞、189 项基础设施阻塞、688 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 33 项已实现、29 项部分实现、112 项数据阻塞、189 项基础设施阻塞、679 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

+ 75 - 3
scripts/deploy-admin-functions.mjs

@@ -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);
       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') {
       const action = String(input.action || '');
       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);
     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 && !['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') {
       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 === 'Model' || className === 'ModelField') 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, '内容新增必须同时写入模型附表,不能走通用保存');
       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);
@@ -682,7 +754,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 === '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 === '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 } }); }

+ 9 - 0
scripts/generate-admin-coverage.mjs

@@ -67,6 +67,15 @@ const explicit = new Map([
   ['Content / Guest/MsgShow', ['implemented', '已由留言详情抽屉替代']],
   ['Content / Guest/MsgShow_Submit', ['implemented', '已由 saveGuestbook 白名单正文编辑替代']],
   ['Content / Guest/Msg_API', ['implemented', '已覆盖审核、取消审核、回收、恢复与带回复引用保护的永久删除']],
+  ['Content / Guest/CateList', ['implemented', '已由 Guestcate 资源列表、搜索和分类结构抽屉替代']],
+  ['Content / Guest/CateAdd', ['implemented', '已由留言分类专用表单替代']],
+  ['Content / Guest/CateAdd_Submit', ['implemented', '已覆盖原子 cateid、留言类型锁定与名称防重']],
+  ['Content / Guest/Cate_API', ['implemented', '已覆盖下级分类、留言与帖子引用保护的删除']],
+  ['Content / Bar/Default', ['implemented', '已由 Guestcate 贴吧类型列表入口替代']],
+  ['Content / Bar/CateList', ['implemented', '已提供贴吧分类列表、搜索、分页与父子结构编辑']],
+  ['Content / Bar/CateAdd', ['partial', '已提供贴吧分类表单;旧版发帖频率文件配置尚未迁移']],
+  ['Content / Bar/CateAdd_Submit', ['partial', '已覆盖原子编号、防重、父子循环和分类元数据;文件配置尚缺']],
+  ['Content / Bar/Cate_API', ['partial', '已覆盖删除引用保护及推荐/取消推荐;清空版块帖子尚未开放']],
 ]);
 
 const blockedDataModules = new Map([

+ 27 - 1
scripts/smoke-admin-functions.mjs

@@ -71,6 +71,7 @@ let adminCreatedUserId = '';
 let temporaryGroupObjectId = '';
 const temporaryNodeObjectIds = [];
 const temporarySpecialObjectIds = [];
+const temporaryGuestCategoryObjectIds = [];
 let temporaryModelObjectId = '';
 const temporaryModelFieldObjectIds = [];
 let registeredUserId = '';
@@ -269,6 +270,27 @@ try {
   temporarySpecialObjectIds.pop();
   await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'Special', objectId: rootSpecial.objectId });
   temporarySpecialObjectIds.pop();
+  const categorySuffix = Date.now().toString(36);
+  const rootCategory = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveGuestCategory', className: 'Guestcate', gtype: 1, parentId: 0, fields: { catename: `冒烟贴吧版块-${categorySuffix}`, desc: '分类生命周期验证', needLog: 1 } });
+  temporaryGuestCategoryObjectIds.push(String(rootCategory.objectId || ''));
+  const rootCateid = Number(rootCategory.cateid);
+  if (!temporaryGuestCategoryObjectIds[0] || !rootCateid || Number(rootCategory.gtype) !== 1 || rootCategory.sourceKey !== `[["Cateid",${rootCateid}]]`) throw new Error(`贴吧分类原子创建异常:${JSON.stringify(rootCategory)}`);
+  const childCategory = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveGuestCategory', className: 'Guestcate', gtype: 1, parentId: rootCateid, fields: { catename: `冒烟子版块-${categorySuffix}` } });
+  temporaryGuestCategoryObjectIds.push(String(childCategory.objectId || ''));
+  const childCateid = Number(childCategory.cateid);
+  if (!temporaryGuestCategoryObjectIds[1] || !childCateid || Number(childCategory.parentId) !== rootCateid) throw new Error('贴吧子分类创建失败');
+  const guestCategory = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveGuestCategory', className: 'Guestcate', gtype: 0, parentId: 0, fields: { catename: `冒烟留言分类-${categorySuffix}`, status: 1 } });
+  temporaryGuestCategoryObjectIds.push(String(guestCategory.objectId || ''));
+  await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveGuestCategory', className: 'Guestcate', gtype: 1, parentId: 0, fields: { catename: rootCategory.catename } }, 409);
+  await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveGuestCategory', className: 'Guestcate', objectId: rootCategory.objectId, gtype: 0, parentId: childCateid, fields: { ...rootCategory, catename: rootCategory.catename } }, 409);
+  const recommendedCategory = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'guestCategoryBatch', className: 'Guestcate', action: 'recommend', objectIds: [rootCategory.objectId] });
+  if (recommendedCategory.results?.[0]?.barInfo !== 'Recommend') throw new Error('贴吧分类推荐失败');
+  const categoryDeleteBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'Guestcate', objectId: rootCategory.objectId }, 409);
+  if (!String(categoryDeleteBlocked.message).includes('引用')) throw new Error('分类子级引用删除保护未返回明确原因');
+  for (const category of [childCategory, rootCategory, guestCategory]) {
+    await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'Guestcate', objectId: category.objectId });
+    temporaryGuestCategoryObjectIds.splice(temporaryGuestCategoryObjectIds.indexOf(String(category.objectId)), 1);
+  }
   const metadataSuffix = Date.now().toString(36);
   const temporaryModelId = 800000000 + Math.floor(Date.now() / 1000) % 100000000;
   const tempModel = await jsonRequest(`${PARSE_URL}/classes/Model`, { method: 'POST', body: JSON.stringify({ sourceKey: `cloud:smoke-model:${metadataSuffix}`, company, modelId: temporaryModelId, modelName: `冒烟模型-${metadataSuffix}`, tableName: `ZL_C_Smoke_${metadataSuffix}`, modelType: 1, itemName: '记录', itemUnit: '条', multiFlag: true }) }, true);
@@ -484,7 +506,7 @@ try {
   const productBlocked = await callLegacyFunction('', { action: 'product_list' }, 501);
   if (!String(productBlocked.retmsg).includes('ZL_Commodities')) throw new Error('缺商品主表的读取接口未明确阻塞');
 
-  console.log('Cloud smoke passed: admin auth/tenant/account lifecycle/group sync/node/special/model/guestbook lifecycle/content workflow/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
+  console.log('Cloud smoke passed: admin auth/tenant/account lifecycle/group sync/node/special/guest-category/model/guestbook lifecycle/content workflow/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
 } finally {
   if (contentHitObjectId) await jsonRequest(`${PARSE_URL}/classes/CommonModel/${contentHitObjectId}`, { method: 'PUT', body: JSON.stringify({ hits: contentHitOriginal }) }, true).catch((error) => {
     console.error(`公开内容浏览量恢复失败:${error.message}`);
@@ -510,6 +532,10 @@ try {
     console.error(`临时专题清理失败:${error.message}`);
     process.exitCode = 1;
   });
+  for (const categoryId of temporaryGuestCategoryObjectIds) if (categoryId) await jsonRequest(`${PARSE_URL}/classes/Guestcate/${categoryId}`, { method: 'DELETE' }, true).catch((error) => {
+    console.error(`临时留言/贴吧分类清理失败:${error.message}`);
+    process.exitCode = 1;
+  });
   for (const fieldId of temporaryModelFieldObjectIds) if (fieldId) await jsonRequest(`${PARSE_URL}/classes/ModelField/${fieldId}`, { method: 'DELETE' }, true).catch((error) => {
     console.error(`临时模型字段清理失败:${error.message}`);
     process.exitCode = 1;

+ 2 - 0
src/app/admin/legacy-admin-coverage.generated.spec.ts

@@ -17,6 +17,8 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('Model/ModelManage')?.status).toBe('implemented');
     expect(byKey.get('Model/ModelAdd_Submit')?.status).toBe('blocked-infrastructure');
     expect(byKey.get('Content / Guest/Msg_API')?.status).toBe('implemented');
+    expect(byKey.get('Content / Guest/CateAdd_Submit')?.status).toBe('implemented');
+    expect(byKey.get('Content / Bar/Cate_API')?.status).toBe('partial');
     expect(byKey.get('User/User_API')?.status).toBe('partial');
     expect(byKey.get('Order/OrderList')?.status).toBe('blocked-data');
     expect(byKey.get('Label / PowerShell/RunScript')?.status).toBe('blocked-infrastructure');

+ 18 - 18
src/app/admin/legacy-admin-coverage.generated.ts

@@ -384,36 +384,36 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content / Bar",
     "action": "Default",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Guestcate 贴吧类型列表入口替代"
   },
   {
     "module": "Content / Bar",
     "action": "CateList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已提供贴吧分类列表、搜索、分页与父子结构编辑"
   },
   {
     "module": "Content / Bar",
     "action": "CateAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已提供贴吧分类表单;旧版发帖频率文件配置尚未迁移"
   },
   {
     "module": "Content / Bar",
     "action": "CateAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已覆盖原子编号、防重、父子循环和分类元数据;文件配置尚缺"
   },
   {
     "module": "Content / Bar",
     "action": "Cate_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已覆盖删除引用保护及推荐/取消推荐;清空版块帖子尚未开放"
   },
   {
     "module": "Content / Bar",
@@ -2659,29 +2659,29 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content / Guest",
     "action": "CateList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Guestcate 资源列表、搜索和分类结构抽屉替代"
   },
   {
     "module": "Content / Guest",
     "action": "CateAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由留言分类专用表单替代"
   },
   {
     "module": "Content / Guest",
     "action": "CateAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已覆盖原子 cateid、留言类型锁定与名称防重"
   },
   {
     "module": "Content / Guest",
     "action": "Cate_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已覆盖下级分类、留言与帖子引用保护的删除"
   },
   {
     "module": "Content / Guest",

+ 7 - 0
src/app/admin/pages/admin-resource.component.html

@@ -101,6 +101,13 @@
             }
           </section>
         }
+        @if (className() === 'Guestcate') {
+          <section class="content-controls guest-category-controls">
+            <header><div><strong>留言 / 贴吧分类结构</strong><span>专用云函数维护原子 cateid、分类类型、父子循环和引用保护</span></div>@if (draftId()) { <b>ID {{ draft()['cateid'] }}</b> }</header>
+            <label><span>分类用途</span><select [disabled]="!!draftId()" [ngModel]="guestCategoryType()" (ngModelChange)="guestCategoryType.set(+$event); targetGuestCategoryParentId.set(0)"><option [ngValue]="0">留言分类</option><option [ngValue]="1">贴吧版块</option></select></label>
+            <label><span>父分类 ID <small>留言分类固定为 0</small></span><input type="number" min="0" [disabled]="guestCategoryType() === 0" [ngModel]="targetGuestCategoryParentId()" (ngModelChange)="targetGuestCategoryParentId.set(+$event)" /></label>
+          </section>
+        }
         @if (className() === 'Guestbook' && draftId()) {
           <section class="content-controls guestbook-controls">
             <header><div><strong>留言审核与管理员回复</strong><span>专用云函数维护旧 status、父留言关系和回复 GID</span></div><b [class.recycled]="selectedGuestbookStatus() === -2">{{ selectedGuestbookStatus() === 99 ? '已审核' : selectedGuestbookStatus() === -2 ? '回收站' : '待审核' }}</b></header>

+ 17 - 0
src/app/admin/pages/admin-resource.component.spec.ts

@@ -286,6 +286,23 @@ describe('AdminResourceComponent', () => {
     expect(component.guestbookActionMessage()).toContain('已发布');
   });
 
+  it('creates a guest category through the atomic category operation', async () => {
+    component.className.set('Guestcate');
+    component.schema.set({ className: 'Guestcate', label: '留言分类', writable: true, supportsSoftDelete: false, fields: [] });
+    component.draft.set({ catename: '新贴吧版块', gtype: 0, parentId: 999, cateid: 999 });
+    component.guestCategoryType.set(1);
+    component.targetGuestCategoryParentId.set(7);
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveGuestCategory'
+      ? { objectId: 'category-object', cateid: 20, catename: '新贴吧版块', gtype: 1, parentId: 7 }
+      : { className: 'Guestcate', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveGuestCategory', {
+      className: 'Guestcate', objectId: undefined, fields: { catename: '新贴吧版块', gtype: 0, parentId: 999, cateid: 999 }, gtype: 1, parentId: 7,
+    }]);
+  });
+
   it('updates content workflow state through the dedicated batch operation', async () => {
     component.className.set('CommonModel');
     component.draftId.set('content-object');

+ 9 - 4
src/app/admin/pages/admin-resource.component.ts

@@ -65,6 +65,8 @@ export class AdminResourceComponent implements OnInit {
   readonly updatingGuestbook = signal(false);
   readonly guestbookActionError = signal('');
   readonly guestbookActionMessage = signal('');
+  readonly guestCategoryType = signal(0);
+  readonly targetGuestCategoryParentId = signal(0);
   readonly selectedIds = signal<string[]>([]);
   readonly bulkError = signal('');
   readonly bulkMessage = signal('');
@@ -103,17 +105,17 @@ export class AdminResourceComponent implements OnInit {
     finally { this.loading.set(false); }
   }
 
-  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); this.resetSpecialActions(); this.resetGuestbookActions(); this.editorOpen.set(true); }
+  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); this.resetSpecialActions(); this.resetGuestbookActions(); this.resetGuestCategoryActions(); this.editorOpen.set(true); }
   async openEdit(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     this.error.set('');
     try {
       const detail = await this.functions.admin<Record<string, unknown>>('get', { className: this.className(), objectId });
-      this.draftId.set(objectId); this.draft.set(detail); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(detail); this.resetContentActions(detail); this.resetNodeActions(detail); this.resetSpecialActions(detail); this.resetGuestbookActions(detail); this.editorOpen.set(true);
+      this.draftId.set(objectId); this.draft.set(detail); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(detail); this.resetContentActions(detail); this.resetNodeActions(detail); this.resetSpecialActions(detail); this.resetGuestbookActions(detail); this.resetGuestCategoryActions(detail); this.editorOpen.set(true);
       if (this.className() === '_User') await this.loadUserGroups();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '详情加载失败'); }
   }
-  closeEditor(): void { this.editorOpen.set(false); this.draft.set({}); this.draftId.set(''); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); this.resetSpecialActions(); this.resetGuestbookActions(); }
+  closeEditor(): void { this.editorOpen.set(false); this.draft.set({}); this.draftId.set(''); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); this.resetSpecialActions(); this.resetGuestbookActions(); this.resetGuestCategoryActions(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -149,10 +151,12 @@ export class AdminResourceComponent implements OnInit {
           : this.className() === 'Special' ? 'saveSpecial'
             : this.className() === 'Model' ? 'saveModelMetadata'
               : this.className() === 'ModelField' ? 'saveModelFieldMetadata'
-                : this.className() === 'Guestbook' ? 'saveGuestbook' : 'save';
+                : this.className() === 'Guestbook' ? 'saveGuestbook'
+                  : this.className() === 'Guestcate' ? 'saveGuestCategory' : 'save';
       const payload: Record<string, unknown> = { className: this.className(), objectId: this.draftId() || undefined, fields: this.draft() };
       if (this.className() === 'Node') payload['parentId'] = this.targetNodeParentId();
       if (this.className() === 'Special') payload['pid'] = this.targetSpecialParentId();
+      if (this.className() === 'Guestcate') { payload['gtype'] = this.guestCategoryType(); payload['parentId'] = this.targetGuestCategoryParentId(); }
       await this.functions.admin(operation, payload);
       this.closeEditor(); await this.load();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '保存失败'); }
@@ -427,4 +431,5 @@ export class AdminResourceComponent implements OnInit {
   private resetNodeActions(detail: Record<string, unknown> = {}): void { this.targetNodeParentId.set(Number(detail['parentId']) || 0); this.nodeActionError.set(''); this.nodeActionMessage.set(''); }
   private resetSpecialActions(detail: Record<string, unknown> = {}): void { this.targetSpecialParentId.set(Number(detail['pid']) || 0); this.specialActionError.set(''); this.specialActionMessage.set(''); }
   private resetGuestbookActions(detail: Record<string, unknown> = {}): void { this.selectedGuestbookStatus.set(Number(detail['status'] ?? 99)); this.guestbookReplyTitle.set(''); this.guestbookReplyContent.set(''); this.guestbookActionError.set(''); this.guestbookActionMessage.set(''); }
+  private resetGuestCategoryActions(detail: Record<string, unknown> = {}): void { this.guestCategoryType.set(Number(detail['gtype']) || 0); this.targetGuestCategoryParentId.set(Number(detail['parentId']) || 0); }
 }