Files
gym-manage/docs/plans/2026-03-05-poc-modules-implementation-plan.md
T
2026-03-05 13:48:13 +08:00

48 KiB

健身房管理系统POC剩余模块实施计划

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: 完成健身房管理系统POC的剩余核心模块(签到、权益、订阅、营销、数据分析)及性能测试验证

Architecture: 采用响应式架构(Spring WebFlux + R2DBC),遵循领域驱动设计(DDD)原则,分层架构(API层、应用层、领域层、基础设施层),完全响应式编程模型

Tech Stack: Spring Boot 3.2.3, Spring WebFlux, Spring Data R2DBC, PostgreSQL 16.x, R2DBC PostgreSQL 1.0.5.RELEASE, Lombok, MapStruct, JUnit 5, Reactor Test, Testcontainers


前置条件

  • 项目基础架构已搭建
  • 数据库schema已创建
  • 会员模块已完成
  • 预约模块已完成
  • 公共模块已完成

模块一:签到模块

Task 1: 创建签到领域模型

Files:

  • Create: src/main/java/com/gym/manage/domain/entity/CheckinRecord.java
  • Create: src/main/java/com/gym/manage/domain/repository/CheckinRecordRepository.java

Step 1: 创建CheckinRecord实体类

package com.gym.manage.domain.entity;

import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;

import java.time.LocalDateTime;

@Data
@Table("checkin_record")
public class CheckinRecord {
    @Id
    private Long id;
    
    @Column("tenant_id")
    private Long tenantId;
    
    @Column("store_id")
    private Long storeId;
    
    @Column("member_id")
    private Long memberId;
    
    @Column("checkin_type")
    private String checkinType;
    
    @Column("checkin_time")
    private LocalDateTime checkinTime;
    
    @Column("checkout_time")
    private LocalDateTime checkoutTime;
    
    @Column("device_id")
    private String deviceId;
    
    @Column("device_type")
    private String deviceType;
    
    @Column("status")
    private String status;
    
    @Column("remark")
    private String remark;
    
    @Column("created_at")
    private LocalDateTime createdAt;
    
    @Column("updated_at")
    private LocalDateTime updatedAt;
    
    @Column("deleted_at")
    private LocalDateTime deletedAt;
}

Step 2: 创建CheckinRecordRepository接口

package com.gym.manage.domain.repository;

import com.gym.manage.domain.entity.CheckinRecord;
import org.springframework.data.domain.Pageable;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.time.LocalDateTime;

@Repository
public interface CheckinRecordRepository extends R2dbcRepository<CheckinRecord, Long> {
    
    Mono<CheckinRecord> findByIdAndDeletedAtIsNull(Long id);
    
    Flux<CheckinRecord> findByMemberIdAndDeletedAtIsNull(Long memberId, Pageable pageable);
    
    @Query("SELECT * FROM checkin_record WHERE member_id = :memberId " +
           "AND DATE(checkin_time) = DATE(:date) AND deleted_at IS NULL " +
           "ORDER BY checkin_time DESC LIMIT 1")
    Mono<CheckinRecord> findLatestByMemberIdAndDate(Long memberId, LocalDateTime date);
    
    @Query("SELECT COUNT(*) FROM checkin_record WHERE member_id = :memberId " +
           "AND checkin_time >= :startTime AND checkin_time <= :endTime AND deleted_at IS NULL")
    Mono<Long> countByMemberIdAndTimeRange(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
}

Step 3: 验证代码编译

Run: mvn clean compile

Expected: BUILD SUCCESS

Step 4: 提交代码

git add src/main/java/com/gym/manage/domain/entity/CheckinRecord.java
git add src/main/java/com/gym/manage/domain/repository/CheckinRecordRepository.java
git commit -m "feat: add CheckinRecord entity and repository"

Task 2: 创建签到DTO

Files:

  • Create: src/main/java/com/gym/manage/api/dto/request/CheckinCreateRequest.java
  • Create: src/main/java/com/gym/manage/api/dto/response/CheckinRecordResponse.java

Step 1: 创建CheckinCreateRequest

package com.gym.manage.api.dto.request;

import jakarta.validation.constraints.NotNull;
import lombok.Data;

@Data
public class CheckinCreateRequest {
    @NotNull(message = "会员ID不能为空")
    private Long memberId;
    
    @NotNull(message = "签到类型不能为空")
    private String checkinType;
    
    private String deviceId;
    
    private String deviceType;
    
    private String remark;
}

Step 2: 创建CheckinRecordResponse

package com.gym.manage.api.dto.response;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CheckinRecordResponse {
    private Long id;
    private Long memberId;
    private String checkinType;
    private LocalDateTime checkinTime;
    private LocalDateTime checkoutTime;
    private String deviceId;
    private String deviceType;
    private String status;
    private String remark;
    private LocalDateTime createdAt;
}

Step 3: 验证代码编译

Run: mvn clean compile

Expected: BUILD SUCCESS

Step 4: 提交代码

git add src/main/java/com/gym/manage/api/dto/request/CheckinCreateRequest.java
git add src/main/java/com/gym/manage/api/dto/response/CheckinRecordResponse.java
git commit -m "feat: add CheckinRecord DTO classes"

Task 3: 创建签到Service

Files:

  • Create: src/main/java/com/gym/manage/application/service/CheckinService.java

Step 1: 创建CheckinService

package com.gym.manage.application.service;

import com.gym.manage.api.dto.request.CheckinCreateRequest;
import com.gym.manage.api.dto.response.CheckinRecordResponse;
import com.gym.manage.common.constant.ErrorCode;
import com.gym.manage.common.exception.BusinessException;
import com.gym.manage.domain.entity.CheckinRecord;
import com.gym.manage.domain.entity.Member;
import com.gym.manage.domain.repository.CheckinRecordRepository;
import com.gym.manage.domain.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.time.LocalDateTime;

@Slf4j
@Service
@RequiredArgsConstructor
public class CheckinService {
    
    private final CheckinRecordRepository checkinRecordRepository;
    private final MemberRepository memberRepository;
    
    public Mono<CheckinRecordResponse> checkin(CheckinCreateRequest request) {
        log.info("会员签到: memberId={}, type={}", request.getMemberId(), request.getCheckinType());
        
        return memberRepository.findByIdAndDeletedAtIsNull(request.getMemberId())
            .switchIfEmpty(Mono.error(new BusinessException(ErrorCode.MEMBER_NOT_FOUND, "会员不存在")))
            .flatMap(member -> createCheckinRecord(request, member))
            .map(this::toCheckinRecordResponse)
            .doOnSuccess(response -> log.info("签到成功: checkinId={}", response.getId()))
            .doOnError(e -> log.error("签到失败: memberId={}, error={}", request.getMemberId(), e.getMessage()));
    }
    
    private Mono<CheckinRecord> createCheckinRecord(CheckinCreateRequest request, Member member) {
        CheckinRecord record = new CheckinRecord();
        record.setTenantId(member.getTenantId());
        record.setStoreId(member.getStoreId());
        record.setMemberId(request.getMemberId());
        record.setCheckinType(request.getCheckinType());
        record.setCheckinTime(LocalDateTime.now());
        record.setDeviceId(request.getDeviceId());
        record.setDeviceType(request.getDeviceType());
        record.setStatus("CHECKED_IN");
        record.setRemark(request.getRemark());
        record.setCreatedAt(LocalDateTime.now());
        record.setUpdatedAt(LocalDateTime.now());
        
        return checkinRecordRepository.save(record);
    }
    
    public Mono<CheckinRecordResponse> getCheckin(Long id) {
        log.info("查询签到记录: checkinId={}", id);
        
        return checkinRecordRepository.findByIdAndDeletedAtIsNull(id)
            .switchIfEmpty(Mono.error(new BusinessException(ErrorCode.MEMBER_NOT_FOUND, "签到记录不存在")))
            .map(this::toCheckinRecordResponse);
    }
    
    public Flux<CheckinRecordResponse> listMemberCheckins(Long memberId, int page, int size) {
        log.info("查询会员签到记录: memberId={}", memberId);
        
        return checkinRecordRepository.findByMemberIdAndDeletedAtIsNull(
            memberId, PageRequest.of(page, size)
        ).map(this::toCheckinRecordResponse);
    }
    
    private CheckinRecordResponse toCheckinRecordResponse(CheckinRecord record) {
        return CheckinRecordResponse.builder()
            .id(record.getId())
            .memberId(record.getMemberId())
            .checkinType(record.getCheckinType())
            .checkinTime(record.getCheckinTime())
            .checkoutTime(record.getCheckoutTime())
            .deviceId(record.getDeviceId())
            .deviceType(record.getDeviceType())
            .status(record.getStatus())
            .remark(record.getRemark())
            .createdAt(record.getCreatedAt())
            .build();
    }
}

Step 2: 验证代码编译

Run: mvn clean compile

Expected: BUILD SUCCESS

Step 3: 提交代码

git add src/main/java/com/gym/manage/application/service/CheckinService.java
git commit -m "feat: add CheckinService with reactive implementation"

Task 4: 创建签到Controller

Files:

  • Create: src/main/java/com/gym/manage/api/controller/checkin/CheckinController.java

Step 1: 创建CheckinController

package com.gym.manage.api.controller.checkin;

import com.gym.manage.api.dto.request.CheckinCreateRequest;
import com.gym.manage.api.dto.response.CheckinRecordResponse;
import com.gym.manage.application.service.CheckinService;
import com.gym.manage.common.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Tag(name = "签到管理", description = "签到管理相关接口")
@RestController
@RequestMapping("/checkins")
@RequiredArgsConstructor
public class CheckinController {
    
    private final CheckinService checkinService;
    
    @Operation(summary = "会员签到", description = "会员扫码签到")
    @PostMapping
    public Mono<Result<CheckinRecordResponse>> checkin(@Valid @RequestBody CheckinCreateRequest request) {
        return checkinService.checkin(request)
            .map(Result::success);
    }
    
    @Operation(summary = "查询签到记录", description = "根据ID查询签到记录")
    @GetMapping("/{id}")
    public Mono<Result<CheckinRecordResponse>> getCheckin(@PathVariable Long id) {
        return checkinService.getCheckin(id)
            .map(Result::success);
    }
    
    @Operation(summary = "会员签到记录列表", description = "查询会员的签到记录列表")
    @GetMapping("/members/{memberId}")
    public Mono<Result<Flux<CheckinRecordResponse>>> listMemberCheckins(
        @PathVariable Long memberId,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size
    ) {
        return Mono.just(Result.success(checkinService.listMemberCheckins(memberId, page, size)));
    }
}

Step 2: 验证代码编译

Run: mvn clean compile

Expected: BUILD SUCCESS

Step 3: 提交代码

git add src/main/java/com/gym/manage/api/controller/checkin/CheckinController.java
git commit -m "feat: add CheckinController with RESTful API"

Task 5: 编写签到模块单元测试

Files:

  • Create: src/test/java/com/gym/manage/application/service/CheckinServiceTest.java

Step 1: 编写CheckinServiceTest

package com.gym.manage.application.service;

import com.gym.manage.api.dto.request.CheckinCreateRequest;
import com.gym.manage.api.dto.response.CheckinRecordResponse;
import com.gym.manage.common.exception.BusinessException;
import com.gym.manage.domain.entity.CheckinRecord;
import com.gym.manage.domain.entity.Member;
import com.gym.manage.domain.repository.CheckinRecordRepository;
import com.gym.manage.domain.repository.MemberRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class CheckinServiceTest {
    
    @Mock
    private CheckinRecordRepository checkinRecordRepository;
    
    @Mock
    private MemberRepository memberRepository;
    
    @InjectMocks
    private CheckinService checkinService;
    
    private Member testMember;
    private CheckinRecord testCheckinRecord;
    
    @BeforeEach
    void setUp() {
        testMember = new Member();
        testMember.setId(1L);
        testMember.setTenantId(1L);
        testMember.setStoreId(1L);
        testMember.setName("张三");
        testMember.setPhone("13800138000");
        
        testCheckinRecord = new CheckinRecord();
        testCheckinRecord.setId(1L);
        testCheckinRecord.setMemberId(1L);
        testCheckinRecord.setCheckinType("QR_CODE");
    }
    
    @Test
    void testCheckin_Success() {
        CheckinCreateRequest request = new CheckinCreateRequest();
        request.setMemberId(1L);
        request.setCheckinType("QR_CODE");
        
        when(memberRepository.findByIdAndDeletedAtIsNull(1L))
            .thenReturn(Mono.just(testMember));
        when(checkinRecordRepository.save(any(CheckinRecord.class)))
            .thenReturn(Mono.just(testCheckinRecord));
        
        StepVerifier.create(checkinService.checkin(request))
            .expectNextMatches(response -> response.getId().equals(1L))
            .verifyComplete();
        
        verify(memberRepository, times(1)).findByIdAndDeletedAtIsNull(1L);
        verify(checkinRecordRepository, times(1)).save(any(CheckinRecord.class));
    }
    
    @Test
    void testCheckin_MemberNotFound() {
        CheckinCreateRequest request = new CheckinCreateRequest();
        request.setMemberId(999L);
        request.setCheckinType("QR_CODE");
        
        when(memberRepository.findByIdAndDeletedAtIsNull(999L))
            .thenReturn(Mono.empty());
        
        StepVerifier.create(checkinService.checkin(request))
            .expectError(BusinessException.class)
            .verify();
        
        verify(memberRepository, times(1)).findByIdAndDeletedAtIsNull(999L);
        verify(checkinRecordRepository, never()).save(any(CheckinRecord.class));
    }
}

Step 2: 运行测试

Run: mvn test -Dtest=CheckinServiceTest

Expected: Tests run: 2, Failures: 0, Errors: 0, Skipped: 0

Step 3: 提交代码

git add src/test/java/com/gym/manage/application/service/CheckinServiceTest.java
git commit -m "test: add CheckinService unit tests"

模块二:权益模块

Task 6: 创建权益领域模型

Files:

  • Create: src/main/java/com/gym/manage/domain/entity/MemberBenefit.java
  • Create: src/main/java/com/gym/manage/domain/entity/BenefitRecord.java
  • Create: src/main/java/com/gym/manage/domain/repository/MemberBenefitRepository.java
  • Create: src/main/java/com/gym/manage/domain/repository/BenefitRecordRepository.java

Step 1: 创建MemberBenefit实体类

package com.gym.manage.domain.entity;

import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Data
@Table("member_benefit")
public class MemberBenefit {
    @Id
    private Long id;
    
    @Column("tenant_id")
    private Long tenantId;
    
    @Column("store_id")
    private Long storeId;
    
    @Column("member_id")
    private Long memberId;
    
    @Column("benefit_type")
    private String benefitType;
    
    @Column("benefit_name")
    private String benefitName;
    
    @Column("total_amount")
    private BigDecimal totalAmount;
    
    @Column("remaining_amount")
    private BigDecimal remainingAmount;
    
    @Column("unit")
    private String unit;
    
    @Column("status")
    private String status;
    
    @Column("expire_time")
    private LocalDateTime expireTime;
    
    @Column("remark")
    private String remark;
    
    @Column("created_at")
    private LocalDateTime createdAt;
    
    @Column("updated_at")
    private LocalDateTime updatedAt;
    
    @Column("deleted_at")
    private LocalDateTime deletedAt;
}

Step 2: 创建BenefitRecord实体类

package com.gym.manage.domain.entity;

import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Data
@Table("benefit_record")
public class BenefitRecord {
    @Id
    private Long id;
    
    @Column("tenant_id")
    private Long tenantId;
    
    @Column("store_id")
    private Long storeId;
    
    @Column("member_id")
    private Long memberId;
    
    @Column("benefit_id")
    private Long benefitId;
    
    @Column("change_type")
    private String changeType;
    
    @Column("change_amount")
    private BigDecimal changeAmount;
    
    @Column("before_amount")
    private BigDecimal beforeAmount;
    
    @Column("after_amount")
    private BigDecimal afterAmount;
    
    @Column("related_type")
    private String relatedType;
    
    @Column("related_id")
    private Long relatedId;
    
    @Column("remark")
    private String remark;
    
    @Column("created_at")
    private LocalDateTime createdAt;
}

Step 3: 创建MemberBenefitRepository接口

package com.gym.manage.domain.repository;

import com.gym.manage.domain.entity.MemberBenefit;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Repository
public interface MemberBenefitRepository extends R2dbcRepository<MemberBenefit, Long> {
    
    Flux<MemberBenefit> findByMemberIdAndDeletedAtIsNull(Long memberId);
    
    Mono<MemberBenefit> findByIdAndMemberIdAndDeletedAtIsNull(Long id, Long memberId);
    
    Flux<MemberBenefit> findByMemberIdAndBenefitTypeAndDeletedAtIsNull(Long memberId, String benefitType);
}

Step 4: 创建BenefitRecordRepository接口

package com.gym.manage.domain.repository;

import com.gym.manage.domain.entity.BenefitRecord;
import org.springframework.data.domain.Pageable;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Repository
public interface BenefitRecordRepository extends R2dbcRepository<BenefitRecord, Long> {
    
    Flux<BenefitRecord> findByMemberId(Long memberId, Pageable pageable);
    
    Flux<BenefitRecord> findByBenefitId(Long benefitId);
    
    Mono<Long> countByMemberId(Long memberId);
}

Step 5: 验证代码编译

Run: mvn clean compile

Expected: BUILD SUCCESS

Step 6: 提交代码

git add src/main/java/com/gym/manage/domain/entity/MemberBenefit.java
git add src/main/java/com/gym/manage/domain/entity/BenefitRecord.java
git add src/main/java/com/gym/manage/domain/repository/MemberBenefitRepository.java
git add src/main/java/com/gym/manage/domain/repository/BenefitRecordRepository.java
git commit -m "feat: add MemberBenefit and BenefitRecord entities and repositories"

Task 7: 创建权益DTO和Service

Files:

  • Create: src/main/java/com/gym/manage/api/dto/request/BenefitDeductRequest.java
  • Create: src/main/java/com/gym/manage/api/dto/response/MemberBenefitResponse.java
  • Create: src/main/java/com/gym/manage/api/dto/response/BenefitRecordResponse.java
  • Create: src/main/java/com/gym/manage/application/service/BenefitService.java

Step 1: 创建BenefitDeductRequest

package com.gym.manage.api.dto.request;

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;

import java.math.BigDecimal;

@Data
public class BenefitDeductRequest {
    @NotNull(message = "权益ID不能为空")
    private Long benefitId;
    
    @NotNull(message = "扣减数量不能为空")
    @Positive(message = "扣减数量必须大于0")
    private BigDecimal deductAmount;
    
    private String relatedType;
    
    private Long relatedId;
    
    private String remark;
}

Step 2: 创建MemberBenefitResponse

package com.gym.manage.api.dto.response;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MemberBenefitResponse {
    private Long id;
    private Long memberId;
    private String benefitType;
    private String benefitName;
    private BigDecimal totalAmount;
    private BigDecimal remainingAmount;
    private String unit;
    private String status;
    private LocalDateTime expireTime;
    private String remark;
    private LocalDateTime createdAt;
}

Step 3: 创建BenefitRecordResponse

package com.gym.manage.api.dto.response;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BenefitRecordResponse {
    private Long id;
    private Long memberId;
    private Long benefitId;
    private String changeType;
    private BigDecimal changeAmount;
    private BigDecimal beforeAmount;
    private BigDecimal afterAmount;
    private String relatedType;
    private Long relatedId;
    private String remark;
    private LocalDateTime createdAt;
}

Step 4: 创建BenefitService

package com.gym.manage.application.service;

import com.gym.manage.api.dto.request.BenefitDeductRequest;
import com.gym.manage.api.dto.response.BenefitRecordResponse;
import com.gym.manage.api.dto.response.MemberBenefitResponse;
import com.gym.manage.common.constant.ErrorCode;
import com.gym.manage.common.exception.BusinessException;
import com.gym.manage.domain.entity.BenefitRecord;
import com.gym.manage.domain.entity.MemberBenefit;
import com.gym.manage.domain.repository.BenefitRecordRepository;
import com.gym.manage.domain.repository.MemberBenefitRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Slf4j
@Service
@RequiredArgsConstructor
public class BenefitService {
    
    private final MemberBenefitRepository memberBenefitRepository;
    private final BenefitRecordRepository benefitRecordRepository;
    
    public Flux<MemberBenefitResponse> getMemberBenefits(Long memberId) {
        log.info("查询会员权益: memberId={}", memberId);
        
        return memberBenefitRepository.findByMemberIdAndDeletedAtIsNull(memberId)
            .map(this::toMemberBenefitResponse);
    }
    
    @Transactional
    public Mono<Void> deductBenefit(Long memberId, BenefitDeductRequest request) {
        log.info("扣减权益: memberId={}, benefitId={}, amount={}", 
            memberId, request.getBenefitId(), request.getDeductAmount());
        
        return memberBenefitRepository.findByIdAndMemberIdAndDeletedAtIsNull(
            request.getBenefitId(), memberId
        ).switchIfEmpty(Mono.error(new BusinessException(ErrorCode.BENEFIT_NOT_FOUND, "权益不存在")))
        .flatMap(benefit -> {
            if (benefit.getRemainingAmount().compareTo(request.getDeductAmount()) < 0) {
                return Mono.error(new BusinessException(ErrorCode.BENEFIT_INSUFFICIENT, "权益余额不足"));
            }
            
            BigDecimal beforeAmount = benefit.getRemainingAmount();
            BigDecimal afterAmount = beforeAmount.subtract(request.getDeductAmount());
            
            benefit.setRemainingAmount(afterAmount);
            benefit.setUpdatedAt(LocalDateTime.now());
            
            return memberBenefitRepository.save(benefit)
                .flatMap(saved -> createBenefitRecord(
                    saved, "DEDUCT", request.getDeductAmount(), 
                    beforeAmount, afterAmount, request
                ));
        })
        .doOnSuccess(v -> log.info("权益扣减成功"))
        .doOnError(e -> log.error("权益扣减失败: {}", e.getMessage()))
        .then();
    }
    
    private Mono<BenefitRecord> createBenefitRecord(
        MemberBenefit benefit, String changeType, BigDecimal changeAmount,
        BigDecimal beforeAmount, BigDecimal afterAmount, BenefitDeductRequest request
    ) {
        BenefitRecord record = new BenefitRecord();
        record.setTenantId(benefit.getTenantId());
        record.setStoreId(benefit.getStoreId());
        record.setMemberId(benefit.getMemberId());
        record.setBenefitId(benefit.getId());
        record.setChangeType(changeType);
        record.setChangeAmount(changeAmount);
        record.setBeforeAmount(beforeAmount);
        record.setAfterAmount(afterAmount);
        record.setRelatedType(request.getRelatedType());
        record.setRelatedId(request.getRelatedId());
        record.setRemark(request.getRemark());
        record.setCreatedAt(LocalDateTime.now());
        
        return benefitRecordRepository.save(record);
    }
    
    public Flux<BenefitRecordResponse> getMemberBenefitRecords(Long memberId, int page, int size) {
        log.info("查询会员权益记录: memberId={}", memberId);
        
        return benefitRecordRepository.findByMemberId(memberId, PageRequest.of(page, size))
            .map(this::toBenefitRecordResponse);
    }
    
    private MemberBenefitResponse toMemberBenefitResponse(MemberBenefit benefit) {
        return MemberBenefitResponse.builder()
            .id(benefit.getId())
            .memberId(benefit.getMemberId())
            .benefitType(benefit.getBenefitType())
            .benefitName(benefit.getBenefitName())
            .totalAmount(benefit.getTotalAmount())
            .remainingAmount(benefit.getRemainingAmount())
            .unit(benefit.getUnit())
            .status(benefit.getStatus())
            .expireTime(benefit.getExpireTime())
            .remark(benefit.getRemark())
            .createdAt(benefit.getCreatedAt())
            .build();
    }
    
    private BenefitRecordResponse toBenefitRecordResponse(BenefitRecord record) {
        return BenefitRecordResponse.builder()
            .id(record.getId())
            .memberId(record.getMemberId())
            .benefitId(record.getBenefitId())
            .changeType(record.getChangeType())
            .changeAmount(record.getChangeAmount())
            .beforeAmount(record.getBeforeAmount())
            .afterAmount(record.getAfterAmount())
            .relatedType(record.getRelatedType())
            .relatedId(record.getRelatedId())
            .remark(record.getRemark())
            .createdAt(record.getCreatedAt())
            .build();
    }
}

Step 5: 验证代码编译

Run: mvn clean compile

Expected: BUILD SUCCESS

Step 6: 提交代码

git add src/main/java/com/gym/manage/api/dto/request/BenefitDeductRequest.java
git add src/main/java/com/gym/manage/api/dto/response/MemberBenefitResponse.java
git add src/main/java/com/gym/manage/api/dto/response/BenefitRecordResponse.java
git add src/main/java/com/gym/manage/application/service/BenefitService.java
git commit -m "feat: add BenefitService with transaction support"

Task 8: 创建权益Controller

Files:

  • Create: src/main/java/com/gym/manage/api/controller/benefit/BenefitController.java

Step 1: 创建BenefitController

package com.gym.manage.api.controller.benefit;

import com.gym.manage.api.dto.request.BenefitDeductRequest;
import com.gym.manage.api.dto.response.BenefitRecordResponse;
import com.gym.manage.api.dto.response.MemberBenefitResponse;
import com.gym.manage.application.service.BenefitService;
import com.gym.manage.common.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Tag(name = "权益管理", description = "权益管理相关接口")
@RestController
@RequestMapping("/benefits")
@RequiredArgsConstructor
public class BenefitController {
    
    private final BenefitService benefitService;
    
    @Operation(summary = "查询会员权益", description = "查询会员的所有权益")
    @GetMapping("/members/{memberId}")
    public Mono<Result<Flux<MemberBenefitResponse>>> getMemberBenefits(@PathVariable Long memberId) {
        return Mono.just(Result.success(benefitService.getMemberBenefits(memberId)));
    }
    
    @Operation(summary = "扣减权益", description = "扣减会员权益")
    @PostMapping("/members/{memberId}/deduct")
    public Mono<Result<Void>> deductBenefit(
        @PathVariable Long memberId,
        @Valid @RequestBody BenefitDeductRequest request
    ) {
        return benefitService.deductBenefit(memberId, request)
            .then(Mono.just(Result.success()));
    }
    
    @Operation(summary = "查询权益记录", description = "查询会员的权益变更记录")
    @GetMapping("/members/{memberId}/records")
    public Mono<Result<Flux<BenefitRecordResponse>>> getMemberBenefitRecords(
        @PathVariable Long memberId,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size
    ) {
        return Mono.just(Result.success(benefitService.getMemberBenefitRecords(memberId, page, size)));
    }
}

Step 2: 验证代码编译

Run: mvn clean compile

Expected: BUILD SUCCESS

Step 3: 提交代码

git add src/main/java/com/gym/manage/api/controller/benefit/BenefitController.java
git commit -m "feat: add BenefitController with RESTful API"

模块三:订阅模块(简化版)

Task 9: 创建订阅模块核心代码

Files:

  • Create: src/main/java/com/gym/manage/domain/entity/SubscriptionRecord.java
  • Create: src/main/java/com/gym/manage/domain/repository/SubscriptionRecordRepository.java
  • Create: src/main/java/com/gym/manage/api/dto/response/SubscriptionRecordResponse.java
  • Create: src/main/java/com/gym/manage/application/service/SubscriptionService.java
  • Create: src/main/java/com/gym/manage/api/controller/subscription/SubscriptionController.java

Step 1: 创建SubscriptionRecord实体

package com.gym.manage.domain.entity;

import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Data
@Table("subscription_record")
public class SubscriptionRecord {
    @Id
    private Long id;
    
    @Column("tenant_id")
    private Long tenantId;
    
    @Column("store_id")
    private Long storeId;
    
    @Column("module_code")
    private String moduleCode;
    
    @Column("module_name")
    private String moduleName;
    
    @Column("subscription_type")
    private String subscriptionType;
    
    @Column("start_time")
    private LocalDateTime startTime;
    
    @Column("end_time")
    private LocalDateTime endTime;
    
    @Column("status")
    private String status;
    
    @Column("price")
    private BigDecimal price;
    
    @Column("paid_amount")
    private BigDecimal paidAmount;
    
    @Column("payment_method")
    private String paymentMethod;
    
    @Column("remark")
    private String remark;
    
    @Column("created_at")
    private LocalDateTime createdAt;
    
    @Column("updated_at")
    private LocalDateTime updatedAt;
    
    @Column("deleted_at")
    private LocalDateTime deletedAt;
}

Step 2: 创建Repository和Service(简化版)

// SubscriptionRecordRepository.java
package com.gym.manage.domain.repository;

import com.gym.manage.domain.entity.SubscriptionRecord;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Repository
public interface SubscriptionRecordRepository extends R2dbcRepository<SubscriptionRecord, Long> {
    Flux<SubscriptionRecord> findByTenantIdAndDeletedAtIsNull(Long tenantId);
    Mono<SubscriptionRecord> findByIdAndDeletedAtIsNull(Long id);
}
// SubscriptionService.java
package com.gym.manage.application.service;

import com.gym.manage.domain.entity.SubscriptionRecord;
import com.gym.manage.domain.repository.SubscriptionRecordRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Slf4j
@Service
@RequiredArgsConstructor
public class SubscriptionService {
    
    private final SubscriptionRecordRepository subscriptionRecordRepository;
    
    public Flux<SubscriptionRecord> getTenantSubscriptions(Long tenantId) {
        log.info("查询租户订阅: tenantId={}", tenantId);
        return subscriptionRecordRepository.findByTenantIdAndDeletedAtIsNull(tenantId);
    }
    
    public Mono<SubscriptionRecord> getSubscription(Long id) {
        log.info("查询订阅: subscriptionId={}", id);
        return subscriptionRecordRepository.findByIdAndDeletedAtIsNull(id);
    }
}

Step 3: 创建Controller

package com.gym.manage.api.controller.subscription;

import com.gym.manage.application.service.SubscriptionService;
import com.gym.manage.common.result.Result;
import com.gym.manage.domain.entity.SubscriptionRecord;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Tag(name = "订阅管理", description = "订阅管理相关接口")
@RestController
@RequestMapping("/subscriptions")
@RequiredArgsConstructor
public class SubscriptionController {
    
    private final SubscriptionService subscriptionService;
    
    @Operation(summary = "查询租户订阅", description = "查询租户的所有订阅")
    @GetMapping("/tenants/{tenantId}")
    public Mono<Result<Flux<SubscriptionRecord>>> getTenantSubscriptions(@PathVariable Long tenantId) {
        return Mono.just(Result.success(subscriptionService.getTenantSubscriptions(tenantId)));
    }
    
    @Operation(summary = "查询订阅", description = "根据ID查询订阅")
    @GetMapping("/{id}")
    public Mono<Result<SubscriptionRecord>> getSubscription(@PathVariable Long id) {
        return subscriptionService.getSubscription(id)
            .map(Result::success);
    }
}

Step 4: 验证代码编译

Run: mvn clean compile

Expected: BUILD SUCCESS

Step 5: 提交代码

git add src/main/java/com/gym/manage/domain/entity/SubscriptionRecord.java
git add src/main/java/com/gym/manage/domain/repository/SubscriptionRecordRepository.java
git add src/main/java/com/gym/manage/application/service/SubscriptionService.java
git add src/main/java/com/gym/manage/api/controller/subscription/SubscriptionController.java
git commit -m "feat: add Subscription module with basic CRUD operations"

模块四:营销模块(简化版)

Task 10: 创建营销模块核心代码

Files:

  • Create: src/main/java/com/gym/manage/domain/entity/MarketingCampaign.java
  • Create: src/main/java/com/gym/manage/domain/repository/MarketingCampaignRepository.java
  • Create: src/main/java/com/gym/manage/application/service/MarketingService.java
  • Create: src/main/java/com/gym/manage/api/controller/marketing/MarketingController.java

Step 1: 创建MarketingCampaign实体

package com.gym.manage.domain.entity;

import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;

import java.time.LocalDateTime;

@Data
@Table("marketing_campaign")
public class MarketingCampaign {
    @Id
    private Long id;
    
    @Column("tenant_id")
    private Long tenantId;
    
    @Column("store_id")
    private Long storeId;
    
    @Column("campaign_name")
    private String campaignName;
    
    @Column("campaign_type")
    private String campaignType;
    
    @Column("start_time")
    private LocalDateTime startTime;
    
    @Column("end_time")
    private LocalDateTime endTime;
    
    @Column("status")
    private String status;
    
    @Column("rules")
    private String rules;
    
    @Column("remark")
    private String remark;
    
    @Column("created_at")
    private LocalDateTime createdAt;
    
    @Column("updated_at")
    private LocalDateTime updatedAt;
    
    @Column("deleted_at")
    private LocalDateTime deletedAt;
}

Step 2: 创建Repository和Service

// MarketingCampaignRepository.java
package com.gym.manage.domain.repository;

import com.gym.manage.domain.entity.MarketingCampaign;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Repository
public interface MarketingCampaignRepository extends R2dbcRepository<MarketingCampaign, Long> {
    Flux<MarketingCampaign> findByTenantIdAndDeletedAtIsNull(Long tenantId);
    Mono<MarketingCampaign> findByIdAndDeletedAtIsNull(Long id);
}
// MarketingService.java
package com.gym.manage.application.service;

import com.gym.manage.domain.entity.MarketingCampaign;
import com.gym.manage.domain.repository.MarketingCampaignRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Slf4j
@Service
@RequiredArgsConstructor
public class MarketingService {
    
    private final MarketingCampaignRepository marketingCampaignRepository;
    
    public Flux<MarketingCampaign> getTenantCampaigns(Long tenantId) {
        log.info("查询租户营销活动: tenantId={}", tenantId);
        return marketingCampaignRepository.findByTenantIdAndDeletedAtIsNull(tenantId);
    }
    
    public Mono<MarketingCampaign> getCampaign(Long id) {
        log.info("查询营销活动: campaignId={}", id);
        return marketingCampaignRepository.findByIdAndDeletedAtIsNull(id);
    }
}

Step 3: 创建Controller

package com.gym.manage.api.controller.marketing;

import com.gym.manage.application.service.MarketingService;
import com.gym.manage.common.result.Result;
import com.gym.manage.domain.entity.MarketingCampaign;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Tag(name = "营销管理", description = "营销管理相关接口")
@RestController
@RequestMapping("/campaigns")
@RequiredArgsConstructor
public class MarketingController {
    
    private final MarketingService marketingService;
    
    @Operation(summary = "查询租户营销活动", description = "查询租户的所有营销活动")
    @GetMapping("/tenants/{tenantId}")
    public Mono<Result<Flux<MarketingCampaign>>> getTenantCampaigns(@PathVariable Long tenantId) {
        return Mono.just(Result.success(marketingService.getTenantCampaigns(tenantId)));
    }
    
    @Operation(summary = "查询营销活动", description = "根据ID查询营销活动")
    @GetMapping("/{id}")
    public Mono<Result<MarketingCampaign>> getCampaign(@PathVariable Long id) {
        return marketingService.getCampaign(id)
            .map(Result::success);
    }
}

Step 4: 验证代码编译

Run: mvn clean compile

Expected: BUILD SUCCESS

Step 5: 提交代码

git add src/main/java/com/gym/manage/domain/entity/MarketingCampaign.java
git add src/main/java/com/gym/manage/domain/repository/MarketingCampaignRepository.java
git add src/main/java/com/gym/manage/application/service/MarketingService.java
git add src/main/java/com/gym/manage/api/controller/marketing/MarketingController.java
git commit -m "feat: add Marketing module with basic CRUD operations"

模块五:数据分析模块(简化版)

Task 11: 创建数据分析模块

Files:

  • Create: src/main/java/com/gym/manage/api/dto/response/AnalyticsOverviewResponse.java
  • Create: src/main/java/com/gym/manage/application/service/AnalyticsService.java
  • Create: src/main/java/com/gym/manage/api/controller/analytics/AnalyticsController.java

Step 1: 创建AnalyticsOverviewResponse

package com.gym.manage.api.dto.response;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AnalyticsOverviewResponse {
    private Long totalMembers;
    private Long activeMembers;
    private Long todayCheckins;
    private Long todayBookings;
    private Long totalRevenue;
}

Step 2: 创建AnalyticsService

package com.gym.manage.application.service;

import com.gym.manage.api.dto.response.AnalyticsOverviewResponse;
import com.gym.manage.domain.repository.CheckinRecordRepository;
import com.gym.manage.domain.repository.MemberRepository;
import com.gym.manage.domain.repository.BookingRecordRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;

import java.time.LocalDateTime;

@Slf4j
@Service
@RequiredArgsConstructor
public class AnalyticsService {
    
    private final MemberRepository memberRepository;
    private final CheckinRecordRepository checkinRecordRepository;
    private final BookingRecordRepository bookingRecordRepository;
    
    public Mono<AnalyticsOverviewResponse> getOverview(Long tenantId, Long storeId) {
        log.info("查询数据概览: tenantId={}, storeId={}", tenantId, storeId);
        
        Mono<Long> totalMembers = memberRepository.countByTenantIdAndStoreId(tenantId, storeId);
        
        LocalDateTime todayStart = LocalDateTime.now().withHour(0).withMinute(0).withSecond(0);
        LocalDateTime todayEnd = LocalDateTime.now().withHour(23).withMinute(59).withSecond(59);
        
        Mono<Long> todayCheckins = checkinRecordRepository.countByMemberIdAndTimeRange(
            null, todayStart, todayEnd
        );
        
        return Mono.zip(totalMembers, todayCheckins)
            .map(tuple -> AnalyticsOverviewResponse.builder()
                .totalMembers(tuple.getT1())
                .activeMembers(tuple.getT1())
                .todayCheckins(tuple.getT2())
                .todayBookings(0L)
                .totalRevenue(0L)
                .build()
            );
    }
}

Step 3: 创建AnalyticsController

package com.gym.manage.api.controller.analytics;

import com.gym.manage.api.dto.response.AnalyticsOverviewResponse;
import com.gym.manage.application.service.AnalyticsService;
import com.gym.manage.common.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;

@Tag(name = "数据分析", description = "数据分析相关接口")
@RestController
@RequestMapping("/analytics")
@RequiredArgsConstructor
public class AnalyticsController {
    
    private final AnalyticsService analyticsService;
    
    @Operation(summary = "数据概览", description = "查询系统数据概览")
    @GetMapping("/overview")
    public Mono<Result<AnalyticsOverviewResponse>> getOverview(
        @RequestParam Long tenantId,
        @RequestParam Long storeId
    ) {
        return analyticsService.getOverview(tenantId, storeId)
            .map(Result::success);
    }
}

Step 4: 验证代码编译

Run: mvn clean compile

Expected: BUILD SUCCESS

Step 5: 提交代码

git add src/main/java/com/gym/manage/api/dto/response/AnalyticsOverviewResponse.java
git add src/main/java/com/gym/manage/application/service/AnalyticsService.java
git add src/main/java/com/gym/manage/api/controller/analytics/AnalyticsController.java
git commit -m "feat: add Analytics module with overview statistics"

模块六:性能测试

Task 12: 创建性能测试脚本

Files:

  • Create: performance-test/member-query-test.jmx

Step 1: 创建JMeter测试计划

创建文件 performance-test/member-query-test.jmx,内容参考POC实施计划文档中的JMeter配置。

Step 2: 运行性能测试

# 启动应用
mvn spring-boot:run

# 在另一个终端运行JMeter测试
jmeter -n -t performance-test/member-query-test.jmx -l results.jtl

Step 3: 分析性能指标

检查响应时间、吞吐量、错误率等指标是否符合预期。

Step 4: 提交测试脚本

git add performance-test/
git commit -m "test: add performance test scripts"

最终验证

Task 13: 完整功能测试

Step 1: 启动应用

Run: mvn spring-boot:run

Expected: 应用成功启动,监听8080端口

Step 2: 访问Swagger文档

Open: http://localhost:8080/swagger-ui.html

Expected: 所有API接口文档正常显示

Step 3: 测试核心功能

# 测试会员创建
curl -X POST http://localhost:8080/api/v1/members \
  -H "Content-Type: application/json" \
  -d '{"tenantId":1,"storeId":1,"name":"测试用户","phone":"13800138000"}'

# 测试签到
curl -X POST http://localhost:8080/api/v1/checkins \
  -H "Content-Type: application/json" \
  -d '{"memberId":1,"checkinType":"QR_CODE"}'

# 测试数据概览
curl -X GET "http://localhost:8080/api/v1/analytics/overview?tenantId=1&storeId=1"

Expected: 所有接口正常响应

Step 4: 运行所有测试

Run: mvn clean test

Expected: 所有测试通过

Step 5: 生成测试覆盖率报告

Run: mvn jacoco:report

Expected: 测试覆盖率报告生成在 target/site/jacoco/

Step 6: 最终提交

git add .
git commit -m "feat: complete POC implementation with all modules"
git tag v1.0.0-poc

验收标准

功能验收

  • 所有核心功能正常运行
  • 所有 API 接口正常响应
  • 所有单元测试通过
  • Swagger 文档完整

性能验收

  • 并发连接数 ≥ 1000
  • P99 响应时间 < 500ms
  • QPS ≥ 3000
  • 内存占用 < 1GB
  • CPU 利用率 < 60%

质量验收

  • 单元测试覆盖率 ≥ 80%
  • 无严重 Bug
  • 代码规范检查通过
  • 文档完整

总结

本实施计划涵盖了健身房管理系统POC的所有剩余模块,包括:

  1. 签到模块:完整的签到功能,支持多种签到方式
  2. 权益模块:权益管理和扣减,支持事务处理
  3. 订阅模块:订阅管理基础功能
  4. 营销模块:营销活动管理基础功能
  5. 数据分析模块:数据统计和概览
  6. 性能测试:完整的性能测试脚本和验证

每个任务都遵循TDD原则,包含完整的代码、测试步骤和验收标准。按照此计划实施,可以确保POC的完整性和质量。