Просмотр исходного кода

feat: migrate admin model metadata

彭峰 1 месяц назад
Родитель
Сommit
6b1fc866e2

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

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

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

@@ -24,7 +24,9 @@ const GENERIC_WRITE_TYPES = new Set(['String','Number','Boolean','Date','Pointer
 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']),
-  Special: new Set(['specId','pid','orderId','sourceKey','cuser','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'])
 };
 
 function inputOf(request) {
@@ -496,6 +498,52 @@ async function handler(request, response) {
       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 === 'saveModelMetadata' || operation === 'saveModelFieldMetadata') {
+      const className = operation === 'saveModelMetadata' ? 'Model' : 'ModelField';
+      const objectId = String(input.objectId || '');
+      if (!objectId) fail(501, 'migration_blocked: 新增模型或字段需要同步创建 PostgreSQL 物理表/列及旧模板文件,托管云函数中未开放此 DDL 流程');
+      const fields = await schemaFor(className);
+      const query = new Parse.Query(className); 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 allowed = className === 'Model'
+        ? new Set(['modelName','itemName','itemUnit','itemIcon','description','islotsize','contentTemplate','thumbnail'])
+        : new Set(['fieldAlias','fieldTips','description','isNotNull','isSearchForm','content','isShow','isView','showList','showWidth','isCopy','islotsize','isChain']);
+      if (className === 'Model') {
+        const modelName = String(payload.modelName == null ? target.get('modelName') || '' : payload.modelName).trim();
+        if (!modelName || modelName.length > 100) fail(400, '模型名称长度应为 1 至 100 位');
+        const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "Model" WHERE "company"=$1 AND "objectId"<>$2 AND LOWER(TRIM(COALESCE("modelName",\'\')))=LOWER($3) LIMIT 1', [pointerId(target.get('company')), objectId, modelName]);
+        if (duplicate) fail(409, '模型名称已存在');
+        target.set('modelName', modelName);
+      } else {
+        const fieldAlias = String(payload.fieldAlias == null ? target.get('fieldAlias') || '' : payload.fieldAlias).trim();
+        if (!fieldAlias || fieldAlias.length > 100) fail(400, '字段别名长度应为 1 至 100 位');
+        target.set('fieldAlias', fieldAlias);
+      }
+      for (const [name, value] of Object.entries(payload)) {
+        if (!allowed.has(name) || !fields[name]) continue;
+        if (value === null) target.unset(name); else target.set(name, toParseValue(fields[name], value));
+      }
+      await target.save(null, { useMasterKey: true }); await audit(context, className === 'Model' ? 'update-model-metadata' : 'update-model-field-metadata', className, objectId);
+      return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'modelFieldOrder') {
+      const items = Array.isArray(input.items) ? input.items : [];
+      if (!items.length || items.length > 100) fail(400, '每次请提交 1 至 100 个字段顺序');
+      const objectIds = items.map((item) => String(item && item.objectId || '').trim());
+      const orderIds = items.map((item) => Number(item && item.orderId));
+      if (objectIds.some((id) => !id) || new Set(objectIds).size !== objectIds.length || orderIds.some((id) => !Number.isInteger(id) || id < 0) || new Set(orderIds).size !== orderIds.length) fail(400, '字段或顺序参数无效/重复');
+      const fields = await schemaFor('ModelField');
+      const query = new Parse.Query('ModelField'); 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 modelIds = [...new Set(targets.map((target) => Number(target.get('modelId')) || 0))];
+      if (modelIds.length !== 1) fail(400, '只能对同一模型的字段排序');
+      const targetMap = new Map(targets.map((target) => [target.id, target]));
+      for (const item of items) targetMap.get(String(item.objectId)).set('orderId', Number(item.orderId));
+      await Parse.Object.saveAll(targets, { useMasterKey: true });
+      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 === 'contentBatch') {
       const action = String(input.action || '');
       if (!['status','recycle','recover','move'].includes(action)) fail(400, '不支持的内容批量操作');
@@ -535,13 +583,13 @@ 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 && className !== 'CommonModel', 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'].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));
       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','specName','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','modelName','fieldName','fieldAlias','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);
@@ -558,6 +606,7 @@ async function handler(request, response) {
       if (className === 'Group') fail(400, '用户组必须走专用保存流程');
       if (className === 'Node') fail(400, '栏目必须走专用保存流程');
       if (className === 'Special') fail(400, '专题必须走专用保存流程');
+      if (className === 'Model' || className === 'ModelField') 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);
@@ -569,6 +618,7 @@ async function handler(request, response) {
     }
     if (operation === 'delete') {
       if (!classWritable || !objectId) fail(400, '该记录不允许删除'); const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); const object = await query.get(objectId, { useMasterKey: true });
+      if (className === 'Model' || className === 'ModelField') fail(501, 'migration_blocked: 删除模型或字段需要同步删除 PostgreSQL 物理表/列并验证历史数据,托管云函数中未开放此 DDL 流程');
       if (className === '_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 } }); }

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

@@ -49,6 +49,17 @@ const explicit = new Map([
   ['Content / Node/UnionSpecial', ['partial', '已提供专题批量迁移;关系合并因目标数据缺失被显式阻塞']],
   ['Content / Node/UnionSpecial_Move', ['implemented', '已由 specialBatch 帐套隔离批量移动替代']],
   ['Content / Node/UnionSpecial_Merge', ['blocked-data', 'CommonModel.specialId 物理列缺失且商品主表未迁移,无法安全重写专题关系']],
+  ['Model/Index', ['partial', '已由全部数据类与 Model/ModelField 资源页替代;旧模型类型入口布局未逐项复刻']],
+  ['Model/ModelManage', ['implemented', '已提供帐套模型列表、搜索、分页与安全元数据编辑']],
+  ['Model/ModelAdd', ['blocked-infrastructure', '新增模型需要 PostgreSQL 建表、生成标签与写入旧模板文件']],
+  ['Model/ModelAdd_Submit', ['blocked-infrastructure', '托管云函数未开放模型 DDL 与旧 Zoomla 模板文件运行时']],
+  ['Model/Model_API', ['partial', '已覆盖安全模板/展示元数据更新;复制和删除模型因 DDL 被阻塞']],
+  ['Model/ModelData', ['partial', '规范化目标类可从资源页管理;任意旧附表名到 Parse 类的映射尚未完整恢复']],
+  ['Model/Field', ['implemented', '已提供模型字段列表、搜索、分页与安全展示元数据编辑']],
+  ['Model/Order_Submit', ['partial', '已提供同模型字段批量排序云函数;专用拖拽界面尚未复刻']],
+  ['Model/FieldAdd', ['blocked-infrastructure', '新增字段需要 PostgreSQL ALTER TABLE 与旧字段控件模板']],
+  ['Model/FieldAdd_Submit', ['blocked-infrastructure', '目标托管环境未开放任意物理列 DDL 与旧控件生成器']],
+  ['Model/Field_API', ['partial', '已覆盖字段顺序更新;字段删除因历史数据与物理列 DDL 被阻塞']],
 ]);
 
 const blockedDataModules = new Map([

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

@@ -71,6 +71,8 @@ let adminCreatedUserId = '';
 let temporaryGroupObjectId = '';
 const temporaryNodeObjectIds = [];
 const temporarySpecialObjectIds = [];
+let temporaryModelObjectId = '';
+const temporaryModelFieldObjectIds = [];
 let registeredUserId = '';
 let memberId = '';
 let memberLegacyId = 0;
@@ -266,6 +268,22 @@ try {
   temporarySpecialObjectIds.pop();
   await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'Special', objectId: rootSpecial.objectId });
   temporarySpecialObjectIds.pop();
+  const metadataSuffix = Date.now().toString(36);
+  const temporaryModelId = 800000000 + Math.floor(Date.now() / 1000) % 100000000;
+  const tempModel = await jsonRequest(`${PARSE_URL}/classes/Model`, { method: 'POST', body: JSON.stringify({ sourceKey: `cloud:smoke-model:${metadataSuffix}`, company, modelId: temporaryModelId, modelName: `冒烟模型-${metadataSuffix}`, tableName: `ZL_C_Smoke_${metadataSuffix}`, modelType: 1, itemName: '记录', itemUnit: '条', multiFlag: true }) }, true);
+  temporaryModelObjectId = String(tempModel.objectId || '');
+  const updatedModel = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveModelMetadata', className: 'Model', objectId: temporaryModelObjectId, fields: { modelName: `冒烟模型更新-${metadataSuffix}`, description: '仅元数据更新', tableName: 'malicious_table', modelId: 1 } });
+  if (Number(updatedModel.modelId) !== temporaryModelId || updatedModel.tableName !== `ZL_C_Smoke_${metadataSuffix}` || updatedModel.description !== '仅元数据更新') throw new Error(`模型结构字段隔离异常:${JSON.stringify(updatedModel)}`);
+  await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveModelMetadata', className: 'Model', fields: { modelName: '禁止新增模型' } }, 501);
+  for (let index = 0; index < 2; index++) {
+    const field = await jsonRequest(`${PARSE_URL}/classes/ModelField`, { method: 'POST', body: JSON.stringify({ sourceKey: `cloud:smoke-model-field:${metadataSuffix}:${index}`, company, fieldId: temporaryModelId + index + 1, modelId: temporaryModelId, fieldName: `smokeField${index}`, fieldAlias: `冒烟字段${index}`, fieldType: 'TextType', orderId: index, isShow: true }) }, true);
+    temporaryModelFieldObjectIds.push(String(field.objectId || ''));
+  }
+  const updatedField = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveModelFieldMetadata', className: 'ModelField', objectId: temporaryModelFieldObjectIds[0], fields: { fieldAlias: '更新后的字段别名', fieldTips: '仅展示元数据', fieldName: 'maliciousField', fieldType: 'SqlType', modelId: 1 } });
+  if (updatedField.fieldAlias !== '更新后的字段别名' || updatedField.fieldName !== 'smokeField0' || updatedField.fieldType !== 'TextType' || Number(updatedField.modelId) !== temporaryModelId) throw new Error(`模型字段结构隔离异常:${JSON.stringify(updatedField)}`);
+  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 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('内容待审核状态更新失败');
@@ -451,7 +469,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 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 metadata 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}`);
@@ -477,6 +495,14 @@ try {
     console.error(`临时专题清理失败:${error.message}`);
     process.exitCode = 1;
   });
+  for (const fieldId of temporaryModelFieldObjectIds) if (fieldId) await jsonRequest(`${PARSE_URL}/classes/ModelField/${fieldId}`, { method: 'DELETE' }, true).catch((error) => {
+    console.error(`临时模型字段清理失败:${error.message}`);
+    process.exitCode = 1;
+  });
+  if (temporaryModelObjectId) await jsonRequest(`${PARSE_URL}/classes/Model/${temporaryModelObjectId}`, { 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

@@ -14,6 +14,8 @@ describe('legacy admin coverage inventory', () => {
     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('Model/ModelManage')?.status).toBe('implemented');
+    expect(byKey.get('Model/ModelAdd_Submit')?.status).toBe('blocked-infrastructure');
     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');

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

@@ -3387,78 +3387,78 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Model",
     "action": "Index",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已由全部数据类与 Model/ModelField 资源页替代;旧模型类型入口布局未逐项复刻"
   },
   {
     "module": "Model",
     "action": "ModelManage",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已提供帐套模型列表、搜索、分页与安全元数据编辑"
   },
   {
     "module": "Model",
     "action": "ModelAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "新增模型需要 PostgreSQL 建表、生成标签与写入旧模板文件"
   },
   {
     "module": "Model",
     "action": "ModelAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "托管云函数未开放模型 DDL 与旧 Zoomla 模板文件运行时"
   },
   {
     "module": "Model",
     "action": "Model_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已覆盖安全模板/展示元数据更新;复制和删除模型因 DDL 被阻塞"
   },
   {
     "module": "Model",
     "action": "ModelData",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "规范化目标类可从资源页管理;任意旧附表名到 Parse 类的映射尚未完整恢复"
   },
   {
     "module": "Model",
     "action": "Field",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已提供模型字段列表、搜索、分页与安全展示元数据编辑"
   },
   {
     "module": "Model",
     "action": "Order_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已提供同模型字段批量排序云函数;专用拖拽界面尚未复刻"
   },
   {
     "module": "Model",
     "action": "FieldAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "新增字段需要 PostgreSQL ALTER TABLE 与旧字段控件模板"
   },
   {
     "module": "Model",
     "action": "FieldAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标托管环境未开放任意物理列 DDL 与旧控件生成器"
   },
   {
     "module": "Model",
     "action": "Field_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已覆盖字段顺序更新;字段删除因历史数据与物理列 DDL 被阻塞"
   },
   {
     "module": "Extend / MyCss",

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

@@ -30,7 +30,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) { <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'].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>
@@ -54,6 +54,9 @@
           @if (createUserError()) { <div class="account-create-error">{{ createUserError() }}</div> }
         </section>
       } @else {
+        @if (className() === 'Model' || className() === 'ModelField') {
+          <section class="account-create-note"><strong>仅编辑安全元数据</strong><span>模型编号、表名、字段名、字段类型和排序属于数据库结构,已锁定。新增、删除、复制或改变字段类型需要 PostgreSQL DDL 与旧模板运行时,当前会明确返回迁移阻塞。</span></section>
+        }
         @for (field of schema()?.fields || []; track field.name) { @if (field.writable) {
           <label><span>{{ field.name }} <small>{{ field.type }}{{ field.required ? ' · 必填' : '' }}</small></span>
             @if (field.type === 'Boolean') { <input type="checkbox" [ngModel]="draft()[field.name] === true" (ngModelChange)="setField(field, $event)" /> }

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

@@ -210,6 +210,38 @@ describe('AdminResourceComponent', () => {
     expect(component.specialActionMessage()).toContain('0');
   });
 
+  it('updates existing model metadata through the protected operation', async () => {
+    component.className.set('Model');
+    component.draftId.set('model-object');
+    component.schema.set({ className: 'Model', label: '内容模型', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.draft.set({ objectId: 'model-object', modelId: 2, tableName: 'ZL_C_Article', modelName: '文章模型', description: '安全说明' });
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveModelMetadata'
+      ? { objectId: 'model-object', modelId: 2, modelName: '文章模型', description: '安全说明' }
+      : { className: 'Model', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveModelMetadata', {
+      className: 'Model', objectId: 'model-object', fields: { objectId: 'model-object', modelId: 2, tableName: 'ZL_C_Article', modelName: '文章模型', description: '安全说明' },
+    }]);
+  });
+
+  it('updates existing model-field presentation metadata without structural writes', async () => {
+    component.className.set('ModelField');
+    component.draftId.set('field-object');
+    component.schema.set({ className: 'ModelField', label: '模型字段', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.draft.set({ objectId: 'field-object', fieldId: 3, modelId: 2, fieldName: 'author', fieldType: 'TextType', fieldAlias: '作者' });
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveModelFieldMetadata'
+      ? { objectId: 'field-object', fieldId: 3, fieldAlias: '作者' }
+      : { className: 'ModelField', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveModelFieldMetadata', {
+      className: 'ModelField', objectId: 'field-object', fields: { objectId: 'field-object', fieldId: 3, modelId: 2, fieldName: 'author', fieldType: 'TextType', fieldAlias: '作者' },
+    }]);
+  });
+
   it('updates content workflow state through the dedicated batch operation', async () => {
     component.className.set('CommonModel');
     component.draftId.set('content-object');

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

@@ -138,7 +138,11 @@ 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' : this.className() === 'Special' ? 'saveSpecial' : 'save';
+      const operation = this.className() === 'Group' ? 'saveGroup'
+        : this.className() === 'Node' ? 'saveNode'
+          : this.className() === 'Special' ? 'saveSpecial'
+            : this.className() === 'Model' ? 'saveModelMetadata'
+              : this.className() === 'ModelField' ? 'saveModelFieldMetadata' : '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();