Эх сурвалжийг харах

feat: migrate admin guestbook workflow

彭峰 1 сар өмнө
parent
commit
287cca8d90

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

@@ -25,8 +25,9 @@
 - 旧 `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` 分类编号、类型、父子和删除引用流程已先从通用创建/删除中封闭。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 20 项已实现、26 项部分实现、112 项数据阻塞、189 项基础设施阻塞、695 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 27 项已实现、26 项部分实现、112 项数据阻塞、189 项基础设施阻塞、688 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

+ 67 - 2
scripts/deploy-admin-functions.mjs

@@ -26,7 +26,9 @@ const CLASS_SYSTEM_FIELDS = {
   Node: new Set(['nodeId','parentId','depth','child','orderId','zstatus','sourceKey','cuser','cuname','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'])
+  ModelField: new Set(['fieldId','modelId','fieldName','fieldType','sourceKey','sysType','orderId']),
+  Guestbook: new Set(['gid','parentid','cateid','userid','status','sourceKey','gdate','ip']),
+  Guestcate: new Set(['cateid','parentId','gtype','sourceKey','orderId'])
 };
 
 function inputOf(request) {
@@ -544,6 +546,65 @@ async function handler(request, response) {
       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 === 'saveGuestbook') {
+      const objectId = String(input.objectId || '');
+      if (!objectId) fail(400, '普通留言新增必须走前台留言云函数;后台只能通过专用回复流程新增');
+      const fields = await schemaFor('Guestbook');
+      const query = new Parse.Query('Guestbook'); 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 title = String(payload.title == null ? target.get('title') || '' : payload.title).trim();
+      const content = String(payload.tcontent == null ? target.get('tcontent') || '' : payload.tcontent);
+      if (!title || title.length > 200) fail(400, '留言标题长度应为 1 至 200 位');
+      if (content.length > 200000) fail(400, '留言内容过长');
+      target.set('title', title); target.set('tcontent', content);
+      await target.save(null, { useMasterKey: true }); await audit(context, 'update-guestbook', 'Guestbook', objectId);
+      return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'guestbookReply') {
+      const parentObjectId = String(input.parentObjectId || '');
+      const title = String(input.title || '').trim() || '[管理员回复]';
+      const content = String(input.content || '');
+      if (!parentObjectId) fail(400, '缺少原留言');
+      if (title.length > 200 || !content.trim() || content.length > 200000) fail(400, '回复标题或内容无效');
+      const fields = await schemaFor('Guestbook');
+      const query = new Parse.Query('Guestbook'); applyTenant(query, fields, context, input.companyId); const parent = await query.get(parentObjectId, { useMasterKey: true });
+      const company = parent.get('company') || context.company; const companyId = pointerId(company); const parentGid = Number(parent.get('gid')) || 0; const cateid = Number(parent.get('cateid')) || 0;
+      if (!companyId || !parentGid) fail(409, '原留言缺少帐套或旧系统编号');
+      const reply = new Parse.Object('Guestbook');
+      reply.set('sourceKey', 'cloud:admin-guestbook-reply:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); reply.set('company', company); reply.set('parentid', parentGid); reply.set('cateid', cateid); reply.set('userid', Number(context.current.get('legacyUserId')) || 0); reply.set('title', title); reply.set('tcontent', content); reply.set('status', 99); reply.set('gdate', new Date()); reply.set('ip', 'admin-cloud');
+      try { await reply.save(null, { useMasterKey: true }); }
+      catch (error) { fail(422, '管理员回复初始写入失败: ' + String(error && error.message || error)); }
+      try {
+        const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-guestbook-gid:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("gid"),0)+1 AS id FROM "Guestbook",lock_row WHERE "company"=$1 AND COALESCE("gid",0)>0) UPDATE "Guestbook" SET "gid"=next_id.id,"sourceKey"=\'[["Gid",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, reply.id]);
+        if (!Number(rows[0] && rows[0].id)) throw new Error('无法分配回复编号');
+      } catch (error) { await reply.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '管理员回复结构写入失败: ' + String(error && error.message || error)); }
+      await reply.fetch({ useMasterKey: true }); await audit({ ...context, company }, 'reply-guestbook', 'Guestbook', reply.id);
+      return response.json({ success: true, data: serializeObject(reply) });
+    }
+    if (operation === 'guestbookBatch') {
+      const action = String(input.action || '');
+      if (!['audit','unaudit','recycle','recover','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('Guestbook');
+      const query = new Parse.Query('Guestbook'); 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 === 'purge') {
+        const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))]; const gids = targets.map((target) => Number(target.get('gid')) || 0);
+        if (companyIds.length !== 1 || gids.some((gid) => gid < 1)) fail(409, '留言缺少有效帐套或编号');
+        const children = await Psql.one('SELECT COUNT(*)::int AS count FROM "Guestbook" WHERE "company"=$1 AND "parentid"=ANY($2::numeric[]) AND NOT ("gid"=ANY($2::numeric[]))', [companyIds[0], gids]);
+        if (Number(children.count) > 0) fail(409, '留言仍有回复,不能永久删除');
+        await Parse.Object.destroyAll(targets, { useMasterKey: true });
+      } else {
+        const status = action === 'recycle' ? -2 : action === 'unaudit' ? 0 : 99;
+        for (const target of targets) target.set('status', status);
+        await Parse.Object.saveAll(targets, { useMasterKey: true });
+      }
+      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 === 'contentBatch') {
       const action = String(input.action || '');
       if (!['status','recycle','recover','move'].includes(action)) fail(400, '不支持的内容批量操作');
@@ -583,7 +644,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'].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','Guestcate'].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));
@@ -607,6 +668,8 @@ async function handler(request, response) {
       if (className === 'Node') fail(400, '栏目必须走专用保存流程');
       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 === '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);
@@ -619,11 +682,13 @@ 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 === '_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 (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 (className === 'Guestbook') { object.set('status', -2); await object.save(null, { useMasterKey: true }); await audit(context, 'guestbook-recycle', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: true, status: -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) } });

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

@@ -60,6 +60,13 @@ const explicit = new Map([
   ['Model/FieldAdd', ['blocked-infrastructure', '新增字段需要 PostgreSQL ALTER TABLE 与旧字段控件模板']],
   ['Model/FieldAdd_Submit', ['blocked-infrastructure', '目标托管环境未开放任意物理列 DDL 与旧控件生成器']],
   ['Model/Field_API', ['partial', '已覆盖字段顺序更新;字段删除因历史数据与物理列 DDL 被阻塞']],
+  ['Content / Guest/Default', ['implemented', '已由 Guestbook 资源页与后台导航替代']],
+  ['Content / Guest/MsgList', ['implemented', '已提供帐套留言列表、标题搜索、分页与批量审核工作流']],
+  ['Content / Guest/ReplyGuest', ['implemented', '已由留言详情中的管理员回复面板替代']],
+  ['Content / Guest/EBtnSubmit', ['implemented', '已由 guestbookReply 原子分配回复 GID 并继承分类/父留言关系']],
+  ['Content / Guest/MsgShow', ['implemented', '已由留言详情抽屉替代']],
+  ['Content / Guest/MsgShow_Submit', ['implemented', '已由 saveGuestbook 白名单正文编辑替代']],
+  ['Content / Guest/Msg_API', ['implemented', '已覆盖审核、取消审核、回收、恢复与带回复引用保护的永久删除']],
 ]);
 
 const blockedDataModules = new Map([

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

@@ -82,6 +82,7 @@ let coachLegacyId = 0;
 let temporaryAppointmentId = '';
 let temporaryAppointmentObjectId = '';
 let temporaryGuestbookId = '';
+let temporaryGuestbookReplyId = '';
 let contentHitObjectId = '';
 let contentHitOriginal = 0;
 const temporaryLessonIds = [];
@@ -284,6 +285,20 @@ try {
   const reorderedFields = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'modelFieldOrder', className: 'ModelField', items: [{ objectId: temporaryModelFieldObjectIds[0], orderId: 2 }, { objectId: temporaryModelFieldObjectIds[1], orderId: 1 }] });
   if (reorderedFields.updated !== 2 || Number(reorderedFields.modelId) !== temporaryModelId) throw new Error('模型字段排序失败');
   await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'ModelField', objectId: temporaryModelFieldObjectIds[0] }, 501);
+  const updatedGuestbook = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveGuestbook', className: 'Guestbook', objectId: temporaryGuestbookId, fields: { title: feedbackModel.Title, tcontent: '管理员规范化编辑', gid: 999, status: -2, cateid: 999 } });
+  if (updatedGuestbook.tcontent !== '管理员规范化编辑' || Number(updatedGuestbook.gid) === 999 || Number(updatedGuestbook.cateid) === 999 || Number(updatedGuestbook.status) === -2) throw new Error(`留言结构字段隔离异常:${JSON.stringify(updatedGuestbook)}`);
+  const guestUnaudited = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'guestbookBatch', className: 'Guestbook', action: 'unaudit', objectIds: [temporaryGuestbookId] });
+  if (Number(guestUnaudited.results?.[0]?.status) !== 0) throw new Error('留言取消审核失败');
+  const guestRecycled = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'guestbookBatch', className: 'Guestbook', action: 'recycle', objectIds: [temporaryGuestbookId] });
+  if (Number(guestRecycled.results?.[0]?.status) !== -2) throw new Error('留言回收失败');
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'guestbookBatch', className: 'Guestbook', action: 'recover', objectIds: [temporaryGuestbookId] });
+  const guestReply = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'guestbookReply', className: 'Guestbook', parentObjectId: temporaryGuestbookId, title: '管理员冒烟回复', content: '云函数回复正文' });
+  temporaryGuestbookReplyId = String(guestReply.objectId || '');
+  if (!temporaryGuestbookReplyId || Number(guestReply.parentid) !== Number(updatedGuestbook.gid) || Number(guestReply.status) !== 99 || !Number(guestReply.gid)) throw new Error(`管理员留言回复异常:${JSON.stringify(guestReply)}`);
+  const guestDeleteBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'guestbookBatch', className: 'Guestbook', action: 'purge', objectIds: [temporaryGuestbookId] }, 409);
+  if (!String(guestDeleteBlocked.message).includes('仍有回复')) throw new Error('留言回复引用删除保护未返回明确原因');
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'guestbookBatch', className: 'Guestbook', action: 'purge', objectIds: [temporaryGuestbookReplyId] });
+  temporaryGuestbookReplyId = '';
 
   const contentPending = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'contentBatch', className: 'CommonModel', action: 'status', objectIds: [temporaryAppointmentCommonObjectId], status: 0 });
   if (contentPending.updated !== 1 || Number(contentPending.results?.[0]?.status) !== 0) throw new Error('内容待审核状态更新失败');
@@ -469,7 +484,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 metadata 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/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}`);
@@ -507,6 +522,10 @@ try {
     console.error(`临时留言清理失败:${error.message}`);
     process.exitCode = 1;
   });
+  if (temporaryGuestbookReplyId) await jsonRequest(`${PARSE_URL}/classes/Guestbook/${temporaryGuestbookReplyId}`, { method: 'DELETE' }, true).catch((error) => {
+    console.error(`临时管理员留言回复清理失败:${error.message}`);
+    process.exitCode = 1;
+  });
   for (const lessonId of temporaryLessonIds) await jsonRequest(`${PARSE_URL}/classes/LessonRecord/${lessonId}`, { method: 'DELETE' }, true).catch((error) => {
     console.error(`临时陪练课次清理失败:${error.message}`);
     process.exitCode = 1;

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

@@ -16,6 +16,7 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('Content / Node/UnionSpecial_Merge')?.status).toBe('blocked-data');
     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('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');

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

@@ -2652,8 +2652,8 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content / Guest",
     "action": "Default",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Guestbook 资源页与后台导航替代"
   },
   {
     "module": "Content / Guest",
@@ -2687,43 +2687,43 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content / Guest",
     "action": "MsgList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已提供帐套留言列表、标题搜索、分页与批量审核工作流"
   },
   {
     "module": "Content / Guest",
     "action": "ReplyGuest",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由留言详情中的管理员回复面板替代"
   },
   {
     "module": "Content / Guest",
     "action": "EBtnSubmit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 guestbookReply 原子分配回复 GID 并继承分类/父留言关系"
   },
   {
     "module": "Content / Guest",
     "action": "MsgShow",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由留言详情抽屉替代"
   },
   {
     "module": "Content / Guest",
     "action": "MsgShow_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 saveGuestbook 白名单正文编辑替代"
   },
   {
     "module": "Content / Guest",
     "action": "Msg_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已覆盖审核、取消审核、回收、恢复与带回复引用保护的永久删除"
   },
   {
     "module": "Helper",

+ 16 - 2
src/app/admin/pages/admin-resource.component.html

@@ -17,8 +17,10 @@
       } @else if (className() === 'Node') {
         <button class="danger-action" type="button" [disabled]="updatingNode()" (click)="applyBulkNodeAction('recycle')">回收</button><button type="button" [disabled]="updatingNode()" (click)="applyBulkNodeAction('recover')">恢复</button>
         <input class="bulk-node" type="number" min="0" [ngModel]="targetNodeParentId()" (ngModelChange)="targetNodeParentId.set(+$event)" placeholder="父节点 ID" /><button type="button" [disabled]="updatingNode()" (click)="applyBulkNodeAction('move')">移动栏目</button>
-      } @else {
+      } @else if (className() === 'Special') {
         <input class="bulk-node" type="number" min="0" [ngModel]="targetSpecialParentId()" (ngModelChange)="targetSpecialParentId.set(+$event)" placeholder="父专题 ID" /><button type="button" [disabled]="updatingSpecial()" (click)="applyBulkSpecialMove()">移动专题</button>
+      } @else {
+        <button type="button" [disabled]="updatingGuestbook()" (click)="applyBulkGuestbookAction('audit')">审核</button><button type="button" [disabled]="updatingGuestbook()" (click)="applyBulkGuestbookAction('unaudit')">取消审核</button><button class="danger-action" type="button" [disabled]="updatingGuestbook()" (click)="applyBulkGuestbookAction('recycle')">回收</button><button type="button" [disabled]="updatingGuestbook()" (click)="applyBulkGuestbookAction('recover')">恢复</button>
       }
     </div>
   }
@@ -30,7 +32,7 @@
   } @else {
     <div class="table-scroll"><table><thead><tr>@if (bulkEnabled()) { <th class="select-col"><input type="checkbox" aria-label="全选当前页" [checked]="allPageSelected()" (change)="togglePageSelection($any($event.target).checked)" /></th> }@for (field of columns(); track field.name) { <th>{{ field.name }}</th> }<th class="actions">操作</th></tr></thead><tbody>
       @for (row of page()?.results || []; track row['objectId']) {
-        <tr>@if (bulkEnabled()) { <td class="select-col"><input type="checkbox" [attr.aria-label]="'选择 ' + row['objectId']" [checked]="isSelected(row['objectId'])" (change)="toggleSelection('' + row['objectId'], $any($event.target).checked)" /></td> }@for (field of columns(); track field.name) { <td [title]="display(row[field.name])">{{ display(row[field.name]) }}</td> }<td class="actions"><button type="button" [attr.aria-label]="schema()?.writable ? '编辑' : '查看'" (click)="openEdit(row)"><lucide-icon [img]="schema()?.writable ? icons.Pencil : icons.Eye" [size]="16" /></button>@if (schema()?.writable && !['Model', 'ModelField'].includes(className())) { <button class="danger" type="button" [attr.aria-label]="['CommonModel', 'Node'].includes(className()) ? '移入回收站' : '删除'" (click)="remove(row)"><lucide-icon [img]="icons.Trash2" [size]="16" /></button> }</td></tr>
+        <tr>@if (bulkEnabled()) { <td class="select-col"><input type="checkbox" [attr.aria-label]="'选择 ' + row['objectId']" [checked]="isSelected(row['objectId'])" (change)="toggleSelection('' + row['objectId'], $any($event.target).checked)" /></td> }@for (field of columns(); track field.name) { <td [title]="display(row[field.name])">{{ display(row[field.name]) }}</td> }<td class="actions"><button type="button" [attr.aria-label]="schema()?.writable ? '编辑' : '查看'" (click)="openEdit(row)"><lucide-icon [img]="schema()?.writable ? icons.Pencil : icons.Eye" [size]="16" /></button>@if (schema()?.writable && !['Model', 'ModelField'].includes(className())) { <button class="danger" type="button" [attr.aria-label]="['CommonModel', 'Node', 'Guestbook'].includes(className()) ? '移入回收站' : '删除'" (click)="remove(row)"><lucide-icon [img]="icons.Trash2" [size]="16" /></button> }</td></tr>
       } @empty { <tr><td class="empty-cell" [attr.colspan]="columns().length + (bulkEnabled() ? 2 : 1)">没有符合条件的数据</td></tr> }
     </tbody></table></div>
     <footer class="pagination"><span>第 {{ page()?.page || 1 }} 页,共 {{ pageCount() }} 页</span><div><button type="button" [disabled]="(page()?.page || 1) <= 1 || loading()" (click)="load((page()?.page || 1) - 1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" />上一页</button><button type="button" [disabled]="(page()?.page || 1) * (page()?.pageSize || 20) >= (page()?.total || 0) || loading()" (click)="load((page()?.page || 1) + 1)">下一页<lucide-icon [img]="icons.ChevronRight" [size]="17" /></button></div></footer>
@@ -99,6 +101,18 @@
             }
           </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>
+            <button type="button" [disabled]="updatingGuestbook()" (click)="applyGuestbookAction('audit')">审核</button><button type="button" [disabled]="updatingGuestbook()" (click)="applyGuestbookAction('unaudit')">取消审核</button>
+            <div class="content-recycle">@if (selectedGuestbookStatus() === -2) { <button type="button" [disabled]="updatingGuestbook()" (click)="applyGuestbookAction('recover')">从回收站恢复</button> } @else { <button class="danger-action" type="button" [disabled]="updatingGuestbook()" (click)="applyGuestbookAction('recycle')">移入回收站</button> }</div>
+            <label><span>回复标题 <small>选填</small></span><input [ngModel]="guestbookReplyTitle()" (ngModelChange)="guestbookReplyTitle.set($event)" placeholder="[管理员回复]" /></label>
+            <label><span>回复内容 <small>必填</small></span><textarea rows="5" [ngModel]="guestbookReplyContent()" (ngModelChange)="guestbookReplyContent.set($event)"></textarea></label>
+            <button type="button" [disabled]="updatingGuestbook() || !guestbookReplyContent().trim()" (click)="replyGuestbook()">发布管理员回复</button>
+            @if (guestbookActionError()) { <div class="content-action-status error">{{ guestbookActionError() }}</div> }
+            @if (guestbookActionMessage()) { <div class="content-action-status success">{{ guestbookActionMessage() }}</div> }
+          </section>
+        }
         @if (className() === '_User' && draftId()) {
           <section class="user-controls">
             <header><div><strong>账号状态与用户组</strong><span>专用云函数会同步旧版 State / GroupID;停用账号会立即撤销会话</span></div><b [class.disabled]="draft()['isDisabled'] === true">{{ draft()['isDisabled'] === true ? '已停用' : '正常' }}</b></header>

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

@@ -242,6 +242,50 @@ describe('AdminResourceComponent', () => {
     }]);
   });
 
+  it('updates an existing guestbook entry through the dedicated editor', async () => {
+    component.className.set('Guestbook');
+    component.draftId.set('guest-object');
+    component.schema.set({ className: 'Guestbook', label: '留言', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.draft.set({ objectId: 'guest-object', gid: 10, status: 99, title: '留言标题', tcontent: '正文' });
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveGuestbook'
+      ? { objectId: 'guest-object', gid: 10, status: 99, title: '留言标题', tcontent: '正文' }
+      : { className: 'Guestbook', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveGuestbook', {
+      className: 'Guestbook', objectId: 'guest-object', fields: { objectId: 'guest-object', gid: 10, status: 99, title: '留言标题', tcontent: '正文' },
+    }]);
+  });
+
+  it('audits a guestbook entry through the workflow operation', async () => {
+    component.className.set('Guestbook');
+    component.draftId.set('guest-object');
+    component.draft.set({ objectId: 'guest-object', gid: 10, status: 0 });
+    component.schema.set({ className: 'Guestbook', label: '留言', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    functions.admin.and.callFake(async (operation: string) => operation === 'guestbookBatch'
+      ? { action: 'audit', updated: 1, results: [{ objectId: 'guest-object', gid: 10, status: 99 }] }
+      : { className: 'Guestbook', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.applyGuestbookAction('audit');
+
+    expect(functions.admin.calls.first().args).toEqual(['guestbookBatch', { className: 'Guestbook', action: 'audit', objectIds: ['guest-object'] }]);
+    expect(component.selectedGuestbookStatus()).toBe(99);
+  });
+
+  it('publishes an admin reply through the parent-safe cloud operation', async () => {
+    component.className.set('Guestbook');
+    component.draftId.set('guest-object');
+    component.guestbookReplyTitle.set('回复标题');
+    component.guestbookReplyContent.set('回复正文');
+    functions.admin.and.resolveTo({ objectId: 'reply-object', gid: 11, parentid: 10, status: 99 });
+
+    await component.replyGuestbook();
+
+    expect(functions.admin).toHaveBeenCalledWith('guestbookReply', { className: 'Guestbook', parentObjectId: 'guest-object', title: '回复标题', content: '回复正文' });
+    expect(component.guestbookActionMessage()).toContain('已发布');
+  });
+
   it('updates content workflow state through the dedicated batch operation', async () => {
     component.className.set('CommonModel');
     component.draftId.set('content-object');

+ 48 - 6
src/app/admin/pages/admin-resource.component.ts

@@ -59,6 +59,12 @@ export class AdminResourceComponent implements OnInit {
   readonly updatingSpecial = signal(false);
   readonly specialActionError = signal('');
   readonly specialActionMessage = signal('');
+  readonly selectedGuestbookStatus = signal(99);
+  readonly guestbookReplyTitle = signal('');
+  readonly guestbookReplyContent = signal('');
+  readonly updatingGuestbook = signal(false);
+  readonly guestbookActionError = signal('');
+  readonly guestbookActionMessage = signal('');
   readonly selectedIds = signal<string[]>([]);
   readonly bulkError = signal('');
   readonly bulkMessage = signal('');
@@ -70,7 +76,7 @@ export class AdminResourceComponent implements OnInit {
     return fields.map((field, index) => ({ field, index })).sort((a, b) => rank(a.field.name) - rank(b.field.name) || a.index - b.index).map(({ field }) => field).slice(0, 7);
   });
   readonly pageCount = computed(() => Math.max(1, Math.ceil((this.page()?.total ?? 0) / (this.page()?.pageSize || 20))));
-  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook'].includes(this.className()));
   readonly allPageSelected = computed(() => {
     const ids = (this.page()?.results || []).map((row) => String(row['objectId'] || '')).filter(Boolean);
     return Boolean(ids.length) && ids.every((id) => this.selectedIds().includes(id));
@@ -97,17 +103,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.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.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.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.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(); }
+  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(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -142,7 +148,8 @@ export class AdminResourceComponent implements OnInit {
         : this.className() === 'Node' ? 'saveNode'
           : this.className() === 'Special' ? 'saveSpecial'
             : this.className() === 'Model' ? 'saveModelMetadata'
-              : this.className() === 'ModelField' ? 'saveModelFieldMetadata' : 'save';
+              : this.className() === 'ModelField' ? 'saveModelFieldMetadata'
+                : this.className() === 'Guestbook' ? 'saveGuestbook' : '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();
@@ -353,9 +360,43 @@ export class AdminResourceComponent implements OnInit {
     } catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量专题移动失败'); }
     finally { this.updatingSpecial.set(false); }
   }
+  async applyGuestbookAction(action: 'audit' | 'unaudit' | 'recycle' | 'recover'): Promise<void> {
+    this.guestbookActionError.set(''); this.guestbookActionMessage.set('');
+    if (this.className() !== 'Guestbook' || !this.draftId()) { this.guestbookActionError.set('只能管理现有留言'); return; }
+    if (action === 'recycle' && !confirm('确认将该留言移入回收站?')) return;
+    this.updatingGuestbook.set(true);
+    try {
+      const result = await this.functions.admin<{ results: Record<string, unknown>[] }>('guestbookBatch', { className: 'Guestbook', action, objectIds: [this.draftId()] });
+      if (result.results?.[0]) { this.draft.set(result.results[0]); this.selectedGuestbookStatus.set(Number(result.results[0]['status']) || 0); }
+      this.guestbookActionMessage.set(action === 'audit' ? '留言已审核' : action === 'unaudit' ? '留言已取消审核' : action === 'recycle' ? '留言已移入回收站' : '留言已恢复'); await this.load();
+    } catch (error) { this.guestbookActionError.set(error instanceof Error ? error.message : '留言状态更新失败'); }
+    finally { this.updatingGuestbook.set(false); }
+  }
+  async replyGuestbook(): Promise<void> {
+    this.guestbookActionError.set(''); this.guestbookActionMessage.set('');
+    const content = this.guestbookReplyContent();
+    if (this.className() !== 'Guestbook' || !this.draftId() || !content.trim()) { this.guestbookActionError.set('请输入回复内容'); return; }
+    this.updatingGuestbook.set(true);
+    try {
+      await this.functions.admin('guestbookReply', { className: 'Guestbook', parentObjectId: this.draftId(), title: this.guestbookReplyTitle().trim(), content });
+      this.guestbookReplyTitle.set(''); this.guestbookReplyContent.set(''); this.guestbookActionMessage.set('管理员回复已发布');
+    } catch (error) { this.guestbookActionError.set(error instanceof Error ? error.message : '管理员回复失败'); }
+    finally { this.updatingGuestbook.set(false); }
+  }
+  async applyBulkGuestbookAction(action: 'audit' | 'unaudit' | 'recycle' | 'recover'): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'Guestbook' || !objectIds.length) { this.bulkError.set('请先选择留言'); return; }
+    if (action === 'recycle' && !confirm(`确认将选中的 ${objectIds.length} 条留言移入回收站?`)) return;
+    this.updatingGuestbook.set(true);
+    try {
+      const result = await this.functions.admin<{ updated: number }>('guestbookBatch', { className: 'Guestbook', action, objectIds });
+      await this.load(); this.bulkMessage.set(`已处理 ${result.updated} 条留言`);
+    } catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量留言操作失败'); }
+    finally { this.updatingGuestbook.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
-    const verb = ['CommonModel', 'Node'].includes(this.className()) ? '移入回收站' : '删除';
+    const verb = ['CommonModel', 'Node', 'Guestbook'].includes(this.className()) ? '移入回收站' : '删除';
     if (!objectId || !confirm(`确认${verb} ${this.className()} / ${objectId}?`)) return;
     try { await this.functions.admin('delete', { className: this.className(), objectId }); await this.load(); }
     catch (error) { this.error.set(error instanceof Error ? error.message : '删除失败'); }
@@ -385,4 +426,5 @@ export class AdminResourceComponent implements OnInit {
   private resetContentActions(detail: Record<string, unknown> = {}): void { this.selectedContentStatus.set(Number(detail['status']) || 0); this.targetContentNodeId.set(Number(detail['nodeId']) || 0); this.contentActionError.set(''); this.contentActionMessage.set(''); }
   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(''); }
 }