Jelajahi Sumber

feat: migrate user promotion reporting

彭峰 1 bulan lalu
induk
melakukan
90e0d0a851

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

@@ -37,6 +37,8 @@
 - `Page` 控制器中与旧字段完全匹配的 `PageStyle` 已迁为 Angular“黄页样式”页与 `pageStyles/savePageStyle/pageStyleBatch`:支持帐套隔离、名称搜索与防重、路径和状态校验、原子分配旧 `PageNodeid`、单一默认样式及 `PageTemplate.userGroup` 引用保护删除。目标 `PageTemplate` 虽保留旧字段但为空,且没有 `PageReg` 用户黄页申请主表或 `SiteOption` 黄页配置层;内容管理仅部分复用现有 `CommonModel`,其余 15 个旧入口由 `legacyPageBlocker` 返回逐项数据或基础设施原因。
 - `Office/Plat` 的 `PlatComp` 与折叠在 `_User.legacyUserPlat` 的旧 `User_Plat` 数据已迁为 Angular“协同办公企业”页面以及 `platformCompanies/savePlatformCompany/platformCompanyMembers/platformCompanyMemberBatch/platformCompanyBatch`:兼容失真的 count,支持企业资料防重、原子旧 ID、同帐套创建人、成员查看/加入/移动/移出,并保护仍有成员或 `PlatUserRole` 角色的企业删除;`legacyUserPlat` 已从通用用户编辑锁定。旧 `Common_UserApply/Blog_Msg/Plat_Topic/Plat_Group` 主表均不存在,申请审核、动态消息、话题和企业群组入口由 `legacyPlatBlocker` 逐项返回原因;旧 Razor `CreateComp/WordTlp` 视图也没有可恢复的交互基线。
 - `Printer/Index` 的控制器本身只是返回已丢失的 Razor 视图;旧程序集可确认其候选业务依赖 `ZL_Shop_PrintDevice/ZL_Shop_PrintTlp/ZL_Shop_PrintMessage` 和飞印客户端,但签约文档与线上 110 个 Schema 均没有这些设备、模板和消息数据,旧客户端还向 `my.feyin.net` 发送明文 HTTP 请求。无法在不知道页面、历史设备密钥和第三方协议可用性的情况下安全伪造 1:1 打印后台,已由 `legacyPrinterBlocker` 明确登记为基础设施阻塞。
+- `User/PrivacyPolicy` 管理的不是普通静态文案,而是 `ZL_LicenceFiles` 用户协议、强制签署、PDF 表单字段、限定用户/用户组以及 `ZL_LicenceUserSign` 逐用户签约历史。目标 Schema 与签约文档均无这两类数据或协议 PDF,5 个动作复用 `legacyLicenceBlocker` 返回逐项数据阻塞原因,不能用注销通道或任意新文本冒充已经签署过的法律记录。
+- `User/Promo.PromoList` 已迁为只读 `UserPromotion` 虚拟资源与 `userPromotions/getUserPromotion`:从 `_User.legacyUserData.ParentUserID` 实时还原用户、邀请人、直属推广人数,支持账号/昵称、邀请人 ID、“仅推广人”和推广成员注册日期筛选,日期语义保持旧版只约束直属成员统计。旧 `Cash` 提现申请、预扣退款账本和 `User_Bank` 提现账户均未迁入,提现首页、审核/驳回及账户维护等 10 个动作由 `legacyUserPromoBlocker` 明确阻塞,发票银行字段不会被误作提现账户。
 - 旧 `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/旧模板运行时。

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


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

@@ -253,6 +253,22 @@ const explicit = new Map([
   ['Office / Plat/CrudAdd_Submit', ['blocked-data', '目标缺少 Plat_Group 的 FirstID/Depth/ManageIDS/MemberIDS 持久层']],
   ['Office / Plat/GroupAdmin_API', ['blocked-data', '目标没有可删除的 Plat_Group 企业群组记录']],
   ['Printer/Index', ['blocked-infrastructure', '旧控制器只返回已丢失的 Razor 视图;目标 Schema 又缺少 PrintDevice/PrintTlp/PrintMessage,且旧飞印客户端依赖明文 HTTP 第三方协议,无法安全等价恢复']],
+  ['User / PrivacyPolicy/LicenceList', ['blocked-data', '目标 Schema 缺少 ZL_LicenceFiles 用户协议定义、强制签署类型与限定用户/用户组数据']],
+  ['User / PrivacyPolicy/LicenceAdd', ['blocked-data', '目标没有可编辑或新增的用户协议、PDF 路径、字段映射和适用范围记录']],
+  ['User / PrivacyPolicy/LicenceAdd_Submit', ['blocked-data', '目标缺少 LicenceFiles 持久层及协议 PDF 文件,不能只保存失效路径']],
+  ['User / PrivacyPolicy/Licence_API', ['blocked-data', '目标没有可启用、停用或删除的用户协议定义']],
+  ['User / PrivacyPolicy/LicenceUserSign', ['blocked-data', '目标 Schema 缺少 ZL_LicenceUserSign 用户签约历史及协议、用户关联']],
+  ['User / Promo/Index', ['blocked-data', '旧入口只重定向到提现申请页,目标缺少 Cash 提现主表,无法提供等价落点']],
+  ['User / Promo/WD', ['blocked-data', '目标 Schema 缺少 Cash 提现申请、手续费、结果、凭证与处理状态记录']],
+  ['User / Promo/WD_API', ['blocked-data', '目标没有可审核的提现申请记录']],
+  ['User / Promo/WDAudit', ['blocked-data', '目标缺少指定提现申请的金额、手续费、账户和处理详情']],
+  ['User / Promo/WDAudit_Accept', ['blocked-data', '目标缺少可原子审核的 Cash 提现事务记录']],
+  ['User / Promo/WDAudit_Reject', ['blocked-data', '目标缺少 Cash 提现申请及对应预扣余额账本,不能安全执行拒绝退款']],
+  ['User / Promo/PromoList', ['implemented', '已由 UserPromotion 虚拟资源和 userPromotions 云函数从 _User.legacyUserData.ParentUserID 还原推广关系、人数、日期及邀请人筛选']],
+  ['User / Promo/UserBank', ['blocked-data', '目标 Schema 缺少 User_Bank 提现账户及审核状态数据']],
+  ['User / Promo/UserBankAdd', ['blocked-data', '目标没有可编辑的开户人、卡号、账户类型和开户行记录']],
+  ['User / Promo/UserBankAdd_Submit', ['blocked-data', '目标缺少 User_Bank 持久层,不能把提现账户错写到发票银行字段']],
+  ['User / Promo/UserBank_API', ['blocked-data', '目标没有可删除、审核或取消审核的提现账户记录']],
   ['Design / Interactive/InteractiveList', ['blocked-data', '目标缺少旧 Design_Pub 交互提交表及 sceneid、uid、fname 数据;DesignAnswer/Pub 不等价']],
   ['Design / Interactive/Interactive_API', ['blocked-data', '目标没有可按旧 Design_Pub ID 删除的交互提交记录']],
   ['Office / iServer/Default', ['blocked-data', '目标 Schema 缺少旧 IServer 服务工单主表与回复表']],

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

@@ -709,9 +709,11 @@ try {
   for (const action of ['Default','AddQuestionRecord','AddQuestionRecord_Submit','BiServer','BiServer_Del','BiServerInfo','DelIServer','UpdateIServer','BselectiServer','ISReplyAdd','ISReplyAdd_Submit']) {
     const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyIServerBlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧 iServer 动作 ${action} 未返回明确迁移阻塞`);
   }
-  for (const action of ['LicenceFiles','LicenceFiles_API','LicenceFilesAdd','LicenceFilesAdd_Submit','LicenceSignApplyList','LicenceSignApplyDetail','LicenceSignApply_API','LicenceSignList','LicenceSignDetail','LicenceSign_API']) {
+  for (const action of ['LicenceFiles','LicenceFiles_API','LicenceFilesAdd','LicenceFilesAdd_Submit','LicenceSignApplyList','LicenceSignApplyDetail','LicenceSignApply_API','LicenceSignList','LicenceSignDetail','LicenceSign_API','LicenceList','LicenceAdd','LicenceAdd_Submit','Licence_API','LicenceUserSign']) {
     const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyLicenceBlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧许可签约动作 ${action} 未返回明确迁移阻塞`);
   }
+  const promotionSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'UserPromotion' }); if (promotionSchema.writable !== false || promotionSchema.creatable !== false || !promotionSchema.fields?.some((field) => field.name === 'promotionCount' && field.writable === false)) throw new Error('用户推广关系 Schema 应保持只读'); const promotionPage = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'userPromotions', className: 'UserPromotion', page: 1, pageSize: 20, search: '', parentUserId: 0, onlyWithChildren: false, startDate: '', endDate: '' }); if (!Number.isInteger(promotionPage.total) || !Array.isArray(promotionPage.results)) throw new Error('用户推广关系列表响应异常'); const promotionSample = promotionPage.results?.[0]; if (promotionSample) { const promotionDetail = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'getUserPromotion', className: 'UserPromotion', objectId: promotionSample.objectId }); if (promotionDetail.objectId !== promotionSample.objectId || !Number.isInteger(Number(promotionDetail.promotionCount))) throw new Error('用户推广关系详情异常'); } const promotionParents = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'userPromotions', className: 'UserPromotion', page: 1, pageSize: 20, search: '', parentUserId: 0, onlyWithChildren: true, startDate: '', endDate: '' }); if (promotionParents.results?.some((entry) => Number(entry.promotionCount) < 1)) throw new Error('仅推广人筛选返回了无成员用户');
+  for (const action of ['Index','WD','WD_API','WDAudit','WDAudit_Accept','WDAudit_Reject','UserBank','UserBankAdd','UserBankAdd_Submit','UserBank_API']) { const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyUserPromoBlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧推广提现动作 ${action} 未返回明确迁移阻塞`); }
   for (const action of ['TxtLog','TxtLogContent','TxtLog_Down','TaskList','TaskAdd','TaskAdd_API','TaskAdd_Submit','Task_API','TaskCenter']) {
     const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyLogBlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧日志任务动作 ${action} 未返回明确迁移阻塞`);
   }

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

@@ -49,6 +49,7 @@ export const ADMIN_NAVIGATION = [
   { 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: 'network', route: '/admin/resources/UserPromotion' },
   { label: 'CRM 客户类型', icon: 'contact-round', route: '/admin/resources/CRMSAttr' },
   { label: '商城运费模板', icon: 'truck', route: '/admin/resources/ShopFareTlp' },
   { label: '系统日志', icon: 'scroll-text', route: '/admin/resources/SysLog' },

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

@@ -53,6 +53,9 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('Config/APIInfo_Submit')?.status).toBe('blocked-infrastructure');
     expect(byKey.get('Config/ReadFile')?.status).toBe('implemented');
     expect(byKey.get('Config/LicenceFile_API')?.status).toBe('blocked-data');
+    expect(byKey.get('User / PrivacyPolicy/LicenceUserSign')?.status).toBe('blocked-data');
+    expect(byKey.get('User / Promo/PromoList')?.status).toBe('implemented');
+    expect(byKey.get('User / Promo/WDAudit_Reject')?.status).toBe('blocked-data');
     expect(byKey.get('Config/FontIcon')?.status).toBe('implemented');
     expect(byKey.get('Config/Hotkey_API')?.status).toBe('blocked-infrastructure');
     expect(byKey.get('Config/Prize_Submit')?.status).toBe('blocked-infrastructure');

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

@@ -4647,36 +4647,36 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "User / PrivacyPolicy",
     "action": "LicenceList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少 ZL_LicenceFiles 用户协议定义、强制签署类型与限定用户/用户组数据"
   },
   {
     "module": "User / PrivacyPolicy",
     "action": "LicenceAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可编辑或新增的用户协议、PDF 路径、字段映射和适用范围记录"
   },
   {
     "module": "User / PrivacyPolicy",
     "action": "LicenceAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少 LicenceFiles 持久层及协议 PDF 文件,不能只保存失效路径"
   },
   {
     "module": "User / PrivacyPolicy",
     "action": "Licence_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可启用、停用或删除的用户协议定义"
   },
   {
     "module": "User / PrivacyPolicy",
     "action": "LicenceUserSign",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少 ZL_LicenceUserSign 用户签约历史及协议、用户关联"
   },
   {
     "module": "Product",
@@ -4843,78 +4843,78 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "User / Promo",
     "action": "Index",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "旧入口只重定向到提现申请页,目标缺少 Cash 提现主表,无法提供等价落点"
   },
   {
     "module": "User / Promo",
     "action": "WD",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少 Cash 提现申请、手续费、结果、凭证与处理状态记录"
   },
   {
     "module": "User / Promo",
     "action": "WD_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可审核的提现申请记录"
   },
   {
     "module": "User / Promo",
     "action": "WDAudit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少指定提现申请的金额、手续费、账户和处理详情"
   },
   {
     "module": "User / Promo",
     "action": "WDAudit_Accept",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少可原子审核的 Cash 提现事务记录"
   },
   {
     "module": "User / Promo",
     "action": "WDAudit_Reject",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少 Cash 提现申请及对应预扣余额账本,不能安全执行拒绝退款"
   },
   {
     "module": "User / Promo",
     "action": "PromoList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 UserPromotion 虚拟资源和 userPromotions 云函数从 _User.legacyUserData.ParentUserID 还原推广关系、人数、日期及邀请人筛选"
   },
   {
     "module": "User / Promo",
     "action": "UserBank",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少 User_Bank 提现账户及审核状态数据"
   },
   {
     "module": "User / Promo",
     "action": "UserBankAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可编辑的开户人、卡号、账户类型和开户行记录"
   },
   {
     "module": "User / Promo",
     "action": "UserBankAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少 User_Bank 持久层,不能把提现账户错写到发票银行字段"
   },
   {
     "module": "User / Promo",
     "action": "UserBank_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可删除、审核或取消审核的提现账户记录"
   },
   {
     "module": "Pub",

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

@@ -36,6 +36,9 @@
   @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 (className() === 'UserPromotion') {
+    <div class="bulk-toolbar"><strong>推广关系范围</strong><input class="bulk-node" type="number" min="0" [ngModel]="filterPromotionParentUserId()" (ngModelChange)="filterPromotionParentUserId.set(+$event)" placeholder="邀请人用户 ID" /><label><input type="checkbox" [ngModel]="filterPromotionOnlyWithChildren()" (ngModelChange)="filterPromotionOnlyWithChildren.set($event)" />仅显示有推广成员的用户</label><input type="date" aria-label="推广开始日期" [ngModel]="filterPromotionStartDate()" (ngModelChange)="filterPromotionStartDate.set($event)" /><input type="date" aria-label="推广结束日期" [ngModel]="filterPromotionEndDate()" (ngModelChange)="filterPromotionEndDate.set($event)" /><button type="button" (click)="load(1)">筛选推广关系</button></div>
+  }
   @if (className() === 'Baike') {
     <div class="bulk-toolbar"><strong>百科状态</strong><select [ngModel]="filterBaikeStatus()" (ngModelChange)="filterBaikeStatus.set(+$event); load(1)"><option [ngValue]="-100">全部审核状态</option><option [ngValue]="1">已审核</option><option [ngValue]="0">未审核</option></select><select [ngModel]="filterBaikeElite()" (ngModelChange)="filterBaikeElite.set(+$event); load(1)"><option [ngValue]="-100">全部推荐状态</option><option [ngValue]="1">已推荐</option><option [ngValue]="0">未推荐</option></select></div>
   }
@@ -200,6 +203,9 @@
         @if (className() === 'UserMoneyLog') {
           <section class="account-create-note"><strong>资金积分流水只读</strong><span>旧单表已按余额、银币、经验、积分、虚拟币和信用分拆分为六个规范化账本;管理页只合并查看,不允许篡改历史流水。</span></section>
         }
+        @if (className() === 'UserPromotion') {
+          <section class="account-create-note"><strong>用户推广关系只读</strong><span>从旧用户数据的 ParentUserID 实时还原邀请人和直属推广人数;日期范围按旧逻辑统计该期间注册的直属成员,不修改已建立的邀请链。</span><code>用户 {{ draft()['userId'] }} · 邀请人 {{ draft()['parentUserName'] || '无' }}({{ draft()['parentUserId'] || 0 }})· 推广 {{ draft()['promotionCount'] || 0 }} 人</code></section>
+        }
         @if (className() === 'Baike') {
           <section class="account-create-note"><strong>百科词条只读详情</strong><span>目标仅保留 Baike 主记录;可在列表执行审核和推荐状态操作。旧 BaikeEdit 版本表、版本应用/驳回链与积分奖励运行时已缺失,不提供伪版本操作。</span></section>
         }

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

@@ -924,6 +924,17 @@ describe('AdminResourceComponent', () => {
     expect(functions.admin).toHaveBeenCalledWith('userMoneyLogs', { className: 'UserMoneyLog', page: 1, pageSize: 20, search: '', type: 4, startDate: '2026-08-01', endDate: '2026-08-18' });
   });
 
+  it('loads promotion relationships with the legacy parent and child-date filters', async () => {
+    component.className.set('UserPromotion');
+    component.schema.set({ className: 'UserPromotion', label: '用户推广关系', writable: false, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.search.set('邀请人'); component.filterPromotionParentUserId.set(12); component.filterPromotionOnlyWithChildren.set(true); component.filterPromotionStartDate.set('2026-08-01'); component.filterPromotionEndDate.set('2026-08-18');
+    functions.admin.and.resolveTo({ className: 'UserPromotion', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.load(1);
+
+    expect(functions.admin).toHaveBeenCalledWith('userPromotions', { className: 'UserPromotion', page: 1, pageSize: 20, search: '邀请人', parentUserId: 12, onlyWithChildren: true, 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');

+ 9 - 3
src/app/admin/pages/admin-resource.component.ts

@@ -147,6 +147,10 @@ export class AdminResourceComponent implements OnInit {
   readonly filterUserMoneyType = signal(1);
   readonly filterUserMoneyStartDate = signal('');
   readonly filterUserMoneyEndDate = signal('');
+  readonly filterPromotionParentUserId = signal(0);
+  readonly filterPromotionOnlyWithChildren = signal(false);
+  readonly filterPromotionStartDate = signal('');
+  readonly filterPromotionEndDate = signal('');
   readonly updatingUserLevel = signal(false);
   readonly filterBaikeStatus = signal(-100);
   readonly filterBaikeElite = signal(-100);
@@ -171,7 +175,7 @@ export class AdminResourceComponent implements OnInit {
   readonly icons = { ChevronLeft, ChevronRight, Eye, KeyRound, LoaderCircle, Pencil, Plus, RefreshCw, Search, Trash2, X };
   readonly columns = computed(() => {
     const fields = this.schema()?.fields ?? [];
-    const preferred = ['objectId', 'name', 'title', 'typeName', 'pageNodeName', 'compName', 'tlpName', 'pName', 'pTitle', 'questionTitle', 'tName', 'newsName', 'nodeName', 'value', 'remark', 'qtitle', 'sName', 'storeName', 'styleName', 'count', 'content', 'str1', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'userName', 'realName', 'cname', 'type1', 'type2', 'detail', 'cdate', 'sRemrk', 'askId', 'zoneId', 'typeId', 'shape', 'qtype', 'required', 'ztype', 'priority', 'zstatus', 'storeState', 'storeCommendState', 'storeStyleId', 'sDefault', 'sIndex', 'str4', 'sdate', 'edate', 'fileUrl', 'type', 'state', 'eliteLevel', 'orderId', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
+    const preferred = ['objectId', 'name', 'title', 'typeName', 'pageNodeName', 'compName', 'tlpName', 'pName', 'pTitle', 'questionTitle', 'tName', 'newsName', 'nodeName', 'value', 'remark', 'qtitle', 'sName', 'storeName', 'styleName', 'count', 'content', 'str1', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'userName', 'honeyName', 'parentUserName', 'promotionCount', 'parentUserId', 'regTime', 'realName', 'cname', 'type1', 'type2', 'detail', 'cdate', 'sRemrk', 'askId', 'zoneId', 'typeId', 'shape', 'qtype', 'required', 'ztype', 'priority', 'zstatus', 'storeState', 'storeCommendState', 'storeStyleId', 'sDefault', 'sIndex', 'str4', 'sdate', 'edate', 'fileUrl', 'type', 'state', 'eliteLevel', 'orderId', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
     const rank = (name: string): number => { const index = preferred.indexOf(name); return index < 0 ? preferred.length : index; };
     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);
   });
@@ -198,6 +202,7 @@ export class AdminResourceComponent implements OnInit {
       this.filterStoreStatus.set(-100); this.resetStoreActions();
       this.resetTagKeyActions();
       this.filterUserMoneyType.set(1); this.filterUserMoneyStartDate.set(''); this.filterUserMoneyEndDate.set('');
+      this.filterPromotionParentUserId.set(0); this.filterPromotionOnlyWithChildren.set(false); this.filterPromotionStartDate.set(''); this.filterPromotionEndDate.set('');
       this.filterBaikeStatus.set(-100); this.filterBaikeElite.set(-100);
       this.filterSystemLogType1.set(''); this.filterSystemLogType2.set(''); this.filterSystemLogStartDate.set(''); this.filterSystemLogEndDate.set('');
       void this.load(1, true);
@@ -213,7 +218,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' : this.className() === 'UserLevel' ? 'userLevels' : this.className() === 'UserMoneyLog' ? 'userMoneyLogs' : this.className() === 'Baike' ? 'baikeEntries' : this.className() === 'CRMSAttr' ? 'crmClientTypes' : this.className() === 'ShopFareTlp' ? 'shopFareTemplates' : this.className() === 'SysLog' ? 'systemLogs' : this.className() === 'MisType' ? 'misTypes' : this.className() === 'PageStyle' ? 'pageStyles' : this.className() === 'PlatComp' ? 'platformCompanies' : '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' : this.className() === 'UserPromotion' ? 'userPromotions' : this.className() === 'Baike' ? 'baikeEntries' : this.className() === 'CRMSAttr' ? 'crmClientTypes' : this.className() === 'ShopFareTlp' ? 'shopFareTemplates' : this.className() === 'SysLog' ? 'systemLogs' : this.className() === 'MisType' ? 'misTypes' : this.className() === 'PageStyle' ? 'pageStyles' : this.className() === 'PlatComp' ? 'platformCompanies' : '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(); }
@@ -224,6 +229,7 @@ export class AdminResourceComponent implements OnInit {
       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(); }
+      if (pageOperation === 'userPromotions') { pagePayload['parentUserId'] = this.filterPromotionParentUserId(); pagePayload['onlyWithChildren'] = this.filterPromotionOnlyWithChildren(); pagePayload['startDate'] = this.filterPromotionStartDate(); pagePayload['endDate'] = this.filterPromotionEndDate(); }
       if (pageOperation === 'baikeEntries') { pagePayload['status'] = this.filterBaikeStatus(); pagePayload['elite'] = this.filterBaikeElite(); }
       if (pageOperation === 'systemLogs') { pagePayload['type1'] = this.filterSystemLogType1(); pagePayload['type2'] = this.filterSystemLogType2(); pagePayload['startDate'] = this.filterSystemLogStartDate(); pagePayload['endDate'] = this.filterSystemLogEndDate(); }
       this.page.set(await this.functions.admin<ResourcePage>(pageOperation, pagePayload));
@@ -238,7 +244,7 @@ export class AdminResourceComponent implements OnInit {
     const objectId = String(row['objectId'] ?? '');
     this.error.set('');
     try {
-      const detailOperation = this.className() === 'ContentTagKey' ? 'getContentTagKeys' : this.className() === 'UserMoneyLog' ? 'getUserMoneyLog' : 'get';
+      const detailOperation = this.className() === 'ContentTagKey' ? 'getContentTagKeys' : this.className() === 'UserMoneyLog' ? 'getUserMoneyLog' : this.className() === 'UserPromotion' ? 'getUserPromotion' : '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.resetPlatformCompanyActions(detail); this.editorOpen.set(true);
       if (this.className() === '_User') await this.loadUserGroups();

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