From 85a179c5b4bf976141c23d2c10556c57bc98113f Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Thu, 21 Aug 2025 01:13:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(voucher):=20=E5=AE=9E=E7=8E=B0=E5=88=B8?= =?UTF-8?q?=E7=A0=81=E6=A0=B8=E9=94=80=E5=8A=9F=E8=83=BD=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加券码批次管理和券码管理相关接口和实现 - 新增券码生成、领取、使用等核心业务逻辑 - 实现了全场免费、商品降价、商品打折三种优惠模式 - 添加了券码状态管理和统计功能 - 优化了数据库表结构和索引 - 编写了详细的开发文档和使用示例 --- .../java/com/ycwl/basic/voucher/CLAUDE.md | 179 ++++++++++++++++ .../voucher/controller/VoucherController.java | 92 +++++++++ .../dto/req/VoucherBatchCreateReq.java | 15 ++ .../voucher/dto/req/VoucherBatchQueryReq.java | 14 ++ .../voucher/dto/req/VoucherClaimReq.java | 10 + .../voucher/dto/req/VoucherCodeQueryReq.java | 15 ++ .../voucher/dto/resp/VoucherBatchResp.java | 25 +++ .../dto/resp/VoucherBatchStatsResp.java | 15 ++ .../voucher/dto/resp/VoucherCodeResp.java | 27 +++ .../voucher/entity/VoucherBatchEntity.java | 30 +++ .../voucher/entity/VoucherCodeEntity.java | 27 +++ .../voucher/enums/VoucherCodeStatus.java | 27 +++ .../voucher/enums/VoucherDiscountType.java | 28 +++ .../voucher/mapper/VoucherBatchMapper.java | 9 + .../voucher/mapper/VoucherCodeMapper.java | 17 ++ .../voucher/service/VoucherBatchService.java | 27 +++ .../voucher/service/VoucherCodeService.java | 23 +++ .../service/impl/VoucherBatchServiceImpl.java | 190 +++++++++++++++++ .../service/impl/VoucherCodeServiceImpl.java | 194 ++++++++++++++++++ 19 files changed, 964 insertions(+) create mode 100644 src/main/java/com/ycwl/basic/voucher/CLAUDE.md create mode 100644 src/main/java/com/ycwl/basic/voucher/controller/VoucherController.java create mode 100644 src/main/java/com/ycwl/basic/voucher/dto/req/VoucherBatchCreateReq.java create mode 100644 src/main/java/com/ycwl/basic/voucher/dto/req/VoucherBatchQueryReq.java create mode 100644 src/main/java/com/ycwl/basic/voucher/dto/req/VoucherClaimReq.java create mode 100644 src/main/java/com/ycwl/basic/voucher/dto/req/VoucherCodeQueryReq.java create mode 100644 src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherBatchResp.java create mode 100644 src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherBatchStatsResp.java create mode 100644 src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherCodeResp.java create mode 100644 src/main/java/com/ycwl/basic/voucher/entity/VoucherBatchEntity.java create mode 100644 src/main/java/com/ycwl/basic/voucher/entity/VoucherCodeEntity.java create mode 100644 src/main/java/com/ycwl/basic/voucher/enums/VoucherCodeStatus.java create mode 100644 src/main/java/com/ycwl/basic/voucher/enums/VoucherDiscountType.java create mode 100644 src/main/java/com/ycwl/basic/voucher/mapper/VoucherBatchMapper.java create mode 100644 src/main/java/com/ycwl/basic/voucher/mapper/VoucherCodeMapper.java create mode 100644 src/main/java/com/ycwl/basic/voucher/service/VoucherBatchService.java create mode 100644 src/main/java/com/ycwl/basic/voucher/service/VoucherCodeService.java create mode 100644 src/main/java/com/ycwl/basic/voucher/service/impl/VoucherBatchServiceImpl.java create mode 100644 src/main/java/com/ycwl/basic/voucher/service/impl/VoucherCodeServiceImpl.java diff --git a/src/main/java/com/ycwl/basic/voucher/CLAUDE.md b/src/main/java/com/ycwl/basic/voucher/CLAUDE.md new file mode 100644 index 0000000..cadf343 --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/CLAUDE.md @@ -0,0 +1,179 @@ +# 券码核销功能模块 + +本模块实现景区券码的批量创建、分发和核销管理功能。支持全场免费、商品降价、商品打折三种优惠模式,确保每个用户在每个景区只能领取一次券码。 + +## 功能概述 + +- **批量创建券码**:管理员可创建券码批次,自动生成指定数量的唯一券码 +- **精准分发控制**:通过景区ID、推客ID、用户faceId进行精准投放 +- **三种优惠模式**:全场免费、商品降价、商品打折 +- **唯一性保证**:同一用户在同一景区只能领取一次券码 +- **完整管理功能**:批次管理、券码查询、使用统计、手动核销 + +## 数据库表结构 + +### 券码批次表 (voucher_batch) +```sql +CREATE TABLE voucher_batch ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + batch_name VARCHAR(100) NOT NULL COMMENT '券码批次名称', + scenic_id BIGINT NOT NULL COMMENT '景区ID', + broker_id BIGINT NOT NULL COMMENT '推客ID', + discount_type TINYINT NOT NULL COMMENT '优惠类型:0=全场免费,1=商品降价,2=商品打折', + discount_value DECIMAL(10,2) COMMENT '优惠值(降价金额或折扣百分比)', + total_count INT NOT NULL COMMENT '总券码数量', + used_count INT DEFAULT 0 COMMENT '已使用数量', + claimed_count INT DEFAULT 0 COMMENT '已领取数量', + status TINYINT DEFAULT 1 COMMENT '状态:0=禁用,1=启用', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + create_by BIGINT COMMENT '创建人ID', + deleted TINYINT DEFAULT 0, + deleted_at DATETIME +); +``` + +### 券码表 (voucher_code) +```sql +CREATE TABLE voucher_code ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + batch_id BIGINT NOT NULL COMMENT '批次ID', + scenic_id BIGINT NOT NULL COMMENT '景区ID', + code VARCHAR(32) NOT NULL UNIQUE COMMENT '券码', + status TINYINT DEFAULT 0 COMMENT '状态:0=未领取,1=已领取未使用,2=已使用', + face_id BIGINT COMMENT '领取人faceId', + claimed_time DATETIME COMMENT '领取时间', + used_time DATETIME COMMENT '使用时间', + remark VARCHAR(500) COMMENT '使用备注', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted TINYINT DEFAULT 0, + deleted_at DATETIME +); +``` + +## 包结构说明 + +``` +com.ycwl.basic.voucher/ +├── controller/ +│ └── VoucherController.java # 控制器:PC端管理和移动端用户接口 +├── service/ +│ ├── VoucherBatchService.java # 券码批次服务接口 +│ ├── VoucherCodeService.java # 券码服务接口 +│ └── impl/ +│ ├── VoucherBatchServiceImpl.java # 券码批次服务实现 +│ └── VoucherCodeServiceImpl.java # 券码服务实现 +├── mapper/ +│ ├── VoucherBatchMapper.java # 券码批次数据访问 +│ └── VoucherCodeMapper.java # 券码数据访问 +├── entity/ +│ ├── VoucherBatchEntity.java # 券码批次实体 +│ └── VoucherCodeEntity.java # 券码实体 +├── dto/ +│ ├── req/ # 请求DTO +│ │ ├── VoucherBatchCreateReq.java # 创建批次请求 +│ │ ├── VoucherBatchQueryReq.java # 批次查询请求 +│ │ ├── VoucherCodeQueryReq.java # 券码查询请求 +│ │ └── VoucherClaimReq.java # 券码领取请求 +│ └── resp/ # 响应DTO +│ ├── VoucherBatchResp.java # 批次响应 +│ ├── VoucherCodeResp.java # 券码响应 +│ └── VoucherBatchStatsResp.java # 批次统计响应 +└── enums/ + ├── VoucherDiscountType.java # 优惠类型枚举 + └── VoucherCodeStatus.java # 券码状态枚举 +``` + +## 核心业务逻辑 + +### 1. 券码生成规则 +- 使用UUID生成8位大写字母数字组合 +- 确保每个券码在系统中唯一 +- 券码初始状态为"未领取" + +### 2. 领取验证逻辑 +```java +// 核心验证:同一faceId在同一scenicId中只能领取一次 +public boolean canClaimVoucher(Long faceId, Long scenicId) { + Integer count = voucherCodeMapper.countByFaceIdAndScenicId(faceId, scenicId); + return count == 0; +} +``` + +### 3. 优惠类型说明 +- **全场免费(0)**:所有商品免费,discountValue可为空 +- **商品降价(1)**:每个商品减免固定金额,discountValue为减免金额 +- **商品打折(2)**:每个商品按百分比打折,discountValue为折扣百分比 + +### 4. 状态流转 +券码状态:未领取(0) → 已领取未使用(1) → 已使用(2) + +## 主要接口说明 + +### PC端管理接口 +- `POST /api/voucher/batch/create` - 创建券码批次 +- `POST /api/voucher/batch/list` - 批次列表查询 +- `GET /api/voucher/batch/{id}` - 批次详情 +- `GET /api/voucher/batch/{id}/stats` - 批次统计 +- `PUT /api/voucher/batch/{id}/status` - 启用/禁用批次 +- `POST /api/voucher/codes` - 券码列表查询 +- `PUT /api/voucher/code/{id}/use` - 手动标记券码已使用 +- `GET /api/voucher/scenic/{scenicId}/users` - 查看景区下用户领取情况 + +### 移动端用户接口 +- `POST /api/voucher/mobile/claim` - 领取券码(一步到位) +- `GET /api/voucher/mobile/my-codes` - 我的券码列表 + +## 关键技术特点 + +### 1. 事务管理 +- 使用`@Transactional`确保券码领取操作的原子性 +- 批次创建和券码生成在同一事务中完成 + +### 2. 并发控制 +- 通过应用层验证避免数据库唯一约束冲突 +- 使用数据库索引优化查询性能 + +### 3. 参数验证 +- 在Service层进行手动参数校验 +- 使用BizException统一错误处理 + +### 4. 数据统计 +- 实时更新批次的已领取数量和已使用数量 +- 提供详细的使用统计和分析数据 + +## 使用示例 + +### 创建券码批次 +```java +VoucherBatchCreateReq req = new VoucherBatchCreateReq(); +req.setBatchName("春节特惠券"); +req.setScenicId(1001L); +req.setBrokerId(2001L); +req.setDiscountType(1); // 商品降价 +req.setDiscountValue(new BigDecimal("10.00")); +req.setTotalCount(1000); + +Long batchId = voucherBatchService.createBatch(req); +``` + +### 用户领取券码 +```java +VoucherClaimReq req = new VoucherClaimReq(); +req.setScenicId(1001L); +req.setBrokerId(2001L); +req.setFaceId(3001L); + +VoucherCodeResp result = voucherCodeService.claimVoucher(req); +``` + +## 注意事项 + +1. **唯一性限制**:同一个faceId在同一个scenicId中只能领取一次券码 +2. **批次状态**:只有启用状态的批次才能领取券码 +3. **券码数量**:确保批次有可用券码才能成功领取 +4. **优惠值验证**:除全场免费外,其他优惠类型必须设置优惠值 +5. **删除机制**:使用逻辑删除,deleted字段标记删除状态 + +## 扩展说明 + +本模块设计为独立功能模块,不依赖支付系统,为后续接入其他优惠策略预留了扩展空间。所有接口都提供了完整的错误处理和参数验证,确保系统的稳定性和可维护性。 \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/controller/VoucherController.java b/src/main/java/com/ycwl/basic/voucher/controller/VoucherController.java new file mode 100644 index 0000000..fea2ab1 --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/controller/VoucherController.java @@ -0,0 +1,92 @@ +package com.ycwl.basic.voucher.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ycwl.basic.utils.ApiResponse; +import com.ycwl.basic.voucher.dto.req.VoucherBatchCreateReq; +import com.ycwl.basic.voucher.dto.req.VoucherBatchQueryReq; +import com.ycwl.basic.voucher.dto.req.VoucherClaimReq; +import com.ycwl.basic.voucher.dto.req.VoucherCodeQueryReq; +import com.ycwl.basic.voucher.dto.resp.VoucherBatchResp; +import com.ycwl.basic.voucher.dto.resp.VoucherBatchStatsResp; +import com.ycwl.basic.voucher.dto.resp.VoucherCodeResp; +import com.ycwl.basic.voucher.service.VoucherBatchService; +import com.ycwl.basic.voucher.service.VoucherCodeService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/voucher") +@RequiredArgsConstructor +public class VoucherController { + + private final VoucherBatchService voucherBatchService; + private final VoucherCodeService voucherCodeService; + + @PostMapping("/batch/create") + public ApiResponse createBatch(@RequestBody VoucherBatchCreateReq req) { + Long batchId = voucherBatchService.createBatch(req); + return ApiResponse.success(batchId); + } + + @PostMapping("/batch/list") + public ApiResponse> getBatchList(@RequestBody VoucherBatchQueryReq req) { + Page page = voucherBatchService.queryBatchList(req); + return ApiResponse.success(page); + } + + @GetMapping("/batch/{id}") + public ApiResponse getBatchDetail(@PathVariable Long id) { + VoucherBatchResp batch = voucherBatchService.getBatchDetail(id); + return ApiResponse.success(batch); + } + + @GetMapping("/batch/{id}/stats") + public ApiResponse getBatchStats(@PathVariable Long id) { + VoucherBatchStatsResp stats = voucherBatchService.getBatchStats(id); + return ApiResponse.success(stats); + } + + @PutMapping("/batch/{id}/status") + public ApiResponse updateBatchStatus(@PathVariable Long id, @RequestParam Integer status) { + voucherBatchService.updateBatchStatus(id, status); + return ApiResponse.success(null); + } + + @PostMapping("/codes") + public ApiResponse> getCodeList(@RequestBody VoucherCodeQueryReq req) { + Page page = voucherCodeService.queryCodeList(req); + return ApiResponse.success(page); + } + + @PutMapping("/code/{id}/use") + public ApiResponse markCodeAsUsed(@PathVariable Long id, @RequestParam(required = false) String remark) { + voucherCodeService.markCodeAsUsed(id, remark); + return ApiResponse.success(null); + } + + @GetMapping("/scenic/{scenicId}/users") + public ApiResponse> getUsersInScenic(@PathVariable Long scenicId, + @RequestParam(defaultValue = "1") Integer pageNum, + @RequestParam(defaultValue = "10") Integer pageSize) { + VoucherCodeQueryReq req = new VoucherCodeQueryReq(); + req.setScenicId(scenicId); + req.setPageNum(pageNum); + req.setPageSize(pageSize); + Page page = voucherCodeService.queryCodeList(req); + return ApiResponse.success(page); + } + + @PostMapping("/mobile/claim") + public ApiResponse claimVoucher(@RequestBody VoucherClaimReq req) { + VoucherCodeResp result = voucherCodeService.claimVoucher(req); + return ApiResponse.success(result); + } + + @GetMapping("/mobile/my-codes") + public ApiResponse> getMyVoucherCodes(@RequestParam Long faceId) { + List codes = voucherCodeService.getMyVoucherCodes(faceId); + return ApiResponse.success(codes); + } +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherBatchCreateReq.java b/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherBatchCreateReq.java new file mode 100644 index 0000000..47c1653 --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherBatchCreateReq.java @@ -0,0 +1,15 @@ +package com.ycwl.basic.voucher.dto.req; + +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class VoucherBatchCreateReq { + private String batchName; + private Long scenicId; + private Long brokerId; + private Integer discountType; + private BigDecimal discountValue; + private Integer totalCount; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherBatchQueryReq.java b/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherBatchQueryReq.java new file mode 100644 index 0000000..ce82a73 --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherBatchQueryReq.java @@ -0,0 +1,14 @@ +package com.ycwl.basic.voucher.dto.req; + +import com.ycwl.basic.model.common.BaseQueryParameterReq; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class VoucherBatchQueryReq extends BaseQueryParameterReq { + private Long scenicId; + private Long brokerId; + private Integer status; + private String batchName; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherClaimReq.java b/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherClaimReq.java new file mode 100644 index 0000000..e6d318d --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherClaimReq.java @@ -0,0 +1,10 @@ +package com.ycwl.basic.voucher.dto.req; + +import lombok.Data; + +@Data +public class VoucherClaimReq { + private Long scenicId; + private Long brokerId; + private Long faceId; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherCodeQueryReq.java b/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherCodeQueryReq.java new file mode 100644 index 0000000..ea853bd --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/dto/req/VoucherCodeQueryReq.java @@ -0,0 +1,15 @@ +package com.ycwl.basic.voucher.dto.req; + +import com.ycwl.basic.model.common.BaseQueryParameterReq; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class VoucherCodeQueryReq extends BaseQueryParameterReq { + private Long batchId; + private Long scenicId; + private Long faceId; + private Integer status; + private String code; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherBatchResp.java b/src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherBatchResp.java new file mode 100644 index 0000000..cbee09d --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherBatchResp.java @@ -0,0 +1,25 @@ +package com.ycwl.basic.voucher.dto.resp; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; + +@Data +public class VoucherBatchResp { + private Long id; + private String batchName; + private Long scenicId; + private Long brokerId; + private Integer discountType; + private String discountTypeName; + private BigDecimal discountValue; + private Integer totalCount; + private Integer usedCount; + private Integer claimedCount; + private Integer availableCount; + private Integer status; + private String statusName; + private Date createTime; + private Long createBy; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherBatchStatsResp.java b/src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherBatchStatsResp.java new file mode 100644 index 0000000..285aee1 --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherBatchStatsResp.java @@ -0,0 +1,15 @@ +package com.ycwl.basic.voucher.dto.resp; + +import lombok.Data; + +@Data +public class VoucherBatchStatsResp { + private Long batchId; + private String batchName; + private Integer totalCount; + private Integer claimedCount; + private Integer usedCount; + private Integer availableCount; + private Double claimedRate; + private Double usedRate; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherCodeResp.java b/src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherCodeResp.java new file mode 100644 index 0000000..6b9a498 --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/dto/resp/VoucherCodeResp.java @@ -0,0 +1,27 @@ +package com.ycwl.basic.voucher.dto.resp; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; + +@Data +public class VoucherCodeResp { + private Long id; + private Long batchId; + private String batchName; + private Long scenicId; + private String code; + private Integer status; + private String statusName; + private Long faceId; + private Date claimedTime; + private Date usedTime; + private String remark; + private Date createTime; + + private Integer discountType; + private String discountTypeName; + private String discountDescription; + private BigDecimal discountValue; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/entity/VoucherBatchEntity.java b/src/main/java/com/ycwl/basic/voucher/entity/VoucherBatchEntity.java new file mode 100644 index 0000000..9d2dfef --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/entity/VoucherBatchEntity.java @@ -0,0 +1,30 @@ +package com.ycwl.basic.voucher.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; + +@Data +@TableName("voucher_batch") +public class VoucherBatchEntity { + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private String batchName; + private Long scenicId; + private Long brokerId; + private Integer discountType; + private BigDecimal discountValue; + private Integer totalCount; + private Integer usedCount; + private Integer claimedCount; + private Integer status; + private Date createTime; + private Long createBy; + private Integer deleted; + private Date deletedAt; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/entity/VoucherCodeEntity.java b/src/main/java/com/ycwl/basic/voucher/entity/VoucherCodeEntity.java new file mode 100644 index 0000000..63a5dab --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/entity/VoucherCodeEntity.java @@ -0,0 +1,27 @@ +package com.ycwl.basic.voucher.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +@Data +@TableName("voucher_code") +public class VoucherCodeEntity { + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private Long batchId; + private Long scenicId; + private String code; + private Integer status; + private Long faceId; + private Date claimedTime; + private Date usedTime; + private String remark; + private Date createTime; + private Integer deleted; + private Date deletedAt; +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/enums/VoucherCodeStatus.java b/src/main/java/com/ycwl/basic/voucher/enums/VoucherCodeStatus.java new file mode 100644 index 0000000..a55bbca --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/enums/VoucherCodeStatus.java @@ -0,0 +1,27 @@ +package com.ycwl.basic.voucher.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum VoucherCodeStatus { + UNCLAIMED(0, "未领取"), + CLAIMED_UNUSED(1, "已领取未使用"), + USED(2, "已使用"); + + private final Integer code; + private final String name; + + public static VoucherCodeStatus getByCode(Integer code) { + if (code == null) { + return null; + } + for (VoucherCodeStatus status : values()) { + if (status.getCode().equals(code)) { + return status; + } + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/enums/VoucherDiscountType.java b/src/main/java/com/ycwl/basic/voucher/enums/VoucherDiscountType.java new file mode 100644 index 0000000..5e53d8d --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/enums/VoucherDiscountType.java @@ -0,0 +1,28 @@ +package com.ycwl.basic.voucher.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum VoucherDiscountType { + FREE_ALL(0, "全场免费", "所有商品免费"), + REDUCE_PRICE(1, "商品降价", "每个商品减免指定金额"), + DISCOUNT(2, "商品打折", "每个商品按百分比打折"); + + private final Integer code; + private final String name; + private final String description; + + public static VoucherDiscountType getByCode(Integer code) { + if (code == null) { + return null; + } + for (VoucherDiscountType type : values()) { + if (type.getCode().equals(code)) { + return type; + } + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/mapper/VoucherBatchMapper.java b/src/main/java/com/ycwl/basic/voucher/mapper/VoucherBatchMapper.java new file mode 100644 index 0000000..e3f561a --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/mapper/VoucherBatchMapper.java @@ -0,0 +1,9 @@ +package com.ycwl.basic.voucher.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ycwl.basic.voucher.entity.VoucherBatchEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface VoucherBatchMapper extends BaseMapper { +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/mapper/VoucherCodeMapper.java b/src/main/java/com/ycwl/basic/voucher/mapper/VoucherCodeMapper.java new file mode 100644 index 0000000..f7c6011 --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/mapper/VoucherCodeMapper.java @@ -0,0 +1,17 @@ +package com.ycwl.basic.voucher.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ycwl.basic.voucher.entity.VoucherCodeEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface VoucherCodeMapper extends BaseMapper { + + @Select("SELECT COUNT(*) FROM voucher_code WHERE scenic_id = #{scenicId} AND face_id = #{faceId} AND status != 0 AND deleted = 0") + Integer countByFaceIdAndScenicId(@Param("faceId") Long faceId, @Param("scenicId") Long scenicId); + + @Select("SELECT * FROM voucher_code WHERE batch_id = #{batchId} AND status = 0 AND deleted = 0 LIMIT 1") + VoucherCodeEntity findFirstAvailableByBatchId(@Param("batchId") Long batchId); +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/service/VoucherBatchService.java b/src/main/java/com/ycwl/basic/voucher/service/VoucherBatchService.java new file mode 100644 index 0000000..063e0b8 --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/service/VoucherBatchService.java @@ -0,0 +1,27 @@ +package com.ycwl.basic.voucher.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ycwl.basic.voucher.dto.req.VoucherBatchCreateReq; +import com.ycwl.basic.voucher.dto.req.VoucherBatchQueryReq; +import com.ycwl.basic.voucher.dto.resp.VoucherBatchResp; +import com.ycwl.basic.voucher.dto.resp.VoucherBatchStatsResp; +import com.ycwl.basic.voucher.entity.VoucherBatchEntity; + +public interface VoucherBatchService { + + Long createBatch(VoucherBatchCreateReq req); + + Page queryBatchList(VoucherBatchQueryReq req); + + VoucherBatchResp getBatchDetail(Long id); + + VoucherBatchStatsResp getBatchStats(Long id); + + void updateBatchStatus(Long id, Integer status); + + void updateBatchClaimedCount(Long batchId); + + void updateBatchUsedCount(Long batchId); + + VoucherBatchEntity getAvailableBatch(Long scenicId, Long brokerId); +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/service/VoucherCodeService.java b/src/main/java/com/ycwl/basic/voucher/service/VoucherCodeService.java new file mode 100644 index 0000000..118acf1 --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/service/VoucherCodeService.java @@ -0,0 +1,23 @@ +package com.ycwl.basic.voucher.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ycwl.basic.voucher.dto.req.VoucherClaimReq; +import com.ycwl.basic.voucher.dto.req.VoucherCodeQueryReq; +import com.ycwl.basic.voucher.dto.resp.VoucherCodeResp; + +import java.util.List; + +public interface VoucherCodeService { + + void generateVoucherCodes(Long batchId, Long scenicId, Integer count); + + VoucherCodeResp claimVoucher(VoucherClaimReq req); + + Page queryCodeList(VoucherCodeQueryReq req); + + List getMyVoucherCodes(Long faceId); + + void markCodeAsUsed(Long codeId, String remark); + + boolean canClaimVoucher(Long faceId, Long scenicId); +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/service/impl/VoucherBatchServiceImpl.java b/src/main/java/com/ycwl/basic/voucher/service/impl/VoucherBatchServiceImpl.java new file mode 100644 index 0000000..0a4bf9d --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/service/impl/VoucherBatchServiceImpl.java @@ -0,0 +1,190 @@ +package com.ycwl.basic.voucher.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ycwl.basic.exception.BizException; +import com.ycwl.basic.interceptor.BaseContextHandler; +import com.ycwl.basic.voucher.dto.req.VoucherBatchCreateReq; +import com.ycwl.basic.voucher.dto.req.VoucherBatchQueryReq; +import com.ycwl.basic.voucher.dto.resp.VoucherBatchResp; +import com.ycwl.basic.voucher.dto.resp.VoucherBatchStatsResp; +import com.ycwl.basic.voucher.entity.VoucherBatchEntity; +import com.ycwl.basic.voucher.enums.VoucherDiscountType; +import com.ycwl.basic.voucher.mapper.VoucherBatchMapper; +import com.ycwl.basic.voucher.service.VoucherBatchService; +import com.ycwl.basic.voucher.service.VoucherCodeService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.Date; + +@Service +@RequiredArgsConstructor +public class VoucherBatchServiceImpl implements VoucherBatchService { + + private final VoucherBatchMapper voucherBatchMapper; + private final VoucherCodeService voucherCodeService; + + @Override + @Transactional + public Long createBatch(VoucherBatchCreateReq req) { + if (req.getBatchName() == null || req.getBatchName().trim().isEmpty()) { + throw new BizException(400, "券码批次名称不能为空"); + } + if (req.getScenicId() == null) { + throw new BizException(400, "景区ID不能为空"); + } + if (req.getBrokerId() == null) { + throw new BizException(400, "推客ID不能为空"); + } + if (req.getDiscountType() == null) { + throw new BizException(400, "优惠类型不能为空"); + } + if (req.getTotalCount() == null || req.getTotalCount() < 1) { + throw new BizException(400, "券码数量必须大于0"); + } + + VoucherDiscountType discountType = VoucherDiscountType.getByCode(req.getDiscountType()); + if (discountType == null) { + throw new BizException(400, "无效的优惠类型"); + } + + if (discountType != VoucherDiscountType.FREE_ALL && req.getDiscountValue() == null) { + throw new BizException(400, "优惠金额不能为空"); + } + + VoucherBatchEntity batch = new VoucherBatchEntity(); + BeanUtils.copyProperties(req, batch); + batch.setUsedCount(0); + batch.setClaimedCount(0); + batch.setStatus(1); + batch.setCreateTime(new Date()); + batch.setCreateBy(BaseContextHandler.getUserId()); + batch.setDeleted(0); + + voucherBatchMapper.insert(batch); + + voucherCodeService.generateVoucherCodes(batch.getId(), req.getScenicId(), req.getTotalCount()); + + return batch.getId(); + } + + @Override + public Page queryBatchList(VoucherBatchQueryReq req) { + Page page = new Page<>(req.getPageNum(), req.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(VoucherBatchEntity::getDeleted, 0) + .eq(req.getScenicId() != null, VoucherBatchEntity::getScenicId, req.getScenicId()) + .eq(req.getBrokerId() != null, VoucherBatchEntity::getBrokerId, req.getBrokerId()) + .eq(req.getStatus() != null, VoucherBatchEntity::getStatus, req.getStatus()) + .like(StringUtils.hasText(req.getBatchName()), VoucherBatchEntity::getBatchName, req.getBatchName()) + .orderByDesc(VoucherBatchEntity::getCreateTime); + + Page entityPage = voucherBatchMapper.selectPage(page, wrapper); + + Page respPage = new Page<>(); + BeanUtils.copyProperties(entityPage, respPage); + + respPage.setRecords(entityPage.getRecords().stream().map(this::convertToResp).toList()); + + return respPage; + } + + @Override + public VoucherBatchResp getBatchDetail(Long id) { + VoucherBatchEntity batch = voucherBatchMapper.selectById(id); + if (batch == null || batch.getDeleted() == 1) { + throw new BizException(404, "券码批次不存在"); + } + + return convertToResp(batch); + } + + @Override + public VoucherBatchStatsResp getBatchStats(Long id) { + VoucherBatchEntity batch = voucherBatchMapper.selectById(id); + if (batch == null || batch.getDeleted() == 1) { + throw new BizException(404, "券码批次不存在"); + } + + VoucherBatchStatsResp stats = new VoucherBatchStatsResp(); + stats.setBatchId(batch.getId()); + stats.setBatchName(batch.getBatchName()); + stats.setTotalCount(batch.getTotalCount()); + stats.setClaimedCount(batch.getClaimedCount()); + stats.setUsedCount(batch.getUsedCount()); + stats.setAvailableCount(batch.getTotalCount() - batch.getClaimedCount()); + + if (batch.getTotalCount() > 0) { + stats.setClaimedRate((double) batch.getClaimedCount() / batch.getTotalCount() * 100); + stats.setUsedRate((double) batch.getUsedCount() / batch.getTotalCount() * 100); + } else { + stats.setClaimedRate(0.0); + stats.setUsedRate(0.0); + } + + return stats; + } + + @Override + public void updateBatchStatus(Long id, Integer status) { + VoucherBatchEntity batch = new VoucherBatchEntity(); + batch.setId(id); + batch.setStatus(status); + + int updated = voucherBatchMapper.updateById(batch); + if (updated == 0) { + throw new BizException(404, "券码批次不存在"); + } + } + + @Override + public void updateBatchClaimedCount(Long batchId) { + VoucherBatchEntity batch = voucherBatchMapper.selectById(batchId); + if (batch != null) { + batch.setClaimedCount(batch.getClaimedCount() + 1); + voucherBatchMapper.updateById(batch); + } + } + + @Override + public void updateBatchUsedCount(Long batchId) { + VoucherBatchEntity batch = voucherBatchMapper.selectById(batchId); + if (batch != null) { + batch.setUsedCount(batch.getUsedCount() + 1); + voucherBatchMapper.updateById(batch); + } + } + + @Override + public VoucherBatchEntity getAvailableBatch(Long scenicId, Long brokerId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(VoucherBatchEntity::getScenicId, scenicId) + .eq(VoucherBatchEntity::getBrokerId, brokerId) + .eq(VoucherBatchEntity::getStatus, 1) + .eq(VoucherBatchEntity::getDeleted, 0) + .lt(VoucherBatchEntity::getClaimedCount, VoucherBatchEntity::getTotalCount) + .orderByDesc(VoucherBatchEntity::getCreateTime); + + return voucherBatchMapper.selectOne(wrapper); + } + + private VoucherBatchResp convertToResp(VoucherBatchEntity batch) { + VoucherBatchResp resp = new VoucherBatchResp(); + BeanUtils.copyProperties(batch, resp); + + VoucherDiscountType discountType = VoucherDiscountType.getByCode(batch.getDiscountType()); + if (discountType != null) { + resp.setDiscountTypeName(discountType.getName()); + } + + resp.setStatusName(batch.getStatus() == 1 ? "启用" : "禁用"); + resp.setAvailableCount(batch.getTotalCount() - batch.getClaimedCount()); + + return resp; + } +} \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/voucher/service/impl/VoucherCodeServiceImpl.java b/src/main/java/com/ycwl/basic/voucher/service/impl/VoucherCodeServiceImpl.java new file mode 100644 index 0000000..c30ed73 --- /dev/null +++ b/src/main/java/com/ycwl/basic/voucher/service/impl/VoucherCodeServiceImpl.java @@ -0,0 +1,194 @@ +package com.ycwl.basic.voucher.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ycwl.basic.exception.BizException; +import com.ycwl.basic.voucher.dto.req.VoucherClaimReq; +import com.ycwl.basic.voucher.dto.req.VoucherCodeQueryReq; +import com.ycwl.basic.voucher.dto.resp.VoucherCodeResp; +import com.ycwl.basic.voucher.entity.VoucherBatchEntity; +import com.ycwl.basic.voucher.entity.VoucherCodeEntity; +import com.ycwl.basic.voucher.enums.VoucherCodeStatus; +import com.ycwl.basic.voucher.enums.VoucherDiscountType; +import com.ycwl.basic.voucher.mapper.VoucherBatchMapper; +import com.ycwl.basic.voucher.mapper.VoucherCodeMapper; +import com.ycwl.basic.voucher.service.VoucherBatchService; +import com.ycwl.basic.voucher.service.VoucherCodeService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class VoucherCodeServiceImpl implements VoucherCodeService { + + private final VoucherCodeMapper voucherCodeMapper; + private final VoucherBatchMapper voucherBatchMapper; + private final VoucherBatchService voucherBatchService; + + @Override + public void generateVoucherCodes(Long batchId, Long scenicId, Integer count) { + List codes = new ArrayList<>(); + + for (int i = 0; i < count; i++) { + VoucherCodeEntity code = new VoucherCodeEntity(); + code.setBatchId(batchId); + code.setScenicId(scenicId); + code.setCode(generateVoucherCode()); + code.setStatus(VoucherCodeStatus.UNCLAIMED.getCode()); + code.setCreateTime(new Date()); + code.setDeleted(0); + codes.add(code); + } + + for (VoucherCodeEntity code : codes) { + voucherCodeMapper.insert(code); + } + } + + @Override + @Transactional + public VoucherCodeResp claimVoucher(VoucherClaimReq req) { + if (req.getScenicId() == null) { + throw new BizException(400, "景区ID不能为空"); + } + if (req.getBrokerId() == null) { + throw new BizException(400, "推客ID不能为空"); + } + if (req.getFaceId() == null) { + throw new BizException(400, "用户faceId不能为空"); + } + + if (!canClaimVoucher(req.getFaceId(), req.getScenicId())) { + throw new BizException(400, "该用户在此景区已领取过券码"); + } + + VoucherBatchEntity batch = voucherBatchService.getAvailableBatch(req.getScenicId(), req.getBrokerId()); + if (batch == null) { + throw new BizException(400, "暂无可用券码批次"); + } + + VoucherCodeEntity availableCode = voucherCodeMapper.findFirstAvailableByBatchId(batch.getId()); + if (availableCode == null) { + throw new BizException(400, "券码已领完"); + } + + availableCode.setFaceId(req.getFaceId()); + availableCode.setStatus(VoucherCodeStatus.CLAIMED_UNUSED.getCode()); + availableCode.setClaimedTime(new Date()); + + voucherCodeMapper.updateById(availableCode); + + voucherBatchService.updateBatchClaimedCount(batch.getId()); + + return convertToResp(availableCode, batch); + } + + @Override + public Page queryCodeList(VoucherCodeQueryReq req) { + Page page = new Page<>(req.getPageNum(), req.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(VoucherCodeEntity::getDeleted, 0) + .eq(req.getBatchId() != null, VoucherCodeEntity::getBatchId, req.getBatchId()) + .eq(req.getScenicId() != null, VoucherCodeEntity::getScenicId, req.getScenicId()) + .eq(req.getFaceId() != null, VoucherCodeEntity::getFaceId, req.getFaceId()) + .eq(req.getStatus() != null, VoucherCodeEntity::getStatus, req.getStatus()) + .like(StringUtils.hasText(req.getCode()), VoucherCodeEntity::getCode, req.getCode()) + .orderByDesc(VoucherCodeEntity::getCreateTime); + + Page entityPage = voucherCodeMapper.selectPage(page, wrapper); + + Page respPage = new Page<>(); + BeanUtils.copyProperties(entityPage, respPage); + + List respList = new ArrayList<>(); + for (VoucherCodeEntity code : entityPage.getRecords()) { + VoucherBatchEntity batch = voucherBatchMapper.selectById(code.getBatchId()); + respList.add(convertToResp(code, batch)); + } + respPage.setRecords(respList); + + return respPage; + } + + @Override + public List getMyVoucherCodes(Long faceId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(VoucherCodeEntity::getFaceId, faceId) + .eq(VoucherCodeEntity::getDeleted, 0) + .orderByDesc(VoucherCodeEntity::getClaimedTime); + + List codes = voucherCodeMapper.selectList(wrapper); + + List respList = new ArrayList<>(); + for (VoucherCodeEntity code : codes) { + VoucherBatchEntity batch = voucherBatchMapper.selectById(code.getBatchId()); + respList.add(convertToResp(code, batch)); + } + + return respList; + } + + @Override + @Transactional + public void markCodeAsUsed(Long codeId, String remark) { + VoucherCodeEntity code = voucherCodeMapper.selectById(codeId); + if (code == null || code.getDeleted() == 1) { + throw new BizException(404, "券码不存在"); + } + + if (code.getStatus() != VoucherCodeStatus.CLAIMED_UNUSED.getCode()) { + throw new BizException(400, "券码状态异常,无法使用"); + } + + code.setStatus(VoucherCodeStatus.USED.getCode()); + code.setUsedTime(new Date()); + code.setRemark(remark); + + voucherCodeMapper.updateById(code); + + voucherBatchService.updateBatchUsedCount(code.getBatchId()); + } + + @Override + public boolean canClaimVoucher(Long faceId, Long scenicId) { + Integer count = voucherCodeMapper.countByFaceIdAndScenicId(faceId, scenicId); + return count == 0; + } + + private String generateVoucherCode() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(); + } + + private VoucherCodeResp convertToResp(VoucherCodeEntity code, VoucherBatchEntity batch) { + VoucherCodeResp resp = new VoucherCodeResp(); + BeanUtils.copyProperties(code, resp); + + if (batch != null) { + resp.setBatchName(batch.getBatchName()); + resp.setDiscountType(batch.getDiscountType()); + resp.setDiscountValue(batch.getDiscountValue()); + + VoucherDiscountType discountType = VoucherDiscountType.getByCode(batch.getDiscountType()); + if (discountType != null) { + resp.setDiscountTypeName(discountType.getName()); + resp.setDiscountDescription(discountType.getDescription()); + } + } + + VoucherCodeStatus status = VoucherCodeStatus.getByCode(code.getStatus()); + if (status != null) { + resp.setStatusName(status.getName()); + } + + return resp; + } +} \ No newline at end of file