Browse Source

feat: migrate core content administration

彭峰 1 month ago
parent
commit
f82e266f5e

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

@@ -22,6 +22,7 @@
 - 当前线上 `Group` 表为空;用户组移动只接受当前帐套中真实存在的 `Group.groupId`,界面会明确提示先在“全部数据类”建立用户组,不会把不存在的默认编号伪装成可选组。
 - 旧 `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` 改为通用只读;新增按钮关闭,避免只写主表却遗漏模型附表。
+- 旧 `GetMDFile` 的固定空响应、`Content_API.duptitle` 与 `ContentManage_Refresh` 已由 `legacyContentUtility/contentBatch` 等价迁移;Angular 内容页增加重复标题检查、单条/批量时间刷新和八列 CSV 安全导出。旧 XLSX 格式暂为部分迁移;专题关系、定时发布、静态 HTML、采集和 Markdown 因物理数据或旧运行时缺失均返回明确 `migration_blocked`。
 - 旧 `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/旧模板运行时。

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

@@ -1583,9 +1583,48 @@ async function handler(request, response) {
       for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'bar-' + action, 'GuestBar', target.id);
       return response.json({ success: true, data: { action, updated: targets.length, cateId, results: targets.map(serializeObject) } });
     }
+    if (operation === 'legacyContentUtility') {
+      const action = String(input.action || '');
+      if (action === 'get-md-file') return response.json({ success: true, data: { content: '' } });
+      if (action === 'duplicate-title') {
+        const title = String(input.title || '').trim();
+        if (!title || title.length > 500) fail(400, '标题长度应为 1 至 500 位');
+        const fields = await schemaFor('CommonModel'); const query = new Parse.Query('CommonModel'); applyTenant(query, fields, context, input.companyId); query.matches('title', '^' + escapeRegex(title) + '$', 'i'); query.descending('updatedAt'); query.limit(100);
+        const results = await query.find({ useMasterKey: true });
+        return response.json({ success: true, data: { title, total: results.length, results: results.map((target) => { const item = serializeObject(target); return { objectId: item.objectId, generalId: item.generalId, title: item.title, nodeId: item.nodeId, status: item.status, updatedAt: item.updatedAt }; }) } });
+      }
+      if (action === 'export') {
+        const requestedIds = Array.isArray(input.objectIds) ? [...new Set(input.objectIds.map((value) => String(value || '').trim()).filter(Boolean))] : [];
+        if (requestedIds.length > 1000) fail(400, '每次最多导出 1000 条内容');
+        const fields = await schemaFor('CommonModel'); const query = new Parse.Query('CommonModel'); applyTenant(query, fields, context, input.companyId); if (requestedIds.length) query.containedIn('objectId', requestedIds); query.descending('updatedAt'); query.limit(requestedIds.length || 1000);
+        const results = await query.find({ useMasterKey: true });
+        if (requestedIds.length && results.length !== requestedIds.length) fail(404, '部分内容不存在或不属于当前帐套');
+        const rows = results.map((target) => ({ id: Number(target.get('generalId')) || 0, title: String(target.get('title') || ''), inputer: String(target.get('inputer') || ''), hits: Number(target.get('hits')) || 0, elite: Number(target.get('eliteLevel')) || 0, status: Number(target.get('status')) || 0, createTime: safeValue(target.get('createTime')), updateTime: safeValue(target.get('upDateTime')) }));
+        return response.json({ success: true, data: { total: rows.length, rows } });
+      }
+      fail(400, '不支持的旧内容兼容动作');
+    }
+    if (operation === 'legacyContentBlocker') {
+      const action = String(input.action || ''); const blockers = {
+        Content_AddToNew: '目标缺少按 CommonModel.tableName 动态复制任意旧模型附表的完整结构与数据',
+        AddToSpec: 'CommonModel.specialId 仅存在于 Parse Schema,PostgreSQL 物理查询仍失败,无法安全读取专题关系',
+        AddToSpec_Submit: '专题关系物理列不可可靠读写,且商品主表未迁移,不能伪造内容加入专题成功',
+        ContentManage_Html: '云函数运行时没有旧模板解析器、站点根目录与静态 HTML 文件宿主',
+        ContentRelease: '目标 Schema 缺少 Content_ScheTask 定时内容任务及既有任务记录',
+        ContentRelease_Submit: '目标没有 HFHelper 调度器与 ZL_Task_Content 脚本执行运行时',
+        CreateHtmlContent: '云函数没有旧节点模板、标签解析器与站点文件系统,无法预览静态生成范围',
+        Create_Submit: '目标没有 ContentHtmlHelper 和旧模板标签运行时,无法执行内容静态生成任务',
+        CreateHtml: '目标云函数不能写入旧站点 HTML 目录,也没有旧模板渲染环境',
+        ConCatch: '目标缺少旧采集项目、采集规则、来源站点与任务历史数据',
+        CatchConfig: '目标没有 SiteOption.Coll_Allow/Coll_Key 配置模型及采集运行时消费者',
+        CatchConfig_Submit: '仅保存采集开关不会产生可执行采集能力,目标也没有旧 SiteConfig 更新链',
+        MarkDown: '目标 Schema 缺少旧 Content_MarkDown 主表与历史 Markdown 记录',
+        MarkDown_Del: '目标没有可定位并删除的旧 Content_MarkDown 记录'
+      }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧内容动作'); fail(501, 'migration_blocked: ' + blockers[action]);
+    }
     if (operation === 'contentBatch') {
       const action = String(input.action || '');
-      if (!['status','recycle','recover','move'].includes(action)) fail(400, '不支持的内容批量操作');
+      if (!['status','recycle','recover','move','refresh'].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 条内容');
@@ -1600,7 +1639,7 @@ async function handler(request, response) {
         if (![-3,-1,0,99].includes(status)) fail(400, '不支持的内容状态');
       } else if (action === 'recycle') status = -2;
       else if (action === 'recover') status = 0;
-      else {
+      else if (action === 'move') {
         nodeId = Number(input.nodeId);
         if (!Number.isInteger(nodeId) || nodeId < 1) fail(400, '请选择有效目标节点');
         const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
@@ -1611,6 +1650,7 @@ async function handler(request, response) {
       for (const target of targets) {
         if (status !== null) target.set('status', status);
         if (nodeId !== null) target.set('nodeId', nodeId);
+        if (action === 'refresh') { const now = new Date(); target.set('createTime', now); target.set('upDateTime', now); }
       }
       await Parse.Object.saveAll(targets, { useMasterKey: true });
       for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'content-' + action, 'CommonModel', target.id);

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

@@ -30,6 +30,28 @@ const explicit = new Map([
   ['Content/Content_Update', ['partial', '可更新安全主表字段;模型附表更新仅覆盖已迁移学习模型']],
   ['Content/ContentRecycle', ['partial', '可回收和恢复内容;独立回收站筛选页尚未提供']],
   ['Content/ContentManage_API', ['partial', '已覆盖恢复;永久删除、清空、专题移除和排序交换尚未迁移']],
+  ['Content/GetMDFile', ['implemented', '旧动作固定返回空字符串,已由 legacyContentUtility 原样兼容为空内容']],
+  ['Content/AddContent', ['partial', '现有 content_add 可为已规范化的六类学习模型生成主表与附表;任意旧动态模型表单仍缺失']],
+  ['Content/ShowContent', ['partial', '已提供 CommonModel 详情与已规范化模型查询;旧评论、任意动态附表、前后篇及静态页预览未恢复']],
+  ['Content/Content_Add', ['partial', '已由 content_add 原子创建已签约模型的 CommonModel 与规范化附表;未迁移模型会明确拒绝']],
+  ['Content/Content_AddToNew', ['blocked-data', '目标缺少按 CommonModel.tableName 动态复制任意旧模型附表的完整结构与数据']],
+  ['Content/Content_Draft', ['partial', 'content_add 支持把已规范化模型以 status=-3 写为草稿;任意旧动态模型仍不可创建']],
+  ['Content/Content_API', ['implemented', '已由 legacyContentUtility duplicate-title 帐套隔离、大小写不敏感地检查重复标题']],
+  ['Content/AddToSpec', ['blocked-data', 'CommonModel.specialId 仅存在于 Parse Schema,PostgreSQL 物理查询仍失败,无法安全读取专题关系']],
+  ['Content/AddToSpec_Submit', ['blocked-data', '专题关系物理列不可可靠读写,且商品主表未迁移,不能伪造加入专题成功']],
+  ['Content/ContentManage_Html', ['blocked-infrastructure', '云函数运行时没有旧模板解析器、站点根目录与静态 HTML 文件宿主']],
+  ['Content/ContentManage_ToExcel', ['partial', '已由帐套隔离导出云函数与 Angular CSV 下载覆盖八个旧导出列;文件格式由 xlsx 降级为安全 CSV']],
+  ['Content/ContentManage_Refresh', ['implemented', '已由 contentBatch refresh 批量同步刷新 createTime 与 upDateTime']],
+  ['Content/ContentRelease', ['blocked-data', '目标 Schema 缺少 Content_ScheTask 定时内容任务及既有任务记录']],
+  ['Content/ContentRelease_Submit', ['blocked-infrastructure', '目标没有 HFHelper 调度器与 ZL_Task_Content 脚本执行运行时']],
+  ['Content/CreateHtmlContent', ['blocked-infrastructure', '云函数没有旧节点模板、标签解析器与站点文件系统,无法预览静态生成范围']],
+  ['Content/Create_Submit', ['blocked-infrastructure', '目标没有 ContentHtmlHelper 和旧模板标签运行时,无法执行内容静态生成任务']],
+  ['Content/CreateHtml', ['blocked-infrastructure', '目标云函数不能写入旧站点 HTML 目录,也没有旧模板渲染环境']],
+  ['Content/ConCatch', ['blocked-data', '目标缺少旧采集项目、采集规则、来源站点与任务历史数据']],
+  ['Content/CatchConfig', ['blocked-infrastructure', '目标没有 SiteOption.Coll_Allow/Coll_Key 配置模型及采集运行时消费者']],
+  ['Content/CatchConfig_Submit', ['blocked-infrastructure', '仅保存采集开关不会产生可执行采集能力,目标也没有旧 SiteConfig 更新链']],
+  ['Content/MarkDown', ['blocked-data', '目标 Schema 缺少旧 Content_MarkDown 主表与历史 Markdown 记录']],
+  ['Content/MarkDown_Del', ['blocked-data', '目标没有可定位并删除的旧 Content_MarkDown 记录']],
   ['Content / Node/NodeManage', ['implemented', '已由 Node 资源列表、分页与专用结构操作替代']],
   ['Content / Node/NodeSearch', ['implemented', '已覆盖按栏目名称搜索并保持帐套隔离']],
   ['Content / Node/NodeAdd', ['implemented', '已由 Node 动态表单与父节点结构控件替代']],

+ 13 - 0
scripts/smoke-admin-functions.mjs

@@ -622,6 +622,19 @@ try {
   if (Number(contentRecovered.results?.[0]?.status) !== 0) throw new Error('内容回收站恢复失败');
   const contentMoved = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'contentBatch', className: 'CommonModel', action: 'move', objectIds: [temporaryAppointmentCommonObjectId], nodeId: 29 });
   if (Number(contentMoved.results?.[0]?.nodeId) !== 29) throw new Error('内容节点移动失败');
+  const contentRefreshed = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'contentBatch', className: 'CommonModel', action: 'refresh', objectIds: [temporaryAppointmentCommonObjectId] });
+  if (contentRefreshed.updated !== 1 || !contentRefreshed.results?.[0]?.createTime || !contentRefreshed.results?.[0]?.upDateTime) throw new Error('内容时间刷新失败');
+  const contentTitle = String(contentRefreshed.results[0].title || '');
+  const duplicateTitles = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyContentUtility', action: 'duplicate-title', title: contentTitle });
+  if (!contentTitle || duplicateTitles.total < 1 || !duplicateTitles.results.some((item) => item.objectId === temporaryAppointmentCommonObjectId)) throw new Error('内容重复标题检查失败');
+  const contentExport = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyContentUtility', action: 'export', objectIds: [temporaryAppointmentCommonObjectId] });
+  if (contentExport.total !== 1 || contentExport.rows?.[0]?.title !== contentTitle) throw new Error('内容导出数据失败');
+  const emptyMd = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyContentUtility', action: 'get-md-file' });
+  if (emptyMd.content !== '') throw new Error('GetMDFile 空响应兼容失败');
+  for (const action of ['Content_AddToNew','AddToSpec','AddToSpec_Submit','ContentManage_Html','ContentRelease','ContentRelease_Submit','CreateHtmlContent','Create_Submit','CreateHtml','ConCatch','CatchConfig','CatchConfig_Submit','MarkDown','MarkDown_Del']) {
+    const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyContentBlocker', action }, 501);
+    if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧内容动作 ${action} 未返回明确迁移阻塞`);
+  }
   await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'contentBatch', className: 'CommonModel', action: 'status', objectIds: [temporaryAppointmentCommonObjectId], status: 99 });
 
   const page = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'list', className: 'CourseAppointment', page: 1, pageSize: 2 });

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

@@ -986,15 +986,15 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content",
     "action": "GetMDFile",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "旧动作固定返回空字符串,已由 legacyContentUtility 原样兼容为空内容"
   },
   {
     "module": "Content",
     "action": "AddContent",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有 content_add 可为已规范化的六类学习模型生成主表与附表;任意旧动态模型表单仍缺失"
   },
   {
     "module": "Content",
@@ -1007,22 +1007,22 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content",
     "action": "ShowContent",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已提供 CommonModel 详情与已规范化模型查询;旧评论、任意动态附表、前后篇及静态页预览未恢复"
   },
   {
     "module": "Content",
     "action": "Content_Add",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已由 content_add 原子创建已签约模型的 CommonModel 与规范化附表;未迁移模型会明确拒绝"
   },
   {
     "module": "Content",
     "action": "Content_AddToNew",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少按 CommonModel.tableName 动态复制任意旧模型附表的完整结构与数据"
   },
   {
     "module": "Content",
@@ -1035,15 +1035,15 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content",
     "action": "Content_Draft",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "content_add 支持把已规范化模型以 status=-3 写为草稿;任意旧动态模型仍不可创建"
   },
   {
     "module": "Content",
     "action": "Content_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 legacyContentUtility duplicate-title 帐套隔离、大小写不敏感地检查重复标题"
   },
   {
     "module": "Content",
@@ -1063,15 +1063,15 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content",
     "action": "AddToSpec",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "CommonModel.specialId 仅存在于 Parse Schema,PostgreSQL 物理查询仍失败,无法安全读取专题关系"
   },
   {
     "module": "Content",
     "action": "AddToSpec_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "专题关系物理列不可可靠读写,且商品主表未迁移,不能伪造加入专题成功"
   },
   {
     "module": "Content",
@@ -1084,8 +1084,8 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content",
     "action": "ContentManage_Html",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "云函数运行时没有旧模板解析器、站点根目录与静态 HTML 文件宿主"
   },
   {
     "module": "Content",
@@ -1119,29 +1119,29 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content",
     "action": "ContentManage_ToExcel",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已由帐套隔离导出云函数与 Angular CSV 下载覆盖八个旧导出列;文件格式由 xlsx 降级为安全 CSV"
   },
   {
     "module": "Content",
     "action": "ContentManage_Refresh",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 contentBatch refresh 批量同步刷新 createTime 与 upDateTime"
   },
   {
     "module": "Content",
     "action": "ContentRelease",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少 Content_ScheTask 定时内容任务及既有任务记录"
   },
   {
     "module": "Content",
     "action": "ContentRelease_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标没有 HFHelper 调度器与 ZL_Task_Content 脚本执行运行时"
   },
   {
     "module": "Content",
@@ -1154,57 +1154,57 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content",
     "action": "CreateHtmlContent",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "云函数没有旧节点模板、标签解析器与站点文件系统,无法预览静态生成范围"
   },
   {
     "module": "Content",
     "action": "Create_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标没有 ContentHtmlHelper 和旧模板标签运行时,无法执行内容静态生成任务"
   },
   {
     "module": "Content",
     "action": "CreateHtml",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标云函数不能写入旧站点 HTML 目录,也没有旧模板渲染环境"
   },
   {
     "module": "Content",
     "action": "ConCatch",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少旧采集项目、采集规则、来源站点与任务历史数据"
   },
   {
     "module": "Content",
     "action": "CatchConfig",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标没有 SiteOption.Coll_Allow/Coll_Key 配置模型及采集运行时消费者"
   },
   {
     "module": "Content",
     "action": "CatchConfig_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "仅保存采集开关不会产生可执行采集能力,目标也没有旧 SiteConfig 更新链"
   },
   {
     "module": "Content",
     "action": "MarkDown",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 Content_MarkDown 主表与历史 Markdown 记录"
   },
   {
     "module": "Content",
     "action": "MarkDown_Del",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可定位并删除的旧 Content_MarkDown 记录"
   },
   {
     "module": "Design / Counter",

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

@@ -1,6 +1,6 @@
 <header class="resource-header">
   <div><p>DATA RESOURCE</p><h1>{{ schema()?.label || className() }}</h1><span>{{ className() }} · {{ page()?.total || 0 | number }} 条记录</span></div>
-  <div class="header-actions"><button type="button" (click)="load(1, true)"><lucide-icon [img]="icons.RefreshCw" [size]="17" />刷新</button>@if (schema()?.writable && schema()?.creatable !== false) { <button class="primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="18" />新建记录</button> }</div>
+  <div class="header-actions"><button type="button" (click)="load(1, true)"><lucide-icon [img]="icons.RefreshCw" [size]="17" />刷新</button>@if (className() === 'CommonModel') { <button type="button" [disabled]="updatingContent()" (click)="exportContent()">导出当前页</button> }@if (schema()?.writable && schema()?.creatable !== false) { <button class="primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="18" />新建记录</button> }</div>
 </header>
 
 <section class="resource-card">
@@ -47,7 +47,7 @@
         <select [ngModel]="selectedUserGroupId()" (ngModelChange)="selectedUserGroupId.set($event)"><option [ngValue]="0">选择用户组</option>@for (group of userGroups(); track group['objectId']) { <option [ngValue]="groupIdOf(group)">{{ groupLabel(group) }}</option> }</select><button type="button" [disabled]="updatingUser() || !selectedUserGroupId()" (click)="applyBulkUserAction('move')">移动分组</button>
       } @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>
+        <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><button type="button" [disabled]="updatingContent()" (click)="applyBulkContentAction('refresh')">刷新时间</button><button type="button" [disabled]="updatingContent()" (click)="exportContent()">导出所选</button>
       } @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>
@@ -203,9 +203,12 @@
             <button type="button" [disabled]="updatingContent()" (click)="applyContentAction('status')">更新状态</button>
             <label><span>目标节点 ID</span><input type="number" min="1" [ngModel]="targetContentNodeId()" (ngModelChange)="targetContentNodeId.set(+$event)" /></label>
             <button type="button" [disabled]="updatingContent() || !targetContentNodeId()" (click)="applyContentAction('move')">移动节点</button>
+            <button type="button" [disabled]="updatingContent()" (click)="applyContentAction('refresh')">刷新内容时间</button>
+            <button type="button" [disabled]="updatingContent()" (click)="checkDuplicateContentTitle()">检查重复标题</button>
             <div class="content-recycle">@if (contentStatusValue() === -2) { <button type="button" [disabled]="updatingContent()" (click)="applyContentAction('recover')">从回收站恢复</button> } @else { <button class="danger-action" type="button" [disabled]="updatingContent()" (click)="applyContentAction('recycle')">移入回收站</button> }</div>
             @if (contentActionError()) { <div class="content-action-status error">{{ contentActionError() }}</div> }
             @if (contentActionMessage()) { <div class="content-action-status success">{{ contentActionMessage() }}</div> }
+            @if (duplicateContentTitles().length) { <div class="content-action-status">@for (item of duplicateContentTitles(); track item['objectId']) { <span>ID {{ item['generalId'] || item['objectId'] }} · 节点 {{ item['nodeId'] || 0 }} · {{ contentStatusLabel($any(item['status'])) }}</span> }</div> }
           </section>
         }
         @if (className() === 'Node') {

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

@@ -795,6 +795,32 @@ describe('AdminResourceComponent', () => {
     expect(component.contentActionMessage()).toContain('已审核');
   });
 
+  it('refreshes legacy content timestamps through the dedicated batch operation', async () => {
+    component.className.set('CommonModel');
+    component.draftId.set('content-object');
+    component.draft.set({ objectId: 'content-object', status: 99, nodeId: 29 });
+    functions.admin.and.callFake(async (operation: string) => operation === 'contentBatch'
+      ? { action: 'refresh', updated: 1, results: [{ objectId: 'content-object', status: 99, nodeId: 29, createTime: '2026-08-18T00:00:00.000Z', upDateTime: '2026-08-18T00:00:00.000Z' }] }
+      : { className: 'CommonModel', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.applyContentAction('refresh');
+
+    expect(functions.admin.calls.first().args).toEqual(['contentBatch', { className: 'CommonModel', action: 'refresh', objectIds: ['content-object'] }]);
+    expect(component.contentActionMessage()).toContain('时间已刷新');
+  });
+
+  it('checks duplicate content titles through the tenant-safe compatibility operation', async () => {
+    component.className.set('CommonModel');
+    component.draft.set({ objectId: 'content-object', title: '同名内容' });
+    functions.admin.and.resolveTo({ title: '同名内容', total: 2, results: [{ objectId: 'content-object' }, { objectId: 'content-other' }] });
+
+    await component.checkDuplicateContentTitle();
+
+    expect(functions.admin).toHaveBeenCalledWith('legacyContentUtility', { action: 'duplicate-title', title: '同名内容' });
+    expect(component.duplicateContentTitles().length).toBe(2);
+    expect(component.contentActionMessage()).toContain('2 条同名内容');
+  });
+
   it('selects and clears all records on the current page', () => {
     component.className.set('CommonModel');
     component.page.set({ className: 'CommonModel', page: 1, pageSize: 20, total: 2, results: [{ objectId: 'content-a' }, { objectId: 'content-b' }] });

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

@@ -58,6 +58,7 @@ export class AdminResourceComponent implements OnInit {
   readonly updatingContent = signal(false);
   readonly contentActionError = signal('');
   readonly contentActionMessage = signal('');
+  readonly duplicateContentTitles = signal<Record<string, unknown>[]>([]);
   readonly targetNodeParentId = signal(0);
   readonly updatingNode = signal(false);
   readonly nodeActionError = signal('');
@@ -367,7 +368,7 @@ export class AdminResourceComponent implements OnInit {
     } catch (error) { this.userActionError.set(error instanceof Error ? error.message : '用户组更新失败'); }
     finally { this.updatingUser.set(false); }
   }
-  async applyContentAction(action: 'status' | 'recycle' | 'recover' | 'move'): Promise<void> {
+  async applyContentAction(action: 'status' | 'recycle' | 'recover' | 'move' | 'refresh'): Promise<void> {
     this.contentActionError.set(''); this.contentActionMessage.set('');
     if (this.className() !== 'CommonModel' || !this.draftId()) { this.contentActionError.set('只能管理现有内容'); return; }
     if (action === 'recycle' && !confirm('确认将该内容移入回收站?')) return;
@@ -390,7 +391,7 @@ export class AdminResourceComponent implements OnInit {
         this.selectedContentStatus.set(Number(result.results[0]['status']) || 0);
         this.targetContentNodeId.set(Number(result.results[0]['nodeId']) || 0);
       }
-      const messages = { status: `内容状态已更新为${this.contentStatusLabel(this.selectedContentStatus())}`, recycle: '内容已移入回收站', recover: '内容已恢复为待审核', move: `内容已移动到节点 ${this.targetContentNodeId()}` };
+      const messages = { status: `内容状态已更新为${this.contentStatusLabel(this.selectedContentStatus())}`, recycle: '内容已移入回收站', recover: '内容已恢复为待审核', move: `内容已移动到节点 ${this.targetContentNodeId()}`, refresh: '内容创建和修改时间已刷新' };
       this.contentActionMessage.set(messages[action]);
       await this.load();
     } catch (error) { this.contentActionError.set(error instanceof Error ? error.message : '内容操作失败'); }
@@ -422,7 +423,7 @@ export class AdminResourceComponent implements OnInit {
     } catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量用户操作失败'); }
     finally { this.updatingUser.set(false); }
   }
-  async applyBulkContentAction(action: 'status' | 'recycle' | 'recover' | 'move'): Promise<void> {
+  async applyBulkContentAction(action: 'status' | 'recycle' | 'recover' | 'move' | 'refresh'): Promise<void> {
     const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
     if (this.className() !== 'CommonModel' || !objectIds.length) { this.bulkError.set('请先选择内容'); return; }
     if (action === 'recycle' && !confirm(`确认将选中的 ${objectIds.length} 条内容移入回收站?`)) return;
@@ -444,6 +445,31 @@ export class AdminResourceComponent implements OnInit {
     } catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量内容操作失败'); }
     finally { this.updatingContent.set(false); }
   }
+  async checkDuplicateContentTitle(): Promise<void> {
+    const title = String(this.draft()['title'] || '').trim(); this.contentActionError.set(''); this.contentActionMessage.set(''); this.duplicateContentTitles.set([]);
+    if (this.className() !== 'CommonModel' || !title) { this.contentActionError.set('当前内容没有可检查的标题'); return; }
+    this.updatingContent.set(true);
+    try {
+      const result = await this.functions.admin<{ total: number; results: Record<string, unknown>[] }>('legacyContentUtility', { action: 'duplicate-title', title });
+      this.duplicateContentTitles.set(result.results || []); this.contentActionMessage.set(`找到 ${result.total || 0} 条同名内容(含当前记录)`);
+    } catch (error) { this.contentActionError.set(error instanceof Error ? error.message : '重复标题检查失败'); }
+    finally { this.updatingContent.set(false); }
+  }
+  async exportContent(): Promise<void> {
+    const selected = this.selectedIds(); const pageIds = (this.page()?.results || []).map((row) => String(row['objectId'] || '')).filter(Boolean); const objectIds = selected.length ? selected : pageIds;
+    this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'CommonModel' || !objectIds.length) { this.bulkError.set('当前没有可导出的内容'); return; }
+    this.updatingContent.set(true);
+    try {
+      const result = await this.functions.admin<{ total: number; rows: Record<string, unknown>[] }>('legacyContentUtility', { action: 'export', objectIds });
+      const labels = ['ID', '标题', '录入者', '点击数', '推荐', '状态', '添加时间', '修改时间']; const keys = ['id', 'title', 'inputer', 'hits', 'elite', 'status', 'createTime', 'updateTime'];
+      const csvCell = (value: unknown) => { let text = String(value ?? ''); if (/^[=+\-@]/.test(text)) text = `'${text}`; return `"${text.replace(/"/g, '""')}"`; };
+      const csv = [labels.map(csvCell).join(','), ...(result.rows || []).map((row) => keys.map((key) => csvCell(row[key])).join(','))].join('\r\n');
+      const url = URL.createObjectURL(new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8' })); const link = document.createElement('a'); link.href = url; link.download = `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-内容管理表.csv`; link.click(); URL.revokeObjectURL(url);
+      this.bulkMessage.set(`已导出 ${result.total || 0} 条内容`);
+    } catch (error) { this.bulkError.set(error instanceof Error ? error.message : '内容导出失败'); }
+    finally { this.updatingContent.set(false); }
+  }
   async applyNodeAction(action: 'recycle' | 'recover' | 'move'): Promise<void> {
     this.nodeActionError.set(''); this.nodeActionMessage.set('');
     if (this.className() !== 'Node' || !this.draftId()) { this.nodeActionError.set('只能管理现有栏目'); return; }
@@ -790,7 +816,7 @@ export class AdminResourceComponent implements OnInit {
   private resetPasswordFields(): void { this.newPassword.set(''); this.confirmPassword.set(''); this.passwordMessage.set(''); this.passwordError.set(''); }
   private resetCreateUserFields(): void { this.createUsername.set(''); this.createPassword.set(''); this.createConfirmPassword.set(''); this.createDisplayName.set(''); this.createMobile.set(''); this.createInviteUsername.set(''); this.createUserError.set(''); }
   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 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(''); this.duplicateContentTitles.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(''); }