Procházet zdrojové kódy

feat: isolate store login, students and scheduling permissions

彭峰 před 1 týdnem
rodič
revize
817b561a53
34 změnil soubory, kde provedl 412 přidání a 58 odebrání
  1. 49 0
      docs/qa/store-scope-phase-two-2026-10-15.md
  2. 3 0
      projects/xiaoshu-admin/src/app/admin-session.service.ts
  3. 5 0
      projects/xiaoshu-admin/src/app/admin.constants.ts
  4. 8 1
      projects/xiaoshu-admin/src/app/admin.guard.ts
  5. 5 1
      projects/xiaoshu-admin/src/app/admin.models.ts
  6. 1 0
      projects/xiaoshu-admin/src/app/admin.routes.ts
  7. 3 3
      projects/xiaoshu-admin/src/app/components/member-credit-account.component.html
  8. 3 1
      projects/xiaoshu-admin/src/app/components/member-credit-account.component.ts
  9. 1 1
      projects/xiaoshu-admin/src/app/components/store-credit-account.component.html
  10. 3 2
      projects/xiaoshu-admin/src/app/components/store-credit-account.component.spec.ts
  11. 3 1
      projects/xiaoshu-admin/src/app/components/store-credit-account.component.ts
  12. 1 1
      projects/xiaoshu-admin/src/app/layout/admin-shell.component.spec.ts
  13. 2 2
      projects/xiaoshu-admin/src/app/layout/admin-shell.component.ts
  14. 2 2
      projects/xiaoshu-admin/src/app/pages/admin-login.component.ts
  15. 2 2
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html
  16. 6 5
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.spec.ts
  17. 3 1
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts
  18. 3 3
      projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.html
  19. 2 1
      projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.spec.ts
  20. 2 0
      projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.ts
  21. 0 0
      projects/xiaoshu-admin/src/app/pages/operations-users.component.html
  22. 1 0
      projects/xiaoshu-admin/src/app/pages/operations-users.component.ts
  23. 16 0
      projects/xiaoshu-admin/src/app/pages/store-home.component.ts
  24. 29 0
      projects/xiaoshu-admin/src/app/store-scope.spec.ts
  25. 10 0
      scripts/cloud/admin-role-policy.js
  26. 1 0
      scripts/cloud/member-credit-ledger.js
  27. 15 9
      scripts/cloud/member-enrollment.js
  28. 78 0
      scripts/cloud/store-scope.js
  29. 22 19
      scripts/deploy-admin-functions.mjs
  30. 12 0
      scripts/migrate-store-scope.mjs
  31. 17 0
      scripts/sql/store-scope.sql
  32. 12 0
      scripts/store-access-policy.mjs
  33. 3 3
      scripts/tests/member-enrollment.test.mjs
  34. 89 0
      scripts/tests/store-scope.test.mjs

+ 49 - 0
docs/qa/store-scope-phase-two-2026-10-15.md

@@ -0,0 +1,49 @@
+# 门店独立登录与数据隔离:第二阶段验收
+
+## 已实现的范围
+
+- 现有有效门店账号可使用管理后台登录,服务端返回独立 `store-admin` 身份,进入「本店工作台/本店学生/本店排课/修改密码」。总部权限保持原有行为。
+- 门店可以管理本店学生资料、创建学生、开通课程、从本店额度向学生划拨或按原单退回额度,以及创建、修改、取消、恢复本店预约。新增学生的归属由服务端绑定,不能指定其他门店。
+- 门店只能选择本店有效老师;历史预约仍按学生当前归属查看。跨店共享老师授权不包含在本阶段。
+- 门店不能充值自身额度、授权运营人员、修改学生所属门店、操作其他门店、访问总部工资、技术通用数据、同步和批量导入接口。门店不能代替老师结课。
+- 总部仍可管理所有门店,门店额度由第一阶段的总部授权流程入账。
+
+## 服务端约束
+
+门店编号和帐套来自服务端会话用户,并重新读取停用、删除及首次改密状态,不信任浏览器角色、筛选条件、门店编号或帐套参数。操作名单默认拒绝新接口。学生对象 ID、学生业务编号、原预约、课程绑定以及老师均独立核对归属;原对象检查早于幂等重放。列表在分页前限制所属门店。
+
+`xs_store_scope_guard` 在开课、额度划拨及排课写入的同一事务内再次检查门店、学生、老师和原对象;学生记录加锁,避免并发转店时使用旧归属写入。保持现有额度预览、课时预留、冲突检查和幂等凭据。
+
+原有 Parse REST 类权限允许绕过网关访问账号与教学数据。本次关闭现有 30 个账号、教学、流水和工资相关类的直接查询、写入与加字段权限,保留原字段保护配置。云函数通过受保护服务器权限执行;`/users/me` 会话验证保持可用。门店旧移动端业务入口拒绝访问,只保留会话校验、改密、退出,防止旧代理上下级查询扩大门店范围。
+
+## 本地验证
+
+- 门店权限、运营账号、开课排课、门店额度和学生流水回归:65 项通过,含真实 PGlite SQL 归属检查与事务失败阻断。
+- 管理端路由、菜单、登录、会话、学生、排课、额度组件:65 项通过。
+- 账号安全与移动教学回归:6 项通过。
+- 管理端生产构建成功;云函数 11 个定义语法校验通过。现有样式预算和 CommonJS 警告不影响构建。
+- Chrome 独立本地测试源使用门店短期会话连接线上后端:本店工作台、四项余额、学生列表、新增学生仅本店归属、课表和新增排课表单可用。代理主动拒绝业务写入,不创建真实学生、课程、预约或充值记录。
+
+## 线上验证
+
+使用两个现有门店的短期会话进行 35 项检查,结束后撤销测试会话;未重设任何真实密码:
+
+- 门店 287 返回 11 条本店学生,门店 100 共 243 条,查询第一页 100 条;不同门店结果无交叉,伪造门店筛选不生效。
+- 门店 100 的 2026 年 10 月课表返回 164 条本店预约、35 位本店有效老师;课程绑定也只关联本店学生。
+- 本店资料、开课选项、额度及流水读取成功。门店自身充值权限为 false。
+- 外店学生资料、额度、开课、创建预约,以及外店原预约的编辑、取消、恢复全部拒绝。非法写入请求不携带有效业务提交数据,不产生真实变更。
+- 工资、总部充值、授权、转店、身份修改、同步、未知接口及旧移动端越权路径全部拒绝。
+- 直接 REST 查询账号、会话、预约、课程、学习记录等返回 Parse 权限错误 119;自身用户空更新也被拒绝。正常 `/users/me` 验证成功。
+- 总部仍返回 628 条会员;老师和学生会话仍有效。另行回读双方首页/账户相关接口,不以构建代替实际请求。
+- 两个有效门店账号原本要求首次改密:57、73。该要求继续生效;287、100 不要求首次改密。未解除或代替用户修改密码。
+- 发现部分历史停用账号的 `identityType` 与旧用户组不一致;保留既有写入保护,不在本轮修改其身份或归属。
+
+## 发布、边界与回滚
+
+数据库新增守卫函数,以及两个运营网关、管理网关、移动网关已发布并回读校验。发布基于真实线上代码做局部替换,保留线上已有 SQL 类型兼容等修复,未覆盖整个服务。权限变更前保存类权限基线;账户业务数据摘要在发布前后相同。
+
+真实基线、哈希和验证结果保存在本机受保护目录:`/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-15-store-scope/`。不将凭据、完整线上用户数据或私有函数快照提交到 Git。
+
+正式前端站点尚未上传本轮静态构建:仓库现有 `release-admin-web.mjs` 仅提供打包和回读校验,没有配置站点上传入口。新页面已在本地管理端可用,并提供按提交版本打包的产物;后端已生效不能等同于生产前端已上线。未进行手机或微信 WebView 验收,也未提交真实生产充值、学生创建或排课。
+
+回滚时先暂停门店业务访问,再基于实际发布基线恢复本次函数段,核对线上哈希以免覆盖后续发布。保留原有余额、凭据、审计和防重约束。直接 REST 权限保持关闭,不为兼容旧页面恢复公共写入。新增数据库守卫函数可保留;需要删除时先确认没有在线调用方。不得通过删交易或重写余额回滚。

+ 3 - 0
projects/xiaoshu-admin/src/app/admin-session.service.ts

@@ -27,6 +27,7 @@ export class AdminSessionService {
   }
 
   readonly session = this.state.asReadonly();
+  readonly isStore = computed(() => this.state()?.operationsRole === 'store-admin');
   readonly isAuthenticated = computed(() => Boolean(this.state()?.sessionToken));
 
   async login(username: string, password: string): Promise<AdminSession> {
@@ -67,6 +68,8 @@ export class AdminSessionService {
       passwordResetRequired: identity.passwordResetRequired ?? current.passwordResetRequired,
       isSuperAdmin: identity.isSuperAdmin,
       operationsRole: identity.operationsRole,
+      storeId: identity.storeId,
+      storeObjectId: identity.storeObjectId,
       company: identity.company ?? current.company,
     };
     this.persist(session);

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

@@ -39,6 +39,11 @@ export const OPERATIONS_NAVIGATION: readonly AdminNavigationGroup[] = [
   { label: '个人设置', items: [{ label: '修改密码', icon: 'key-round', route: '/admin/change-password' }] },
 ];
 
+export const STORE_NAVIGATION: readonly AdminNavigationGroup[] = [
+ {label:'门店运营',items:[{label:'本店工作台',icon:'store',route:'/admin/store-home'},{label:'本店学生',icon:'users',route:'/admin/members'},{label:'本店排课',icon:'calendar-days',route:'/admin/schedule'}]},
+ {label:'个人设置',items:[{label:'修改密码',icon:'key-round',route:'/admin/change-password'}]}
+];
+
 export const TECH_NAVIGATION: readonly AdminNavigationGroup[] = [
   { label: '技术总览', items: [{ label: '技术工作台', icon: 'layout-dashboard', route: '/tech-admin/dashboard' }] },
   { label: '数据与接口', items: [

+ 8 - 1
projects/xiaoshu-admin/src/app/admin.guard.ts

@@ -7,7 +7,14 @@ import { CloudFunctionsService } from './cloud-functions.service';
 export const adminGuard: CanActivateFn = (_route, state) => {
   const sessions = inject(AdminSessionService);
   if(sessions.session()?.passwordResetRequired&&state.url!=='/admin/change-password')return inject(Router).createUrlTree(['/admin/change-password']);
-  if (sessions.isAuthenticated()) return true;
+  if (sessions.isAuthenticated()) {
+    if(sessions.session()?.operationsRole==='store-admin'){
+      const path=state.url.split('?')[0];
+      if(path==='/admin'||path==='/admin/dashboard')return inject(Router).createUrlTree(['/admin/store-home']);
+      if(!/^\/admin\/(store-home|members(?:\/[^/]+)?|schedule|change-password|forbidden)$/.test(path))return inject(Router).createUrlTree(['/admin/forbidden']);
+    }
+    return true;
+  }
   return inject(Router).createUrlTree(['/admin/login'], { queryParams: { returnUrl: state.url } });
 };
 

+ 5 - 1
projects/xiaoshu-admin/src/app/admin.models.ts

@@ -5,6 +5,8 @@ export interface ParsePointer {
 }
 
 export interface AdminIdentity {
+  storeId?: number;
+  storeObjectId?: string;
   passwordResetRequired?: boolean;
   objectId: string;
   username: string;
@@ -16,6 +18,8 @@ export interface AdminIdentity {
 }
 
 export interface AdminSession {
+  storeId?: number;
+  storeObjectId?: string;
   passwordResetRequired?: boolean;
   objectId: string;
   username: string;
@@ -27,7 +31,7 @@ export interface AdminSession {
   operationsRole?: OperationsRole;
 }
 
-export type OperationsRole = 'ops-manager' | 'ops-staff' | 'ops-auditor';
+export type OperationsRole = 'store-admin' | 'ops-manager' | 'ops-staff' | 'ops-auditor';
 
 export interface CloudEnvelope<T> {
   success: boolean;

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

@@ -2,6 +2,7 @@ import { Routes } from '@angular/router';
 import { adminGuard, operatorManagerGuard, superAdminGuard } from './admin.guard';
 
 const operationsChildren: Routes = [
+  {path:'store-home',data:{title:'本店工作台',parentUrl:null},loadComponent:()=>import('./pages/store-home.component').then(m=>m.StoreHomeComponent)},
   { path: 'support-requests', data: { title: '意见反馈与休假申请', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/support-requests.component').then(m => m.SupportRequestsComponent) },
   {path:'privacy-requests',data:{title:'注销申请',parentUrl:'/admin/dashboard'},canActivate:[operatorManagerGuard],loadComponent:()=>import('./pages/privacy-requests.component').then(m=>m.PrivacyRequestsComponent)},
   { path: '', pathMatch: 'full', redirectTo: 'dashboard' },

+ 3 - 3
projects/xiaoshu-admin/src/app/components/member-credit-account.component.html

@@ -4,12 +4,12 @@
 @if(data();as value){
 <div [class.credit-account-meta]="!store">
 <p>{{store?'总部入账后,可向所属学员划拨课时。':'充值来源:'+(value.storeName||'尚未关联门店,请先在会员资料中关联有效门店')}}</p>
-@if(!store){<p>体验资格:{{value.trialStatus==='issued'?'已发放过,不可再次领取':value.trialStatus==='eligible'?'可发放 1 次':'待核验旧系统体验记录'}} · 退回体验额度不恢复领取资格</p>@if(value.canPost&&value.trialStatus==='pending'){<button type="button" (click)="importHistory()" [disabled]="saving()">核验旧体验并补齐历史流水</button>}}
+@if(!store){<p>体验资格:{{value.trialStatus==='issued'?'已发放过,不可再次领取':value.trialStatus==='eligible'?'可发放 1 次':'待核验旧系统体验记录'}} · 退回体验额度不恢复领取资格</p>@if(!isStore()&&value.canPost&&value.trialStatus==='pending'){<button type="button" (click)="importHistory()" [disabled]="saving()">核验旧体验并补齐历史流水</button>}}
 </div>
 <div class="accounts" [class.member-credit-summary]="!store">@for(a of value.accounts;track a.account){<article><strong>{{a.label}}</strong><b>{{a.available | number:'1.0-2'}}</b><span>可用 · 余额 {{a.balance}} · 已预留 {{a.reserved}}</span>@if(a.balance<0){<small class="error">历史负余额,需核对</small>}@if(!store){<div class="account-actions">@if(value.canPost){<button type="button" (click)="chooseAccount(a.account)">充值</button>}<button type="button" (click)="chooseAccount(a.account,true)">明细</button></div>}</article>}</div>
 @if(!value.canPost){<p>当前账号没有充值或扣回权限,可查看账户和流水。</p>}
 @if(value.canPost){<form (ngSubmit)="store?submit():check()" (input)="resetPreview()" (change)="resetPreview()"><fieldset [class.credit-entry-fields]="!store" [disabled]="saving()||loading()">
-@if(!store){<label class="credit-direction">业务类型<select name="direction" [(ngModel)]="direction" (ngModelChange)="sourceId='';resetPreview()"><option value="add">门店划拨充值</option><option value="deduct">原单扣回并退回门店</option><option value="adjust">历史差错调整</option></select></label>}
+@if(!store){<label class="credit-direction">业务类型<select name="direction" [(ngModel)]="direction" (ngModelChange)="sourceId='';resetPreview()"><option value="add">门店划拨充值</option><option value="deduct">原单扣回并退回门店</option>@if(!isStore()){<option value="adjust">历史差错调整</option>}</select></label>}
 @if(direction==='deduct'&&!store){<label class="wide credit-source">原充值单<select name="source" [(ngModel)]="sourceId" (ngModelChange)="selectSource($event)"><option value="">请选择原充值记录</option>@for(t of refundable();track t.objectId){<option [value]="t.objectId">{{t.createdAt|date:'yyyy-MM-dd HH:mm':'+0800'}} · {{label(t.account)}} · 可退 {{t.unreturned}}</option>}</select></label>}
 <label>课时类型<select name="account" [(ngModel)]="account" (ngModelChange)="resetPreview()" [disabled]="direction==='deduct'&&!store">@for(a of value.accounts;track a.account){<option [value]="a.account">{{a.label}}</option>}</select></label>
 <label class="credit-quantity">数量<input name="amount" type="number" [min]="account==='UserPoint'?0.01:1" [step]="account==='UserPoint'?0.01:1" [(ngModel)]="amount" required /></label>
@@ -30,7 +30,7 @@
 @if(!store){<section class="ledger-panel"><header class="section-header"><h3>全部课时流水</h3><button type="button" (click)="export()" [disabled]="exporting()">{{exporting()?'正在导出…':'导出 Excel'}}</button></header><p>时间均为北京时间;成套充值按一笔业务展示,可展开查看双方账户明细。</p>
 <div class="form-grid ledger-filters"><label>账户<select [(ngModel)]="filterAccount"><option value="">全部账户</option>@for(a of data()?.accounts||[];track a.account){<option [value]="a.account">{{a.label}}</option>}</select></label><label class="credit-direction">业务类型<select [(ngModel)]="filterKind"><option value="">全部业务</option>@for(k of ['transfer','refund','adjust','lesson','recharge','deduction'];track k){<option [value]="k">{{kindLabel(k)}}</option>}</select></label><app-operations-date-filter label="开始日期" [value]="dateFrom" (valueChange)="dateFrom=$event"/><app-operations-date-filter label="结束日期" [value]="dateTo" (valueChange)="dateTo=$event"/><div class="account-actions"><button type="button" (click)="query()">查询</button><button type="button" (click)="query(true)">重置</button></div></div>
 @if(ledgerError()){<p role="alert" class="error">{{ledgerError()}}</p>}@if(ledgerBusy()){<p role="status">正在加载流水…</p>}
-@for(row of ledger()?.items||[];track row.groupId){<details class="ledger-entry"><summary><time>{{row.at|date:'yyyy-MM-dd HH:mm':'+0800'}}</time><strong>{{kindLabel(row.lines[0].kind)}}</strong><span>@for(l of row.lines;track l.account){ {{label(l.account)}} {{l.delta>0?'+':''}}{{l.delta}} }</span><small>{{row.lines[0].reason||'未记录'}}</small></summary><div class="scroll"><table><thead><tr><th>账户</th><th>增减</th><th>调整前</th><th>调整后</th><th>操作人</th><th>来源与单据</th><th>门店</th></tr></thead><tbody>@for(l of row.lines;track l.account){<tr><td>{{label(l.account)}}</td><td>{{l.delta}}</td><td>{{l.before??'未记录'}}</td><td>{{l.after??'未记录'}}</td><td>{{l.operator||'未记录'}}</td><td>{{l.source==='legacy'?'旧系统':'新系统'}}<small>{{l.reference}}</small>@if(l.lessonId){<button type="button" (click)="lessonId=l.lessonId">查看上课记录</button>}</td><td>{{l.storeId||'未记录'}}@if(l.balances){<small>余额 {{l.balances.storeBefore??'—'}} → {{l.balances.storeAfter??'—'}}</small>}</td></tr>}</tbody></table></div><p>原因:{{row.lines[0].reason||'未记录'}}</p></details>}@empty{ @if(!ledgerBusy()&&!ledgerError()){<p>暂无符合条件的流水</p>} }
+@for(row of ledger()?.items||[];track row.groupId){<details class="ledger-entry"><summary><time>{{row.at|date:'yyyy-MM-dd HH:mm':'+0800'}}</time><strong>{{kindLabel(row.lines[0].kind)}}</strong><span>@for(l of row.lines;track l.account){ {{label(l.account)}} {{l.delta>0?'+':''}}{{l.delta}} }</span><small>{{row.lines[0].reason||'未记录'}}</small></summary><div class="scroll"><table><thead><tr><th>账户</th><th>增减</th><th>调整前</th><th>调整后</th><th>操作人</th><th>来源与单据</th><th>门店</th></tr></thead><tbody>@for(l of row.lines;track l.account){<tr><td>{{label(l.account)}}</td><td>{{l.delta}}</td><td>{{l.before??'未记录'}}</td><td>{{l.after??'未记录'}}</td><td>{{l.operator||'未记录'}}</td><td>{{l.source==='legacy'?'旧系统':'新系统'}}<small>{{l.reference}}</small>@if(l.lessonId&&!isStore()){<button type="button" (click)="lessonId=l.lessonId">查看上课记录</button>}</td><td>{{l.storeId||'未记录'}}@if(l.balances){<small>余额 {{l.balances.storeBefore??'—'}} → {{l.balances.storeAfter??'—'}}</small>}</td></tr>}</tbody></table></div><p>原因:{{row.lines[0].reason||'未记录'}}</p></details>}@empty{ @if(!ledgerBusy()&&!ledgerError()){<p>暂无符合条件的流水</p>} }
 <div class="section-header"><span>共 {{ledger()?.total||0}} 笔业务</span><label>每页<select [(ngModel)]="pageSize" (ngModelChange)="query()"><option [ngValue]="20">20 条</option><option [ngValue]="50">50 条</option><option [ngValue]="100">100 条</option></select></label><div class="account-actions"><button type="button" (click)="turn(-1)" [disabled]="page<=1||ledgerBusy()">上一页</button><span>{{page}}</span><button type="button" (click)="turn(1)" [disabled]="page*pageSize>=(ledger()?.total||0)||ledgerBusy()">下一页</button></div></div></section>}
 </section>
 @if(lessonId){<app-lesson-record-detail [lessonId]="lessonId" (closed)="lessonId=''"/>}

+ 3 - 1
projects/xiaoshu-admin/src/app/components/member-credit-account.component.ts

@@ -1,6 +1,7 @@
+import {AdminSessionService} from '../admin-session.service';
 import {DropdownDirective} from '../shared/dropdown.directive';
 import {CommonModule} from '@angular/common';
-import {Component,Input,Output,EventEmitter,OnChanges,ElementRef,inject,signal} from '@angular/core';
+import {Component,Input,Output,EventEmitter,OnChanges,ElementRef,computed,inject,signal} from '@angular/core';
 import {FormsModule} from '@angular/forms';
 import {RouterLink} from '@angular/router';
 import {OperationsService} from '../operations.service';
@@ -13,6 +14,7 @@ interface CreditPreview {version:string;studentId:number;storeId:number;lines:Cr
 interface LedgerPage {total:number;version:string;items:{groupId:string;at:string;lines:CreditLine[]}[];}
 @Component({selector:'app-member-credit-account',standalone:true,imports:[DropdownDirective,CommonModule,FormsModule,RouterLink,OperationsDateFilterComponent,LessonRecordDetailComponent],templateUrl:'./member-credit-account.component.html',styleUrls:['./member-enrollment.scss']})
 export class MemberCreditAccountComponent implements OnChanges {
+ readonly isStore=(()=>{const sessions=inject(AdminSessionService);return computed(()=>sessions.session()?.operationsRole==='store-admin');})();
  @Input({required:true}) objectId='';@Input() store=false;@Input() refreshVersion=0;@Output() changed=new EventEmitter<void>();
  private host=inject(ElementRef<HTMLElement>);private operations=inject(OperationsService);loading=signal(false);private serial=0;data=signal<CreditAccounts|null>(null);error=signal('');message=signal('');saving=signal(false);preview=signal<CreditPreview|null>(null);
  ledger=signal<LedgerPage|null>(null);ledgerBusy=signal(false);exporting=signal(false);ledgerError=signal('');private ledgerSerial=0;lessonId='';

+ 1 - 1
projects/xiaoshu-admin/src/app/components/store-credit-account.component.html

@@ -5,7 +5,7 @@
  @if(loading()&&!data()){<p>正在读取门店额度…</p>}
  @if(data()){<div class="balances">@for(a of accounts;track a.key){<article><span>{{a.label}}</span><strong>{{balance(a.key)?.balance | number:'1.0-2'}} <small>{{a.unit}}</small></strong><small>可用 {{balance(a.key)?.available | number:'1.0-2'}} · 预留 {{balance(a.key)?.reserved | number:'1.0-2'}}</small></article>}</div>}
  <nav><button type="button" [class.active]="tab()==='accounts'" (click)="tab.set('accounts')">额度概览</button><button type="button" [class.active]="tab()==='ledger'" (click)="showLedger()">额度流水</button></nav>
- @if(tab()==='accounts'){<p>总部为门店入账,门店再向所属学生划拨。四类额度分别记账。</p>@if(data()&&!data()?.canPost){<p>当前账号可查看额度;充值需由超级管理员单独授权。</p>}}
+ @if(tab()==='accounts'){<p>总部为门店入账,门店再向所属学生划拨。四类额度分别记账。</p>@if(data()&&!data()?.canPost){<p>{{isStore()?'本店额度由总部充值;为学生充值请进入学生的课时账户。':'当前账号可查看额度;充值需由超级管理员单独授权。'}}</p>}}
  @if(tab()==='ledger'){
  <div class="filters"><label>账户<select [(ngModel)]="accountFilter"><option value="">全部账户</option>@for(a of accounts;track a.key){<option [value]="a.key">{{a.label}}</option>}</select></label><label>业务<select [(ngModel)]="kindFilter"><option value="">全部业务</option>@for(k of ['fund','transfer','refund','adjust','recharge','deduction','lesson'];track k){<option [value]="k">{{kindLabel(k)}}</option>}</select></label><label>开始日期<input type="date" [(ngModel)]="dateFrom"/></label><label>结束日期<input type="date" [(ngModel)]="dateTo"/></label><button type="button" (click)="loadLedger(true)" [disabled]="ledgerBusy()">查询</button><button type="button" (click)="export()" [disabled]="exporting()">{{exporting()?'导出中…':'导出 Excel'}}</button></div>
  @if(ledgerError()){<p class="error" role="alert">{{ledgerError()}}</p>}@if(ledgerBusy()){<p>正在读取流水…</p>}

+ 3 - 2
projects/xiaoshu-admin/src/app/components/store-credit-account.component.spec.ts

@@ -1,3 +1,4 @@
+import {AdminSessionService} from '../admin-session.service';
 import {ComponentFixture,TestBed} from '@angular/core/testing';
 import {StoreCreditAccountComponent} from './store-credit-account.component';
 import {OperationsService} from '../operations.service';
@@ -7,7 +8,7 @@ describe('门店额度业务',()=>{
  let fixture:ComponentFixture<StoreCreditAccountComponent>,c:StoreCreditAccountComponent,request:jasmine.Spy,mutate:jasmine.Spy;
  const data={storeId:10,storeName:'测试门店',canPost:true,accounts:[{account:'Purse',balance:4,available:4,reserved:0}],transactions:[]};
  const plan={version:'v1',storeId:10,lines:[{account:'Purse',delta:2,before:4,after:6}]};
- beforeEach(async()=>{request=jasmine.createSpy('request').and.callFake(async(op:string)=>op.endsWith('credit-accounts')?data:op.endsWith('credit-preview')?plan:{items:[],total:0,version:'ledger-v1'});mutate=jasmine.createSpy('mutate').and.resolveTo({});await TestBed.configureTestingModule({imports:[StoreCreditAccountComponent],providers:[{provide:OperationsService,useValue:{request,mutate}}]}).compileComponents();fixture=TestBed.createComponent(StoreCreditAccountComponent);fixture.componentRef.setInput('objectId','store');fixture.detectChanges();await fixture.whenStable();c=fixture.componentInstance;});
+ beforeEach(async()=>{request=jasmine.createSpy('request').and.callFake(async(op:string)=>op.endsWith('credit-accounts')?data:op.endsWith('credit-preview')?plan:{items:[],total:0,version:'ledger-v1'});mutate=jasmine.createSpy('mutate').and.resolveTo({});await TestBed.configureTestingModule({imports:[StoreCreditAccountComponent],providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}},{provide:OperationsService,useValue:{request,mutate}}]}).compileComponents();fixture=TestBed.createComponent(StoreCreditAccountComponent);fixture.componentRef.setInput('objectId','store');fixture.detectChanges();await fixture.whenStable();c=fixture.componentInstance;});
  afterEach(()=>fixture.destroy());
  function fill(){c.start();c.amounts={Purse:2,SilverCoin:3,UserExp:4,UserPoint:2.5};c.method='运营记账';c.reason='总部额度入账';}
  it('一次预览四类增量,只在确认后发送同一门店和版本',async()=>{fill();await c.check();expect(request.calls.mostRecent().args[1].payload.lines.length).toBe(4);expect(mutate).not.toHaveBeenCalled();await c.submit();expect(mutate.calls.mostRecent().args.slice(0,5)).toEqual(['ops/stores/credit-post','store','总部额度入账',{direction:'add',payMethod:'运营记账',lines:[{account:'Purse',amount:2},{account:'SilverCoin',amount:3},{account:'UserExp',amount:4},{account:'UserPoint',amount:2.5}]},'v1']);});
@@ -19,5 +20,5 @@ describe('门店额度业务',()=>{
  it('空项不入账,非法数字不请求服务器',async()=>{fill();c.amounts['Purse']=0.5;const count=request.calls.count();await c.check();expect(request.calls.count()).toBe(count);c.amounts['Purse']=0;await c.check();expect(request.calls.mostRecent().args[1].payload.lines.map((x:{account:string})=>x.account)).not.toContain('Purse');});
 });
 describe('门店充值独立授权',()=>{
- it('授权与撤销通过专用接口并要求原因',async()=>{const request=jasmine.createSpy().and.resolveTo({items:[]}),mutate=jasmine.createSpy().and.resolveTo({});await TestBed.configureTestingModule({imports:[StoreCreditGrantsComponent],providers:[{provide:OperationsService,useValue:{request,mutate}}]}).compileComponents();const fixture=TestBed.createComponent(StoreCreditGrantsComponent),c=fixture.componentInstance,row={objectId:'manager',name:'运营',username:'ops',enabled:false,role:'ops-manager'};await c.save(row);expect(mutate).not.toHaveBeenCalled();c.reason='负责门店充值';await c.save(row);expect(mutate).toHaveBeenCalledWith('ops/stores/credit-grants/save','manager','负责门店充值',{enabled:true});fixture.destroy();});
+ it('授权与撤销通过专用接口并要求原因',async()=>{const request=jasmine.createSpy().and.resolveTo({items:[]}),mutate=jasmine.createSpy().and.resolveTo({});await TestBed.configureTestingModule({imports:[StoreCreditGrantsComponent],providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}},{provide:OperationsService,useValue:{request,mutate}}]}).compileComponents();const fixture=TestBed.createComponent(StoreCreditGrantsComponent),c=fixture.componentInstance,row={objectId:'manager',name:'运营',username:'ops',enabled:false,role:'ops-manager'};await c.save(row);expect(mutate).not.toHaveBeenCalled();c.reason='负责门店充值';await c.save(row);expect(mutate).toHaveBeenCalledWith('ops/stores/credit-grants/save','manager','负责门店充值',{enabled:true});fixture.destroy();});
 });

+ 3 - 1
projects/xiaoshu-admin/src/app/components/store-credit-account.component.ts

@@ -1,5 +1,6 @@
+import {AdminSessionService} from '../admin-session.service';
 import { CommonModule } from '@angular/common';
-import { Component, EventEmitter, Input, OnChanges, OnDestroy, Output, inject, signal } from '@angular/core';
+import { Component, EventEmitter, Input, OnChanges, OnDestroy, Output, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { OperationsService } from '../operations.service';
 import { CreditAccounts } from '../member-enrollment.models';
@@ -9,6 +10,7 @@ interface CreditPlan {version:string;storeId:number;lines:CreditLine[];}
 interface Ledger {total:number;version:string;items:Array<{groupId:string;at:string;lines:CreditLine[]}>;}
 @Component({selector:'app-store-credit-account',standalone:true,imports:[CommonModule,FormsModule],templateUrl:'./store-credit-account.component.html',styleUrls:['./store-credit-account.component.scss']})
 export class StoreCreditAccountComponent implements OnChanges,OnDestroy {
+ readonly isStore=(()=>{const sessions=inject(AdminSessionService);return computed(()=>sessions.session()?.operationsRole==='store-admin');})();
  @Input({required:true}) objectId='';@Input() compact=false;@Input() initialOpen=false;@Output() closed=new EventEmitter<void>();@Output() posted=new EventEmitter<string>();
  private readonly operations=inject(OperationsService);private serial=0;private ledgerSerial=0;private destroyed=false;private key='';private fingerprint='';private previewFingerprint='';
  readonly accounts=[{key:'Purse',label:'30分钟课时',unit:'节'},{key:'SilverCoin',label:'60分钟课时',unit:'节'},{key:'UserExp',label:'体验次数',unit:'次'},{key:'UserPoint',label:'陪练时长',unit:'小时'}];

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

@@ -36,7 +36,7 @@ describe('Admin shell navigation', () => {
         const element = harness.routeNativeElement!;
         const back = element.querySelector<HTMLAnchorElement>('.back-button');
         expect(element.querySelector('.page-location')?.textContent).withContext(url).toContain(child.data!['title']);
-        if (child.path === 'dashboard') {
+        if (child.data!['parentUrl'] === null) {
           expect(back).withContext(url).toBeNull();
         } else {
           const target = child.path!.startsWith('members/') ? '/admin/members'

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

@@ -51,7 +51,7 @@ import {
   WalletCards,
   Workflow,
 } from 'lucide-angular';
-import { OPERATIONS_NAVIGATION, TECH_NAVIGATION } from '../admin.constants';
+import { OPERATIONS_NAVIGATION, STORE_NAVIGATION, TECH_NAVIGATION } from '../admin.constants';
 import { AdminRequestActivityService } from '../admin-request-activity.service';
 import { AdminSessionService } from '../admin-session.service';
 
@@ -117,7 +117,7 @@ export class AdminShellComponent {
     updatePage();
     router.events.pipe(filter(event => event instanceof NavigationEnd), takeUntilDestroyed()).subscribe(updatePage);
     this.mode = route.snapshot.data['mode'] === 'technical' ? 'technical' : 'operations';
-    this.navigation = computed(() => (this.mode === 'technical' ? TECH_NAVIGATION : OPERATIONS_NAVIGATION).filter(group => group.label !== '账号与安全' || this.sessions.session()?.isSuperAdmin || this.sessions.session()?.operationsRole === 'ops-manager'));
+    this.navigation = computed(() => (this.sessions.session()?.operationsRole==='store-admin'?STORE_NAVIGATION:this.mode === 'technical' ? TECH_NAVIGATION : OPERATIONS_NAVIGATION).filter(group => group.label !== '账号与安全' || this.sessions.session()?.isSuperAdmin || this.sessions.session()?.operationsRole === 'ops-manager'));
   }
 
   iconFor(name: string) {

+ 2 - 2
projects/xiaoshu-admin/src/app/pages/admin-login.component.ts

@@ -44,14 +44,14 @@ export class AdminLoginComponent {
     try {
       attemptToken = (await this.sessions.login(this.username, this.password)).sessionToken;
       const returnUrl = this.sessions.session()?.passwordResetRequired ? '/admin/change-password' : this.route.snapshot.queryParamMap.get('returnUrl') || '/admin/dashboard';
-      if (returnUrl.split('?')[0] === '/admin/dashboard') {
+      if (this.sessions.session()?.isSuperAdmin && returnUrl.split('?')[0] === '/admin/dashboard') {
         void this.operations.dashboard().catch(() => undefined);
         void import('../layout/admin-shell.component');
         void import('./admin-dashboard.component');
       }
       const meta = await this.functions.admin<{ identity: AdminIdentity }>('meta');
       this.sessions.applyIdentity(meta.identity);
-      await this.router.navigateByUrl(returnUrl);
+      await this.router.navigateByUrl(meta.identity.operationsRole==='store-admin'&&!this.sessions.session()?.passwordResetRequired?'/admin/store-home':returnUrl);
     } catch (error) {
       // Transient metadata/network failures must not revoke a successful login.
       if (error instanceof CloudFunctionError && error.status === 403 && attemptToken && this.sessions.session()?.sessionToken === attemptToken) this.sessions.logout(false);

+ 2 - 2
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html

@@ -88,7 +88,7 @@
   </header>
   @if(view()==='list'){
     <div class="table-scroll"><table class="data-table"><thead><tr><th>时间</th><th>会员</th><th>陪练</th><th>课程</th><th>课型</th><th>状态</th><th>操作</th></tr></thead><tbody>
-      @for(item of visibleAppointments();track item.targetId){<tr><td>{{item.startsAt|date:'MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</td><td>{{item.studentName}}</td><td>{{item.coachName}}@if(item.coachAccountMissing){<small class="exception">历史陪练账号已失效</small>}</td><td>{{item.courseName}}</td><td>{{item.classTypeLabel}}@if(item.readOnly){<small class="live-source">旧系统数据</small>}</td><td><span class="badge status-badge" [ngClass]="statusTone(item)">{{statusDisplayLabel(item)}}</span></td><td><div class="row-actions"><button type="button" (click)="openDetails(item)">查看明细</button>@if(!item.readOnly){<button type="button" (click)="viewFeedback(item)">查看教学记录</button>}@if(item.readOnly){<span class="read-only-label">原业务端维护</span>}@else{@if(rowActions(item);as actions){@if(actions.canEdit){<button type="button" (click)="openEdit(item)">修改</button>}@if(actions.canRestore){<button type="button" (click)="action(item,'ops/appointments/restore')">恢复</button>}@if(actions.canCancel){<button type="button" (click)="openDetails(item)">取消</button>}@if(actions.canComplete){<button type="button" (click)="action(item,'ops/appointments/complete')">{{actions.completeLabel}}</button>}@if(!actions.canEdit&&!actions.canRestore&&!actions.canCancel&&!actions.canComplete&&item.statusCode<11){<span class="read-only-label">按业务端状态流转</span>}}}</div></td></tr>}
+      @for(item of visibleAppointments();track item.targetId){<tr><td>{{item.startsAt|date:'MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</td><td>{{item.studentName}}</td><td>{{item.coachName}}@if(item.coachAccountMissing){<small class="exception">历史陪练账号已失效</small>}</td><td>{{item.courseName}}</td><td>{{item.classTypeLabel}}@if(item.readOnly){<small class="live-source">旧系统数据</small>}</td><td><span class="badge status-badge" [ngClass]="statusTone(item)">{{statusDisplayLabel(item)}}</span></td><td><div class="row-actions"><button type="button" (click)="openDetails(item)">查看明细</button>@if(!item.readOnly&&!isStore()){<button type="button" (click)="viewFeedback(item)">查看教学记录</button>}@if(item.readOnly){<span class="read-only-label">原业务端维护</span>}@else{@if(rowActions(item);as actions){@if(actions.canEdit){<button type="button" (click)="openEdit(item)">修改</button>}@if(actions.canRestore){<button type="button" (click)="action(item,'ops/appointments/restore')">恢复</button>}@if(actions.canCancel){<button type="button" (click)="openDetails(item)">取消</button>}@if(actions.canComplete){<button type="button" (click)="action(item,'ops/appointments/complete')">{{actions.completeLabel}}</button>}@if(!actions.canEdit&&!actions.canRestore&&!actions.canCancel&&!actions.canComplete&&item.statusCode<11){<span class="read-only-label">按业务端状态流转</span>}}}</div></td></tr>}
       @empty{<tr><td colspan="7"><div class="empty">{{error()?'课表未能加载,请重试':hasScheduleFilters()?'没有符合筛选条件的排课':'当前范围暂无排课;历史数据范围为 '+(data()?.dataDateFrom||'—')+' 至 '+(data()?.dataDateTo||'—')}}</div></td></tr>}
     </tbody></table></div>
   } @else {
@@ -118,7 +118,7 @@
       <div class="schedule-detail-cancel"><label for="schedule-cancel-reason">取消原因</label><textarea id="schedule-cancel-reason" rows="2" [ngModel]="cancelReason()" (ngModelChange)="cancelReason.set($event)" placeholder="填写取消原因后确认,操作将记录在业务日志中"></textarea></div>
     } @else if(item.readOnly){<p class="notice">这条排课来自旧系统实时数据。当前没有安全的旧系统写回通道,新后台暂不能取消;请在原业务端取消后重新同步。</p>}
     @if(detailError()){<p class="notice error" role="alert">{{detailError()}}</p>}
-    <footer>@if(!item.readOnly){<button type="button" (click)="viewFeedback(item)">查看教学记录</button>}<button type="button" (click)="closeDetails()" [disabled]="saving()">关闭</button>@if(rowActions(item).canEdit){<button type="button" (click)="editFromDetails(item)">修改排课</button>}@if(rowActions(item).canCancel){<button class="danger-action" type="button" (click)="cancelFromDetails()" [disabled]="saving()">{{saving()?'正在取消…':'确认取消排课'}}</button>}</footer>
+    <footer>@if(!item.readOnly&&!isStore()){<button type="button" (click)="viewFeedback(item)">查看教学记录</button>}<button type="button" (click)="closeDetails()" [disabled]="saving()">关闭</button>@if(rowActions(item).canEdit){<button type="button" (click)="editFromDetails(item)">修改排课</button>}@if(rowActions(item).canCancel){<button class="danger-action" type="button" (click)="cancelFromDetails()" [disabled]="saving()">{{saving()?'正在取消…':'确认取消排课'}}</button>}</footer>
   </aside>
 }
 

+ 6 - 5
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.spec.ts

@@ -1,3 +1,4 @@
+import {AdminSessionService} from '../admin-session.service';
 import { BehaviorSubject, Subject } from 'rxjs';
 import { OperationsLiveRefreshService } from '../operations-live-refresh.service';
 import { ComponentFixture, fakeAsync, flushMicrotasks, TestBed } from '@angular/core/testing';
@@ -177,7 +178,7 @@ describe('排课数据加载提示', () => {
     const response = new Promise<ScheduleCalendarData>(resolve => { finish = resolve; });
     await TestBed.configureTestingModule({
       imports: [OperationsScheduleComponent],
-      providers: [
+      providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}},
         { provide: OperationsService, useValue: { schedule: jasmine.createSpy().and.returnValue(response) } },
         { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } },
       ],
@@ -207,7 +208,7 @@ describe('排课明细与取消', () => {
     const mutate = jasmine.createSpy('mutate');
     await TestBed.configureTestingModule({
       imports: [OperationsScheduleComponent],
-      providers: [
+      providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}},
         { provide: OperationsService, useValue: { schedule: async () => ({ appointments: [], members: [], courses: [], coaches: [] }), mutate } },
         { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } },
       ],
@@ -228,7 +229,7 @@ describe('排课明细与取消', () => {
     const mutate = jasmine.createSpy('mutate').and.resolveTo({});
     await TestBed.configureTestingModule({
       imports: [OperationsScheduleComponent],
-      providers: [
+      providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}},
         { provide: OperationsService, useValue: { schedule: async () => ({ appointments: [], members: [], courses: [], coaches: [] }), mutate } },
         { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } },
       ],
@@ -252,7 +253,7 @@ describe('排课表单预检', () => {
   async function form(request: () => Promise<Record<string, unknown>>) {
     await TestBed.configureTestingModule({
       imports: [OperationsScheduleComponent],
-      providers: [
+      providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}},
         { provide: OperationsService, useValue: { request } },
         { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } },
       ],
@@ -299,7 +300,7 @@ describe('排课页面复用后的会员与开课数据', () => {
     changes = new Subject(); params = new BehaviorSubject(convertToParamMap({}));
     route = {snapshot:{queryParamMap:params.value},queryParamMap:params};
     schedule = jasmine.createSpy('schedule').and.callFake(async () => empty());
-    await TestBed.configureTestingModule({imports:[OperationsScheduleComponent],providers:[
+    await TestBed.configureTestingModule({imports:[OperationsScheduleComponent],providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}},
       {provide:OperationsService,useValue:{schedule,dataChanges$:changes}},
       {provide:ActivatedRoute,useValue:route},
       {provide:OperationsLiveRefreshService,useValue:{watch:()=>()=>undefined}},

+ 3 - 1
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts

@@ -1,3 +1,4 @@
+import { AdminSessionService } from '../admin-session.service';
 import { LessonRecordDetailComponent } from './lesson-record-detail.component';
 import { AnchoredMenuDirective } from '../shared/anchored-menu.directive';
 import { DropdownDirective } from '../shared/dropdown.directive';
@@ -151,7 +152,8 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
   saveButtonLabel():string{if(this.saving())return'保存中…';if(!this.preview())return'请先检查冲突';if(this.previewConflictCount()&&this.previewCandidates().length>1&&this.previewAvailableCount()>0)return`仅保存 ${this.previewAvailableCount()} 节无冲突排课`;return this.editing()?'确认修改':'确认保存';}
   statusTone(item:ScheduleAppointment):ScheduleStatusTone{return scheduleStatusTone(item);}
   statusDisplayLabel(item:ScheduleAppointment):string{return scheduleStatusLabel(item);}
-  rowActions(item:ScheduleAppointment):ReturnType<typeof scheduleRowActions>{return scheduleRowActions(item);}
+  readonly isStore=(()=>{const sessions=inject(AdminSessionService);return computed(()=>sessions.session()?.operationsRole==='store-admin');})();
+  rowActions(item:ScheduleAppointment):ReturnType<typeof scheduleRowActions>{const actions=scheduleRowActions(item);return this.isStore()?{...actions,canComplete:false}:actions;}
   openDetails(item:ScheduleAppointment):void{this.detail.set(item);this.cancelReason.set('');this.detailError.set('');}
   closeDetails():void{if(this.saving())return;this.detail.set(null);this.cancelReason.set('');this.detailError.set('');}
   editFromDetails(item:ScheduleAppointment):void{this.closeDetails();this.openEdit(item);}

+ 3 - 3
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.html

@@ -17,8 +17,8 @@
     <button type="button" [class.active]="tab()==='overview'" [attr.aria-pressed]="tab()==='overview'" (click)="selectTab('overview')"><lucide-icon [img]="icons.UserRound" [size]="17" />概览</button>
     <button type="button" [class.active]="tab()==='courses'" [attr.aria-pressed]="tab()==='courses'" (click)="selectTab('courses')"><lucide-icon [img]="icons.BookOpen" [size]="17" />课程</button>
     <button type="button" [class.active]="tab()==='appointments'" [attr.aria-pressed]="tab()==='appointments'" (click)="selectTab('appointments')"><lucide-icon [img]="icons.CalendarDays" [size]="17" />预约</button>
-    <button type="button" [class.active]="tab()==='lessons'" [attr.aria-pressed]="tab()==='lessons'" (click)="selectTab('lessons')"><lucide-icon [img]="icons.GraduationCap" [size]="17" />上课记录</button>
-    <button type="button" [class.active]="tab()==='reports'" [attr.aria-pressed]="tab()==='reports'" (click)="selectTab('reports')"><lucide-icon [img]="icons.FileChartColumn" [size]="17" />学习报表</button>
+@if(!isStore()){    <button type="button" [class.active]="tab()==='lessons'" [attr.aria-pressed]="tab()==='lessons'" (click)="selectTab('lessons')"><lucide-icon [img]="icons.GraduationCap" [size]="17" />上课记录</button>}
+@if(!isStore()){    <button type="button" [class.active]="tab()==='reports'" [attr.aria-pressed]="tab()==='reports'" (click)="selectTab('reports')"><lucide-icon [img]="icons.FileChartColumn" [size]="17" />学习报表</button>}
     <button type="button" [class.active]="tab()==='balances'" [attr.aria-pressed]="tab()==='balances'" (click)="selectTab('balances')"><lucide-icon [img]="icons.WalletCards" [size]="17" />充值与课时流水</button>
     <button type="button" [class.active]="tab()==='audit'" [attr.aria-pressed]="tab()==='audit'" (click)="selectTab('audit')"><lucide-icon [img]="icons.FileClock" [size]="17" />操作审计</button>
   </nav>
@@ -30,7 +30,7 @@
       <div class="detail-grid">
         <section class="panel profile-editor"><header><div><h2>基本资料</h2><span>维护会员联系方式与学习资料</span></div>@if (detail.permissions.canEdit) { <button type="button" class="primary" (click)="saveProfile()" [disabled]="saving()"><lucide-icon [img]="icons.Save" [size]="16" />保存资料</button> }</header><div class="form-grid"><label><span>会员姓名</span><input [disabled]="!detail.permissions.canEdit" [ngModel]="editName()" (ngModelChange)="editName.set($event)" /></label><label><span>昵称</span><input [disabled]="!detail.permissions.canEdit" [ngModel]="editNickname()" (ngModelChange)="editNickname.set($event)" /></label><label><span>手机号</span><div class="mobile-control"><input [disabled]="!detail.permissions.canEdit || !revealMobile()" [ngModel]="revealMobile() ? editMobile() : detail.mobileMasked" (ngModelChange)="editMobile.set($event)" />@if (!revealMobile() && detail.permissions.canRevealMobile) { <button type="button" (click)="reveal()"><lucide-icon [img]="icons.Eye" [size]="15" />查看</button> }</div></label><label><span>邮箱</span><input [disabled]="!detail.permissions.canEdit" [ngModel]="editEmail()" (ngModelChange)="editEmail.set($event)" /></label><label><span>性别</span><select [disabled]="!detail.permissions.canEdit" [ngModel]="editGender()" (ngModelChange)="editGender.set($event)"><option value="">未填写</option><option value="男">男</option><option value="女">女</option></select></label><label><span>年级</span><input [disabled]="!detail.permissions.canEdit" [ngModel]="editGrade()" (ngModelChange)="editGrade.set($event)" /></label></div><dl class="account-facts"><div><dt>注册时间</dt><dd>{{ detail.registeredAt | date:'yyyy-MM-dd HH:mm' }}</dd></div><div><dt>最后登录</dt><dd>{{ detail.lastLoginAt ? (detail.lastLoginAt | date:'yyyy-MM-dd HH:mm') : '—' }}</dd></div><div><dt>登录次数</dt><dd>{{ detail.loginCount }}</dd></div></dl></section>
         <section class="panel learning-panel"><header><div><h2>学习与课时</h2><span>{{ detail.learningStatusReason }}</span></div><span class="learning-status" [attr.data-status]="detail.learningStatus">{{ detail.learningStatusLabel }}</span></header><div class="metric-grid"><article><strong>{{ detail.periods30 }}</strong><span>30 分钟课时</span></article><article><strong>{{ detail.periods60 }}</strong><span>60 分钟课时</span></article><article><strong>{{ detail.trialPeriods }}</strong><span>体验课时</span></article><article><strong>{{ detail.balances.userPoint }}</strong><span>陪练时长</span></article><article><strong>{{ detail.activeCourseCount }}</strong><span>有效课程</span></article><article><strong>{{ detail.studyRecordCount }}</strong><span>学习记录</span></article></div><div class="risk-list">@for (risk of detail.riskFlags || []; track risk) { <span>{{ risk }}</span> } @empty { <small>当前没有运营风险提示</small> }</div></section>
-        <section class="panel store-panel"><header><div><h2>所属门店/代理</h2><span>选择后点击“保存资料”生效</span></div></header><label><span>搜索门店或代理</span><div class="search-control"><input [disabled]="!detail.permissions.canEdit" [ngModel]="agentSearch()" (ngModelChange)="agentSearch.set($event)" placeholder="输入门店或代理名称" /></div></label>@if (detail.permissions.canEdit) { <div class="option-list"><button type="button" [class.selected]="!parentUserId()" (click)="clearAgent()">暂不绑定</button>@for (agent of filteredAgents(); track agent.userId) { <button type="button" [class.selected]="parentUserId()===agent.userId" (click)="selectAgent(agent)"><strong>{{ agent.displayName }}</strong><span>{{ parentUserId()===agent.userId ? '已选择' : '选择门店' }}</span></button> }</div> }</section>
+@if(!isStore()){        <section class="panel store-panel"><header><div><h2>所属门店/代理</h2><span>选择后点击“保存资料”生效</span></div></header><label><span>搜索门店或代理</span><div class="search-control"><input [disabled]="!detail.permissions.canEdit" [ngModel]="agentSearch()" (ngModelChange)="agentSearch.set($event)" placeholder="输入门店或代理名称" /></div></label>@if (detail.permissions.canEdit) { <div class="option-list"><button type="button" [class.selected]="!parentUserId()" (click)="clearAgent()">暂不绑定</button>@for (agent of filteredAgents(); track agent.userId) { <button type="button" [class.selected]="parentUserId()===agent.userId" (click)="selectAgent(agent)"><strong>{{ agent.displayName }}</strong><span>{{ parentUserId()===agent.userId ? '已选择' : '选择门店' }}</span></button> }</div> }</section>}
         <section class="panel risk-panel"><header><div><h2>账号安全</h2><span>高风险操作仅运营经理可执行</span></div></header>@if (detail.permissions.canManage) { <div class="security-actions"><div class="account-status-action"><div><strong>账号状态</strong><span>{{ detail.statusLabel }}</span></div><button type="button" class="danger" (click)="changeStatus()" [disabled]="saving()"><lucide-icon [img]="icons.ShieldAlert" [size]="17" />{{ detail.status === 'disabled' ? '恢复账号' : '停用账号' }}</button></div>@if (detail.permissions.canResetPassword) { <div class="password-reset-group"><label><span>新临时密码</span><input type="password" autocomplete="new-password" [ngModel]="resetPassword()" (ngModelChange)="resetPassword.set($event)" placeholder="至少 8 位" /></label><button type="button" (click)="resetMemberPassword()" [disabled]="saving()">重置密码并退出旧会话</button></div> }</div> } @else { <p class="muted">当前账号没有高风险操作权限。</p> }</section>
       </div>
     }

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.spec.ts

@@ -1,3 +1,4 @@
+import {AdminSessionService} from '../admin-session.service';
 import { TestBed } from '@angular/core/testing';
 import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
 import { OperationsUserDetailComponent } from './operations-user-detail.component';
@@ -10,7 +11,7 @@ describe('会员详情业务交互', () => {
   beforeEach(()=>{
     operations=jasmine.createSpyObj('OperationsService',['userDetail','request','mutate','memberRows','learningReports']);
     operations.userDetail.and.resolveTo(detail);operations.request.and.resolveTo({items:[],total:41});operations.learningReports.and.resolveTo({items:[],total:0} as never);
-    TestBed.configureTestingModule({imports:[OperationsUserDetailComponent],providers:[provideRouter([]),{provide:ActivatedRoute,useValue:{snapshot:{paramMap:convertToParamMap({objectId:'member-1'}),queryParamMap:convertToParamMap({})}}},{provide:OperationsService,useValue:operations}]});
+    TestBed.configureTestingModule({imports:[OperationsUserDetailComponent],providers: [{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}},provideRouter([]),{provide:ActivatedRoute,useValue:{snapshot:{paramMap:convertToParamMap({objectId:'member-1'}),queryParamMap:convertToParamMap({})}}},{provide:OperationsService,useValue:operations}]});
   });
   it('搜索门店不会清空已保存的门店归属,显式取消才解绑', async()=>{
     const fixture=TestBed.createComponent(OperationsUserDetailComponent),c=fixture.componentInstance;fixture.detectChanges();await fixture.whenStable();fixture.detectChanges();

+ 2 - 0
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.ts

@@ -1,4 +1,5 @@
 import { LessonRecordsComponent } from './lesson-records.component';
+import { AdminSessionService } from '../admin-session.service';
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { OperationsDateFilterComponent } from '../components/operations-date-filter.component';
 import { MemberCreditAccountComponent } from '../components/member-credit-account.component';
@@ -20,6 +21,7 @@ type MemberTab = 'overview' | 'courses' | 'appointments' | 'lessons' | 'reports'
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class OperationsUserDetailComponent implements OnInit {
+  readonly isStore=(()=>{const sessions=inject(AdminSessionService);return computed(()=>sessions.session()?.operationsRole==='store-admin');})();
   readonly user = signal<OperationsUserDetail | null>(null); readonly tab = signal<MemberTab>('overview'); readonly related = signal<Record<MemberTab, Record<string, unknown>[]>>({ overview: [], courses: [], appointments: [], lessons: [], reports: [], balances: [], audit: [] });
   readonly notes = signal<Record<string, unknown>[]>([]); readonly loading = signal(true); readonly sectionLoading = signal(false); readonly saving = signal(false); readonly error = signal(''); readonly message = signal(''); readonly reason = signal('');
   readonly editName = signal(''); readonly editNickname = signal(''); readonly editMobile = signal(''); readonly editEmail = signal(''); readonly editGender = signal(''); readonly editGrade = signal(''); readonly parentUserId = signal(0); readonly agentSearch = signal('');

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
projects/xiaoshu-admin/src/app/pages/operations-users.component.html


+ 1 - 0
projects/xiaoshu-admin/src/app/pages/operations-users.component.ts

@@ -28,6 +28,7 @@ export class OperationsUsersComponent implements OnInit, OnDestroy {
   private readonly router = inject(Router); private readonly operations = inject(OperationsService); private readonly sessions = inject(AdminSessionService); private readonly liveRefresh = inject(OperationsLiveRefreshService); private stopLiveRefresh?: () => void;
   private loadRequest = 0;
 
+  readonly isStore = computed(()=>this.sessions.session()?.operationsRole==='store-admin');
   readonly canCreate = computed(() => this.sessions.session()?.operationsRole !== 'ops-auditor');
   readonly canBulkImport = computed(() => this.sessions.session()?.operationsRole === 'ops-manager');
   readonly filteredCreateAgents = computed(() => { const keyword = this.createAgentSearch().trim().toLowerCase(); return (this.page()?.agents || []).filter(agent => !keyword || `${agent.displayName} ${agent.userId}`.toLowerCase().includes(keyword)).slice(0, 30); });

+ 16 - 0
projects/xiaoshu-admin/src/app/pages/store-home.component.ts

@@ -0,0 +1,16 @@
+import {Component,inject,signal,OnInit} from '@angular/core';
+import {RouterLink} from '@angular/router';
+import {OperationsService} from '../operations.service';
+import {StoreCreditAccountComponent} from '../components/store-credit-account.component';
+interface StoreHome {storeId:number;storeObjectId:string;storeName:string;summary:{total:number;todayNew:number;studying:number};}
+@Component({selector:'app-store-home',standalone:true,imports:[RouterLink,StoreCreditAccountComponent],template:`
+<header><div><p>门店工作台</p><h1>{{data()?.storeName||'我的门店'}}</h1><span>管理本店学生、开课与排课</span></div><button type="button" (click)="load()" [disabled]="loading()">刷新</button></header>
+@if(error()){<p class="error" role="alert">{{error()}}</p>}
+@if(loading()&&!data()){<p>正在读取本店数据…</p>}
+@if(data();as d){<section class="summary"><article><span>本店学生</span><strong>{{d.summary.total}}</strong></article><article><span>学习中</span><strong>{{d.summary.studying}}</strong></article><article><span>今日新增</span><strong>{{d.summary.todayNew}}</strong></article></section><nav><a routerLink="/admin/members">管理学生 / 开通课程</a><a routerLink="/admin/schedule">查看课表 / 新增排课</a></nav><app-store-credit-account [objectId]="d.storeObjectId"/>}
+`,styles:[`:host{display:block;color:#213e38}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}h1{margin:8px 0}header p,span{color:#667e74}.summary{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.summary article{background:#fff;border:1px solid #d9e5df;border-radius:16px;padding:24px}.summary strong{display:block;font-size:32px;margin-top:12px;color:#18745f}nav{display:flex;gap:16px;margin:24px 0}a,button{padding:12px 18px;border-radius:10px;background:#18745f;color:white;border:0;text-decoration:none;cursor:pointer}.error{color:#a33322}@media(max-width:600px){.summary article{padding:14px}nav{flex-direction:column}}`]})
+export class StoreHomeComponent implements OnInit {
+ private readonly operations=inject(OperationsService);data=signal<StoreHome|null>(null);loading=signal(false);error=signal('');
+ ngOnInit(){void this.load();}
+ async load(){if(this.loading())return;this.loading.set(true);this.error.set('');try{this.data.set(await this.operations.request<StoreHome>('ops/store/home'));}catch(e){this.error.set(e instanceof Error?e.message:'本店数据加载失败');}finally{this.loading.set(false);}}
+}

+ 29 - 0
projects/xiaoshu-admin/src/app/store-scope.spec.ts

@@ -0,0 +1,29 @@
+import {TestBed} from '@angular/core/testing';
+import {Router,UrlTree,provideRouter} from '@angular/router';
+import {signal} from '@angular/core';
+import {AdminSessionService} from './admin-session.service';
+import {adminGuard} from './admin.guard';
+import {MemberCreditAccountComponent} from './components/member-credit-account.component';
+import {StoreCreditAccountComponent} from './components/store-credit-account.component';
+import {OperationsService} from './operations.service';
+import {STORE_NAVIGATION} from './admin.constants';
+
+describe('门店后台入口与权限',()=>{
+ const session=signal({operationsRole:'store-admin',storeId:10,storeObjectId:'store-a',passwordResetRequired:false});
+ beforeEach(()=>TestBed.configureTestingModule({providers:[provideRouter([]),{provide:AdminSessionService,useValue:{session,isAuthenticated:()=>true}},{provide:OperationsService,useValue:{request:()=>Promise.resolve({accounts:[],transactions:[],canPost:false,items:[],total:0})}}]}));
+ it('门店首页重定向且阻止直接访问总部路由',()=>{
+  for(const path of ['/admin/dashboard','/admin/payroll','/admin/stores','/admin/operator-accounts','/tech-admin/dashboard']){
+   const result=TestBed.runInInjectionContext(()=>adminGuard({} as never,{url:path} as never)) as UrlTree;
+   expect(TestBed.inject(Router).serializeUrl(result)).toBe(path==='/admin/dashboard'?'/admin/store-home':'/admin/forbidden');
+  }
+  for(const path of ['/admin/members','/admin/members/a','/admin/schedule','/admin/store-home','/admin/change-password'])expect(TestBed.runInInjectionContext(()=>adminGuard({} as never,{url:path} as never))).toBeTrue();
+ });
+ it('仅展示本店工作台、学生、排课和个人密码菜单',()=>{expect(STORE_NAVIGATION.flatMap(g=>g.items).map(i=>i.route)).toEqual(['/admin/store-home','/admin/members','/admin/schedule','/admin/change-password']);});
+ it('学生充值不提供历史差错增额或旧系统导入入口',async()=>{
+  const f=TestBed.createComponent(MemberCreditAccountComponent);f.componentInstance.data.set({storeId:10,storeName:'本店',trialStatus:'pending',canPost:true,accounts:[],transactions:[]} as never);f.detectChanges();
+  expect(f.nativeElement.querySelector('select[name="direction"] option[value="adjust"]')).toBeNull();expect(f.nativeElement.textContent).not.toContain('核验旧体验');f.destroy();
+ });
+ it('本店额度说明由总部充值,不出现充值授权建议',()=>{
+  const f=TestBed.createComponent(StoreCreditAccountComponent);f.componentInstance.data.set({storeId:10,storeName:'本店',canPost:false,accounts:[],transactions:[]} as never);f.detectChanges();expect(f.nativeElement.textContent).toContain('本店额度由总部充值');expect(f.nativeElement.textContent).not.toContain('单独授权');f.destroy();
+ });
+});

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

@@ -10,3 +10,13 @@ function adminRoleSnapshot(current) {
   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};
 }
+
+// A store is an independent principal, never a company administrator.
+function storeAccountSnapshot(current) {
+  const read = key => typeof current.get === 'function' ? current.get(key) : current[key];
+  const legacy = read('legacyUserData') || {};
+  const identity = String(read('identityType') || '');
+  const storeId = Number(read('legacyUserId') || legacy.UserID);
+  const store = identity === 'store' || (!identity && Number(read('legacyGroupId') || legacy.GroupID) === 2);
+  return store && Number.isSafeInteger(storeId) && storeId > 0 ? storeId : 0;
+}

+ 1 - 0
scripts/cloud/member-credit-ledger.js

@@ -10,5 +10,6 @@ async function memberLedger(context,input,store=false){
  SELECT "groupId",max("happenedAt") at,jsonb_agg(to_jsonb(l) ORDER BY account,id) lines FROM xs_member_ledger l WHERE company=$1 AND "studentId"=$2 GROUP BY "groupId"
  ), filtered AS (SELECT * FROM grouped WHERE ($3='' OR EXISTS(SELECT 1 FROM jsonb_array_elements(lines) v WHERE v->>'account'=$3)) AND ($4='' OR EXISTS(SELECT 1 FROM jsonb_array_elements(lines) v WHERE v->>'kind'=$4)) AND ($5='' OR at>=NULLIF($5,'')::date::timestamp AT TIME ZONE 'Asia/Shanghai') AND ($6='' OR at<(NULLIF($6,'')::date+1)::timestamp AT TIME ZONE 'Asia/Shanghai'))
  SELECT (SELECT md5(COALESCE(string_agg(lines::text,'' ORDER BY "groupId"),'')) FROM filtered) version,(SELECT count(*)::int FROM filtered) total,COALESCE((SELECT jsonb_agg(to_jsonb(p)) FROM (SELECT * FROM filtered ORDER BY at DESC NULLS LAST,"groupId" DESC LIMIT $7 OFFSET $8) p),'[]') items`,[co,uid,text(input.account),text(input.kind),from,to,size,(page-1)*size]);
+ if(context.storeId&&!store)for(const item of result.items)for(const line of item.lines){if(number(line.storeId)!==context.storeId){delete line.balances;delete line.storeBefore;delete line.storeAfter;delete line.storeAvailableBefore;delete line.storeAvailableAfter;}}
  return{...result,page,pageSize:size};
 }

+ 15 - 9
scripts/cloud/member-enrollment.js

@@ -1,6 +1,12 @@
 // Appended to the operations gateway; all writes remain behind its authentication.
-async function enrollmentSql(sql, args) {
-  try { return await Psql.one(sql,args); }
+async function enrollmentSql(sql, args, scope=null) {
+  try {
+    if(scope?.context.storeId)return await Psql.tx(async tx=>{
+      await tx.one('SELECT xs_store_scope_guard($1,$2,$3,$4,$5)',[pointerId(scope.context.company),scope.context.current.id,scope.studentId,scope.coachId||0,scope.targetId||'']);
+      return tx.one(sql,args);
+    });
+    return await Psql.one(sql,args);
+  }
   catch(error) { if(/does not exist/i.test(text(error.message))) fail(503,'用户开课数据迁移尚未完成'); fail(409,text(error.message).replace(/^.*ERROR:\s*/,'')); }
 }
 async function creditAccounts(context,input,store=false) {
@@ -11,14 +17,14 @@ async function creditAccounts(context,input,store=false) {
   const transactions=await Psql.query('SELECT t."objectId",t."kind",t."account",t."amount",t."storeId",t."studentId",t."sourceTransactionId",t."operatorId",t."reason",t."payMethod",t."balances",t."createdAt",t."operationId",t."amount"-COALESCE((SELECT SUM(r."amount") FROM "CreditTransaction" r WHERE r."company"=t."company" AND r."sourceTransactionId"=t."objectId" AND r."kind"=\'refund\'),0) AS "unreturned" FROM "CreditTransaction" t WHERE t."company"=$1 AND '+(store?'t."storeId"':'t."studentId"')+'=$2 ORDER BY t."createdAt" DESC',[co,uid]);
   const parent=store?null:await userByLegacyId(context,input,number(legacy.ParentUserID),2);
   const trial=store?null:await Psql.oneOrNone('SELECT status,evidence FROM "TrialEligibility" WHERE company=$1 AND "studentId"=$2',[co,uid]);
-  return{trialStatus:trial?.status||'pending',accounts,transactions:transactions.filter(t=>store||!t.operationId||t.account!=='UserPoint'||!transactions.some(o=>o.operationId===t.operationId&&o.account!=='UserPoint')),storeId:store?uid:number(legacy.ParentUserID),storeName:store?displayName(target.toJSON()):parent?displayName(parent):'',canPost:store?context.isSuperAdmin:roleOf(context)==='ops-manager'};
+  return{trialStatus:trial?.status||'pending',accounts,transactions:transactions.filter(t=>(!context.storeId||number(t.storeId)===context.storeId)&&(store||!t.operationId||t.account!=='UserPoint'||!transactions.some(o=>o.operationId===t.operationId&&o.account!=='UserPoint'))),storeId:store?uid:number(legacy.ParentUserID),storeName:store?displayName(target.toJSON()):parent?displayName(parent):'',canPost:store?context.isSuperAdmin:roleOf(context)==='ops-manager'};
 }
 async function postCredits(context,input,payload,store=false) {
   if(store)return storeCreditPost(context,input,payload);
   requireRole(context,['ops-manager']);
   const target=store?await scopedStoreObject(context,input):await memberTarget(context,input,text(input.targetId));
   const account=text(payload.account)||({1:'Purse',2:'SilverCoin',3:'UserExp',4:'UserPoint'})[number(payload.type)],kind=store?'fund':payload.direction==='deduct'?'refund':'transfer';
-  if(!store){const result=await enrollmentSql('SELECT xs_credit_operation($1,$2,$3,$4::jsonb,$5,$6,$7) AS result',[pointerId(target.get('company')),context.current.id,target.id,JSON.stringify({...payload,account}),reasonOf(input),text(input.idempotencyKey),text(input.expectedUpdatedAt)]);return result.result;}
+  if(!store){const result=await enrollmentSql('SELECT xs_credit_operation($1,$2,$3,$4::jsonb,$5,$6,$7) AS result',[pointerId(target.get('company')),context.current.id,target.id,JSON.stringify({...payload,account}),reasonOf(input),text(input.idempotencyKey),text(input.expectedUpdatedAt)],{context,studentId:number(target.get('legacyUserId'))});return result.result;}
   const result=await enrollmentSql('SELECT xs_credit_post($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) AS result',[pointerId(target.get('company')),context.current.id,kind,target.id,account,number(payload.amount),text(payload.sourceTransactionId),reasonOf(input),text(payload.payMethod),text(input.idempotencyKey)]);
   const clean={...result.result};delete clean.request;return clean;
 }
@@ -52,10 +58,10 @@ async function saveEnrollment(context,input,payload) {
   if(!options.eligible)fail(409,options.reason);
   const selected=ids.map(id=>options.items.find(row=>row.courseId===id));if(selected.some(row=>!row||!row.eligible))fail(409,'课程内容为空、已停用或与分类学阶不符');
   const categoryName=options.categories.find(row=>row.key===category).name;
-  return(await enrollmentSql('SELECT xs_enroll($1,$2,$3,$4,$5::jsonb,$6::jsonb,$7,$8) AS result',[companyIdOf(context,input),context.current.id,uid,category,JSON.stringify(selected.map(row=>({...row,categoryName}))),JSON.stringify(options.rules.map(rule=>rule.creditCosts)),reason,text(input.idempotencyKey)])).result;
+  return(await enrollmentSql('SELECT xs_enroll($1,$2,$3,$4,$5::jsonb,$6::jsonb,$7,$8) AS result',[companyIdOf(context,input),context.current.id,uid,category,JSON.stringify(selected.map(row=>({...row,categoryName}))),JSON.stringify(options.rules.map(rule=>rule.creditCosts)),reason,text(input.idempotencyKey)],{context,studentId:uid})).result;
 }
 async function enrollmentBindings(context,input,uid=0) {
-  const rows=await Psql.query('SELECT b.*,c."objectId" AS "targetId",c."status",n."nodeName" AS "courseName" FROM "CourseBinding" b JOIN "CommonModel" c ON c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" JOIN "Node" n ON n."company"=b."company" AND n."nodeId"::text=b."kcid"::text WHERE b."company"=$1 AND ($2::numeric=0 OR b."yhid"::text=$2::text) AND c."status"=99 AND b."enrollmentStatus"=\'active\' AND COALESCE(n."zstatus",99)=99',[companyIdOf(context,input),uid]);
+  const rows=await Psql.query('SELECT b.*,c."objectId" AS "targetId",c."status",n."nodeName" AS "courseName" FROM "CourseBinding" b JOIN "CommonModel" c ON c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" JOIN "Node" n ON n."company"=b."company" AND n."nodeId"::text=b."kcid"::text WHERE b."company"=$1 AND ($2::numeric=0 OR b."yhid"::text=$2::text) AND c."status"=99 AND b."enrollmentStatus"=\'active\' AND COALESCE(n."zstatus",99)=99 AND ($3::text=\'\' OR EXISTS(SELECT 1 FROM \"_User\" s WHERE s.company=b.company AND s.\"legacyUserId\"::text=b.yhid::text AND s.\"identityType\"=\'member\' AND s.\"legacyUserData\"->>\'ParentUserID\'=$3 AND COALESCE(s.\"isDeleted\",false)=false))',[companyIdOf(context,input),uid,context.storeId?String(context.storeId):'']);
   return rows.map(row=>({bindingId:text(row.objectId),targetId:text(row.targetId),studentId:number(row.yhid),courseId:number(row.kcid),courseName:text(row.courseName),categoryKey:text(row.categoryKey),categoryName:TEACHING_CATEGORY_LABELS.get(text(row.categoryKey))||text(row.categoryName),learnedCount:number(row.yxx),wordCount:number(row.cksl)}));
 }
 async function enrollmentTeachingContext(context,input,payload,date) {
@@ -75,7 +81,7 @@ async function toggleEnrollment(context,input,restore) {
   if(number(target.get('modelId'))!==58)fail(400,'目标不是开课记录');
   const b=await findAddon('CourseBinding',target.get('company'),target.get('itemId'));if(!b)fail(404,'开课记录不存在');
   let costs=[];if(restore){const opt=await enrollmentOptions(context,{...input,studentId:b.get('yhid'),categoryKey:b.get('categoryKey')});if(!opt.items.some(row=>row.courseId===number(b.get('kcid'))&&row.eligible))fail(409,opt.reason||'课程内容不可用');costs=opt.rules.map(rule=>rule.creditCosts);}
-  return(await enrollmentSql('SELECT xs_enrollment_status($1,$2,$3,$4,$5,$6::jsonb) AS result',[co,b.id,restore,context.current.id,reason,JSON.stringify(costs)])).result;
+  return(await enrollmentSql('SELECT xs_enrollment_status($1,$2,$3,$4,$5,$6::jsonb) AS result',[co,b.id,restore,context.current.id,reason,JSON.stringify(costs)],{context,studentId:number(b.get('yhid')),targetId:target.id})).result;
 }
 async function saveEnrollmentSchedule(context,input,payload,editing=false) {
  requireRole(context,['ops-manager','ops-staff']);const reason=reasonOf(input),co=companyIdOf(context,input),idem=text(input.idempotencyKey),editId=editing?text(input.targetId):'',req={actor:context.current.id,operation:editing?'update':'create',targetId:editId,payload,reason};
@@ -86,10 +92,10 @@ async function saveEnrollmentSchedule(context,input,payload,editing=false) {
  if(check.conflictCount&&!skip)fail(409,'排课存在时间冲突');if(!selected.length)fail(409,'没有可保存的排课');
  const coach=number(payload.coachId)?await userByLegacyId(context,input,payload.coachId,3):null,student=await userByLegacyId(context,input,payload.studentId,1);
  const entries=selected.map(item=>{const start=new Date(item.startsAt),date=businessDate(start),time=start.toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false,hour:'2-digit',minute:'2-digit'});return{common:{title:displayName(student)+'('+check.courseCategoryName+')',subtitle:check.courseName},addon:{szyh:String(payload.studentId),pl:coach?String(payload.coachId):'',fxpl:coach?String(payload.coachId):'',plxm:coach?displayName(coach):'',kcid:String(payload.courseId),yykcid:String(payload.courseId),dslx:String(legacyClassTypeFor(check.courseCategoryKey,check.durationMinutes)),jffs:check.deliveryLabel,bindingId:check.bindingId,courseCategoryKey:check.courseCategoryKey,courseCategoryName:check.courseCategoryName,durationMinutes:check.durationMinutes,deliveryMode:check.deliveryMode,teachingRuleId:item.teachingRule.objectId,teacherPaySnapshot:item.teachingRule.teacherPay,creditCostsSnapshot:item.teachingRule.creditCosts,bxrq:date,sdsd:time,yysj:date+' '+time,dszt:'0'}};});
- const result=(await enrollmentSql('SELECT xs_schedule_save($1,$2,$3::jsonb,$4::jsonb,$5,$6,$7) AS result',[co,context.current.id,JSON.stringify(req),JSON.stringify(entries),idem,editId,text(input.expectedUpdatedAt)])).result;return{...result,skipped:check.candidates.length-selected.length,warnings:check.warnings};
+ const result=(await enrollmentSql('SELECT xs_schedule_save($1,$2,$3::jsonb,$4::jsonb,$5,$6,$7) AS result',[co,context.current.id,JSON.stringify(req),JSON.stringify(entries),idem,editId,text(input.expectedUpdatedAt)],{context,studentId:number(payload.studentId),coachId:number(payload.coachId),targetId:editId})).result;return{...result,skipped:check.candidates.length-selected.length,warnings:check.warnings};
 }
 async function cancelEnrollmentSchedule(context,input,restore){
  requireRole(context,['ops-manager','ops-staff']);const reason=reasonOf(input),pair=await appointmentPair(context,input,text(input.targetId));assertVersion(pair.common,input.expectedUpdatedAt);
 
- return(await enrollmentSql('SELECT xs_schedule_cancel($1,$2,$3,$4,$5,$6) AS result',[pointerId(pair.common.get('company')),pair.common.id,restore,context.current.id,reason,text(input.expectedUpdatedAt)])).result;
+ return(await enrollmentSql('SELECT xs_schedule_cancel($1,$2,$3,$4,$5,$6) AS result',[pointerId(pair.common.get('company')),pair.common.id,restore,context.current.id,reason,text(input.expectedUpdatedAt)],{context,studentId:number(pair.addon.get('szyh')),targetId:pair.common.id})).result;
 }

+ 78 - 0
scripts/cloud/store-scope.js

@@ -0,0 +1,78 @@
+// Store requests use an explicit capability list; new headquarters endpoints
+// stay closed until their ownership rules have been reviewed.
+const STORE_OPERATIONS = new Set([
+ 'ops/store/home','ops/live/versions','ops/users/list','ops/users/detail','ops/users/create',
+ 'ops/users/update-profile','ops/users/reset-password','ops/users/save-note','ops/users/notes',
+ 'ops/users/audit','ops/users/export','ops/users/reveal-mobile','ops/users/change-status',
+ 'ops/users/credit-accounts','ops/users/credit-preview','ops/users/adjust-balance','ops/users/credit-ledger',
+ 'ops/stores/credit-accounts','ops/stores/credit-ledger',
+ 'ops/course-bindings/list','ops/course-bindings/options','ops/course-bindings/save',
+ 'ops/course-bindings/recycle','ops/course-bindings/restore','ops/appointments/list',
+ 'ops/schedule/calendar','ops/appointments/preflight','ops/appointments/create',
+ 'ops/appointments/update','ops/appointments/cancel','ops/appointments/restore'
+]);
+function storeOwns(context,row) {
+ return !context.storeId || (pointerId(row.company)||row.company)===pointerId(context.company) &&
+   number(legacyOf(row).ParentUserID)===context.storeId && row.identityType==='member' && row.isDeleted!==true;
+}
+async function storeAssertMember(context,input,id,objectId=false) {
+ if(!context.storeId)return;
+ const row=await Psql.oneOrNone('SELECT "objectId",company,"identityType","isDeleted","legacyUserData" FROM "_User" WHERE company=$1 AND '+(objectId?'"objectId"=$2':'"legacyUserId"::text=$2'),[pointerId(context.company),text(id)]);
+ if(!row||!storeOwns(context,row))fail(404,'学生不存在或不属于本店');
+}
+async function storeAssertObject(context,input,className,target) {
+ if(!context.storeId)return;
+ if(className==='_User')return storeAssertMember(context,input,target.id,true);
+ if(className!=='CommonModel')fail(403,'门店无权访问此类记录');
+ const map={54:['CourseAppointment','szyh'],58:['CourseBinding','yhid']},entry=map[number(target.get('modelId'))];
+ if(!entry)fail(403,'门店无权操作此类记录');
+ const row=await Psql.oneOrNone('SELECT "'+entry[1]+'" AS uid FROM "'+entry[0]+'" WHERE company=$1 AND id::text=$2::text',[pointerId(context.company),target.get('itemId')]);
+ if(!row)fail(404,'记录不存在');await storeAssertMember(context,input,row.uid);
+}
+async function prepareStoreRequest(context,input,payload) {
+ if(!STORE_OPERATIONS.has(text(input.operation)))fail(403,'门店账号无权访问此功能');
+ if(input.companyId&&text(input.companyId)!==pointerId(context.company))fail(403,'不能切换其他帐套');
+ // Private scope values are overwritten, never trusted from browser filters.
+ input.companyId=pointerId(context.company);input._storeId=context.storeId;
+ input.filters={...(input.filters||{}),parentUserId:context.storeId};input.source='database';
+ if(['ops/stores/credit-accounts','ops/stores/credit-ledger'].includes(input.operation)){
+  if(text(input.objectId||input.targetId)!==context.current.id)fail(404,'门店不存在');
+ }
+ if(input.operation==='ops/users/create'||input.operation==='ops/users/update-profile'){
+  if(number(payload.parentUserId)&&number(payload.parentUserId)!==context.storeId)fail(403,'不能将学生归属其他门店');
+  payload.parentUserId=context.storeId;
+ }
+ if(input.operation==='ops/users/credit-preview'||input.operation==='ops/users/adjust-balance'){
+  if(!['add','deduct'].includes(text(payload.direction)||'add'))fail(403,'门店只能划拨或按原单退回额度');
+  if(payload.sourceTransactionId){const row=await Psql.oneOrNone('SELECT "storeId" FROM "CreditTransaction" WHERE company=$1 AND "objectId"=$2',[pointerId(context.company),text(payload.sourceTransactionId)]);if(!row||number(row.storeId)!==context.storeId)fail(404,'原单不属于本店');}
+ }
+ for(const id of [input.studentId,payload.studentId])if(id!==undefined&&id!==null&&id!=='')await storeAssertMember(context,input,id);
+ // Check target before idempotent replay as well as before any mutation.
+ if(input.operation.startsWith('ops/users/')&&text(input.objectId||input.targetId))await storeAssertMember(context,input,input.objectId||input.targetId,true);
+ if(['ops/appointments/preflight','ops/appointments/update','ops/appointments/cancel','ops/appointments/restore','ops/course-bindings/recycle','ops/course-bindings/restore'].includes(input.operation)&&input.targetId)await scopedObject('CommonModel',text(input.targetId),context,input);
+ if(number(payload.coachId)&&!await userByLegacyId(context,input,payload.coachId,3))fail(404,'陪练老师不存在或不属于本店');
+}
+function storePublicSchedule(data) {
+ return {...data,appointments:data.appointments.map(({teacherPaySnapshot,...row})=>row),courses:data.courses||[],workflowIssues:[]};
+}
+async function storeSchedule(context,input) {
+ const from=text(input.dateFrom)||businessDate(),to=text(input.dateTo)||from;
+ const [members,appointments,coachRows,summary]=await Promise.all([
+  queryUsers(context,input),appointmentRowsInRange(context,input,from,to),
+  Psql.query('SELECT "objectId","legacyUserId","realName",nickname,username FROM "_User" WHERE company=$1 AND "identityType"=\'coach\' AND "legacyUserData"->>\'ParentUserID\'=$2 AND COALESCE("isDisabled",false)=false AND COALESCE("isDeleted",false)=false ORDER BY "legacyUserId"',[pointerId(context.company),String(context.storeId)]),
+  Psql.one('SELECT count(*)::int total,min(left(a.yysj,10)) first,max(left(a.yysj,10)) last FROM "CourseAppointment" a JOIN "_User" s ON s.company=a.company AND s."legacyUserId"::text=a.szyh::text WHERE a.company=$1 AND s."identityType"=\'member\' AND s."legacyUserData"->>\'ParentUserID\'=$2 AND COALESCE(s."isDeleted",false)=false',[pointerId(context.company),String(context.storeId)])
+ ]);
+ const selected=appointments.filter(row=>!number(input.filters?.coachId)||row.coachId===number(input.filters.coachId));
+ const coaches=coachRows.map(row=>({objectId:row.objectId,userId:number(row.legacyUserId),displayName:displayName(row),mobile:'',mobileMasked:'',status:'active',statusLabel:'正常',todayAppointments:selected.filter(a=>a.coachId===number(row.legacyUserId)&&!a.cancelled).length,availabilityConfigured:false}));
+ return storePublicSchedule(await enrollmentSchedule(context,input,{dateFrom:from,dateTo:to,members,coaches,appointments:selected,unassigned:selected.filter(a=>!a.coachId&&!a.cancelled),totalAppointments:summary.total,dataDateFrom:text(summary.first),dataDateTo:text(summary.last),refreshedAt:new Date().toISOString(),source:'database',liveSourceAvailable:true,liveSourceMessage:'本店排课',sync:{state:'synced',label:'本店数据'}}));
+}
+async function storeHome(context,input) {
+ const members=await queryUsers(context,input),store=context.current;
+ return {storeId:context.storeId,storeObjectId:store.id,storeName:storeNameOf(store.toJSON()),summary:memberSummary(members),refreshedAt:new Date().toISOString()};
+}
+function storeSafeRules(context,value) {
+ if(!context.storeId)return value;
+ // Retain teaching costs required for enrollment, omit headquarters pay data.
+ const clean=item=>{if(Array.isArray(item))return item.map(clean);if(!item||typeof item!=='object')return item;return Object.fromEntries(Object.entries(item).filter(([key])=>!['teacherPay','teacherPaySnapshot','operatorId'].includes(key)).map(([key,v])=>[key,clean(v)]));};
+ const result=clean(value);if(result.conflicts)result.conflicts=result.conflicts.map(({targetId,...row})=>row);return result;
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 22 - 19
scripts/deploy-admin-functions.mjs


+ 12 - 0
scripts/migrate-store-scope.mjs

@@ -0,0 +1,12 @@
+#!/usr/bin/env node
+import {writeFileSync,mkdirSync} from 'node:fs';
+import {resolve} from 'node:path';
+import {STORE_PRIVATE_CLASSES,privateClassPermissions} from './store-access-policy.mjs';
+const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',key=process.env.XIAOSHU_MASTER_KEY;
+if(!key)throw Error('缺少 XIAOSHU_MASTER_KEY');
+async function request(path,method='GET',body){const r=await fetch(base+path,{method,headers:{'X-Parse-Application-Id':app,'X-Parse-Master-Key':key,'Content-Type':'application/json'},...(body?{body:JSON.stringify(body)}:{})});const value=await r.json();if(!r.ok)throw Error(`${path}: ${value.error||r.status}`);return value;}
+const schemas=(await request('/schemas')).results,items=STORE_PRIVATE_CLASSES.map(name=>schemas.find(s=>s.className===name)).filter(Boolean),out=resolve(process.env.XIAOSHU_BACKUP_DIR||'.local/store-scope-backup');
+mkdirSync(out,{recursive:true,mode:0o700});const filename=resolve(out,`permissions-${Date.now()}.json`);writeFileSync(filename,JSON.stringify(items.map(s=>({className:s.className,classLevelPermissions:s.classLevelPermissions})),null,2),{mode:0o600});
+console.log(`已备份 ${items.length} 个表的权限:${filename}`);
+if(process.argv.includes('--apply'))for(const schema of items){await request('/schemas/'+schema.className,'PUT',{classLevelPermissions:privateClassPermissions(schema.classLevelPermissions)});console.log('已限制直接访问:'+schema.className);}
+else console.log('只读检查完成;--apply 才会应用权限。数据库函数请通过受保护迁移通道安装 scripts/sql/store-scope.sql。');

+ 17 - 0
scripts/sql/store-scope.sql

@@ -0,0 +1,17 @@
+-- Recheck scope under the same transaction/locks as the existing credit and
+-- scheduling write; a concurrent headquarters ownership change cannot slip in.
+CREATE OR REPLACE FUNCTION xs_store_scope_guard(co text,actor text,uid numeric,coach numeric DEFAULT 0,target text DEFAULT '') RETURNS void LANGUAGE plpgsql AS $$
+DECLARE st "_User"; student "_User"; owner_uid numeric;
+BEGIN
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ SELECT * INTO st FROM "_User" WHERE "objectId"=actor AND company=co FOR SHARE;
+ IF NOT FOUND OR COALESCE(st."isDisabled",false) OR COALESCE((to_jsonb(st)->>'isDeleted')::boolean,false)
+ OR NOT (st."identityType"='store' OR (st."identityType" IS NULL AND st."legacyGroupId"=2)) OR COALESCE(st."legacyUserId",0)<=0 THEN RAISE EXCEPTION '门店账号无效或已停用';END IF;
+ SELECT * INTO student FROM "_User" WHERE company=co AND "legacyUserId"=uid FOR UPDATE;
+ IF NOT FOUND OR student."identityType" IS DISTINCT FROM 'member' OR COALESCE((to_jsonb(student)->>'isDeleted')::boolean,false) OR (student."legacyUserData"->>'ParentUserID') IS DISTINCT FROM st."legacyUserId"::text THEN RAISE EXCEPTION '学生不存在或不属于本店';END IF;
+ IF coach>0 AND NOT EXISTS(SELECT 1 FROM "_User" u WHERE u.company=co AND u."legacyUserId"=coach AND u."identityType"='coach' AND u."legacyUserData"->>'ParentUserID'=st."legacyUserId"::text AND COALESCE(u."isDisabled",false)=false AND COALESCE((to_jsonb(u)->>'isDeleted')::boolean,false)=false) THEN RAISE EXCEPTION '陪练老师不属于本店或已停用';END IF;
+ IF COALESCE(target,'')<>'' THEN
+  SELECT CASE c."modelId"::text WHEN '54' THEN a.szyh::numeric WHEN '58' THEN b.yhid::numeric END INTO owner_uid FROM "CommonModel" c LEFT JOIN "CourseAppointment" a ON a.company=c.company AND c."modelId"::text='54' AND a.id::text=c."itemId"::text LEFT JOIN "CourseBinding" b ON b.company=c.company AND c."modelId"::text='58' AND b.id::text=c."itemId"::text WHERE c.company=co AND c."objectId"=target;
+  IF owner_uid IS DISTINCT FROM uid THEN RAISE EXCEPTION '原记录不属于此学生';END IF;
+ END IF;
+END $$;

+ 12 - 0
scripts/store-access-policy.mjs

@@ -0,0 +1,12 @@
+// Teaching and account records are served through authenticated, scoped cloud
+// functions. Public REST access would bypass the store gateway entirely.
+export const STORE_PRIVATE_CLASSES = [
+ '_User','_Session','Role','Manager','PlatUserRole','CommonModel','CourseBinding','CourseAppointment',
+ 'LessonRecord','DailyStudyRecord','PracticeRecord','CoachAvailability',
+ 'MemoryPracticeRecord','UnitLearningRecord','SurveyLog','UserBankAccount','UserCredit','UserDummyPoint','UserFavorite','UserCoupon','MemberNote','SysLog','UserExpDomP','UserSIcon','UserExpHis','UserUserPoint',
+ 'CourseCreditReservation','CreditTransaction','LessonCompletionReport','LessonReview',
+ 'ReviewTaskReward','ReviewRewardAdjustment','PayrollBatch','PayrollLine','PayrollAdjustment'
+];
+export function privateClassPermissions(previous={}) {
+ return {...previous,find:{},get:{},count:{},create:{},update:{},delete:{},addField:{}};
+}

+ 3 - 3
scripts/tests/member-enrollment.test.mjs

@@ -9,7 +9,7 @@ const post=async(kind,target,amount,idem,account='Purse',source='')=>(await one(
 const balance=async(id,account='Purse')=>Number((await one('SELECT "legacyUserData"->>$2 AS b FROM "_User" WHERE "objectId"=$1',[id,account])).b||0);
 const enroll=async(category,idem,uid=20,course=100,costs=[[{account:'Purse',amount:1}]])=>(await one('SELECT xs_enroll(\'co\',\'admin\',$1,$2,$3::jsonb,$4::jsonb,\'测试开课\',$5) AS result',[uid,category,JSON.stringify([{courseId:course,courseName:'词表',wordCount:200,categoryName:category}]),JSON.stringify(costs),idem])).result;
 before(async()=>{
- await db.exec(`CREATE TABLE "_User" ("objectId" text PRIMARY KEY,"company" text,"legacyUserId" double precision,"legacyGroupId" double precision,"identityType" text,"isDisabled" boolean,"legacyUserData" jsonb,"updatedAt" timestamptz);
+ await db.exec(`CREATE TABLE "_User" ("objectId" text PRIMARY KEY,"company" text,"legacyUserId" double precision,"legacyGroupId" double precision,"identityType" text,"isDisabled" boolean,"isDeleted" boolean,"legacyUserData" jsonb,"updatedAt" timestamptz);
  CREATE TABLE "CourseBinding" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text,"yhid" text,"kcid" text,"yxx" double precision,"cksl" double precision,"syjd" text);
  CREATE TABLE "CommonModel" ("objectId" text PRIMARY KEY,"company" text,"createdAt" timestamptz,"updatedAt" timestamptz,"createTime" timestamptz,"sourceKey" text,"generalId" double precision,"itemId" double precision,"modelId" double precision,"nodeId" double precision,"tableName" text,"title" text,"subtitle" text,"status" double precision,"inputer" text);
  CREATE TABLE "CourseAppointment" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"szyh" text,"kcid" text,"pl" text,"fxpl" text,"dszt" text,"dslx" text,"jffs" text,"jssj" text,"updatedAt" timestamptz,"creditCostsSnapshot" jsonb,"creditHoldStatus" text,"courseCategoryKey" text,"courseCategoryName" text,"teacherPaySnapshot" double precision);
@@ -18,7 +18,7 @@ before(async()=>{
  CREATE TABLE "CourseTeachingProfile" ("company" text,"courseId" double precision,"categoryKey" text,"confirmed" boolean);
  CREATE TABLE "CourseCreditReservation" ("objectId" text PRIMARY KEY,"company" text,"studentId" double precision,"appointmentId" text,"creditCosts" jsonb,"state" text,"consumedAt" timestamptz,"updatedAt" timestamptz);`);
  for(const table of ['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'])await db.exec(`CREATE TABLE "${table}" ("objectId" text PRIMARY KEY,"company" text,"sourceKey" text UNIQUE,"createdAt" timestamptz,"updatedAt" timestamptz,"userId" double precision,"score" double precision,"scoreBefore" double precision,"hisTime" timestamptz,"operator" double precision,"scoreType" double precision,"detail" text,"remark" text,"payMethod" text)`);
- await db.exec(`INSERT INTO "_User" VALUES ('store','co',10,2,'store',false,'{}',now()),('student','co',20,1,'member',false,'{"ParentUserID":10}',now()),('empty','co',21,1,'member',false,'{}',now()),('foreign','other',22,1,'member',false,'{}',now());`);
+ await db.exec(`INSERT INTO "_User" VALUES ('store','co',10,2,'store',false,false,'{}',now()),('student','co',20,1,'member',false,false,'{"ParentUserID":10}',now()),('empty','co',21,1,'member',false,false,'{}',now()),('foreign','other',22,1,'member',false,false,'{}',now());`);
  await db.exec(`ALTER TABLE "CourseAppointment" ADD COLUMN "kssj" text,ADD COLUMN "plxm" text,ADD COLUMN "yykcid" text,ADD COLUMN "durationMinutes" numeric,ADD COLUMN "deliveryMode" text,ADD COLUMN "teachingRuleId" text,ADD COLUMN "bxrq" text,ADD COLUMN "sdsd" text,ADD COLUMN "yysj" text,ADD COLUMN "sourceKey" text,ADD COLUMN "createdAt" timestamptz;
  ALTER TABLE "CourseCreditReservation" ADD COLUMN "sourceKey" text,ADD COLUMN "createdAt" timestamptz,ADD COLUMN "appointmentGeneralId" numeric,ADD COLUMN "ruleId" text,ADD COLUMN "reservedAt" timestamptz;`);
  await db.exec(`CREATE TABLE "Node" ("company" text,"nodeId" numeric,"parentId" numeric,"nodeName" text,"zstatus" numeric);
@@ -119,7 +119,7 @@ test('运营网关开课排课、开课授权与管理员审计纠错共用课
  const {default:vm}=await import('node:vm');const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
  const c={Date,Psql:{query,one,oneOrNone:one,none:query}};vm.createContext(c);vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/member-enrollment.js',import.meta.url),'utf8'),c);
  c.companyIdOf=()=> 'co';c.requireRole=()=>{};c.teachingCategoryRows=async()=>vm.runInContext('TEACHING_CATEGORY_DEFAULTS',c);c.teachingRuleRows=async()=>c.defaultTeachingRules();c.allAppointmentRows=async()=>[];c.availabilityRows=async()=>[];
- await query('INSERT INTO "_User" VALUES (\'teacher\',\'co\',30,3,\'coach\',false,\'{}\',now())');
+ await query('INSERT INTO "_User" VALUES (\'teacher\',\'co\',30,3,\'coach\',false,false,\'{}\',now())');
  await post('fund','store',1,'integration-fund','UserExp');await post('transfer','student',1,'integration-transfer','UserExp');
  const context={current:{id:'admin'}},result=await c.saveEnrollment(context,{reason:'联调开课',idempotencyKey:'integration-enroll'},{studentId:20,categoryKey:'trial',courseIds:[100]});
  assert.equal(result.count,1);const bid=result.bindingIds[0];

+ 89 - 0
scripts/tests/store-scope.test.mjs

@@ -0,0 +1,89 @@
+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 read=p=>readFileSync(new URL(p,import.meta.url),'utf8');
+const source=read('../deploy-admin-functions.mjs');
+const ops=source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
+const policy=read('../cloud/admin-role-policy.js'),scope=read('../cloud/store-scope.js');
+const plain=v=>JSON.parse(JSON.stringify(v));
+function env(){const c={Date,console};vm.createContext(c);vm.runInContext(ops+'\n'+policy+'\n'+scope+'\n'+read('../cloud/member-enrollment.js'),c);return c;}
+function user(id,attrs){return{id,get:k=>attrs[k],fetch:async()=>{},toJSON:()=>({objectId:id,...attrs})};}
+const company={id:'co'},account=user('store-a',{company,identityType:'store',legacyUserId:10,legacyGroupId:2});
+const ctx={current:account,company,storeId:10,isSuperAdmin:false,roles:['store-admin']};
+const member={objectId:'member-a',company:'co',identityType:'member',legacyUserData:{ParentUserID:10}};
+test('门店身份独立,伪造帐套或总部角色不能拓宽权限',async()=>{
+ const c=env();const a=await c.auth({user:account,company:{id:'other'}});assert.equal(a.storeId,10);assert.equal(a.company.id,'co');assert.equal(a.isSuperAdmin,false);
+ const mixed=user('mixed',{company,identityType:'store',legacyUserId:10,roles:['super-admin'],isAdmin:true});assert.equal((await c.auth({user:mixed})).isSuperAdmin,false);
+ assert.equal(c.storeAccountSnapshot(user('member',{identityType:'member',legacyGroupId:2,legacyUserId:20})),0);
+ await assert.rejects(c.auth({user:user('disabled',{company,identityType:'store',legacyUserId:10,isDisabled:true})}),/停用/);
+ await assert.rejects(c.auth({user:user('orphan',{identityType:'store',legacyUserId:10})}),/帐套/);
+});
+test('门店接口默认拒绝总部、工资、通用数据、同步与未经审核的新接口',async()=>{
+ const c=env();for(const operation of ['ops/stores/credit-post','ops/stores/credit-grants/save','ops/stores/update','ops/payroll/list','ops/coaches/create','ops/users/change-group','ops/relations/save','ops/users/import-commit','ops/sync/run','ops/new/endpoint','ops/appointments/complete'])await assert.rejects(c.prepareStoreRequest(ctx,{operation},{}),/无权/);
+});
+test('新增学生强制本店归属,拒绝伪造跨店、跨帐套及直接差错增额',async()=>{
+ const c=env(),input={operation:'ops/users/create',_storeId:999,filters:{parentUserId:99}},p={parentUserId:0};await c.prepareStoreRequest(ctx,input,p);assert.equal(p.parentUserId,10);assert.equal(input._storeId,10);assert.equal(input.filters.parentUserId,10);
+ await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/users/create'},{parentUserId:99}),/其他门店/);
+ await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/users/list',companyId:'other'},{}),/帐套/);
+ await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/users/credit-preview'},{direction:'adjust'}),/只能/);
+});
+test('按 objectId 和学生业务编号逐项核验,外店同帐套也不可访问',async()=>{
+ const c=env();c.Psql={oneOrNone:async(_sql,args)=>args[1]==='20'||args[1]==='member-a'?member:{...member,objectId:'member-b',legacyUserData:{ParentUserID:11}}};
+ for(const operation of ['ops/users/detail','ops/users/credit-ledger','ops/users/update-profile','ops/users/reset-password']){
+ await c.prepareStoreRequest(ctx,{operation,objectId:'member-a'},{});await assert.rejects(c.prepareStoreRequest(ctx,{operation,targetId:'member-b'},{}),/不属于本店/);
+ }
+ for(const operation of ['ops/course-bindings/options','ops/course-bindings/save','ops/appointments/create'])await assert.rejects(c.prepareStoreRequest(ctx,{operation,studentId:21},{studentId:20}),/不属于本店/);
+ await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/stores/credit-accounts',objectId:'store-b'},{}),/门店不存在/);
+});
+test('修改预约先核验原预约,再进入幂等结果;不能拿自己的学生覆盖外店预约',async()=>{
+ const c=env(),calls=[];c.storeAssertMember=async()=>{};c.scopedObject=async(_class,id)=>{calls.push(id);throw Error('原预约属于外店');};
+ await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/appointments/update',targetId:'other-appointment',idempotencyKey:'old-key'},{studentId:20}),/外店/);assert.deepEqual(calls,['other-appointment']);
+});
+test('预约和开课对象按关联学生校验,其他模型默认拒绝',async()=>{
+ const c=env();c.Psql={oneOrNone:async()=>({uid:21})};c.storeAssertMember=async(_c,_i,id)=>{assert.equal(id,21);throw Error('外店学生');};
+ for(const modelId of [54,58])await assert.rejects(c.storeAssertObject(ctx,{},'CommonModel',user('c',{modelId,itemId:1})),/外店/);
+ await assert.rejects(c.storeAssertObject(ctx,{},'CommonModel',user('c',{modelId:52})),/无权/);
+});
+test('老师、学生和门店单目标查询不跨店,停用老师不可排课',async()=>{
+ const c=env();let row={legacyUserId:30,identityType:'coach',legacyUserData:{ParentUserID:11}};c.Psql={oneOrNone:async()=>row};assert.equal(await c.userByLegacyId(ctx,{},30,3),null);row.legacyUserData.ParentUserID=10;assert.equal((await c.userByLegacyId(ctx,{},30,3)).legacyUserId,30);row.isDisabled=true;assert.equal(await c.userByLegacyId(ctx,{},30,3),null);row={legacyUserId:11};assert.equal(await c.userByLegacyId(ctx,{},11,2),null);
+});
+test('课酬与跨店冲突原单编号不传给门店,保留计费规则',()=>{
+ const c=env(),result=c.storeSafeRules(ctx,{rules:[{teacherPay:45,creditCosts:[{account:'Purse',amount:1}]}],candidates:[{teachingRule:{teacherPay:45}}],conflicts:[{index:0,targetId:'secret',reason:'陪练时间冲突'}]});assert.equal(result.rules[0].teacherPay,undefined);assert.equal(result.rules[0].creditCosts[0].amount,1);assert.equal(result.conflicts[0].targetId,undefined);
+});
+test('课程列表先按门店过滤再统计分页,不能通过搜索或翻页看到外店',async()=>{
+ const db=new PGlite();try{await db.exec(`CREATE TABLE sample("objectId" text,"targetId" text,"generalId" int,"studentObjectId" text,"studentRealName" text,"studentNickname" text,"studentUsername" text,"courseTitle" text,"studentLegacy" jsonb,"yhid" text,"kcid" text,"categoryKey" text,"enrollmentStatus" text,"syjd" text,"commonUpdatedAt" timestamptz);
+ INSERT INTO sample("objectId","targetId","studentRealName","studentLegacy","commonUpdatedAt") VALUES ('a','a','本店','{"ParentUserID":10}',now()),('b','b','外店','{"ParentUserID":11}',now());`);
+ const c=env();c.Psql={query:async(sql,args)=>(await db.query(sql,args)).rows};const result=await c.businessRecordSqlPage('course-bindings','SELECT * FROM sample',[],{_storeId:10,page:1,pageSize:1});assert.equal(result.total,1);assert.equal(result.items[0].objectId,'a');const other=await c.businessRecordSqlPage('course-bindings','SELECT * FROM sample',[],{_storeId:10,search:'外店'});assert.equal(other.total,0);
+ }finally{await db.close();}
+});
+test('事务内重新验证门店、学生、老师与原单,跨店或停用失败后余额不变',async()=>{
+ const db=new PGlite();try{await db.exec(`CREATE TABLE "_User"("objectId" text PRIMARY KEY,company text,"legacyUserId" double precision,"legacyGroupId" double precision,"identityType" text,"isDisabled" boolean,"isDeleted" boolean,"legacyUserData" jsonb);
+ CREATE TABLE "CommonModel"("objectId" text,company text,"modelId" int,"itemId" int);CREATE TABLE "CourseAppointment"(company text,id int,szyh text);CREATE TABLE "CourseBinding"(company text,id int,yhid text);
+ INSERT INTO "_User" VALUES('store-a','co',10,2,'store',false,false,'{"Purse":10}'),('store-b','co',11,2,'store',false,false,'{}'),('member-a','co',20,1,'member',false,false,'{"ParentUserID":10,"Purse":2}'),('member-b','co',21,1,'member',false,false,'{"ParentUserID":11}'),('teacher-a','co',30,3,'coach',false,false,'{"ParentUserID":10}'),('teacher-b','co',31,3,'coach',false,false,'{"ParentUserID":11}');
+ INSERT INTO "CommonModel" VALUES('appt-a','co',54,1),('appt-b','co',54,2);INSERT INTO "CourseAppointment" VALUES('co',1,'20'),('co',2,'21');`);await db.exec(read('../sql/store-scope.sql'));
+ const guard=(actor,uid,coach=0,target='',co='co')=>db.query('SELECT xs_store_scope_guard($1,$2,$3,$4,$5)',[co,actor,uid,coach,target]);
+ await guard('store-a',20,30,'appt-a');await assert.rejects(guard('store-a',21),/不属于本店/);await assert.rejects(guard('store-a',20,31),/陪练/);await assert.rejects(guard('store-a',20,30,'appt-b'),/原记录/);await assert.rejects(guard('store-a',20,30,'','other'),/账号无效/);
+ await db.exec(`UPDATE "_User" SET "isDisabled"=true WHERE "objectId"='store-a'`);await assert.rejects(guard('store-a',20),/停用/);assert.equal((await db.query(`SELECT "legacyUserData" FROM "_User" WHERE "objectId"='member-a'`)).rows[0].legacyUserData.Purse,2);
+ }finally{await db.close();}
+});
+test('运营写入将权限复核和实际事务放在同一数据库事务内',async()=>{
+ const c=env(),calls=[];c.Psql={tx:async fn=>fn({one:async sql=>{calls.push(sql);if(sql.includes('guard'))throw Error('学生已经转店');return{result:1};}})};await assert.rejects(c.enrollmentSql('SELECT write()',[],{context:ctx,studentId:20}),/转店/);assert.equal(calls.length,1);
+});
+test('直接 REST 权限关闭,保留既有字段保护规则',async()=>{
+ const {STORE_PRIVATE_CLASSES,privateClassPermissions}=await import('../store-access-policy.mjs');
+ for(const name of ['_User','_Session','CommonModel','CourseAppointment','CourseBinding','MemoryPracticeRecord','LessonRecord','CreditTransaction'])assert.ok(STORE_PRIVATE_CLASSES.includes(name));
+ const result=privateClassPermissions({find:{'*':true},update:{requiresAuthentication:true},protectedFields:{'*':['password']}});
+ for(const operation of ['find','get','count','create','update','delete','addField'])assert.deepEqual(result[operation],{});
+ assert.deepEqual(result.protectedFields,{'*':['password']});
+});
+test('门店元数据可以读取,技术后台和旧移动业务入口拒绝门店',async()=>{
+ const admin=source.match(/const adminGatewayCode = String.raw`([\s\S]*?)\n`;/)[1],c={Date,console};vm.createContext(c);vm.runInContext(admin+'\n'+policy,c);
+ const attrs={company,identityType:'store',legacyUserId:10,legacyGroupId:2},a=user('store-a',attrs);let status=200,out;
+ const response={status:n=>{status=n;return response;},json:v=>{out=v;return v;}};
+ c.Parse={Query:class{notEqualTo(){} async count(){throw Error('store must not read functions');}}};
+ await c.handler({user:a,body:{params:{operation:'meta'}}},response);assert.equal(status,200);assert.equal(out.data.identity.operationsRole,'store-admin');assert.equal(out.data.identity.isSuperAdmin,false);
+ await c.handler({user:a,body:{params:{operation:'catalog'}}},response);assert.equal(status,403);
+ const app=source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];assert.ok(app.includes("门店业务请使用门店管理后台"));
+});

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů