feat: scaffold monorepo with shared types and NestJS server

- 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
This commit is contained in:
richarjiang
2026-04-02 11:37:35 +08:00
parent 05337944d8
commit 90b54d1138
31 changed files with 7437 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Dependencies
node_modules/
# Build output
dist/
dist-ssr/
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# Test coverage
coverage/
# Prisma
packages/server/prisma/*.db
packages/server/prisma/migrations/**/migration_lock.toml
# uni-app build
packages/app/dist/
packages/app/unpackage/
# Misc
*.tgz
.cache/

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
shamefully-hoist=true

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"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"
},
"pnpm": {
"onlyBuiltDependencies": [
"@nestjs/core",
"@prisma/client",
"@prisma/engines",
"prisma"
]
}
}

View File

@@ -0,0 +1,19 @@
# Database
DATABASE_URL=postgresql://user:pass@localhost:5432/mp_pilates
# JWT
JWT_SECRET=change-me-to-a-secure-random-string
# WeChat Mini Program
WX_APPID=your-appid
WX_SECRET=your-secret
# WeChat Pay
WX_MCH_ID=your-mch-id
WX_MCH_KEY=your-mch-key
WX_MCH_SERIAL_NO=your-serial-no
WX_MCH_CERT_PATH=./certs/apiclient_cert.pem
WX_MCH_KEY_PATH=./certs/apiclient_key.pem
# Server
PORT=3000

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@@ -0,0 +1,64 @@
{
"name": "@mp-pilates/server",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "nest start",
"start:prod": "node dist/main",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node prisma/seed.ts",
"lint": "eslint \"{src,test}/**/*.ts\""
},
"dependencies": {
"@mp-pilates/shared": "workspace:*",
"@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.4.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.0",
"@nestjs/schedule": "^4.1.0",
"@prisma/client": "^5.19.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.4.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/passport-jwt": "^4.0.1",
"jest": "^29.7.0",
"prisma": "^5.19.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.0"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s", "!**/*.module.ts", "!main.ts"],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@mp-pilates/shared(.*)$": "<rootDir>/../../shared/src$1"
}
}
}

View File

@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common'
@Controller()
export class AppController {
@Get('health')
health() {
return { status: 'ok', timestamp: new Date().toISOString() }
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { AppController } from './app.controller'
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
}),
],
controllers: [AppController],
})
export class AppModule {}

View File

@@ -0,0 +1,24 @@
import { NestFactory } from '@nestjs/core'
import { ValidationPipe } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { AppModule } from './app.module'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
const configService = app.get(ConfigService)
app.setGlobalPrefix('api')
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
)
app.enableCors()
const port = configService.get<number>('PORT', 3000)
await app.listen(port)
console.log(`Server running on http://localhost:${port}`)
}
bootstrap()

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist"
},
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}

View File

@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"module": "CommonJS",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "ES2021",
"sourceMap": true,
"incremental": true,
"declaration": false,
"declarationMap": false
},
"include": ["src"],
"exclude": ["node_modules", "dist", "test"]
}

View File

@@ -0,0 +1,13 @@
{
"name": "@mp-pilates/shared",
"version": "0.1.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"devDependencies": {
"typescript": "^5.4.0"
}
}

View File

@@ -0,0 +1,21 @@
/** 默认免费取消截止小时数 */
export const DEFAULT_CANCEL_HOURS_LIMIT = 2
/** 默认时段容量(私教 = 1 */
export const DEFAULT_SLOT_CAPACITY = 1
/** 自动生成时段的天数范围 */
export const SLOT_GENERATION_DAYS = 14
/** 时段筛选区间 */
export const TIME_PERIODS = {
MORNING: { label: '上午', start: '06:00', end: '12:00' },
AFTERNOON: { label: '下午', start: '12:00', end: '18:00' },
EVENING: { label: '晚上', start: '18:00', end: '22:00' },
} as const
/** 日期选择器展示天数 */
export const DATE_SELECTOR_DAYS = 7
/** 星期映射 */
export const WEEKDAY_LABELS = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日'] as const

View File

@@ -0,0 +1,46 @@
// ===== User =====
export enum UserRole {
MEMBER = 'MEMBER',
ADMIN = 'ADMIN',
}
// ===== CardType =====
export enum CardTypeCategory {
TIMES = 'TIMES',
DURATION = 'DURATION',
TRIAL = 'TRIAL',
}
// ===== Membership =====
export enum MembershipStatus {
ACTIVE = 'ACTIVE',
EXPIRED = 'EXPIRED',
USED_UP = 'USED_UP',
}
// ===== TimeSlot =====
export enum TimeSlotStatus {
OPEN = 'OPEN',
FULL = 'FULL',
CLOSED = 'CLOSED',
}
export enum TimeSlotSource {
TEMPLATE = 'TEMPLATE',
MANUAL = 'MANUAL',
}
// ===== Booking =====
export enum BookingStatus {
CONFIRMED = 'CONFIRMED',
CANCELLED = 'CANCELLED',
COMPLETED = 'COMPLETED',
NO_SHOW = 'NO_SHOW',
}
// ===== Order =====
export enum OrderStatus {
PENDING = 'PENDING',
PAID = 'PAID',
REFUNDED = 'REFUNDED',
}

View File

@@ -0,0 +1,52 @@
// Enums
export {
UserRole,
CardTypeCategory,
MembershipStatus,
TimeSlotStatus,
TimeSlotSource,
BookingStatus,
OrderStatus,
} from './enums'
// Constants
export {
DEFAULT_CANCEL_HOURS_LIMIT,
DEFAULT_SLOT_CAPACITY,
SLOT_GENERATION_DAYS,
TIME_PERIODS,
DATE_SELECTOR_DAYS,
WEEKDAY_LABELS,
} from './constants'
// Types
export type {
User,
UserProfileResponse,
UpdateProfileDto,
UserStatsResponse,
CardType,
CreateCardTypeDto,
UpdateCardTypeDto,
Membership,
MembershipWithCardType,
WeekTemplate,
WeekTemplateInput,
TimeSlot,
TimeSlotWithBookingStatus,
CreateManualSlotDto,
Booking,
BookingWithDetails,
CreateBookingDto,
Order,
OrderWithDetails,
CreateOrderDto,
PaymentParams,
CreateOrderResponse,
StudioConfig,
UpdateStudioConfigDto,
ApiResponse,
PaginatedData,
PaginatedResponse,
PaginationQuery,
} from './types/index'

View File

@@ -0,0 +1,19 @@
export interface ApiResponse<T> {
readonly success: boolean
readonly data: T | null
readonly message: string | null
}
export interface PaginatedData<T> {
readonly items: readonly T[]
readonly total: number
readonly page: number
readonly limit: number
}
export type PaginatedResponse<T> = ApiResponse<PaginatedData<T>>
export interface PaginationQuery {
readonly page?: number
readonly limit?: number
}

View File

@@ -0,0 +1,31 @@
import { BookingStatus } from '../enums'
export interface Booking {
readonly id: string
readonly userId: string
readonly timeSlotId: string
readonly membershipId: string
readonly status: BookingStatus
readonly cancelledAt: string | null
readonly createdAt: string
readonly updatedAt: string
}
export interface BookingWithDetails extends Booking {
readonly timeSlot: {
readonly date: string
readonly startTime: string
readonly endTime: string
}
readonly membership: {
readonly id: string
readonly cardType: {
readonly name: string
}
}
}
export interface CreateBookingDto {
readonly timeSlotId: string
readonly membershipId: string
}

View File

@@ -0,0 +1,38 @@
import { CardTypeCategory } from '../enums'
export interface CardType {
readonly id: string
readonly name: string
readonly type: CardTypeCategory
readonly totalTimes: number | null
readonly durationDays: number
readonly price: number
readonly originalPrice: number | null
readonly description: string | null
readonly isActive: boolean
readonly sortOrder: number
readonly createdAt: string
readonly updatedAt: string
}
export interface CreateCardTypeDto {
readonly name: string
readonly type: CardTypeCategory
readonly totalTimes?: number
readonly durationDays: number
readonly price: number
readonly originalPrice?: number
readonly description?: string
readonly sortOrder?: number
}
export interface UpdateCardTypeDto {
readonly name?: string
readonly totalTimes?: number
readonly durationDays?: number
readonly price?: number
readonly originalPrice?: number
readonly description?: string
readonly isActive?: boolean
readonly sortOrder?: number
}

View File

@@ -0,0 +1,9 @@
export type { User, UserProfileResponse, UpdateProfileDto, UserStatsResponse } from './user'
export type { CardType, CreateCardTypeDto, UpdateCardTypeDto } from './card-type'
export type { Membership, MembershipWithCardType } from './membership'
export type { WeekTemplate, WeekTemplateInput } from './week-template'
export type { TimeSlot, TimeSlotWithBookingStatus, CreateManualSlotDto } from './time-slot'
export type { Booking, BookingWithDetails, CreateBookingDto } from './booking'
export type { Order, OrderWithDetails, CreateOrderDto, PaymentParams, CreateOrderResponse } from './order'
export type { StudioConfig, UpdateStudioConfigDto } from './studio'
export type { ApiResponse, PaginatedData, PaginatedResponse, PaginationQuery } from './api'

View File

@@ -0,0 +1,18 @@
import { MembershipStatus } from '../enums'
import { CardType } from './card-type'
export interface Membership {
readonly id: string
readonly userId: string
readonly cardTypeId: string
readonly remainingTimes: number | null
readonly startDate: string
readonly expireDate: string
readonly status: MembershipStatus
readonly createdAt: string
readonly updatedAt: string
}
export interface MembershipWithCardType extends Membership {
readonly cardType: CardType
}

View File

@@ -0,0 +1,42 @@
import { OrderStatus } from '../enums'
export interface Order {
readonly id: string
readonly userId: string
readonly cardTypeId: string
readonly orderNo: string
readonly amount: number
readonly status: OrderStatus
readonly wxTransactionId: string | null
readonly paidAt: string | null
readonly createdAt: string
readonly updatedAt: string
}
export interface OrderWithDetails extends Order {
readonly cardType: {
readonly name: string
readonly type: string
}
readonly user?: {
readonly nickname: string
readonly phone: string | null
}
}
export interface CreateOrderDto {
readonly cardTypeId: string
}
export interface PaymentParams {
readonly timeStamp: string
readonly nonceStr: string
readonly package: string
readonly signType: string
readonly paySign: string
}
export interface CreateOrderResponse {
readonly order: Order
readonly paymentParams: PaymentParams
}

View File

@@ -0,0 +1,25 @@
export interface StudioConfig {
readonly id: string
readonly name: string
readonly logo: string | null
readonly bannerUrl: string | null
readonly address: string
readonly phone: string
readonly latitude: number | null
readonly longitude: number | null
readonly cancelHoursLimit: number
readonly photos: string[]
readonly updatedAt: string
}
export interface UpdateStudioConfigDto {
readonly name?: string
readonly logo?: string
readonly bannerUrl?: string
readonly address?: string
readonly phone?: string
readonly latitude?: number
readonly longitude?: number
readonly cancelHoursLimit?: number
readonly photos?: string[]
}

View File

@@ -0,0 +1,29 @@
import { TimeSlotStatus, TimeSlotSource } from '../enums'
export interface TimeSlot {
readonly id: string
readonly date: string
readonly startTime: string
readonly endTime: string
readonly capacity: number
readonly bookedCount: number
readonly status: TimeSlotStatus
readonly source: TimeSlotSource
readonly templateId: string | null
readonly createdAt: string
readonly updatedAt: string
}
export interface TimeSlotWithBookingStatus extends TimeSlot {
/** 当前用户是否已预约此时段 */
readonly isBookedByMe: boolean
/** 当前用户在此时段的预约 ID用于取消 */
readonly myBookingId: string | null
}
export interface CreateManualSlotDto {
readonly date: string
readonly startTime: string
readonly endTime: string
readonly capacity?: number
}

View File

@@ -0,0 +1,36 @@
import { UserRole } from '../enums'
export interface User {
readonly id: string
readonly openid: string
readonly unionid: string | null
readonly phone: string | null
readonly nickname: string
readonly avatarUrl: string | null
readonly role: UserRole
readonly createdAt: string
readonly updatedAt: string
}
export interface UserProfileResponse {
readonly id: string
readonly phone: string | null
readonly nickname: string
readonly avatarUrl: string | null
readonly role: UserRole
readonly activeMembershipCount: number
readonly createdAt: string
}
export interface UpdateProfileDto {
readonly nickname?: string
readonly avatarUrl?: string
}
export interface UserStatsResponse {
readonly totalBookings: number
readonly totalDays: number
readonly monthBookings: number
readonly monthDays: number
readonly monthHours: number
}

View File

@@ -0,0 +1,18 @@
export interface WeekTemplate {
readonly id: string
readonly dayOfWeek: number
readonly startTime: string
readonly endTime: string
readonly capacity: number
readonly isActive: boolean
readonly createdAt: string
readonly updatedAt: string
}
export interface WeekTemplateInput {
readonly dayOfWeek: number
readonly startTime: string
readonly endTime: string
readonly capacity?: number
readonly isActive?: boolean
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true
},
"include": ["src"]
}

5657
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
packages:
- 'packages/*'

20
tsconfig.base.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"paths": {
"@mp-pilates/shared": ["./packages/shared/src/index.ts"],
"@mp-pilates/shared/*": ["./packages/shared/src/*"]
}
},
"exclude": ["node_modules", "dist"]
}