feat(shop): 添加用户订单和卡包统计功能

- 在 ShopOrderMapper 中新增 selectUserOrderStats 方法用于订单状态统计
- 在 ShopOrderService 和 ShopOrderServiceImpl 中实现用户订单统计功能
- 添加 UserOrderStats DTO 类定义订单各状态数量统计
- 创建 UserOrderController 提供用户订单统计 API 接口
- 实现用户卡包统计功能,包括余额、积分、优惠券、礼品卡统计
- 添加 UserCardController 和 UserCardStats DTO 类
- 优化 Swagger 配置以支持 /api/user/** 路径的 API 文档
- 为统计接口添加 Redis 缓存以提升性能
- 清理 ShopOrderController 中不必要的导入依赖
This commit is contained in:
2026-01-20 13:00:41 +08:00
parent ceaaf287b0
commit b1b106c397
10 changed files with 336 additions and 7 deletions

View File

@@ -80,7 +80,8 @@ public class SwaggerConfig {
public GroupedOpenApi shopApi() {
return GroupedOpenApi.builder()
.group("shop")
.pathsToMatch("/api/shop/**")
// 订单等用户侧接口在 shop 包内,但路径使用 /api/user/**(前端统一 user 侧 API 前缀)
.pathsToMatch("/api/shop/**", "/api/user/**")
.packagesToScan("com.gxwebsoft.shop")
.build();
}

View File

@@ -1,7 +1,5 @@
package com.gxwebsoft.shop.controller;
import cn.hutool.core.date.DateField;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.NumberUtil;
import cn.hutool.core.util.ObjectUtil;
@@ -14,9 +12,7 @@ import com.gxwebsoft.common.core.utils.WechatCertAutoConfig;
import com.gxwebsoft.common.core.utils.WechatPayConfigValidator;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.system.entity.Payment;
import com.gxwebsoft.shop.entity.ShopExpress;
import com.gxwebsoft.shop.entity.ShopOrderDelivery;
import com.gxwebsoft.shop.entity.ShopUserAddress;
import com.gxwebsoft.shop.service.*;
import com.gxwebsoft.shop.service.impl.KuaiDi100Impl;
import com.gxwebsoft.shop.task.OrderAutoCancelTask;
@@ -31,7 +27,6 @@ import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.system.entity.User;
import com.kuaidi100.sdk.request.BOrderReq;
import com.wechat.pay.java.core.notification.NotificationConfig;
import com.wechat.pay.java.core.notification.NotificationParser;
import com.wechat.pay.java.core.notification.RequestParam;
@@ -51,7 +46,6 @@ import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

View File

@@ -0,0 +1,94 @@
package com.gxwebsoft.shop.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.common.core.utils.RedisUtil;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.common.system.service.UserService;
import com.gxwebsoft.shop.dto.UserCardStats;
import com.gxwebsoft.shop.entity.ShopGift;
import com.gxwebsoft.shop.entity.ShopUserCoupon;
import com.gxwebsoft.shop.service.ShopGiftService;
import com.gxwebsoft.shop.service.ShopUserCouponService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
@Tag(name = "用户卡包")
@RestController
@RequestMapping("/api/user/card")
public class UserCardController extends BaseController {
private static final long USER_CARD_STATS_CACHE_SECONDS = 60L;
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Resource
private RedisUtil redisUtil;
@Resource
private UserService userService;
@Resource
private ShopUserCouponService shopUserCouponService;
@Resource
private ShopGiftService shopGiftService;
@Operation(summary = "获取当前用户卡包统计(余额/积分/优惠券/礼品卡60s 缓存)")
@GetMapping("/stats")
public ApiResult<UserCardStats> getUserCardStats() {
Integer userId = getLoginUserId();
if (userId == null) {
return fail("请先登录", null);
}
String cacheKey = "UserCard:Stats:"
+ (getTenantId() == null ? "0" : getTenantId())
+ ":" + userId;
UserCardStats cached = redisUtil.get(cacheKey, UserCardStats.class);
if (cached != null) {
return success("ok", cached);
}
// 余额/积分从用户表取;优惠券/礼品卡用 COUNT 聚合
User user = userService.getById(userId);
BigDecimal balance = user == null || user.getBalance() == null ? BigDecimal.ZERO : user.getBalance();
Integer points = user == null || user.getPoints() == null ? 0 : user.getPoints();
long coupons = shopUserCouponService.count(new LambdaQueryWrapper<ShopUserCoupon>()
.eq(ShopUserCoupon::getUserId, userId)
.eq(ShopUserCoupon::getStatus, ShopUserCoupon.STATUS_UNUSED));
long giftCards = shopGiftService.count(new LambdaQueryWrapper<ShopGift>()
.eq(ShopGift::getUserId, userId)
.eq(ShopGift::getStatus, 0));
UserCardStats stats = new UserCardStats();
stats.setBalance(balance.setScale(2, RoundingMode.HALF_UP).toPlainString());
stats.setPoints(points);
stats.setCoupons(toInt(coupons));
stats.setGiftCards(toInt(giftCards));
stats.setLastUpdateTime(LocalDateTime.now().format(DATETIME_FMT));
redisUtil.set(cacheKey, stats, USER_CARD_STATS_CACHE_SECONDS, TimeUnit.SECONDS);
return success("ok", stats);
}
private static Integer toInt(long value) {
if (value <= 0) {
return 0;
}
if (value > Integer.MAX_VALUE) {
return Integer.MAX_VALUE;
}
return (int) value;
}
}

View File

@@ -0,0 +1,37 @@
package com.gxwebsoft.shop.controller;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.shop.dto.UserOrderStats;
import com.gxwebsoft.shop.service.ShopOrderService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
/**
* 用户侧订单接口
*/
@Tag(name = "用户订单")
@RestController
@RequestMapping("/api/user/orders")
public class UserOrderController extends BaseController {
@Resource
private ShopOrderService shopOrderService;
@Operation(summary = "获取当前用户订单状态统计(一次聚合查询,带 60s 缓存)")
@GetMapping("/stats")
public ApiResult<UserOrderStats> getUserOrderStats(@RequestParam(value = "type", required = false) Integer type) {
Integer userId = getLoginUserId();
if (userId == null) {
return fail("用户未登录", null);
}
return success(shopOrderService.getUserOrderStats(userId, getTenantId(), type));
}
}

View File

@@ -0,0 +1,31 @@
package com.gxwebsoft.shop.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 用户卡包统计(余额/积分/优惠券/礼品卡)
*/
@Data
@Schema(name = "UserCardStats", description = "用户卡包统计")
public class UserCardStats implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "用户余额(字符串/BigDecimal 字符串)")
private String balance;
@Schema(description = "用户积分")
private Integer points;
@Schema(description = "可用优惠券数量(按 status=0 口径)")
private Integer coupons;
@Schema(description = "未使用礼品卡数量(按 status=0 口径)")
private Integer giftCards;
@Schema(description = "最后更新时间yyyy-MM-dd HH:mm:ss")
private String lastUpdateTime;
}

View File

@@ -0,0 +1,50 @@
package com.gxwebsoft.shop.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.util.Map;
/**
* 用户订单各状态数量统计(用于订单页徽标/统计)
*/
@Data
@Schema(name = "UserOrderStats", description = "用户订单各状态数量统计")
public class UserOrderStats implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "全部默认过滤已取消订单order_status != 2")
private Long total;
@Schema(description = "待支付statusFilter=0")
private Long waitPay;
@Schema(description = "待发货statusFilter=1")
private Long waitDeliver;
@Schema(description = "待核销statusFilter=2")
private Long waitVerify;
@Schema(description = "待收货statusFilter=3")
private Long waitReceive;
@Schema(description = "待评价statusFilter=4")
private Long waitComment;
@Schema(description = "已完成statusFilter=5")
private Long completed;
@Schema(description = "退款/售后statusFilter=6")
private Long refund;
@Schema(description = "已删除statusFilter=7")
private Long deleted;
@Schema(description = "已取消statusFilter=8")
private Long canceled;
@Schema(description = "按 statusFilter 聚合的数量映射key 为 \"0\"~\"8\"")
private Map<String, Long> statusFilter;
}

View File

@@ -11,6 +11,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/**
* 订单Mapper
@@ -52,4 +53,11 @@ public interface ShopOrderMapper extends BaseMapper<ShopOrder> {
*/
@Select("SELECT COALESCE(SUM(pay_price), 0) FROM shop_order WHERE pay_status = 1 AND deleted = 0 AND pay_price IS NOT NULL")
BigDecimal selectTotalAmount();
/**
* 获取用户订单各状态数量(用于前端订单页徽标/统计,一次查询返回多项聚合结果)
*/
Map<String, Object> selectUserOrderStats(@Param("userId") Integer userId,
@Param("tenantId") Integer tenantId,
@Param("type") Integer type);
}

View File

@@ -281,6 +281,33 @@
<include refid="selectSql"></include>
</select>
<!--
用户订单状态统计:
对齐 ShopOrderMapper.xml 里 statusFilter 的含义0/1/2/3/4/5/6/7/8避免前端通过分页接口串行请求统计数量。
-->
<select id="selectUserOrderStats" resultType="java.util.HashMap">
SELECT
-- “全部”默认也会过滤已关闭(取消)订单statusFilter != 8 时会加 a.order_status != 2
COALESCE(SUM(CASE WHEN a.deleted = 0 AND a.order_status != 2 THEN 1 ELSE 0 END), 0) AS total,
COALESCE(SUM(CASE WHEN a.deleted = 0 AND a.order_status != 2 AND a.pay_status = 0 AND a.order_status = 0 THEN 1 ELSE 0 END), 0) AS waitPay,
COALESCE(SUM(CASE WHEN a.deleted = 0 AND a.order_status != 2 AND a.pay_status = 1 AND a.delivery_status = 10 AND a.order_status = 0 THEN 1 ELSE 0 END), 0) AS waitDeliver,
COALESCE(SUM(CASE WHEN a.deleted = 0 AND a.order_status != 2 AND a.pay_status = 1 AND a.order_status = 0 THEN 1 ELSE 0 END), 0) AS waitVerify,
COALESCE(SUM(CASE WHEN a.deleted = 0 AND a.order_status != 2 AND a.delivery_status = 20 AND a.order_status != 1 THEN 1 ELSE 0 END), 0) AS waitReceive,
COALESCE(SUM(CASE WHEN a.deleted = 0 AND a.order_status != 2 AND a.order_status = 1 AND a.evaluate_status = 0 THEN 1 ELSE 0 END), 0) AS waitComment,
COALESCE(SUM(CASE WHEN a.deleted = 0 AND a.order_status != 2 AND a.order_status = 1 THEN 1 ELSE 0 END), 0) AS completed,
COALESCE(SUM(CASE WHEN a.deleted = 0 AND a.order_status != 2 AND (a.order_status = 4 OR a.order_status = 5 OR a.order_status = 6 OR a.order_status = 7) THEN 1 ELSE 0 END), 0) AS refund,
COALESCE(SUM(CASE WHEN a.deleted = 1 THEN 1 ELSE 0 END), 0) AS deleted,
COALESCE(SUM(CASE WHEN a.deleted = 0 AND a.order_status = 2 THEN 1 ELSE 0 END), 0) AS canceled
FROM shop_order a
WHERE a.user_id = #{userId}
<if test="tenantId != null">
AND a.tenant_id = #{tenantId}
</if>
<if test="type != null">
AND a.type = #{type}
</if>
</select>
<!-- 根据订单号查询订单 -->
<select id="getByOutTradeNo" resultType="com.gxwebsoft.shop.entity.ShopOrder">
SELECT * FROM shop_order WHERE order_no = #{outTradeNo}

View File

@@ -2,6 +2,7 @@ package com.gxwebsoft.shop.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.shop.dto.UserOrderStats;
import com.gxwebsoft.shop.entity.ShopOrder;
import com.gxwebsoft.shop.param.ShopOrderParam;
@@ -76,4 +77,13 @@ public interface ShopOrderService extends IService<ShopOrder> {
* @return 是否更新成功
*/
boolean syncPaymentStatus(String orderNo, Integer paymentStatus, String transactionId, String payTime, Integer tenantId);
/**
* 获取当前用户订单状态统计(一次聚合查询,避免前端用分页接口串行拉取多个状态数量)
*
* @param userId 用户ID
* @param tenantId 租户ID可为空优先走租户插件
* @param type 订单类型(可为空)
*/
UserOrderStats getUserOrderStats(Integer userId, Integer tenantId, Integer type);
}

View File

@@ -38,6 +38,7 @@ import javax.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
@@ -84,6 +85,8 @@ public class ShopOrderServiceImpl extends ServiceImpl<ShopOrderMapper, ShopOrder
@Resource
private ShopExpressService shopExpressService;
private static final long USER_ORDER_STATS_CACHE_SECONDS = 60L;
@Override
public PageResult<ShopOrder> pageRel(ShopOrderParam param) {
@@ -135,6 +138,80 @@ public class ShopOrderServiceImpl extends ServiceImpl<ShopOrderMapper, ShopOrder
return param.getOne(baseMapper.selectListRel(param));
}
@Override
public com.gxwebsoft.shop.dto.UserOrderStats getUserOrderStats(Integer userId, Integer tenantId, Integer type) {
if (userId == null) {
// 保持调用方逻辑简单:未登录场景由 controller 处理,这里返回全 0
return new com.gxwebsoft.shop.dto.UserOrderStats();
}
String cacheKey = "ShopOrder:UserOrderStats:"
+ (tenantId == null ? "0" : tenantId)
+ ":" + userId
+ ":" + (type == null ? "all" : type);
com.gxwebsoft.shop.dto.UserOrderStats cached = redisUtil.get(cacheKey, com.gxwebsoft.shop.dto.UserOrderStats.class);
if (cached != null) {
return cached;
}
Map<String, Object> raw = baseMapper.selectUserOrderStats(userId, tenantId, type);
if (raw == null) {
raw = Collections.emptyMap();
}
com.gxwebsoft.shop.dto.UserOrderStats stats = new com.gxwebsoft.shop.dto.UserOrderStats();
stats.setTotal(getLong(raw, "total"));
stats.setWaitPay(getLong(raw, "waitPay"));
stats.setWaitDeliver(getLong(raw, "waitDeliver"));
stats.setWaitVerify(getLong(raw, "waitVerify"));
stats.setWaitReceive(getLong(raw, "waitReceive"));
stats.setWaitComment(getLong(raw, "waitComment"));
stats.setCompleted(getLong(raw, "completed"));
stats.setRefund(getLong(raw, "refund"));
stats.setDeleted(getLong(raw, "deleted"));
stats.setCanceled(getLong(raw, "canceled"));
Map<String, Long> statusFilter = new HashMap<>();
statusFilter.put("0", stats.getWaitPay());
statusFilter.put("1", stats.getWaitDeliver());
statusFilter.put("2", stats.getWaitVerify());
statusFilter.put("3", stats.getWaitReceive());
statusFilter.put("4", stats.getWaitComment());
statusFilter.put("5", stats.getCompleted());
statusFilter.put("6", stats.getRefund());
statusFilter.put("7", stats.getDeleted());
statusFilter.put("8", stats.getCanceled());
stats.setStatusFilter(statusFilter);
redisUtil.set(cacheKey, stats, USER_ORDER_STATS_CACHE_SECONDS, TimeUnit.SECONDS);
return stats;
}
private static Long getLong(Map<String, Object> raw, String key) {
Object value = raw.get(key);
if (value == null) {
value = raw.get(key.toUpperCase(Locale.ROOT));
}
if (value == null) {
value = raw.get(key.toLowerCase(Locale.ROOT));
}
return toLong(value);
}
private static Long toLong(Object value) {
if (value == null) {
return 0L;
}
if (value instanceof Number) {
return ((Number) value).longValue();
}
try {
return Long.parseLong(String.valueOf(value));
} catch (Exception e) {
return 0L;
}
}
@Override
public HashMap<String, String> createWxOrder(ShopOrder order) {
Payment payment = null; // 声明在try块外面这样catch块也能访问