operator-accounts.js 8.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
  1. const OPERATOR_ROLES = [
  2. {key:'ops-manager',label:'运营经理',description:'管理运营业务及本帐套运营账号;不能管理超级管理员'},
  3. {key:'ops-staff',label:'运营人员',description:'处理日常会员、开课、排课与学习业务'},
  4. {key:'ops-auditor',label:'审计员',description:'只读核查业务记录与报表'},
  5. {key:'super-admin',label:'超级管理员',description:'管理技术后台及超级管理员权限'},
  6. ];
  7. function operatorCompany(context){const company=context.current.get('company');const id=company&&(company.id||company.objectId);if(!id)fail(403,'请先为当前管理员关联帐套');return id;}
  8. function requireOperatorManager(context){if(operationsRoleOf(context)!=='ops-manager')fail(403,'只有运营经理或超级管理员可以管理运营账号');return operatorCompany(context);}
  9. function operatorProjection(row,context){const policy=adminRoleSnapshot(row),self=row.objectId===context.current.id,editable=!self&&(context.isSuperAdmin||!policy.isSuperAdmin);return{objectId:row.objectId,username:row.username,displayName:row.realName||row.nickname||row.username,roleKey:policy.roleKey,roleName:OPERATOR_ROLES.find(r=>r.key===policy.roleKey)?.label||'待设置角色',legacyRole:policy.legacyRole,disabled:row.isDisabled===true,updatedAt:row.updatedAt instanceof Date?row.updatedAt.toISOString():row.updatedAt,createdAt:row.createdAt instanceof Date?row.createdAt.toISOString():row.createdAt,self,canEdit:context.isSuperAdmin||!policy.isSuperAdmin,canManage:editable};}
  10. function operatorReason(input){const value=String(input.reason||'').trim();if(value.length<2||value.length>500)fail(400,'请填写 2 至 500 字的操作原因');return value;}
  11. function operatorPassword(value){if(typeof value!=='string'||value.length<8||value.length>72)fail(400,'密码长度应为 8 至 72 位');return value;}
  12. async function operatorTarget(context,id){const company=requireOperatorManager(context),q=new Parse.Query('_User');q.equalTo('company',context.current.get('company'));const target=await q.get(String(id||''),{useMasterKey:true});const role=adminRoleSnapshot(target);if(target.get('isDeleted')===true||(!role.isAdmin&&target.get('identityType')!=='operator'))fail(404,'运营账号不存在');if(role.isSuperAdmin&&!context.isSuperAdmin)fail(403,'不能修改超级管理员');return target;}
  13. async function operatorAudit(context,target,action,reason,before={},after={}){const log=new Parse.Object('OperatorAccountAudit');log.set('company',context.current.get('company'));log.set('actorId',context.current.id);log.set('actorName',String(context.current.get('username')||''));log.set('targetId',target.id||'');log.set('targetName',String(target.get('username')||''));log.set('action',action);log.set('reason',reason);log.set('before',before);log.set('after',after);await log.save(null,{useMasterKey:true});return log;}
  14. async function operatorChange(context,targetId,action,payload,reason,expected=''){
  15. const co=requireOperatorManager(context),id=Array.from({length:20},()=>Math.random().toString(36).slice(2,3)).join('');
  16. try{return(await Psql.one('SELECT xs_operator_change($1,$2,$3,$4,$5::jsonb,$6,$7,$8) AS result',[co,context.current.id,targetId,action,JSON.stringify(payload),reason,expected,id])).result;}catch(error){const message=String(error.message||'账号操作失败');if(/无权|超级管理员|不能分配/.test(message))fail(403,message);if(/已被其他/.test(message))fail(409,message);if(/不存在/.test(message))fail(404,message);if(/不能|请填写|无效/.test(message))fail(400,message);throw error;}
  17. }
  18. async function operatorAccounts(context,input){
  19. const co=requireOperatorManager(context),operation=String(input.operation).replace('operatorAccounts/',''),payload=input.payload||{};
  20. if(operation==='list'){
  21. const page=Math.max(1,Math.floor(Number(input.page)||1)),size=20,search=String(input.search||'').trim(),role=String(input.role||''),status=String(input.status||'');
  22. const where='u."company"=$1 AND COALESCE(u."isDeleted",false)=false AND (xs_operator_role(to_jsonb(u))<>\'\' OR u."isAdmin"=true OR u."identityType"=\'operator\') AND ($2=\'\' OR position(lower($2) in lower(COALESCE(u."username",\'\')||\' \'||COALESCE(u."realName",\'\')))>0) AND ($3=\'\' OR xs_operator_role(to_jsonb(u))=$3) AND ($4=\'\' OR COALESCE(u."isDisabled",false)=($4=\'disabled\'))';
  23. const fields='u."objectId",u."username",u."realName",u."nickname",u."roles",u."role",u."adminRoleKey",u."isAdmin",u."isDisabled",u."createdAt",u."updatedAt"';
  24. const [rows,total]=await Promise.all([Psql.query('SELECT '+fields+' FROM "_User" u WHERE '+where+' ORDER BY u."createdAt" DESC,u."objectId" LIMIT $5 OFFSET $6',[co,search,role,status,size,(page-1)*size]),Psql.one('SELECT count(*)::int AS total FROM "_User" u WHERE '+where,[co,search,role,status])]);
  25. return {items:rows.map(row=>operatorProjection(row,context)),total:total.total,page,pageSize:size,roles:OPERATOR_ROLES.filter(r=>context.isSuperAdmin||r.key!=='super-admin'),currentId:context.current.id};
  26. }
  27. if(operation==='audit'){
  28. const target=await operatorTarget(context,input.targetId),page=Math.max(1,Math.floor(Number(input.page)||1));const [items,total]=await Promise.all([Psql.query('SELECT "objectId","actorName","action","reason","before","after","createdAt" FROM "OperatorAccountAudit" WHERE "company"=$1 AND "targetId"=$2 ORDER BY "createdAt" DESC LIMIT 20 OFFSET $3',[co,target.id,(page-1)*20]),Psql.one('SELECT count(*)::int AS total FROM "OperatorAccountAudit" WHERE "company"=$1 AND "targetId"=$2',[co,target.id])]);return {items,total:total.total,page,pageSize:20};
  29. }
  30. const reason=operatorReason(input);
  31. if(operation==='create'){
  32. const username=String(payload.username||'').trim(),displayName=String(payload.displayName||'').trim(),roleKey=String(payload.roleKey||'');
  33. if(!/^[A-Za-z0-9_.@-]{3,64}$/.test(username))fail(400,'登录账号须为 3 至 64 位字母、数字或 _ . @ -');
  34. if(!displayName||displayName.length>80)fail(400,'请填写 1 至 80 字的姓名');
  35. if(!OPERATOR_ROLES.some(r=>r.key===roleKey)||(roleKey==='super-admin'&&!context.isSuperAdmin))fail(403,'不能分配该角色');
  36. const password=operatorPassword(payload.password),created=new Parse.User();created.setUsername(username);created.setPassword(password);created.set('company',context.current.get('company'));created.set('identityType','operator');created.set('isAdmin',false);created.set('isDisabled',true);created.set('isDeleted',false);created.setACL(new Parse.ACL());
  37. try{await created.signUp(null,{useMasterKey:true});}catch(error){if(Number(error.code)===202)fail(409,'该登录账号已存在,请更换');throw error;}
  38. // Do not enable until the actor is rechecked inside the locked transaction.
  39. try{return await operatorChange(context,created.id,'create',{displayName,roleKey},reason);}catch(error){await created.destroy({useMasterKey:true});throw error;}
  40. }
  41. const target=await operatorTarget(context,input.targetId);
  42. if(operation==='save')return operatorChange(context,target.id,'save',{displayName:payload.displayName,roleKey:payload.roleKey},reason,String(input.expectedUpdatedAt||''));
  43. if(operation==='status')return operatorChange(context,target.id,'status',{disabled:payload.disabled},reason,String(input.expectedUpdatedAt||''));
  44. if(operation==='revoke-sessions'){if(target.id===context.current.id)fail(400,'退出自己的会话请使用退出登录');return operatorChange(context,target.id,'revoke-sessions',{},reason);}
  45. if(operation==='reset-password'){
  46. if(target.id===context.current.id)fail(400,'修改自己的密码请使用修改密码入口');
  47. const password=operatorPassword(payload.password);
  48. // Disable first under the permission lock. A failed password/session update
  49. // leaves the account disabled instead of leaving old credentials usable.
  50. const wasDisabled=target.get('isDisabled')===true;
  51. await operatorChange(context,target.id,'status',{disabled:true},reason.slice(0,470)+'(重置密码,暂时停用)',String(input.expectedUpdatedAt||''));
  52. const log=await operatorAudit(context,target,'reset-password-started',reason);
  53. try{await target.fetch({useMasterKey:true});target.setPassword(password);target.set('passwordResetRequired',false);await target.save(null,{useMasterKey:true});await revokeSessions([target]);
  54. if(!wasDisabled)await operatorChange(context,target.id,'status',{disabled:false},reason.slice(0,470)+'(重置完成,恢复账号)',target.updatedAt.toISOString());
  55. log.set('action','reset-password');await log.save(null,{useMasterKey:true});return {objectId:target.id};
  56. }catch(error){fail(409,'密码重置未全部确认,请刷新核对账号状态后重试。');}
  57. }
  58. fail(404,'未知运营账号操作');
  59. }