For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Rebuild the customer create/list/summary slice so backend schema, APIs, frontend forms/lists, and docs all align with backend/docs/数据库设计.md#L16-L54, with hairstylist_id added as the only structural supplement required for JWT isolation.
Architecture: Replace the current incorrect customer model instead of extending it. Backend persists and queries the real customer table fields from the approved database design, frontend adapts its current page structure to the new field semantics, and docs are replaced so the old incorrect model does not remain as reference material.
Tech Stack: NestJS 11, Prisma, MySQL, Jest, Supertest, React 19, React Router 7, TypeScript, JWT auth, existing /api/v1 API structure, existing storage upload endpoint.
/api/v1 prefix.customer implementation must use backend/docs/数据库设计.md#L16-L54 as the source-of-truth field model.hairstylist_id for JWT-based data isolation.type=avatar.https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png.page + pageSize.total, today, followUp, simulated.today must be based on plan_date.followUp must be derived from next_follow_date <= today.kana, avatar_url, contact_type, line_status, last_visit_date, next_visit_date, visit_cycle, hair_type, hair_condition, concern, desired_image, formula_note, private_note, follow_status, follow_timing, follow_message) must not remain as the main customer model.30日以上未到店 must be removed because the source-of-truth SQL has no last_visit_date.Files:
backend/prisma/schema.prismabackend/prisma/migrations/20260628120000_add_customer_table/migration.sqlbackend/src/config/env.schema.tsbackend/src/config/app.config.tsbackend/src/config/app.config.spec.tsbackend/.env.exampleInterfaces:
backend/docs/数据库设计.md#L16-L54Customer model with the approved field names plus hairstylistIdcustomer.defaultAvatarUrlProduces: SQL migration comments and indexes aligned with the redesigned model
[ ] Step 1: Write the failing config test for the customer default avatar URL
Update backend/src/config/app.config.spec.ts with:
it('exposes the customer default avatar url', () => {
process.env.CUSTOMER_DEFAULT_AVATAR_URL =
'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png';
const config = appConfig();
expect(config.customer.defaultAvatarUrl).toBe(
'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
);
});
Run: npm test -- app.config.spec.ts --runInBand
Expected: FAIL because customer.defaultAvatarUrl does not exist yet.
Update backend/src/config/env.schema.ts with:
CUSTOMER_DEFAULT_AVATAR_URL: z.string().url(),
Update backend/src/config/app.config.ts with:
customer: {
defaultAvatarUrl:
process.env.CUSTOMER_DEFAULT_AVATAR_URL ??
'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
},
Update backend/.env.example with:
CUSTOMER_DEFAULT_AVATAR_URL=https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png
Customer model to match the approved database designReplace the customer-related enums and model in backend/prisma/schema.prisma with:
enum CustomerExpectContact {
LINE
PHONE
EMAIL
NONE
}
enum CustomerHairLength {
SHORT
MEDIUM
LONG
UNCONFIRM
}
enum CustomerHairTexture {
UNCONFIRM
THIN
NORMAL
HARD_CRUDE
CURLY
}
enum CustomerFirstSuggestHair {
AUTO
LONG
MEDIUM
SHORT
}
enum CustomerVisitPurpose {
CUT_DYE
CUT
DYE
CONSULT_SIM
}
model Customer {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
hairstylistId BigInt @map("hairstylist_id") @db.UnsignedBigInt
name String @db.VarChar(30)
namePronunciation String @default("") @map("name_pronunciation") @db.VarChar(50)
phone String @db.VarChar(20)
email String @default("") @db.VarChar(100)
birthday DateTime? @db.Date
avatar String @default("") @db.VarChar(500)
expectContact CustomerExpectContact @default(NONE) @map("expect_contact")
hairLength CustomerHairLength @default(UNCONFIRM) @map("hair_length")
hairTexture CustomerHairTexture @default(UNCONFIRM) @map("hair_texture")
notice String? @db.Text
allergy String @default("") @db.VarChar(500)
innerRemark String? @map("inner_remark") @db.Text
nextFollowDate DateTime? @map("next_follow_date") @db.Date
recommendVisitDate DateTime? @map("recommend_visit_date") @db.Date
firstSuggestHair CustomerFirstSuggestHair @default(AUTO) @map("first_suggest_hair")
planDate DateTime? @map("plan_date") @db.Date
isFirstRegister Int @default(0) @map("is_first_register") @db.UnsignedTinyInt
visitPurpose CustomerVisitPurpose? @map("visit_purpose")
trouble String? @db.Text
consultContent String? @map("consult_content") @db.Text
expectLook String? @map("expect_look") @db.Text
createTime DateTime @default(now()) @map("create_time")
updateTime DateTime @updatedAt @map("update_time")
isDeleted Int @default(0) @map("is_deleted") @db.UnsignedTinyInt
@@unique([phone, isDeleted], map: "uk_phone_del")
@@index([hairstylistId, isDeleted], map: "idx_hairstylist_is_deleted")
@@index([isDeleted], map: "idx_is_deleted")
@@index([nextFollowDate, isDeleted], map: "idx_next_follow")
@@index([recommendVisitDate, isDeleted], map: "idx_recommend_visit")
@@index([planDate, isDeleted], map: "idx_plan_date")
@@map("customer")
}
Update backend/prisma/migrations/20260628120000_add_customer_table/migration.sql with:
CREATE TABLE `customer` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '顾客主键ID',
`hairstylist_id` BIGINT UNSIGNED NOT NULL COMMENT '所属发型师ID,JWT 隔离必需字段',
`name` VARCHAR(30) NOT NULL COMMENT '顾客姓名(必填)',
`name_pronunciation` VARCHAR(50) DEFAULT '' COMMENT '姓名读音',
`phone` VARCHAR(20) NOT NULL COMMENT '联系电话(必填)',
`email` VARCHAR(100) DEFAULT '' COMMENT '邮箱',
`birthday` DATE NULL COMMENT '生日',
`avatar` VARCHAR(500) DEFAULT '' COMMENT '顾客头像图片URL地址',
`expect_contact` ENUM('LINE','PHONE','EMAIL','NONE') NOT NULL DEFAULT 'NONE' COMMENT '期望联系方式:LINE/电话/邮箱/未设置',
`hair_length` ENUM('SHORT','MEDIUM','LONG','UNCONFIRM') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '当前头发长度:短发/中长发/长发/未确认',
`hair_texture` ENUM('UNCONFIRM','THIN','NORMAL','HARD_CRUDE','CURLY') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '发质:未确认/细/普通/粗硬/有自然卷',
`notice` TEXT NULL COMMENT '发型护理注意事项',
`allergy` VARCHAR(500) DEFAULT '' COMMENT '过敏史/过敏产品',
`inner_remark` TEXT NULL COMMENT '门店内部备注(仅内部查看)',
`next_follow_date` DATE NULL COMMENT '下次跟进日期',
`recommend_visit_date` DATE NULL COMMENT '推荐到店日',
`first_suggest_hair` ENUM('AUTO','LONG','MEDIUM','SHORT') NOT NULL DEFAULT 'AUTO' COMMENT '首次建议发型模板:自动推荐/长发/中长发/短发',
`plan_date` DATE NULL COMMENT '到店计划日',
`is_first_register` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否首次登记:0否,1是',
`visit_purpose` ENUM('CUT_DYE','CUT','DYE','CONSULT_SIM') NULL COMMENT '到店目的:剪发+染发/剪发/染发/模拟咨询',
`trouble` TEXT NULL COMMENT '发质/发型烦恼',
`consult_content` TEXT NULL COMMENT '详细咨询内容',
`expect_look` TEXT NULL COMMENT '期望发型形象描述',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除 0正常 1删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_phone_del` (`phone`,`is_deleted`) COMMENT '手机号唯一,删除后可重录',
KEY `idx_hairstylist_is_deleted` (`hairstylist_id`,`is_deleted`) COMMENT '按发型师隔离顾客数据',
KEY `idx_is_deleted` (`is_deleted`) COMMENT '逻辑删除筛选',
KEY `idx_next_follow` (`next_follow_date`,`is_deleted`) COMMENT '跟进日期筛选索引',
KEY `idx_recommend_visit` (`recommend_visit_date`,`is_deleted`) COMMENT '推荐到店日期索引',
KEY `idx_plan_date` (`plan_date`,`is_deleted`) COMMENT '到店计划日筛选索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='顾客信息+咨询登记表(合并单表)';
Run: npm test -- app.config.spec.ts --runInBand
Expected: PASS.
Run: npx prisma validate
Expected: The schema at prisma/schema.prisma is valid.
[ ] Step 7: Commit the persistence redesign
git add backend/prisma/schema.prisma backend/prisma/migrations/20260628120000_add_customer_table/migration.sql backend/src/config/env.schema.ts backend/src/config/app.config.ts backend/src/config/app.config.spec.ts backend/.env.example
git commit -m "feat: realign customer persistence with db schema"
Files:
backend/src/modules/customers/dto/customer-upsert.dto.tsbackend/src/modules/customers/dto/list-customers-query.dto.tsbackend/src/modules/customers/dto/customer-summary.dto.tsbackend/src/modules/customers/customers.mapper.tsbackend/src/modules/customers/customers.service.tsbackend/src/modules/customers/customers.controller.tsbackend/src/modules/customers/customers.service.spec.tsbackend/test/app.e2e-spec.tsInterfaces:
Customer model from Task 1, config customer.defaultAvatarUrl, JWT stylistIdCustomersService.create(stylistId: bigint, body: CustomerUpsertDto)CustomersService.list(stylistId: bigint, query: ListCustomersQueryDto)CustomersService.getSummary(stylistId: bigint): Promise<CustomerSummaryDto>Produces: API routes POST /api/v1/customers, GET /api/v1/customers, GET /api/v1/customers/summary
[ ] Step 1: Write failing service tests for the redesigned fields and summary semantics
Update backend/src/modules/customers/customers.service.spec.ts with:
it('creates a customer using the source-of-truth fields', async () => {
prisma.customer.create.mockResolvedValue({
id: BigInt(11),
hairstylistId: BigInt(1),
name: 'Sato Misaki',
namePronunciation: 'さとう みさき',
phone: '090-0000-0000',
email: '',
birthday: null,
avatar:
'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
expectContact: 'LINE',
hairLength: 'MEDIUM',
hairTexture: 'NORMAL',
notice: null,
allergy: '',
innerRemark: null,
nextFollowDate: new Date('2026-07-12T00:00:00.000Z'),
recommendVisitDate: new Date('2026-07-20T00:00:00.000Z'),
firstSuggestHair: 'AUTO',
planDate: new Date('2026-06-28T00:00:00.000Z'),
isFirstRegister: 1,
visitPurpose: 'CONSULT_SIM',
trouble: '发量少',
consultContent: '希望有轻盈感',
expectLook: '自然层次感',
createTime: new Date('2026-06-28T00:00:00.000Z'),
updateTime: new Date('2026-06-28T00:00:00.000Z'),
isDeleted: 0,
});
await service.create(BigInt(1), {
name: 'Sato Misaki',
namePronunciation: 'さとう みさき',
phone: '090-0000-0000',
expectContact: 'LINE',
hairLength: 'MEDIUM',
hairTexture: 'NORMAL',
nextFollowDate: '2026-07-12',
recommendVisitDate: '2026-07-20',
planDate: '2026-06-28',
isFirstRegister: true,
visitPurpose: 'CONSULT_SIM',
trouble: '发量少',
consultContent: '希望有轻盈感',
expectLook: '自然层次感',
});
expect(prisma.customer.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
hairstylistId: BigInt(1),
namePronunciation: 'さとう みさき',
expectContact: 'LINE',
hairTexture: 'NORMAL',
planDate: new Date('2026-06-28T00:00:00.000Z'),
}),
}),
);
});
it('maps visit filters to plan_date and follow filters to next_follow_date', async () => {
prisma.customer.findMany.mockResolvedValue([]);
prisma.customer.count.mockResolvedValue(0);
await service.list(BigInt(1), {
page: 1,
pageSize: 20,
q: 'Misaki',
visitStatus: 'TODAY',
followStatus: 'NEED_FOLLOW_UP',
hairLength: 'MEDIUM',
});
expect(prisma.customer.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
hairstylistId: BigInt(1),
isDeleted: 0,
hairLength: 'MEDIUM',
planDate: expect.any(Object),
nextFollowDate: expect.any(Object),
}),
}),
);
});
it('counts summary values using plan_date and next_follow_date', async () => {
prisma.customer.count
.mockResolvedValueOnce(12)
.mockResolvedValueOnce(3)
.mockResolvedValueOnce(4);
prisma.aiHairSimulation.findMany.mockResolvedValue([
{ customerId: BigInt(10) },
{ customerId: BigInt(10) },
{ customerId: BigInt(11) },
]);
const result = await service.getSummary(BigInt(1));
expect(result).toEqual({
total: 12,
today: 3,
followUp: 4,
simulated: 2,
});
});
Run: npm test -- customers.service.spec.ts --runInBand
Expected: FAIL because service logic still reflects the old incorrect model.
Update backend/src/modules/customers/dto/customer-upsert.dto.ts with:
import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsIn, IsOptional, IsString } from 'class-validator';
export class CustomerUpsertDto {
@IsString()
name!: string;
@IsOptional()
@IsString()
namePronunciation?: string;
@IsString()
phone!: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
birthday?: string;
@IsOptional()
@IsString()
avatar?: string;
@IsOptional()
@IsIn(['LINE', 'PHONE', 'EMAIL', 'NONE'])
expectContact?: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE';
@IsOptional()
@IsIn(['SHORT', 'MEDIUM', 'LONG', 'UNCONFIRM'])
hairLength?: 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM';
@IsOptional()
@IsIn(['UNCONFIRM', 'THIN', 'NORMAL', 'HARD_CRUDE', 'CURLY'])
hairTexture?: 'UNCONFIRM' | 'THIN' | 'NORMAL' | 'HARD_CRUDE' | 'CURLY';
@IsOptional()
@IsString()
notice?: string;
@IsOptional()
@IsString()
allergy?: string;
@IsOptional()
@IsString()
innerRemark?: string;
@IsOptional()
@IsString()
nextFollowDate?: string;
@IsOptional()
@IsString()
recommendVisitDate?: string;
@IsOptional()
@IsIn(['AUTO', 'LONG', 'MEDIUM', 'SHORT'])
firstSuggestHair?: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT';
@IsOptional()
@IsString()
planDate?: string;
@IsOptional()
@Transform(({ value }) => value === true || value === 'true' || value === 1 || value === '1')
@IsBoolean()
isFirstRegister?: boolean;
@IsOptional()
@IsIn(['CUT_DYE', 'CUT', 'DYE', 'CONSULT_SIM'])
visitPurpose?: 'CUT_DYE' | 'CUT' | 'DYE' | 'CONSULT_SIM';
@IsOptional()
@IsString()
trouble?: string;
@IsOptional()
@IsString()
consultContent?: string;
@IsOptional()
@IsString()
expectLook?: string;
}
Update backend/src/modules/customers/dto/list-customers-query.dto.ts with:
import { Transform } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class ListCustomersQueryDto {
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsIn(['ALL', 'TODAY', 'THIS_WEEK'])
visitStatus?: 'ALL' | 'TODAY' | 'THIS_WEEK';
@IsOptional()
@IsIn(['ALL', 'NEED_FOLLOW_UP', 'NO_NEED_FOLLOW_UP'])
followStatus?: 'ALL' | 'NEED_FOLLOW_UP' | 'NO_NEED_FOLLOW_UP';
@IsOptional()
@IsIn(['ALL', 'SHORT', 'MEDIUM', 'LONG', 'UNCONFIRM'])
hairLength?: 'ALL' | 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM';
}
Update backend/src/modules/customers/customers.mapper.ts with:
export function parseDateOnly(value?: string) {
return value ? new Date(`${value}T00:00:00.000Z`) : null;
}
export function formatDateOnly(value: Date | null) {
return value ? value.toISOString().slice(0, 10) : undefined;
}
export function toBooleanFlag(value?: boolean) {
return value ? 1 : 0;
}
export function toFollowState(nextFollowDate: Date | null, now = new Date()) {
if (!nextFollowDate) return 'NO_NEED_FOLLOW_UP';
const startOfToday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
return nextFollowDate <= startOfToday ? 'NEED_FOLLOW_UP' : 'NO_NEED_FOLLOW_UP';
}
CustomersService to persist and query the real customer fieldsUpdate the create() data block in backend/src/modules/customers/customers.service.ts to:
const created = await this.prisma.customer.create({
data: {
hairstylistId: stylistId,
name: body.name.trim(),
namePronunciation: body.namePronunciation?.trim() ?? '',
phone: body.phone.trim(),
email: body.email?.trim() ?? '',
birthday: parseDateOnly(body.birthday),
avatar:
body.avatar?.trim() ||
this.configService.getOrThrow<string>('customer.defaultAvatarUrl'),
expectContact: body.expectContact ?? 'NONE',
hairLength: body.hairLength ?? 'UNCONFIRM',
hairTexture: body.hairTexture ?? 'UNCONFIRM',
notice: body.notice?.trim() || null,
allergy: body.allergy?.trim() ?? '',
innerRemark: body.innerRemark?.trim() || null,
nextFollowDate: parseDateOnly(body.nextFollowDate),
recommendVisitDate: parseDateOnly(body.recommendVisitDate),
firstSuggestHair: body.firstSuggestHair ?? 'AUTO',
planDate: parseDateOnly(body.planDate),
isFirstRegister: toBooleanFlag(body.isFirstRegister),
visitPurpose: body.visitPurpose ?? null,
trouble: body.trouble?.trim() || null,
consultContent: body.consultContent?.trim() || null,
expectLook: body.expectLook?.trim() || null,
},
});
Update the list where builder to:
if (query.q?.trim()) {
where.OR = [
{ name: { contains: query.q.trim() } },
{ phone: { contains: query.q.trim() } },
{ innerRemark: { contains: query.q.trim() } },
{ trouble: { contains: query.q.trim() } },
{ consultContent: { contains: query.q.trim() } },
];
}
if (query.hairLength && query.hairLength !== 'ALL') {
where.hairLength = query.hairLength;
}
const now = new Date();
const startOfToday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const endOfToday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
const dayOfWeek = startOfToday.getUTCDay();
const diffToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const startOfWeek = new Date(startOfToday);
startOfWeek.setUTCDate(startOfWeek.getUTCDate() - diffToMonday);
const endOfWeek = new Date(startOfWeek);
endOfWeek.setUTCDate(endOfWeek.getUTCDate() + 7);
if (query.visitStatus === 'TODAY') {
where.planDate = { gte: startOfToday, lt: endOfToday };
} else if (query.visitStatus === 'THIS_WEEK') {
where.planDate = { gte: startOfWeek, lt: endOfWeek };
}
if (query.followStatus === 'NEED_FOLLOW_UP') {
where.nextFollowDate = { not: null, lte: startOfToday };
} else if (query.followStatus === 'NO_NEED_FOLLOW_UP') {
where.OR = [
...(where.OR ?? []),
{ nextFollowDate: null },
{ nextFollowDate: { gt: startOfToday } },
];
}
Return customer entities as:
return {
id: customer.id.toString(),
name: customer.name,
namePronunciation: customer.namePronunciation || undefined,
phone: customer.phone,
email: customer.email || undefined,
birthday: formatDateOnly(customer.birthday),
avatar: customer.avatar,
expectContact: customer.expectContact,
hairLength: customer.hairLength,
hairTexture: customer.hairTexture,
notice: customer.notice ?? undefined,
allergy: customer.allergy || undefined,
innerRemark: customer.innerRemark ?? undefined,
nextFollowDate: formatDateOnly(customer.nextFollowDate),
recommendVisitDate: formatDateOnly(customer.recommendVisitDate),
firstSuggestHair: customer.firstSuggestHair,
planDate: formatDateOnly(customer.planDate),
isFirstRegister: customer.isFirstRegister === 1,
visitPurpose: customer.visitPurpose ?? undefined,
trouble: customer.trouble ?? undefined,
consultContent: customer.consultContent ?? undefined,
expectLook: customer.expectLook ?? undefined,
followState: toFollowState(customer.nextFollowDate),
};
Count summary values as:
const [total, todayCount, followUp, simulations] = await Promise.all([
this.prisma.customer.count({ where: { hairstylistId: stylistId, isDeleted: 0 } }),
this.prisma.customer.count({
where: {
hairstylistId: stylistId,
isDeleted: 0,
planDate: { gte: startOfDay, lt: endOfDay },
},
}),
this.prisma.customer.count({
where: {
hairstylistId: stylistId,
isDeleted: 0,
nextFollowDate: { not: null, lte: startOfDay },
},
}),
this.prisma.aiHairSimulation.findMany({
where: {
hairstylistId: stylistId,
isDeleted: 0,
customerId: { not: null },
},
select: { customerId: true },
}),
]);
Update backend/src/modules/customers/customers.controller.ts with:
import { Body, Controller, Get, Post, Query, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CustomerUpsertDto } from './dto/customer-upsert.dto';
import { ListCustomersQueryDto } from './dto/list-customers-query.dto';
import { CustomersService } from './customers.service';
type AuthenticatedRequest = Request & {
user: {
stylistId: string;
};
};
@UseGuards(JwtAuthGuard)
@Controller('customers')
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}
@Get()
list(@Req() request: AuthenticatedRequest, @Query() query: ListCustomersQueryDto) {
return this.customersService.list(BigInt(request.user.stylistId), query);
}
@Get('summary')
summary(@Req() request: AuthenticatedRequest) {
return this.customersService.getSummary(BigInt(request.user.stylistId));
}
@Post()
create(@Req() request: AuthenticatedRequest, @Body() body: CustomerUpsertDto) {
return this.customersService.create(BigInt(request.user.stylistId), body);
}
}
Add to backend/test/app.e2e-spec.ts:
it('/api/v1/customers (POST) creates a customer using the redesigned fields', async () => {
const token = await jwtService.signAsync({
sub: 1,
stylistId: '1',
email: 'stylist@example.com',
username: 'Ogawa',
});
const response = await request(app.getHttpServer())
.post('/api/v1/customers')
.set('Authorization', `Bearer ${token}`)
.send({
name: 'Sato Misaki',
namePronunciation: 'さとう みさき',
phone: '090-0000-0000',
expectContact: 'LINE',
hairLength: 'MEDIUM',
hairTexture: 'NORMAL',
planDate: '2026-06-28',
nextFollowDate: '2026-07-12',
visitPurpose: 'CONSULT_SIM',
trouble: '发量少',
consultContent: '希望有轻盈感',
expectLook: '自然层次感',
})
.expect(201);
expect(response.body.ok).toBe(true);
expect(response.body.data.namePronunciation).toBe('さとう みさき');
expect(response.body.data.avatar).toBe(
'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
);
});
it('/api/v1/customers (GET) returns paginated customers using plan_date semantics', async () => {
const token = await jwtService.signAsync({
sub: 1,
stylistId: '1',
email: 'stylist@example.com',
username: 'Ogawa',
});
const response = await request(app.getHttpServer())
.get('/api/v1/customers?page=1&pageSize=20&q=Sato&visitStatus=TODAY&hairLength=MEDIUM')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body.ok).toBe(true);
expect(response.body.data.page).toBe(1);
expect(response.body.data.pageSize).toBe(20);
expect(Array.isArray(response.body.data.items)).toBe(true);
});
Run: npm test -- customers.service.spec.ts app.config.spec.ts --runInBand
Expected: PASS.
Run: npm run test:e2e -- app.e2e-spec.ts
Expected: PASS.
[ ] Step 9: Commit the backend redesign
git add backend/src/modules/customers backend/test/app.e2e-spec.ts
git commit -m "feat: rebuild customer backend around db schema"
Files:
frontend/src/services/api/contracts.tsfrontend/src/services/api/customers.tsfrontend/src/shared/customerForm.tsfrontend/src/pages/dashboard/customers/CustomerFormPage.tsxfrontend/src/pages/dashboard/customers/CustomerListPage.tsxfrontend/src/pages/dashboard/customers/__tests__/CustomerFormPage.test.tsxfrontend/src/pages/dashboard/customers/__tests__/CustomerListPage.test.tsxfrontend/src/i18n/locales/zh/translation.jsonfrontend/src/i18n/locales/ja/translation.jsonfrontend/public/css/aitori.cssInterfaces:
CustomerEntity aligned with avatar, namePronunciation, expectContact, hairTexture, nextFollowDate, recommendVisitDate, planDate, followStateCustomerUpsertDtoProduces: list UI and filters aligned with plan_date, derived follow state, and always-visible pagination
[ ] Step 1: Write failing frontend tests for the redesigned form payload and list semantics
Update frontend/src/pages/dashboard/customers/__tests__/CustomerFormPage.test.tsx with:
it('submits the redesigned customer payload', async () => {
createCustomer.mockResolvedValue({ id: '1' });
render(
<HelmetProvider>
<MemoryRouter>
<CustomerFormPage />
</MemoryRouter>
</HelmetProvider>,
);
fireEvent.change(screen.getByLabelText(/姓名/i), { target: { value: 'Sato Misaki' } });
fireEvent.change(screen.getByLabelText(/电话号码/i), { target: { value: '090-0000-0000' } });
fireEvent.click(screen.getAllByRole('button', { name: /登记顾客/i })[0]);
await waitFor(() => {
expect(createCustomer).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Sato Misaki',
phone: '090-0000-0000',
}),
);
});
});
Update frontend/src/pages/dashboard/customers/__tests__/CustomerListPage.test.tsx to assert:
expect(screen.getByText('待设置')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '上一页' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '下一页' })).toBeInTheDocument();
Run: npm test -- CustomerFormPage.test.tsx CustomerListPage.test.tsx
Expected: FAIL because frontend contracts and page logic still depend on the old customer model.
Update frontend/src/services/api/contracts.ts with:
export type CustomerEntity = {
id: string
name: string
namePronunciation?: string
phone: string
email?: string
birthday?: string
avatar?: string
expectContact?: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE'
hairLength?: 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM'
hairTexture?: 'UNCONFIRM' | 'THIN' | 'NORMAL' | 'HARD_CRUDE' | 'CURLY'
notice?: string
allergy?: string
innerRemark?: string
nextFollowDate?: string
recommendVisitDate?: string
firstSuggestHair?: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT'
planDate?: string
isFirstRegister?: boolean
visitPurpose?: 'CUT_DYE' | 'CUT' | 'DYE' | 'CONSULT_SIM'
trouble?: string
consultContent?: string
expectLook?: string
followState?: 'NEED_FOLLOW_UP' | 'NO_NEED_FOLLOW_UP'
}
export type PaginatedCustomerList = {
items: CustomerEntity[]
total: number
page: number
pageSize: number
}
Update frontend/src/services/api/customers.ts with:
export type CustomerUpsertInput = {
name: string
namePronunciation?: string
phone: string
email?: string
birthday?: string
avatar?: string
expectContact?: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE'
hairLength?: 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM'
hairTexture?: 'UNCONFIRM' | 'THIN' | 'NORMAL' | 'HARD_CRUDE' | 'CURLY'
notice?: string
allergy?: string
innerRemark?: string
nextFollowDate?: string
recommendVisitDate?: string
firstSuggestHair?: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT'
planDate?: string
isFirstRegister?: boolean
visitPurpose?: 'CUT_DYE' | 'CUT' | 'DYE' | 'CONSULT_SIM'
trouble?: string
consultContent?: string
expectLook?: string
}
export async function listCustomers(
params: {
page?: number
pageSize?: number
q?: string
visitStatus?: string
followStatus?: string
hairLength?: string
} = {},
): Promise<PaginatedCustomerList> {
const query = new URLSearchParams()
if (params.page) query.set('page', String(params.page))
if (params.pageSize) query.set('pageSize', String(params.pageSize))
if (params.q) query.set('q', params.q)
if (params.visitStatus) query.set('visitStatus', params.visitStatus)
if (params.followStatus) query.set('followStatus', params.followStatus)
if (params.hairLength) query.set('hairLength', params.hairLength)
const qs = query.toString()
return http.get<PaginatedCustomerList>(`/customers${qs ? `?${qs}` : ''}`)
}
Update frontend/src/shared/customerForm.ts with:
export type CustomerFormValues = {
name: string
namePronunciation: string
phone: string
email: string
birthday: string
expectContact: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE'
hairLength: 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM'
hairTexture: 'UNCONFIRM' | 'THIN' | 'NORMAL' | 'HARD_CRUDE' | 'CURLY'
notice: string
allergy: string
innerRemark: string
nextFollowDate: string
recommendVisitDate: string
firstSuggestHair: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT'
planDate: string
isFirstRegister: boolean
visitPurpose: 'CUT_DYE' | 'CUT' | 'DYE' | 'CONSULT_SIM' | ''
trouble: string
consultContent: string
expectLook: string
}
export function emptyCustomerForm(): CustomerFormValues {
return {
name: '',
namePronunciation: '',
phone: '',
email: '',
birthday: '',
expectContact: 'NONE',
hairLength: 'UNCONFIRM',
hairTexture: 'UNCONFIRM',
notice: '',
allergy: '',
innerRemark: '',
nextFollowDate: '',
recommendVisitDate: '',
firstSuggestHair: 'AUTO',
planDate: '',
isFirstRegister: false,
visitPurpose: '',
trouble: '',
consultContent: '',
expectLook: '',
}
}
export function formValuesToPayload(values: CustomerFormValues, avatar?: string): CustomerUpsertInput {
const pick = (value: string) => (value.trim() ? value.trim() : undefined)
return {
name: values.name.trim(),
namePronunciation: pick(values.namePronunciation),
phone: values.phone.trim(),
email: pick(values.email),
birthday: pick(values.birthday),
avatar,
expectContact: values.expectContact,
hairLength: values.hairLength,
hairTexture: values.hairTexture,
notice: pick(values.notice),
allergy: pick(values.allergy),
innerRemark: pick(values.innerRemark),
nextFollowDate: pick(values.nextFollowDate),
recommendVisitDate: pick(values.recommendVisitDate),
firstSuggestHair: values.firstSuggestHair,
planDate: pick(values.planDate),
isFirstRegister: values.isFirstRegister,
visitPurpose: values.visitPurpose || undefined,
trouble: pick(values.trouble),
consultContent: pick(values.consultContent),
expectLook: pick(values.expectLook),
}
}
Update the submit block in frontend/src/pages/dashboard/customers/CustomerFormPage.tsx to:
const handleSubmit: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault()
if (!form.name || !form.phone || submitting) return
setError('')
setSubmitting(true)
try {
let avatar: string | undefined
if (photoFile) avatar = (await uploadMedia(photoFile, 'avatar')).url
const payload = formValuesToPayload(form, avatar)
await createCustomer(payload)
navigate(path('/customers'))
} catch (err) {
setError(getSubmitErrorMessage(err, locale))
} finally {
setSubmitting(false)
}
}
Bind visible fields to namePronunciation, expectContact, hairTexture, nextFollowDate, recommendVisitDate, planDate, visitPurpose, trouble, consultContent, and expectLook, and remove legacy submit payload usage of lineStatus, lastVisit, nextVisit, visitCycle, hairType, hairCondition, concern, desiredImage, formulaNote, privateNote, followStatus, followTiming, and followMessage.
Update frontend/src/pages/dashboard/customers/CustomerListPage.tsx with:
const VISIT_STATUS_API_MAP: Record<string, string | undefined> = {
全部: undefined,
今天到店: 'TODAY',
本周到店: 'THIS_WEEK',
全て: undefined,
本日来店: 'TODAY',
今週来店: 'THIS_WEEK',
}
const FOLLOW_STATUS_API_MAP: Record<string, string | undefined> = {
全部: undefined,
需跟进: 'NEED_FOLLOW_UP',
无需跟进: 'NO_NEED_FOLLOW_UP',
全て: undefined,
要フォロー: 'NEED_FOLLOW_UP',
フォロー不要: 'NO_NEED_FOLLOW_UP',
}
const HAIR_LENGTH_API_MAP: Record<string, string | undefined> = {
全部: undefined,
短发: 'SHORT',
中长发: 'MEDIUM',
长发: 'LONG',
未确认: 'UNCONFIRM',
全て: undefined,
ショート: 'SHORT',
ミディアム: 'MEDIUM',
ロング: 'LONG',
未設定: 'UNCONFIRM',
}
Render list rows with:
<span>{customer.planDate || '—'}</span>
<span>{customer.trouble || '—'}</span>
<span>
<span className="workbench-badge">
{customer.followState === 'NEED_FOLLOW_UP'
? pageCopy.statusMap.needFollowUp
: pageCopy.statusMap.noNeedFollowUp}
</span>
</span>
Keep the pager block and always render it using Math.max(1, Math.ceil(total / pageSize)).
Update frontend/src/i18n/locales/zh/translation.json with:
"followOptions": ["全部", "需跟进", "无需跟进"],
"styleOptions": ["全部", "短发", "中长发", "长发", "未确认"],
"statusMap": { "needFollowUp": "需跟进", "noNeedFollowUp": "待设置" },
"pagination": { "prev": "上一页", "next": "下一页" }
Update frontend/src/i18n/locales/ja/translation.json with:
"followOptions": ["すべて", "要フォロー", "フォロー不要"],
"styleOptions": ["すべて", "ショート", "ミディアム", "ロング", "未設定"],
"statusMap": { "needFollowUp": "要フォロー", "noNeedFollowUp": "未設定" },
"pagination": { "prev": "前へ", "next": "次へ" }
Keep frontend/public/css/aitori.css row layout as:
.customer-row {
min-width: 760px;
display: grid;
grid-template-columns: 260px 140px minmax(180px, 1fr) 120px;
gap: 16px;
align-items: center;
padding: 16px 0;
}
Run: npm test -- CustomerFormPage.test.tsx CustomerListPage.test.tsx customers.test.ts App.test.tsx
Expected: PASS.
[ ] Step 9: Commit the frontend redesign
git add frontend/src/services/api/contracts.ts frontend/src/services/api/customers.ts frontend/src/shared/customerForm.ts frontend/src/pages/dashboard/customers frontend/src/i18n/locales/zh/translation.json frontend/src/i18n/locales/ja/translation.json frontend/public/css/aitori.css
git commit -m "feat: rebuild customer frontend around db schema"
Files:
backend/docs/数据库设计.mddocs/superpowers/specs/2026-06-28-customer-module-create-list-summary-design.md only if implementation reveals a spec correction is neededdocs/superpowers/plans/2026-06-28-customer-module-create-list-summary.md only if implementation-driven corrections are neededbackend/src/modules/customers/**frontend/src/pages/dashboard/customers/**Interfaces:
Produces: final verified branch state with docs that no longer describe the incorrect customer model
[ ] Step 1: Rewrite backend/docs/数据库设计.md so the customer section matches the real implementation
Replace the customer table block so it matches the final implemented structure, including hairstylist_id, and remove any wording that suggests the old incorrect model is still relevant.
Use this SQL block as the baseline:
CREATE TABLE `customer` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '顾客主键ID',
`hairstylist_id` BIGINT UNSIGNED NOT NULL COMMENT '所属发型师ID,JWT 隔离必需字段',
`name` VARCHAR(30) NOT NULL COMMENT '顾客姓名(必填)',
`name_pronunciation` VARCHAR(50) DEFAULT '' COMMENT '姓名读音',
`phone` VARCHAR(20) NOT NULL COMMENT '联系电话(必填)',
`email` VARCHAR(100) DEFAULT '' COMMENT '邮箱',
`birthday` DATE NULL COMMENT '生日',
`avatar` VARCHAR(500) DEFAULT '' COMMENT '顾客头像图片URL地址',
`expect_contact` ENUM('LINE','PHONE','EMAIL','NONE') NOT NULL DEFAULT 'NONE' COMMENT '期望联系方式:LINE/电话/邮箱/未设置',
`hair_length` ENUM('SHORT','MEDIUM','LONG','UNCONFIRM') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '当前头发长度:短发/中长发/长发/未确认',
`hair_texture` ENUM('UNCONFIRM','THIN','NORMAL','HARD_CRUDE','CURLY') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '发质:未确认/细/普通/粗硬/有自然卷',
`notice` TEXT NULL COMMENT '发型护理注意事项',
`allergy` VARCHAR(500) DEFAULT '' COMMENT '过敏史/过敏产品',
`inner_remark` TEXT NULL COMMENT '门店内部备注(仅内部查看)',
`next_follow_date` DATE NULL COMMENT '下次跟进日期',
`recommend_visit_date` DATE NULL COMMENT '推荐到店日',
`first_suggest_hair` ENUM('AUTO','LONG','MEDIUM','SHORT') NOT NULL DEFAULT 'AUTO' COMMENT '首次建议发型模板:自动推荐/长发/中长发/短发',
`plan_date` DATE NULL COMMENT '到店计划日',
`is_first_register` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否首次登记:0否,1是',
`visit_purpose` ENUM('CUT_DYE','CUT','DYE','CONSULT_SIM') NULL COMMENT '到店目的:剪发+染发/剪发/染发/模拟咨询',
`trouble` TEXT NULL COMMENT '发质/发型烦恼',
`consult_content` TEXT NULL COMMENT '详细咨询内容',
`expect_look` TEXT NULL COMMENT '期望发型形象描述',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除 0正常 1删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_phone_del` (`phone`,`is_deleted`) COMMENT '手机号唯一,删除后可重录',
KEY `idx_hairstylist_is_deleted` (`hairstylist_id`,`is_deleted`) COMMENT '按发型师隔离顾客数据',
KEY `idx_is_deleted` (`is_deleted`) COMMENT '逻辑删除筛选',
KEY `idx_next_follow` (`next_follow_date`,`is_deleted`) COMMENT '跟进日期筛选索引',
KEY `idx_recommend_visit` (`recommend_visit_date`,`is_deleted`) COMMENT '推荐到店日期索引',
KEY `idx_plan_date` (`plan_date`,`is_deleted`) COMMENT '到店计划日筛选索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='顾客信息+咨询登记表(合并单表)';
Run from backend:
npm run lint
npm test -- app.config.spec.ts customers.service.spec.ts --runInBand
npm run test:e2e -- app.e2e-spec.ts
Expected: PASS.
Run from frontend:
npm test -- customers.test.ts CustomerListPage.test.tsx CustomerFormPage.test.tsx App.test.tsx
Expected: PASS.
Check diagnostics for:
backend/src/modules/customers/customers.service.ts
backend/src/modules/customers/customers.controller.ts
backend/src/modules/customers/dto/customer-upsert.dto.ts
backend/src/modules/customers/dto/list-customers-query.dto.ts
frontend/src/shared/customerForm.ts
frontend/src/pages/dashboard/customers/CustomerFormPage.tsx
frontend/src/pages/dashboard/customers/CustomerListPage.tsx
frontend/src/services/api/customers.ts
backend/docs/数据库设计.md
Expected: no newly introduced blocking diagnostics.
Confirm these outcomes:
- Customer schema matches 数据库设计 customer block plus hairstylist_id
- Customer create uses avatar fallback and JWT hairstylist isolation
- Customer list uses plan_date for visit filters
- Customer follow state is derived from next_follow_date, not stored as a legacy enum
- Customer list no longer offers unsupported 30-day-no-visit semantics
- Customer pager always renders
- Outdated customer docs are replaced, not preserved alongside the new model
Expected: all items confirmed.
[ ] Step 5: Commit the verified redesign and doc replacement
git add backend/docs/数据库设计.md docs/superpowers/specs/2026-06-28-customer-module-create-list-summary-design.md docs/superpowers/plans/2026-06-28-customer-module-create-list-summary.md backend/src/modules/customers frontend/src/pages/dashboard/customers frontend/src/services/api frontend/src/shared/customerForm.ts frontend/src/i18n/locales/zh/translation.json frontend/src/i18n/locales/ja/translation.json frontend/public/css/aitori.css
git commit -m "feat: redesign customer module around database schema"