Browse Source

fix: persist public profile identity and keep /profile usable

Empty experience years, unsaved slugs, missing invite codes, and expired sessions were breaking the new stylist homepage flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
chenyu 1 month ago
parent
commit
38a311df9d

+ 6 - 1
backend/src/modules/stylist/dto/update-stylist-profile.dto.ts

@@ -47,7 +47,12 @@ export class UpdateStylistProfileDto {
   bookingUrl?: string;
 
   @IsOptional()
-  @Type(() => Number)
+  @Transform(({ value }) => {
+    if (value === null || value === undefined || value === '') return null;
+    const parsed = Number(value);
+    return Number.isFinite(parsed) ? parsed : value;
+  })
+  @ValidateIf((_, value) => value !== null && value !== undefined)
   @IsInt()
   @Min(1)
   @Max(30)

+ 28 - 0
backend/src/modules/stylist/stylist.service.spec.ts

@@ -125,11 +125,39 @@ describe('StylistService', () => {
         where: expect.objectContaining({
           status: 'PUBLIC',
           isSystemTemplate: 'NO',
+          templateUrl: { not: '' },
         }),
       }),
     );
   });
 
+  it('persists a public slug and invite code when they are missing', async () => {
+    const pending = { ...stylist, slug: null, simulationInviteCode: null };
+    prisma.hairstylistUser.findFirst
+      .mockResolvedValueOnce(pending)
+      .mockResolvedValueOnce(null)
+      .mockResolvedValueOnce(null);
+    prisma.hairstylistUser.update.mockResolvedValue({
+      ...pending,
+      slug: 'yasuaki',
+      simulationInviteCode: 'ABCD2345',
+    });
+    prisma.hairTemplate.count.mockResolvedValue(0);
+    prisma.hairTemplate.findMany.mockResolvedValue([]);
+
+    await expect(service.getMyProfile(BigInt(7))).resolves.toMatchObject({
+      slug: 'yasuaki',
+      inviteCode: 'ABCD2345',
+    });
+    expect(prisma.hairstylistUser.update).toHaveBeenCalledWith({
+      where: { id: stylist.id },
+      data: {
+        slug: 'yasuaki',
+        simulationInviteCode: expect.stringMatching(/^[A-HJ-NP-Z2-9]{8}$/),
+      },
+    });
+  });
+
   it('throws when the public slug does not exist', async () => {
     prisma.hairstylistUser.findFirst.mockResolvedValue(null);
     await expect(service.getPublicProfile('missing')).rejects.toBeInstanceOf(NotFoundException);

+ 78 - 6
backend/src/modules/stylist/stylist.service.ts

@@ -4,6 +4,7 @@ import {
   Injectable,
   NotFoundException,
 } from '@nestjs/common';
+import { randomBytes } from 'crypto';
 import { Prisma } from '@prisma/client';
 import { PrismaService } from '../../prisma/prisma.service';
 import { normalizeTemplateNameI18n } from '../templates/template-name-i18n';
@@ -38,7 +39,7 @@ export class StylistService {
   constructor(private readonly prisma: PrismaService) {}
 
   async getMyProfile(stylistId: bigint): Promise<StylistProfileDto> {
-    const stylist = await this.getOwnedStylist(stylistId);
+    const stylist = await this.ensurePublicIdentity(await this.getOwnedStylist(stylistId));
     const [publishedTemplateCount, templates] = await this.getPublishedTemplates(stylist.id);
     return this.toProfileDto(stylist, publishedTemplateCount, templates);
   }
@@ -48,7 +49,7 @@ export class StylistService {
     dto: UpdateStylistProfileDto,
   ): Promise<StylistProfileDto> {
     const stylist = await this.getOwnedStylist(stylistId);
-    const slug = this.normalizeSlug(dto.slug, stylist.username);
+    const slug = this.normalizeSlug(dto.slug, stylist.username, stylist.id);
 
     if (isReservedProfileSlug(slug)) {
       throw new BadRequestException('该公开主页路径不可用');
@@ -110,9 +111,10 @@ export class StylistService {
       throw new NotFoundException('公开主页不存在');
     }
 
-    const [publishedTemplateCount, templates] = await this.getPublishedTemplates(stylist.id);
+    const resolved = await this.ensurePublicIdentity(stylist);
+    const [publishedTemplateCount, templates] = await this.getPublishedTemplates(resolved.id);
 
-    const profile = this.toProfileDto(stylist, publishedTemplateCount, templates);
+    const profile = this.toProfileDto(resolved, publishedTemplateCount, templates);
     const { email, username, ...publicProfile } = profile;
     void email;
     void username;
@@ -135,6 +137,7 @@ export class StylistService {
       isDeleted: 0,
       status: 'PUBLIC' as const,
       isSystemTemplate: 'NO' as const,
+      templateUrl: { not: '' },
     };
 
     const [publishedTemplateCount, templates] = await Promise.all([
@@ -166,8 +169,77 @@ export class StylistService {
     ] as const;
   }
 
-  private normalizeSlug(slug: string, username: string) {
-    return slugifyProfilePath(slug || username) || `stylist`;
+  private async ensurePublicIdentity(stylist: StylistRow): Promise<StylistRow> {
+    if (stylist.slug && stylist.simulationInviteCode) return stylist;
+
+    for (let attempt = 0; attempt < 10; attempt += 1) {
+      const data: Prisma.HairstylistUserUpdateInput = {};
+      if (!stylist.slug) {
+        data.slug = await this.allocateSlug(stylist);
+      }
+      if (!stylist.simulationInviteCode) {
+        data.simulationInviteCode = await this.allocateInviteCode(stylist.id);
+      }
+
+      try {
+        return await this.prisma.hairstylistUser.update({
+          where: { id: stylist.id },
+          data,
+        });
+      } catch (error) {
+        if (
+          error instanceof Prisma.PrismaClientKnownRequestError &&
+          error.code === 'P2002'
+        ) {
+          continue;
+        }
+        throw error;
+      }
+    }
+
+    throw new BadRequestException('暂时无法生成公开主页信息,请稍后重试');
+  }
+
+  private async allocateSlug(stylist: StylistRow): Promise<string> {
+    const fromName = slugifyProfilePath(stylist.username);
+    const base =
+      fromName && !isReservedProfileSlug(fromName)
+        ? fromName
+        : `stylist-${stylist.id.toString()}`;
+
+    for (let index = 0; index < 20; index += 1) {
+      const candidate = index === 0 ? base : `${base}-${index + 1}`;
+      if (isReservedProfileSlug(candidate)) continue;
+      const taken = await this.prisma.hairstylistUser.findFirst({
+        where: { slug: candidate, NOT: { id: stylist.id } },
+        select: { id: true },
+      });
+      if (!taken) return candidate;
+    }
+
+    return `stylist-${stylist.id.toString()}`;
+  }
+
+  private async allocateInviteCode(stylistId: bigint): Promise<string> {
+    for (let attempt = 0; attempt < 10; attempt += 1) {
+      const inviteCode = this.generateInviteCode();
+      const taken = await this.prisma.hairstylistUser.findFirst({
+        where: { simulationInviteCode: inviteCode, NOT: { id: stylistId } },
+        select: { id: true },
+      });
+      if (!taken) return inviteCode;
+    }
+    throw new BadRequestException('暂时无法生成模拟代码,请稍后重试');
+  }
+
+  private generateInviteCode(): string {
+    const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+    const bytes = randomBytes(8);
+    return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join('');
+  }
+
+  private normalizeSlug(slug: string, username: string, stylistId: bigint) {
+    return slugifyProfilePath(slug || username) || `stylist-${stylistId.toString()}`;
   }
 
   private toProfileDto(

+ 14 - 3
frontend/src/pages/customer/StylistProfileLivePage.tsx

@@ -12,13 +12,20 @@ import {
   type PublicStylistProfile,
   type StylistPublicTemplate,
 } from '../../services/api/stylist'
+import { ApiError } from '../../services/http/client'
 import { CUSTOMER_SIMULATION_CODE_KEY } from './CustomerSimulationPage'
 
 type LiveProfile = Omit<PublicStylistProfile, 'templates'> & {
   templates: StylistPublicTemplate[]
 }
 
-export function StylistProfileLivePage({ slug }: { slug?: string }) {
+export function StylistProfileLivePage({
+  slug,
+  onAuthExpired,
+}: {
+  slug?: string
+  onAuthExpired?: () => void
+}) {
   const { locale, path, copy } = usePageContext()
   const pageCopy = copy.stylistProfileLive
   const [profile, setProfile] = useState<LiveProfile | null>(null)
@@ -39,13 +46,17 @@ export function StylistProfileLivePage({ slug }: { slug?: string }) {
       })
       .catch((err) => {
         if (cancelled) return
+        if (!slug && err instanceof ApiError && err.status === 401) {
+          onAuthExpired?.()
+          return
+        }
         setProfile(null)
         setError(err instanceof Error ? err.message : pageCopy.notFound)
       })
     return () => {
       cancelled = true
     }
-  }, [pageCopy.notFound, slug])
+  }, [onAuthExpired, pageCopy.notFound, slug])
 
   const simulationPath = profile?.inviteCode
     ? path(`/customer-simulation?code=${encodeURIComponent(profile.inviteCode)}`)
@@ -69,7 +80,7 @@ export function StylistProfileLivePage({ slug }: { slug?: string }) {
     return (
       <PublicLayout>
         <Helmet><html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.notFound}`}</title></Helmet>
-        <section className="half-section ipad-top-space-margin"><div className="container"><div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px" role="alert"><p className="mb-20px">{error}</p><Link to={path('/profile')} className="btn btn-large btn-rounded btn-base-color fw-700">{pageCopy.backToDemo}</Link></div></div></section>
+        <section className="half-section ipad-top-space-margin"><div className="container"><div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px" role="alert"><p className="mb-20px">{error}</p><a href={`${path('/profile')}?demo=1`} className="btn btn-large btn-rounded btn-base-color fw-700">{pageCopy.backToDemo}</a></div></div></section>
       </PublicLayout>
     )
   }

+ 9 - 4
frontend/src/pages/customer/StylistProfilePage.tsx

@@ -1,5 +1,5 @@
-import { useMemo, useState } from 'react'
-import { Link, useParams } from 'react-router-dom'
+import { useCallback, useMemo, useState } from 'react'
+import { Link, useParams, useSearchParams } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { PublicLayout } from '../../layouts/PublicLayout'
 import { ImageLightbox } from '../../components/common/ImageLightbox'
@@ -13,9 +13,14 @@ type TemplateFilter = '*' | PublicHairstyleTemplate['category']
 
 export function StylistProfilePage() {
   const { slug } = useParams<{ slug?: string }>()
+  const [searchParams] = useSearchParams()
+  const [demoFallback, setDemoFallback] = useState(false)
+  const handleAuthExpired = useCallback(() => setDemoFallback(true), [])
   if (slug) return <StylistProfileLivePage slug={slug} />
-  if (isAuthenticated()) return <StylistProfileLivePage />
-  return <StylistProfileDemoPage />
+  if (searchParams.get('demo') === '1' || demoFallback || !isAuthenticated()) {
+    return <StylistProfileDemoPage />
+  }
+  return <StylistProfileLivePage onAuthExpired={handleAuthExpired} />
 }
 
 function StylistProfileDemoPage() {

+ 12 - 0
frontend/src/pages/customer/__tests__/StylistProfilePage.test.tsx

@@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react'
 import { MemoryRouter, Route, Routes } from 'react-router-dom'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import '../../../i18n'
+import { ApiError } from '../../../services/http/client'
 import { StylistProfilePage } from '../StylistProfilePage'
 
 const { isAuthenticated, getStylistMe, getPublicStylistProfile } = vi.hoisted(() => ({
@@ -161,4 +162,15 @@ describe('StylistProfilePage', () => {
     expect(screen.getByText('层次剪发')).toBeInTheDocument()
     expect(screen.queryByText('使用次数')).not.toBeInTheDocument()
   })
+
+  it('falls back to the demo page when the logged-in session has expired', async () => {
+    isAuthenticated.mockReturnValue(true)
+    getStylistMe.mockRejectedValue(new ApiError('UNAUTHORIZED', '登录已过期', 401))
+    renderProfile('/zh/profile')
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: '用你的照片,试试这位发型师设计的发型。' })).toBeInTheDocument()
+    })
+    expect(screen.getByText('顾客反馈')).toBeInTheDocument()
+  })
 })