Kaynağa Gözat

feat: migrate admin group lifecycle

彭峰 1 ay önce
ebeveyn
işleme
73ba230ec5

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

@@ -20,6 +20,7 @@
 - 旧 `User_API` 的锁定、解锁和移动用户组已迁为专用 `userBatch` 管理员云函数:支持每批 1–100 人、强制帐套边界,原子同步 `isDisabled/legacyUserData.State` 或 `legacyGroupId/legacyUserData.GroupID`;停用会撤销全部 Parse 会话,普通管理员不能停用自己或管理其他管理员。旧版账号登录也会拒绝已停用/已删除用户。
 - `_User` 的状态、旧用户 ID、旧用户组与旧资料 JSON 已从通用字段编辑中移除;密码重置和用户删除新增管理员目标保护并撤销目标会话,避免绕过专用一致性流程。
 - 当前线上 `Group` 表为空;用户组移动只接受当前帐套中真实存在的 `Group.groupId`,界面会明确提示先在“全部数据类”建立用户组,不会把不存在的默认编号伪装成可选组。
+- 旧 `GroupAdd/GroupAdd_Submit` 已迁为专用 `saveGroup`:新增组使用 PostgreSQL 事务锁在帐套内原子分配只读 `groupId`,校验同名组、父组归属与层级循环;通用保存不能绕过。删除组前会拒绝仍被用户或下级组引用的记录。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

+ 54 - 1
scripts/deploy-admin-functions.mjs

@@ -18,7 +18,7 @@ const ALLOWED_CLASSES = new Set([
   'PageTemplate','PaperQuestions','PayPlat','Permission','PlatComp','App','DesignAnswer','DesignAsk','Attachment','DesignPage','DesignQuestion','DesignRes','Feedback','DesignScence','StudentAchieve','ContentArticle','DesignSiteInfo','Profile','AdInfo','AdZone','ARoleAuth','Baike','ExamSysQuestions','ContactInfo','VocabularyWord','AssessmentProfile','Agency','MemoryPracticeRecord','DeliveryCenter','CourseBinding','PracticeRecord','CourseAppointment','Account','SurveyItem','SurveyLog','LessonRecord','DailyStudyRecord','CommonModel','ContentPublish','Company','_Role','_User','CRMSAttr','Currency','Datadic','Datadiccategory','DesignTlp','DocModel','DocPermission','ExamClass','ExamSysPapers','ExamType','ExamPoint','ExTeacher','FontPicShape','FontPicShapeType','Grade','GradeCate','Group','GroupModel','GuestBar','Guestbook','Guestcate','MailTemp','Manager','MisProcedure','MisProLevel','MisSign','MisType','PageStyle','Model','ModelField','Node','NodeAuth','NodeModelTemplate','Product','PlatUserRole','Pub','PubTw','PubWTHD','PubZXDC','PublishNode','QuestionsKnowledge','Role','StoreProduct','SafeMobile','Search','SenTask','ServiceSeat','ShopFareTlp','ShopMoneyRegular','Special','StoreApplication','StoreStyle','SysCSSManage','SysHoliday','SysLog','Temp','ThirdPlatInfo','UserCredit','UserDummyPoint','UserFriendGroup','UserLevel','UserSIcon','UserUserPoint','UserExpDomP','UserExpHis'
 ]);
 const READ_ONLY_CLASSES = new Set(['_Role','Permission','PayPlat','ThirdPlatInfo']);
-const SYSTEM_FIELDS = new Set(['objectId','createdAt','updatedAt','ACL','company','password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','adminRoleKey','isAdmin','roles','role','isDisabled','legacyUserId','legacyGroupId','legacyUserData']);
+const SYSTEM_FIELDS = new Set(['objectId','createdAt','updatedAt','ACL','company','password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','adminRoleKey','isAdmin','roles','role','isDisabled','legacyUserId','legacyGroupId','legacyUserData','groupId']);
 const HIDDEN_FIELDS = new Set(['password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','apiKey','appSecret','secret','token']);
 const GENERIC_WRITE_TYPES = new Set(['String','Number','Boolean','Date','Pointer','Object','Array']);
 
@@ -258,6 +258,57 @@ async function handler(request, response) {
       for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'user-' + action, '_User', target.id);
       return response.json({ success: true, data: { action, updated: targets.length, revokedSessions, group: group ? serializeObject(group) : null, results: targets.map(serializeObject) } });
     }
+    if (operation === 'saveGroup') {
+      const objectId = String(input.objectId || '');
+      const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
+      const groupName = String(payload.groupName || '').trim();
+      const description = String(payload.description || groupName).trim();
+      const parentGroupId = payload.parentGroupId === undefined || payload.parentGroupId === '' ? 0 : Number(payload.parentGroupId);
+      if (!groupName || groupName.length > 100) fail(400, '用户组名称为必填项且不能超过 100 位');
+      if (description.length > 500) fail(400, '用户组说明不能超过 500 位');
+      if (!Number.isInteger(parentGroupId) || parentGroupId < 0) fail(400, '父用户组编号无效');
+      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 groupFields = await schemaFor('Group');
+      let target;
+      let currentGroupId = 0;
+      if (objectId) {
+        const query = new Parse.Query('Group'); applyTenant(query, groupFields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
+        currentGroupId = Number(target.get('groupId')) || 0;
+      } else target = new Parse.Object('Group');
+      const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "Group" WHERE "company"=$1 AND LOWER(COALESCE("groupName",\'\'))=LOWER($2) AND ($3=\'\' OR "objectId"<>$3) LIMIT 1', [companyId, groupName, objectId]);
+      if (duplicate) fail(409, '当前帐套已存在同名用户组');
+      if (parentGroupId) {
+        let cursor = parentGroupId; const visited = new Set();
+        while (cursor) {
+          if (cursor === currentGroupId || visited.has(cursor)) fail(400, '父用户组不能形成循环');
+          visited.add(cursor);
+          const parent = await Psql.oneOrNone('SELECT "parentGroupId" AS parent_id FROM "Group" WHERE "company"=$1 AND "groupId"=$2 LIMIT 1', [companyId, cursor]);
+          if (!parent) fail(404, '父用户组不存在或不属于当前帐套');
+          cursor = Number(parent.parent_id) || 0;
+          if (visited.size > 100) fail(400, '用户组层级过深');
+        }
+      }
+      target.set('company', company); target.set('groupName', groupName); target.set('description', description); target.set('parentGroupId', parentGroupId);
+      const stringFields = ['otherName','enroll','signImg'];
+      const numberFields = ['orderId','companyGroup','vipgroup','rebateRate','credit','vipnum','upPoint','upSicon','favCount','consumeType','ccountPerDay','upGradeMoney'];
+      for (const name of stringFields) if (payload[name] !== undefined && groupFields[name]) target.set(name, String(payload[name] || '').trim());
+      for (const name of numberFields) if (payload[name] !== undefined && payload[name] !== '') { const value = Number(payload[name]); if (!Number.isFinite(value)) fail(400, name + ' 必须是数字'); if (groupFields[name]) target.set(name, value); }
+      if (payload.regSelect !== undefined && groupFields.regSelect) target.set('regSelect', payload.regSelect === true || payload.regSelect === 'true');
+      if (!objectId && groupFields.sourceKey) target.set('sourceKey', 'cloud:admin-group:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10));
+      await target.save(null, { useMasterKey: true });
+      if (!objectId) {
+        try {
+          const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-group-id\'))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("groupId"),0)+1 AS id FROM "Group",lock_row WHERE "company"=$1 AND COALESCE("groupId",0)>0) UPDATE "Group" SET "groupId"=next_id.id,"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id]);
+          currentGroupId = Number(rows[0] && rows[0].id) || 0;
+          if (!currentGroupId) throw new Error('无法分配用户组编号');
+        } catch (error) { await target.destroy({ useMasterKey: true }).catch(() => undefined); throw error; }
+      }
+      await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-group' : 'create-group', 'Group', target.id);
+      return response.json({ success: true, data: serializeObject(target) });
+    }
     const className = String(input.className || '');
     assertClass(className);
     const fields = await schemaFor(className);
@@ -284,6 +335,7 @@ async function handler(request, response) {
     if (operation === 'save') {
       if (!classWritable) fail(403, '该系统类不允许通用编辑');
       if (className === '_User' && !objectId) fail(400, '新增用户必须走专用开户流程');
+      if (className === 'Group') 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);
       const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
@@ -295,6 +347,7 @@ async function handler(request, response) {
     if (operation === 'delete') {
       if (!classWritable || !objectId) fail(400, '该记录不允许删除'); const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); const object = await query.get(objectId, { useMasterKey: true });
       if (className === '_User') assertCanManageUser(context, object, 'lock');
+      if (className === 'Group') { const groupId = Number(object.get('groupId')) || 0; const companyId = pointerId(object.get('company')); const users = await Psql.one('SELECT COUNT(*)::int AS count FROM "_User" WHERE "company"=$1 AND COALESCE("legacyGroupId",0)=$2 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE)', [companyId, groupId]); const children = await Psql.one('SELECT COUNT(*)::int AS count FROM "Group" WHERE "company"=$1 AND COALESCE("parentGroupId",0)=$2', [companyId, groupId]); if (Number(users.count) > 0) fail(409, '该用户组仍有用户,不能删除'); if (Number(children.count) > 0) fail(409, '该用户组仍有下级组,不能删除'); }
       if (fields.isDeleted) { object.set('isDeleted', true); await object.save(null, { useMasterKey: true }); } else await object.destroy({ useMasterKey: true });
       if (className === '_User') await revokeSessions([object]);
       await audit(context, 'delete', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: Boolean(fields.isDeleted) } });

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

@@ -37,6 +37,17 @@ async function callFunction(path, token, params) {
   return payload.data;
 }
 
+async function callFunctionError(path, token, params, expectedStatus) {
+  const response = await fetch(`${FUNCTION_URL}/${path}`, {
+    method: 'POST',
+    headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ token, params }),
+  });
+  const payload = await response.json().catch(() => ({}));
+  if (response.status !== expectedStatus || payload.success !== false) throw new Error(`${path} 期望 ${expectedStatus} 失败,实际 ${response.status}: ${payload.message || payload.error || ''}`);
+  return payload;
+}
+
 async function callLegacyFunction(token, params, expectedStatus = 200) {
   const response = await fetch(`${FUNCTION_URL}/xiaoshu/app/gateway`, {
     method: 'POST',
@@ -181,17 +192,20 @@ try {
   await callLegacyFunction('', { action: 'user_login_passwd', name: managedUsername, passwd: managedPassword }, 403);
   const unlocked = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'userBatch', className: '_User', action: 'unlock', objectIds: [adminCreatedUserId], reason: '恢复冒烟用户' });
   if (unlocked.updated !== 1 || unlocked.results?.[0]?.isDisabled !== false || Number(unlocked.results?.[0]?.legacyUserData?.State) !== 1) throw new Error(`用户解锁异常:${JSON.stringify(unlocked)}`);
-  const temporaryGroupNumber = 800000000 + Math.floor(Date.now() / 1000) % 100000000;
-  const temporaryGroup = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'save', className: 'Group', fields: { groupId: temporaryGroupNumber, groupName: '云函数冒烟临时组', sourceKey: `cloud:admin-smoke-group:${temporaryGroupNumber}` } });
+  const temporaryGroup = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveGroup', className: 'Group', fields: { groupName: '云函数冒烟临时组', description: '验证帐套内原子组编号', parentGroupId: 0, regSelect: false } });
   temporaryGroupObjectId = String(temporaryGroup.objectId || '');
-  if (!temporaryGroupObjectId || Number(temporaryGroup.groupId) !== temporaryGroupNumber) throw new Error('临时用户组创建失败');
+  const temporaryGroupNumber = Number(temporaryGroup.groupId);
+  if (!temporaryGroupObjectId || !temporaryGroupNumber) throw new Error('临时用户组创建失败');
   const moved = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'userBatch', className: '_User', action: 'move', objectIds: [adminCreatedUserId], groupId: temporaryGroupNumber });
   if (moved.updated !== 1 || Number(moved.results?.[0]?.legacyGroupId) !== temporaryGroupNumber || Number(moved.results?.[0]?.legacyUserData?.GroupID) !== temporaryGroupNumber) throw new Error(`用户组同步异常:${JSON.stringify(moved)}`);
+  const groupDeleteBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'Group', objectId: temporaryGroupObjectId }, 409);
+  if (!String(groupDeleteBlocked.message).includes('仍有用户')) throw new Error('用户组引用删除保护未返回明确原因');
   const managedAppLogin = await callLegacyFunction('', { action: 'user_login_passwd', name: managedUsername, passwd: managedPassword });
   if (!managedAppLogin.result?.sessionToken || Number(managedAppLogin.addon?.State) !== 1) throw new Error('解锁后旧版登录异常');
   await jsonRequest(`${PARSE_URL}/users/${adminCreatedUserId}`, { method: 'DELETE' }, true);
   adminCreatedUserId = '';
-  await jsonRequest(`${PARSE_URL}/classes/Group/${temporaryGroupObjectId}`, { method: 'DELETE' }, true);
+  const deletedGroup = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'Group', objectId: temporaryGroupObjectId });
+  if (deletedGroup.objectId !== temporaryGroupObjectId) throw new Error('无引用用户组删除失败');
   temporaryGroupObjectId = '';
 
   const schema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'CourseAppointment' });

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

@@ -129,4 +129,19 @@ describe('AdminResourceComponent', () => {
     expect(functions.admin.calls.first().args).toEqual(['userBatch', { className: '_User', action: 'move', objectIds: ['member-object'], groupId: 3 }]);
     expect(component.draft()['legacyGroupId']).toBe(3);
   });
+
+  it('routes group creation through the atomic group operation', async () => {
+    component.className.set('Group');
+    component.schema.set({ className: 'Group', label: '用户组', writable: true, supportsSoftDelete: false, fields: [] });
+    component.draft.set({ groupName: '普通会员', parentGroupId: 0, regSelect: true });
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveGroup'
+      ? { objectId: 'group-object', groupId: 1, groupName: '普通会员' }
+      : { className: 'Group', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveGroup', {
+      className: 'Group', objectId: undefined, fields: { groupName: '普通会员', parentGroupId: 0, regSelect: true },
+    }]);
+  });
 });

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

@@ -116,7 +116,8 @@ export class AdminResourceComponent implements OnInit {
     if (invalidFields.length) { this.error.set(`请先修正字段格式:${invalidFields.join('、')}`); return; }
     this.saving.set(true); this.error.set('');
     try {
-      await this.functions.admin('save', { className: this.className(), objectId: this.draftId() || undefined, fields: this.draft() });
+      const operation = this.className() === 'Group' ? 'saveGroup' : 'save';
+      await this.functions.admin(operation, { className: this.className(), objectId: this.draftId() || undefined, fields: this.draft() });
       this.closeEditor(); await this.load();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '保存失败'); }
     finally { this.saving.set(false); }