feat: 删除JSP视图层,完善评价和通知系统,新增拼团模块

- 删除所有 JSP 页面(20个文件),前端完全迁移至 Vue 3 SPA
- 完善评价系统:ReviewDialog 组件、用户评价历史页、评价状态检查API
- 新增通知系统:Notification 实体/仓库/服务/控制器,NotificationCenter 接入真实API
- 新增拼团模块:GroupBuying 全套后端和前端页面
- 修复 review check API 参数双重包装导致请求格式错误
- 修复通知 API 路径缺少 /api 前缀和响应格式处理
- MessageListenerService 集成 NotificationService 创建持久化通知

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 16:40:26 +08:00
parent b684ea38d4
commit c4582655d9
115 changed files with 5968 additions and 12623 deletions

View File

@@ -2,8 +2,10 @@ package com.org.flashsalesystem;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class FlashSaleSystemApplication {
public static void main(String[] args) {

View File

@@ -292,4 +292,16 @@ public class RedissonConfig {
log.info("加载购物车操作Lua脚本");
return script;
}
/**
* 拼团库存扣减Lua脚本
*/
@Bean
public DefaultRedisScript<Long> groupBuyingStockScript() {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/groupbuying_stock.lua")));
script.setResultType(Long.class);
log.info("加载拼团库存扣减Lua脚本");
return script;
}
}

View File

@@ -150,6 +150,31 @@ public class FlashSaleController {
}
}
/**
* 获取秒杀活动统计信息
*/
@GetMapping("/statistics")
public ResponseEntity<Map<String, Object>> getFlashSaleStatistics(HttpServletRequest request) {
try {
Long userId = getCurrentUserId(request);
Map<String, Object> stats = flashSaleService.getFlashSaleStatistics(userId);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", stats);
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("获取秒杀统计失败", e);
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", e.getMessage());
return ResponseEntity.badRequest().body(response);
}
}
/**
* 获取秒杀活动详情
*/

View File

@@ -0,0 +1,277 @@
package com.org.flashsalesystem.controller;
import com.org.flashsalesystem.dto.GroupBuyingDTO;
import com.org.flashsalesystem.dto.UserDTO;
import com.org.flashsalesystem.service.GroupBuyingService;
import com.org.flashsalesystem.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.HashMap;
import java.util.Map;
@Tag(name = "拼团管理", description = "拼团活动创建、参与、团组管理等接口")
@RestController
@RequestMapping("/api/groupbuying")
@Slf4j
public class GroupBuyingController {
@Autowired
private GroupBuyingService groupBuyingService;
@Autowired
private UserService userService;
// ========== 用户端 ==========
@Operation(summary = "获取拼团活动列表")
@GetMapping("/list")
public ResponseEntity<Map<String, Object>> getList(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) Integer status) {
try {
Map<String, Object> result = groupBuyingService.getGroupBuyingList(page, size, status);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", result);
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("获取拼团活动列表失败", e);
return badRequest(e.getMessage());
}
}
@Operation(summary = "获取拼团活动详情")
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> getDetail(@PathVariable Long id) {
try {
GroupBuyingDTO detail = groupBuyingService.getGroupBuyingDetail(id);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", detail);
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("获取拼团活动详情失败", e);
return badRequest(e.getMessage());
}
}
@Operation(summary = "获取活动下的团组列表")
@GetMapping("/{id}/groups")
public ResponseEntity<Map<String, Object>> getGroups(
@PathVariable Long id,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
try {
Map<String, Object> result = groupBuyingService.getGroupsByActivity(id, page, size);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", result);
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("获取团组列表失败", e);
return badRequest(e.getMessage());
}
}
@Operation(summary = "参与拼团")
@PostMapping("/join")
public ResponseEntity<Map<String, Object>> joinGroup(
@Validated @RequestBody GroupBuyingDTO.JoinGroupDTO joinDTO,
HttpServletRequest request) {
try {
Long userId = getCurrentUserId(request);
if (userId == null) {
return createUnauthorizedResponse();
}
GroupBuyingDTO.JoinResultDTO result = groupBuyingService.joinGroupBuying(joinDTO, userId);
Map<String, Object> response = new HashMap<>();
response.put("success", result.getSuccess());
response.put("message", result.getMessage());
response.put("data", result);
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("参与拼团失败", e);
return badRequest(e.getMessage());
}
}
@Operation(summary = "获取团组详情")
@GetMapping("/group/{groupId}")
public ResponseEntity<Map<String, Object>> getGroupDetail(@PathVariable Long groupId) {
try {
GroupBuyingDTO.GroupInfoDTO detail = groupBuyingService.getGroupDetail(groupId);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", detail);
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("获取团组详情失败", e);
return badRequest(e.getMessage());
}
}
@Operation(summary = "退出团组")
@PostMapping("/group/{groupId}/cancel")
public ResponseEntity<Map<String, Object>> cancelMembership(
@PathVariable Long groupId,
HttpServletRequest request) {
try {
Long userId = getCurrentUserId(request);
if (userId == null) {
return createUnauthorizedResponse();
}
groupBuyingService.cancelMembership(groupId, userId);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "已退出团组");
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("退出团组失败", e);
return badRequest(e.getMessage());
}
}
@Operation(summary = "我的团组")
@GetMapping("/my-groups")
public ResponseEntity<Map<String, Object>> getMyGroups(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
HttpServletRequest request) {
try {
Long userId = getCurrentUserId(request);
if (userId == null) {
return createUnauthorizedResponse();
}
Map<String, Object> result = groupBuyingService.getMyGroups(userId, page, size);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", result);
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("获取我的团组失败", e);
return badRequest(e.getMessage());
}
}
@Operation(summary = "拼团统计数据")
@GetMapping("/statistics")
public ResponseEntity<Map<String, Object>> getStatistics(HttpServletRequest request) {
try {
Long userId = getCurrentUserId(request);
GroupBuyingDTO.StatisticsDTO stats = groupBuyingService.getStatistics(userId);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", stats);
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("获取拼团统计失败", e);
return badRequest(e.getMessage());
}
}
// ========== 管理员端 ==========
@Operation(summary = "创建拼团活动")
@PostMapping("/admin/create")
public ResponseEntity<Map<String, Object>> create(
@Validated @RequestBody GroupBuyingDTO.CreateDTO createDTO) {
try {
GroupBuyingDTO result = groupBuyingService.createGroupBuying(createDTO);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "拼团活动创建成功");
response.put("data", result);
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("创建拼团活动失败", e);
return badRequest(e.getMessage());
}
}
@Operation(summary = "更新拼团活动")
@PutMapping("/admin/{id}")
public ResponseEntity<Map<String, Object>> update(
@PathVariable Long id,
@Validated @RequestBody GroupBuyingDTO.UpdateDTO updateDTO) {
try {
GroupBuyingDTO result = groupBuyingService.updateGroupBuying(id, updateDTO);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "拼团活动更新成功");
response.put("data", result);
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("更新拼团活动失败", e);
return badRequest(e.getMessage());
}
}
@Operation(summary = "删除拼团活动")
@DeleteMapping("/admin/{id}")
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
try {
boolean success = groupBuyingService.deleteGroupBuying(id);
Map<String, Object> response = new HashMap<>();
response.put("success", success);
response.put("message", success ? "拼团活动删除成功" : "拼团活动删除失败");
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("删除拼团活动失败", e);
return badRequest(e.getMessage());
}
}
@Operation(summary = "预热所有拼团活动库存")
@PostMapping("/admin/preload-all")
public ResponseEntity<Map<String, Object>> preloadAll() {
try {
groupBuyingService.preloadAllActiveStock();
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "拼团活动库存预热完成");
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("预热拼团库存失败", e);
return badRequest(e.getMessage());
}
}
// ========== 工具方法 ==========
private Long getCurrentUserId(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) return null;
String token = (String) session.getAttribute("token");
UserDTO user = userService.getUserByToken(token);
return user != null ? user.getId() : null;
}
private ResponseEntity<Map<String, Object>> createUnauthorizedResponse() {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", "用户未登录或登录已过期");
return ResponseEntity.status(401).body(response);
}
private ResponseEntity<Map<String, Object>> badRequest(String message) {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", message);
return ResponseEntity.badRequest().body(response);
}
}

View File

@@ -0,0 +1,116 @@
package com.org.flashsalesystem.controller;
import com.org.flashsalesystem.dto.UserDTO;
import com.org.flashsalesystem.service.NotificationService;
import com.org.flashsalesystem.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/notification")
public class NotificationController {
@Autowired
private NotificationService notificationService;
@Autowired
private UserService userService;
@GetMapping("/list")
public ResponseEntity<Map<String, Object>> getNotifications(
@RequestParam(required = false) String type,
HttpServletRequest request) {
Long userId = getCurrentUserId(request);
if (userId == null) {
return unauthorized();
}
Map<String, Object> response = new HashMap<>();
response.put("success", true);
if (type != null && !type.isEmpty()) {
response.put("data", notificationService.getUserNotificationsByType(userId, type));
} else {
response.put("data", notificationService.getUserNotifications(userId));
}
return ResponseEntity.ok(response);
}
@GetMapping("/unread-count")
public ResponseEntity<Map<String, Object>> getUnreadCount(HttpServletRequest request) {
Long userId = getCurrentUserId(request);
if (userId == null) {
return unauthorized();
}
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", notificationService.getUnreadCount(userId));
return ResponseEntity.ok(response);
}
@PutMapping("/{id}/read")
public ResponseEntity<Map<String, Object>> markAsRead(@PathVariable Long id,
HttpServletRequest request) {
Long userId = getCurrentUserId(request);
if (userId == null) {
return unauthorized();
}
notificationService.markAsRead(id, userId);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "已标记为已读");
return ResponseEntity.ok(response);
}
@PutMapping("/read-all")
public ResponseEntity<Map<String, Object>> markAllAsRead(HttpServletRequest request) {
Long userId = getCurrentUserId(request);
if (userId == null) {
return unauthorized();
}
notificationService.markAllAsRead(userId);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "已全部标记为已读");
return ResponseEntity.ok(response);
}
@DeleteMapping("/clear")
public ResponseEntity<Map<String, Object>> clearAll(HttpServletRequest request) {
Long userId = getCurrentUserId(request);
if (userId == null) {
return unauthorized();
}
notificationService.clearAll(userId);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "已清空所有通知");
return ResponseEntity.ok(response);
}
private Long getCurrentUserId(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
String token = (String) session.getAttribute("token");
UserDTO user = userService.getUserByToken(token);
return user != null ? user.getId() : null;
}
private ResponseEntity<Map<String, Object>> unauthorized() {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", "用户未登录或登录已过期");
return ResponseEntity.status(401).body(response);
}
}

View File

@@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@@ -41,10 +42,52 @@ public class ProductReviewController {
return unauthorized();
}
Map<String, Object> response = new HashMap<>();
try {
response.put("success", true);
response.put("message", "评价提交成功");
response.put("data", productReviewService.createReview(userId, createDTO));
return ResponseEntity.ok(response);
} catch (RuntimeException e) {
response.put("success", false);
response.put("message", e.getMessage());
return ResponseEntity.badRequest().body(response);
}
}
@GetMapping("/check")
public ResponseEntity<Map<String, Object>> checkReview(@RequestParam Long orderId,
@RequestParam Long productId) {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "评价提交成功");
response.put("data", productReviewService.createReview(userId, createDTO));
response.put("data", productReviewService.checkReviewStatus(orderId, productId));
return ResponseEntity.ok(response);
}
@GetMapping("/my")
public ResponseEntity<Map<String, Object>> getMyReviews(HttpServletRequest request) {
Long userId = getCurrentUserId(request);
if (userId == null) {
return unauthorized();
}
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", productReviewService.getUserReviews(userId));
return ResponseEntity.ok(response);
}
@GetMapping("/order/{orderId}")
public ResponseEntity<Map<String, Object>> getOrderReviews(@PathVariable Long orderId,
HttpServletRequest request) {
Long userId = getCurrentUserId(request);
if (userId == null) {
return unauthorized();
}
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", productReviewService.getOrderReviews(orderId));
return ResponseEntity.ok(response);
}

View File

@@ -0,0 +1,173 @@
package com.org.flashsalesystem.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GroupBuyingDTO {
private Long id;
private Long productId;
private String productName;
private String productImageUrl;
private BigDecimal productPrice;
private BigDecimal groupPrice;
private Integer requiredMembers;
private Integer durationMinutes;
private Integer totalStock;
private Integer remainingStock;
private Integer maxPerUser;
private Integer status;
private String statusDescription;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updatedAt;
private Integer activeGroupCount;
private BigDecimal discount;
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class CreateDTO {
@NotNull(message = "商品ID不能为空")
private Long productId;
@NotNull(message = "拼团价格不能为空")
@DecimalMin(value = "0.01", message = "拼团价格必须大于0")
private BigDecimal groupPrice;
@Min(value = 2, message = "成团人数至少为2人")
private Integer requiredMembers = 2;
@Min(value = 1, message = "有效期至少为1分钟")
private Integer durationMinutes = 1440;
@Min(value = 1, message = "总库存至少为1")
private Integer totalStock;
@Min(value = 1, message = "每人限购至少为1")
private Integer maxPerUser = 1;
@NotNull(message = "开始时间不能为空")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startTime;
@NotNull(message = "结束时间不能为空")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endTime;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class UpdateDTO {
private Long productId;
private BigDecimal groupPrice;
private Integer requiredMembers;
private Integer durationMinutes;
private Integer totalStock;
private Integer maxPerUser;
private Integer status;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endTime;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class JoinGroupDTO {
@NotNull(message = "拼团活动ID不能为空")
private Long groupBuyingId;
private Long groupId;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class GroupInfoDTO {
private Long id;
private String groupNo;
private Long groupBuyingId;
private Long leaderUserId;
private String leaderUsername;
private Integer requiredMembers;
private Integer currentMembers;
private Integer status;
private String statusDescription;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime expireTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime completedAt;
private List<MemberDTO> members;
private GroupBuyingDTO groupBuying;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class MemberDTO {
private Long id;
private Long userId;
private String username;
private String avatar;
private Long orderId;
private Integer status;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime joinedAt;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class JoinResultDTO {
private Boolean success;
private String message;
private Long groupId;
private String groupNo;
private Long orderId;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class StatisticsDTO {
private Long totalActivities;
private Long activeActivities;
private Long myGroups;
private Long successGroups;
private BigDecimal totalSaved;
}
}

View File

@@ -25,6 +25,7 @@ public class OrderDTO {
private Long userId;
private String username;
private Long productId;
private Long flashSaleId;
private String productName;
private String productImageUrl;
private Integer quantity;

View File

@@ -20,6 +20,8 @@ public class ProductReviewDTO {
private Long userId;
private Long orderId;
private String username;
private String productName;
private String productImage;
private Integer rating;
private String content;
private Integer status;
@@ -64,4 +66,12 @@ public class ProductReviewDTO {
private Integer status;
private String adminReply;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class CheckDTO {
private boolean reviewed;
private ProductReviewDTO review;
}
}

View File

@@ -0,0 +1,98 @@
package com.org.flashsalesystem.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "group_buying")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GroupBuying {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "product_id", nullable = false)
private Long productId;
@Column(name = "group_price", nullable = false, precision = 10, scale = 2)
private BigDecimal groupPrice;
@Column(name = "required_members", nullable = false)
private Integer requiredMembers = 2;
@Column(name = "duration_minutes", nullable = false)
private Integer durationMinutes = 1440;
@Column(name = "total_stock", nullable = false)
private Integer totalStock;
@Column(name = "remaining_stock", nullable = false)
private Integer remainingStock;
@Column(name = "max_per_user", nullable = false)
private Integer maxPerUser = 1;
/**
* 状态0-草稿 1-未开始 2-进行中 3-已结束
*/
@Column(nullable = false)
private Integer status = 0;
@Column(name = "start_time", nullable = false)
private LocalDateTime startTime;
@Column(name = "end_time", nullable = false)
private LocalDateTime endTime;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", insertable = false, updatable = false)
private Product product;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
public boolean isActive() {
LocalDateTime now = LocalDateTime.now();
return now.isAfter(startTime) && now.isBefore(endTime) && status == 2;
}
public enum GroupBuyingStatus {
DRAFT(0, "草稿"),
PENDING(1, "未开始"),
ACTIVE(2, "进行中"),
ENDED(3, "已结束");
private final int code;
private final String description;
GroupBuyingStatus(int code, String description) {
this.code = code;
this.description = description;
}
public int getCode() { return code; }
public String getDescription() { return description; }
}
}

View File

@@ -0,0 +1,80 @@
package com.org.flashsalesystem.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "group_buying_group")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GroupBuyingGroup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "group_no", nullable = false, unique = true, length = 64)
private String groupNo;
@Column(name = "group_buying_id", nullable = false)
private Long groupBuyingId;
@Column(name = "leader_user_id", nullable = false)
private Long leaderUserId;
@Column(name = "required_members", nullable = false)
private Integer requiredMembers;
@Column(name = "current_members", nullable = false)
private Integer currentMembers = 1;
/**
* 状态1-拼团中 2-已成团 3-已失败(超时)
*/
@Column(nullable = false)
private Integer status = 1;
@Column(name = "expire_time", nullable = false)
private LocalDateTime expireTime;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "completed_at")
private LocalDateTime completedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "group_buying_id", insertable = false, updatable = false)
private GroupBuying groupBuying;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "leader_user_id", insertable = false, updatable = false)
private User leader;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
public enum GroupStatus {
FORMING(1, "拼团中"),
SUCCESS(2, "已成团"),
FAILED(3, "已失败");
private final int code;
private final String description;
GroupStatus(int code, String description) {
this.code = code;
this.description = description;
}
public int getCode() { return code; }
public String getDescription() { return description; }
}
}

View File

@@ -0,0 +1,53 @@
package com.org.flashsalesystem.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "group_buying_member", uniqueConstraints = {
@UniqueConstraint(name = "uk_group_user", columnNames = {"group_id", "user_id"})
})
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GroupBuyingMember {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "group_id", nullable = false)
private Long groupId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "order_id")
private Long orderId;
/**
* 状态1-已加入 2-已成团 3-已退出
*/
@Column(nullable = false)
private Integer status = 1;
@Column(name = "joined_at", nullable = false, updatable = false)
private LocalDateTime joinedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "group_id", insertable = false, updatable = false)
private GroupBuyingGroup group;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", insertable = false, updatable = false)
private User user;
@PrePersist
protected void onCreate() {
joinedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,49 @@
package com.org.flashsalesystem.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "notifications", indexes = {
@Index(name = "idx_notification_user_read", columnList = "user_id, is_read"),
@Index(name = "idx_notification_user_created", columnList = "user_id, created_at")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Notification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(nullable = false, length = 32)
private String type;
@Column(nullable = false)
private String title;
@Column(nullable = false, columnDefinition = "TEXT")
private String message;
@Column(length = 255)
private String link;
@Column(name = "is_read", nullable = false)
private Boolean read = false;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}

View File

@@ -40,6 +40,12 @@ public class Order {
@Column(name = "product_id", nullable = false)
private Long productId;
@Column(name = "flash_sale_id")
private Long flashSaleId;
@Column(name = "group_buying_group_id")
private Long groupBuyingGroupId;
@Min(value = 1, message = "商品数量必须大于0")
@Column(nullable = false)
private Integer quantity;
@@ -145,7 +151,8 @@ public class Order {
*/
public enum OrderType {
NORMAL(1, "普通订单"),
FLASH_SALE(2, "秒杀订单");
FLASH_SALE(2, "秒杀订单"),
GROUP_BUYING(3, "拼团订单");
private final int code;
private final String description;

View File

@@ -11,7 +11,6 @@ import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 秒杀活动数据访问层
@@ -19,16 +18,18 @@ import java.util.Optional;
@Repository
public interface FlashSaleRepository extends JpaRepository<FlashSale, Long> {
/**
* 根据商品ID查找秒杀活动
*/
Optional<FlashSale> findByProductId(Long productId);
/**
* 分页查找指定商品的秒杀活动
*/
Page<FlashSale> findByProductId(Long productId, Pageable pageable);
/**
* 查找指定时间点覆盖的商品秒杀活动
*/
@Query("SELECT f FROM FlashSale f WHERE f.productId = :productId AND f.startTime <= :targetTime AND f.endTime >= :targetTime")
List<FlashSale> findByProductIdAndCoveringTime(@Param("productId") Long productId,
@Param("targetTime") LocalDateTime targetTime);
/**
* 根据商品ID和状态查找秒杀活动
*/
@@ -78,6 +79,13 @@ public interface FlashSaleRepository extends JpaRepository<FlashSale, Long> {
" >= :quantity")
int updateFlashStock(@Param("flashSaleId") Long flashSaleId, @Param("quantity") Integer quantity);
/**
* 恢复秒杀库存(订单取消时使用)
*/
@Modifying
@Query("UPDATE FlashSale f SET f.flashStock = f.flashStock + :quantity WHERE f.id = :flashSaleId")
int increaseFlashStock(@Param("flashSaleId") Long flashSaleId, @Param("quantity") Integer quantity);
/**
* 更新秒杀活动状态
*/

View File

@@ -0,0 +1,47 @@
package com.org.flashsalesystem.repository;
import com.org.flashsalesystem.entity.GroupBuyingGroup;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Repository
public interface GroupBuyingGroupRepository extends JpaRepository<GroupBuyingGroup, Long> {
List<GroupBuyingGroup> findByGroupBuyingIdAndStatus(Long groupBuyingId, Integer status);
Optional<GroupBuyingGroup> findByGroupNo(String groupNo);
@Query("SELECT g FROM GroupBuyingGroup g WHERE g.status = 1 AND g.expireTime < :now")
List<GroupBuyingGroup> findExpiredGroups(@Param("now") LocalDateTime now);
List<GroupBuyingGroup> findByLeaderUserId(Long userId);
Page<GroupBuyingGroup> findByGroupBuyingId(Long groupBuyingId, Pageable pageable);
@Query("SELECT g FROM GroupBuyingGroup g WHERE g.id IN " +
"(SELECT m.groupId FROM GroupBuyingMember m WHERE m.userId = :userId AND m.status != 3)")
Page<GroupBuyingGroup> findByMemberUserId(@Param("userId") Long userId, Pageable pageable);
long countByGroupBuyingIdAndStatus(Long groupBuyingId, Integer status);
@Modifying
@Query("UPDATE GroupBuyingGroup g SET g.currentMembers = g.currentMembers + 1 WHERE g.id = :id")
int incrementCurrentMembers(@Param("id") Long id);
@Modifying
@Query("UPDATE GroupBuyingGroup g SET g.currentMembers = g.currentMembers - 1 WHERE g.id = :id AND g.currentMembers > 0")
int decrementCurrentMembers(@Param("id") Long id);
@Modifying
@Query("UPDATE GroupBuyingGroup g SET g.status = :status, g.completedAt = :completedAt WHERE g.id = :id")
int updateStatusAndCompletedAt(@Param("id") Long id, @Param("status") Integer status, @Param("completedAt") LocalDateTime completedAt);
}

View File

@@ -0,0 +1,29 @@
package com.org.flashsalesystem.repository;
import com.org.flashsalesystem.entity.GroupBuyingMember;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface GroupBuyingMemberRepository extends JpaRepository<GroupBuyingMember, Long> {
List<GroupBuyingMember> findByGroupId(Long groupId);
List<GroupBuyingMember> findByGroupIdAndStatus(Long groupId, Integer status);
boolean existsByGroupIdAndUserIdAndStatusNot(Long groupId, Long userId, Integer excludeStatus);
Optional<GroupBuyingMember> findByGroupIdAndUserId(Long groupId, Long userId);
long countByGroupIdAndStatusNot(Long groupId, Integer excludeStatus);
@Modifying
@Query("UPDATE GroupBuyingMember m SET m.status = :status WHERE m.groupId = :groupId AND m.status = 1")
int updateStatusByGroupId(@Param("groupId") Long groupId, @Param("status") Integer status);
}

View File

@@ -0,0 +1,45 @@
package com.org.flashsalesystem.repository;
import com.org.flashsalesystem.entity.GroupBuying;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface GroupBuyingRepository extends JpaRepository<GroupBuying, Long> {
@Query("SELECT g FROM GroupBuying g WHERE g.startTime <= :now AND g.endTime > :now AND g.status = 2")
List<GroupBuying> findActiveGroupBuyings(@Param("now") LocalDateTime now);
@Query("SELECT g FROM GroupBuying g WHERE g.startTime <= :now AND g.endTime > :now AND g.status = 2")
Page<GroupBuying> findActiveGroupBuyings(@Param("now") LocalDateTime now, Pageable pageable);
@Query("SELECT g FROM GroupBuying g WHERE g.startTime > :now AND g.status = 1")
List<GroupBuying> findUpcomingGroupBuyings(@Param("now") LocalDateTime now);
@Query("SELECT g FROM GroupBuying g WHERE g.endTime <= :now OR g.status = 3")
Page<GroupBuying> findEndedGroupBuyings(@Param("now") LocalDateTime now, Pageable pageable);
Page<GroupBuying> findByStatus(Integer status, Pageable pageable);
@Modifying
@Query("UPDATE GroupBuying g SET g.remainingStock = g.remainingStock - :quantity WHERE g.id = :id AND g.remainingStock >= :quantity")
int updateStock(@Param("id") Long id, @Param("quantity") Integer quantity);
@Modifying
@Query("UPDATE GroupBuying g SET g.remainingStock = g.remainingStock + :quantity WHERE g.id = :id")
int increaseStock(@Param("id") Long id, @Param("quantity") Integer quantity);
@Modifying
@Query("UPDATE GroupBuying g SET g.status = :status WHERE g.id = :id")
int updateStatus(@Param("id") Long id, @Param("status") Integer status);
long countByStatus(Integer status);
}

View File

@@ -0,0 +1,30 @@
package com.org.flashsalesystem.repository;
import com.org.flashsalesystem.entity.Notification;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface NotificationRepository extends JpaRepository<Notification, Long> {
List<Notification> findByUserIdOrderByCreatedAtDesc(Long userId);
List<Notification> findByUserIdAndTypeOrderByCreatedAtDesc(Long userId, String type);
long countByUserIdAndReadFalse(Long userId);
@Modifying
@Query("UPDATE Notification n SET n.read = true WHERE n.userId = :userId AND n.read = false")
int markAllAsRead(@Param("userId") Long userId);
@Modifying
@Query("UPDATE Notification n SET n.read = true WHERE n.id = :id AND n.userId = :userId")
int markAsRead(@Param("id") Long id, @Param("userId") Long userId);
void deleteByUserId(Long userId);
}

View File

@@ -49,6 +49,23 @@ public interface OrderRepository extends JpaRepository<Order, Long> {
*/
Page<Order> findByOrderType(Integer orderType, Pageable pageable);
/**
* 分页查找用户指定类型的订单
*/
Page<Order> findByUserIdAndOrderType(Long userId, Integer orderType, Pageable pageable);
/**
* 统计用户指定类型的订单数量
*/
@Query("SELECT COUNT(o) FROM Order o WHERE o.userId = :userId AND o.orderType = :orderType")
Long countByUserIdAndOrderType(@Param("userId") Long userId, @Param("orderType") Integer orderType);
/**
* 统计用户指定类型且非取消的订单数量(抢购成功)
*/
@Query("SELECT COUNT(o) FROM Order o WHERE o.userId = :userId AND o.orderType = :orderType AND o.status != 5")
Long countByUserIdAndOrderTypeAndStatusNot5(@Param("userId") Long userId, @Param("orderType") Integer orderType);
/**
* 查找秒杀订单
*/
@@ -116,10 +133,14 @@ public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findFlashSaleOrdersByUserId(@Param("userId") Long userId);
/**
* 检查用户是否已经购买过指定商品的秒杀
* 检查用户是否已经参与过指定秒杀活动
*/
@Query("SELECT COUNT(o) > 0 FROM Order o WHERE o.userId = :userId AND o.productId = :productId AND o.orderType = 2")
boolean existsFlashSaleOrder(@Param("userId") Long userId, @Param("productId") Long productId);
boolean existsByUserIdAndFlashSaleIdAndOrderType(Long userId, Long flashSaleId, Integer orderType);
/**
* 检查指定秒杀活动是否已有订单
*/
boolean existsByFlashSaleIdAndOrderType(Long flashSaleId, Integer orderType);
/**
* 根据创建时间范围统计订单数量

View File

@@ -18,6 +18,16 @@ public interface ProductReviewRepository extends JpaRepository<ProductReview, Lo
long countByProductId(Long productId);
long countByProductIdAndStatus(Long productId, Integer status);
@Query("SELECT AVG(r.rating) FROM ProductReview r WHERE r.productId = :productId")
Double findAverageRatingByProductId(@Param("productId") Long productId);
List<ProductReview> findByUserIdOrderByCreatedAtDesc(Long userId);
List<ProductReview> findByOrderId(Long orderId);
boolean existsByOrderIdAndProductId(Long orderId, Long productId);
Optional<ProductReview> findByOrderIdAndProductId(Long orderId, Long productId);
}

View File

@@ -8,6 +8,8 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashMap;
@@ -318,6 +320,7 @@ public class CartService {
/**
* 购物车下单
*/
@Transactional
public OrderDTO checkoutCart(Long userId, List<Long> productIds) {
log.info("购物车下单: 用户ID={}, 商品IDs={}", userId, productIds);

View File

@@ -16,6 +16,7 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -90,10 +91,9 @@ public class FlashSaleService {
throw new RuntimeException("开始时间不能早于当前时间");
}
// 检查是否已有该商品的秒杀活动
Optional<FlashSale> existingFlashSale = flashSaleRepository.findByProductId(createDTO.getProductId());
if (existingFlashSale.isPresent()) {
throw new RuntimeException("该商品已有秒杀活动");
// 验证秒杀价格必须小于商品原价
if (createDTO.getFlashPrice().compareTo(product.getPrice()) >= 0) {
throw new RuntimeException("秒杀价格必须小于商品原价");
}
// 创建秒杀活动
@@ -157,8 +157,8 @@ public class FlashSaleService {
}
// 检查数据库中是否已有订单
if (orderRepository.existsFlashSaleOrder(userId, flashSale.getProductId())) {
return createFailResult("您已经购买过该商品");
if (orderRepository.existsByUserIdAndFlashSaleIdAndOrderType(userId, flashSale.getId(), 2)) {
return createFailResult("您已经参与过该秒杀活动");
}
// 检查购买数量限制
@@ -174,6 +174,14 @@ public class FlashSaleService {
}
try {
// 二次校验:锁内重新检查用户是否已参与(防止并发竞态)
if (redisService.sIsMember(successUsersKey, userId)) {
return createFailResult("您已经参与过该秒杀活动");
}
if (orderRepository.existsByUserIdAndFlashSaleIdAndOrderType(userId, flashSale.getId(), 2)) {
return createFailResult("您已经参与过该秒杀活动");
}
// 检查并修复库存数据
String stockKey = FLASH_SALE_STOCK_PREFIX + flashSale.getId();
String currentStock = redisService.getString(stockKey);
@@ -300,9 +308,11 @@ public class FlashSaleService {
// 验证排序字段
String sortBy = validateSortField(queryDTO.getSortBy());
// 限制分页大小
int pageSize = Math.min(queryDTO.getSize(), 100);
// 构建分页和排序
Sort sort = Sort.by(Sort.Direction.fromString(queryDTO.getSortDirection()), sortBy);
Pageable pageable = PageRequest.of(queryDTO.getPage(), queryDTO.getSize(), sort);
Pageable pageable = PageRequest.of(queryDTO.getPage(), pageSize, sort);
Page<FlashSale> flashSalePage;
LocalDateTime now = LocalDateTime.now();
@@ -621,6 +631,55 @@ public class FlashSaleService {
}
}
/**
* 恢复秒杀库存(订单取消时调用)
* 恢复Redis库存、DB库存并移除成功用户集合记录
*/
@Transactional
public void restoreFlashSaleStock(Long flashSaleId, Long productId, LocalDateTime orderCreatedAt, Long userId,
Integer quantity) {
log.info("恢复秒杀库存: flashSaleId={}, productId={}, orderCreatedAt={}, userId={}, quantity={}",
flashSaleId, productId, orderCreatedAt, userId, quantity);
Optional<FlashSale> flashSaleOpt = Optional.empty();
if (flashSaleId == null) {
List<FlashSale> matchedFlashSales = flashSaleRepository.findByProductIdAndCoveringTime(productId,
orderCreatedAt);
if (matchedFlashSales.size() == 1) {
flashSaleOpt = Optional.of(matchedFlashSales.get(0));
log.info("根据商品和下单时间回填秒杀活动: flashSaleId={}, productId={}",
flashSaleOpt.get().getId(), productId);
} else {
log.warn("订单未记录秒杀活动ID且无法唯一匹配历史活动跳过秒杀库存恢复: productId={}, matches={}",
productId, matchedFlashSales.size());
return;
}
} else {
flashSaleOpt = flashSaleRepository.findById(flashSaleId);
}
if (!flashSaleOpt.isPresent()) {
log.warn("未找到对应的秒杀活动,跳过秒杀库存恢复: flashSaleId={}", flashSaleId);
return;
}
FlashSale flashSale = flashSaleOpt.get();
// 恢复Redis库存
String stockKey = FLASH_SALE_STOCK_PREFIX + flashSale.getId();
redisService.incrBy(stockKey, quantity);
// 恢复DB库存
flashSaleRepository.increaseFlashStock(flashSale.getId(), quantity);
// 移除成功用户集合记录
String successUsersKey = FLASH_SALE_SUCCESS_USERS_PREFIX + flashSale.getId();
redisService.sRem(successUsersKey, userId);
log.info("秒杀库存恢复成功: flashSaleId={}, userId={}, quantity={}",
flashSale.getId(), userId, quantity);
}
/**
* 获取秒杀活动剩余库存
*/
@@ -712,7 +771,7 @@ public class FlashSaleService {
}
// 检查是否有相关订单
if (orderRepository.existsFlashSaleOrder(null, flashSale.getProductId())) {
if (orderRepository.existsByFlashSaleIdAndOrderType(flashSaleId, 2)) {
throw new RuntimeException("该秒杀活动已有订单,无法删除");
}
@@ -886,6 +945,60 @@ public class FlashSaleService {
return buildFlashSaleDTO(flashSale, product);
}
/**
* 获取秒杀活动统计信息(即将开始、正在进行的全局数量 + 用户参与/成功数量)
*/
public Map<String, Object> getFlashSaleStatistics(Long userId) {
LocalDateTime now = LocalDateTime.now();
long upcoming = flashSaleRepository.findUpcomingFlashSales(now).size();
long active = flashSaleRepository.findActiveFlashSales(now).size();
long participated = 0;
long success = 0;
if (userId != null) {
participated = orderRepository.countByUserIdAndOrderType(userId, 2);
success = orderRepository.countByUserIdAndOrderTypeAndStatusNot5(userId, 2);
}
Map<String, Object> stats = new HashMap<>();
stats.put("upcoming", upcoming);
stats.put("active", active);
stats.put("participated", participated);
stats.put("success", success);
return stats;
}
/**
* 定时预热即将开始的秒杀活动库存每5分钟执行一次
*/
@Scheduled(fixedRate = 300000)
public void scheduledPreloadFlashSales() {
log.info("定时任务:检查即将开始的秒杀活动并预热库存");
try {
LocalDateTime now = LocalDateTime.now();
LocalDateTime threshold = now.plusMinutes(30);
List<FlashSale> upcomingFlashSales = flashSaleRepository.findUpcomingFlashSales(now);
int preloadCount = 0;
for (FlashSale flashSale : upcomingFlashSales) {
if (flashSale.getStartTime().isBefore(threshold)) {
preloadFlashSale(flashSale.getId());
preloadCount++;
}
}
if (preloadCount > 0) {
log.info("定时预热完成:预热了{}个即将开始的秒杀活动", preloadCount);
}
// 同时更新秒杀活动状态
updateFlashSaleStatus();
} catch (Exception e) {
log.error("定时预热秒杀活动失败", e);
}
}
/**
* 更新秒杀活动状态
*/
@@ -1051,6 +1164,7 @@ public class FlashSaleService {
order.setOrderNo("FS" + System.currentTimeMillis() + String.format("%03d", new java.util.Random().nextInt(1000)));
order.setUserId(userId);
order.setProductId(flashSale.getProductId());
order.setFlashSaleId(flashSale.getId());
order.setQuantity(participateDTO.getQuantity());
order.setTotalPrice(flashSale.getFlashPrice().multiply(BigDecimal.valueOf(participateDTO.getQuantity())));
order.setStatus(1); // 待支付

View File

@@ -0,0 +1,652 @@
package com.org.flashsalesystem.service;
import com.org.flashsalesystem.dto.GroupBuyingDTO;
import com.org.flashsalesystem.dto.ProductDTO;
import com.org.flashsalesystem.dto.UserDTO;
import com.org.flashsalesystem.entity.*;
import com.org.flashsalesystem.repository.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Service
@Slf4j
public class GroupBuyingService {
private static final String GB_STOCK_PREFIX = "groupbuying_stock:";
private static final String GB_LOCK_PREFIX = "groupbuying_lock:";
private static final String GB_MEMBERS_PREFIX = "groupbuying_members:";
@Autowired
private GroupBuyingRepository groupBuyingRepository;
@Autowired
private GroupBuyingGroupRepository groupBuyingGroupRepository;
@Autowired
private GroupBuyingMemberRepository groupBuyingMemberRepository;
@Autowired
private OrderRepository orderRepository;
@Autowired
private ProductRepository productRepository;
@Autowired
private ProductService productService;
@Autowired
private UserService userService;
@Autowired
private RedisService redisService;
@Autowired
private RedissonLockService redissonLockService;
@Autowired
@Qualifier("customStringRedisTemplate")
private RedisTemplate<String, String> stringRedisTemplate;
@Autowired
private DefaultRedisScript<Long> groupBuyingStockScript;
// ========== 管理员操作 ==========
@Transactional
public GroupBuyingDTO createGroupBuying(GroupBuyingDTO.CreateDTO createDTO) {
log.info("创建拼团活动: productId={}", createDTO.getProductId());
Product product = productRepository.findById(createDTO.getProductId())
.orElseThrow(() -> new RuntimeException("商品不存在"));
if (createDTO.getGroupPrice().compareTo(product.getPrice()) >= 0) {
throw new RuntimeException("拼团价格必须低于商品原价");
}
if (createDTO.getEndTime().isBefore(createDTO.getStartTime())) {
throw new RuntimeException("结束时间不能早于开始时间");
}
GroupBuying gb = new GroupBuying();
gb.setProductId(createDTO.getProductId());
gb.setGroupPrice(createDTO.getGroupPrice());
gb.setRequiredMembers(createDTO.getRequiredMembers());
gb.setDurationMinutes(createDTO.getDurationMinutes());
gb.setTotalStock(createDTO.getTotalStock());
gb.setRemainingStock(createDTO.getTotalStock());
gb.setMaxPerUser(createDTO.getMaxPerUser());
gb.setStatus(0); // 草稿
gb.setStartTime(createDTO.getStartTime());
gb.setEndTime(createDTO.getEndTime());
gb = groupBuyingRepository.save(gb);
log.info("拼团活动创建成功: id={}", gb.getId());
return buildDTO(gb);
}
@Transactional
public GroupBuyingDTO updateGroupBuying(Long id, GroupBuyingDTO.UpdateDTO updateDTO) {
GroupBuying gb = groupBuyingRepository.findById(id)
.orElseThrow(() -> new RuntimeException("拼团活动不存在"));
if (gb.getStatus() == 2) {
throw new RuntimeException("进行中的活动不能修改");
}
if (updateDTO.getProductId() != null) gb.setProductId(updateDTO.getProductId());
if (updateDTO.getGroupPrice() != null) gb.setGroupPrice(updateDTO.getGroupPrice());
if (updateDTO.getRequiredMembers() != null) gb.setRequiredMembers(updateDTO.getRequiredMembers());
if (updateDTO.getDurationMinutes() != null) gb.setDurationMinutes(updateDTO.getDurationMinutes());
if (updateDTO.getTotalStock() != null) {
int diff = updateDTO.getTotalStock() - gb.getTotalStock();
gb.setTotalStock(updateDTO.getTotalStock());
gb.setRemainingStock(gb.getRemainingStock() + diff);
}
if (updateDTO.getMaxPerUser() != null) gb.setMaxPerUser(updateDTO.getMaxPerUser());
if (updateDTO.getStatus() != null) gb.setStatus(updateDTO.getStatus());
if (updateDTO.getStartTime() != null) gb.setStartTime(updateDTO.getStartTime());
if (updateDTO.getEndTime() != null) gb.setEndTime(updateDTO.getEndTime());
gb = groupBuyingRepository.save(gb);
return buildDTO(gb);
}
@Transactional
public boolean deleteGroupBuying(Long id) {
GroupBuying gb = groupBuyingRepository.findById(id)
.orElseThrow(() -> new RuntimeException("拼团活动不存在"));
if (gb.getStatus() == 2) {
throw new RuntimeException("进行中的活动不能删除");
}
groupBuyingRepository.deleteById(id);
redisService.delete(GB_STOCK_PREFIX + id);
return true;
}
// ========== 查询操作 ==========
public Map<String, Object> getGroupBuyingList(int page, int size, Integer status) {
int pageSize = Math.min(size, 100);
Pageable pageable = PageRequest.of(page, pageSize, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<GroupBuying> gbPage;
LocalDateTime now = LocalDateTime.now();
if (status != null) {
if (status == 2) {
gbPage = groupBuyingRepository.findActiveGroupBuyings(now, pageable);
} else if (status == 3) {
gbPage = groupBuyingRepository.findEndedGroupBuyings(now, pageable);
} else {
gbPage = groupBuyingRepository.findByStatus(status, pageable);
}
} else {
gbPage = groupBuyingRepository.findAll(pageable);
}
List<GroupBuyingDTO> dtos = gbPage.getContent().stream()
.map(this::buildDTO)
.collect(Collectors.toList());
Map<String, Object> result = new HashMap<>();
result.put("content", dtos);
result.put("totalElements", gbPage.getTotalElements());
result.put("totalPages", gbPage.getTotalPages());
result.put("currentPage", gbPage.getNumber());
result.put("size", gbPage.getSize());
return result;
}
public GroupBuyingDTO getGroupBuyingDetail(Long id) {
GroupBuying gb = groupBuyingRepository.findById(id)
.orElseThrow(() -> new RuntimeException("拼团活动不存在"));
return buildDTO(gb);
}
public Map<String, Object> getGroupsByActivity(Long groupBuyingId, int page, int size) {
Pageable pageable = PageRequest.of(page, Math.min(size, 100), Sort.by(Sort.Direction.DESC, "createdAt"));
Page<GroupBuyingGroup> groupPage = groupBuyingGroupRepository.findByGroupBuyingId(groupBuyingId, pageable);
List<GroupBuyingDTO.GroupInfoDTO> dtos = groupPage.getContent().stream()
.map(this::buildGroupInfoDTO)
.collect(Collectors.toList());
Map<String, Object> result = new HashMap<>();
result.put("content", dtos);
result.put("totalElements", groupPage.getTotalElements());
result.put("totalPages", groupPage.getTotalPages());
result.put("currentPage", groupPage.getNumber());
result.put("size", groupPage.getSize());
return result;
}
public GroupBuyingDTO.GroupInfoDTO getGroupDetail(Long groupId) {
GroupBuyingGroup group = groupBuyingGroupRepository.findById(groupId)
.orElseThrow(() -> new RuntimeException("团组不存在"));
return buildGroupInfoDTO(group);
}
public Map<String, Object> getMyGroups(Long userId, int page, int size) {
Pageable pageable = PageRequest.of(page, Math.min(size, 100), Sort.by(Sort.Direction.DESC, "createdAt"));
Page<GroupBuyingGroup> groupPage = groupBuyingGroupRepository.findByMemberUserId(userId, pageable);
List<GroupBuyingDTO.GroupInfoDTO> dtos = groupPage.getContent().stream()
.map(this::buildGroupInfoDTO)
.collect(Collectors.toList());
Map<String, Object> result = new HashMap<>();
result.put("content", dtos);
result.put("totalElements", groupPage.getTotalElements());
result.put("totalPages", groupPage.getTotalPages());
result.put("currentPage", groupPage.getNumber());
result.put("size", groupPage.getSize());
return result;
}
public GroupBuyingDTO.StatisticsDTO getStatistics(Long userId) {
GroupBuyingDTO.StatisticsDTO stats = new GroupBuyingDTO.StatisticsDTO();
LocalDateTime now = LocalDateTime.now();
stats.setTotalActivities(groupBuyingRepository.count());
stats.setActiveActivities(groupBuyingRepository.countByStatus(2));
if (userId != null) {
Page<GroupBuyingGroup> myGroups = groupBuyingGroupRepository.findByMemberUserId(userId, PageRequest.of(0, 1));
stats.setMyGroups(myGroups.getTotalElements());
// Count successful groups where user participated
long successCount = 0;
BigDecimal totalSaved = BigDecimal.ZERO;
Page<GroupBuyingGroup> allMyGroups = groupBuyingGroupRepository.findByMemberUserId(userId, PageRequest.of(0, 1000));
for (GroupBuyingGroup g : allMyGroups.getContent()) {
if (g.getStatus() == 2) {
successCount++;
GroupBuying gb = groupBuyingRepository.findById(g.getGroupBuyingId()).orElse(null);
if (gb != null) {
Product product = productRepository.findById(gb.getProductId()).orElse(null);
if (product != null) {
totalSaved = totalSaved.add(product.getPrice().subtract(gb.getGroupPrice()));
}
}
}
}
stats.setSuccessGroups(successCount);
stats.setTotalSaved(totalSaved);
} else {
stats.setMyGroups(0L);
stats.setSuccessGroups(0L);
stats.setTotalSaved(BigDecimal.ZERO);
}
return stats;
}
// ========== 核心拼团逻辑 ==========
@Transactional
public GroupBuyingDTO.JoinResultDTO joinGroupBuying(GroupBuyingDTO.JoinGroupDTO joinDTO, Long userId) {
Long activityId = joinDTO.getGroupBuyingId();
String lockKey = GB_LOCK_PREFIX + activityId;
if (!redissonLockService.tryLock(lockKey, 5, 30)) {
throw new RuntimeException("系统繁忙,请稍后重试");
}
try {
return doJoinGroupBuying(joinDTO, userId);
} finally {
redissonLockService.unlock(lockKey);
}
}
private GroupBuyingDTO.JoinResultDTO doJoinGroupBuying(GroupBuyingDTO.JoinGroupDTO joinDTO, Long userId) {
Long activityId = joinDTO.getGroupBuyingId();
// 1. 校验活动状态
GroupBuying gb = groupBuyingRepository.findById(activityId)
.orElseThrow(() -> new RuntimeException("拼团活动不存在"));
if (!gb.isActive()) {
throw new RuntimeException("拼团活动未在进行中");
}
if (gb.getRemainingStock() <= 0) {
throw new RuntimeException("库存不足");
}
// 2. 获取商品信息
Product product = productRepository.findById(gb.getProductId())
.orElseThrow(() -> new RuntimeException("商品不存在"));
GroupBuyingGroup group;
if (joinDTO.getGroupId() != null) {
// 加入现有团组
group = groupBuyingGroupRepository.findById(joinDTO.getGroupId())
.orElseThrow(() -> new RuntimeException("团组不存在"));
if (group.getStatus() != 1) {
throw new RuntimeException("该团组已成团或已过期");
}
if (LocalDateTime.now().isAfter(group.getExpireTime())) {
throw new RuntimeException("该团组已过期");
}
if (group.getCurrentMembers() >= group.getRequiredMembers()) {
throw new RuntimeException("该团组已满员");
}
// 检查用户是否已经在该团组中
if (groupBuyingMemberRepository.existsByGroupIdAndUserIdAndStatusNot(group.getId(), userId, 3)) {
throw new RuntimeException("您已在该团组中");
}
} else {
// 创建新团组
group = new GroupBuyingGroup();
group.setGroupNo("GB" + System.currentTimeMillis() + String.format("%03d", new Random().nextInt(1000)));
group.setGroupBuyingId(activityId);
group.setLeaderUserId(userId);
group.setRequiredMembers(gb.getRequiredMembers());
group.setCurrentMembers(0); // will be incremented below
group.setStatus(1);
group.setExpireTime(LocalDateTime.now().plusMinutes(gb.getDurationMinutes()));
group = groupBuyingGroupRepository.save(group);
}
// 3. Redis 原子扣库存
String stockKey = GB_STOCK_PREFIX + activityId;
Long result = stringRedisTemplate.execute(groupBuyingStockScript,
Collections.singletonList(stockKey), "1");
if (result == null || result < 0) {
// Redis stock exhausted, double check DB
if (gb.getRemainingStock() <= 0) {
throw new RuntimeException("库存不足");
}
}
// 4. DB 扣库存
int updated = groupBuyingRepository.updateStock(activityId, 1);
if (updated == 0) {
// Restore Redis stock
redisService.incrBy(stockKey, 1);
throw new RuntimeException("库存不足");
}
// 5. 创建订单
Order order = new Order();
order.setOrderNo("GB" + System.currentTimeMillis() + String.format("%03d", new Random().nextInt(1000)));
order.setUserId(userId);
order.setProductId(gb.getProductId());
order.setQuantity(1);
order.setTotalPrice(gb.getGroupPrice());
order.setStatus(1); // 待支付
order.setOrderType(3); // 拼团订单
order.setGroupBuyingGroupId(group.getId());
order = orderRepository.save(order);
// 6. 创建成员记录
GroupBuyingMember member = new GroupBuyingMember();
member.setGroupId(group.getId());
member.setUserId(userId);
member.setOrderId(order.getId());
member.setStatus(1);
groupBuyingMemberRepository.save(member);
// 7. 更新团组人数
groupBuyingGroupRepository.incrementCurrentMembers(group.getId());
// Refresh group from DB
group = groupBuyingGroupRepository.findById(group.getId()).orElse(group);
// 8. 检查是否满员 → 成团
if (group.getCurrentMembers() >= group.getRequiredMembers()) {
groupBuyingGroupRepository.updateStatusAndCompletedAt(group.getId(), 2, LocalDateTime.now());
// Update all members status to SUCCESS
groupBuyingMemberRepository.updateStatusByGroupId(group.getId(), 2);
log.info("拼团成功: groupId={}, groupNo={}", group.getId(), group.getGroupNo());
}
// Add to Redis members set
redisService.sAdd(GB_MEMBERS_PREFIX + group.getId(), userId.toString());
redisService.expire(GB_MEMBERS_PREFIX + group.getId(), 7, TimeUnit.DAYS);
GroupBuyingDTO.JoinResultDTO resultDTO = new GroupBuyingDTO.JoinResultDTO();
resultDTO.setSuccess(true);
resultDTO.setMessage(joinDTO.getGroupId() != null ? "加入拼团成功" : "开团成功");
resultDTO.setGroupId(group.getId());
resultDTO.setGroupNo(group.getGroupNo());
resultDTO.setOrderId(order.getId());
log.info("用户{}参与拼团成功: activityId={}, groupId={}, orderId={}", userId, activityId, group.getId(), order.getId());
return resultDTO;
}
@Transactional
public void cancelMembership(Long groupId, Long userId) {
log.info("退出拼团: groupId={}, userId={}", groupId, userId);
GroupBuyingGroup group = groupBuyingGroupRepository.findById(groupId)
.orElseThrow(() -> new RuntimeException("团组不存在"));
if (group.getStatus() != 1) {
throw new RuntimeException("已成团或已失败的团组不能退出");
}
GroupBuyingMember member = groupBuyingMemberRepository.findByGroupIdAndUserId(groupId, userId)
.orElseThrow(() -> new RuntimeException("您不在该团组中"));
if (member.getStatus() == 3) {
throw new RuntimeException("您已退出该团组");
}
// 更新成员状态
member.setStatus(3);
groupBuyingMemberRepository.save(member);
// 更新团组人数
groupBuyingGroupRepository.decrementCurrentMembers(groupId);
// 恢复库存
Long activityId = group.getGroupBuyingId();
groupBuyingRepository.increaseStock(activityId, 1);
redisService.incrBy(GB_STOCK_PREFIX + activityId, 1);
// 取消订单
if (member.getOrderId() != null) {
Optional<Order> orderOpt = orderRepository.findById(member.getOrderId());
if (orderOpt.isPresent()) {
Order order = orderOpt.get();
if (order.getStatus() == 1) {
order.setStatus(5);
order.setRemark("退出拼团,订单取消");
orderRepository.save(order);
}
}
}
// Remove from Redis set
redisService.sRem(GB_MEMBERS_PREFIX + groupId, userId.toString());
log.info("退出拼团成功: groupId={}, userId={}", groupId, userId);
}
// ========== 定时任务 ==========
@Scheduled(fixedRate = 60000) // 每分钟检查
@Transactional
public void scheduledCheckExpiredGroups() {
List<GroupBuyingGroup> expiredGroups = groupBuyingGroupRepository.findExpiredGroups(LocalDateTime.now());
for (GroupBuyingGroup group : expiredGroups) {
try {
handleExpiredGroup(group);
} catch (Exception e) {
log.error("处理超时团组失败: groupId={}", group.getId(), e);
}
}
}
@Transactional
public void handleExpiredGroup(GroupBuyingGroup group) {
log.info("处理超时团组: groupId={}, groupNo={}", group.getId(), group.getGroupNo());
// Mark group as FAILED
groupBuyingGroupRepository.updateStatusAndCompletedAt(group.getId(), 3, LocalDateTime.now());
// Get active members
List<GroupBuyingMember> members = groupBuyingMemberRepository.findByGroupIdAndStatus(group.getId(), 1);
for (GroupBuyingMember member : members) {
// Update member status
member.setStatus(3);
groupBuyingMemberRepository.save(member);
// Restore stock
groupBuyingRepository.increaseStock(group.getGroupBuyingId(), 1);
redisService.incrBy(GB_STOCK_PREFIX + group.getGroupBuyingId(), 1);
// Cancel order
if (member.getOrderId() != null) {
Optional<Order> orderOpt = orderRepository.findById(member.getOrderId());
if (orderOpt.isPresent()) {
Order order = orderOpt.get();
if (order.getStatus() == 1) {
order.setStatus(5);
order.setRemark("拼团超时,订单自动取消");
orderRepository.save(order);
}
}
}
}
// Clean Redis
redisService.delete(GB_MEMBERS_PREFIX + group.getId());
log.info("超时团组处理完成: groupId={}", group.getId());
}
@Scheduled(fixedRate = 300000) // 每5分钟
@Transactional
public void scheduledUpdateStatus() {
LocalDateTime now = LocalDateTime.now();
// Activate pending activities whose start time has passed
List<GroupBuying> upcoming = groupBuyingRepository.findUpcomingGroupBuyings(now);
// These are status=1 and startTime > now, so nothing to activate here
// Actually find activities that should be active: status=1, startTime <= now, endTime > now
List<GroupBuying> all = groupBuyingRepository.findAll();
for (GroupBuying gb : all) {
if (gb.getStatus() == 1 && !now.isBefore(gb.getStartTime()) && now.isBefore(gb.getEndTime())) {
groupBuyingRepository.updateStatus(gb.getId(), 2);
preloadStock(gb);
log.info("拼团活动已激活: id={}", gb.getId());
} else if (gb.getStatus() == 2 && !now.isBefore(gb.getEndTime())) {
groupBuyingRepository.updateStatus(gb.getId(), 3);
log.info("拼团活动已结束: id={}", gb.getId());
}
}
}
public void preloadStock(GroupBuying gb) {
String stockKey = GB_STOCK_PREFIX + gb.getId();
redisService.setString(stockKey, String.valueOf(gb.getRemainingStock()));
long ttl = java.time.Duration.between(LocalDateTime.now(), gb.getEndTime()).getSeconds() + 3600;
redisService.expire(stockKey, ttl, TimeUnit.SECONDS);
log.info("拼团库存预热: id={}, stock={}", gb.getId(), gb.getRemainingStock());
}
public void preloadAllActiveStock() {
List<GroupBuying> activeList = groupBuyingRepository.findActiveGroupBuyings(LocalDateTime.now());
for (GroupBuying gb : activeList) {
preloadStock(gb);
}
log.info("所有拼团活动库存预热完成, count={}", activeList.size());
}
// ========== 构建 DTO ==========
private GroupBuyingDTO buildDTO(GroupBuying gb) {
GroupBuyingDTO dto = new GroupBuyingDTO();
dto.setId(gb.getId());
dto.setProductId(gb.getProductId());
dto.setGroupPrice(gb.getGroupPrice());
dto.setRequiredMembers(gb.getRequiredMembers());
dto.setDurationMinutes(gb.getDurationMinutes());
dto.setTotalStock(gb.getTotalStock());
dto.setRemainingStock(gb.getRemainingStock());
dto.setMaxPerUser(gb.getMaxPerUser());
dto.setStatus(gb.getStatus());
dto.setStatusDescription(getStatusDescription(gb.getStatus()));
dto.setStartTime(gb.getStartTime());
dto.setEndTime(gb.getEndTime());
dto.setCreatedAt(gb.getCreatedAt());
dto.setUpdatedAt(gb.getUpdatedAt());
// Product info
ProductDTO product = productService.getProductById(gb.getProductId());
if (product != null) {
dto.setProductName(product.getName());
dto.setProductImageUrl(product.getImageUrl());
dto.setProductPrice(product.getPrice());
dto.setDiscount(product.getPrice().subtract(gb.getGroupPrice()));
}
// Active group count
long activeGroups = groupBuyingGroupRepository.countByGroupBuyingIdAndStatus(gb.getId(), 1);
dto.setActiveGroupCount((int) activeGroups);
return dto;
}
private GroupBuyingDTO.GroupInfoDTO buildGroupInfoDTO(GroupBuyingGroup group) {
GroupBuyingDTO.GroupInfoDTO dto = new GroupBuyingDTO.GroupInfoDTO();
dto.setId(group.getId());
dto.setGroupNo(group.getGroupNo());
dto.setGroupBuyingId(group.getGroupBuyingId());
dto.setLeaderUserId(group.getLeaderUserId());
dto.setRequiredMembers(group.getRequiredMembers());
dto.setCurrentMembers(group.getCurrentMembers());
dto.setStatus(group.getStatus());
dto.setStatusDescription(getGroupStatusDescription(group.getStatus()));
dto.setExpireTime(group.getExpireTime());
dto.setCreatedAt(group.getCreatedAt());
dto.setCompletedAt(group.getCompletedAt());
// Leader info
UserDTO leader = userService.getUserById(group.getLeaderUserId());
if (leader != null) {
dto.setLeaderUsername(leader.getUsername());
}
// Members
List<GroupBuyingMember> members = groupBuyingMemberRepository.findByGroupId(group.getId());
List<GroupBuyingDTO.MemberDTO> memberDTOs = members.stream()
.filter(m -> m.getStatus() != 3) // exclude exited
.map(m -> {
GroupBuyingDTO.MemberDTO memberDTO = new GroupBuyingDTO.MemberDTO();
memberDTO.setId(m.getId());
memberDTO.setUserId(m.getUserId());
memberDTO.setOrderId(m.getOrderId());
memberDTO.setStatus(m.getStatus());
memberDTO.setJoinedAt(m.getJoinedAt());
UserDTO user = userService.getUserById(m.getUserId());
if (user != null) {
memberDTO.setUsername(user.getUsername());
memberDTO.setAvatar(user.getAvatar());
}
return memberDTO;
})
.collect(Collectors.toList());
dto.setMembers(memberDTOs);
// Activity info
GroupBuying gb = groupBuyingRepository.findById(group.getGroupBuyingId()).orElse(null);
if (gb != null) {
dto.setGroupBuying(buildDTO(gb));
}
return dto;
}
private String getStatusDescription(Integer status) {
if (status == null) return "未知";
switch (status) {
case 0: return "草稿";
case 1: return "未开始";
case 2: return "进行中";
case 3: return "已结束";
default: return "未知";
}
}
private String getGroupStatusDescription(Integer status) {
if (status == null) return "未知";
switch (status) {
case 1: return "拼团中";
case 2: return "已成团";
case 3: return "已失败";
default: return "未知";
}
}
}

View File

@@ -26,6 +26,9 @@ public class MessageListenerService {
@Autowired
private ObjectMapper objectMapper;
@Autowired
private NotificationService notificationService;
/**
* 初始化消息监听器
*/
@@ -62,31 +65,43 @@ public class MessageListenerService {
* 处理订单状态变更
*/
private void handleOrderStatusChange(Long orderId, Long userId, Integer status, String action) {
// 可以在这里实现:
// 1. 发送邮件通知
// 2. 推送消息
// 3. 更新统计数据
// 4. 触发其他业务流程
if (userId == null) {
log.warn("订单状态变更缺少用户ID: orderId={}", orderId);
return;
}
String title;
String message;
String link = "/order/" + orderId;
switch (action) {
case "created":
log.info("订单创建通知处理: 订单ID={}", orderId);
title = "订单创建成功";
message = "您的订单 #" + orderId + " 已创建,请尽快完成支付";
break;
case "paid":
log.info("订单支付通知处理: 订单ID={}", orderId);
title = "订单支付成功";
message = "您的订单 #" + orderId + " 已支付成功,等待商家发货";
break;
case "shipped":
log.info("订单发货通知处理: 订单ID={}", orderId);
title = "订单发货";
message = "您的订单 #" + orderId + " 已发货,请注意查收";
break;
case "completed":
log.info("订单完成通知处理: 订单ID={}", orderId);
title = "订单完成";
message = "您的订单 #" + orderId + " 已完成,欢迎评价";
break;
case "cancelled":
log.info("订单取消通知处理: 订单ID={}", orderId);
title = "订单取消";
message = "您的订单 #" + orderId + " 已取消";
break;
default:
log.info("未知订单状态变更: {}", action);
return;
}
notificationService.createNotification(userId, "order", title, message, link);
log.info("订单状态变更通知已创建: 订单ID={}, 操作={}", orderId, action);
}
/**
@@ -112,20 +127,23 @@ public class MessageListenerService {
* 处理秒杀结果
*/
private void handleFlashSaleResult(Long userId, Long flashSaleId, Boolean success, Map<String, Object> data) {
// 可以在这里实现:
// 1. 实时通知用户
// 2. 统计秒杀数据
// 3. 风控分析
// 4. 营销推荐
if (userId == null) {
log.warn("秒杀结果缺少用户ID: flashSaleId={}", flashSaleId);
return;
}
String link = "/flashsale/" + flashSaleId;
if (success) {
log.info("秒杀成功处理: 用户ID={}, 秒杀ID={}", userId, flashSaleId);
// 发送成功通知
sendFlashSaleSuccessNotification(userId, flashSaleId);
String title = "秒杀成功";
String message = "恭喜您成功抢购秒杀商品,请尽快完成支付!";
notificationService.createNotification(userId, "flashsale", title, message, link);
log.info("秒杀成功通知已创建: 用户ID={}, 秒杀ID={}", userId, flashSaleId);
} else {
log.info("秒杀失败处理: 用户ID={}, 秒杀ID={}", userId, flashSaleId);
// 可以推荐其他商品
recommendAlternativeProducts(userId, flashSaleId);
String title = "秒杀未中";
String message = "很遗憾,本次秒杀未能抢购成功,下次再来吧!";
notificationService.createNotification(userId, "flashsale", title, message, link);
log.info("秒杀失败通知已创建: 用户ID={}, 秒杀ID={}", userId, flashSaleId);
}
}
@@ -161,26 +179,9 @@ public class MessageListenerService {
* 检查库存预警
*/
private void checkStockAlert(Long productId) {
// 实现库存预警逻辑
log.debug("检查商品库存预警: 商品ID={}", productId);
}
/**
* 发送秒杀成功通知
*/
private void sendFlashSaleSuccessNotification(Long userId, Long flashSaleId) {
// 实现成功通知逻辑
log.debug("发送秒杀成功通知: 用户ID={}, 秒杀ID={}", userId, flashSaleId);
}
/**
* 推荐替代商品
*/
private void recommendAlternativeProducts(Long userId, Long flashSaleId) {
// 实现商品推荐逻辑
log.debug("推荐替代商品: 用户ID={}, 秒杀ID={}", userId, flashSaleId);
}
/**
* 提取Long值
*/

View File

@@ -0,0 +1,57 @@
package com.org.flashsalesystem.service;
import com.org.flashsalesystem.entity.Notification;
import com.org.flashsalesystem.repository.NotificationRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Slf4j
public class NotificationService {
@Autowired
private NotificationRepository notificationRepository;
public void createNotification(Long userId, String type, String title, String message, String link) {
Notification notification = new Notification();
notification.setUserId(userId);
notification.setType(type);
notification.setTitle(title);
notification.setMessage(message);
notification.setLink(link);
notification.setRead(false);
notificationRepository.save(notification);
log.debug("通知已创建: userId={}, type={}, title={}", userId, type, title);
}
public List<Notification> getUserNotifications(Long userId) {
return notificationRepository.findByUserIdOrderByCreatedAtDesc(userId);
}
public List<Notification> getUserNotificationsByType(Long userId, String type) {
return notificationRepository.findByUserIdAndTypeOrderByCreatedAtDesc(userId, type);
}
public long getUnreadCount(Long userId) {
return notificationRepository.countByUserIdAndReadFalse(userId);
}
@Transactional
public void markAsRead(Long notificationId, Long userId) {
notificationRepository.markAsRead(notificationId, userId);
}
@Transactional
public void markAllAsRead(Long userId) {
notificationRepository.markAllAsRead(userId);
}
@Transactional
public void clearAll(Long userId) {
notificationRepository.deleteByUserId(userId);
}
}

View File

@@ -52,6 +52,10 @@ public class OrderService {
private UserService userService;
@Autowired
private UserAddressRepository userAddressRepository;
@Autowired
private FlashSaleService flashSaleService;
@Autowired
private GroupBuyingService groupBuyingService;
/**
* 创建普通订单
@@ -219,14 +223,20 @@ public class OrderService {
* 获取用户订单列表
*/
public Map<String, Object> getUserOrders(Long userId, OrderDTO.QueryDTO queryDTO) {
// 限制分页大小
int pageSize = Math.min(queryDTO.getSize(), 100);
// 构建分页和排序
Sort sort = Sort.by(Sort.Direction.fromString(queryDTO.getSortDirection()), queryDTO.getSortBy());
Pageable pageable = PageRequest.of(queryDTO.getPage(), queryDTO.getSize(), sort);
Pageable pageable = PageRequest.of(queryDTO.getPage(), pageSize, sort);
Page<Order> orderPage;
// 根据查询条件获取订单
if (queryDTO.getStatus() != null) {
if (queryDTO.getStatus() != null && queryDTO.getOrderType() != null) {
orderPage = orderRepository.findByUserIdAndStatus(userId, queryDTO.getStatus(), pageable);
} else if (queryDTO.getOrderType() != null) {
orderPage = orderRepository.findByUserIdAndOrderType(userId, queryDTO.getOrderType(), pageable);
} else if (queryDTO.getStatus() != null) {
orderPage = orderRepository.findByUserIdAndStatus(userId, queryDTO.getStatus(), pageable);
} else {
orderPage = orderRepository.findByUserId(userId, pageable);
@@ -259,9 +269,11 @@ public class OrderService {
* 获取所有订单列表(管理员)
*/
public Map<String, Object> getAllOrders(OrderDTO.QueryDTO queryDTO) {
// 限制分页大小
int pageSize = Math.min(queryDTO.getSize(), 100);
// 构建分页和排序
Sort sort = Sort.by(Sort.Direction.fromString(queryDTO.getSortDirection()), queryDTO.getSortBy());
Pageable pageable = PageRequest.of(queryDTO.getPage(), queryDTO.getSize(), sort);
Pageable pageable = PageRequest.of(queryDTO.getPage(), pageSize, sort);
Page<Order> orderPage;
@@ -402,6 +414,21 @@ public class OrderService {
// 恢复库存
productService.updateStock(order.getProductId(), order.getQuantity(), "increase");
// 秒杀订单额外恢复秒杀库存
if (order.getOrderType() != null && order.getOrderType() == 2) {
flashSaleService.restoreFlashSaleStock(order.getFlashSaleId(), order.getProductId(), order.getCreatedAt(),
order.getUserId(), order.getQuantity());
}
// 拼团订单额外处理
if (order.getOrderType() != null && order.getOrderType() == 3 && order.getGroupBuyingGroupId() != null) {
try {
groupBuyingService.cancelMembership(order.getGroupBuyingGroupId(), order.getUserId());
} catch (Exception e) {
log.warn("拼团退出处理失败: orderId={}, error={}", orderId, e.getMessage());
}
}
// 更新缓存
cacheOrderInfo(order);
@@ -569,6 +596,7 @@ public class OrderService {
orderMap.put("groupNo", order.getGroupNo() == null ? "" : order.getGroupNo());
orderMap.put("userId", order.getUserId().toString());
orderMap.put("productId", order.getProductId().toString());
orderMap.put("flashSaleId", order.getFlashSaleId() == null ? "" : order.getFlashSaleId().toString());
orderMap.put("quantity", order.getQuantity().toString());
orderMap.put("totalPrice", order.getTotalPrice().toString());
orderMap.put("status", order.getStatus().toString());
@@ -603,6 +631,8 @@ public class OrderService {
orderDTO.setGroupNo((String) orderMap.get("groupNo"));
orderDTO.setUserId(Long.valueOf((String) orderMap.get("userId")));
orderDTO.setProductId(Long.valueOf((String) orderMap.get("productId")));
String flashSaleId = (String) orderMap.get("flashSaleId");
if (flashSaleId != null && !flashSaleId.isEmpty()) { orderDTO.setFlashSaleId(Long.valueOf(flashSaleId)); }
orderDTO.setQuantity(Integer.valueOf((String) orderMap.get("quantity")));
orderDTO.setTotalPrice(new BigDecimal((String) orderMap.get("totalPrice")));
orderDTO.setStatus(Integer.valueOf((String) orderMap.get("status")));
@@ -849,6 +879,8 @@ public class OrderService {
return "普通订单";
case 2:
return "秒杀订单";
case 3:
return "拼团订单";
default:
return "未知类型";
}

View File

@@ -4,9 +4,11 @@ import com.org.flashsalesystem.dto.ProductReviewDTO;
import com.org.flashsalesystem.dto.UserDTO;
import com.org.flashsalesystem.entity.Order;
import com.org.flashsalesystem.entity.OrderItem;
import com.org.flashsalesystem.entity.Product;
import com.org.flashsalesystem.entity.ProductReview;
import com.org.flashsalesystem.repository.OrderItemRepository;
import com.org.flashsalesystem.repository.OrderRepository;
import com.org.flashsalesystem.repository.ProductRepository;
import com.org.flashsalesystem.repository.ProductReviewRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
@@ -15,6 +17,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@@ -30,6 +33,9 @@ public class ProductReviewService {
@Autowired
private OrderItemRepository orderItemRepository;
@Autowired
private ProductRepository productRepository;
@Autowired
private UserService userService;
@@ -68,7 +74,7 @@ public class ProductReviewService {
.map(this::toDTO)
.collect(Collectors.toList());
Double average = productReviewRepository.findAverageRatingByProductId(productId);
Long total = productReviewRepository.countByProductId(productId);
Long total = productReviewRepository.countByProductIdAndStatus(productId, 1);
return new ProductReviewDTO.SummaryDTO(average == null ? 0.0 : average, total, reviews);
}
@@ -89,12 +95,39 @@ public class ProductReviewService {
return toDTO(review);
}
public ProductReviewDTO.CheckDTO checkReviewStatus(Long orderId, Long productId) {
ProductReviewDTO.CheckDTO checkDTO = new ProductReviewDTO.CheckDTO();
Optional<ProductReview> review = productReviewRepository.findByOrderIdAndProductId(orderId, productId);
checkDTO.setReviewed(review.isPresent());
review.ifPresent(r -> checkDTO.setReview(toDTO(r)));
return checkDTO;
}
public List<ProductReviewDTO> getUserReviews(Long userId) {
return productReviewRepository.findByUserIdOrderByCreatedAtDesc(userId)
.stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
public List<ProductReviewDTO> getOrderReviews(Long orderId) {
return productReviewRepository.findByOrderId(orderId)
.stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
private ProductReviewDTO toDTO(ProductReview review) {
ProductReviewDTO dto = new ProductReviewDTO();
BeanUtils.copyProperties(review, dto);
UserDTO user = userService.getUserById(review.getUserId());
dto.setUsername(user != null ? user.getUsername() : "匿名用户");
dto.setStatusText(review.getStatus() != null && review.getStatus() == 1 ? "显示" : "隐藏");
Optional<Product> product = productRepository.findById(review.getProductId());
if (product.isPresent()) {
dto.setProductName(product.get().getName());
dto.setProductImage(product.get().getImageUrl());
}
return dto;
}
}

View File

@@ -120,9 +120,11 @@ public class ProductService {
return (Map<String, Object>) cachedResult;
}
// 限制分页大小
int pageSize = Math.min(queryDTO.getSize(), 100);
// 构建分页和排序
Sort sort = Sort.by(Sort.Direction.fromString(queryDTO.getSortDirection()), queryDTO.getSortBy());
Pageable pageable = PageRequest.of(queryDTO.getPage(), queryDTO.getSize(), sort);
Pageable pageable = PageRequest.of(queryDTO.getPage(), pageSize, sort);
Integer status = queryDTO.getStatus() != null ? queryDTO.getStatus() : 1;
String keyword = queryDTO.getKeyword() != null && !queryDTO.getKeyword().trim().isEmpty()