Просмотр исходного кода

feat(admin): manage operator accounts and restore legacy admin access

彭峰 1 месяц назад
Родитель
Сommit
2eb3ab611f

+ 55 - 0
docs/qa/operator-accounts-2026-09-09.md

@@ -0,0 +1,55 @@
+# 运营账号管理与旧管理员权限修复
+
+## 原因与修复
+
+线上原运营管理员具有 `isAdmin=true`、`role=admin`、`roles=[admin]`,账号未停用、资料未丢失;新运营网关只识别 `ops-manager`、`ops-staff`、`ops-auditor`。登录身份与业务权限不一致导致工作台、会员详情报“当前运营角色无权执行该操作”。
+
+管理网关和两个运营网关现在共用 `admin-role-policy.js`:优先读取显式角色,再兼容旧管理员为本帐套运营经理,不升级为超级管理员。显式未知运营角色不会回退获取经理权限。禁用和删除状态仍先于角色校验。当前原会话已可读取工作台与会员资料。
+
+## 功能入口
+
+`/admin/operator-accounts`,左侧“账号与安全 → 运营账号管理”。
+
+- 按姓名、登录账号、角色和状态查询,20 条一页。
+- 新增后台账号、设置姓名与角色;新账号默认选择运营人员。
+- 编辑姓名、调整角色、启用或停用账号。
+- 重置其他运营账号密码,或者单独退出其全部旧会话。
+- 本人的密码仍通过“修改密码”校验原密码后修改。
+- 查看每个账号的操作原因、操作者、时间及修改前后信息;审计按页读取。
+
+| 身份 | 管理范围 |
+| --- | --- |
+| 运营经理(含旧 admin) | 本帐套运营经理、运营人员、审计员;不能管理或授予超级管理员 |
+| 运营人员、审计员 | 不开放运营账号管理;继续使用对应角色的业务权限 |
+| 超级管理员 | 所属帐套中的全部后台角色;技术后台权限保持独立 |
+
+自己不能停用自己或修改自己的角色。账号用停用保留记录,不提供永久删除。角色变更、停用与退出会话在同一数据库事务中撤销旧会话并记录审计。公司锁与事务内操作者重检防止两个经理互相停用造成无人可管理。
+
+## 底层权限保护
+
+线上验收发现 Parse 用户所有者可以直接更新自定义权限字段,即使新账号设置了私有 ACL。因此增加数据库授权字段保护触发器:
+
+- 公共注册不能附带管理员身份。
+- 普通用户接口不能修改 roles、role、adminRoleKey、isAdmin。
+- 已有运营账号的帐套、身份、启停用状态不可通过普通用户接口更改,也不能直接删除管理员。
+- 管理页面调用受控事务 `xs_operator_change`;事务校验实际操作者与目标帐套、权限、自身保护和版本后才写入。
+- 密码交给 Parse 哈希;重置先停用并撤销会话,失败提示核对状态再重试。列表及审计不含密码、令牌或哈希。
+- 403 权限拒绝不会再强制退出正常的管理会话;401 会话失效仍退出。
+
+维护注意:旧脚本通过 Parse REST 直接创建带管理员标志的用户或修改权限字段会被拒绝。日常开户应调用运营账号管理接口。迁移和隔离测试需要持有 Master Key,由限指定测试用户的一次性云函数执行受控 SQL;`smoke-operator-accounts.mjs` 包含这一流程及精确清理,不应关闭权限保护来运行旧脚本。
+
+## 验证
+
+- `npm run test:admin:ci`:192 项通过(包含表单、角色限制、重复提交、密码确认与 403 会话保留)。
+- `node --test scripts/tests/operator-accounts.test.mjs`:8 项通过(角色兼容、数据库隔离、审计、版本、自身保护、经理互相停用、直接接口越权保护)。
+- `npm run test:member-enrollment`:26 项通过。
+- `npm run build:admin`、11 个云函数定义校验和 diff 检查通过;已有其他页面样式预算及 CommonJS 警告保留。
+- `scripts/smoke-operator-accounts.mjs`:18 项线上验收通过,包含新旧账号登录、真实账号创建、重复账号拒绝、直接 Parse 权限修改拒绝、角色变更、密码新旧有效性、停用和恢复、主动会话撤销、审计及敏感字段检查。
+- 输出 `OPERATOR_SMOKE_PASSED`、`OPERATOR_FIXTURES_CLEANED`;只改动独立测试账号,未修改真实管理员的密码和角色数据。
+- 真实浏览器验证工作台恢复、账号列表、创建表单、本人角色不可编辑、审计入口、390px 窄屏。含业务资料的截图只保存在本机 `tmp/operator-account-ui/`。
+
+## 发布
+
+更新前备份三个网关及管理员角色白名单数据至 `tmp/operator-accounts-release-1788963918963/`。迁移脚本为新增的 `OperatorAccountAudit` 注册 Master-only Parse schema,并安装权限事务和触发器;迁移临时用户及临时函数已清理。
+
+管理网关与两个运营网关已更新。前端在本机 4300 服务生效并生成生产构建;尚无本次确认的独立公网前端托管地址,不把本机界面等同于公网前端部署。

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

@@ -29,6 +29,7 @@ export const OPERATIONS_NAVIGATION: readonly AdminNavigationGroup[] = [
     { label: '词库管理', icon: 'languages', route: '/admin/vocabulary' },
     { label: '专项训练', icon: 'book-open', route: '/admin/special-training' },
   ] },
+  { label: '账号与安全', items: [{ label: '运营账号管理', icon: 'shield-check', route: '/admin/operator-accounts' }] },
   { label: '个人设置', items: [{ label: '修改密码', icon: 'key-round', route: '/admin/change-password' }] },
 ];
 

+ 6 - 0
projects/xiaoshu-admin/src/app/admin.guard.ts

@@ -23,3 +23,9 @@ export const superAdminGuard: CanActivateFn = async (_route, state) => {
     return router.createUrlTree(['/admin/forbidden']);
   }
 };
+
+export const operatorManagerGuard: CanActivateFn = async () => {
+  const sessions=inject(AdminSessionService),router=inject(Router),functions=inject(CloudFunctionsService);
+  try { const identity=(await functions.admin<{identity:AdminIdentity}>('meta')).identity; sessions.applyIdentity(identity); return identity.isSuperAdmin||identity.operationsRole==='ops-manager'?true:router.createUrlTree(['/admin/forbidden']); }
+  catch { return router.createUrlTree(['/admin/forbidden']); }
+};

+ 2 - 1
projects/xiaoshu-admin/src/app/admin.routes.ts

@@ -1,5 +1,5 @@
 import { Routes } from '@angular/router';
-import { adminGuard, superAdminGuard } from './admin.guard';
+import { adminGuard, operatorManagerGuard, superAdminGuard } from './admin.guard';
 
 const operationsChildren: Routes = [
   { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
@@ -18,6 +18,7 @@ const operationsChildren: Routes = [
   { path: 'payroll', loadComponent: () => import('./pages/operations-payroll.component').then((module) => module.OperationsPayrollComponent) },
   { path: 'vocabulary', loadComponent: () => import('./pages/operations-vocabulary.component').then((module) => module.OperationsVocabularyComponent) },
   { path: 'special-training', loadComponent: () => import('./pages/operations-special-training.component').then((module) => module.OperationsSpecialTrainingComponent) },
+  { path: 'operator-accounts', canActivate: [operatorManagerGuard], loadComponent: () => import('./pages/operator-accounts.component').then(module => module.OperatorAccountsComponent) },
   { path: 'change-password', loadComponent: () => import('./pages/admin-change-password.component').then((module) => module.AdminChangePasswordComponent) },
   { path: 'forbidden', loadComponent: () => import('./pages/admin-forbidden.component').then((module) => module.AdminForbiddenComponent) },
 ];

+ 7 - 0
projects/xiaoshu-admin/src/app/cloud-functions.service.spec.ts

@@ -39,4 +39,11 @@ describe('CloudFunctionsService', () => {
     request.flush({ success: true, data: { page: 1, pageSize: 20, total: 0, items: [] } });
     await expectAsync(pending).toBeResolvedTo({ page: 1, pageSize: 20, total: 0, items: [] });
   });
+  it('does not log out an authenticated user for a forbidden account-management action', async () => {
+    const pending=service.admin('operatorAccounts/list');
+    const assertion=expectAsync(pending).toBeRejected();
+    http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/admin/gateway`).flush({message:'只有运营经理可以管理账号'},{status:403,statusText:'Forbidden'});
+    await assertion; expect(TestBed.inject(AdminSessionService).logout).not.toHaveBeenCalled();
+  });
+
 });

+ 1 - 1
projects/xiaoshu-admin/src/app/cloud-functions.service.ts

@@ -30,7 +30,7 @@ export class CloudFunctionsService {
       if (error instanceof CloudFunctionError) throw error;
       if (error instanceof HttpErrorResponse) {
         const payload = error.error as { error?: string; message?: string } | null;
-        if (error.status === 401 || error.status === 403) this.sessions.logout();
+        if (error.status === 401) this.sessions.logout();
         throw new CloudFunctionError(payload?.message || payload?.error || error.message, error.status, payload);
       }
       throw new CloudFunctionError(error instanceof Error ? error.message : '未知云函数错误');

+ 1 - 1
projects/xiaoshu-admin/src/app/layout/admin-shell.component.html

@@ -6,7 +6,7 @@
     </a>
 
     <nav aria-label="后台主导航">
-      @for (group of navigation; track group.label) {
+      @for (group of navigation(); track group.label) {
         <p class="nav-group-label">{{ group.label }}</p>
         @for (item of group.items; track item.route) {
           <a [routerLink]="item.route" routerLinkActive="active" (click)="mobileOpen.set(false)">

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

@@ -1,5 +1,5 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
 import { ActivatedRoute, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
 import {
   BadgeCent,
@@ -71,7 +71,7 @@ export class AdminShellComponent {
 
   constructor(readonly sessions: AdminSessionService, route: ActivatedRoute) {
     this.mode = route.snapshot.data['mode'] === 'technical' ? 'technical' : 'operations';
-    this.navigation = this.mode === 'technical' ? TECH_NAVIGATION : OPERATIONS_NAVIGATION;
+    this.navigation = computed(() => (this.mode === 'technical' ? TECH_NAVIGATION : OPERATIONS_NAVIGATION).filter(group => group.label !== '账号与安全' || this.sessions.session()?.isSuperAdmin || this.sessions.session()?.operationsRole === 'ops-manager'));
   }
 
   iconFor(name: string) {

+ 24 - 0
projects/xiaoshu-admin/src/app/pages/operator-accounts.component.html

@@ -0,0 +1,24 @@
+<header class="page-header"><div><p>ACCOUNT MANAGEMENT</p><h1>运营账号管理</h1><span>管理本帐套后台人员、角色与登录安全</span></div><div class="actions"><button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" />刷新</button><button type="button" class="primary" (click)="open('create')" [disabled]="!data()"><lucide-icon [img]="icons.Plus" [size]="17" />新增账号</button></div></header>
+@if(error()){<p role="alert" class="notice error">{{error()}}</p>}@if(message()){<p role="status" class="notice">{{message()}}</p>}
+@if(data();as result){
+<section class="role-guide" aria-label="运营角色说明">@for(role of result.roles;track role.key){<article><strong><lucide-icon [img]="icons.ShieldCheck" [size]="17" />{{role.label}}</strong><p>{{role.description}}</p></article>}</section>
+<section class="panel">
+<form class="filters" (ngSubmit)="load(1)"><label>搜索账号<input name="search" [(ngModel)]="search" placeholder="登录账号或姓名" /></label><label>角色<select name="role" [(ngModel)]="role"><option value="">全部角色</option>@for(r of result.roles;track r.key){<option [value]="r.key">{{r.label}}</option>}</select></label><label>状态<select name="status" [(ngModel)]="status"><option value="">全部状态</option><option value="active">正常</option><option value="disabled">已停用</option></select></label><button class="primary" type="submit" [disabled]="loading()">查询</button></form>
+<div class="table-scroll"><table><thead><tr><th>姓名与账号</th><th>角色</th><th>状态</th><th>创建时间</th><th>操作</th></tr></thead><tbody>@for(row of result.items;track row.objectId){<tr><td><strong>{{row.displayName}}</strong>@if(row.self){<span class="tag">当前账号</span>}@if(row.username!==row.displayName){<small>{{row.username}}</small>}</td><td>{{row.roleName}}@if(row.legacyRole){<small>旧管理员身份兼容</small>}</td><td><span class="badge" [class.disabled]="row.disabled">{{row.disabled?'已停用':'正常'}}</span></td><td class="nowrap">{{row.createdAt | date:'yyyy-MM-dd HH:mm'}}</td><td><div class="row-actions">@if(row.canEdit){<button type="button" (click)="open('save',row)">编辑</button>}@if(row.canManage){<button type="button" (click)="open('status',row)" [class.danger]="!row.disabled">{{row.disabled?'启用':'停用'}}</button><button type="button" (click)="open('reset-password',row)">重置密码</button><button type="button" (click)="open('revoke-sessions',row)">退出会话</button>}@if(row.self){<a class="action-link" routerLink="/admin/change-password">修改密码</a>}@if(row.canEdit){<button type="button" (click)="showAudit(row)">审计记录</button>}</div></td></tr>}@empty{<tr><td colspan="5">暂无匹配的运营账号</td></tr>}</tbody></table></div>
+<footer class="pagination"><span>共 {{result.total}} 个账号</span><button type="button" (click)="load(page()-1)" [disabled]="loading()||page()<=1">上一页</button><span>第 {{page()}} 页</span><button type="button" (click)="load(page()+1)" [disabled]="loading()||page()*20>=result.total">下一页</button></footer>
+</section>}
+@if(loading()){<p role="status" class="loading">正在加载运营账号…</p>}
+@if(action();as currentAction){
+<div class="overlay"><section class="dialog" role="dialog" aria-modal="true" aria-labelledby="operator-dialog-title"><header><h2 id="operator-dialog-title">{{title()}}</h2><button type="button" aria-label="关闭账号操作" (click)="close()" [disabled]="saving()"><lucide-icon [img]="icons.X" [size]="18" /></button></header>
+@if(selected();as row){<p class="target">{{row.displayName}} · {{row.username}}</p>}
+@if(formError()){<p class="notice error" role="alert">{{formError()}}</p>}
+<form (ngSubmit)="submit()"><fieldset [disabled]="saving()">
+@if(currentAction==='create'){<label>登录账号<input name="username" [(ngModel)]="username" required maxlength="64" autocomplete="off" placeholder="3 至 64 位字母、数字或 _ . @ -" /></label>}
+@if(currentAction==='create'||currentAction==='save'){<label>姓名<input name="displayName" [(ngModel)]="displayName" maxlength="80" required /></label><label>运营角色<select name="roleKey" [(ngModel)]="roleKey" [disabled]="selected()?.self===true">@for(r of data()?.roles;track r.key){<option [value]="r.key">{{r.label}}</option>}</select></label>@if(selected()?.self){<small>当前登录账号不能修改自己的管理角色。</small>}}
+@if(currentAction==='create'||currentAction==='reset-password'){<label>{{currentAction==='create'?'初始密码':'新密码'}}<input name="password" type="password" [(ngModel)]="password" minlength="8" maxlength="72" required autocomplete="new-password" placeholder="8 至 72 位" /></label><label>确认密码<input name="confirmPassword" type="password" [(ngModel)]="confirmPassword" required autocomplete="new-password" /></label><small>密码不会在列表或审计中显示。请通过你们的内部渠道告知本人。</small>}
+@if(currentAction==='status'){<p>{{selected()?.disabled?'启用后,该账号可以重新登录后台。':'停用后,该账号无法继续使用后台,已有登录会话会退出。'}}</p>}
+@if(currentAction==='revoke-sessions'){<p>将退出该账号当前所有登录会话。账号仍可使用现有密码重新登录。</p>}
+<label>操作原因<textarea name="reason" [(ngModel)]="reason" minlength="2" maxlength="500" required placeholder="至少 2 个字,将记入操作审计"></textarea></label>
+<div class="form-actions"><button type="button" (click)="close()">取消</button><button class="primary" type="submit">{{saving()?'处理中…':'确认'+title()}}</button></div>
+</fieldset></form></section></div>}
+@if(auditTarget();as row){<div class="overlay"><section class="dialog audit-dialog" role="dialog" aria-modal="true" aria-labelledby="operator-audit-title"><header><h2 id="operator-audit-title">{{row.displayName}}的审计记录</h2><button type="button" aria-label="关闭审计记录" (click)="closeAudit()"><lucide-icon [img]="icons.X" [size]="18" /></button></header>@if(auditError()){<p class="notice error" role="alert">{{auditError()}}</p>}@if(auditLoading()){<p role="status">正在加载审计…</p>}@else{@for(entry of audits();track entry.objectId){<article class="audit-entry"><strong>{{auditLabel(entry.action)}} · {{entry.actorName}}</strong><small>{{entry.createdAt|date:'yyyy-MM-dd HH:mm:ss'}}</small><p>{{entry.reason}}</p>@if(auditSummary(entry.before)){<p>操作前:{{auditSummary(entry.before)}}</p>}@if(auditSummary(entry.after)){<p>操作后:{{auditSummary(entry.after)}}</p>}</article>}@empty{<p>暂无运营账号管理记录,旧业务审计仍保留在原记录中。</p>}}<footer class="pagination"><span>共 {{auditTotal()}} 条</span><button type="button" (click)="showAudit(row,auditPage()-1)" [disabled]="auditLoading()||auditPage()<=1">上一页</button><button type="button" (click)="showAudit(row,auditPage()+1)" [disabled]="auditLoading()||auditPage()*20>=auditTotal()">下一页</button></footer></section></div>}

Разница между файлами не показана из-за своего большого размера
+ 2 - 0
projects/xiaoshu-admin/src/app/pages/operator-accounts.component.scss


+ 14 - 0
projects/xiaoshu-admin/src/app/pages/operator-accounts.component.spec.ts

@@ -0,0 +1,14 @@
+import {TestBed} from '@angular/core/testing';
+import {provideRouter} from '@angular/router';
+import {CloudFunctionsService} from '../cloud-functions.service';
+import {OperatorAccountsComponent,OperatorAccount} from './operator-accounts.component';
+describe('运营账号管理',()=>{
+ let api:jasmine.SpyObj<CloudFunctionsService>;
+ const row={objectId:'staff',username:'operator01',displayName:'运营同事',roleKey:'ops-staff',roleName:'运营人员',disabled:false,updatedAt:'2026-09-09T00:00:00.000Z',canEdit:true,canManage:true,self:false} as OperatorAccount;
+ beforeEach(()=>{api=jasmine.createSpyObj('CloudFunctionsService',['admin']);api.admin.and.resolveTo({items:[row],total:1,page:1,pageSize:20,roles:[{key:'ops-staff',label:'运营人员',description:'日常业务'}]});TestBed.configureTestingModule({imports:[OperatorAccountsComponent],providers:[provideRouter([]),{provide:CloudFunctionsService,useValue:api}]});});
+ it('打开停用操作只展示确认表单,不立即提交',async()=>{const c=TestBed.createComponent(OperatorAccountsComponent).componentInstance;c.open('status',row);expect(api.admin).not.toHaveBeenCalled();expect(c.title()).toBe('停用账号');c.reason='离职停用';await c.submit();expect(api.admin).toHaveBeenCalledWith('operatorAccounts/status',jasmine.objectContaining({targetId:'staff',expectedUpdatedAt:row.updatedAt,payload:{disabled:true}}));});
+ it('新增账号校验密码确认,成功后清空密码',async()=>{const c=TestBed.createComponent(OperatorAccountsComponent).componentInstance;c.open('create');c.username='operator02';c.displayName='新运营';c.reason='新增人员';c.password='initial123';c.confirmPassword='different';await c.submit();expect(api.admin).not.toHaveBeenCalled();c.confirmPassword=c.password;await c.submit();expect(api.admin).toHaveBeenCalledWith('operatorAccounts/create',jasmine.objectContaining({payload:jasmine.objectContaining({username:'operator02',roleKey:'ops-staff'})}));expect(c.password).toBe('');expect(c.confirmPassword).toBe('');expect(c.action()).toBeNull();});
+ it('提交中阻止重复提交和关闭',async()=>{const c=TestBed.createComponent(OperatorAccountsComponent).componentInstance;c.open('status',row);c.reason='测试停用';let done!:(v:unknown)=>void;api.admin.and.returnValue(new Promise(resolve=>done=resolve));const pending=c.submit();await c.submit();c.close();expect(c.action()).toBe('status');expect(api.admin).toHaveBeenCalledTimes(1);api.admin.and.resolveTo({items:[],total:0,page:1,roles:[]});done({});await pending;expect(c.action()).toBeNull();});
+ it('本人的角色选择禁用,并提供修改密码入口',async()=>{const fixture=TestBed.createComponent(OperatorAccountsComponent),c=fixture.componentInstance;fixture.detectChanges();await fixture.whenStable();c.open('save',{...row,self:true});fixture.detectChanges();await fixture.whenStable();fixture.detectChanges();expect(fixture.nativeElement.querySelector('select[name="roleKey"]').disabled).toBeTrue();});
+ it('账号筛选翻页请求保留筛选条件',async()=>{const c=TestBed.createComponent(OperatorAccountsComponent).componentInstance;c.search='同事';c.role='ops-auditor';c.status='disabled';await c.load(2);expect(api.admin).toHaveBeenCalledWith('operatorAccounts/list',{page:2,search:'同事',role:'ops-auditor',status:'disabled'});});
+});

+ 40 - 0
projects/xiaoshu-admin/src/app/pages/operator-accounts.component.ts

@@ -0,0 +1,40 @@
+import {CommonModule} from '@angular/common';
+import {Component,inject,OnInit,signal} from '@angular/core';
+import {FormsModule} from '@angular/forms';
+import {RouterLink} from '@angular/router';
+import {LucideAngularModule,Plus,RefreshCw,ShieldCheck,X} from 'lucide-angular';
+import {CloudFunctionsService} from '../cloud-functions.service';
+
+export interface OperatorAccount {objectId:string;username:string;displayName:string;roleKey:string;roleName:string;legacyRole:boolean;disabled:boolean;updatedAt:string;createdAt:string;self:boolean;canEdit:boolean;canManage:boolean;}
+interface AccountPage {items:OperatorAccount[];total:number;page:number;pageSize:number;roles:{key:string;label:string;description:string}[];currentId:string;}
+interface AuditEntry {objectId:string;actorName:string;action:string;reason:string;before:Record<string,unknown>;after:Record<string,unknown>;createdAt:string;}
+type Action='create'|'save'|'status'|'reset-password'|'revoke-sessions';
+@Component({selector:'app-operator-accounts',standalone:true,imports:[CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operator-accounts.component.html',styleUrl:'./operator-accounts.component.scss'})
+export class OperatorAccountsComponent implements OnInit {
+ private api=inject(CloudFunctionsService);readonly icons={Plus,RefreshCw,ShieldCheck,X};
+ data=signal<AccountPage|null>(null);loading=signal(false);saving=signal(false);error=signal('');message=signal('');formError=signal('');
+ page=signal(1);search='';role='';status='';action=signal<Action|null>(null);selected=signal<OperatorAccount|null>(null);
+ username='';displayName='';roleKey='ops-staff';password='';confirmPassword='';reason='';
+ auditTarget=signal<OperatorAccount|null>(null);audits=signal<AuditEntry[]>([]);auditLoading=signal(false);auditError=signal('');auditPage=signal(1);auditTotal=signal(0);private auditSerial=0;
+ ngOnInit(){void this.load();}
+ async load(page=this.page()){if(this.loading())return;this.loading.set(true);this.error.set('');try{const data=await this.api.admin<AccountPage>('operatorAccounts/list',{page,search:this.search,role:this.role,status:this.status});this.data.set(data);this.page.set(data.page);}catch(e){this.error.set(e instanceof Error?e.message:'运营账号加载失败');}finally{this.loading.set(false);}}
+ open(action:Action,row:OperatorAccount|null=null){this.action.set(action);this.selected.set(row);this.username=row?.username||'';this.displayName=row?.displayName||'';this.roleKey=row?.roleKey||'ops-staff';this.password='';this.confirmPassword='';this.reason='';this.formError.set('');this.message.set('');}
+ close(){if(this.saving())return;this.action.set(null);this.password='';this.confirmPassword='';}
+ title(){return {'create':'新增运营账号','save':'编辑运营账号','status':this.selected()?.disabled?'启用账号':'停用账号','reset-password':'重置账号密码','revoke-sessions':'退出旧会话'}[this.action()||'create'];}
+ async submit(){
+  const action=this.action(),row=this.selected();if(!action||this.saving())return;this.formError.set('');
+  if(this.reason.trim().length<2){this.formError.set('请填写至少 2 个字的操作原因');return;}
+  if(['create','save'].includes(action)&&(!this.displayName.trim()||!this.roleKey)){this.formError.set('请填写姓名并选择角色');return;}
+  if(action==='create'&&!/^[A-Za-z0-9_.@-]{3,64}$/.test(this.username.trim())){this.formError.set('账号须为 3 至 64 位字母、数字或 _ . @ -');return;}
+  if(action==='create'||action==='reset-password'){if(this.password.length<8||this.password.length>72||this.password!==this.confirmPassword){this.formError.set('密码须为 8 至 72 位,且两次输入一致');return;}}
+  const payload:Record<string,unknown>=action==='create'?{username:this.username.trim(),displayName:this.displayName.trim(),roleKey:this.roleKey,password:this.password}:action==='save'?{displayName:this.displayName.trim(),roleKey:this.roleKey}:action==='status'?{disabled:!row?.disabled}:action==='reset-password'?{password:this.password}:{};
+  this.saving.set(true);
+  try{await this.api.admin('operatorAccounts/'+action,{targetId:row?.objectId,expectedUpdatedAt:row?.updatedAt,reason:this.reason.trim(),payload});this.password='';this.confirmPassword='';this.action.set(null);this.message.set(this.titleForSuccess(action));await this.load(action==='create'?1:this.page());}
+  catch(e){this.formError.set(e instanceof Error?e.message:'操作失败');}finally{this.saving.set(false);}
+ }
+ private titleForSuccess(action:Action){return {'create':'运营账号已创建,可以使用新账号登录','save':'账号资料已保存;角色变更会退出旧会话','status':'账号状态已更新','reset-password':'密码已重置,旧会话已退出','revoke-sessions':'该账号的旧会话已退出'}[action];}
+ async showAudit(row:OperatorAccount,page=1){const serial=++this.auditSerial;this.auditTarget.set(row);this.auditLoading.set(true);this.auditError.set('');this.audits.set([]);try{const result=await this.api.admin<{items:AuditEntry[];total:number}>('operatorAccounts/audit',{targetId:row.objectId,page});if(serial!==this.auditSerial)return;this.audits.set(result.items);this.auditTotal.set(result.total);this.auditPage.set(page);}catch(e){if(serial===this.auditSerial)this.auditError.set(e instanceof Error?e.message:'审计加载失败');}finally{if(serial===this.auditSerial)this.auditLoading.set(false);}}
+ closeAudit(){++this.auditSerial;this.auditTarget.set(null);}
+ auditLabel(action:string){return ({create:'创建账号',save:'修改资料与角色',status:'变更账号状态','reset-password':'重置密码','reset-password-started':'密码重置处理中或未完成','revoke-sessions':'退出旧会话'} as Record<string,string>)[action]||action;}
+ auditSummary(value:Record<string,unknown>){const role=this.data()?.roles.find(r=>r.key===value['roleKey'])?.label||value['roleKey'];return [value['displayName'],role,typeof value['disabled']==='boolean'?(value['disabled']?'已停用':'正常'):''].filter(Boolean).join(' · ');}
+}

+ 12 - 0
scripts/cloud/admin-role-policy.js

@@ -0,0 +1,12 @@
+// One policy for the administrative and operational gateways. Legacy admin means
+// company operations manager, never cross-company super administrator.
+function adminRoleSnapshot(current) {
+  const read = key => typeof current.get === 'function' ? current.get(key) : current[key];
+  const roles = Array.isArray(read('roles')) ? read('roles').map(String) : [];
+  const key = String(read('adminRoleKey') || '');
+  const valid = ['super-admin','ops-manager','ops-staff','ops-auditor'];
+  let role = valid.includes(key) ? key : valid.find(value => roles.includes(value)) || '';
+  const legacy = read('isAdmin') === true || read('role') === 'admin' || roles.includes('admin');
+  if (!role && !key && !roles.some(value => value.startsWith('ops-')) && legacy) role = 'ops-manager';
+  return {roles:role ? [...new Set([...roles.filter(value=>!valid.includes(value)),role])] : roles,isSuperAdmin:role==='super-admin',operationsRole:role==='super-admin'?'ops-manager':role||'denied',roleKey:role,legacyRole:role==='ops-manager'&&!key&&!roles.some(value=>valid.includes(value)),isAdmin:Boolean(role)||legacy};
+}

+ 59 - 0
scripts/cloud/operator-accounts.js

@@ -0,0 +1,59 @@
+const OPERATOR_ROLES = [
+ {key:'ops-manager',label:'运营经理',description:'管理运营业务及本帐套运营账号;不能管理超级管理员'},
+ {key:'ops-staff',label:'运营人员',description:'处理日常会员、开课、排课与学习业务'},
+ {key:'ops-auditor',label:'审计员',description:'只读核查业务记录与报表'},
+ {key:'super-admin',label:'超级管理员',description:'管理技术后台及超级管理员权限'},
+];
+function operatorCompany(context){const company=context.current.get('company');const id=company&&(company.id||company.objectId);if(!id)fail(403,'请先为当前管理员关联帐套');return id;}
+function requireOperatorManager(context){if(operationsRoleOf(context)!=='ops-manager')fail(403,'只有运营经理或超级管理员可以管理运营账号');return operatorCompany(context);}
+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};}
+function operatorReason(input){const value=String(input.reason||'').trim();if(value.length<2||value.length>500)fail(400,'请填写 2 至 500 字的操作原因');return value;}
+function operatorPassword(value){if(typeof value!=='string'||value.length<8||value.length>72)fail(400,'密码长度应为 8 至 72 位');return value;}
+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;}
+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;}
+async function operatorChange(context,targetId,action,payload,reason,expected=''){
+ const co=requireOperatorManager(context),id=Array.from({length:20},()=>Math.random().toString(36).slice(2,3)).join('');
+ 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;}
+}
+async function operatorAccounts(context,input){
+ const co=requireOperatorManager(context),operation=String(input.operation).replace('operatorAccounts/',''),payload=input.payload||{};
+ if(operation==='list'){
+  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||'');
+  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\'))';
+  const fields='u."objectId",u."username",u."realName",u."nickname",u."roles",u."role",u."adminRoleKey",u."isAdmin",u."isDisabled",u."createdAt",u."updatedAt"';
+  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])]);
+  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};
+ }
+ if(operation==='audit'){
+  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};
+ }
+ const reason=operatorReason(input);
+ if(operation==='create'){
+  const username=String(payload.username||'').trim(),displayName=String(payload.displayName||'').trim(),roleKey=String(payload.roleKey||'');
+  if(!/^[A-Za-z0-9_.@-]{3,64}$/.test(username))fail(400,'登录账号须为 3 至 64 位字母、数字或 _ . @ -');
+  if(!displayName||displayName.length>80)fail(400,'请填写 1 至 80 字的姓名');
+  if(!OPERATOR_ROLES.some(r=>r.key===roleKey)||(roleKey==='super-admin'&&!context.isSuperAdmin))fail(403,'不能分配该角色');
+  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());
+  try{await created.signUp(null,{useMasterKey:true});}catch(error){if(Number(error.code)===202)fail(409,'该登录账号已存在,请更换');throw error;}
+  // Do not enable until the actor is rechecked inside the locked transaction.
+  try{return await operatorChange(context,created.id,'create',{displayName,roleKey},reason);}catch(error){await created.destroy({useMasterKey:true});throw error;}
+ }
+ const target=await operatorTarget(context,input.targetId);
+ if(operation==='save')return operatorChange(context,target.id,'save',{displayName:payload.displayName,roleKey:payload.roleKey},reason,String(input.expectedUpdatedAt||''));
+ if(operation==='status')return operatorChange(context,target.id,'status',{disabled:payload.disabled},reason,String(input.expectedUpdatedAt||''));
+ if(operation==='revoke-sessions'){if(target.id===context.current.id)fail(400,'退出自己的会话请使用退出登录');return operatorChange(context,target.id,'revoke-sessions',{},reason);}
+ if(operation==='reset-password'){
+  if(target.id===context.current.id)fail(400,'修改自己的密码请使用修改密码入口');
+  const password=operatorPassword(payload.password);
+  // Disable first under the permission lock. A failed password/session update
+  // leaves the account disabled instead of leaving old credentials usable.
+  const wasDisabled=target.get('isDisabled')===true;
+  await operatorChange(context,target.id,'status',{disabled:true},reason.slice(0,470)+'(重置密码,暂时停用)',String(input.expectedUpdatedAt||''));
+  const log=await operatorAudit(context,target,'reset-password-started',reason);
+  try{await target.fetch({useMasterKey:true});target.setPassword(password);target.set('passwordResetRequired',false);await target.save(null,{useMasterKey:true});await revokeSessions([target]);
+   if(!wasDisabled)await operatorChange(context,target.id,'status',{disabled:false},reason.slice(0,470)+'(重置完成,恢复账号)',target.updatedAt.toISOString());
+   log.set('action','reset-password');await log.save(null,{useMasterKey:true});return {objectId:target.id};
+  }catch(error){fail(409,'密码重置未全部确认,请刷新核对账号状态后重试。');}
+ }
+ fail(404,'未知运营账号操作');
+}

+ 10 - 14
scripts/deploy-admin-functions.mjs

@@ -4,6 +4,8 @@ import { readFileSync } from 'node:fs';
 const enrollmentOpsCode = readFileSync(new URL('./cloud/member-enrollment.js', import.meta.url), 'utf8');
 const enrollmentAppCode = readFileSync(new URL('./cloud/member-enrollment-app.js', import.meta.url), 'utf8');
 const payrollIntegrityCode = readFileSync(new URL('./cloud/payroll-integrity.js', import.meta.url), 'utf8');
+const operatorAccountsCode = readFileSync(new URL('./cloud/operator-accounts.js', import.meta.url), 'utf8');
+const adminRolePolicyCode = readFileSync(new URL('./cloud/admin-role-policy.js', import.meta.url), 'utf8');
 const reviewRewardCode = readFileSync(new URL('./cloud/review-rewards.js', import.meta.url), 'utf8');
 
 const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
@@ -118,20 +120,13 @@ async function requireAdmin(request) {
   if (!current) fail(401, '管理员会话已失效');
   await current.fetch({ useMasterKey: true });
   if (current.get('isDisabled') === true || current.get('isDeleted') === true) fail(403, '管理员账号已停用');
-  const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : [];
-  const role = String(current.get('role') || '');
-  const roleKey = String(current.get('adminRoleKey') || '');
-  const isSuperAdmin = roleKey === 'super-admin' || roles.includes('super-admin');
-  const isAdmin = current.get('isAdmin') === true || role === 'admin' || roles.includes('admin') || isSuperAdmin;
+  const {roles,isSuperAdmin,isAdmin} = adminRoleSnapshot(current);
   if (!isAdmin) fail(403, '当前账号未被授权为后台管理员');
   const company = request.company || current.get('company') || null;
   if (!company && !isSuperAdmin) fail(403, '管理员账号尚未分配帐套');
   return { current, roles, isSuperAdmin, company };
 }
-function isAdminAccount(target) {
-  const roles = Array.isArray(target.get('roles')) ? target.get('roles').map(String) : [];
-  return target.get('isAdmin') === true || target.get('role') === 'admin' || roles.includes('admin') || target.get('adminRoleKey') === 'super-admin' || roles.includes('super-admin');
-}
+function isAdminAccount(target) { return adminRoleSnapshot(target).isAdmin; }
 function assertCanManageUser(context, target, action) {
   if (action === 'lock' && target.id === context.current.id) fail(400, '不能停用当前登录账号');
   if (isAdminAccount(target) && target.id !== context.current.id && !context.isSuperAdmin) fail(403, '只有超级管理员可以管理其他管理员账号');
@@ -215,6 +210,7 @@ async function handler(request, response) {
     const operation = String(input.operation || 'meta');
     const context = await requireAdmin(request);
     if (['techDashboard','catalog','apiCatalog','schema','list','get','save','delete'].includes(operation) && !context.isSuperAdmin) fail(403, '只有超级管理员可以使用技术后台和通用数据接口');
+    if (operation.startsWith('operatorAccounts/')) return response.json({success:true,data:await operatorAccounts(context,input)});
     if (operation === 'meta') {
       const functionQuery = new Parse.Query('Function');
       functionQuery.notEqualTo('enabled', false);
@@ -2954,8 +2950,8 @@ function safe(value, depth = 0) { if (value == null || depth > 4) return value;
 async function auth(request) {
   const current = request.user || (typeof user !== 'undefined' ? user : null); if (!current) fail(401, '管理员会话已失效'); await current.fetch({ useMasterKey:true });
   if (current.get('isDisabled') === true || current.get('isDeleted') === true) fail(403, '管理员账号已停用');
-  const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : []; const role = String(current.get('role') || ''); const roleKey = String(current.get('adminRoleKey') || ''); const isSuperAdmin = roleKey === 'super-admin' || roles.includes('super-admin');
-  if (!(current.get('isAdmin') === true || role === 'admin' || roles.includes('admin') || roles.some((item) => item.startsWith('ops-')) || isSuperAdmin)) fail(403, '当前账号未获运营后台权限');
+  const {roles,isSuperAdmin,isAdmin} = adminRoleSnapshot(current);
+  if (!isAdmin) fail(403, '当前账号未获运营后台权限');
   const company = request.company || current.get('company') || null; if (!company && !isSuperAdmin) fail(403, '管理员账号尚未分配帐套'); return { current, roles, isSuperAdmin, company };
 }
 async function fieldsOf(className) { return (await new Parse.Schema(className).get({ useMasterKey:true })).fields || {}; }
@@ -3689,9 +3685,9 @@ async function handler(request,response) {
 `;
 
 const definitions = [
-  { name: 'xiaoshu.ops.gateway', desc: '小树陪练运营管理后台专用网关(业务投影、分级隐私、三级权限与审计)', path: 'xiaoshu/ops/gateway', code: operationsGatewayCode + reviewRewardCode + enrollmentOpsCode + payrollIntegrityCode, params: [{ name: 'operation', type: 'String', required: true }] },
-  { name: 'xiaoshu.ops.gateway.v3', desc: '小树陪练运营管理后台专用网关 V3(含历史排课投影、上海业务日期与陪练排课汇总)', path: 'xiaoshu/ops/gateway-v3', code: operationsGatewayCode + reviewRewardCode + enrollmentOpsCode + payrollIntegrityCode, params: [{ name: 'operation', type: 'String', required: true }] },
-  { name: 'xiaoshu.admin.gateway', desc: '小树陪练 Angular 管理后台统一数据网关(权限、帐套、CRUD、统计)', path: 'xiaoshu/admin/gateway', code: adminGatewayCode, params: [{ name: 'operation', type: 'String', required: true }] },
+  { name: 'xiaoshu.ops.gateway', desc: '小树陪练运营管理后台专用网关(业务投影、分级隐私、三级权限与审计)', path: 'xiaoshu/ops/gateway', code: operationsGatewayCode + adminRolePolicyCode + reviewRewardCode + enrollmentOpsCode + payrollIntegrityCode, params: [{ name: 'operation', type: 'String', required: true }] },
+  { name: 'xiaoshu.ops.gateway.v3', desc: '小树陪练运营管理后台专用网关 V3(含历史排课投影、上海业务日期与陪练排课汇总)', path: 'xiaoshu/ops/gateway-v3', code: operationsGatewayCode + adminRolePolicyCode + reviewRewardCode + enrollmentOpsCode + payrollIntegrityCode, params: [{ name: 'operation', type: 'String', required: true }] },
+  { name: 'xiaoshu.admin.gateway', desc: '小树陪练 Angular 管理后台统一数据网关(权限、帐套、CRUD、统计)', path: 'xiaoshu/admin/gateway', code: adminGatewayCode + adminRolePolicyCode + operatorAccountsCode, params: [{ name: 'operation', type: 'String', required: true }] },
   { name: 'cms.content-normalizer', desc: '替代 ZL_StripeHtmlTag/ZL_StripeTrimstr,保守输出纯文本', path: 'xiaoshu/cms/content-normalizer', code: cmsReadCode('content-normalizer'), params: [{ name: 'content', type: 'String', required: false }] },
   { name: 'cms.users.extended', desc: '替代 ZL_EX_UserView,返回安全用户扩展资料', path: 'xiaoshu/cms/users/extended', code: cmsReadCode('user-extended'), params: [{ name: 'userId', type: 'String', required: true }] },
   { name: 'cms.users.platform', desc: '替代 ZL_User_PlatView', path: 'xiaoshu/cms/users/platform', code: cmsReadCode('user-platform'), params: [{ name: 'userId', type: 'String', required: true }] },

+ 21 - 0
scripts/migrate-operator-accounts.mjs

@@ -0,0 +1,21 @@
+#!/usr/bin/env node
+import {readFileSync,mkdirSync,writeFileSync} from 'node:fs';
+import {randomBytes,createHash} from 'node:crypto';
+const sql=readFileSync(new URL('./sql/operator-accounts.sql',import.meta.url),'utf8');
+if(process.argv.includes('--validate')){console.log('Operator account migration ready: '+createHash('sha256').update(sql).digest('hex'));process.exit(0);}
+if(!process.env.XIAOSHU_MASTER_KEY)throw Error('缺少 XIAOSHU_MASTER_KEY');
+const root=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),co=process.env.XIAOSHU_COMPANY_ID||'7pIbDBJmKx';
+async function request(path,method='GET',body){const r=await fetch(root+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main','X-Parse-Master-Key':process.env.XIAOSHU_MASTER_KEY},...(body?{body:JSON.stringify(body)}:{})});const d=await r.json();if(!r.ok||d.error)throw Error(d.error||'HTTP '+r.status);return d;}
+const fields={company:{type:'Pointer',targetClass:'Company'},actorId:{type:'String'},actorName:{type:'String'},targetId:{type:'String'},targetName:{type:'String'},action:{type:'String'},reason:{type:'String'},before:{type:'Object'},after:{type:'Object'}};
+let schema;try{schema=await request('/schemas/OperatorAccountAudit');}catch(e){if(!/exist|schema|class/i.test(e.message))throw e;}
+const dir='tmp/operator-account-migration-'+Date.now();mkdirSync(dir,{recursive:true,mode:0o700});writeFileSync(dir+'/schema-before.json',JSON.stringify(schema||null),{mode:0o600});
+console.log('Preflight ready; schema backup: '+dir);if(!process.argv.includes('--apply'))process.exit(0);
+const missing=Object.fromEntries(Object.entries(fields).filter(([key])=>!schema?.fields?.[key]));
+await request('/schemas/OperatorAccountAudit',schema?'PUT':'POST',{className:'OperatorAccountAudit',fields:missing,classLevelPermissions:{find:{},get:{},count:{},create:{},update:{},delete:{},addField:{}}});
+const suffix=Date.now()+'_'+randomBytes(5).toString('hex'),path='xiaoshu/system/operators-'+suffix;let uid='',fid='';
+try{
+ const actor=await request('/users','POST',{username:'operator_migration_'+suffix,password:randomBytes(32).toString('base64url'),company:{__type:'Pointer',className:'Company',objectId:co}});uid=actor.objectId;
+ const code=`async function handler(request,response){if(!request.user||request.user.id!==${JSON.stringify(uid)})return response.status(403).json({success:false});try{await Psql.none(${JSON.stringify(sql)});return response.json({success:true});}catch(e){return response.status(500).json({success:false,message:String(e.message)});}}`;
+ fid=(await request('/classes/Function','POST',{name:path,path,code,enabled:true,company:{__type:'Pointer',className:'Company',objectId:co}})).objectId;
+ const r=await fetch(root.replace(/\/parse$/,'')+'/api/functions/'+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:actor.sessionToken,params:{}})}),d=await r.json();if(!r.ok||!d.success)throw Error(d.message||'Migration failed');console.log('Operator schema and locked transaction installed');
+}finally{if(fid)await request('/classes/Function/'+fid,'DELETE');if(uid)await request('/users/'+uid,'DELETE');console.log('Migration fixtures cleaned');}

+ 59 - 0
scripts/smoke-operator-accounts.mjs

@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+// All mutations target newly created fixtures. No existing account is edited.
+import {randomBytes} from 'node:crypto';
+import {mkdirSync,writeFileSync} from 'node:fs';
+const base=process.env.XIAOSHU_SERVER_URL||'https://server.xiaoshu.pro',app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',co=process.env.XIAOSHU_COMPANY_ID||'7pIbDBJmKx';
+if(!process.env.XIAOSHU_MASTER_KEY)throw Error('Missing master key');
+const id=Date.now()+'_'+randomBytes(4).toString('hex'),password=randomBytes(18).toString('base64url'),nextPassword=randomBytes(18).toString('base64url'),users=[],dir='tmp/operator-smoke-'+id;mkdirSync(dir,{recursive:true,mode:0o700});
+async function parse(path,method='GET',body,session){const r=await fetch(base+'/parse'+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,...(session?{'X-Parse-Session-Token':session}:{'X-Parse-Master-Key':process.env.XIAOSHU_MASTER_KEY})},...(body?{body:JSON.stringify(body)}:{})});const d=await r.json();if(!r.ok||d.error)throw Error(d.error||'HTTP '+r.status);return d;}
+async function call(token,operation,params={},gateway='admin/gateway'){const r=await fetch(base+'/api/functions/xiaoshu/'+gateway,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{operation,...params}})}),d=await r.json();if(!r.ok||!d.success)throw Error(d.message||d.error||'HTTP '+r.status);return d.data;}
+const expect=(truth,label)=>{if(!truth)throw Error(label);console.log('PASS '+label);};
+async function denied(fn,label){try{await fn();}catch{console.log('PASS '+label);return;}throw Error(label+' unexpectedly allowed');}
+const login=async(username,pass=password)=>{const r=await fetch(base+'/parse/login?'+new URLSearchParams({username,password:pass}),{headers:{'X-Parse-Application-Id':app}}),d=await r.json();if(!r.ok)throw Error('Login rejected');return d;};
+async function seed(suffix,fields={}){const u=await parse('/users','POST',{username:'operator_smoke_'+id+'_'+suffix,password,company:{__type:'Pointer',className:'Company',objectId:co},...fields});users.push(u.objectId);writeFileSync(dir+'/fixture.json',JSON.stringify({users,actor:users[0]}),{mode:0o600});return u;}
+let actor;
+try{
+ actor=await seed('legacy');const token=actor.sessionToken;
+ // Master-authorized test provisioning; public Parse registration cannot assign roles.
+ if(!/^[A-Za-z0-9]+$/.test(actor.objectId))throw Error('Invalid fixture id');
+ const helperPath='xiaoshu/system/operator-fixture-'+id;
+ const grantSql="DO $$ BEGIN PERFORM set_config('xiaoshu.operator_write','on',true); UPDATE \"_User\" SET \"isAdmin\"=true,\"role\"='admin',\"roles\"='[\"admin\"]'::jsonb WHERE \"objectId\"='"+actor.objectId+"'; END $$;";
+ const helperCode=`async function handler(request,response){if(!request.user||request.user.id!==${JSON.stringify(actor.objectId)})return response.status(403).json({success:false});await Psql.none(${JSON.stringify(grantSql)});return response.json({success:true,data:{ready:true}});}`;
+ const helper=await parse('/classes/Function','POST',{name:helperPath,path:helperPath,code:helperCode,enabled:true,company:{__type:'Pointer',className:'Company',objectId:co}});
+ try{await call(token,'',{},helperPath.replace('xiaoshu/',''));}finally{await parse('/classes/Function/'+helper.objectId,'DELETE');}
+
+ const meta=await call(token,'meta');expect(meta.identity.operationsRole==='ops-manager'&&!meta.identity.isSuperAdmin,'旧 admin 会话识别运营经理且未提升为超级管理员');
+ await call(token,'ops/dashboard/summary',{},'ops/gateway-v3');console.log('PASS 旧管理员可通过运营网关权限校验');
+ const accountName='operator_smoke_'+id+'_staff';
+ const created=await call(token,'operatorAccounts/create',{reason:'独立账号验收',payload:{username:accountName,displayName:'运营账号测试',roleKey:'ops-staff',password}});users.push(created.objectId);writeFileSync(dir+'/fixture.json',JSON.stringify({users,actor:users[0]}),{mode:0o600});
+ const list=await call(token,'operatorAccounts/list',{search:accountName});let row=list.items.find(r=>r.objectId===created.objectId);expect(row&&row.roleKey==='ops-staff'&&!row.disabled,'新增账号及姓名角色查询');
+ await denied(()=>call(token,'operatorAccounts/create',{reason:'重复账号验收',payload:{username:accountName,displayName:'重复',roleKey:'ops-staff',password}}),'重复登录账号被拒绝');
+ const logged=await login(accountName);expect((await call(logged.sessionToken,'meta')).identity.operationsRole==='ops-staff','新账号可登录且角色正确');
+ await denied(()=>parse('/users/'+created.objectId,'PUT',{adminRoleKey:'super-admin',roles:['super-admin']},logged.sessionToken),'普通账号不能直接通过 Parse 修改权限字段');
+ await denied(()=>call(logged.sessionToken,'operatorAccounts/list'),'普通运营人员不能管理账号');
+ await denied(()=>call(token,'operatorAccounts/save',{targetId:created.objectId,reason:'越权验收',payload:{displayName:'测试',roleKey:'super-admin'}}),'经理不能授予超级管理员');
+ await call(token,'operatorAccounts/save',{targetId:created.objectId,expectedUpdatedAt:row.updatedAt,reason:'角色变更验收',payload:{displayName:'审计测试',roleKey:'ops-auditor'}});
+ await denied(()=>call(logged.sessionToken,'meta'),'角色变更使旧会话失效');
+ row=(await call(token,'operatorAccounts/list',{search:accountName})).items[0];expect(row.roleKey==='ops-auditor','角色变更保存正确');
+ await denied(()=>call(token,'operatorAccounts/status',{targetId:actor.objectId,reason:'本人保护',payload:{disabled:true}}),'不能停用当前登录账号');
+ await call(token,'operatorAccounts/reset-password',{targetId:row.objectId,expectedUpdatedAt:row.updatedAt,reason:'密码重置验收',payload:{password:nextPassword}});
+ await denied(()=>login(accountName,password),'旧密码失效');const newLogin=await login(accountName,nextPassword);expect((await call(newLogin.sessionToken,'meta')).identity.operationsRole==='ops-auditor','新密码登录并保留角色');
+ row=(await call(token,'operatorAccounts/list',{search:accountName})).items[0];await call(token,'operatorAccounts/status',{targetId:row.objectId,expectedUpdatedAt:row.updatedAt,reason:'停用验收',payload:{disabled:true}});await denied(()=>call(newLogin.sessionToken,'meta'),'停用撤销登录会话');
+ row=(await call(token,'operatorAccounts/list',{search:accountName,status:'disabled'})).items[0];expect(row?.disabled,'停用筛选正确');await call(token,'operatorAccounts/status',{targetId:row.objectId,expectedUpdatedAt:row.updatedAt,reason:'恢复验收',payload:{disabled:false}});
+ const restored=await login(accountName,nextPassword);await call(token,'operatorAccounts/revoke-sessions',{targetId:row.objectId,reason:'退出会话验收'});await denied(()=>call(restored.sessionToken,'meta'),'主动退出旧会话生效');
+ const audit=await call(token,'operatorAccounts/audit',{targetId:row.objectId});expect(audit.items.some(a=>a.action==='reset-password')&&audit.items.some(a=>a.action==='save')&&audit.items.some(a=>a.action==='create'),'创建角色密码操作都有审计');expect(!JSON.stringify(audit).includes(password)&&!JSON.stringify(audit).includes(nextPassword),'审计不包含密码');
+ console.log('OPERATOR_SMOKE_PASSED');
+ }finally{
+ // Privileged SQL cleanup is restricted to these exact disposable ids; direct
+ // user deletion must not bypass last-administrator protection.
+ const cleaner=await parse('/users','POST',{username:'operator_cleanup_'+id,password,company:{__type:'Pointer',className:'Company',objectId:co}});
+ let helper;
+ try{
+  if(users.some(uid=>!/^[A-Za-z0-9]+$/.test(uid)))throw Error('Invalid fixture id');
+  const ids=users.map(uid=>"'"+uid+"'").join(',')||"''";
+  const sql="DO $$ BEGIN PERFORM set_config('xiaoshu.operator_write','on',true); DELETE FROM \"OperatorAccountAudit\" WHERE \"actorId\" IN ("+ids+"); DELETE FROM \"_Session\" WHERE \"user\" IN ("+ids+"); DELETE FROM \"_User\" WHERE \"objectId\" IN ("+ids+"); END $$;";
+  const path='xiaoshu/system/operator-cleanup-'+id,code=`async function handler(request,response){if(!request.user||request.user.id!==${JSON.stringify(cleaner.objectId)})return response.status(403).json({success:false});await Psql.none(${JSON.stringify(sql)});return response.json({success:true,data:{cleaned:true}});}`;
+  helper=await parse('/classes/Function','POST',{name:path,path,code,enabled:true,company:{__type:'Pointer',className:'Company',objectId:co}});
+  await call(cleaner.sessionToken,'',{},path.replace('xiaoshu/',''));console.log('OPERATOR_FIXTURES_CLEANED');
+ }finally{if(helper)await parse('/classes/Function/'+helper.objectId,'DELETE');await parse('/users/'+cleaner.objectId,'DELETE');}
+}

+ 74 - 0
scripts/sql/operator-accounts.sql

@@ -0,0 +1,74 @@
+-- Role/status changes recheck the actor under a company lock. Two managers
+-- cannot concurrently disable/demote each other and leave no manager behind.
+CREATE OR REPLACE FUNCTION xs_operator_role(u jsonb) RETURNS text LANGUAGE sql IMMUTABLE AS $$
+ SELECT CASE
+ WHEN u->>'adminRoleKey' IN ('super-admin','ops-manager','ops-staff','ops-auditor') THEN u->>'adminRoleKey'
+ WHEN (CASE WHEN jsonb_typeof(u->'roles')='array' THEN u->'roles' ELSE '[]'::jsonb END) ? 'super-admin' THEN 'super-admin'
+ WHEN (CASE WHEN jsonb_typeof(u->'roles')='array' THEN u->'roles' ELSE '[]'::jsonb END) ? 'ops-manager' THEN 'ops-manager'
+ WHEN (CASE WHEN jsonb_typeof(u->'roles')='array' THEN u->'roles' ELSE '[]'::jsonb END) ? 'ops-staff' THEN 'ops-staff'
+ WHEN (CASE WHEN jsonb_typeof(u->'roles')='array' THEN u->'roles' ELSE '[]'::jsonb END) ? 'ops-auditor' THEN 'ops-auditor'
+ WHEN COALESCE(u->>'adminRoleKey','')='' AND NOT EXISTS(SELECT 1 FROM jsonb_array_elements_text((CASE WHEN jsonb_typeof(u->'roles')='array' THEN u->'roles' ELSE '[]'::jsonb END)) r WHERE r LIKE 'ops-%')
+ AND (u->>'isAdmin'='true' OR u->>'role'='admin' OR (CASE WHEN jsonb_typeof(u->'roles')='array' THEN u->'roles' ELSE '[]'::jsonb END) ? 'admin') THEN 'ops-manager'
+ ELSE '' END
+$$;
+CREATE OR REPLACE FUNCTION xs_operator_change(co text, actor_id text, target_id text, operation text, payload jsonb, reason text, expected text, audit_id text)
+RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE actor "_User"%ROWTYPE; target "_User"%ROWTYPE; actor_role text; target_role text; next_role text; before_value jsonb; after_value jsonb; revoked integer := 0;
+BEGIN
+ PERFORM set_config('xiaoshu.operator_write','on',true);
+ IF co IS NULL OR co='' OR length(trim(reason)) NOT BETWEEN 2 AND 500 THEN RAISE EXCEPTION '帐套或操作原因无效'; END IF;
+ PERFORM pg_advisory_xact_lock(hashtextextended('operator-account:'||co,0));
+ SELECT * INTO actor FROM "_User" WHERE "objectId"=actor_id FOR UPDATE;
+ actor_role := xs_operator_role(to_jsonb(actor));
+ IF actor."objectId" IS NULL OR COALESCE(actor."isDisabled",false) OR COALESCE(actor."isDeleted",false) OR actor_role NOT IN ('ops-manager','super-admin') OR actor."company" IS DISTINCT FROM co THEN RAISE EXCEPTION '当前账号无权管理运营账号'; END IF;
+ SELECT * INTO target FROM "_User" WHERE "objectId"=target_id AND "company"=co FOR UPDATE;
+ target_role := xs_operator_role(to_jsonb(target));
+ IF target."objectId" IS NULL OR COALESCE(target."isDeleted",false) OR (target_role='' AND COALESCE(target."identityType",'')<>'operator' AND NOT COALESCE(target."isAdmin",false) AND COALESCE(target."role",'')<>'admin' AND NOT COALESCE(target."roles",'[]'::jsonb) ? 'admin') THEN RAISE EXCEPTION '运营账号不存在'; END IF;
+ IF target_role='super-admin' AND actor_role<>'super-admin' THEN RAISE EXCEPTION '不能修改超级管理员'; END IF;
+ IF NULLIF(expected,'') IS NOT NULL AND date_trunc('milliseconds',target."updatedAt") IS DISTINCT FROM expected::timestamptz THEN RAISE EXCEPTION '账号已被其他管理员修改,请刷新'; END IF;
+ before_value := jsonb_build_object('displayName',target."realName",'roleKey',target_role,'disabled',COALESCE(target."isDisabled",false));
+ IF operation IN ('save','create') THEN
+  next_role := payload->>'roleKey';
+  IF next_role IS NULL OR next_role NOT IN ('super-admin','ops-manager','ops-staff','ops-auditor') OR (next_role='super-admin' AND actor_role<>'super-admin') THEN RAISE EXCEPTION '不能分配该角色'; END IF;
+  IF actor_id=target_id AND next_role<>target_role THEN RAISE EXCEPTION '不能修改自己的管理角色'; END IF;
+  IF length(trim(COALESCE(payload->>'displayName',''))) NOT BETWEEN 1 AND 80 THEN RAISE EXCEPTION '请填写 1 至 80 字的姓名'; END IF;
+  UPDATE "_User" SET "realName"=trim(payload->>'displayName'),"nickname"=trim(payload->>'displayName'),"roles"=jsonb_build_array('admin',next_role),"adminRoleKey"=next_role,"role"='admin',"isAdmin"=true,"identityType"='operator',"isDisabled"=CASE WHEN operation='create' THEN false ELSE "isDisabled" END,"updatedAt"=date_trunc('milliseconds',clock_timestamp()) WHERE "objectId"=target_id;
+  IF next_role<>target_role THEN DELETE FROM "_Session" WHERE "user"=target_id;GET DIAGNOSTICS revoked=ROW_COUNT; END IF;
+ ELSIF operation='status' THEN
+  IF actor_id=target_id THEN RAISE EXCEPTION '不能停用当前登录账号'; END IF;
+  IF jsonb_typeof(payload->'disabled') IS DISTINCT FROM 'boolean' THEN RAISE EXCEPTION '账号状态无效'; END IF;
+  UPDATE "_User" SET "isDisabled"=(payload->>'disabled')::boolean,"updatedAt"=date_trunc('milliseconds',clock_timestamp()) WHERE "objectId"=target_id;
+  IF (payload->>'disabled')::boolean THEN DELETE FROM "_Session" WHERE "user"=target_id;GET DIAGNOSTICS revoked=ROW_COUNT; END IF;
+ ELSIF operation='revoke-sessions' THEN
+  DELETE FROM "_Session" WHERE "user"=target_id;GET DIAGNOSTICS revoked=ROW_COUNT;
+ ELSE RAISE EXCEPTION '不支持的账号操作'; END IF;
+ SELECT jsonb_build_object('displayName',"realName",'roleKey',xs_operator_role(to_jsonb(u)),'disabled',COALESCE("isDisabled",false)) INTO after_value FROM "_User" u WHERE "objectId"=target_id;
+ INSERT INTO "OperatorAccountAudit"("objectId","company","actorId","actorName","targetId","targetName","action","reason","before","after","createdAt","updatedAt") VALUES(audit_id,co,actor_id,actor."username",target_id,target."username",operation,reason,before_value,after_value,now(),now());
+ PERFORM set_config('xiaoshu.operator_write','off',true);
+ RETURN jsonb_build_object('objectId',target_id,'revokedSessions',revoked);
+END $$;
+
+-- Parse user endpoints allow owners to update custom fields even with a private
+-- ACL. Protect authorization at storage level, including crafted sign-up data.
+CREATE OR REPLACE FUNCTION xs_guard_operator_authority() RETURNS trigger LANGUAGE plpgsql AS $$
+DECLARE old_json jsonb; new_json jsonb := to_jsonb(NEW); key text;
+BEGIN
+ IF current_setting('xiaoshu.operator_write',true)='on' THEN IF TG_OP='DELETE' THEN RETURN OLD; END IF; RETURN NEW; END IF;
+ IF TG_OP='DELETE' THEN IF xs_operator_role(to_jsonb(OLD))<>'' THEN RAISE EXCEPTION '运营账号请通过账号管理停用,不能直接删除'; END IF; RETURN OLD; END IF;
+ IF TG_OP='INSERT' THEN
+  IF xs_operator_role(new_json)<>'' OR NEW."isAdmin"=true OR COALESCE(NEW."adminRoleKey",'')<>'' OR NEW."role"='admin' THEN RAISE EXCEPTION '管理员角色只能通过运营账号管理分配'; END IF;
+  RETURN NEW;
+ END IF;
+ old_json := to_jsonb(OLD);
+ FOREACH key IN ARRAY ARRAY['roles','role','adminRoleKey','isAdmin'] LOOP
+  IF old_json->key IS DISTINCT FROM new_json->key THEN RAISE EXCEPTION '管理员权限字段只能通过运营账号管理修改'; END IF;
+ END LOOP;
+ IF xs_operator_role(old_json)<>'' OR OLD."identityType"='operator' THEN
+  FOREACH key IN ARRAY ARRAY['company','identityType','isDisabled','isDeleted'] LOOP
+   IF old_json->key IS DISTINCT FROM new_json->key THEN RAISE EXCEPTION '管理员归属与状态只能通过运营账号管理修改'; END IF;
+  END LOOP;
+ END IF;
+ RETURN NEW;
+END $$;
+DROP TRIGGER IF EXISTS xs_operator_authority_guard ON "_User";
+CREATE TRIGGER xs_operator_authority_guard BEFORE INSERT OR UPDATE OR DELETE ON "_User" FOR EACH ROW EXECUTE FUNCTION xs_guard_operator_authority();

+ 18 - 0
scripts/tests/operator-accounts.test.mjs

@@ -0,0 +1,18 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import vm from 'node:vm';
+import {PGlite} from '@electric-sql/pglite';
+const policy=readFileSync(new URL('../cloud/admin-role-policy.js',import.meta.url),'utf8');
+const context={};vm.createContext(context);vm.runInContext(policy,context);
+test('旧管理员仅兼容本帐套运营经理,普通会员不获得后台权限',()=>{const r=context.adminRoleSnapshot({isAdmin:true,roles:['admin'],role:'admin'});assert.equal(r.operationsRole,'ops-manager');assert.equal(r.isSuperAdmin,false);assert.equal(r.legacyRole,true);assert.equal(context.adminRoleSnapshot({roles:[]}).isAdmin,false);});
+test('显式角色优先,adminRoleKey 与数组一致识别,未知运营角色拒绝',()=>{for(const role of ['ops-manager','ops-staff','ops-auditor']){assert.equal(context.adminRoleSnapshot({adminRoleKey:role}).operationsRole,role);assert.equal(context.adminRoleSnapshot({roles:[role]}).operationsRole,role);}assert.equal(context.adminRoleSnapshot({isAdmin:true,adminRoleKey:'ops-missing'}).operationsRole,'denied');assert.equal(context.adminRoleSnapshot({roles:['admin','ops-missing']}).operationsRole,'denied');assert.equal(context.adminRoleSnapshot({adminRoleKey:'ops-auditor',roles:['admin','ops-manager','super-admin']}).isSuperAdmin,false);});
+async function database(){const db=new PGlite();await db.exec(`CREATE TABLE "_User"("objectId" text PRIMARY KEY,"company" text,"username" text,"realName" text,"nickname" text,"roles" jsonb,"role" text,"adminRoleKey" text,"isAdmin" boolean,"identityType" text,"isDisabled" boolean,"isDeleted" boolean,"updatedAt" timestamptz DEFAULT now());CREATE TABLE "_Session"("objectId" text,"user" text);CREATE TABLE "OperatorAccountAudit"("objectId" text PRIMARY KEY,"company" text,"actorId" text,"actorName" text,"targetId" text,"targetName" text,"action" text,"reason" text,"before" jsonb,"after" jsonb,"createdAt" timestamptz,"updatedAt" timestamptz);
+ INSERT INTO "_User"("objectId","company","username","roles","isAdmin","isDisabled") VALUES('manager','co','manager','["admin"]',true,false),('staff','co','staff','["ops-staff"]',true,false),('manager2','co','manager2','["ops-manager"]',true,false),('super','co','super','["super-admin"]',true,false),('outside','other','outside','["ops-staff"]',true,false);INSERT INTO "_Session" VALUES('session','staff');`);await db.exec(readFileSync(new URL('../sql/operator-accounts.sql',import.meta.url),'utf8'));return db;}
+const change=(db,actor,target,action,payload={},version='',audit='a')=>db.query('SELECT xs_operator_change($1,$2,$3,$4,$5,$6,$7,$8) AS result',['co',actor,target,action,JSON.stringify(payload),'测试管理',version,audit]);
+test('角色修改撤销旧会话且审计只保存业务字段',async()=>{const db=await database();try{await change(db,'manager','staff','save',{displayName:'运营同事',roleKey:'ops-auditor'});assert.equal((await db.query('SELECT * FROM "_Session"')).rows.length,0);const audit=(await db.query('SELECT * FROM "OperatorAccountAudit"')).rows[0];assert.equal(audit.after.roleKey,'ops-auditor');assert.equal(audit.before.roleKey,'ops-staff');assert.equal((await db.query('SELECT "adminRoleKey" FROM "_User" WHERE "objectId"=\'staff\'')).rows[0].adminRoleKey,'ops-auditor');}finally{await db.close();}});
+test('普通运营不能管理账号,经理不能越帐套或提权超级管理员',async()=>{const db=await database();try{await assert.rejects(change(db,'staff','manager','status',{disabled:true}),/无权/);await assert.rejects(change(db,'manager','outside','status',{disabled:true}),/不存在/);await assert.rejects(change(db,'manager','super','status',{disabled:true}),/超级管理员/);await assert.rejects(change(db,'manager','staff','save',{displayName:'越权',roleKey:'super-admin'}),/不能分配/);assert.equal((await db.query('SELECT * FROM "OperatorAccountAudit"')).rows.length,0);}finally{await db.close();}});
+test('保护本人和版本冲突,停用后旧会话被清除',async()=>{const db=await database();try{await assert.rejects(change(db,'manager','manager','status',{disabled:true}),/不能停用/);await assert.rejects(change(db,'manager','manager','save',{displayName:'本人',roleKey:'ops-staff'}),/自己/);await assert.rejects(change(db,'manager','staff','status',{disabled:true},'2000-01-01'),/已被/);await change(db,'manager','staff','status',{disabled:true});assert.equal((await db.query('SELECT * FROM "_Session"')).rows.length,0);}finally{await db.close();}});
+test('两个经理互相停用时后一个操作重新验证权限,保留有效经理',async()=>{const db=await database();try{const r=await Promise.allSettled([change(db,'manager','manager2','status',{disabled:true},'','a'),change(db,'manager2','manager','status',{disabled:true},'','b')]);assert.equal(r.filter(x=>x.status==='fulfilled').length,1);assert.equal((await db.query('SELECT count(*)::int AS n FROM "_User" WHERE "objectId" IN (\'manager\',\'manager2\') AND NOT "isDisabled"')).rows[0].n,1);}finally{await db.close();}});
+test('SQL 与云函数角色口径一致',async()=>{const db=await database();try{for(const row of [{isAdmin:true},{roles:['admin']},{roles:['ops-auditor'],adminRoleKey:'ops-staff'},{adminRoleKey:'ops-missing',isAdmin:true},{roles:['super-admin']},{roles:[]},{roles:['admin','ops-missing']}]){const r=await db.query('SELECT xs_operator_role($1::jsonb) AS role',[JSON.stringify(row)]);assert.equal(r.rows[0].role,context.adminRoleSnapshot(row).roleKey);}}finally{await db.close();}});
+test('底层用户接口不能直接修改权限、跨帐套或构造管理员注册',async()=>{const db=await database();try{await assert.rejects(db.query('DELETE FROM "_User" WHERE "objectId"=\'manager\''),/不能直接删除/);await assert.rejects(db.query('UPDATE "_User" SET "roles"=\'["super-admin"]\' WHERE "objectId"=\'staff\''),/权限字段/);await assert.rejects(db.query('UPDATE "_User" SET "company"=\'other\' WHERE "objectId"=\'staff\''),/归属与状态/);await assert.rejects(db.query('INSERT INTO "_User"("objectId","roles") VALUES(\'attacker\',\'["admin"]\')'),/只能通过/);await db.query('INSERT INTO "_User"("objectId","isAdmin","identityType","isDisabled") VALUES(\'pending\',false,\'operator\',true)');assert.equal((await db.query('SELECT count(*)::int AS n FROM "_User" WHERE "objectId"=\'pending\'')).rows[0].n,1);}finally{await db.close();}});

Некоторые файлы не были показаны из-за большого количества измененных файлов