Ver Fonte

feat(auth): standardize initial student passwords and member reset rules

彭峰 há 8 horas atrás
pai
commit
e7d5422102

+ 3 - 2
projects/xiaoshu-admin/src/app/components/member-enrollment.component.spec.ts

@@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing';
 import { MemberEnrollmentComponent } from './member-enrollment.component';
 import { MemberEnrollmentComponent } from './member-enrollment.component';
 import { MemberCreditAccountComponent } from './member-credit-account.component';
 import { MemberCreditAccountComponent } from './member-credit-account.component';
 import { OperationsService } from '../operations.service';
 import { OperationsService } from '../operations.service';
+import { AdminSessionService } from '../admin-session.service';
 import { EnrollmentOptions } from '../member-enrollment.models';
 import { EnrollmentOptions } from '../member-enrollment.models';
 
 
 describe('会员开课与课时操作', () => {
 describe('会员开课与课时操作', () => {
@@ -10,7 +11,7 @@ describe('会员开课与课时操作', () => {
  beforeEach(() => {
  beforeEach(() => {
   operations = jasmine.createSpyObj('OperationsService', ['request','mutate']);
   operations = jasmine.createSpyObj('OperationsService', ['request','mutate']);
   operations.request.and.resolveTo(options());operations.mutate.and.resolveTo({});
   operations.request.and.resolveTo(options());operations.mutate.and.resolveTo({});
-  TestBed.configureTestingModule({imports:[MemberEnrollmentComponent,MemberCreditAccountComponent],providers:[{provide:OperationsService,useValue:operations}]});
+  TestBed.configureTestingModule({imports:[MemberEnrollmentComponent,MemberCreditAccountComponent],providers:[{provide:OperationsService,useValue:operations},{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}}]});
  });
  });
  it('切换分类清空待开内容,提交明确分类与内容 ID', async () => {
  it('切换分类清空待开内容,提交明确分类与内容 ID', async () => {
   const fixture=TestBed.createComponent(MemberEnrollmentComponent),c=fixture.componentInstance;c.studentId=20;await c.load();fixture.detectChanges();
   const fixture=TestBed.createComponent(MemberEnrollmentComponent),c=fixture.componentInstance;c.studentId=20;await c.load();fixture.detectChanges();
@@ -46,7 +47,7 @@ describe('会员开课与课时操作', () => {
 
 
 describe('会员充值预览保护',()=>{
 describe('会员充值预览保护',()=>{
  let operations:jasmine.SpyObj<OperationsService>;
  let operations:jasmine.SpyObj<OperationsService>;
- beforeEach(()=>{operations=jasmine.createSpyObj('OperationsService',['request','mutate']);TestBed.configureTestingModule({imports:[MemberCreditAccountComponent],providers:[{provide:OperationsService,useValue:operations}]});});
+ beforeEach(()=>{operations=jasmine.createSpyObj('OperationsService',['request','mutate']);TestBed.configureTestingModule({imports:[MemberCreditAccountComponent],providers:[{provide:OperationsService,useValue:operations},{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}}]});});
  it('必须先预览,预览后修改数量不能提交旧确认',async()=>{const c=TestBed.createComponent(MemberCreditAccountComponent).componentInstance;c.objectId='s';c.amount=40;c.method='运营记账';c.reason='购买课时';await c.submit();expect(operations.mutate).not.toHaveBeenCalled();operations.request.and.resolveTo({version:'server-v1',lines:[{account:'Purse',delta:40},{account:'UserPoint',delta:20}]});await c.check();c.amount=20;await c.submit();expect(operations.mutate).not.toHaveBeenCalled();expect(c.error()).toContain('预览');});
  it('必须先预览,预览后修改数量不能提交旧确认',async()=>{const c=TestBed.createComponent(MemberCreditAccountComponent).componentInstance;c.objectId='s';c.amount=40;c.method='运营记账';c.reason='购买课时';await c.submit();expect(operations.mutate).not.toHaveBeenCalled();operations.request.and.resolveTo({version:'server-v1',lines:[{account:'Purse',delta:40},{account:'UserPoint',delta:20}]});await c.check();c.amount=20;await c.submit();expect(operations.mutate).not.toHaveBeenCalled();expect(c.error()).toContain('预览');});
  it('服务端拒绝预览时保留输入并不执行记账',async()=>{const c=TestBed.createComponent(MemberCreditAccountComponent).componentInstance;c.objectId='s';c.amount=40;c.method='运营记账';c.reason='购买课时';operations.request.and.rejectWith(new Error('门店库存不足'));await c.check();expect(c.amount).toBe(40);expect(c.reason).toBe('购买课时');expect(c.preview()).toBeNull();expect(c.error()).toContain('库存不足');expect(operations.mutate).not.toHaveBeenCalled();});
  it('服务端拒绝预览时保留输入并不执行记账',async()=>{const c=TestBed.createComponent(MemberCreditAccountComponent).componentInstance;c.objectId='s';c.amount=40;c.method='运营记账';c.reason='购买课时';operations.request.and.rejectWith(new Error('门店库存不足'));await c.check();expect(c.amount).toBe(40);expect(c.reason).toBe('购买课时');expect(c.preview()).toBeNull();expect(c.error()).toContain('库存不足');expect(operations.mutate).not.toHaveBeenCalled();});
 });
 });

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.html


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

@@ -27,7 +27,7 @@ export class OperationsUserDetailComponent implements OnInit {
   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('');
   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('');
   readonly refreshVersion = signal(0); readonly recordPage = signal(1); readonly recordTotal = signal(0); readonly pageSize = 20; readonly reportFrom = signal(''); readonly reportTo = signal(''); private sectionSerial = 0;
   readonly refreshVersion = signal(0); readonly recordPage = signal(1); readonly recordTotal = signal(0); readonly pageSize = 20; readonly reportFrom = signal(''); readonly reportTo = signal(''); private sectionSerial = 0;
   readonly pageCount = computed(() => Math.max(1, Math.ceil(this.recordTotal() / this.pageSize)));
   readonly pageCount = computed(() => Math.max(1, Math.ceil(this.recordTotal() / this.pageSize)));
-  readonly resetPassword = signal(''); readonly noteContent = signal(''); readonly revealMobile = signal(false);
+  readonly resetPassword = signal('12345678'); readonly noteContent = signal(''); readonly revealMobile = signal(false);
   readonly icons = { ArrowLeft, BookOpen, CalendarDays, ClipboardList, Clock3, Eye, FileChartColumn, FileClock, GraduationCap, LoaderCircle, MessageSquarePlus, RefreshCw, Save, ShieldAlert, UserRound, WalletCards };
   readonly icons = { ArrowLeft, BookOpen, CalendarDays, ClipboardList, Clock3, Eye, FileChartColumn, FileClock, GraduationCap, LoaderCircle, MessageSquarePlus, RefreshCw, Save, ShieldAlert, UserRound, WalletCards };
   private readonly route = inject(ActivatedRoute); private readonly operations = inject(OperationsService); private readonly objectId = this.route.snapshot.paramMap.get('objectId') || '';
   private readonly route = inject(ActivatedRoute); private readonly operations = inject(OperationsService); private readonly objectId = this.route.snapshot.paramMap.get('objectId') || '';
   readonly filteredAgents = computed(() => { const keyword = this.agentSearch().trim().toLowerCase(); return (this.user()?.agents || []).filter(agent => !keyword || `${agent.displayName} ${agent.userId}`.toLowerCase().includes(keyword)).slice(0, 30); });
   readonly filteredAgents = computed(() => { const keyword = this.agentSearch().trim().toLowerCase(); return (this.user()?.agents || []).filter(agent => !keyword || `${agent.displayName} ${agent.userId}`.toLowerCase().includes(keyword)).slice(0, 30); });
@@ -73,7 +73,7 @@ export class OperationsUserDetailComponent implements OnInit {
   clearAgent(): void { this.parentUserId.set(0); this.agentSearch.set(''); }
   clearAgent(): void { this.parentUserId.set(0); this.agentSearch.set(''); }
   async saveProfile(): Promise<void> { if (await this.run('ops/users/update-profile', { displayName: this.editName(), nickname: this.editNickname(), mobile: this.revealMobile() ? this.editMobile() : '', email: this.editEmail(), gender: this.editGender(), grade: this.editGrade(), parentUserId: this.parentUserId() }, '会员资料已更新')) await this.load(); }
   async saveProfile(): Promise<void> { if (await this.run('ops/users/update-profile', { displayName: this.editName(), nickname: this.editNickname(), mobile: this.revealMobile() ? this.editMobile() : '', email: this.editEmail(), gender: this.editGender(), grade: this.editGrade(), parentUserId: this.parentUserId() }, '会员资料已更新')) await this.load(); }
   async changeStatus(): Promise<void> { const user = this.user(); if (user && await this.run('ops/users/change-status', { disabled: user.status !== 'disabled' }, user.status === 'disabled' ? '账号已恢复' : '账号已停用')) await this.load(); }
   async changeStatus(): Promise<void> { const user = this.user(); if (user && await this.run('ops/users/change-status', { disabled: user.status !== 'disabled' }, user.status === 'disabled' ? '账号已恢复' : '账号已停用')) await this.load(); }
-  async resetMemberPassword(): Promise<void> { if (this.resetPassword().length < 8) { this.error.set('临时密码至少 8 位'); return; } if (await this.run('ops/users/reset-password', { password: this.resetPassword() }, '临时密码已更新,旧会话已退出')) { this.resetPassword.set(''); await this.load(); } }
+  async resetMemberPassword(): Promise<void> { if (this.resetPassword().length < 8) { this.error.set('新密码至少 8 位'); return; } if (await this.run('ops/users/reset-password', { password: this.resetPassword() }, '密码已更新,旧会话已退出')) { this.resetPassword.set('12345678'); await this.load(); } }
   async saveNote(): Promise<void> { if (!this.noteContent().trim()) { this.error.set('请输入跟进备注'); return; } if (await this.run('ops/users/save-note', { content: this.noteContent() }, '跟进备注已保存')) { this.noteContent.set(''); await this.loadSection('audit'); } }
   async saveNote(): Promise<void> { if (!this.noteContent().trim()) { this.error.set('请输入跟进备注'); return; } if (await this.run('ops/users/save-note', { content: this.noteContent() }, '跟进备注已保存')) { this.noteContent.set(''); await this.loadSection('audit'); } }
   async toggleCourse(row: Record<string, unknown>): Promise<void> { const recycled = Number(row['status']) === -2; if (await this.run(recycled ? 'ops/course-bindings/restore' : 'ops/course-bindings/recycle', {}, recycled ? '课程绑定已恢复' : '课程绑定已移入回收站', String(row['targetId'] || ''), String(row['updatedAt'] || ''))) await this.refreshBusiness(); }
   async toggleCourse(row: Record<string, unknown>): Promise<void> { const recycled = Number(row['status']) === -2; if (await this.run(recycled ? 'ops/course-bindings/restore' : 'ops/course-bindings/recycle', {}, recycled ? '课程绑定已恢复' : '课程绑定已移入回收站', String(row['targetId'] || ''), String(row['updatedAt'] || ''))) await this.refreshBusiness(); }
   async reveal(): Promise<void> { const user = this.user(); if (!user) return; this.error.set(''); try { const result = await this.operations.mutate<{ mobile: string }>('ops/users/reveal-mobile', user.objectId, '运营核对会员联系方式'); this.user.set({ ...user, mobile: result.mobile }); this.editMobile.set(result.mobile); this.revealMobile.set(true); } catch (error) { this.error.set(error instanceof Error ? error.message : '手机号查看失败'); } }
   async reveal(): Promise<void> { const user = this.user(); if (!user) return; this.error.set(''); try { const result = await this.operations.mutate<{ mobile: string }>('ops/users/reveal-mobile', user.objectId, '运营核对会员联系方式'); this.user.set({ ...user, mobile: result.mobile }); this.editMobile.set(result.mobile); this.revealMobile.set(true); } catch (error) { this.error.set(error instanceof Error ? error.message : '手机号查看失败'); } }

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
projects/xiaoshu-admin/src/app/pages/operations-users.component.html


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

@@ -22,7 +22,7 @@ export class OperationsUsersComponent implements OnInit, OnDestroy {
   readonly query = signal(''); readonly status = signal('active'); readonly learningStatus = signal('studying'); readonly risk = signal('all');
   readonly query = signal(''); readonly status = signal('active'); readonly learningStatus = signal('studying'); readonly risk = signal('all');
   readonly parentUserId = signal(0); readonly registeredFrom = signal(''); readonly registeredTo = signal(''); readonly sortValue = signal('registeredAt:desc');
   readonly parentUserId = signal(0); readonly registeredFrom = signal(''); readonly registeredTo = signal(''); readonly sortValue = signal('registeredAt:desc');
   readonly loading = signal(true); readonly searching = signal(false); readonly refreshing = signal(false); readonly saving = signal(false); readonly exporting = signal(false); readonly error = signal(''); readonly message = signal(''); readonly lastRefreshedAt = signal('');
   readonly loading = signal(true); readonly searching = signal(false); readonly refreshing = signal(false); readonly saving = signal(false); readonly exporting = signal(false); readonly error = signal(''); readonly message = signal(''); readonly lastRefreshedAt = signal('');
-  readonly createOpen = signal(false); readonly createName = signal(''); readonly createNickname = signal(''); readonly createMobile = signal(''); readonly createGender = signal(''); readonly createGrade = signal(''); readonly createPassword = signal(''); readonly createParentUserId = signal(0); readonly createAgentSearch = signal(''); readonly createReason = signal('新增会员');
+  readonly createOpen = signal(false); readonly createName = signal(''); readonly createNickname = signal(''); readonly createMobile = signal(''); readonly createGender = signal(''); readonly createGrade = signal(''); readonly createParentUserId = signal(0); readonly createAgentSearch = signal(''); readonly createReason = signal('新增会员');
   readonly importOpen = signal(false); readonly importFileName = signal(''); readonly importRows = signal<Record<string, unknown>[]>([]); readonly importPreview = signal<MemberImportPreview | null>(null); readonly importReason = signal('批量导入会员'); readonly importing = signal(false); readonly importIdempotencyKey = signal('');
   readonly importOpen = signal(false); readonly importFileName = signal(''); readonly importRows = signal<Record<string, unknown>[]>([]); readonly importPreview = signal<MemberImportPreview | null>(null); readonly importReason = signal('批量导入会员'); readonly importing = signal(false); readonly importIdempotencyKey = signal('');
   readonly icons = { ChevronLeft, ChevronRight, Download, Eye, FileSpreadsheet, LoaderCircle, Plus, RefreshCw, Search, Upload, UserPlus, Users, X };
   readonly icons = { ChevronLeft, ChevronRight, Download, Eye, FileSpreadsheet, LoaderCircle, Plus, RefreshCw, Search, Upload, UserPlus, Users, X };
   private readonly router = inject(Router); private readonly operations = inject(OperationsService); private readonly sessions = inject(AdminSessionService); private readonly liveRefresh = inject(OperationsLiveRefreshService); private stopLiveRefresh?: () => void;
   private readonly router = inject(Router); private readonly operations = inject(OperationsService); private readonly sessions = inject(AdminSessionService); private readonly liveRefresh = inject(OperationsLiveRefreshService); private stopLiveRefresh?: () => void;
@@ -61,7 +61,7 @@ export class OperationsUsersComponent implements OnInit, OnDestroy {
   pageCount(): number { const value = this.page(); return Math.max(1, Math.ceil((value?.total ?? 0) / (value?.pageSize ?? 20))); }
   pageCount(): number { const value = this.page(); return Math.max(1, Math.ceil((value?.total ?? 0) / (value?.pageSize ?? 20))); }
   selectCreateAgent(agent: MemberAgentOption): void { this.createParentUserId.set(agent.userId); this.createAgentSearch.set(agent.displayName); }
   selectCreateAgent(agent: MemberAgentOption): void { this.createParentUserId.set(agent.userId); this.createAgentSearch.set(agent.displayName); }
   clearCreateAgent(): void { this.createParentUserId.set(0); this.createAgentSearch.set(''); }
   clearCreateAgent(): void { this.createParentUserId.set(0); this.createAgentSearch.set(''); }
-  openCreate(): void { this.createName.set(''); this.createNickname.set(''); this.createMobile.set(''); this.createGender.set(''); this.createGrade.set(''); this.createPassword.set(''); this.clearCreateAgent(); this.createReason.set('新增会员'); this.error.set(''); this.createOpen.set(true); }
+  openCreate(): void { this.createName.set(''); this.createNickname.set(''); this.createMobile.set(''); this.createGender.set(''); this.createGrade.set(''); this.clearCreateAgent(); this.createReason.set('新增会员'); this.error.set(''); this.createOpen.set(true); }
 
 
   openImport(): void {
   openImport(): void {
     this.importFileName.set(''); this.importRows.set([]); this.importPreview.set(null); this.importReason.set('批量导入会员'); this.importIdempotencyKey.set(''); this.error.set(''); this.importOpen.set(true);
     this.importFileName.set(''); this.importRows.set([]); this.importPreview.set(null); this.importReason.set('批量导入会员'); this.importIdempotencyKey.set(''); this.error.set(''); this.importOpen.set(true);
@@ -77,7 +77,7 @@ export class OperationsUsersComponent implements OnInit, OnDestroy {
       const sheet = utils.aoa_to_sheet([['姓名', '昵称', '手机号', '性别', '年级', '邮箱', '门店ID', '所属门店', '临时密码']]);
       const sheet = utils.aoa_to_sheet([['姓名', '昵称', '手机号', '性别', '年级', '邮箱', '门店ID', '所属门店', '临时密码']]);
       sheet['!cols'] = [{ wch: 14 }, { wch: 14 }, { wch: 20 }, { wch: 10 }, { wch: 14 }, { wch: 22 }, { wch: 20 }, { wch: 22 }, { wch: 18 }];
       sheet['!cols'] = [{ wch: 14 }, { wch: 14 }, { wch: 20 }, { wch: 10 }, { wch: 14 }, { wch: 22 }, { wch: 20 }, { wch: 22 }, { wch: 18 }];
       const instructions = utils.aoa_to_sheet([
       const instructions = utils.aoa_to_sheet([
-        ['字段', '填写要求'], ['姓名', '必填,最长 100 字'], ['昵称', '可选,不填时使用姓名'], ['手机号', '必填,11 位;同时作为登录账号'], ['性别', '可选:男、女或其他'], ['年级', '可选,最长 50 字'], ['邮箱', '可选,需为有效邮箱'], ['门店ID / 所属门店', '二选一;名称重复时必须填写门店 ID'], ['临时密码', '必填,8 至 72 位;预检批次与日志均不保存明文'], ['单次上限', '200 人;请勿改动“会员导入”工作表的表头'],
+        ['字段', '填写要求'], ['姓名', '必填,最长 100 字'], ['昵称', '可选,不填时使用姓名'], ['手机号', '必填,11 位;同时作为登录账号'], ['性别', '可选:男、女或其他'], ['年级', '可选,最长 50 字'], ['邮箱', '可选,需为有效邮箱'], ['门店ID / 所属门店', '二选一;名称重复时必须填写门店 ID'], ['临时密码', '统一使用 12345678,无需填写;学生不能自行修改'], ['单次上限', '200 人;请勿改动“会员导入”工作表的表头'],
       ]); instructions['!cols'] = [{ wch: 24 }, { wch: 58 }];
       ]); instructions['!cols'] = [{ wch: 24 }, { wch: 58 }];
       const workbook = utils.book_new(); utils.book_append_sheet(workbook, sheet, '会员导入'); utils.book_append_sheet(workbook, instructions, '填写说明'); writeFile(workbook, '小树陪练会员导入模板.xlsx');
       const workbook = utils.book_new(); utils.book_append_sheet(workbook, sheet, '会员导入'); utils.book_append_sheet(workbook, instructions, '填写说明'); writeFile(workbook, '小树陪练会员导入模板.xlsx');
     } catch (error) {
     } catch (error) {
@@ -119,9 +119,9 @@ export class OperationsUsersComponent implements OnInit, OnDestroy {
 
 
   async createMember(): Promise<void> {
   async createMember(): Promise<void> {
     if (this.saving()) return;
     if (this.saving()) return;
-    if (!this.createName().trim() || !/^1\d{10}$/.test(this.createMobile()) || this.createPassword().length < 8 || this.createReason().trim().length < 2) { this.error.set('请填写姓名、有效手机号、至少 8 位临时密码和操作原因'); return; }
+    if (!this.createName().trim() || !/^1\d{10}$/.test(this.createMobile()) || this.createReason().trim().length < 2) { this.error.set('请填写姓名、有效手机号和操作原因'); return; }
     this.saving.set(true); this.error.set('');
     this.saving.set(true); this.error.set('');
-    try { const created=await this.operations.mutate<{objectId:string}>('ops/users/create', '', this.createReason(), { displayName: this.createName(), nickname: this.createNickname() || this.createName(), mobile: this.createMobile(), username: this.createMobile(), gender: this.createGender(), grade: this.createGrade(), parentUserId: this.createParentUserId(), password: this.createPassword() }); this.createOpen.set(false); this.message.set('会员已新增,请继续充值课时'); await this.router.navigate(['/admin/members',created.objectId],{queryParams:{tab:'balances'}}); }
+    try { const created=await this.operations.mutate<{objectId:string}>('ops/users/create', '', this.createReason(), { displayName: this.createName(), nickname: this.createNickname() || this.createName(), mobile: this.createMobile(), username: this.createMobile(), gender: this.createGender(), grade: this.createGrade(), parentUserId: this.createParentUserId() }); this.createOpen.set(false); this.message.set('会员已新增,请继续充值课时'); await this.router.navigate(['/admin/members',created.objectId],{queryParams:{tab:'balances'}}); }
     catch (error) { this.error.set(error instanceof Error ? error.message : '新增会员失败'); }
     catch (error) { this.error.set(error instanceof Error ? error.message : '新增会员失败'); }
     finally { this.saving.set(false); }
     finally { this.saving.set(false); }
   }
   }

+ 3 - 2
projects/xiaoshu-mobile/src/app/features/auth/login-page.component.html

@@ -17,8 +17,9 @@
       <label class="field-label" for="login-code">短信验证码</label><div class="input-wrap code"><lucide-icon [img]="icons.MessageSquareText" [size]="19" /><input aria-label="短信验证码" id="login-code" name="code" inputmode="numeric" maxlength="6" [(ngModel)]="code" placeholder="6位短信验证码" /><button type="button" (click)="sendCode()" [disabled]="countdown() > 0">{{ countdown() ? countdown() + '秒' : '获取验证码' }}</button></div>
       <label class="field-label" for="login-code">短信验证码</label><div class="input-wrap code"><lucide-icon [img]="icons.MessageSquareText" [size]="19" /><input aria-label="短信验证码" id="login-code" name="code" inputmode="numeric" maxlength="6" [(ngModel)]="code" placeholder="6位短信验证码" /><button type="button" (click)="sendCode()" [disabled]="countdown() > 0">{{ countdown() ? countdown() + '秒' : '获取验证码' }}</button></div>
     } @else {
     } @else {
       <label class="field-label" for="login-account">账号</label><div class="input-wrap"><lucide-icon [img]="icons.Phone" [size]="19" /><input aria-label="账号" id="login-account" name="account" autocomplete="username" [(ngModel)]="account" placeholder="手机号、会员姓名或账号" /></div>
       <label class="field-label" for="login-account">账号</label><div class="input-wrap"><lucide-icon [img]="icons.Phone" [size]="19" /><input aria-label="账号" id="login-account" name="account" autocomplete="username" [(ngModel)]="account" placeholder="手机号、会员姓名或账号" /></div>
-      <label class="field-label" for="login-password">密码</label><div class="input-wrap"><lucide-icon [img]="icons.LockKeyhole" [size]="19" /><input aria-label="密码" id="login-password" name="password" [type]="showPassword() ? 'text' : 'password'" autocomplete="current-password" [(ngModel)]="password" [placeholder]="registering() ? '8至72位密码' : '请输入密码'" /><button class="eye-button" type="button" aria-label="显示或隐藏密码" title="显示或隐藏密码" (click)="showPassword.set(!showPassword())"><lucide-icon [img]="showPassword() ? icons.EyeOff : icons.Eye" [size]="18" /></button></div>
-      @if (registering()) { <label class="field-label" for="login-confirmPassword">确认密码</label><div class="input-wrap"><lucide-icon [img]="icons.LockKeyhole" [size]="19" /><input aria-label="确认密码" id="login-confirmPassword" name="confirmPassword" type="password" autocomplete="new-password" [(ngModel)]="confirmPassword" placeholder="再次输入密码" /></div> }
+      @if (registering()) { <p class="session-notice">初始密码统一为 12345678,密码由管理员管理。</p> } @else {
+      <label class="field-label" for="login-password">密码</label><div class="input-wrap"><lucide-icon [img]="icons.LockKeyhole" [size]="19" /><input aria-label="密码" id="login-password" name="password" [type]="showPassword() ? 'text' : 'password'" autocomplete="current-password" [(ngModel)]="password" placeholder="请输入密码" /><button class="eye-button" type="button" aria-label="显示或隐藏密码" title="显示或隐藏密码" (click)="showPassword.set(!showPassword())"><lucide-icon [img]="showPassword() ? icons.EyeOff : icons.Eye" [size]="18" /></button></div>
+      }
     }
     }
 
 
     @if (registering()) { <label class="field-label" for="login-inviteCode">邀请码(选填)</label><div class="input-wrap"><lucide-icon [img]="icons.UserPlus" [size]="19" /><input aria-label="邀请码" id="login-inviteCode" name="inviteCode" [(ngModel)]="inviteCode" placeholder="邀请码(选填)" /></div> }
     @if (registering()) { <label class="field-label" for="login-inviteCode">邀请码(选填)</label><div class="input-wrap"><lucide-icon [img]="icons.UserPlus" [size]="19" /><input aria-label="邀请码" id="login-inviteCode" name="inviteCode" [(ngModel)]="inviteCode" placeholder="邀请码(选填)" /></div> }

+ 2 - 4
projects/xiaoshu-mobile/src/app/features/auth/login-page.component.ts

@@ -32,7 +32,6 @@ export class LoginPageComponent implements OnInit, OnDestroy {
   code = '';
   code = '';
   account = '';
   account = '';
   password = '';
   password = '';
-  confirmPassword = '';
   inviteCode = '';
   inviteCode = '';
   privacyChecked = false;
   privacyChecked = false;
   private timer: ReturnType<typeof setInterval> | null = null;
   private timer: ReturnType<typeof setInterval> | null = null;
@@ -99,10 +98,9 @@ export class LoginPageComponent implements OnInit, OnDestroy {
 
 
   private submitClassic(): void {
   private submitClassic(): void {
     if (!this.account.trim()) { this.platform.toast('请输入账号', 'error'); return; }
     if (!this.account.trim()) { this.platform.toast('请输入账号', 'error'); return; }
-    if (!this.password || this.password.length > 72 || (this.registering() && this.password.length < 8)) { this.platform.toast(this.registering() ? '新密码长度应为8至72位' : '请输入不超过72位的密码', 'error'); return; }
-    if (this.registering() && this.password !== this.confirmPassword) { this.platform.toast('两次输入的密码不一致', 'error'); return; }
+    if (!this.registering() && (!this.password || this.password.length > 72)) { this.platform.toast('请输入不超过72位的密码', 'error'); return; }
     if (this.registering()) {
     if (this.registering()) {
-      const payload = { name: this.account, passwd: this.password, code: this.inviteCode };
+      const payload = { name: this.account, passwd: '12345678', code: this.inviteCode };
       this.platform.loading.set(true);
       this.platform.loading.set(true);
       this.api.post('user_info_name', { uname: this.account }).subscribe({
       this.api.post('user_info_name', { uname: this.account }).subscribe({
         next: () => { this.platform.loading.set(false); this.platform.toast('此用户名已被使用', 'error'); },
         next: () => { this.platform.loading.set(false); this.platform.toast('此用户名已被使用', 'error'); },

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/auth/password-page.component.spec.ts

@@ -1,2 +1,3 @@
 import {TestBed} from '@angular/core/testing';import {Router} from '@angular/router';import {Subject} from 'rxjs';import {PasswordPageComponent} from './password-page.component';import {ApiService} from '../../core/api.service';import {SessionService} from '../../core/session.service';
 import {TestBed} from '@angular/core/testing';import {Router} from '@angular/router';import {Subject} from 'rxjs';import {PasswordPageComponent} from './password-page.component';import {ApiService} from '../../core/api.service';import {SessionService} from '../../core/session.service';
 describe('Password change',()=>{it('keeps entered values on failure, unlocks retry and clears all credentials after success',()=>{let reply=new Subject();const post=jasmine.createSpy('post').and.callFake(()=>reply.asObservable()),logout=jasmine.createSpy('logout'),navigate=jasmine.createSpy('navigate').and.resolveTo(true);TestBed.configureTestingModule({providers:[{provide:ApiService,useValue:{post}},{provide:SessionService,useValue:{user:()=>({}),logout}},{provide:Router,useValue:{navigate}}]});const page=TestBed.runInInjectionContext(()=>new PasswordPageComponent());page.oldPassword='old-password';page.newPassword=page.confirmation='new-password';page.save();reply.error(new Error('网络暂不可用'));expect(page.saving()).toBeFalse();expect(page.newPassword).toBe('new-password');expect(logout).not.toHaveBeenCalled();reply=new Subject();page.save();reply.next({});reply.complete();expect(logout).toHaveBeenCalledOnceWith(false);expect(page.oldPassword+page.newPassword+page.confirmation).toBe('');expect(navigate).toHaveBeenCalled();});});
 describe('Password change',()=>{it('keeps entered values on failure, unlocks retry and clears all credentials after success',()=>{let reply=new Subject();const post=jasmine.createSpy('post').and.callFake(()=>reply.asObservable()),logout=jasmine.createSpy('logout'),navigate=jasmine.createSpy('navigate').and.resolveTo(true);TestBed.configureTestingModule({providers:[{provide:ApiService,useValue:{post}},{provide:SessionService,useValue:{user:()=>({}),logout}},{provide:Router,useValue:{navigate}}]});const page=TestBed.runInInjectionContext(()=>new PasswordPageComponent());page.oldPassword='old-password';page.newPassword=page.confirmation='new-password';page.save();reply.error(new Error('网络暂不可用'));expect(page.saving()).toBeFalse();expect(page.newPassword).toBe('new-password');expect(logout).not.toHaveBeenCalled();reply=new Subject();page.save();reply.next({});reply.complete();expect(logout).toHaveBeenCalledOnceWith(false);expect(page.oldPassword+page.newPassword+page.confirmation).toBe('');expect(navigate).toHaveBeenCalled();});});
+describe('Managed student passwords',()=>{for(const user of [{groupId:1},{groupId:3,identityType:'member'}])it('prevents a student account from submitting a password change',()=>{const post=jasmine.createSpy('post');TestBed.configureTestingModule({providers:[{provide:ApiService,useValue:{post}},{provide:SessionService,useValue:{user:()=>user}},{provide:Router,useValue:{}}]});const page=TestBed.runInInjectionContext(()=>new PasswordPageComponent());page.oldPassword='12345678';page.newPassword=page.confirmation='new-password';page.save();expect(page.isMember()).toBeTrue();expect(post).not.toHaveBeenCalled();expect(page.error()).toContain('管理员统一管理');});});

+ 8 - 13
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.spec.ts

@@ -14,6 +14,7 @@ describe('CourseCenterPageComponent live appointment refresh', () => {
   let overview: ReturnType<typeof companionOverview>;
   let overview: ReturnType<typeof companionOverview>;
 
 
   beforeEach(() => {
   beforeEach(() => {
+    localStorage.removeItem('xiaoshu.learning.homeDaily.v1');
     overview = companionOverview('第一节伴学');
     overview = companionOverview('第一节伴学');
     post = jasmine.createSpy('post').and.callFake(() => of({ result: overview }));
     post = jasmine.createSpy('post').and.callFake(() => of({ result: overview }));
     watch = jasmine.createSpy('watch').and.returnValue(() => undefined);
     watch = jasmine.createSpy('watch').and.returnValue(() => undefined);
@@ -23,36 +24,30 @@ describe('CourseCenterPageComponent live appointment refresh', () => {
         { provide: ActivatedRoute, useValue: { snapshot: { data: { meta: { path: 'pages/home/cart' } }, queryParamMap: convertToParamMap({}) } } },
         { provide: ActivatedRoute, useValue: { snapshot: { data: { meta: { path: 'pages/home/cart' } }, queryParamMap: convertToParamMap({}) } } },
         { provide: ApiService, useValue: { post } },
         { provide: ApiService, useValue: { post } },
         { provide: LiveDataRefreshService, useValue: { watch } },
         { provide: LiveDataRefreshService, useValue: { watch } },
-        { provide: SessionService, useValue: { user: signal({ userId: 42 }), groupId: signal(1) } },
+        { provide: SessionService, useValue: { user: signal({ userId: 42, groupId: 1 }), groupId: signal(1), token: signal('course-center-test-session') } },
         { provide: PlatformService, useValue: { toast: jasmine.createSpy('toast') } },
         { provide: PlatformService, useValue: { toast: jasmine.createSpy('toast') } },
       ],
       ],
     });
     });
   });
   });
+  afterEach(() => localStorage.removeItem('xiaoshu.learning.homeDaily.v1'));
 
 
-  it('uses the successful appointment pull and reloads the list when the watched version changes', () => {
+  it('uses the successful appointment pull and supports explicit refresh without polling', () => {
     const fixture = TestBed.createComponent(CourseCenterPageComponent);
     const fixture = TestBed.createComponent(CourseCenterPageComponent);
     fixture.detectChanges();
     fixture.detectChanges();
     const page = fixture.componentInstance;
     const page = fixture.componentInstance;
-    const options = watch.calls.mostRecent().args[0];
-
-    expect(options.scopes).toEqual(['courses', 'appointments']);
-    expect(options.studentId()).toBe(42);
+    expect(watch).not.toHaveBeenCalled();
     expect(page.schedules()[0]['Title']).toBe('第一节伴学');
     expect(page.schedules()[0]['Title']).toBe('第一节伴学');
     expect(fixture.nativeElement.querySelector('.center-sync')).toBeNull();
     expect(fixture.nativeElement.querySelector('.center-sync')).toBeNull();
 
 
-    options.onState({ checking: false, checkedAt: '', syncStatus: { state: 'unavailable' } });
-    fixture.detectChanges();
-    expect(fixture.nativeElement.querySelector('.center-sync')).toBeNull();
-
     overview = companionOverview('新增加的伴学');
     overview = companionOverview('新增加的伴学');
-    options.onRefresh('version');
+    page.load(true, true, true);
     fixture.detectChanges();
     fixture.detectChanges();
     expect(post.calls.mostRecent().args[1].refresh).toBeTrue();
     expect(post.calls.mostRecent().args[1].refresh).toBeTrue();
     expect(page.schedules()[0]['Title']).toBe('新增加的伴学');
     expect(page.schedules()[0]['Title']).toBe('新增加的伴学');
     expect(fixture.nativeElement.querySelector('.center-sync')).toBeNull();
     expect(fixture.nativeElement.querySelector('.center-sync')).toBeNull();
 
 
     post.and.returnValue(throwError(() => new Error('读取失败')));
     post.and.returnValue(throwError(() => new Error('读取失败')));
-    options.onRefresh('version');
+    page.load(true, true, true);
     fixture.detectChanges();
     fixture.detectChanges();
     expect(fixture.nativeElement.querySelector('.center-sync')?.textContent).toContain('实时同步暂时不可用');
     expect(fixture.nativeElement.querySelector('.center-sync')?.textContent).toContain('实时同步暂时不可用');
     expect(page.schedules()[0]['Title']).toBe('新增加的伴学');
     expect(page.schedules()[0]['Title']).toBe('新增加的伴学');
@@ -70,7 +65,7 @@ describe('CourseCenterPageComponent live appointment refresh', () => {
     tick(6_000);
     tick(6_000);
     fixture.detectChanges();
     fixture.detectChanges();
     expect(fixture.nativeElement.querySelector('.center-sync')).toBeNull();
     expect(fixture.nativeElement.querySelector('.center-sync')).toBeNull();
-    watch.calls.mostRecent().args[0].onState({ checking: false, checkedAt: '', syncStatus: { state: 'unavailable' } });
+    page.load(true, true, true);
     fixture.detectChanges();
     fixture.detectChanges();
     expect(fixture.nativeElement.querySelector('.center-sync')).toBeNull();
     expect(fixture.nativeElement.querySelector('.center-sync')).toBeNull();
 
 

Diff do ficheiro suprimidas por serem muito extensas
+ 4 - 20
scripts/deploy-admin-functions.mjs


+ 28 - 0
scripts/tests/account-security.test.mjs

@@ -1,5 +1,33 @@
 import {test} from 'node:test';import assert from 'node:assert/strict';import vm from 'node:vm';import {readFileSync} from 'node:fs';import {PGlite} from '@electric-sql/pglite';
 import {test} from 'node:test';import assert from 'node:assert/strict';import vm from 'node:vm';import {readFileSync} from 'node:fs';import {PGlite} from '@electric-sql/pglite';
 const code=readFileSync(new URL('../cloud/account-security.js',import.meta.url),'utf8');
 const code=readFileSync(new URL('../cloud/account-security.js',import.meta.url),'utf8');
+const gatewaySource=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+test('会员姓名和绑定手机号解析到同一账号,昵称不参与登录,同名要求手机号',async()=>{
+ const db=new PGlite();try{
+  await db.exec(`CREATE TABLE "_User"("objectId" text,"username" text,"company" text,"realName" text,"mobile" text,"phone" text,"legacyUserId" numeric,"legacyUserData" jsonb,"isDeleted" boolean);INSERT INTO "_User" VALUES('u1','account-one','co','可爱同学','13800000001',NULL,1,'{"TrueName":"可爱同学","HoneyName":"可改昵称"}',false),('other','another','other-company','可爱同学','13800000009',NULL,9,'{}',false);`);
+  const c=vm.createContext({DEFAULT_COMPANY_ID:'co',Psql:{query:async(sql,args)=>(await db.query(sql,args)).rows},legacyLoginResult:()=>({}),number:v=>Number(v)||0,firstValue:(...a)=>a.find(v=>v!==undefined&&v!==null&&v!==''),fail:(status,message)=>{throw Object.assign(Error(message),{status})},Parse:{Query:class{async get(id){return{id}}}}});
+  vm.runInContext(gatewaySource.slice(gatewaySource.indexOf('async function migratedLoginTarget('),gatewaySource.indexOf('const READING_CLASSES=')),c);
+  assert.equal((await c.migratedLoginTarget('可爱同学')).id,'u1');assert.equal((await c.migratedLoginTarget('13800000001')).id,'u1');assert.equal(await c.migratedLoginTarget('可改昵称'),null);assert.equal(await c.migratedLoginTarget("' OR 1=1 --"),null);
+  await db.exec(`INSERT INTO "_User" VALUES('u2','account-two','co','可爱同学','13800000002',NULL,2,'{}',false);`);
+  await assert.rejects(c.migratedLoginTarget('可爱同学'),/姓名重复/);assert.equal((await c.migratedLoginTarget('13800000001')).id,'u1');
+  await db.exec(`UPDATE "_User" SET "isDeleted"=true WHERE "objectId"='u2'`);assert.equal((await c.migratedLoginTarget('可爱同学')).id,'u1');
+ }finally{await db.close();}
+});
+test('创建会员由服务端统一设置初始密码,编辑资料不会重置密码',()=>{
+ const fn=gatewaySource.split('\n').find(line=>line.startsWith('function memberProfilePayload('));const c=vm.createContext({text:v=>String(v||'').trim(),number:v=>Number(v)||0,fail:(_,m)=>{throw Error(m)}});vm.runInContext(fn,c);
+ const profile={displayName:'新同学',mobile:'13800000001'};assert.equal(c.memberProfilePayload(profile,true).password,'12345678');assert.equal(c.memberProfilePayload({...profile,password:'caller-selected-password'},true).password,'12345678');assert.equal(c.memberProfilePayload(profile,false).password,'');
+});
+test('学生修改昵称时不能同时改动绑定姓名和手机号',async()=>{
+ const attrs={legacyGroupId:1,realName:'会员姓名',mobile:'13800000001',nickname:'旧昵称',legacyUserData:{TrueName:'会员姓名',Mobile:'13800000001',HoneyName:'旧昵称'}};let saved=0;const current={get:k=>attrs[k],set:(k,v)=>attrs[k]=v,save:async()=>saved++};
+ const c=vm.createContext({ownLegacyId:()=>1,number:v=>Number(v)||0,isAdminUser:()=>false,isMemberAccount:()=>true,parseObject:v=>v||{},fieldsOf:async()=>({realName:{},mobile:{},nickname:{}}),legacyData:u=>u.get('legacyUserData'),writeValue:v=>v,cleanText:v=>v,legacyUser:()=>({}),legacyUserAddon:()=>({})});
+ vm.runInContext(gatewaySource.slice(gatewaySource.indexOf('async function updateUserProfile('),gatewaySource.indexOf('function legacyUserRow(')),c);
+ await c.updateUserProfile({mu:{trueName:'篡改姓名',mobile:'13900000000',honeyName:'新昵称'}},current);assert.equal(saved,1);assert.equal(attrs.realName,'会员姓名');assert.equal(attrs.mobile,'13800000001');assert.equal(attrs.legacyUserData.TrueName,'会员姓名');assert.equal(attrs.legacyUserData.Mobile,'13800000001');assert.equal(attrs.nickname,'新昵称');assert.equal(attrs.legacyUserData.HoneyName,'新昵称');
+});
+test('旧分组为 3 的已确认会员可以由管理员重置,陪练身份仍被拒绝',async()=>{
+ const values={legacyGroupId:3,identityType:'member',legacyUserId:22};const target={get:k=>values[k],toJSON:()=>values};
+ const c=vm.createContext({scopedObject:async()=>target,number:v=>Number(v)||0,displayName:()=> '会员姓名',pointerId:()=> 'co',Psql:{oneOrNone:async()=>null},fail:(_,m)=>{throw Error(m)}});
+ vm.runInContext(gatewaySource.split('\n').find(line=>line.startsWith('async function memberTarget(')),c);
+ assert.equal(await c.memberTarget({company:{}},{},'student'),target);values.identityType='coach';await assert.rejects(c.memberTarget({company:{}},{},'coach'),/身份已发生变化/);
+});
 test('旧密码只允许显式首次迁移,已重置和历史不明账号拒绝且不调用旧系统',async()=>{
 test('旧密码只允许显式首次迁移,已重置和历史不明账号拒绝且不调用旧系统',async()=>{
  for(const state of [{},{legacyPasswordMigrationAllowed:false},{legacyPasswordMigrationAllowed:true,passwordResetRequired:true}]){
  for(const state of [{},{legacyPasswordMigrationAllowed:false},{legacyPasswordMigrationAllowed:true,passwordResetRequired:true}]){
  let verified=0,saved=0;const c=vm.createContext({migratedLoginTarget:async()=>({get:k=>state[k],save:async()=>saved++}),verifyLegacyPassword:async()=>verified++,fail:(status,msg)=>{throw Error(msg)}});vm.runInContext(code,c);await assert.rejects(c.migrateLegacyLogin('a','old'),/密码错误/);assert.equal(saved,0);assert.equal(verified,0);
  let verified=0,saved=0;const c=vm.createContext({migratedLoginTarget:async()=>({get:k=>state[k],save:async()=>saved++}),verifyLegacyPassword:async()=>verified++,fail:(status,msg)=>{throw Error(msg)}});vm.runInContext(code,c);await assert.rejects(c.migrateLegacyLogin('a','old'),/密码错误/);assert.equal(saved,0);assert.equal(verified,0);

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff