Răsfoiți Sursa

feat: migrate admin special lifecycle

彭峰 2 luni în urmă
părinte
comite
d8c8eaa72f

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

@@ -23,8 +23,9 @@
 - 旧 `GroupAdd/GroupAdd_Submit` 已迁为专用 `saveGroup`:新增组使用 PostgreSQL 事务锁在帐套内原子分配只读 `groupId`,校验同名组、父组归属与层级循环;通用保存不能绕过。删除组前会拒绝仍被用户或下级组引用的记录。
 - 旧 `ContentManage_Status`、`ContentManage_Del`、回收站恢复与 `ContentMove_Submit` 已迁为专用 `contentBatch`:支持每批 1–100 条内容、限定旧状态 `-3/-2/-1/0/99`、校验目标节点帐套并保留 addon 关联。`CommonModel` 的 `generalId/modelId/nodeId/itemId/tableName/status/sourceKey/orderId` 改为通用只读;新增按钮关闭,避免只写主表却遗漏模型附表。
 - 旧 `NodeAdd_Submit`、`Node_API` 与 `UnionNode_Move` 的核心栏目生命周期已迁为专用 `saveNode/nodeBatch`:新增栏目以临时来源键完成 Parse 初始写入,再使用 PostgreSQL 事务锁原子分配 `nodeId/orderId` 并替换为旧 `sourceKey`;保存会校验同级名称/目录重复、父栏目帐套和回收状态,移动会阻断自身/下级循环并递归修正所有后代深度。回收、恢复和每批 1–100 个栏目迁移已接入 Angular;永久删除仅在没有内容、下级栏目、节点权限和模型模板引用时开放,通用保存与删除不能绕过。
-- `_User`、`CommonModel` 与 `Node` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,以及回收、恢复、移动栏目;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 14 项已实现、19 项部分实现、111 项数据阻塞、185 项基础设施阻塞、713 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 旧 `SpecialAdd_Save` 与 `UnionSpecial_Move` 已迁为专用 `saveSpecial/specialBatch`:新增专题使用临时来源键和 PostgreSQL 事务锁原子分配 `specId/orderId`,保存时校验全帐套名称/目录唯一、父专题归属与层级循环;删除会拒绝仍有下级专题的记录。旧 `UnionSpecial_Merge` 被明确列为数据阻塞:`CommonModel.specialId` 只存在于 Parse Schema、PostgreSQL 物理列缺失,商品主表也未迁移,无法安全改写内容/商品专题关系。
+- `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 18 项已实现、21 项部分实现、112 项数据阻塞、185 项基础设施阻塞、706 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

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

@@ -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 CLASS_SYSTEM_FIELDS = {
   CommonModel: new Set(['generalId','modelId','nodeId','itemId','tableName','status','isDeleted','sourceKey','orderId']),
-  Node: new Set(['nodeId','parentId','depth','child','orderId','zstatus','sourceKey','cuser','cuname','editDate'])
+  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) {
@@ -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);
       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') {
       const action = String(input.action || '');
       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));
       let query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId);
       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 total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize);
       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 === 'Group') fail(400, '用户组必须走专用保存流程');
       if (className === 'Node') fail(400, '栏目必须走专用保存流程');
+      if (className === 'Special') 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);
@@ -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 === '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 (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

@@ -42,6 +42,13 @@ const explicit = new Map([
   ['Content / Node/BatchNode', ['partial', '已提供栏目批量回收、恢复和结构迁移']],
   ['Content / Node/BatchNode_Submit', ['partial', '已覆盖结构批处理;旧批量模板、权限与计费字段仍待专用实现']],
   ['Content / Node/UnionNode', ['partial', '已提供栏目批量迁移;栏目合并仍待实现']],
+  ['Content / Node/Special_API', ['partial', '已覆盖删除引用保护与保存时名称/目录校验;旧独立校验响应未保留']],
+  ['Content / Node/Special', ['implemented', '已由 Special 资源列表、搜索与分页替代']],
+  ['Content / Node/SpecialAdd', ['implemented', '已由专题动态表单与父专题结构控件替代']],
+  ['Content / Node/SpecialAdd_Save', ['implemented', '已覆盖原子专题编号、全帐套防重、父专题与循环校验']],
+  ['Content / Node/UnionSpecial', ['partial', '已提供专题批量迁移;关系合并因目标数据缺失被显式阻塞']],
+  ['Content / Node/UnionSpecial_Move', ['implemented', '已由 specialBatch 帐套隔离批量移动替代']],
+  ['Content / Node/UnionSpecial_Merge', ['blocked-data', 'CommonModel.specialId 物理列缺失且商品主表未迁移,无法安全重写专题关系']],
 ]);
 
 const blockedDataModules = new Map([

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

@@ -70,6 +70,7 @@ let userId = '';
 let adminCreatedUserId = '';
 let temporaryGroupObjectId = '';
 const temporaryNodeObjectIds = [];
+const temporarySpecialObjectIds = [];
 let registeredUserId = '';
 let memberId = '';
 let memberLegacyId = 0;
@@ -244,6 +245,27 @@ try {
   temporaryNodeObjectIds.pop();
   await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'nodeBatch', className: 'Node', action: 'purge', objectIds: [rootNode.objectId] });
   temporaryNodeObjectIds.pop();
+  const specialSuffix = Date.now().toString(36);
+  const rootSpecial = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveSpecial', className: 'Special', pid: 0, fields: { specName: `云函数冒烟专题-${specialSuffix}`, specDir: `special-${specialSuffix}`, specCate: 0, specDesc: '专题生命周期验证' } });
+  temporarySpecialObjectIds.push(String(rootSpecial.objectId || ''));
+  const rootSpecId = Number(rootSpecial.specId);
+  if (!temporarySpecialObjectIds[0] || !rootSpecId || Number(rootSpecial.pid) !== 0 || rootSpecial.sourceKey !== `[["SpecID",${rootSpecId}]]`) throw new Error(`专题原子创建异常:${JSON.stringify(rootSpecial)}`);
+  const childSpecial = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveSpecial', className: 'Special', pid: rootSpecId, fields: { specName: `云函数冒烟子专题-${specialSuffix}`, specDir: `special-child-${specialSuffix}` } });
+  temporarySpecialObjectIds.push(String(childSpecial.objectId || ''));
+  const childSpecId = Number(childSpecial.specId);
+  if (!temporarySpecialObjectIds[1] || !childSpecId || Number(childSpecial.pid) !== rootSpecId) throw new Error(`子专题创建异常:${JSON.stringify(childSpecial)}`);
+  await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveSpecial', className: 'Special', pid: 0, fields: { specName: rootSpecial.specName, specDir: `special-other-${specialSuffix}` } }, 409);
+  await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveSpecial', className: 'Special', objectId: rootSpecial.objectId, pid: childSpecId, fields: { ...rootSpecial, specName: rootSpecial.specName, specDir: rootSpecial.specDir } }, 409);
+  const specialDeleteBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'Special', objectId: rootSpecial.objectId }, 409);
+  if (!String(specialDeleteBlocked.message).includes('下级专题')) throw new Error('专题子级引用删除保护未返回明确原因');
+  const movedSpecial = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'specialBatch', className: 'Special', action: 'move', objectIds: [childSpecial.objectId], pid: 0 });
+  if (movedSpecial.updated !== 1 || Number(movedSpecial.results?.[0]?.pid) !== 0) throw new Error('专题批量迁移失败');
+  const mergeBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'specialBatch', className: 'Special', action: 'merge', objectIds: [childSpecial.objectId], targetId: rootSpecId }, 501);
+  if (!String(mergeBlocked.message).includes('specialId')) throw new Error('专题合并数据阻塞未返回明确原因');
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'Special', objectId: childSpecial.objectId });
+  temporarySpecialObjectIds.pop();
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'Special', objectId: rootSpecial.objectId });
+  temporarySpecialObjectIds.pop();
 
   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('内容待审核状态更新失败');
@@ -429,7 +451,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 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 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}`);
@@ -451,6 +473,10 @@ try {
     console.error(`临时栏目清理失败:${error.message}`);
     process.exitCode = 1;
   });
+  for (const specialId of temporarySpecialObjectIds) if (specialId) await jsonRequest(`${PARSE_URL}/classes/Special/${specialId}`, { method: 'DELETE' }, true).catch((error) => {
+    console.error(`临时专题清理失败:${error.message}`);
+    process.exitCode = 1;
+  });
   if (temporaryGuestbookId) await jsonRequest(`${PARSE_URL}/classes/Guestbook/${temporaryGuestbookId}`, { method: 'DELETE' }, true).catch((error) => {
     console.error(`临时留言清理失败:${error.message}`);
     process.exitCode = 1;

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

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

@@ -3611,50 +3611,50 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content / Node",
     "action": "Special_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已覆盖删除引用保护与保存时名称/目录校验;旧独立校验响应未保留"
   },
   {
     "module": "Content / Node",
     "action": "Special",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Special 资源列表、搜索与分页替代"
   },
   {
     "module": "Content / Node",
     "action": "SpecialAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由专题动态表单与父专题结构控件替代"
   },
   {
     "module": "Content / Node",
     "action": "SpecialAdd_Save",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已覆盖原子专题编号、全帐套防重、父专题与循环校验"
   },
   {
     "module": "Content / Node",
     "action": "UnionSpecial",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已提供专题批量迁移;关系合并因目标数据缺失被显式阻塞"
   },
   {
     "module": "Content / Node",
     "action": "UnionSpecial_Move",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 specialBatch 帐套隔离批量移动替代"
   },
   {
     "module": "Content / Node",
     "action": "UnionSpecial_Merge",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "CommonModel.specialId 物理列缺失且商品主表未迁移,无法安全重写专题关系"
   },
   {
     "module": "Office / OA",

+ 14 - 1
src/app/admin/pages/admin-resource.component.html

@@ -14,9 +14,11 @@
       } @else if (className() === 'CommonModel') {
         <select [ngModel]="selectedContentStatus()" (ngModelChange)="selectedContentStatus.set($event)"><option [ngValue]="99">已审核</option><option [ngValue]="0">待审核</option><option [ngValue]="-1">未通过</option><option [ngValue]="-3">草稿</option></select><button type="button" [disabled]="updatingContent()" (click)="applyBulkContentAction('status')">更新状态</button><button class="danger-action" type="button" [disabled]="updatingContent()" (click)="applyBulkContentAction('recycle')">回收</button><button type="button" [disabled]="updatingContent()" (click)="applyBulkContentAction('recover')">恢复</button>
         <input class="bulk-node" type="number" min="1" [ngModel]="targetContentNodeId()" (ngModelChange)="targetContentNodeId.set(+$event)" placeholder="节点 ID" /><button type="button" [disabled]="updatingContent() || !targetContentNodeId()" (click)="applyBulkContentAction('move')">移动节点</button>
-      } @else {
+      } @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 {
+        <input class="bulk-node" type="number" min="0" [ngModel]="targetSpecialParentId()" (ngModelChange)="targetSpecialParentId.set(+$event)" placeholder="父专题 ID" /><button type="button" [disabled]="updatingSpecial()" (click)="applyBulkSpecialMove()">移动专题</button>
       }
     </div>
   }
@@ -83,6 +85,17 @@
             }
           </section>
         }
+        @if (className() === 'Special') {
+          <section class="content-controls special-controls">
+            <header><div><strong>专题结构</strong><span>专用云函数维护专题编号、全帐套名称/目录防重与父子循环保护</span></div>@if (draftId()) { <b>ID {{ draft()['specId'] }}</b> }</header>
+            <label><span>父专题 ID <small>顶级专题填 0</small></span><input type="number" min="0" [ngModel]="targetSpecialParentId()" (ngModelChange)="targetSpecialParentId.set(+$event)" /></label>
+            @if (draftId()) {
+              <button type="button" [disabled]="updatingSpecial()" (click)="moveSpecial()">移动专题</button>
+              @if (specialActionError()) { <div class="content-action-status error">{{ specialActionError() }}</div> }
+              @if (specialActionMessage()) { <div class="content-action-status success">{{ specialActionMessage() }}</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>

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

@@ -178,6 +178,38 @@ describe('AdminResourceComponent', () => {
     expect(component.nodeActionMessage()).toContain('29');
   });
 
+  it('routes special creation through the structural cloud operation', async () => {
+    component.className.set('Special');
+    component.schema.set({ className: 'Special', label: '专题', writable: true, supportsSoftDelete: false, fields: [] });
+    component.draft.set({ specName: '新专题', specDir: 'new-special', specId: 999, pid: 999 });
+    component.targetSpecialParentId.set(1);
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveSpecial'
+      ? { objectId: 'special-object', specId: 10, specName: '新专题', pid: 1 }
+      : { className: 'Special', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveSpecial', {
+      className: 'Special', objectId: undefined, fields: { specName: '新专题', specDir: 'new-special', specId: 999, pid: 999 }, pid: 1,
+    }]);
+  });
+
+  it('moves a special through the dedicated batch operation', async () => {
+    component.className.set('Special');
+    component.draftId.set('special-object');
+    component.draft.set({ objectId: 'special-object', specId: 10, pid: 1 });
+    component.schema.set({ className: 'Special', label: '专题', writable: true, supportsSoftDelete: false, fields: [] });
+    component.targetSpecialParentId.set(0);
+    functions.admin.and.callFake(async (operation: string) => operation === 'specialBatch'
+      ? { action: 'move', updated: 1, results: [{ objectId: 'special-object', specId: 10, pid: 0 }] }
+      : { className: 'Special', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.moveSpecial();
+
+    expect(functions.admin.calls.first().args).toEqual(['specialBatch', { className: 'Special', action: 'move', objectIds: ['special-object'], pid: 0 }]);
+    expect(component.specialActionMessage()).toContain('0');
+  });
+
   it('updates content workflow state through the dedicated batch operation', async () => {
     component.className.set('CommonModel');
     component.draftId.set('content-object');

+ 34 - 5
src/app/admin/pages/admin-resource.component.ts

@@ -55,6 +55,10 @@ export class AdminResourceComponent implements OnInit {
   readonly updatingNode = signal(false);
   readonly nodeActionError = signal('');
   readonly nodeActionMessage = signal('');
+  readonly targetSpecialParentId = signal(0);
+  readonly updatingSpecial = signal(false);
+  readonly specialActionError = signal('');
+  readonly specialActionMessage = signal('');
   readonly selectedIds = signal<string[]>([]);
   readonly bulkError = signal('');
   readonly bulkMessage = signal('');
@@ -66,7 +70,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'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special'].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));
@@ -93,17 +97,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.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.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.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.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(); }
+  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(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -134,9 +138,10 @@ export class AdminResourceComponent implements OnInit {
     if (invalidFields.length) { this.error.set(`请先修正字段格式:${invalidFields.join('、')}`); return; }
     this.saving.set(true); this.error.set('');
     try {
-      const operation = this.className() === 'Group' ? 'saveGroup' : this.className() === 'Node' ? 'saveNode' : 'save';
+      const operation = this.className() === 'Group' ? 'saveGroup' : this.className() === 'Node' ? 'saveNode' : this.className() === 'Special' ? 'saveSpecial' : '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();
       await this.functions.admin(operation, payload);
       this.closeEditor(); await this.load();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '保存失败'); }
@@ -321,6 +326,29 @@ export class AdminResourceComponent implements OnInit {
     } catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量栏目操作失败'); }
     finally { this.updatingNode.set(false); }
   }
+  async moveSpecial(): Promise<void> {
+    this.specialActionError.set(''); this.specialActionMessage.set('');
+    const pid = this.targetSpecialParentId();
+    if (this.className() !== 'Special' || !this.draftId() || !Number.isInteger(pid) || pid < 0) { this.specialActionError.set('请输入有效父专题 ID,顶级专题填 0'); return; }
+    this.updatingSpecial.set(true);
+    try {
+      const result = await this.functions.admin<{ results: Record<string, unknown>[] }>('specialBatch', { className: 'Special', action: 'move', objectIds: [this.draftId()], pid });
+      if (result.results?.[0]) { this.draft.set(result.results[0]); this.targetSpecialParentId.set(Number(result.results[0]['pid']) || 0); }
+      this.specialActionMessage.set(`专题已移动到父专题 ${pid}`); await this.load();
+    } catch (error) { this.specialActionError.set(error instanceof Error ? error.message : '专题移动失败'); }
+    finally { this.updatingSpecial.set(false); }
+  }
+  async applyBulkSpecialMove(): Promise<void> {
+    const objectIds = this.selectedIds(); const pid = this.targetSpecialParentId(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'Special' || !objectIds.length) { this.bulkError.set('请先选择专题'); return; }
+    if (!Number.isInteger(pid) || pid < 0) { this.bulkError.set('请输入有效父专题 ID,顶级专题填 0'); return; }
+    this.updatingSpecial.set(true);
+    try {
+      const result = await this.functions.admin<{ updated: number }>('specialBatch', { className: 'Special', action: 'move', objectIds, pid });
+      await this.load(); this.bulkMessage.set(`已移动 ${result.updated} 个专题`);
+    } catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量专题移动失败'); }
+    finally { this.updatingSpecial.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     const verb = ['CommonModel', 'Node'].includes(this.className()) ? '移入回收站' : '删除';
@@ -352,4 +380,5 @@ export class AdminResourceComponent implements OnInit {
   private resetUserActions(detail: Record<string, unknown> = {}): void { this.userGroups.set([]); this.selectedUserGroupId.set(Number(detail['legacyGroupId'] ?? (detail['legacyUserData'] as Record<string, unknown> | undefined)?.['GroupID']) || 0); this.userActionReason.set(''); this.userActionError.set(''); this.userActionMessage.set(''); }
   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(''); }
 }