Jelajahi Sumber

feat: migrate user addon administration

彭峰 2 bulan lalu
induk
melakukan
a823ca4704

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

@@ -46,8 +46,9 @@
 - 旧店铺后台 `StoreManage/Store_API/StoreStyle/Style_API/StoreStyleAdd/StoreStyleAdd_Submit/StoreUpdate/StoreUpdate_Submit` 已映射到规范化 `StoreApplication/StoreStyle`:店铺按状态和关键词筛选,支持审核、取消审核、推荐、取消推荐,现有资料可维护同帐套所属账号、样式、联系方式、地址和展示内容;样式可维护三类模板路径、缩略图和说明,删除前检查店铺引用。Angular 新增店铺、店铺样式入口、状态工具和归属/样式选择。店铺与样式旧数字 ID 都不能通过兼容层安全新增,店铺创建还依赖已丢失的模型 6 动态字段链,因此新增明确 501。旧 `ZL_Commodities` 商品主表完全缺失,目标 `Product/StoreProduct` 只有图片等扩展碎片,无法核验店铺商品引用,所以真实删除店铺与 `Product` 页面均明确数据阻塞,未冒险制造孤儿商品。
 - 旧内容附加功能 `TagKey/TagKey_Submit` 已迁移为 Angular 全局内容标签词库:保留管道符分隔、移除空白/引号/反斜杠和原顺序语义,改由 `saveContentTagKeys` 管理云函数写入 Parse Config,不再依赖旧 ASP.NET 服务器的 `/js/ICMS/tags.json` 本地文件。线上完整 Schema 核验不存在 `Comment/ContentAudit/ContentVerBak` 或等价类,因此 `Comment/Commont_API`、4 个内容审核流动作以及 `VerBak/VerBak_Del` 均由 `legacyContentAddonBlocker` 返回逐项数据阻塞原因,未用 `CommonModel` 状态伪造丢失的审批历史。
 - 旧商城供应商 `Vendor/Vendor_API/VendorAdd/VendorAdd_Submit` 全部依赖未迁入的 `ZL_Shop_Vendor`:线上 Schema 与签约数据库文档均没有 `Vendor/ShopVendor/Manufacturers`。现有 `Agency` 只有机构编号/名称/负责人,`DeliveryCenter` 只有交付中心编号/负责人,都缺供应商地址、手机、传真、邮箱和状态,不能等价映射。`legacyShopVendorBlocker` 已按动作返回数据阻塞原因,未擅自新建未签约数据类或污染机构数据。
+- 旧用户附加功能中,`PointGroup/PointGroupAdd/PointGroup_API/PointGroup_Submit` 已映射到 `UserLevel`:提供帐套隔离列表、关键词搜索、现有等级别名/积分阈值/图片/备注编辑和批量删除;旧 `id` 与 Parse 保留字段冲突,因此新增等级明确 501。`VMoneyLog` 已由只读 `UserMoneyLog` 虚拟资源统一聚合 `UserExpDomP/UserSIcon/UserExpHis/UserUserPoint/UserDummyPoint/UserCredit` 六类规范化账本,支持余额/银币/经验/积分/虚拟币/信用分类型、日期、用户名与摘要筛选,不允许篡改历史流水。旧 `ThirdInfo.config` 六个提供商和密钥未迁入,现有 `ThirdPlatInfo` 是不同业务平台且没有旧 OAuth/XML 消费者;`PrizeTaskLog(Global.SignIn)` 用户签到日志与 `UserRealNameAuth` 实名申请/奖励链也全部缺失。这 6 个动作由 `legacyUserAddonBlocker` 逐项返回数或基础设施阻塞,未把 `MisSign` 工作签到或 `Profile` 零散身份字段伪装成旧审批历史。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 91 项已实现、59 项部分实现、139 项数据阻塞、203 项基础设施阻塞、550 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 94 项已实现、61 项部分实现、144 项数据阻塞、204 项基础设施阻塞、539 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

File diff ditekan karena terlalu besar
+ 19 - 2
scripts/deploy-admin-functions.mjs


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

@@ -159,6 +159,17 @@ const explicit = new Map([
   ['Shop / Addon/Vendor_API', ['blocked-data', '目标没有可定位和删除的供应商记录;Agency/DeliveryCenter 语义不等价']],
   ['Shop / Addon/VendorAdd', ['blocked-data', '目标无签约数据类承载名称、地址、手机、传真、邮箱和状态']],
   ['Shop / Addon/VendorAdd_Submit', ['blocked-data', '按既定数据库设计不能擅自新建 ShopVendor,也不能错写 Agency/DeliveryCenter']],
+  ['User / Addon/PointGroup', ['implemented', '已由 userLevels 帐套隔离、关键词与排序分页列表替代']],
+  ['User / Addon/PointGroupAdd', ['partial', '现有积分等级编辑页已迁移;新增因旧 id 与 Parse 保留字段冲突而阻塞']],
+  ['User / Addon/PointGroup_API', ['implemented', '已由 userLevelBatch 覆盖每批 1–100 条帐套隔离删除']],
+  ['User / Addon/PointGroup_Submit', ['partial', '现有等级由 saveUserLevel 白名单更新别名、积分阈值、图片和备注;新增明确阻塞']],
+  ['User / Addon/VMoneyLog', ['implemented', '已由 userMoneyLogs 聚合六类规范化账本,保留类型、日期、关键词与用户名筛选']],
+  ['User / Addon/ThirdInfo', ['blocked-data', '旧 ThirdInfo.config 六个提供商配置与历史密钥未迁入;ThirdPlatInfo 是不同业务平台']],
+  ['User / Addon/ThirdInfo_Submit', ['blocked-infrastructure', '云函数没有旧 XML 文件系统与 Baidu/QQ/Sina/Wechat 等 OAuth 运行时消费者']],
+  ['User / Addon/SigninList', ['blocked-data', '目标 Schema 缺少 PrizeTaskLog 及 Global.SignIn 用户签到任务日志']],
+  ['User / Addon/RealNameAuth', ['blocked-data', '目标 Schema 缺少 UserRealNameAuth 申请主表、审核状态与历史数据']],
+  ['User / Addon/RealNameAuthAdd', ['blocked-data', '目标 Profile 零散身份字段不等价于可审阅的实名认证申请']],
+  ['User / Addon/RealNameAuth_API', ['blocked-data', '目标缺少实名申请记录及 Global.RealNameAuth 任务奖励链']],
   ['FontPic/Index', ['blocked-data', '目标 Schema 缺少旧 FontPic 成品字体图主表与历史数据']],
   ['FontPic/FontPicInfo', ['blocked-data', '目标 Schema 缺少旧 FontPic 成品字体图详情数据']],
   ['FontPic/FontPicInfo_Submit', ['blocked-data', '目标 Schema 缺少旧 FontPic 成品字体图持久化模型']],

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

@@ -114,6 +114,8 @@ let storeStyleRestore = null;
 let temporaryStoreApplicationId = '';
 let temporaryStoreStyleId = '';
 let contentTagsRestore = null;
+let userLevelRestore = null;
+let temporaryUserLevelId = '';
 const temporaryLessonIds = [];
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
@@ -345,6 +347,12 @@ try {
   const contentTagPage = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'contentTagKeys', className: 'ContentTagKey', page: 1, pageSize: 20, search: '' }); const originalContentTags = Array.isArray(contentTagPage.results?.[0]?.tags) ? contentTagPage.results[0].tags.map(String) : []; contentTagsRestore = originalContentTags; const tagProbe = `angular-smoke-${Date.now()}`; const probeContentTags = originalContentTags.length >= 1000 ? [...originalContentTags.slice(0, 999), tagProbe] : [...originalContentTags, tagProbe]; const savedContentTags = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveContentTagKeys', className: 'ContentTagKey', objectId: 'global-content-tags', tags: probeContentTags }); if (savedContentTags.count !== probeContentTags.length || savedContentTags.tags?.at(-1) !== tagProbe) throw new Error('内容标签 Parse Config 保存异常'); const readContentTags = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'getContentTagKeys', className: 'ContentTagKey', objectId: 'global-content-tags' }); if (readContentTags.tags?.at(-1) !== tagProbe) throw new Error('内容标签 Parse Config 写后读失败'); await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveContentTagKeys', className: 'ContentTagKey', objectId: 'global-content-tags', tags: originalContentTags }); contentTagsRestore = null;
   for (const action of ['Comment','Commont_API','ConAudit_API','ConAudit_SJ_API','ConAudit','ConAuditDetail','VerBak','VerBak_Del']) { const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyContentAddonBlocker', action }, 501); if (!String(blocked.message).startsWith('migration_blocked:')) throw new Error(`内容附加动作 ${action} 未返回明确数据阻塞`); }
   for (const action of ['Vendor','Vendor_API','VendorAdd','VendorAdd_Submit']) { const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyShopVendorBlocker', action }, 501); if (!String(blocked.message).startsWith('migration_blocked:')) throw new Error(`旧供应商动作 ${action} 未返回明确数据阻塞`); }
+  const userLevelSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'UserLevel' }); const userLevelFieldMap = Object.fromEntries((userLevelSchema.fields || []).map((field) => [field.name, field])); if (userLevelSchema.creatable !== false || ['id','cdate','ztype','addon1','addon2','addon3','addon4','addon5','orderId','storeId','zstatus','sourceKey','discountRate'].some((name) => userLevelFieldMap[name]?.writable !== false) || ['alias','pointRate','image','remark'].some((name) => userLevelFieldMap[name]?.writable !== true)) throw new Error('积分等级 Schema 字段隔离异常');
+  const userLevels = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'userLevels', className: 'UserLevel', page: 1, pageSize: 100, search: '' }); const sampleUserLevel = userLevels.results?.[0]; if (userLevels.total < 1 || !sampleUserLevel?.objectId || !Number(sampleUserLevel.id) || !sampleUserLevel.alias) throw new Error('积分等级历史数据读取异常');
+  const userLevelCreateBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveUserLevel', className: 'UserLevel', fields: {} }, 501); if (!String(userLevelCreateBlocked.message).includes('UserLevel.id')) throw new Error('积分等级新增未返回旧 ID 阻塞原因'); userLevelRestore = { objectId: sampleUserLevel.objectId, fields: { alias: sampleUserLevel.alias, pointRate: Number(sampleUserLevel.pointRate || 0), image: sampleUserLevel.image, remark: sampleUserLevel.remark } }; const levelProbeRemark = `${String(sampleUserLevel.remark || '')} [angular-smoke]`.trim(); const savedUserLevel = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveUserLevel', className: 'UserLevel', objectId: sampleUserLevel.objectId, fields: { ...sampleUserLevel, pointRate: Number(sampleUserLevel.pointRate || 0), remark: levelProbeRemark } }); if (savedUserLevel.remark !== levelProbeRemark || Number(savedUserLevel.id) !== Number(sampleUserLevel.id)) throw new Error('积分等级专用编辑失败'); await jsonRequest(`${PARSE_URL}/classes/UserLevel/${userLevelRestore.objectId}`, { method: 'PUT', body: JSON.stringify(userLevelRestore.fields) }, true); userLevelRestore = null;
+  const directUserLevel = await jsonRequest(`${PARSE_URL}/classes/UserLevel`, { method: 'POST', body: JSON.stringify({ alias: '__user_level_delete_smoke__', pointRate: 999999, image: '', remark: 'temporary', sourceKey: `smoke-user-level-${Date.now()}`, company }) }, true); temporaryUserLevelId = directUserLevel.objectId; const readDirectUserLevel = await jsonRequest(`${PARSE_URL}/classes/UserLevel/${temporaryUserLevelId}`, {}, true); if (readDirectUserLevel.id != null) throw new Error('积分等级省略 id 的创建探测意外生成了旧编号'); const deletedUserLevel = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'userLevelBatch', className: 'UserLevel', action: 'delete', objectIds: [temporaryUserLevelId] }); if (deletedUserLevel.updated !== 1) throw new Error('积分等级批量删除失败'); temporaryUserLevelId = '';
+  const userMoneySchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'UserMoneyLog' }); if (userMoneySchema.writable !== false || userMoneySchema.creatable !== false || userMoneySchema.fields?.some((field) => field.writable)) throw new Error('资金积分流水虚拟 Schema 应全部只读'); const moneyPage = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'userMoneyLogs', className: 'UserMoneyLog', type: 1, page: 1, pageSize: 20, search: '', startDate: '', endDate: '' }); const sampleMoneyLog = moneyPage.results?.[0]; if (moneyPage.total < 1 || !String(sampleMoneyLog?.objectId || '').startsWith('UserExpDomP:') || sampleMoneyLog.moneyTypeLabel !== '余额') throw new Error('余额流水规范化聚合异常'); const moneyDetail = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'getUserMoneyLog', className: 'UserMoneyLog', objectId: sampleMoneyLog.objectId }); if (moneyDetail.objectId !== sampleMoneyLog.objectId || moneyDetail.recordObjectId !== sampleMoneyLog.recordObjectId) throw new Error('资金流水详情读取异常'); const moneyDate = String(sampleMoneyLog.hisTime?.iso || sampleMoneyLog.hisTime || '').slice(0,10); const filteredMoney = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'userMoneyLogs', className: 'UserMoneyLog', type: 1, page: 1, pageSize: 20, search: String(sampleMoneyLog.userId || ''), startDate: moneyDate, endDate: moneyDate }); if (filteredMoney.total < 1) throw new Error('资金流水用户/日期筛选失败'); for (const type of [2,3,4,5,6]) { const ledger = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'userMoneyLogs', className: 'UserMoneyLog', type, page: 1, pageSize: 1, search: '', startDate: '', endDate: '' }); if (ledger.className !== 'UserMoneyLog') throw new Error(`资金流水类型 ${type} 未正确路由`); }
+  for (const action of ['ThirdInfo','ThirdInfo_Submit','SigninList','RealNameAuth','RealNameAuthAdd','RealNameAuth_API']) { const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyUserAddonBlocker', action }, 501); if (!String(blocked.message).startsWith('migration_blocked:')) throw new Error(`旧用户附加动作 ${action} 未返回明确阻塞原因`); }
   await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'save', className: 'Node', fields: { nodeName: '禁止通用新增' } }, 400);
   const nodeSuffix = Date.now().toString(36);
   const rootNode = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveNode', className: 'Node', parentId: 0, fields: { nodeName: `云函数冒烟栏目-${nodeSuffix}`, nodeDir: `smoke-${nodeSuffix}`, nodeType: 1, contentModel: '54' } });
@@ -780,7 +788,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/guest-category/bar/exam-class/exam-point/knowledge/exam-teacher/dictionary/grade-dictionary/currency/holiday/search-navigation/advertising/font-shapes/surveys/customer-service/stores/content-tags/shop-vendor-blockers/api-catalog/config-blockers/model/guestbook lifecycle/content workflow/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
+  console.log('Cloud smoke passed: admin auth/tenant/account lifecycle/group sync/node/special/guest-category/bar/exam-class/exam-point/knowledge/exam-teacher/dictionary/grade-dictionary/currency/holiday/search-navigation/advertising/font-shapes/surveys/customer-service/stores/content-tags/shop-vendor-blockers/user-level/money-ledgers/user-addon-blockers/api-catalog/config-blockers/model/guestbook lifecycle/content workflow/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
 } finally {
   if (adZoneRestore) await jsonRequest(`${PARSE_URL}/classes/AdZone/${adZoneRestore.objectId}`, { method: 'PUT', body: JSON.stringify({ remind: adZoneRestore.remind, zstatus: adZoneRestore.zstatus }) }, true).catch((error) => { console.error(`广告位冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
   if (adInfoRestore) await jsonRequest(`${PARSE_URL}/classes/AdInfo/${adInfoRestore.objectId}`, { method: 'PUT', body: JSON.stringify({ remind: adInfoRestore.remind, zstatus: adInfoRestore.zstatus }) }, true).catch((error) => { console.error(`广告内容冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
@@ -801,6 +809,8 @@ try {
   if (temporaryStoreApplicationId) await jsonRequest(`${PARSE_URL}/classes/StoreApplication/${temporaryStoreApplicationId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时店铺清理失败:${error.message}`); process.exitCode = 1; });
   if (temporaryStoreStyleId) await jsonRequest(`${PARSE_URL}/classes/StoreStyle/${temporaryStoreStyleId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时店铺样式清理失败:${error.message}`); process.exitCode = 1; });
   if (contentTagsRestore) await jsonRequest(`${PARSE_URL}/config`, { method: 'PUT', body: JSON.stringify({ params: { legacyContentTags: contentTagsRestore } }) }, true).catch((error) => { console.error(`内容标签冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
+  if (userLevelRestore) await jsonRequest(`${PARSE_URL}/classes/UserLevel/${userLevelRestore.objectId}`, { method: 'PUT', body: JSON.stringify(userLevelRestore.fields) }, true).catch((error) => { console.error(`积分等级冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
+  if (temporaryUserLevelId) await jsonRequest(`${PARSE_URL}/classes/UserLevel/${temporaryUserLevelId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时积分等级清理失败:${error.message}`); process.exitCode = 1; });
   if (contentHitObjectId) await jsonRequest(`${PARSE_URL}/classes/CommonModel/${contentHitObjectId}`, { method: 'PUT', body: JSON.stringify({ hits: contentHitOriginal }) }, true).catch((error) => {
     console.error(`公开内容浏览量恢复失败:${error.message}`);
     process.exitCode = 1;

+ 2 - 0
src/app/admin/admin.constants.ts

@@ -45,6 +45,8 @@ export const ADMIN_NAVIGATION = [
   { label: '店铺管理', icon: 'store', route: '/admin/resources/StoreApplication' },
   { label: '店铺样式', icon: 'palette', route: '/admin/resources/StoreStyle' },
   { label: '内容标签词库', icon: 'tags', route: '/admin/resources/ContentTagKey' },
+  { label: '积分等级', icon: 'badge-cent', route: '/admin/resources/UserLevel' },
+  { label: '资金积分流水', icon: 'wallet-cards', route: '/admin/resources/UserMoneyLog' },
   { label: '题库管理', icon: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
   { label: '迁移状态', icon: 'route', route: '/admin/migration' },
 ] as const;

+ 4 - 2
src/app/admin/layout/admin-shell.component.ts

@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
 import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
 import {
+  BadgeCent,
   Bell,
   BookOpen,
   Braces,
@@ -33,6 +34,7 @@ import {
   TableProperties,
   Tags,
   Users,
+  WalletCards,
 } from 'lucide-angular';
 import { ADMIN_NAVIGATION } from '../admin.constants';
 import { AdminSessionService } from '../admin-session.service';
@@ -50,9 +52,9 @@ export class AdminShellComponent {
   readonly collapsed = signal(false);
   readonly mobileOpen = signal(false);
   readonly icons = {
-    Bell, BookOpen, Braces, CalendarDays, ChartBarBig, ChartNoAxesCombined, ChevronLeft, ChevronRight,
+    BadgeCent, Bell, BookOpen, Braces, CalendarDays, ChartBarBig, ChartNoAxesCombined, ChevronLeft, ChevronRight,
     ClipboardCheck, ClipboardList, Files, GalleryHorizontalEnd, GraduationCap, Headphones, Languages, LayoutDashboard, ListChecks, ListTodo,
-    LogOut, Megaphone, Menu, MessageSquareText, Palette, Route, Search, Shapes, Store, TableProperties, Tags, Users,
+    LogOut, Megaphone, Menu, MessageSquareText, Palette, Route, Search, Shapes, Store, TableProperties, Tags, Users, WalletCards,
   };
 
   constructor(readonly sessions: AdminSessionService) {}

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

@@ -86,6 +86,14 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('Shop / Addon/Vendor')?.status).toBe('blocked-data');
     expect(byKey.get('Shop / Addon/Vendor_API')?.status).toBe('blocked-data');
     expect(byKey.get('Shop / Addon/VendorAdd_Submit')?.status).toBe('blocked-data');
+    expect(byKey.get('User / Addon/PointGroup')?.status).toBe('implemented');
+    expect(byKey.get('User / Addon/PointGroupAdd')?.status).toBe('partial');
+    expect(byKey.get('User / Addon/PointGroup_API')?.status).toBe('implemented');
+    expect(byKey.get('User / Addon/VMoneyLog')?.status).toBe('implemented');
+    expect(byKey.get('User / Addon/ThirdInfo')?.status).toBe('blocked-data');
+    expect(byKey.get('User / Addon/ThirdInfo_Submit')?.status).toBe('blocked-infrastructure');
+    expect(byKey.get('User / Addon/SigninList')?.status).toBe('blocked-data');
+    expect(byKey.get('User / Addon/RealNameAuth_API')?.status).toBe('blocked-data');
     expect(byKey.get('Exam/Teacher_Add')?.status).toBe('implemented');
     expect(byKey.get('User/User_API')?.status).toBe('partial');
     expect(byKey.get('Order/OrderList')?.status).toBe('blocked-data');

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

@@ -174,78 +174,78 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "User / Addon",
     "action": "PointGroup",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 userLevels 帐套隔离、关键词与排序分页列表替代"
   },
   {
     "module": "User / Addon",
     "action": "PointGroupAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有积分等级编辑页已迁移;新增因旧 id 与 Parse 保留字段冲突而阻塞"
   },
   {
     "module": "User / Addon",
     "action": "PointGroup_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 userLevelBatch 覆盖每批 1–100 条帐套隔离删除"
   },
   {
     "module": "User / Addon",
     "action": "PointGroup_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有等级由 saveUserLevel 白名单更新别名、积分阈值、图片和备注;新增明确阻塞"
   },
   {
     "module": "User / Addon",
     "action": "VMoneyLog",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 userMoneyLogs 聚合六类规范化账本,保留类型、日期、关键词与用户名筛选"
   },
   {
     "module": "User / Addon",
     "action": "ThirdInfo",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "旧 ThirdInfo.config 六个提供商配置与历史密钥未迁入;ThirdPlatInfo 是不同业务平台"
   },
   {
     "module": "User / Addon",
     "action": "ThirdInfo_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "云函数没有旧 XML 文件系统与 Baidu/QQ/Sina/Wechat 等 OAuth 运行时消费者"
   },
   {
     "module": "User / Addon",
     "action": "SigninList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少 PrizeTaskLog 及 Global.SignIn 用户签到任务日志"
   },
   {
     "module": "User / Addon",
     "action": "RealNameAuth",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少 UserRealNameAuth 申请主表、审核状态与历史数据"
   },
   {
     "module": "User / Addon",
     "action": "RealNameAuthAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Profile 零散身份字段不等价于可审阅的实名认证申请"
   },
   {
     "module": "User / Addon",
     "action": "RealNameAuth_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少实名申请记录及 Global.RealNameAuth 任务奖励链"
   },
   {
     "module": "Content / Ask",

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

@@ -33,6 +33,9 @@
   @if (className() === 'StoreApplication') {
     <div class="bulk-toolbar"><strong>店铺状态</strong><select [ngModel]="filterStoreStatus()" (ngModelChange)="filterStoreStatus.set(+$event); load(1)"><option [ngValue]="-100">全部状态</option><option [ngValue]="99">已审核</option><option [ngValue]="0">待审核</option><option [ngValue]="-1">未通过</option></select></div>
   }
+  @if (className() === 'UserMoneyLog') {
+    <div class="bulk-toolbar"><strong>流水类型</strong><select [ngModel]="filterUserMoneyType()" (ngModelChange)="filterUserMoneyType.set(+$event); load(1)"><option [ngValue]="1">余额</option><option [ngValue]="2">银币</option><option [ngValue]="3">经验</option><option [ngValue]="4">积分</option><option [ngValue]="5">虚拟币</option><option [ngValue]="6">信用分</option></select><input type="date" aria-label="开始日期" [ngModel]="filterUserMoneyStartDate()" (ngModelChange)="filterUserMoneyStartDate.set($event)" /><input type="date" aria-label="结束日期" [ngModel]="filterUserMoneyEndDate()" (ngModelChange)="filterUserMoneyEndDate.set($event)" /><button type="button" (click)="load(1)">按日期筛选</button></div>
+  }
   @if (bulkEnabled() && selectedIds().length) {
     <div class="bulk-toolbar"><strong>已选 {{ selectedIds().length }} 条</strong>
       @if (className() === '_User') {
@@ -77,6 +80,8 @@
         <button type="button" [disabled]="updatingStore()" (click)="applyBulkStoreAction('audit')">审核</button><button type="button" [disabled]="updatingStore()" (click)="applyBulkStoreAction('unaudit')">取消审核</button><button type="button" [disabled]="updatingStore()" (click)="applyBulkStoreAction('elite')">推荐</button><button type="button" [disabled]="updatingStore()" (click)="applyBulkStoreAction('unelite')">取消推荐</button>
       } @else if (className() === 'StoreStyle') {
         <button class="danger-action" type="button" [disabled]="updatingStore()" (click)="applyBulkStoreAction('delete')">删除样式</button>
+      } @else if (className() === 'UserLevel') {
+        <button class="danger-action" type="button" [disabled]="updatingUserLevel()" (click)="applyBulkUserLevelDelete()">删除等级</button>
       } @else {
         <button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('audit')">审核</button><button class="danger-action" type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('recycle')">回收</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('recover')">恢复</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('elite')">加精</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('top-section')">版块置顶</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('top-global')">全局置顶</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('top-undo')">取消排序</button>
         <input class="bulk-node" type="number" min="1" [ngModel]="targetBarCategoryId()" (ngModelChange)="targetBarCategoryId.set(+$event)" placeholder="版块 ID" /><button type="button" [disabled]="updatingBar() || !targetBarCategoryId()" (click)="applyBulkBarAction('move')">移动版块</button>
@@ -142,6 +147,12 @@
         @if (className() === 'ContentTagKey') {
           <section class="content-controls"><header><div><strong>全局内容标签</strong><span>对应旧 /js/ICMS/tags.json,改由管理云函数写入 Parse Config,不依赖服务器本地文件系统</span></div><b>{{ draft()['count'] || 0 }} 个</b></header><label><span>标签内容 <small>使用 | 或换行分隔,最多 1000 个</small></span><textarea rows="14" [ngModel]="tagKeyContent()" (ngModelChange)="tagKeyContent.set($event)" placeholder="教育|英语|阅读"></textarea></label><div class="content-action-status">保存时按旧逻辑移除空白、引号和反斜杠;顺序保持不变。</div></section>
         }
+        @if (className() === 'UserLevel') {
+          <section class="account-create-note"><strong>现有积分等级编辑</strong><span>仅维护旧页面的等级别名、积分阈值、图片和备注。UserLevel.id 与 Parse 保留字段冲突,因此新增请求会返回明确迁移阻塞。</span></section>
+        }
+        @if (className() === 'UserMoneyLog') {
+          <section class="account-create-note"><strong>资金积分流水只读</strong><span>旧单表已按余额、银币、经验、积分、虚拟币和信用分拆分为六个规范化账本;管理页只合并查看,不允许篡改历史流水。</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)" /> }

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

@@ -868,4 +868,47 @@ describe('AdminResourceComponent', () => {
       className: 'ContentTagKey', objectId: 'global-content-tags', fields: { objectId: 'global-content-tags', count: 2, content: '教育|英语', tags: ['教育', '英语'] }, content: '教育 | 英语\n阅读',
     }]);
   });
+
+  it('updates an existing user level through its protected operation', async () => {
+    component.className.set('UserLevel');
+    component.schema.set({ className: 'UserLevel', label: '积分等级', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.draftId.set('level-object');
+    component.draft.set({ objectId: 'level-object', id: 1, alias: '白银会员', pointRate: 100, image: '/level.png', remark: '等级备注' });
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveUserLevel'
+      ? { objectId: 'level-object', id: 1, alias: '白银会员', pointRate: 100 }
+      : { className: 'UserLevel', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveUserLevel', {
+      className: 'UserLevel', objectId: 'level-object', fields: { objectId: 'level-object', id: 1, alias: '白银会员', pointRate: 100, image: '/level.png', remark: '等级备注' },
+    }]);
+  });
+
+  it('loads the selected normalized money ledger with date filters', async () => {
+    component.className.set('UserMoneyLog');
+    component.schema.set({ className: 'UserMoneyLog', label: '资金积分流水', writable: false, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.filterUserMoneyType.set(4);
+    component.filterUserMoneyStartDate.set('2026-08-01');
+    component.filterUserMoneyEndDate.set('2026-08-18');
+    functions.admin.and.resolveTo({ className: 'UserMoneyLog', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.load(1);
+
+    expect(functions.admin).toHaveBeenCalledWith('userMoneyLogs', { className: 'UserMoneyLog', page: 1, pageSize: 20, search: '', type: 4, startDate: '2026-08-01', endDate: '2026-08-18' });
+  });
+
+  it('deletes selected user levels through the dedicated batch operation', async () => {
+    spyOn(window, 'confirm').and.returnValue(true);
+    component.className.set('UserLevel');
+    component.selectedIds.set(['level-a', 'level-b']);
+    functions.admin.and.callFake(async (operation: string) => operation === 'userLevelBatch'
+      ? { action: 'delete', updated: 2, results: [] }
+      : { className: 'UserLevel', page: 1, pageSize: 20, total: 0, results: [] });
+
+    await component.applyBulkUserLevelDelete();
+
+    expect(functions.admin.calls.first().args).toEqual(['userLevelBatch', { className: 'UserLevel', action: 'delete', objectIds: ['level-a', 'level-b'] }]);
+    expect(component.bulkMessage()).toContain('2 个积分等级');
+  });
 });

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

@@ -133,6 +133,10 @@ export class AdminResourceComponent implements OnInit {
   readonly storeStyles = signal<Record<string, unknown>[]>([]);
   readonly updatingStore = signal(false);
   readonly tagKeyContent = signal('');
+  readonly filterUserMoneyType = signal(1);
+  readonly filterUserMoneyStartDate = signal('');
+  readonly filterUserMoneyEndDate = signal('');
+  readonly updatingUserLevel = signal(false);
   readonly selectedIds = signal<string[]>([]);
   readonly bulkError = signal('');
   readonly bulkMessage = signal('');
@@ -144,7 +148,7 @@ export class AdminResourceComponent implements OnInit {
     return fields.map((field, index) => ({ field, index })).sort((a, b) => rank(a.field.name) - rank(b.field.name) || a.index - b.index).map(({ field }) => field).slice(0, 7);
   });
   readonly pageCount = computed(() => Math.max(1, Math.ceil((this.page()?.total ?? 0) / (this.page()?.pageSize || 20))));
-  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType', 'DesignAsk', 'DesignQuestion', 'DesignAnswer', 'ServiceSeat', 'Temp', 'StoreApplication', 'StoreStyle'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType', 'DesignAsk', 'DesignQuestion', 'DesignAnswer', 'ServiceSeat', 'Temp', 'StoreApplication', 'StoreStyle', 'UserLevel'].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));
@@ -165,6 +169,7 @@ export class AdminResourceComponent implements OnInit {
       this.resetServiceActions();
       this.filterStoreStatus.set(-100); this.resetStoreActions();
       this.resetTagKeyActions();
+      this.filterUserMoneyType.set(1); this.filterUserMoneyStartDate.set(''); this.filterUserMoneyEndDate.set('');
       void this.load(1, true);
     });
   }
@@ -178,7 +183,7 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'FontPicShape' && reloadSchema) await this.loadFontShapeTypes();
       if (['DesignQuestion', 'DesignAnswer'].includes(this.className()) && reloadSchema) { await this.loadSurveys(); if (!this.filterSurveyId() && this.surveys().length) this.filterSurveyId.set(this.surveyIdOf(this.surveys()[0])); }
       if (this.className() === 'StoreApplication' && reloadSchema) await this.loadStoreStyles();
-      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : this.className() === 'AdZone' ? 'adZones' : this.className() === 'AdInfo' ? 'adInfos' : this.className() === 'FontPicShape' ? 'fontShapes' : this.className() === 'FontPicShapeType' ? 'fontShapeTypes' : this.className() === 'DesignAsk' ? 'surveys' : this.className() === 'DesignQuestion' ? 'surveyQuestions' : this.className() === 'DesignAnswer' ? 'surveyResults' : this.className() === 'ServiceSeat' ? 'serviceSeats' : this.className() === 'Temp' ? 'serviceCodes' : this.className() === 'StoreApplication' ? 'storeApplications' : this.className() === 'StoreStyle' ? 'storeStyles' : this.className() === 'ContentTagKey' ? 'contentTagKeys' : 'list';
+      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : this.className() === 'AdZone' ? 'adZones' : this.className() === 'AdInfo' ? 'adInfos' : this.className() === 'FontPicShape' ? 'fontShapes' : this.className() === 'FontPicShapeType' ? 'fontShapeTypes' : this.className() === 'DesignAsk' ? 'surveys' : this.className() === 'DesignQuestion' ? 'surveyQuestions' : this.className() === 'DesignAnswer' ? 'surveyResults' : this.className() === 'ServiceSeat' ? 'serviceSeats' : this.className() === 'Temp' ? 'serviceCodes' : this.className() === 'StoreApplication' ? 'storeApplications' : this.className() === 'StoreStyle' ? 'storeStyles' : this.className() === 'ContentTagKey' ? 'contentTagKeys' : this.className() === 'UserLevel' ? 'userLevels' : this.className() === 'UserMoneyLog' ? 'userMoneyLogs' : 'list';
       const pagePayload: Record<string, unknown> = { className: this.className(), page: pageNumber, pageSize: 20, search: this.search().trim() };
       if (pageOperation === 'gradeOptions') { pagePayload['categoryId'] = this.filterGradeCategoryId(); pagePayload['parentId'] = this.filterGradeParentId(); }
       if (pageOperation === 'searchNavigations') { pagePayload['type'] = this.filterSearchNavigationType(); pagePayload['state'] = this.filterSearchNavigationState(); pagePayload['elite'] = this.filterSearchNavigationElite(); }
@@ -188,6 +193,7 @@ export class AdminResourceComponent implements OnInit {
       if (pageOperation === 'surveys') pagePayload['status'] = this.filterSurveyStatus();
       if (pageOperation === 'surveyQuestions' || pageOperation === 'surveyResults') pagePayload['askId'] = this.filterSurveyId();
       if (pageOperation === 'storeApplications') pagePayload['status'] = this.filterStoreStatus();
+      if (pageOperation === 'userMoneyLogs') { pagePayload['type'] = this.filterUserMoneyType(); pagePayload['startDate'] = this.filterUserMoneyStartDate(); pagePayload['endDate'] = this.filterUserMoneyEndDate(); }
       this.page.set(await this.functions.admin<ResourcePage>(pageOperation, pagePayload));
       if (this.className() === '_User' && reloadSchema) await this.loadUserGroups();
       if (this.className() === 'Datadic' && reloadSchema) await this.loadDictionaryCategories();
@@ -200,7 +206,7 @@ export class AdminResourceComponent implements OnInit {
     const objectId = String(row['objectId'] ?? '');
     this.error.set('');
     try {
-      const detailOperation = this.className() === 'ContentTagKey' ? 'getContentTagKeys' : 'get';
+      const detailOperation = this.className() === 'ContentTagKey' ? 'getContentTagKeys' : this.className() === 'UserMoneyLog' ? 'getUserMoneyLog' : 'get';
       const detail = await this.functions.admin<Record<string, unknown>>(detailOperation, { className: this.className(), objectId });
       this.draftId.set(objectId); this.draft.set(detail); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(detail); this.resetContentActions(detail); this.resetNodeActions(detail); this.resetSpecialActions(detail); this.resetGuestbookActions(detail); this.resetGuestCategoryActions(detail); this.resetBarActions(detail); this.resetExamClassActions(detail); this.resetExamPointActions(detail); this.resetKnowledgeActions(detail); this.resetTeacherActions(detail); this.resetDictionaryActions(detail); this.resetGradeActions(detail); this.resetAdActions(detail); this.resetFontShapeActions(detail); this.resetSurveyActions(); this.resetServiceActions(detail); this.resetStoreActions(detail); this.resetTagKeyActions(detail); this.editorOpen.set(true);
       if (this.className() === '_User') await this.loadUserGroups();
@@ -268,7 +274,8 @@ export class AdminResourceComponent implements OnInit {
                                                         : this.className() === 'Temp' ? 'saveServiceCode'
                                                           : this.className() === 'StoreApplication' ? 'saveStoreApplication'
                                                             : this.className() === 'StoreStyle' ? 'saveStoreStyle'
-                                                              : this.className() === 'ContentTagKey' ? 'saveContentTagKeys' : 'save';
+                                                              : this.className() === 'ContentTagKey' ? 'saveContentTagKeys'
+                                                                : this.className() === 'UserLevel' ? 'saveUserLevel' : '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();
@@ -684,6 +691,15 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.bulkError.set(error instanceof Error ? error.message : '店铺批量操作失败'); }
     finally { this.updatingStore.set(false); }
   }
+  async applyBulkUserLevelDelete(): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'UserLevel' || !objectIds.length) { this.bulkError.set('请先选择积分等级'); return; }
+    if (!confirm(`确认删除选中的 ${objectIds.length} 个积分等级?`)) return;
+    this.updatingUserLevel.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>('userLevelBatch', { className: 'UserLevel', action: 'delete', objectIds }); await this.load(); this.bulkMessage.set(`已删除 ${result.updated} 个积分等级`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : '积分等级删除失败'); }
+    finally { this.updatingUserLevel.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     const verb = ['CommonModel', 'Node', 'Guestbook'].includes(this.className()) ? '移入回收站' : '删除';
@@ -697,6 +713,7 @@ export class AdminResourceComponent implements OnInit {
       else if (['DesignAsk', 'DesignQuestion', 'DesignAnswer'].includes(this.className())) await this.functions.admin('surveyBatch', { className: this.className(), action: 'delete', objectIds: [objectId] });
       else if (['ServiceSeat', 'Temp'].includes(this.className())) await this.functions.admin('serviceBatch', { className: this.className(), action: 'delete', objectIds: [objectId] });
       else if (['StoreApplication', 'StoreStyle'].includes(this.className())) await this.functions.admin('storeBatch', { className: this.className(), action: 'delete', objectIds: [objectId] });
+      else if (this.className() === 'UserLevel') await this.functions.admin('userLevelBatch', { className: 'UserLevel', action: 'delete', objectIds: [objectId] });
       else await this.functions.admin('delete', { className: this.className(), objectId });
       await this.load();
     }

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini