소스 검색

feat: migrate currency management safely

彭峰 1 개월 전
부모
커밋
75b0d14d29

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

@@ -35,8 +35,9 @@
 - 旧考试后台 `ExTeacherManage/AddExTeacher/Teacher_Add` 已迁到 `ExTeacher`:`saveExamTeacher` 仅白名单保存姓名、岗位、授课、头像路径和简介,使用帐套事务锁原子分配旧 `id/sourceKey`,服务端记录创建管理员与时间,并校验任教 `ExamClass` 属于同一帐套。Angular 新增“考试教师”入口和任教分类结构控件;线上 Schema 扫描未发现指向 `ExTeacher` 的 Pointer 或明确数字外键,删除仍保留帐套隔离和审计。
 - 旧数据字典 `DicCateList/DictCate_API/DicCateSel/DicItemList/DicItem_API` 已按官方 `ZL_Datadiccategory/ZL_Datadic` 模型迁为 `saveDictionaryCategory/saveDictionaryItem/dictionaryCategories/dictionaryBatch`:分类与字典项分别分配旧 `DicCateID/DicID`,校验帐套内分类名称和分类内字典项名称防重,支持每批 1–100 条启用、停用和删除;分类仍有字典项时禁止删除。线上这两类的业务字段由迁移兼容层从登记数据还原,Parse Schema 虽声明字段但 PostgreSQL 物理表没有对应列,因此编号改为经 Parse 读取全局最大值并依靠唯一 `sourceKey` 冲突重试,未伪装成不可执行的物理列事务;新分类编号还会高于所有既有 `diccate` 引用,避免错误接管历史字典项。Angular 新增分类、字典项导航和帐套隔离分类选择控件。
 - 旧多级数据字典 `GradeCateManage/GCateAdd/GCateAdd_Submit/GCate_API/GradeOption/GOptionAdd/GOptionAdd_Submit/GOption_API` 已按官方 `ZL_GradeCate/ZL_Grade` 模型迁为 `saveGradeCategory/saveGradeOption/gradeCategories/gradeOptions/gradeBatch`:分类维护至少两级的 `gradeField` 层级别名,选项按分类和父选项自动推导 `grade`,校验同级名称防重、父级归属和最大层级;编辑选项只允许改名,不能篡改 `cate/parentId/grade`。分类和选项分别保护仍有选项、下级选项的删除,Angular 提供按分类及父选项查看当前层、专用新建表单与批量删除。`GradeCate` 的兼容层计数会错误返回 0,因此编号和读取同样使用 Parse 行结果与唯一 `sourceKey` 冲突重试,不依赖失真的 count 或缺失物理业务列。
+- 旧商城扩展 `Currency/Currency_API` 的现有货币管理已迁到目标 `Currency`:`saveCurrency` 白名单更新货币名称、2–10 位大写代码、符号、正数当前汇率与备注,并校验帐套内名称及代码唯一;`currencyBatch` 支持每批 1–100 条帐套隔离删除。`CurrencyAdd/CurrencyAdd_Submit` 明确列为基础设施阻塞:旧数字字段就叫 `id`,与 Parse SDK 对象保留 `id` 冲突,兼容层物理表又没有可写业务列,实测以 `sourceKey` 新增后重新读取仍无法得到旧数字 ID;Angular 已隐藏新增入口,云函数对新增返回 501,避免产生坏数据。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 61 项已实现、32 项部分实现、114 项数据阻塞、189 项基础设施阻塞、646 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 63 项已实现、32 项部分实现、114 项数据阻塞、191 项基础设施阻塞、642 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

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

@@ -13,6 +13,7 @@ const CLASS_LABELS = {
   ExamClass: '试题分类', ExamSysQuestions: '考试题库', ExamSysPapers: '试卷', ExamType: '考试类型', ExamPoint: '知识点', PaperQuestions: '试卷题目',
   Datadiccategory: '数据字典分类', Datadic: '数据字典项',
   GradeCate: '多级字典分类', Grade: '多级字典选项',
+  Currency: '货币管理',
   Product: '商品', StoreProduct: '门店商品', StoreApplication: '门店申请', ShopFareTlp: '运费模板', ShopMoneyRegular: '金额规则', PayPlat: '支付平台',
   GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科', Search: '搜索记录', Agency: '代理机构', DeliveryCenter: '交付中心'
 };
@@ -39,7 +40,8 @@ const CLASS_SYSTEM_FIELDS = {
   Datadiccategory: new Set(['diccateid','sourceKey']),
   Datadic: new Set(['dicid','diccate','sourceKey']),
   GradeCate: new Set(['cateId','sourceKey']),
-  Grade: new Set(['gradeId','cate','parentId','grade','sourceKey'])
+  Grade: new Set(['gradeId','cate','parentId','grade','sourceKey']),
+  Currency: new Set(['id','sourceKey'])
 };
 
 function inputOf(request) {
@@ -53,11 +55,12 @@ function safeValue(value, depth = 0) {
   if (value == null || depth > 4) return value;
   if (Array.isArray(value)) return value.map((item) => safeValue(item, depth + 1));
   if (value instanceof Date) return value.toISOString();
+  if (value && typeof value === 'object' && ['Object','Pointer'].includes(value.__type) && value.className && value.objectId) return { __type: 'Pointer', className: String(value.className), objectId: String(value.objectId) };
   if (value && typeof value.toJSON === 'function') return safeValue(value.toJSON(), depth + 1);
   if (typeof value === 'object') {
     const output = {};
     for (const [key, item] of Object.entries(value)) {
-      if (HIDDEN_FIELDS.has(key) || /(?:password|secret|sessiontoken|masterkey|privatekey)/i.test(key)) continue;
+      if (HIDDEN_FIELDS.has(key) || /(?:password|secret|token|masterkey|privatekey|mch_key|^ak$|^sk$)/i.test(key)) continue;
       output[key] = safeValue(item, depth + 1);
     }
     return output;
@@ -881,6 +884,29 @@ async function handler(request, response) {
       await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'grade-delete', className, target.id);
       return response.json({ success: true, data: { action: 'delete', updated: targets.length, results: [] } });
     }
+    if (operation === 'saveCurrency') {
+      const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
+      const title = String(payload.title || '').trim(); const currencyName = String(payload.currencyName || '').trim().toUpperCase(); const currencySymbol = String(payload.currencySymbol || '').trim(); const exchange = Number(payload.currentExchange); const remark = String(payload.remark || '').trim();
+      if (!title || title.length > 50) fail(400, '货币名称长度应为 1 至 50 位');
+      if (!/^[A-Z][A-Z0-9]{1,9}$/.test(currencyName)) fail(400, '货币代码应为 2 至 10 位大写字母或数字');
+      if (!currencySymbol || currencySymbol.length > 16) fail(400, '货币符号长度应为 1 至 16 位');
+      if (!Number.isFinite(exchange) || exchange <= 0 || exchange > 1000000000) fail(400, '当前汇率必须是大于 0 的有效数字');
+      if (remark.length > 255) fail(400, '货币备注不能超过 255 位');
+      let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '货币必须指定帐套');
+      const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true }); const fields = await schemaFor('Currency'); let target;
+      if (!objectId) fail(501, 'migration_blocked: Currency.id 与 Parse 对象保留 id 冲突,且 PostgreSQL 物理表缺少可写旧 ID 列,不能安全新增货币');
+      { const query = new Parse.Query('Currency'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); companyId = pointerId(target.get('company')) || companyId; }
+      const duplicateQuery = new Parse.Query('Currency'); applyTenant(duplicateQuery, fields, context, input.companyId); duplicateQuery.limit(1000); const duplicates = await duplicateQuery.find({ useMasterKey: true });
+      if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('title') || '').trim().toLowerCase() === title.toLowerCase())) fail(409, '同一帐套的货币名称不能重复');
+      if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('currencyName') || '').trim().toUpperCase() === currencyName)) fail(409, '同一帐套的货币代码不能重复');
+      target.set('title', title); target.set('currencyName', currencyName); target.set('currencySymbol', currencySymbol); target.set('currentExchange', exchange); target.set('remark', remark); target.set('company', company);
+      try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '货币写入失败: ' + String(error && error.message || error)); }
+      await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-currency' : 'create-currency', 'Currency', target.id); return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'currencyBatch') {
+      if (String(input.className || '') !== 'Currency' || String(input.action || '') !== 'delete') fail(400, '不支持的货币批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条货币记录');
+      const fields = await schemaFor('Currency'); const query = new Parse.Query('Currency'); 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, '部分货币记录不存在或不属于当前帐套'); await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'currency-delete', 'Currency', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length, results: [] } });
+    }
     if (operation === 'saveExamClass') {
       const objectId = String(input.objectId || '');
       const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
@@ -1144,7 +1170,7 @@ async function handler(request, response) {
     const classWritable = !READ_ONLY_CLASSES.has(className);
     if (operation === 'schema') {
       const fieldList = Object.entries(fields).filter(([name]) => !HIDDEN_FIELDS.has(name) && !/(?:password|secret|sessiontoken|masterkey|privatekey)/i.test(name)).map(([name, field]) => ({ name, type: field.type, targetClass: field.targetClass, required: field.required === true, writable: classWritable && !isSystemField(className, name) && GENERIC_WRITE_TYPES.has(field.type) }));
-      return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, creatable: classWritable && !['CommonModel','Model','ModelField','Guestbook'].includes(className), supportsSoftDelete: Boolean(fields.isDeleted) } });
+      return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, creatable: classWritable && !['CommonModel','Model','ModelField','Guestbook','Currency'].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));
@@ -1176,6 +1202,7 @@ async function handler(request, response) {
       if (className === 'ExTeacher') fail(400, '考试教师必须走专用保存流程');
       if (className === 'Datadiccategory' || className === 'Datadic') fail(400, '数据字典必须走专用保存流程');
       if (className === 'GradeCate' || className === 'Grade') fail(400, '多级数据字典必须走专用保存流程');
+      if (className === 'Currency') 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);

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

@@ -109,6 +109,10 @@ const explicit = new Map([
   ['Dict/GOptionAdd', ['implemented', '已由 Grade 专用分类、父级选项表单替代']],
   ['Dict/GOptionAdd_Submit', ['implemented', '已由 saveGradeOption 自动层级、同级防重和父级归属校验替代']],
   ['Dict/GOption_API', ['implemented', '已由 gradeBatch 带下级引用保护的批量删除替代']],
+  ['Shop / Exp/Currency', ['implemented', '已提供帐套货币列表、搜索、分页与批量删除']],
+  ['Shop / Exp/CurrencyAdd', ['blocked-infrastructure', 'Currency.id 与 Parse 保留 id 冲突,物理表又缺少可写旧 ID 列,Angular 已隐藏新增入口']],
+  ['Shop / Exp/CurrencyAdd_Submit', ['blocked-infrastructure', '无法为新货币持久化旧数字 ID;saveCurrency 对新增明确返回 migration_blocked']],
+  ['Shop / Exp/Currency_API', ['implemented', '已由 currencyBatch 帐套隔离批量删除替代']],
 ]);
 
 const blockedDataModules = new Map([

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

@@ -422,6 +422,9 @@ try {
   await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'gradeBatch', className: 'Grade', action: 'delete', objectIds: [childGrade.objectId] }); temporaryGradeOptionIds.pop();
   await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'gradeBatch', className: 'Grade', action: 'delete', objectIds: [rootGrade.objectId] }); temporaryGradeOptionIds.pop();
   await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'gradeBatch', className: 'GradeCate', action: 'delete', objectIds: [gradeCategory.objectId] }); temporaryGradeCategoryIds.pop();
+  const currencyCreateBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveCurrency', className: 'Currency', fields: { title: '禁止无旧 ID 货币', currencyName: 'TST', currencySymbol: 'T$', currentExchange: 1.25, remark: '应被阻塞' } }, 501);
+  if (!String(currencyCreateBlocked.message).includes('保留 id')) throw new Error('货币新增未返回明确旧 ID 阻塞原因');
+  if (dictionaryCategory.company?.__type !== 'Pointer' || JSON.stringify(dictionaryCategory).match(/accessToken|mch_key|"ak"|"sk"/i)) throw new Error('后台响应展开了帐套敏感配置');
   const examPointSuffix = Date.now().toString(36);
   const rootExamPoint = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveExamPoint', className: 'ExamPoint', parentId: 0, orderId: 0, fields: { testPoint: `冒烟考点-${examPointSuffix}` } });
   temporaryExamPointObjectIds.push(String(rootExamPoint.objectId || ''));
@@ -656,7 +659,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/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/model/guestbook lifecycle/content workflow/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
 } finally {
   if (contentHitObjectId) await jsonRequest(`${PARSE_URL}/classes/CommonModel/${contentHitObjectId}`, { method: 'PUT', body: JSON.stringify({ hits: contentHitOriginal }) }, true).catch((error) => {
     console.error(`公开内容浏览量恢复失败:${error.message}`);

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

@@ -28,6 +28,7 @@ export const ADMIN_NAVIGATION = [
   { label: '数据字典', icon: 'list-tree', route: '/admin/resources/Datadic' },
   { label: '多级字典分类', icon: 'layers-3', route: '/admin/resources/GradeCate' },
   { label: '多级字典选项', icon: 'git-branch', route: '/admin/resources/Grade' },
+  { label: '货币管理', icon: 'circle-dollar-sign', route: '/admin/resources/Currency' },
   { label: '题库管理', icon: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
   { label: '迁移状态', icon: 'route', route: '/admin/migration' },
 ] as const;

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

@@ -38,6 +38,9 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('Dict/GCateAdd_Submit')?.status).toBe('implemented');
     expect(byKey.get('Dict/GradeOption')?.status).toBe('implemented');
     expect(byKey.get('Dict/GOptionAdd_Submit')?.status).toBe('implemented');
+    expect(byKey.get('Shop / Exp/Currency')?.status).toBe('implemented');
+    expect(byKey.get('Shop / Exp/CurrencyAdd_Submit')?.status).toBe('blocked-infrastructure');
+    expect(byKey.get('Shop / Exp/Currency_API')?.status).toBe('implemented');
     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');

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

@@ -2414,29 +2414,29 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Shop / Exp",
     "action": "Currency",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已提供帐套货币列表、搜索、分页与批量删除"
   },
   {
     "module": "Shop / Exp",
     "action": "CurrencyAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "Currency.id 与 Parse 保留 id 冲突,物理表又缺少可写旧 ID 列,Angular 已隐藏新增入口"
   },
   {
     "module": "Shop / Exp",
     "action": "CurrencyAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "无法为新货币持久化旧数字 ID;saveCurrency 对新增明确返回 migration_blocked"
   },
   {
     "module": "Shop / Exp",
     "action": "Currency_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 currencyBatch 帐套隔离批量删除替代"
   },
   {
     "module": "Files",

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

@@ -28,6 +28,8 @@
         <button type="button" [disabled]="updatingDictionary()" (click)="applyBulkDictionaryAction('enable')">启用</button><button type="button" [disabled]="updatingDictionary()" (click)="applyBulkDictionaryAction('disable')">停用</button><button class="danger-action" type="button" [disabled]="updatingDictionary()" (click)="applyBulkDictionaryAction('delete')">删除</button>
       } @else if (className() === 'GradeCate' || className() === 'Grade') {
         <button class="danger-action" type="button" [disabled]="updatingGrade()" (click)="applyBulkGradeDelete()">删除</button>
+      } @else if (className() === 'Currency') {
+        <button class="danger-action" type="button" [disabled]="updatingCurrency()" (click)="applyBulkCurrencyDelete()">删除</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>

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

@@ -459,6 +459,34 @@ describe('AdminResourceComponent', () => {
     expect(component.bulkMessage()).toContain('2 条多级字典记录');
   });
 
+  it('updates an existing currency through the validated dedicated operation', async () => {
+    component.className.set('Currency');
+    component.schema.set({ className: 'Currency', label: '货币管理', writable: true, supportsSoftDelete: false, fields: [] });
+    component.draftId.set('currency-object');
+    component.draft.set({ title: '测试币', currencyName: 'TST', currencySymbol: 'T$', currentExchange: 1.25, remark: '测试', id: 999 });
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveCurrency'
+      ? { objectId: 'currency-object', id: 20, title: '测试币', currencyName: 'TST' }
+      : { className: 'Currency', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveCurrency', {
+      className: 'Currency', objectId: 'currency-object', fields: { title: '测试币', currencyName: 'TST', currencySymbol: 'T$', currentExchange: 1.25, remark: '测试', id: 999 },
+    }]);
+  });
+
+  it('batch deletes selected currencies through the tenant-safe operation', async () => {
+    component.className.set('Currency'); component.selectedIds.set(['currency-a']); spyOn(window, 'confirm').and.returnValue(true);
+    functions.admin.and.callFake(async (operation: string) => operation === 'currencyBatch'
+      ? { action: 'delete', updated: 1, results: [] }
+      : { className: 'Currency', page: 1, pageSize: 20, total: 0, results: [] });
+
+    await component.applyBulkCurrencyDelete();
+
+    expect(functions.admin.calls.first().args).toEqual(['currencyBatch', { className: 'Currency', action: 'delete', objectIds: ['currency-a'] }]);
+    expect(component.bulkMessage()).toContain('1 条货币记录');
+  });
+
   it('updates a forum post through the dedicated workflow operation', async () => {
     component.className.set('GuestBar');
     component.draftId.set('bar-object');

+ 13 - 2
src/app/admin/pages/admin-resource.component.ts

@@ -89,6 +89,7 @@ export class AdminResourceComponent implements OnInit {
   readonly filterGradeParentId = signal(0);
   readonly gradeCategories = signal<Record<string, unknown>[]>([]);
   readonly updatingGrade = signal(false);
+  readonly updatingCurrency = signal(false);
   readonly selectedIds = signal<string[]>([]);
   readonly bulkError = signal('');
   readonly bulkMessage = signal('');
@@ -100,7 +101,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'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency'].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));
@@ -188,7 +189,8 @@ export class AdminResourceComponent implements OnInit {
                             : this.className() === 'Datadiccategory' ? 'saveDictionaryCategory'
                               : this.className() === 'Datadic' ? 'saveDictionaryItem'
                                 : this.className() === 'GradeCate' ? 'saveGradeCategory'
-                                  : this.className() === 'Grade' ? 'saveGradeOption' : 'save';
+                                  : this.className() === 'Grade' ? 'saveGradeOption'
+                                    : this.className() === 'Currency' ? 'saveCurrency' : '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();
@@ -493,6 +495,15 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量删除多级字典失败'); }
     finally { this.updatingGrade.set(false); }
   }
+  async applyBulkCurrencyDelete(): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'Currency' || !objectIds.length) { this.bulkError.set('请先选择货币记录'); return; }
+    if (!confirm(`确认删除选中的 ${objectIds.length} 条货币记录?`)) return;
+    this.updatingCurrency.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>('currencyBatch', { className: 'Currency', action: 'delete', objectIds }); await this.load(); this.bulkMessage.set(`已删除 ${result.updated} 条货币记录`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量删除货币失败'); }
+    finally { this.updatingCurrency.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     const verb = ['CommonModel', 'Node', 'Guestbook'].includes(this.className()) ? '移入回收站' : '删除';