- pnpm workspace with packages/app, packages/server, packages/shared - @mp-pilates/shared: enums, constants, TypeScript interfaces for all 8 data models - @mp-pilates/server: NestJS bootstrap with health check, validation pipe, CORS - Base TypeScript config with strict mode
1121 lines
36 KiB
Markdown
1121 lines
36 KiB
Markdown
# 普拉提私教约课小程序 — 实施计划
|
|
|
|
> **Spec**: [2026-04-02-pilates-booking-miniprogram-design.md](../specs/2026-04-02-pilates-booking-miniprogram-design.md)
|
|
> **Created**: 2026-04-02
|
|
|
|
---
|
|
|
|
## Phase 1: Monorepo 基础设施
|
|
|
|
### Task 1.1: 初始化 pnpm monorepo
|
|
|
|
**Files to create/modify:**
|
|
- `package.json` (root)
|
|
- `pnpm-workspace.yaml`
|
|
- `tsconfig.base.json`
|
|
- `.gitignore`
|
|
- `.nvmrc`
|
|
- `.npmrc`
|
|
|
|
**Steps:**
|
|
1. Create root `package.json`:
|
|
```json
|
|
{
|
|
"name": "mp-pilates",
|
|
"private": true,
|
|
"scripts": {
|
|
"dev:server": "pnpm --filter @mp-pilates/server dev",
|
|
"dev:app": "pnpm --filter @mp-pilates/app dev:mp-weixin",
|
|
"build:shared": "pnpm --filter @mp-pilates/shared build",
|
|
"build:server": "pnpm --filter @mp-pilates/server build",
|
|
"build:app": "pnpm --filter @mp-pilates/app build:mp-weixin",
|
|
"test": "pnpm -r test",
|
|
"lint": "pnpm -r lint"
|
|
},
|
|
"engines": {
|
|
"node": ">=18",
|
|
"pnpm": ">=8"
|
|
}
|
|
}
|
|
```
|
|
|
|
2. Create `pnpm-workspace.yaml`:
|
|
```yaml
|
|
packages:
|
|
- 'packages/*'
|
|
```
|
|
|
|
3. Create `tsconfig.base.json` with shared compiler options (strict, ESNext, paths for `@mp-pilates/shared`).
|
|
|
|
4. Create `.gitignore` (node_modules, dist, .env*, .DS_Store, *.local, dist-ssr).
|
|
|
|
5. Create `.nvmrc` with `20`.
|
|
|
|
6. Create `.npmrc` with `shamefully-hoist=true` (needed for uni-app).
|
|
|
|
**Verify:** `pnpm install` runs without error from root.
|
|
|
|
**Commit:** `chore: initialize pnpm monorepo structure`
|
|
|
|
---
|
|
|
|
### Task 1.2: 创建 shared 包
|
|
|
|
**Files to create:**
|
|
- `packages/shared/package.json`
|
|
- `packages/shared/tsconfig.json`
|
|
- `packages/shared/src/index.ts`
|
|
- `packages/shared/src/enums.ts`
|
|
- `packages/shared/src/constants.ts`
|
|
- `packages/shared/src/types/index.ts`
|
|
- `packages/shared/src/types/user.ts`
|
|
- `packages/shared/src/types/card-type.ts`
|
|
- `packages/shared/src/types/membership.ts`
|
|
- `packages/shared/src/types/time-slot.ts`
|
|
- `packages/shared/src/types/booking.ts`
|
|
- `packages/shared/src/types/order.ts`
|
|
- `packages/shared/src/types/studio.ts`
|
|
- `packages/shared/src/types/week-template.ts`
|
|
- `packages/shared/src/types/api.ts`
|
|
|
|
**Steps:**
|
|
|
|
1. Create `packages/shared/package.json`:
|
|
```json
|
|
{
|
|
"name": "@mp-pilates/shared",
|
|
"version": "0.1.0",
|
|
"main": "dist/index.js",
|
|
"types": "dist/index.d.ts",
|
|
"scripts": {
|
|
"build": "tsc",
|
|
"dev": "tsc --watch"
|
|
}
|
|
}
|
|
```
|
|
|
|
2. Define enums in `src/enums.ts`:
|
|
```typescript
|
|
export enum UserRole { MEMBER = 'MEMBER', ADMIN = 'ADMIN' }
|
|
export enum CardTypeCategory { TIMES = 'TIMES', DURATION = 'DURATION', TRIAL = 'TRIAL' }
|
|
export enum MembershipStatus { ACTIVE = 'ACTIVE', EXPIRED = 'EXPIRED', USED_UP = 'USED_UP' }
|
|
export enum TimeSlotStatus { OPEN = 'OPEN', FULL = 'FULL', CLOSED = 'CLOSED' }
|
|
export enum TimeSlotSource { TEMPLATE = 'TEMPLATE', MANUAL = 'MANUAL' }
|
|
export enum BookingStatus { CONFIRMED = 'CONFIRMED', CANCELLED = 'CANCELLED', COMPLETED = 'COMPLETED', NO_SHOW = 'NO_SHOW' }
|
|
export enum OrderStatus { PENDING = 'PENDING', PAID = 'PAID', REFUNDED = 'REFUNDED' }
|
|
```
|
|
|
|
3. Define constants in `src/constants.ts`:
|
|
```typescript
|
|
export const DEFAULT_CANCEL_HOURS_LIMIT = 2
|
|
export const DEFAULT_SLOT_CAPACITY = 1
|
|
export const SLOT_GENERATION_DAYS = 14
|
|
export const TIME_PERIODS = { MORNING: { start: '06:00', end: '12:00' }, AFTERNOON: { start: '12:00', end: '18:00' }, EVENING: { start: '18:00', end: '22:00' } } as const
|
|
```
|
|
|
|
4. Define TypeScript interfaces for all data models in `src/types/` — one file per model, matching the spec's data model section. Each interface has both the full model (`User`) and creation DTO (`CreateUserDto`), response DTO (`UserResponse`).
|
|
|
|
5. Define API response types in `src/types/api.ts`:
|
|
```typescript
|
|
export interface ApiResponse<T> {
|
|
readonly success: boolean
|
|
readonly data: T | null
|
|
readonly message: string | null
|
|
}
|
|
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
|
|
readonly total: number
|
|
readonly page: number
|
|
readonly limit: number
|
|
}
|
|
```
|
|
|
|
6. Re-export all from `src/index.ts`.
|
|
|
|
**Verify:** `cd packages/shared && pnpm build` compiles without error and produces `dist/`.
|
|
|
|
**Commit:** `feat(shared): add shared types, enums, and constants`
|
|
|
|
---
|
|
|
|
## Phase 2: 后端基础
|
|
|
|
### Task 2.1: 初始化 NestJS 服务端
|
|
|
|
**Files to create:**
|
|
- `packages/server/package.json`
|
|
- `packages/server/tsconfig.json`
|
|
- `packages/server/tsconfig.build.json`
|
|
- `packages/server/nest-cli.json`
|
|
- `packages/server/src/main.ts`
|
|
- `packages/server/src/app.module.ts`
|
|
- `packages/server/src/app.controller.ts` (health check)
|
|
- `packages/server/.env.example`
|
|
- `packages/server/.env`
|
|
|
|
**Steps:**
|
|
1. Create `package.json` with dependencies:
|
|
- `@nestjs/core`, `@nestjs/common`, `@nestjs/platform-express`
|
|
- `@nestjs/config`, `@nestjs/jwt`, `@nestjs/schedule`
|
|
- `@prisma/client`, `prisma`
|
|
- `@mp-pilates/shared` (workspace reference)
|
|
- Dev: `@nestjs/testing`, `jest`, `ts-jest`, `@types/jest`, `supertest`
|
|
|
|
2. Create NestJS bootstrap in `src/main.ts`:
|
|
- Global validation pipe (`class-validator`, `class-transformer`)
|
|
- CORS config
|
|
- Global prefix `/api`
|
|
- Port from env (default 3000)
|
|
|
|
3. Create `AppModule` with `ConfigModule.forRoot()`, health check endpoint.
|
|
|
|
4. Create `.env.example`:
|
|
```
|
|
DATABASE_URL=postgresql://user:pass@localhost:5432/mp_pilates
|
|
JWT_SECRET=change-me
|
|
WX_APPID=your-appid
|
|
WX_SECRET=your-secret
|
|
WX_MCH_ID=your-mch-id
|
|
WX_MCH_KEY=your-mch-key
|
|
PORT=3000
|
|
```
|
|
|
|
**Verify:** `cd packages/server && pnpm dev` starts the server, `curl localhost:3000/api/health` returns 200.
|
|
|
|
**Commit:** `feat(server): initialize NestJS project with health check`
|
|
|
|
---
|
|
|
|
### Task 2.2: Prisma schema 和数据库
|
|
|
|
**Files to create:**
|
|
- `packages/server/prisma/schema.prisma`
|
|
- `packages/server/src/prisma/prisma.module.ts`
|
|
- `packages/server/src/prisma/prisma.service.ts`
|
|
|
|
**Steps:**
|
|
|
|
1. Write `prisma/schema.prisma` with all 8 models from spec:
|
|
|
|
```prisma
|
|
generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
enum UserRole {
|
|
MEMBER
|
|
ADMIN
|
|
}
|
|
|
|
enum CardTypeCategory {
|
|
TIMES
|
|
DURATION
|
|
TRIAL
|
|
}
|
|
|
|
enum MembershipStatus {
|
|
ACTIVE
|
|
EXPIRED
|
|
USED_UP
|
|
}
|
|
|
|
enum TimeSlotStatus {
|
|
OPEN
|
|
FULL
|
|
CLOSED
|
|
}
|
|
|
|
enum TimeSlotSource {
|
|
TEMPLATE
|
|
MANUAL
|
|
}
|
|
|
|
enum BookingStatus {
|
|
CONFIRMED
|
|
CANCELLED
|
|
COMPLETED
|
|
NO_SHOW
|
|
}
|
|
|
|
enum OrderStatus {
|
|
PENDING
|
|
PAID
|
|
REFUNDED
|
|
}
|
|
|
|
model User {
|
|
id String @id @default(uuid())
|
|
openid String @unique
|
|
unionid String?
|
|
phone String?
|
|
nickname String @default("")
|
|
avatarUrl String?
|
|
role UserRole @default(MEMBER)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
memberships Membership[]
|
|
bookings Booking[]
|
|
orders Order[]
|
|
|
|
@@map("users")
|
|
}
|
|
|
|
model CardType {
|
|
id String @id @default(uuid())
|
|
name String
|
|
type CardTypeCategory
|
|
totalTimes Int?
|
|
durationDays Int
|
|
price Decimal @db.Decimal(10, 0)
|
|
originalPrice Decimal? @db.Decimal(10, 0)
|
|
description String?
|
|
isActive Boolean @default(true)
|
|
sortOrder Int @default(0)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
memberships Membership[]
|
|
orders Order[]
|
|
|
|
@@map("card_types")
|
|
}
|
|
|
|
model Membership {
|
|
id String @id @default(uuid())
|
|
userId String
|
|
cardTypeId String
|
|
remainingTimes Int?
|
|
startDate DateTime
|
|
expireDate DateTime
|
|
status MembershipStatus @default(ACTIVE)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
user User @relation(fields: [userId], references: [id])
|
|
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
|
bookings Booking[]
|
|
|
|
@@map("memberships")
|
|
}
|
|
|
|
model WeekTemplate {
|
|
id String @id @default(uuid())
|
|
dayOfWeek Int
|
|
startTime String
|
|
endTime String
|
|
capacity Int @default(1)
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
timeSlots TimeSlot[]
|
|
|
|
@@map("week_templates")
|
|
}
|
|
|
|
model TimeSlot {
|
|
id String @id @default(uuid())
|
|
date DateTime @db.Date
|
|
startTime String
|
|
endTime String
|
|
capacity Int @default(1)
|
|
bookedCount Int @default(0)
|
|
status TimeSlotStatus @default(OPEN)
|
|
source TimeSlotSource @default(TEMPLATE)
|
|
templateId String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
template WeekTemplate? @relation(fields: [templateId], references: [id])
|
|
bookings Booking[]
|
|
|
|
@@unique([date, startTime, endTime])
|
|
@@map("time_slots")
|
|
}
|
|
|
|
model Booking {
|
|
id String @id @default(uuid())
|
|
userId String
|
|
timeSlotId String
|
|
membershipId String
|
|
status BookingStatus @default(CONFIRMED)
|
|
cancelledAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
user User @relation(fields: [userId], references: [id])
|
|
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
|
|
membership Membership @relation(fields: [membershipId], references: [id])
|
|
|
|
@@unique([userId, timeSlotId])
|
|
@@map("bookings")
|
|
}
|
|
|
|
model Order {
|
|
id String @id @default(uuid())
|
|
userId String
|
|
cardTypeId String
|
|
orderNo String @unique
|
|
amount Decimal @db.Decimal(10, 0)
|
|
status OrderStatus @default(PENDING)
|
|
wxTransactionId String?
|
|
paidAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
user User @relation(fields: [userId], references: [id])
|
|
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
|
|
|
@@map("orders")
|
|
}
|
|
|
|
model StudioConfig {
|
|
id String @id @default(uuid())
|
|
name String
|
|
logo String?
|
|
bannerUrl String?
|
|
address String @default("")
|
|
phone String @default("")
|
|
latitude Decimal? @db.Decimal(10, 7)
|
|
longitude Decimal? @db.Decimal(10, 7)
|
|
cancelHoursLimit Int @default(2)
|
|
photos Json @default("[]")
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@map("studio_config")
|
|
}
|
|
```
|
|
|
|
2. Create `PrismaService` extending `PrismaClient` with `onModuleInit` / `onModuleDestroy`.
|
|
|
|
3. Create `PrismaModule` as a global module.
|
|
|
|
4. Add Prisma scripts to server package.json:
|
|
```json
|
|
"prisma:generate": "prisma generate",
|
|
"prisma:migrate": "prisma migrate dev",
|
|
"prisma:seed": "ts-node prisma/seed.ts"
|
|
```
|
|
|
|
**Verify:**
|
|
```bash
|
|
cd packages/server
|
|
pnpm prisma generate
|
|
pnpm prisma migrate dev --name init
|
|
```
|
|
Database created with all tables.
|
|
|
|
**Commit:** `feat(server): add Prisma schema with all data models`
|
|
|
|
---
|
|
|
|
### Task 2.3: 认证模块 (Auth)
|
|
|
|
**Files to create:**
|
|
- `packages/server/src/auth/auth.module.ts`
|
|
- `packages/server/src/auth/auth.controller.ts`
|
|
- `packages/server/src/auth/auth.service.ts`
|
|
- `packages/server/src/auth/jwt.strategy.ts`
|
|
- `packages/server/src/auth/jwt-auth.guard.ts`
|
|
- `packages/server/src/auth/roles.guard.ts`
|
|
- `packages/server/src/auth/roles.decorator.ts`
|
|
- `packages/server/src/auth/dto/login.dto.ts`
|
|
- `packages/server/src/auth/dto/bind-phone.dto.ts`
|
|
- `packages/server/src/auth/wechat.service.ts`
|
|
- `packages/server/src/auth/__tests__/auth.service.spec.ts`
|
|
- `packages/server/src/auth/__tests__/auth.controller.spec.ts`
|
|
|
|
**Steps:**
|
|
|
|
1. `WechatService`: encapsulates `code2session` API call to exchange wx code for openid/session_key. Mock-able for tests.
|
|
|
|
2. `AuthService`:
|
|
- `login(code: string)`: call WechatService → find or create User → sign JWT with `{ sub: user.id, role: user.role }`
|
|
- `bindPhone(userId: string, encryptedData: string, iv: string)`: decrypt phone → update user
|
|
|
|
3. `AuthController`:
|
|
- `POST /auth/login` → `{ code }` → returns `{ token, user }`
|
|
- `POST /auth/phone` → `{ encryptedData, iv }` → returns updated user (requires auth)
|
|
|
|
4. `JwtStrategy` + `JwtAuthGuard`: standard Passport JWT strategy.
|
|
|
|
5. `RolesGuard` + `@Roles()` decorator: check `user.role` against required roles.
|
|
|
|
**Tests (TDD):**
|
|
- Auth service: mock WechatService + PrismaService, test login creates user, returns JWT
|
|
- Auth service: test login with existing user returns existing user
|
|
- Auth controller: e2e test with supertest
|
|
|
|
**Verify:** `cd packages/server && pnpm test -- --testPathPattern=auth`
|
|
|
|
**Commit:** `feat(server): add auth module with WeChat login and JWT`
|
|
|
|
---
|
|
|
|
### Task 2.4: 用户模块 (User)
|
|
|
|
**Files to create:**
|
|
- `packages/server/src/user/user.module.ts`
|
|
- `packages/server/src/user/user.controller.ts`
|
|
- `packages/server/src/user/user.service.ts`
|
|
- `packages/server/src/user/__tests__/user.service.spec.ts`
|
|
|
|
**Steps:**
|
|
1. `UserService`:
|
|
- `getProfile(userId)`: return user with active memberships count
|
|
- `updateProfile(userId, dto)`: update nickname/avatarUrl (immutable — return new object)
|
|
- `getStats(userId)`: query bookings for training stats (total count, this month count, total days, this month days, this month hours)
|
|
|
|
2. `UserController` (all routes require auth):
|
|
- `GET /user/profile`
|
|
- `PUT /user/profile`
|
|
- `GET /user/stats`
|
|
|
|
**Tests:** Mock Prisma, verify stats calculation, verify profile update returns new data.
|
|
|
|
**Verify:** `pnpm test -- --testPathPattern=user`
|
|
|
|
**Commit:** `feat(server): add user module with profile and stats`
|
|
|
|
---
|
|
|
|
### Task 2.5: 工作室信息模块 (Studio)
|
|
|
|
**Files to create:**
|
|
- `packages/server/src/studio/studio.module.ts`
|
|
- `packages/server/src/studio/studio.controller.ts`
|
|
- `packages/server/src/studio/studio.service.ts`
|
|
- `packages/server/src/studio/__tests__/studio.service.spec.ts`
|
|
- `packages/server/prisma/seed.ts`
|
|
|
|
**Steps:**
|
|
1. `StudioService`:
|
|
- `getInfo()`: return the single StudioConfig record (create default if not exists)
|
|
- `updateInfo(dto)`: update studio config (ADMIN only)
|
|
|
|
2. `StudioController`:
|
|
- `GET /studio/info` — public
|
|
- `PUT /admin/studio/info` — ADMIN only
|
|
|
|
3. Seed script: create default StudioConfig record.
|
|
|
|
**Tests:** Verify get creates default, verify update returns new record.
|
|
|
|
**Verify:** `pnpm test -- --testPathPattern=studio`
|
|
|
|
**Commit:** `feat(server): add studio module with config management`
|
|
|
|
---
|
|
|
|
### Task 2.6: 卡种与会员卡模块 (Membership)
|
|
|
|
**Files to create:**
|
|
- `packages/server/src/membership/membership.module.ts`
|
|
- `packages/server/src/membership/membership.controller.ts`
|
|
- `packages/server/src/membership/membership.service.ts`
|
|
- `packages/server/src/membership/dto/create-card-type.dto.ts`
|
|
- `packages/server/src/membership/dto/update-card-type.dto.ts`
|
|
- `packages/server/src/membership/__tests__/membership.service.spec.ts`
|
|
|
|
**Steps:**
|
|
1. `MembershipService`:
|
|
- `getActiveCardTypes()`: list card types where `isActive=true`, ordered by `sortOrder`
|
|
- `getUserMemberships(userId)`: user's memberships with cardType info
|
|
- `getValidMembership(userId)`: find first ACTIVE membership with remaining times/valid date
|
|
- `deductMembership(membershipId)`: decrement `remainingTimes` (TIMES/TRIAL) or noop (DURATION), mark USED_UP if times=0
|
|
- `restoreMembership(membershipId)`: increment `remainingTimes` for cancellation refund, restore status from USED_UP to ACTIVE
|
|
- Admin CRUD for card types
|
|
|
|
2. `MembershipController`:
|
|
- `GET /membership/card-types` — public
|
|
- `GET /membership/my` — authed
|
|
- `GET /admin/card-types`, `POST`, `PUT :id`, `DELETE :id` — ADMIN
|
|
|
|
**Tests:**
|
|
- deductMembership: times card decrements, used up marks status
|
|
- deductMembership: duration card does nothing to times
|
|
- restoreMembership: restores count and status
|
|
- getValidMembership: returns first valid card, skips expired
|
|
|
|
**Verify:** `pnpm test -- --testPathPattern=membership`
|
|
|
|
**Commit:** `feat(server): add membership module with card types and deduction logic`
|
|
|
|
---
|
|
|
|
### Task 2.7: 时段与排课模块 (TimeSlot)
|
|
|
|
**Files to create:**
|
|
- `packages/server/src/time-slot/time-slot.module.ts`
|
|
- `packages/server/src/time-slot/time-slot.controller.ts`
|
|
- `packages/server/src/time-slot/time-slot.service.ts`
|
|
- `packages/server/src/time-slot/slot-generator.service.ts`
|
|
- `packages/server/src/time-slot/dto/query-slots.dto.ts`
|
|
- `packages/server/src/time-slot/__tests__/time-slot.service.spec.ts`
|
|
- `packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts`
|
|
|
|
**Steps:**
|
|
1. `TimeSlotService`:
|
|
- `getAvailableSlots(date: string)`: query TimeSlots for date, include booking status for current user
|
|
- `getSlotById(id)`: single slot with details
|
|
- Admin operations: create manual slot, close slot
|
|
|
|
2. `SlotGeneratorService`:
|
|
- `generateSlots(daysAhead: number = 14)`: read active WeekTemplates → generate TimeSlots for future dates, skip if `[date, startTime, endTime]` already exists
|
|
- `cleanupExpiredSlots()`: mark past OPEN slots as CLOSED
|
|
- `checkExpiredMemberships()`: mark expired memberships as EXPIRED, used-up as USED_UP
|
|
|
|
3. Admin controller endpoints:
|
|
- `GET /admin/week-template`
|
|
- `PUT /admin/week-template` (full replacement of all templates)
|
|
- `POST /admin/time-slot/manual`
|
|
- `PUT /admin/time-slot/:id/close`
|
|
- `POST /admin/generate-slots` (manual trigger)
|
|
|
|
**Tests:**
|
|
- SlotGenerator: generates correct slots from templates
|
|
- SlotGenerator: skips existing date+time combinations
|
|
- SlotGenerator: marks past slots as CLOSED
|
|
- TimeSlotService: returns slots with user booking status
|
|
|
|
**Verify:** `pnpm test -- --testPathPattern=time-slot`
|
|
|
|
**Commit:** `feat(server): add time-slot module with template-based generation`
|
|
|
|
---
|
|
|
|
### Task 2.8: 预约模块 (Booking)
|
|
|
|
**Files to create:**
|
|
- `packages/server/src/booking/booking.module.ts`
|
|
- `packages/server/src/booking/booking.controller.ts`
|
|
- `packages/server/src/booking/booking.service.ts`
|
|
- `packages/server/src/booking/dto/create-booking.dto.ts`
|
|
- `packages/server/src/booking/__tests__/booking.service.spec.ts`
|
|
|
|
**Steps:**
|
|
1. `BookingService`:
|
|
- `createBooking(userId, timeSlotId, membershipId)`:
|
|
- Validate: slot is OPEN, not already booked by user, membership is valid
|
|
- Transaction: create Booking, increment slot `bookedCount`, deduct membership
|
|
- If `bookedCount >= capacity` → set slot status to FULL
|
|
- `cancelBooking(userId, bookingId)`:
|
|
- Check ownership, check booking status is CONFIRMED
|
|
- Calculate if within `cancelHoursLimit`
|
|
- Transaction: update booking to CANCELLED, decrement `bookedCount`, restore membership (if within limit), reset slot to OPEN if was FULL
|
|
- `getMyBookings(userId, status?)`: paginated list
|
|
- `getUpcomingBookings(userId)`: future confirmed bookings
|
|
- Admin: `getAllBookings(filters)`: all bookings with user info
|
|
|
|
2. `BookingController`:
|
|
- `POST /booking` — `{ timeSlotId, membershipId }`
|
|
- `PUT /booking/:id/cancel`
|
|
- `GET /booking/my`
|
|
- `GET /booking/my/upcoming`
|
|
- `GET /admin/bookings` — ADMIN
|
|
|
|
**Tests (critical business logic):**
|
|
- createBooking: success path — booking created, count incremented, membership deducted
|
|
- createBooking: fail — slot full
|
|
- createBooking: fail — duplicate booking
|
|
- createBooking: fail — expired membership
|
|
- cancelBooking: within limit — refunds membership
|
|
- cancelBooking: past limit — no refund, still cancels
|
|
- cancelBooking: slot status changes from FULL to OPEN
|
|
|
|
**Verify:** `pnpm test -- --testPathPattern=booking`
|
|
|
|
**Commit:** `feat(server): add booking module with reservation and cancellation logic`
|
|
|
|
---
|
|
|
|
### Task 2.9: 支付模块 (Payment)
|
|
|
|
**Files to create:**
|
|
- `packages/server/src/payment/payment.module.ts`
|
|
- `packages/server/src/payment/payment.controller.ts`
|
|
- `packages/server/src/payment/payment.service.ts`
|
|
- `packages/server/src/payment/wechat-pay.service.ts`
|
|
- `packages/server/src/payment/dto/create-order.dto.ts`
|
|
- `packages/server/src/payment/__tests__/payment.service.spec.ts`
|
|
|
|
**Steps:**
|
|
1. `WechatPayService`: encapsulate WeChat Pay unified order API, sign verification, callback parsing. Mock-able for tests.
|
|
|
|
2. `PaymentService`:
|
|
- `createOrder(userId, cardTypeId)`:
|
|
- Generate order number (timestamp + random)
|
|
- Create Order (PENDING)
|
|
- Call WechatPay unified order → return payment params
|
|
- `handleWxNotify(xml)`:
|
|
- Parse and verify signature
|
|
- Find order by orderNo
|
|
- Transaction: update Order to PAID, create Membership (calculate expireDate from cardType.durationDays, set remainingTimes from cardType.totalTimes)
|
|
- `getMyOrders(userId)`: paginated order list
|
|
- `getOrdersByAdmin()`: ADMIN view
|
|
|
|
3. `PaymentController`:
|
|
- `POST /payment/create-order` — authed
|
|
- `POST /payment/wx-notify` — public (WeChat callback, no auth)
|
|
- `GET /payment/orders` — authed
|
|
- `GET /admin/orders` — ADMIN
|
|
|
|
**Tests:**
|
|
- createOrder: creates PENDING order with correct amount
|
|
- handleWxNotify: valid callback → order PAID, membership created with correct expiry
|
|
- handleWxNotify: duplicate callback → idempotent (no duplicate membership)
|
|
- handleWxNotify: invalid signature → rejected
|
|
|
|
**Verify:** `pnpm test -- --testPathPattern=payment`
|
|
|
|
**Commit:** `feat(server): add payment module with WeChat Pay integration`
|
|
|
|
---
|
|
|
|
### Task 2.10: 定时任务
|
|
|
|
**Files to create:**
|
|
- `packages/server/src/scheduler/scheduler.module.ts`
|
|
- `packages/server/src/scheduler/scheduler.service.ts`
|
|
- `packages/server/src/scheduler/__tests__/scheduler.service.spec.ts`
|
|
|
|
**Steps:**
|
|
1. Use `@nestjs/schedule` with `@Cron`:
|
|
- Daily 2:00 AM: `SlotGeneratorService.generateSlots(14)` — generate slots for next 14 days
|
|
- Daily 2:30 AM: `SlotGeneratorService.cleanupExpiredSlots()` — close past slots
|
|
- Daily 3:00 AM: `SlotGeneratorService.checkExpiredMemberships()` — expire memberships
|
|
- Daily 22:00: Mark today's CONFIRMED bookings as COMPLETED (lesson is done)
|
|
|
|
2. Register `ScheduleModule.forRoot()` in AppModule.
|
|
|
|
**Tests:** Mock cron, verify each task calls the right service method.
|
|
|
|
**Verify:** `pnpm test -- --testPathPattern=scheduler`
|
|
|
|
**Commit:** `feat(server): add scheduled tasks for slot generation and cleanup`
|
|
|
|
---
|
|
|
|
### Task 2.11: 数据库 seed 脚本
|
|
|
|
**Files to create:**
|
|
- `packages/server/prisma/seed.ts`
|
|
|
|
**Steps:**
|
|
1. Create seed data:
|
|
- 1 StudioConfig record with placeholder values
|
|
- 3 CardType records: 「私教10次卡」(TIMES, 10次, 90天, 2999元), 「月卡」(DURATION, 30天, 999元), 「体验课」(TRIAL, 1次, 7天, 99元)
|
|
- 1 ADMIN user (with a test openid)
|
|
- WeekTemplate for weekdays 09:00-10:00, 10:00-11:00, 14:00-15:00, 15:00-16:00
|
|
|
|
2. Use `prisma.$transaction` for atomicity, skip if records already exist.
|
|
|
|
**Verify:** `pnpm prisma db seed` runs successfully.
|
|
|
|
**Commit:** `feat(server): add database seed script`
|
|
|
|
---
|
|
|
|
## Phase 3: 前端基础
|
|
|
|
### Task 3.1: 初始化 uni-app 项目
|
|
|
|
**Files to create:**
|
|
- `packages/app/package.json`
|
|
- `packages/app/tsconfig.json`
|
|
- `packages/app/vite.config.ts`
|
|
- `packages/app/src/main.ts`
|
|
- `packages/app/src/App.vue`
|
|
- `packages/app/src/pages.json`
|
|
- `packages/app/src/manifest.json`
|
|
- `packages/app/src/uni.scss`
|
|
- `packages/app/index.html`
|
|
|
|
**Steps:**
|
|
1. Initialize with `@dcloudio/uni-app` (Vue 3 + Vite + TypeScript template).
|
|
|
|
2. Install dependencies:
|
|
- `@dcloudio/uni-app`, `@dcloudio/uni-mp-weixin`, `@dcloudio/uni-ui`
|
|
- `pinia`, `@mp-pilates/shared`
|
|
|
|
3. Configure `pages.json` with tab bar (3 tabs) and all sub-pages:
|
|
```json
|
|
{
|
|
"pages": [
|
|
{ "path": "pages/home/index", "style": { "navigationBarTitleText": "首页" } },
|
|
{ "path": "pages/booking/index", "style": { "navigationBarTitleText": "预约课程" } },
|
|
{ "path": "pages/profile/index", "style": { "navigationBarTitleText": "我的" } }
|
|
],
|
|
"subPackages": [
|
|
{
|
|
"root": "pages/card",
|
|
"pages": [
|
|
{ "path": "detail", "style": { "navigationBarTitleText": "购买会员卡" } }
|
|
]
|
|
},
|
|
{
|
|
"root": "pages/profile",
|
|
"pages": [
|
|
{ "path": "membership", "style": { "navigationBarTitleText": "我的会员卡" } },
|
|
{ "path": "bookings", "style": { "navigationBarTitleText": "我的预约" } },
|
|
{ "path": "info", "style": { "navigationBarTitleText": "个人信息" } }
|
|
]
|
|
},
|
|
{
|
|
"root": "pages/admin",
|
|
"pages": [
|
|
{ "path": "index", "style": { "navigationBarTitleText": "管理中心" } },
|
|
{ "path": "week-template", "style": { "navigationBarTitleText": "排课设置" } },
|
|
{ "path": "slot-adjust", "style": { "navigationBarTitleText": "时段调整" } },
|
|
{ "path": "members", "style": { "navigationBarTitleText": "会员管理" } },
|
|
{ "path": "orders", "style": { "navigationBarTitleText": "订单管理" } },
|
|
{ "path": "card-types", "style": { "navigationBarTitleText": "卡种管理" } },
|
|
{ "path": "studio", "style": { "navigationBarTitleText": "工作室设置" } }
|
|
]
|
|
}
|
|
],
|
|
"tabBar": {
|
|
"color": "#999",
|
|
"selectedColor": "#333",
|
|
"list": [
|
|
{ "pagePath": "pages/home/index", "text": "首页", "iconPath": "static/tab/home.png", "selectedIconPath": "static/tab/home-active.png" },
|
|
{ "pagePath": "pages/booking/index", "text": "预约", "iconPath": "static/tab/booking.png", "selectedIconPath": "static/tab/booking-active.png" },
|
|
{ "pagePath": "pages/profile/index", "text": "我的", "iconPath": "static/tab/profile.png", "selectedIconPath": "static/tab/profile-active.png" }
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
4. Configure `manifest.json` with WeChat appid placeholder and permission settings.
|
|
|
|
**Verify:** `cd packages/app && pnpm dev:mp-weixin` compiles without error.
|
|
|
|
**Commit:** `feat(app): initialize uni-app project with page routing`
|
|
|
|
---
|
|
|
|
### Task 3.2: 前端基础设施 (HTTP, Auth, Store)
|
|
|
|
**Files to create:**
|
|
- `packages/app/src/utils/request.ts`
|
|
- `packages/app/src/utils/auth.ts`
|
|
- `packages/app/src/stores/user.ts`
|
|
- `packages/app/src/stores/studio.ts`
|
|
|
|
**Steps:**
|
|
|
|
1. `utils/request.ts` — 封装 `uni.request`:
|
|
- Base URL from env config
|
|
- Auto attach JWT token header
|
|
- Response interceptor: handle 401 (redirect to login), extract `data` from ApiResponse
|
|
- Request/response typing with generics
|
|
|
|
```typescript
|
|
export function request<T>(options: RequestOptions): Promise<T> {
|
|
return new Promise((resolve, reject) => {
|
|
const token = uni.getStorageSync('token')
|
|
uni.request({
|
|
url: `${BASE_URL}${options.url}`,
|
|
method: options.method || 'GET',
|
|
data: options.data,
|
|
header: {
|
|
'Content-Type': 'application/json',
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
...options.header,
|
|
},
|
|
success: (res) => {
|
|
if (res.statusCode === 401) {
|
|
uni.removeStorageSync('token')
|
|
// redirect to login
|
|
reject(new Error('Unauthorized'))
|
|
return
|
|
}
|
|
const body = res.data as ApiResponse<T>
|
|
if (body.success) {
|
|
resolve(body.data as T)
|
|
} else {
|
|
reject(new Error(body.message || 'Request failed'))
|
|
}
|
|
},
|
|
fail: reject,
|
|
})
|
|
})
|
|
}
|
|
```
|
|
|
|
2. `utils/auth.ts` — WeChat login helper:
|
|
- `wxLogin()`: call `wx.login()`, send code to `/auth/login`, store JWT
|
|
- `wxGetPhone(e)`: extract encrypted data, send to `/auth/phone`
|
|
- `checkAuth()`: check token exists, validate
|
|
|
|
3. `stores/user.ts` (Pinia):
|
|
- State: `user`, `token`, `stats`, `memberships`
|
|
- Actions: `login()`, `fetchProfile()`, `fetchStats()`, `fetchMemberships()`
|
|
- Getters: `isLoggedIn`, `isAdmin`, `hasValidMembership`, `activeMembership`
|
|
|
|
4. `stores/studio.ts` (Pinia):
|
|
- State: `studioInfo`
|
|
- Actions: `fetchStudioInfo()`
|
|
|
|
**Verify:** Build compiles. Stores are importable and typed.
|
|
|
|
**Commit:** `feat(app): add HTTP client, auth utils, and Pinia stores`
|
|
|
|
---
|
|
|
|
## Phase 4: 前端页面
|
|
|
|
### Task 4.1: 首页
|
|
|
|
**Files to create:**
|
|
- `packages/app/src/pages/home/index.vue`
|
|
- `packages/app/src/components/BrandBanner.vue`
|
|
- `packages/app/src/components/StudioInfo.vue`
|
|
- `packages/app/src/components/QuickEntry.vue`
|
|
- `packages/app/src/components/UpcomingBooking.vue`
|
|
- `packages/app/src/components/CardShop.vue`
|
|
|
|
**Steps:**
|
|
1. `BrandBanner`: display logo, studio name, background image from StudioConfig.
|
|
2. `StudioInfo`: photo swiper, address (tap → map), phone (tap → call).
|
|
3. `QuickEntry`: conditional display based on user state:
|
|
- Not logged in → 「微信登录」
|
|
- New user (no memberships) → 「预约体验课」
|
|
- Has valid card → 「一键约课」
|
|
- Card running low (<=2 remaining) → 「续卡提醒」
|
|
- No valid card → 「购买会员卡」
|
|
4. `UpcomingBooking`: show next 1-2 upcoming bookings with date/time.
|
|
5. `CardShop`: horizontal scrollable card type list, tap → card detail page.
|
|
|
|
**Verify:** Page renders in WeChat DevTools. Quick entries change based on mock data.
|
|
|
|
**Commit:** `feat(app): implement home page with smart entry points`
|
|
|
|
---
|
|
|
|
### Task 4.2: 课程预约页
|
|
|
|
**Files to create:**
|
|
- `packages/app/src/pages/booking/index.vue`
|
|
- `packages/app/src/components/DateSelector.vue`
|
|
- `packages/app/src/components/TimePeriodFilter.vue`
|
|
- `packages/app/src/components/SlotCard.vue`
|
|
- `packages/app/src/components/BookingConfirmPopup.vue`
|
|
- `packages/app/src/stores/booking.ts`
|
|
|
|
**Steps:**
|
|
1. `DateSelector`: horizontal scroll, 7 days, highlight today, show weekday. Active date fetches slots.
|
|
2. `TimePeriodFilter`: tabs for 全部/上午/下午/晚上, filter displayed slots.
|
|
3. `SlotCard`: display time range, status (available/full/booked). Tap → booking flow.
|
|
4. `BookingConfirmPopup`: uni-popup showing slot info + membership to deduct + confirm/cancel buttons.
|
|
5. `stores/booking.ts`: fetch slots by date, create booking, cancel booking.
|
|
|
|
**Booking flow in component:**
|
|
```
|
|
tap "预约" → check login → check membership
|
|
→ no membership → navigate to card purchase
|
|
→ has membership → show BookingConfirmPopup
|
|
→ confirm → call createBooking API → success toast → refresh slots
|
|
```
|
|
|
|
**Verify:** Page loads slots, date switching works, booking popup shows.
|
|
|
|
**Commit:** `feat(app): implement booking page with date selector and reservation flow`
|
|
|
|
---
|
|
|
|
### Task 4.3: 个人中心页
|
|
|
|
**Files to create:**
|
|
- `packages/app/src/pages/profile/index.vue`
|
|
- `packages/app/src/components/UserCard.vue`
|
|
- `packages/app/src/components/TrainingStats.vue`
|
|
- `packages/app/src/components/ProfileMenu.vue`
|
|
|
|
**Steps:**
|
|
1. `UserCard`: avatar, nickname, phone, total training days/count.
|
|
2. `TrainingStats`: this month's count/days/hours in a card layout.
|
|
3. `ProfileMenu`: list items — 我的会员卡, 我的预约, 个人信息. If ADMIN → 管理中心 entry at bottom.
|
|
4. Login prompt if not authenticated.
|
|
|
|
**Verify:** Page renders, menu items navigate correctly.
|
|
|
|
**Commit:** `feat(app): implement profile page with stats and menu`
|
|
|
|
---
|
|
|
|
### Task 4.4: 子页面 — 会员卡购买、我的会员卡、我的预约、个人信息
|
|
|
|
**Files to create:**
|
|
- `packages/app/src/pages/card/detail.vue`
|
|
- `packages/app/src/pages/profile/membership.vue`
|
|
- `packages/app/src/pages/profile/bookings.vue`
|
|
- `packages/app/src/pages/profile/info.vue`
|
|
|
|
**Steps:**
|
|
|
|
1. **卡种详情/购买** (`card/detail.vue`):
|
|
- Receive `cardTypeId` via route param
|
|
- Display card info: name, type, price (with original price strikethrough), description, duration
|
|
- "立即购买" button → call createOrder → `wx.requestPayment` → success → navigate to my memberships
|
|
|
|
2. **我的会员卡** (`profile/membership.vue`):
|
|
- List all memberships grouped: ACTIVE first, then EXPIRED/USED_UP
|
|
- Each card shows: name, remaining times or expire date, status badge
|
|
|
|
3. **我的预约** (`profile/bookings.vue`):
|
|
- Two tabs: 即将上课 / 历史记录
|
|
- Upcoming: show date, time, cancel button (with time check)
|
|
- History: show status (COMPLETED, CANCELLED, NO_SHOW)
|
|
|
|
4. **个人信息** (`profile/info.vue`):
|
|
- Edit nickname (text input)
|
|
- Bind/change phone (wx.getPhoneNumber button)
|
|
- Avatar display (from WeChat)
|
|
|
|
**Verify:** Each page renders and connects to API correctly.
|
|
|
|
**Commit:** `feat(app): implement card purchase, membership, bookings, and profile info pages`
|
|
|
|
---
|
|
|
|
### Task 4.5: 管理页面
|
|
|
|
**Files to create:**
|
|
- `packages/app/src/pages/admin/index.vue`
|
|
- `packages/app/src/pages/admin/week-template.vue`
|
|
- `packages/app/src/pages/admin/slot-adjust.vue`
|
|
- `packages/app/src/pages/admin/members.vue`
|
|
- `packages/app/src/pages/admin/orders.vue`
|
|
- `packages/app/src/pages/admin/card-types.vue`
|
|
- `packages/app/src/pages/admin/studio.vue`
|
|
- `packages/app/src/stores/admin.ts`
|
|
|
|
**Steps:**
|
|
|
|
1. **管理中心** (`admin/index.vue`): grid menu with icons — 排课设置, 时段调整, 会员管理, 订单管理, 卡种管理, 工作室设置.
|
|
|
|
2. **周模板管理** (`admin/week-template.vue`):
|
|
- 7-column grid (Mon-Sun)
|
|
- Each day shows time slots as tags
|
|
- Add button per day → time picker (start/end) → add template
|
|
- Toggle switch per slot to enable/disable
|
|
- Save → PUT /admin/week-template (full replacement)
|
|
|
|
3. **临时时段调整** (`admin/slot-adjust.vue`):
|
|
- Calendar date picker
|
|
- Show generated slots for selected date
|
|
- Close slot button, Add manual slot form
|
|
- "重新生成" button → POST /admin/generate-slots
|
|
|
|
4. **会员管理** (`admin/members.vue`):
|
|
- Search by nickname/phone
|
|
- List: avatar, nickname, active card info, total bookings
|
|
- Tap → member detail (memberships + booking history)
|
|
|
|
5. **订单管理** (`admin/orders.vue`):
|
|
- List: order no, user, card type, amount (format from cents), status, time
|
|
- Status filter tabs (ALL, PENDING, PAID, REFUNDED)
|
|
|
|
6. **卡种管理** (`admin/card-types.vue`):
|
|
- List current card types with edit/delete
|
|
- Add new card type form: name, type, times, duration, price, original price, description
|
|
- Toggle isActive
|
|
|
|
7. **工作室设置** (`admin/studio.vue`):
|
|
- Form: name, address, phone, cancelHoursLimit
|
|
- Image upload for logo, banner, photos
|
|
- Map picker for lat/lng (optional, use wx.chooseLocation)
|
|
|
|
8. `stores/admin.ts`: admin-specific API calls for all admin endpoints.
|
|
|
|
**Verify:** All admin pages render and CRUD operations work.
|
|
|
|
**Commit:** `feat(app): implement admin management pages`
|
|
|
|
---
|
|
|
|
## Phase 5: 集成与完善
|
|
|
|
### Task 5.1: 端到端联调
|
|
|
|
**Steps:**
|
|
1. Start server with seed data
|
|
2. Open mini-program in WeChat DevTools
|
|
3. Test complete flows:
|
|
- Login → home page → quick entries display correctly
|
|
- Purchase card → payment flow (mock mode)
|
|
- Book a slot → confirm → check deduction
|
|
- Cancel booking → verify refund logic
|
|
- Admin: create week template → generate slots → verify on booking page
|
|
4. Fix any integration issues
|
|
|
|
**Verify:** All flows work end-to-end in WeChat DevTools.
|
|
|
|
**Commit:** `fix: resolve integration issues from e2e testing`
|
|
|
|
---
|
|
|
|
### Task 5.2: 错误处理与 loading 状态
|
|
|
|
**Files to create/modify:**
|
|
- `packages/app/src/components/LoadingState.vue`
|
|
- `packages/app/src/components/EmptyState.vue`
|
|
- `packages/app/src/components/ErrorToast.vue`
|
|
|
|
**Steps:**
|
|
1. Add loading skeletons to list pages (booking, memberships, admin lists).
|
|
2. Add empty state illustrations for: no bookings, no memberships, no slots.
|
|
3. Add error handling: network error toast, retry mechanism.
|
|
4. Add pull-to-refresh on list pages.
|
|
5. Add `uni.showLoading` / `uni.hideLoading` for mutation operations (book, cancel, purchase).
|
|
|
|
**Verify:** Loading states display, errors are caught and shown, empty states render.
|
|
|
|
**Commit:** `feat(app): add loading states, empty states, and error handling`
|
|
|
|
---
|
|
|
|
### Task 5.3: 服务端全量测试
|
|
|
|
**Steps:**
|
|
1. Run full test suite: `cd packages/server && pnpm test`
|
|
2. Check coverage: `pnpm test -- --coverage`
|
|
3. Ensure ≥80% coverage on:
|
|
- `booking.service.ts` (critical business logic)
|
|
- `membership.service.ts` (deduction logic)
|
|
- `payment.service.ts` (order + callback)
|
|
- `slot-generator.service.ts` (slot generation)
|
|
- `auth.service.ts` (login flow)
|
|
4. Add missing tests if below threshold.
|
|
|
|
**Verify:** `pnpm test -- --coverage` shows ≥80% on critical modules.
|
|
|
|
**Commit:** `test(server): ensure 80%+ coverage on critical business modules`
|
|
|
|
---
|
|
|
|
## Execution Notes
|
|
|
|
### Dependency Order
|
|
- Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5
|
|
- Within Phase 2: Tasks 2.1, 2.2 must come first. Tasks 2.3-2.5 can be parallelized. Tasks 2.6-2.8 depend on 2.3 (auth). Task 2.9 depends on 2.6. Task 2.10 depends on 2.7.
|
|
- Within Phase 4: Task 4.1-4.3 can be parallelized after 3.2. Task 4.4 depends on 4.1-4.3 stores. Task 4.5 depends on all backend modules.
|
|
|
|
### Parallelizable Tasks
|
|
- **Phase 2**: Auth (2.3) + Studio (2.5) + User (2.4) can run in parallel after 2.2
|
|
- **Phase 4**: Home (4.1) + Booking (4.2) + Profile (4.3) can run in parallel after 3.2
|
|
|
|
### Key Risks
|
|
1. **WeChat Pay integration**: Requires real merchant credentials for testing. Use mock mode first, real integration last.
|
|
2. **uni-app + pnpm workspace**: `shamefully-hoist=true` is needed. May need to adjust `vite.config.ts` for shared package resolution.
|
|
3. **Prisma in monorepo**: Need to ensure `prisma generate` runs before server build. Add as `postinstall` script.
|
|
|
|
### Total Commits: ~17
|
|
### Estimated Tasks: 16 main tasks across 5 phases
|