Browse Source

Merge branch 'develop' of https://gitee.com/CrazyWorld/ruoyi-vue-pro into develop

# Conflicts:
#	yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/article/AppArticleController.java
YunaiV 1 year ago
parent
commit
eb048532f7
43 changed files with 1398 additions and 255 deletions
  1. 22 0
      sql/mysql/optinal/product_browse_history.sql
  2. 34 0
      sql/mysql/optinal/product_statistics.sql
  3. 19 0
      yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/pojo/SortablePageParam.java
  4. 11 2
      yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/object/BeanUtils.java
  5. 11 1
      yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java
  6. 52 4
      yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtils.java
  7. 0 1
      yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/java/controller/vo/saveReqVO.vm
  8. 63 58
      yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/api/api.ts.vm
  9. 1 1
      yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/views/components/form_sub_erp.vue.vm
  10. 1 1
      yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/views/components/form_sub_normal.vue.vm
  11. 1 1
      yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/views/components/list_sub_erp.vue.vm
  12. 4 1
      yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/views/form.vue.vm
  13. 16 8
      yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/views/index.vue.vm
  14. 39 0
      yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/history/ProductBrowseHistoryController.java
  15. 33 0
      yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/history/vo/ProductBrowseHistoryPageReqVO.java
  16. 34 0
      yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/history/vo/ProductBrowseHistoryRespVO.java
  17. 90 0
      yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/history/AppProductBrowseHistoryController.java
  18. 19 0
      yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/history/vo/AppProductBrowseHistoryDeleteReqVO.java
  19. 23 0
      yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/history/vo/AppProductBrowseHistoryPageReqVO.java
  20. 29 0
      yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/history/vo/AppProductBrowseHistoryRespVO.java
  21. 8 0
      yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/spu/AppProductSpuController.java
  22. 42 0
      yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/history/ProductBrowseHistoryDO.java
  23. 52 0
      yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/history/ProductBrowseHistoryMapper.java
  24. 16 3
      yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/spu/ProductSpuMapper.java
  25. 58 0
      yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/history/ProductBrowseHistoryService.java
  26. 77 0
      yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/history/ProductBrowseHistoryServiceImpl.java
  27. 8 0
      yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/spu/ProductSpuService.java
  28. 5 0
      yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/spu/ProductSpuServiceImpl.java
  29. 68 18
      yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/controller/admin/product/ProductStatisticsController.java
  30. 25 0
      yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/controller/admin/product/vo/ProductStatisticsReqVO.java
  31. 81 0
      yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/controller/admin/product/vo/ProductStatisticsRespVO.java
  32. 6 8
      yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/controller/admin/trade/TradeStatisticsController.java
  33. 1 1
      yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/controller/admin/trade/vo/TradeTrendSummaryRespVO.java
  34. 80 0
      yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/dataobject/product/ProductStatisticsDO.java
  35. 0 74
      yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/product/ProductSpuStatisticsDO.java
  36. 0 70
      yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/product/ProductStatisticsDO.java
  37. 80 0
      yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/product/ProductStatisticsMapper.java
  38. 48 0
      yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/job/product/ProductStatisticsJob.java
  39. 51 0
      yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/product/ProductStatisticsService.java
  40. 123 0
      yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/product/ProductStatisticsServiceImpl.java
  41. 1 1
      yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/trade/TradeStatisticsService.java
  42. 2 2
      yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/trade/TradeStatisticsServiceImpl.java
  43. 64 0
      yudao-module-mall/yudao-module-statistics-biz/src/main/resources/mapper/product/ProductStatisticsMapper.xml

+ 22 - 0
sql/mysql/optinal/product_browse_history.sql

@@ -0,0 +1,22 @@
+CREATE TABLE product_browse_history
+(
+    id           bigint AUTO_INCREMENT COMMENT '记录编号'
+        PRIMARY KEY,
+    user_id      bigint                                NOT NULL COMMENT '用户编号',
+    spu_id       bigint                                NOT NULL COMMENT '商品 SPU 编号',
+    user_deleted bit         DEFAULT b'0'              NOT NULL COMMENT '用户是否删除',
+    creator      varchar(64) DEFAULT ''                NULL COMMENT '创建者',
+    create_time  datetime    DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',
+    updater      varchar(64) DEFAULT ''                NULL COMMENT '更新者',
+    update_time  datetime    DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    deleted      bit         DEFAULT b'0'              NOT NULL COMMENT '是否删除',
+    tenant_id    bigint      DEFAULT 0                 NOT NULL COMMENT '租户编号'
+)
+    COMMENT '商品浏览记录表';
+
+CREATE INDEX idx_spuId
+    ON product_browse_history (spu_id);
+
+CREATE INDEX idx_userId
+    ON product_browse_history (user_id);
+

+ 34 - 0
sql/mysql/optinal/product_statistics.sql

@@ -0,0 +1,34 @@
+CREATE TABLE product_statistics
+(
+    id                      bigint AUTO_INCREMENT COMMENT '编号,主键自增' PRIMARY KEY,
+    time                    date                                  NOT NULL COMMENT '统计日期',
+    spu_id                  bigint                                NOT NULL COMMENT '商品SPU编号',
+    browse_count            int         DEFAULT 0                 NOT NULL COMMENT '浏览量',
+    browse_user_count       int         DEFAULT 0                 NOT NULL COMMENT '访客量',
+    favorite_count          int         DEFAULT 0                 NOT NULL COMMENT '收藏数量',
+    cart_count              int         DEFAULT 0                 NOT NULL COMMENT '加购数量',
+    order_count             int         DEFAULT 0                 NOT NULL COMMENT '下单件数',
+    order_pay_count         int         DEFAULT 0                 NOT NULL COMMENT '支付件数',
+    order_pay_price         int         DEFAULT 0                 NOT NULL COMMENT '支付金额,单位:分',
+    after_sale_count        int         DEFAULT 0                 NOT NULL COMMENT '退款件数',
+    after_sale_refund_price int         DEFAULT 0                 NOT NULL COMMENT '退款金额,单位:分',
+    browse_convert_percent  int         DEFAULT 0                 NOT NULL COMMENT '访客支付转化率(百分比)',
+    creator                 varchar(64) DEFAULT ''                NULL COMMENT '创建者',
+    create_time             datetime    DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',
+    updater                 varchar(64) DEFAULT ''                NULL COMMENT '更新者',
+    update_time             datetime    DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    deleted                 bit         DEFAULT b'0'              NOT NULL COMMENT '是否删除',
+    tenant_id               bigint      DEFAULT 0                 NOT NULL COMMENT '租户编号'
+)
+    COMMENT '商品统计表';
+
+CREATE INDEX idx_time
+    ON product_statistics (time);
+
+CREATE INDEX idx_spu_id
+    ON product_statistics (spu_id);
+
+INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) VALUES ('商品统计', '', 2, 6, 2358, 'product', 'fa:product-hunt', 'statistics/product/index', 'ProductStatistics', 0, true, true, true, '', '2023-12-15 18:54:28', '', '2023-12-15 18:54:33', false);
+SELECT @parentId1 := LAST_INSERT_ID();
+INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) VALUES ('商品统计查询', 'statistics:product:query', 3, 1, @parentId, '', '', '', null, 0, true, true, true, '', '2023-09-30 03:22:40', '', '2023-09-30 03:22:40', false);
+INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) VALUES ('商品统计导出', 'statistics:product:export', 3, 2, @parentId, '', '', '', null, 0, true, true, true, '', '2023-09-30 03:22:40', '', '2023-09-30 03:22:40', false);

+ 19 - 0
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/pojo/SortablePageParam.java

@@ -0,0 +1,19 @@
+package cn.iocoder.yudao.framework.common.pojo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+import java.util.List;
+
+@Schema(description = "可排序的分页参数")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class SortablePageParam extends PageParam {
+
+    @Schema(description = "排序字段")
+    private List<SortingField> sortingFields;
+
+}

+ 11 - 2
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/object/BeanUtils.java

@@ -5,6 +5,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
 
 import java.util.List;
+import java.util.function.Consumer;
 
 /**
  * Bean 工具类
@@ -27,11 +28,19 @@ public class BeanUtils {
         return CollectionUtils.convertList(source, s -> toBean(s, targetType));
     }
 
-    public static  <S, T> PageResult<T> toBean(PageResult<S> source, Class<T> targetType) {
+    public static <S, T> PageResult<T> toBean(PageResult<S> source, Class<T> targetType) {
+        return toBean(source, targetType, null);
+    }
+
+    public static <S, T> PageResult<T> toBean(PageResult<S> source, Class<T> targetType, Consumer<T> peek) {
         if (source == null) {
             return null;
         }
-        return new PageResult<>(toBean(source.getList(), targetType), source.getTotal());
+        List<T> list = toBean(source.getList(), targetType);
+        if (peek != null) {
+            list.forEach(peek);
+        }
+        return new PageResult<>(list, source.getTotal());
     }
 
 }

+ 11 - 1
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java

@@ -3,6 +3,8 @@ package cn.iocoder.yudao.framework.mybatis.core.mapper;
 import cn.hutool.core.collection.CollUtil;
 import cn.iocoder.yudao.framework.common.pojo.PageParam;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.pojo.SortablePageParam;
+import cn.iocoder.yudao.framework.common.pojo.SortingField;
 import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@@ -27,7 +29,15 @@ import java.util.List;
  */
 public interface BaseMapperX<T> extends MPJBaseMapper<T> {
 
+    default PageResult<T> selectPage(SortablePageParam pageParam, @Param("ew") Wrapper<T> queryWrapper) {
+        return selectPage(pageParam, pageParam.getSortingFields(), queryWrapper);
+    }
+
     default PageResult<T> selectPage(PageParam pageParam, @Param("ew") Wrapper<T> queryWrapper) {
+        return selectPage(pageParam, null, queryWrapper);
+    }
+
+    default PageResult<T> selectPage(PageParam pageParam, Collection<SortingField> sortingFields, @Param("ew") Wrapper<T> queryWrapper) {
         // 特殊:不分页,直接查询全部
         if (PageParam.PAGE_SIZE_NONE.equals(pageParam.getPageSize())) {
             List<T> list = selectList(queryWrapper);
@@ -35,7 +45,7 @@ public interface BaseMapperX<T> extends MPJBaseMapper<T> {
         }
 
         // MyBatis Plus 查询
-        IPage<T> mpPage = MyBatisUtils.buildPage(pageParam);
+        IPage<T> mpPage = MyBatisUtils.buildPage(pageParam, sortingFields);
         selectPage(mpPage, queryWrapper);
         // 转换返回
         return new PageResult<>(mpPage.getRecords(), mpPage.getTotal());

+ 52 - 4
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/MyBatisUtils.java

@@ -1,7 +1,12 @@
 package cn.iocoder.yudao.framework.mybatis.core.util;
 
+import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.collection.CollectionUtil;
+import cn.hutool.core.lang.func.Func1;
+import cn.hutool.core.lang.func.LambdaUtil;
+import cn.hutool.core.util.ArrayUtil;
 import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import cn.iocoder.yudao.framework.common.pojo.SortablePageParam;
 import cn.iocoder.yudao.framework.common.pojo.SortingField;
 import com.baomidou.mybatisplus.core.metadata.OrderItem;
 import com.baomidou.mybatisplus.core.toolkit.StringPool;
@@ -11,6 +16,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import net.sf.jsqlparser.expression.Alias;
 import net.sf.jsqlparser.schema.Column;
 import net.sf.jsqlparser.schema.Table;
+import org.springframework.util.Assert;
 
 import java.util.ArrayList;
 import java.util.Collection;
@@ -45,8 +51,8 @@ public class MyBatisUtils {
      * 由于 MybatisPlusInterceptor 不支持添加拦截器,所以只能全量设置
      *
      * @param interceptor 链
-     * @param inner 拦截器
-     * @param index 位置
+     * @param inner       拦截器
+     * @param index       位置
      */
     public static void addInterceptor(MybatisPlusInterceptor interceptor, InnerInterceptor inner, int index) {
         List<InnerInterceptor> inners = new ArrayList<>(interceptor.getInterceptors());
@@ -73,9 +79,9 @@ public class MyBatisUtils {
     /**
      * 构建 Column 对象
      *
-     * @param tableName 表名
+     * @param tableName  表名
      * @param tableAlias 别名
-     * @param column 字段名
+     * @param column     字段名
      * @return Column 对象
      */
     public static Column buildColumn(String tableName, Alias tableAlias, String column) {
@@ -85,4 +91,46 @@ public class MyBatisUtils {
         return new Column(tableName + StringPool.DOT + column);
     }
 
+
+    /**
+     * 构建排序字段(默认倒序)
+     *
+     * @param func 排序字段的 Lambda 表达式
+     * @param <T>  排序字段所属的类型
+     * @return 排序字段
+     */
+    public static <T> SortingField buildSortingField(Func1<T, ?> func) {
+        return buildSortingField(func, SortingField.ORDER_DESC);
+    }
+
+    /**
+     * 构建排序字段
+     *
+     * @param func  排序字段的 Lambda 表达式
+     * @param order 排序类型 {@link SortingField#ORDER_ASC} {@link SortingField#ORDER_DESC}
+     * @param <T>   排序字段所属的类型
+     * @return 排序字段
+     */
+    public static <T> SortingField buildSortingField(Func1<T, ?> func, String order) {
+        Object[] orderTypes = {SortingField.ORDER_ASC, SortingField.ORDER_DESC};
+        Assert.isTrue(ArrayUtil.contains(orderTypes, order), String.format("字段的排序类型只能是%s/%s", orderTypes));
+
+        String fieldName = LambdaUtil.getFieldName(func);
+        return new SortingField(fieldName, order);
+    }
+
+    /**
+     * 构建默认的排序字段
+     * 如果排序字段为空,则设置排序字段;否则忽略
+     *
+     * @param sortablePageParam 排序分页查询参数
+     * @param func              排序字段的 Lambda 表达式
+     * @param <T>               排序字段所属的类型
+     */
+    public static <T> void buildDefaultSortingField(SortablePageParam sortablePageParam, Func1<T, ?> func) {
+        if (sortablePageParam != null && CollUtil.isEmpty(sortablePageParam.getSortingFields())) {
+            sortablePageParam.setSortingFields(List.of(buildSortingField(func)));
+        }
+    }
+
 }

+ 0 - 1
yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/java/controller/vo/saveReqVO.vm

@@ -5,7 +5,6 @@ import lombok.*;
 import java.util.*;
 import jakarta.validation.constraints.*;
 ## 处理 BigDecimal 字段的引入
-import java.util.*;
 #foreach ($column in $columns)
 #if (${column.javaType} == "BigDecimal")
 import java.math.BigDecimal;

+ 63 - 58
yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/api/api.ts.vm

@@ -1,12 +1,14 @@
 import request from '@/config/axios'
 #set ($baseURL = "/${table.moduleName}/${simpleClassName_strikeCase}")
 
+// ${table.classComment} VO
 export interface ${simpleClassName}VO {
 #foreach ($column in $columns)
 #if ($column.createOperation || $column.updateOperation)
+  // ${column.columnComment}
 #if(${column.javaType.toLowerCase()} == "long" || ${column.javaType.toLowerCase()} == "integer" || ${column.javaType.toLowerCase()} == "short" || ${column.javaType.toLowerCase()} == "double" || ${column.javaType.toLowerCase()} == "bigdecimal")
   ${column.javaField}: number
-#elseif(${column.javaType.toLowerCase()} == "date" || ${column.javaType.toLowerCase()} == "localdatetime")
+#elseif(${column.javaType.toLowerCase()} == "date" || ${column.javaType.toLowerCase()} == "localdate" || ${column.javaType.toLowerCase()} == "localdatetime")
   ${column.javaField}: Date
 #else
   ${column.javaField}: ${column.javaType.toLowerCase()}
@@ -15,42 +17,44 @@ export interface ${simpleClassName}VO {
 #end
 }
 
+// ${table.classComment} API
+export const ${simpleClassName}Api = {
 #if ( $table.templateType != 2 )
-// 查询${table.classComment}分页
-export const get${simpleClassName}Page = async (params) => {
-  return await request.get({ url: `${baseURL}/page`, params })
-}
+  // 查询${table.classComment}分页
+  get${simpleClassName}Page: async (params: any) => {
+    return await request.get({ url: `${baseURL}/page`, params })
+  },
 #else
-// 查询${table.classComment}列表
-export const get${simpleClassName}List = async (params) => {
-  return await request.get({ url: `${baseURL}/list`, params })
-}
+  // 查询${table.classComment}列表
+  get${simpleClassName}List: async (params) => {
+    return await request.get({ url: `${baseURL}/list`, params })
+  },
 #end
 
-// 查询${table.classComment}详情
-export const get${simpleClassName} = async (id: number) => {
-  return await request.get({ url: `${baseURL}/get?id=` + id })
-}
+  // 查询${table.classComment}详情
+  get${simpleClassName}: async (id: number) => {
+    return await request.get({ url: `${baseURL}/get?id=` + id })
+  },
 
-// 新增${table.classComment}
-export const create${simpleClassName} = async (data: ${simpleClassName}VO) => {
-  return await request.post({ url: `${baseURL}/create`, data })
-}
+  // 新增${table.classComment}
+  create${simpleClassName}: async (data: ${simpleClassName}VO) => {
+    return await request.post({ url: `${baseURL}/create`, data })
+  },
 
-// 修改${table.classComment}
-export const update${simpleClassName} = async (data: ${simpleClassName}VO) => {
-  return await request.put({ url: `${baseURL}/update`, data })
-}
+  // 修改${table.classComment}
+  update${simpleClassName}: async (data: ${simpleClassName}VO) => {
+    return await request.put({ url: `${baseURL}/update`, data })
+  },
 
-// 删除${table.classComment}
-export const delete${simpleClassName} = async (id: number) => {
-  return await request.delete({ url: `${baseURL}/delete?id=` + id })
-}
+  // 删除${table.classComment}
+  delete${simpleClassName}: async (id: number) => {
+    return await request.delete({ url: `${baseURL}/delete?id=` + id })
+  },
 
-// 导出${table.classComment} Excel
-export const export${simpleClassName} = async (params) => {
-  return await request.download({ url: `${baseURL}/export-excel`, params })
-}
+  // 导出${table.classComment} Excel
+  export${simpleClassName}: async (params) => {
+    return await request.download({ url: `${baseURL}/export-excel`, params })
+  },
 ## 特殊:主子表专属逻辑
 #foreach ($subTable in $subTables)
 #set ($index = $foreach.count - 1)
@@ -66,46 +70,47 @@ export const export${simpleClassName} = async (params) => {
 ## 情况一:MASTER_ERP 时,需要分查询页子表
 #if ( $table.templateType == 11 )
 
-// 获得${subTable.classComment}分页
-export const get${subSimpleClassName}Page = async (params) => {
-  return await request.get({ url: `${baseURL}/${subSimpleClassName_strikeCase}/page`, params })
-}
+  // 获得${subTable.classComment}分页
+  get${subSimpleClassName}Page: async (params) => {
+    return await request.get({ url: `${baseURL}/${subSimpleClassName_strikeCase}/page`, params })
+  },
 ## 情况二:非 MASTER_ERP 时,需要列表查询子表
 #else
   #if ( $subTable.subJoinMany )
 
-// 获得${subTable.classComment}列表
-export const get${subSimpleClassName}ListBy${SubJoinColumnName} = async (${subJoinColumn.javaField}) => {
-  return await request.get({ url: `${baseURL}/${subSimpleClassName_strikeCase}/list-by-${subJoinColumn_strikeCase}?${subJoinColumn.javaField}=` + ${subJoinColumn.javaField} })
-}
+  // 获得${subTable.classComment}列表
+  get${subSimpleClassName}ListBy${SubJoinColumnName}: async (${subJoinColumn.javaField}) => {
+    return await request.get({ url: `${baseURL}/${subSimpleClassName_strikeCase}/list-by-${subJoinColumn_strikeCase}?${subJoinColumn.javaField}=` + ${subJoinColumn.javaField} })
+  },
   #else
 
-// 获得${subTable.classComment}
-export const get${subSimpleClassName}By${SubJoinColumnName} = async (${subJoinColumn.javaField}) => {
-  return await request.get({ url: `${baseURL}/${subSimpleClassName_strikeCase}/get-by-${subJoinColumn_strikeCase}?${subJoinColumn.javaField}=` + ${subJoinColumn.javaField} })
-}
+  // 获得${subTable.classComment}
+  get${subSimpleClassName}By${SubJoinColumnName}: async (${subJoinColumn.javaField}) => {
+    return await request.get({ url: `${baseURL}/${subSimpleClassName_strikeCase}/get-by-${subJoinColumn_strikeCase}?${subJoinColumn.javaField}=` + ${subJoinColumn.javaField} })
+  },
   #end
 #end
 ## 特殊:MASTER_ERP 时,支持单个的新增、修改、删除操作
 #if ( $table.templateType == 11 )
-// 新增${subTable.classComment}
-export const create${subSimpleClassName} = async (data) => {
-  return await request.post({ url: `${baseURL}/${subSimpleClassName_strikeCase}/create`, data })
-}
+  // 新增${subTable.classComment}
+  create${subSimpleClassName}: async (data) => {
+    return await request.post({ url: `${baseURL}/${subSimpleClassName_strikeCase}/create`, data })
+  },
 
-// 修改${subTable.classComment}
-export const update${subSimpleClassName} = async (data) => {
-  return await request.put({ url: `${baseURL}/${subSimpleClassName_strikeCase}/update`, data })
-}
+  // 修改${subTable.classComment}
+  update${subSimpleClassName}: async (data) => {
+    return await request.put({ url: `${baseURL}/${subSimpleClassName_strikeCase}/update`, data })
+  },
 
-// 删除${subTable.classComment}
-export const delete${subSimpleClassName} = async (id: number) => {
-  return await request.delete({ url: `${baseURL}/${subSimpleClassName_strikeCase}/delete?id=` + id })
-}
+  // 删除${subTable.classComment}
+  delete${subSimpleClassName}: async (id: number) => {
+    return await request.delete({ url: `${baseURL}/${subSimpleClassName_strikeCase}/delete?id=` + id })
+  },
 
-// 获得${subTable.classComment}
-export const get${subSimpleClassName} = async (id: number) => {
-  return await request.get({ url: `${baseURL}/${subSimpleClassName_strikeCase}/get?id=` + id })
-}
+  // 获得${subTable.classComment}
+  get${subSimpleClassName}: async (id: number) => {
+    return await request.get({ url: `${baseURL}/${subSimpleClassName_strikeCase}/get?id=` + id })
+  },
 #end
-#end
+#end
+}

+ 1 - 1
yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/views/components/form_sub_erp.vue.vm

@@ -114,7 +114,7 @@
 </template>
 <script setup lang="ts">
 import { getIntDictOptions, getStrDictOptions, getBoolDictOptions, DICT_TYPE } from '@/utils/dict'
-import * as ${simpleClassName}Api from '@/api/${table.moduleName}/${table.businessName}'
+import { ${simpleClassName}Api } from '@/api/${table.moduleName}/${table.businessName}'
 
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗

+ 1 - 1
yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/views/components/form_sub_normal.vue.vm

@@ -265,7 +265,7 @@
 </template>
 <script setup lang="ts">
 import { getIntDictOptions, getStrDictOptions, getBoolDictOptions, DICT_TYPE } from '@/utils/dict'
-import * as ${simpleClassName}Api from '@/api/${table.moduleName}/${table.businessName}'
+import { ${simpleClassName}Api } from '@/api/${table.moduleName}/${table.businessName}'
 
 const props = defineProps<{
   ${subJoinColumn.javaField}: undefined // ${subJoinColumn.columnComment}(主表的关联字段)

+ 1 - 1
yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/views/components/list_sub_erp.vue.vm

@@ -85,7 +85,7 @@
 <script setup lang="ts">
 import { getIntDictOptions, getStrDictOptions, getBoolDictOptions, DICT_TYPE } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
-import * as ${simpleClassName}Api from '@/api/${table.moduleName}/${table.businessName}'
+import { ${simpleClassName}Api } from '@/api/${table.moduleName}/${table.businessName}'
 #if ($table.templateType == 11)
 import ${subSimpleClassName}Form from './${subSimpleClassName}Form.vue'
 #end

+ 4 - 1
yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/views/form.vue.vm

@@ -140,7 +140,7 @@
 </template>
 <script setup lang="ts">
 import { getIntDictOptions, getStrDictOptions, getBoolDictOptions, DICT_TYPE } from '@/utils/dict'
-import * as ${simpleClassName}Api from '@/api/${table.moduleName}/${table.businessName}'
+import { ${simpleClassName}Api, ${simpleClassName}VO } from '@/api/${table.moduleName}/${table.businessName}'
 ## 特殊:树表专属逻辑
 #if ( $table.templateType == 2 )
 import { defaultProps, handleTree } from '@/utils/tree'
@@ -152,6 +152,9 @@ import ${subSimpleClassName}Form from './components/${subSimpleClassName}Form.vu
 #end
 #end
 
+/** ${table.classComment} 表单 */
+defineOptions({ name: '${simpleClassName}Form' })
+
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
 

+ 16 - 8
yudao-module-infra/yudao-module-infra-biz/src/main/resources/codegen/vue3/views/index.vue.vm

@@ -240,7 +240,7 @@ import { dateFormatter } from '@/utils/formatTime'
 import { handleTree } from '@/utils/tree'
 #end
 import download from '@/utils/download'
-import * as ${simpleClassName}Api from '@/api/${table.moduleName}/${table.businessName}'
+import { ${simpleClassName}Api, ${simpleClassName}VO } from '@/api/${table.moduleName}/${table.businessName}'
 import ${simpleClassName}Form from './${simpleClassName}Form.vue'
 ## 特殊:主子表专属逻辑
 #if ( $table.templateType != 10 )
@@ -249,16 +249,22 @@ import ${subSimpleClassName}List from './components/${subSimpleClassName}List.vu
 #end
 #end
 
+/** ${table.classComment} 列表 */
 defineOptions({ name: '${table.className}' })
 
-const message = useMessage() // 消息弹窗
-const { t } = useI18n() // 国际化
+// 消息弹窗
+const message = useMessage()
+// 国际化
+const { t } = useI18n()
 
-const loading = ref(true) // 列表的加载中
-const list = ref([]) // 列表的数据
+// 列表的加载中
+const loading = ref(true)
+// 列表的数据
+const list = ref<${simpleClassName}VO[]>([])
 ## 特殊:树表专属逻辑(树不需要分页接口)
 #if ( $table.templateType != 2 )
-const total = ref(0) // 列表的总页数
+// 列表的总页数
+const total = ref(0)
 #end
 const queryParams = reactive({
 ## 特殊:树表专属逻辑(树不需要分页接口)
@@ -277,8 +283,10 @@ const queryParams = reactive({
     #end
   #end
 })
-const queryFormRef = ref() // 搜索的表单
-const exportLoading = ref(false) // 导出的加载中
+// 搜索的表单
+const queryFormRef = ref()
+// 导出的加载中
+const exportLoading = ref(false)
 
 /** 查询列表 */
 const getList = async () => {

+ 39 - 0
yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/history/ProductBrowseHistoryController.java

@@ -0,0 +1,39 @@
+package cn.iocoder.yudao.module.product.controller.admin.history;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.module.product.controller.admin.history.vo.ProductBrowseHistoryPageReqVO;
+import cn.iocoder.yudao.module.product.controller.admin.history.vo.ProductBrowseHistoryRespVO;
+import cn.iocoder.yudao.module.product.dal.dataobject.history.ProductBrowseHistoryDO;
+import cn.iocoder.yudao.module.product.service.history.ProductBrowseHistoryService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+
+@Tag(name = "管理后台 - 商品浏览记录")
+@RestController
+@RequestMapping("/product/browse-history")
+@Validated
+public class ProductBrowseHistoryController {
+
+    @Resource
+    private ProductBrowseHistoryService browseHistoryService;
+
+    @GetMapping("/page")
+    @Operation(summary = "获得商品浏览记录分页")
+    @PreAuthorize("@ss.hasPermission('product:browse-history:query')")
+    public CommonResult<PageResult<ProductBrowseHistoryRespVO>> getBrowseHistoryPage(@Valid ProductBrowseHistoryPageReqVO pageReqVO) {
+        PageResult<ProductBrowseHistoryDO> pageResult = browseHistoryService.getBrowseHistoryPage(pageReqVO);
+        return success(BeanUtils.toBean(pageResult, ProductBrowseHistoryRespVO.class));
+    }
+
+}

+ 33 - 0
yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/history/vo/ProductBrowseHistoryPageReqVO.java

@@ -0,0 +1,33 @@
+package cn.iocoder.yudao.module.product.controller.admin.history.vo;
+
+import cn.iocoder.yudao.framework.common.pojo.SortablePageParam;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@Schema(description = "管理后台 - 商品浏览记录分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class ProductBrowseHistoryPageReqVO extends SortablePageParam {
+
+    @Schema(description = "用户编号", example = "4314")
+    private Long userId;
+
+    @Schema(description = "用户是否删除", example = "false")
+    private Boolean userDeleted;
+
+    @Schema(description = "商品 SPU 编号", example = "42")
+    private Long spuId;
+
+    @Schema(description = "创建时间")
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    private LocalDateTime[] createTime;
+
+}

+ 34 - 0
yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/history/vo/ProductBrowseHistoryRespVO.java

@@ -0,0 +1,34 @@
+package cn.iocoder.yudao.module.product.controller.admin.history.vo;
+
+import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
+import com.alibaba.excel.annotation.ExcelProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "管理后台 - 商品浏览记录 Response VO")
+@Data
+@ExcelIgnoreUnannotated
+public class ProductBrowseHistoryRespVO {
+
+    @Schema(description = "记录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "26055")
+    @ExcelProperty("记录编号")
+    private Long id;
+
+    @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "4314")
+    @ExcelProperty("用户编号")
+    private Long userId;
+
+    @Schema(description = "用户是否删除", example = "false")
+    private Boolean userDeleted;
+
+    @Schema(description = "商品 SPU 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "42")
+    @ExcelProperty("商品 SPU 编号")
+    private Long spuId;
+
+    @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
+    @ExcelProperty("创建时间")
+    private LocalDateTime createTime;
+
+}

+ 90 - 0
yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/history/AppProductBrowseHistoryController.java

@@ -0,0 +1,90 @@
+package cn.iocoder.yudao.module.product.controller.app.history;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.framework.security.core.annotations.PreAuthenticated;
+import cn.iocoder.yudao.module.product.controller.admin.history.vo.ProductBrowseHistoryPageReqVO;
+import cn.iocoder.yudao.module.product.controller.app.history.vo.AppProductBrowseHistoryDeleteReqVO;
+import cn.iocoder.yudao.module.product.controller.app.history.vo.AppProductBrowseHistoryPageReqVO;
+import cn.iocoder.yudao.module.product.controller.app.history.vo.AppProductBrowseHistoryRespVO;
+import cn.iocoder.yudao.module.product.dal.dataobject.history.ProductBrowseHistoryDO;
+import cn.iocoder.yudao.module.product.dal.dataobject.spu.ProductSpuDO;
+import cn.iocoder.yudao.module.product.service.history.ProductBrowseHistoryService;
+import cn.iocoder.yudao.module.product.service.spu.ProductSpuService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
+import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
+
+@Tag(name = "用户 APP - 商品浏览记录")
+@RestController
+@RequestMapping("/product/browse-history")
+public class AppProductBrowseHistoryController {
+
+    @Resource
+    private ProductBrowseHistoryService productBrowseHistoryService;
+    @Resource
+    private ProductSpuService productSpuService;
+
+    @DeleteMapping(value = "/delete")
+    @Operation(summary = "删除商品浏览记录")
+    @PreAuthenticated
+    public CommonResult<Boolean> deleteBrowseHistory(@RequestBody @Valid AppProductBrowseHistoryDeleteReqVO reqVO) {
+        productBrowseHistoryService.hideUserBrowseHistory(getLoginUserId(), reqVO.getSpuIds());
+        return success(Boolean.TRUE);
+    }
+
+    @DeleteMapping(value = "/clean")
+    @Operation(summary = "清空商品浏览记录")
+    @PreAuthenticated
+    public CommonResult<Boolean> cleanBrowseHistory() {
+        productBrowseHistoryService.hideUserBrowseHistory(getLoginUserId(), null);
+        return success(Boolean.TRUE);
+    }
+
+    @GetMapping(value = "/get-count")
+    @Operation(summary = "获得商品浏览记录数量")
+    @PreAuthenticated
+    public CommonResult<Long> getBrowseHistoryCount() {
+        return success(productBrowseHistoryService.getBrowseHistoryCount(getLoginUserId(), false));
+    }
+
+    @GetMapping(value = "/page")
+    @Operation(summary = "获得商品浏览记录分页")
+    @PreAuthenticated
+    public CommonResult<PageResult<AppProductBrowseHistoryRespVO>> getBrowseHistoryPage(AppProductBrowseHistoryPageReqVO reqVO) {
+        ProductBrowseHistoryPageReqVO pageReqVO = BeanUtils.toBean(reqVO, ProductBrowseHistoryPageReqVO.class);
+        pageReqVO.setUserId(getLoginUserId());
+        // 排除用户已删除的(隐藏的)
+        pageReqVO.setUserDeleted(false);
+        PageResult<ProductBrowseHistoryDO> pageResult = productBrowseHistoryService.getBrowseHistoryPage(pageReqVO);
+        if (CollUtil.isEmpty(pageResult.getList())) {
+            return success(PageResult.empty());
+        }
+
+        // 得到商品 spu 信息
+        Set<Long> spuIds = convertSet(pageResult.getList(), ProductBrowseHistoryDO::getSpuId);
+        Map<Long, ProductSpuDO> spuMap = convertMap(productSpuService.getSpuList(spuIds), ProductSpuDO::getId);
+
+        // 转换 VO 结果
+        PageResult<AppProductBrowseHistoryRespVO> result = BeanUtils.toBean(pageResult, AppProductBrowseHistoryRespVO.class,
+                vo -> Optional.ofNullable(spuMap.get(vo.getSpuId())).ifPresent(spu -> {
+                    vo.setSpuName(spu.getName());
+                    vo.setPicUrl(spu.getPicUrl());
+                }));
+        return success(result);
+    }
+
+}

+ 19 - 0
yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/history/vo/AppProductBrowseHistoryDeleteReqVO.java

@@ -0,0 +1,19 @@
+package cn.iocoder.yudao.module.product.controller.app.history.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotEmpty;
+import lombok.Data;
+
+import java.util.List;
+
+import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
+
+@Schema(description = "用户 APP - 删除商品浏览记录的 Request VO")
+@Data
+public class AppProductBrowseHistoryDeleteReqVO {
+
+    @Schema(description = "商品 SPU 编号数组", requiredMode = REQUIRED, example = "29502")
+    @NotEmpty(message = "商品 SPU 编号数组不能为空")
+    private List<Long> spuIds;
+
+}

+ 23 - 0
yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/history/vo/AppProductBrowseHistoryPageReqVO.java

@@ -0,0 +1,23 @@
+package cn.iocoder.yudao.module.product.controller.app.history.vo;
+
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@Schema(description = "用户 APP - 商品浏览记录分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class AppProductBrowseHistoryPageReqVO extends PageParam {
+    @Schema(description = "创建时间")
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    private LocalDateTime[] createTime;
+
+}

+ 29 - 0
yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/history/vo/AppProductBrowseHistoryRespVO.java

@@ -0,0 +1,29 @@
+package cn.iocoder.yudao.module.product.controller.app.history.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
+
+@Schema(description = "用户 App - 商品浏览记录 Response VO")
+@Data
+public class AppProductBrowseHistoryRespVO {
+
+    @Schema(description = "编号", requiredMode = REQUIRED, example = "1")
+    private Long id;
+
+    @Schema(description = "商品 SPU 编号", requiredMode = REQUIRED, example = "29502")
+    private Long spuId;
+
+    // ========== 商品相关字段 ==========
+
+    @Schema(description = "商品 SPU 名称", example = "赵六")
+    private String spuName;
+
+    @Schema(description = "商品封面图", example = "https://domain/pic.png")
+    private String picUrl;
+
+    @Schema(description = "商品单价", example = "100")
+    private Integer price;
+
+}

+ 8 - 0
yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/spu/AppProductSpuController.java

@@ -14,6 +14,7 @@ import cn.iocoder.yudao.module.product.convert.spu.ProductSpuConvert;
 import cn.iocoder.yudao.module.product.dal.dataobject.sku.ProductSkuDO;
 import cn.iocoder.yudao.module.product.dal.dataobject.spu.ProductSpuDO;
 import cn.iocoder.yudao.module.product.enums.spu.ProductSpuStatusEnum;
+import cn.iocoder.yudao.module.product.service.history.ProductBrowseHistoryService;
 import cn.iocoder.yudao.module.product.service.sku.ProductSkuService;
 import cn.iocoder.yudao.module.product.service.spu.ProductSpuService;
 import io.swagger.v3.oas.annotations.Operation;
@@ -48,6 +49,8 @@ public class AppProductSpuController {
     private ProductSpuService productSpuService;
     @Resource
     private ProductSkuService productSkuService;
+    @Resource
+    private ProductBrowseHistoryService productBrowseHistoryService;
 
     @Resource
     private MemberLevelApi memberLevelApi;
@@ -122,6 +125,11 @@ public class AppProductSpuController {
             throw exception(SPU_NOT_ENABLE);
         }
 
+        // 增加浏览量
+        productSpuService.updateBrowseCount(id, 1);
+        // 保存浏览记录
+        productBrowseHistoryService.createBrowseHistory(getLoginUserId(), id);
+
         // 拼接返回
         List<ProductSkuDO> skus = productSkuService.getSkuListBySpuId(spu.getId());
         AppProductSpuDetailRespVO detailVO = ProductSpuConvert.INSTANCE.convertForGetSpuDetail(spu, skus);

+ 42 - 0
yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/history/ProductBrowseHistoryDO.java

@@ -0,0 +1,42 @@
+package cn.iocoder.yudao.module.product.dal.dataobject.history;
+
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+
+/**
+ * 商品浏览记录 DO
+ *
+ * @author owen
+ */
+@TableName("product_browse_history")
+@KeySequence("product_browse_history_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ProductBrowseHistoryDO extends BaseDO {
+
+    /**
+     * 记录编号
+     */
+    @TableId
+    private Long id;
+    /**
+     * 商品 SPU 编号
+     */
+    private Long spuId;
+    /**
+     * 用户编号
+     */
+    private Long userId;
+    /**
+     * 用户是否删除
+     */
+    private Boolean userDeleted;
+
+}

+ 52 - 0
yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/history/ProductBrowseHistoryMapper.java

@@ -0,0 +1,52 @@
+package cn.iocoder.yudao.module.product.dal.mysql.history;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.module.product.controller.admin.history.vo.ProductBrowseHistoryPageReqVO;
+import cn.iocoder.yudao.module.product.dal.dataobject.history.ProductBrowseHistoryDO;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.Collection;
+
+/**
+ * 商品浏览记录 Mapper
+ *
+ * @author owen
+ */
+@Mapper
+public interface ProductBrowseHistoryMapper extends BaseMapperX<ProductBrowseHistoryDO> {
+
+    default PageResult<ProductBrowseHistoryDO> selectPage(ProductBrowseHistoryPageReqVO reqVO) {
+        return selectPage(reqVO, new LambdaQueryWrapperX<ProductBrowseHistoryDO>()
+                .eqIfPresent(ProductBrowseHistoryDO::getUserId, reqVO.getUserId())
+                .eqIfPresent(ProductBrowseHistoryDO::getUserDeleted, reqVO.getUserDeleted())
+                .eqIfPresent(ProductBrowseHistoryDO::getSpuId, reqVO.getSpuId())
+                .betweenIfPresent(ProductBrowseHistoryDO::getCreateTime, reqVO.getCreateTime())
+                .orderByDesc(ProductBrowseHistoryDO::getId));
+    }
+
+    default void updateUserDeletedByUserId(Long userId, Collection<Long> spuIds, Boolean userDeleted) {
+        update(new LambdaUpdateWrapper<ProductBrowseHistoryDO>()
+                .eq(ProductBrowseHistoryDO::getUserId, userId)
+                .in(CollUtil.isNotEmpty(spuIds), ProductBrowseHistoryDO::getSpuId, spuIds)
+                .set(ProductBrowseHistoryDO::getUserDeleted, userDeleted));
+    }
+
+    default Long selectCountByUserIdAndUserDeleted(Long userId, Boolean userDeleted) {
+        return selectCount(new LambdaQueryWrapperX<ProductBrowseHistoryDO>()
+                .eq(ProductBrowseHistoryDO::getUserId, userId)
+                .eqIfPresent(ProductBrowseHistoryDO::getUserDeleted, userDeleted));
+    }
+
+    default Page<ProductBrowseHistoryDO> selectPageByUserIdOrderByCreateTimeAsc(Long userId, Integer pageNo, Integer pageSize) {
+        Page<ProductBrowseHistoryDO> page = Page.of(pageNo, pageSize);
+        return selectPage(page, new LambdaQueryWrapperX<ProductBrowseHistoryDO>()
+                .eqIfPresent(ProductBrowseHistoryDO::getUserId, userId)
+                .orderByAsc(ProductBrowseHistoryDO::getCreateTime));
+    }
+
+}

+ 16 - 3
yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/spu/ProductSpuMapper.java

@@ -71,7 +71,7 @@ public interface ProductSpuMapper extends BaseMapperX<ProductSpuDO> {
             query.eq(ProductSpuDO::getRecommendBenefit, true);
         } else if (ObjUtil.equal(pageReqVO.getRecommendType(), AppProductSpuPageReqVO.RECOMMEND_TYPE_BEST)) {
             query.eq(ProductSpuDO::getRecommendBest, true);
-        }  else if (ObjUtil.equal(pageReqVO.getRecommendType(), AppProductSpuPageReqVO.RECOMMEND_TYPE_NEW)) {
+        } else if (ObjUtil.equal(pageReqVO.getRecommendType(), AppProductSpuPageReqVO.RECOMMEND_TYPE_NEW)) {
             query.eq(ProductSpuDO::getRecommendNew, true);
         } else if (ObjUtil.equal(pageReqVO.getRecommendType(), AppProductSpuPageReqVO.RECOMMEND_TYPE_GOOD)) {
             query.eq(ProductSpuDO::getRecommendGood, true);
@@ -141,8 +141,8 @@ public interface ProductSpuMapper extends BaseMapperX<ProductSpuDO> {
     /**
      * 添加后台 Tab 选项的查询条件
      *
-     * @param tabType      标签类型
-     * @param query 查询条件
+     * @param tabType 标签类型
+     * @param query   查询条件
      */
     static void appendTabQuery(Integer tabType, LambdaQueryWrapperX<ProductSpuDO> query) {
         // 出售中商品
@@ -169,4 +169,17 @@ public interface ProductSpuMapper extends BaseMapperX<ProductSpuDO> {
         }
     }
 
+    /**
+     * 更新商品 SPU 浏览量
+     *
+     * @param id        商品 SPU 编号
+     * @param incrCount 增加的数量
+     */
+    default void updateBrowseCount(Long id, int incrCount) {
+        LambdaUpdateWrapper<ProductSpuDO> updateWrapper = new LambdaUpdateWrapper<ProductSpuDO>()
+                .setSql(" browse_count = browse_count +" + incrCount)
+                .eq(ProductSpuDO::getId, id);
+        update(null, updateWrapper);
+    }
+
 }

+ 58 - 0
yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/history/ProductBrowseHistoryService.java

@@ -0,0 +1,58 @@
+package cn.iocoder.yudao.module.product.service.history;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.module.product.controller.admin.history.vo.ProductBrowseHistoryPageReqVO;
+import cn.iocoder.yudao.module.product.dal.dataobject.history.ProductBrowseHistoryDO;
+
+import java.util.Collection;
+
+/**
+ * 商品浏览记录 Service 接口
+ *
+ * @author owen
+ */
+public interface ProductBrowseHistoryService {
+
+    /**
+     * 创建商品浏览记录
+     *
+     * @param userId 用户编号
+     * @param spuId  SPU 编号
+     * @return 编号
+     */
+    Long createBrowseHistory(Long userId, Long spuId);
+
+    /**
+     * 隐藏用户商品浏览记录
+     *
+     * @param userId 用户编号
+     * @param spuId  SPU 编号
+     */
+    void hideUserBrowseHistory(Long userId, Collection<Long> spuId);
+
+    /**
+     * 获得商品浏览记录
+     *
+     * @param id 编号
+     * @return 商品浏览记录
+     */
+    ProductBrowseHistoryDO getBrowseHistory(Long id);
+
+    /**
+     * 获取用户记录数量
+     *
+     * @param userId      用户编号
+     * @param userDeleted 用户是否删除
+     * @return 数量
+     */
+    Long getBrowseHistoryCount(Long userId, Boolean userDeleted);
+
+    /**
+     * 获得商品浏览记录分页
+     *
+     * @param pageReqVO 分页查询
+     * @return 商品浏览记录分页
+     */
+    PageResult<ProductBrowseHistoryDO> getBrowseHistoryPage(ProductBrowseHistoryPageReqVO pageReqVO);
+
+}

+ 77 - 0
yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/history/ProductBrowseHistoryServiceImpl.java

@@ -0,0 +1,77 @@
+package cn.iocoder.yudao.module.product.service.history;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.module.product.controller.admin.history.vo.ProductBrowseHistoryPageReqVO;
+import cn.iocoder.yudao.module.product.dal.dataobject.history.ProductBrowseHistoryDO;
+import cn.iocoder.yudao.module.product.dal.mysql.history.ProductBrowseHistoryMapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import java.util.Collection;
+
+/**
+ * 商品浏览记录 Service 实现类
+ *
+ * @author owen
+ */
+@Service
+@Validated
+public class ProductBrowseHistoryServiceImpl implements ProductBrowseHistoryService {
+    private static final int USER_STORE_MAXIMUM = 100;
+
+    @Resource
+    private ProductBrowseHistoryMapper browseHistoryMapper;
+
+    @Override
+    public Long createBrowseHistory(Long userId, Long spuId) {
+        // 用户未登录时不记录
+        if (userId == null) {
+            return null;
+        }
+
+        // 情况一:同一个商品,只保留最新的一条记录
+        ProductBrowseHistoryDO historyDO = browseHistoryMapper.selectOne(ProductBrowseHistoryDO::getUserId, userId, ProductBrowseHistoryDO::getSpuId, spuId);
+        if (historyDO != null) {
+            browseHistoryMapper.deleteById(historyDO);
+        } else {
+            // 情况二:限制每个用户的浏览记录的条数(只查一条最早地记录、记录总数)
+            Page<ProductBrowseHistoryDO> pageResult = browseHistoryMapper.selectPageByUserIdOrderByCreateTimeAsc(userId, 1, 1);
+            if (pageResult.getTotal() >= USER_STORE_MAXIMUM) {
+                // 删除最早的一条
+                browseHistoryMapper.deleteById(CollUtil.getFirst(pageResult.getRecords()));
+            }
+        }
+
+        // 插入
+        ProductBrowseHistoryDO browseHistory = new ProductBrowseHistoryDO()
+                .setUserId(userId)
+                .setSpuId(spuId);
+        browseHistoryMapper.insert(browseHistory);
+        // 返回
+        return browseHistory.getId();
+    }
+
+    @Override
+    public void hideUserBrowseHistory(Long userId, Collection<Long> spuIds) {
+        browseHistoryMapper.updateUserDeletedByUserId(userId, spuIds, true);
+    }
+
+    @Override
+    public ProductBrowseHistoryDO getBrowseHistory(Long id) {
+        return browseHistoryMapper.selectById(id);
+    }
+
+    @Override
+    public Long getBrowseHistoryCount(Long userId, Boolean userDeleted) {
+        return browseHistoryMapper.selectCountByUserIdAndUserDeleted(userId, userDeleted);
+    }
+
+    @Override
+    public PageResult<ProductBrowseHistoryDO> getBrowseHistoryPage(ProductBrowseHistoryPageReqVO pageReqVO) {
+        return browseHistoryMapper.selectPage(pageReqVO);
+    }
+
+}

+ 8 - 0
yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/spu/ProductSpuService.java

@@ -148,4 +148,12 @@ public interface ProductSpuService {
      */
     List<ProductSpuDO> validateSpuList(Collection<Long> ids);
 
+    /**
+     * 更新商品 SPU 浏览量
+     *
+     * @param id        商品 SPU 编号
+     * @param incrCount 增加的数量
+     */
+    void updateBrowseCount(Long id, int incrCount);
+
 }

+ 5 - 0
yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/spu/ProductSpuServiceImpl.java

@@ -156,6 +156,11 @@ public class ProductSpuServiceImpl implements ProductSpuService {
         return list;
     }
 
+    @Override
+    public void updateBrowseCount(Long id, int incrCount) {
+        productSpuMapper.updateBrowseCount(id , incrCount);
+    }
+
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void deleteSpu(Long id) {

+ 68 - 18
yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/controller/admin/product/ProductStatisticsController.java

@@ -2,40 +2,90 @@ package cn.iocoder.yudao.module.statistics.controller.admin.product;
 
 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
-import cn.iocoder.yudao.module.statistics.dal.mysql.product.ProductSpuStatisticsDO;
-import cn.iocoder.yudao.module.statistics.dal.mysql.product.ProductStatisticsDO;
+import cn.iocoder.yudao.framework.common.pojo.SortablePageParam;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
+import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi;
+import cn.iocoder.yudao.module.product.api.spu.dto.ProductSpuRespDTO;
+import cn.iocoder.yudao.module.statistics.controller.admin.common.vo.DataComparisonRespVO;
+import cn.iocoder.yudao.module.statistics.controller.admin.product.vo.ProductStatisticsReqVO;
+import cn.iocoder.yudao.module.statistics.controller.admin.product.vo.ProductStatisticsRespVO;
+import cn.iocoder.yudao.module.statistics.dal.dataobject.product.ProductStatisticsDO;
+import cn.iocoder.yudao.module.statistics.service.product.ProductStatisticsService;
+import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
-import lombok.extern.slf4j.Slf4j;
+import jakarta.annotation.Resource;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
-import java.time.LocalDateTime;
+import java.io.IOException;
 import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
 
 @Tag(name = "管理后台 - 商品统计")
 @RestController
 @RequestMapping("/statistics/product")
 @Validated
-@Slf4j
 public class ProductStatisticsController {
 
-    // TODO @麦子:返回 ProductStatisticsComparisonResp, 里面有两个字段,一个是选择的时间范围的合计结果,一个是对比的时间范围的合计结果;
-    // 例如说,选择时间范围是 2023-10-01 ~ 2023-10-02,那么对比就是 2023-09-30,再倒推 2 天;
-    public CommonResult<Object> getProductStatisticsComparison() {
-        return null;
+    @Resource
+    private ProductStatisticsService productStatisticsService;
+
+    @Resource
+    private ProductSpuApi productSpuApi;
+
+    @GetMapping("/analyse")
+    @Operation(summary = "获得商品统计分析")
+    @PreAuthorize("@ss.hasPermission('statistics:product:query')")
+    public CommonResult<DataComparisonRespVO<ProductStatisticsRespVO>> getProductStatisticsAnalyse(ProductStatisticsReqVO reqVO) {
+        return success(productStatisticsService.getProductStatisticsAnalyse(reqVO));
+    }
+
+    @GetMapping("/list")
+    @Operation(summary = "获得商品统计明细(日期维度)")
+    @PreAuthorize("@ss.hasPermission('statistics:product:query')")
+    public CommonResult<List<ProductStatisticsRespVO>> getProductStatisticsList(ProductStatisticsReqVO reqVO) {
+        List<ProductStatisticsDO> list = productStatisticsService.getProductStatisticsList(reqVO);
+        return success(BeanUtils.toBean(list, ProductStatisticsRespVO.class));
     }
 
-    // TODO @麦子:查询指定时间范围内的商品统计数据;DO 到时需要改成 VO 哈
-    public CommonResult<List<ProductStatisticsDO>> getProductStatisticsList(
-            LocalDateTime[] times) {
-        return null;
+    @GetMapping("/export-excel")
+    @Operation(summary = "导出获得商品统计明细 Excel(日期维度)")
+    @PreAuthorize("@ss.hasPermission('statistics:product:export')")
+    public void exportProductStatisticsExcel(ProductStatisticsReqVO reqVO, HttpServletResponse response) throws IOException {
+        List<ProductStatisticsDO> list = productStatisticsService.getProductStatisticsList(reqVO);
+        // 导出 Excel
+        List<ProductStatisticsRespVO> voList = BeanUtils.toBean(list, ProductStatisticsRespVO.class);
+        ExcelUtils.write(response, "商品状况.xls", "数据", ProductStatisticsRespVO.class, voList);
     }
 
-    // TODO @麦子:查询指定时间范围内的商品 SPU 统计数据;DO 到时需要改成 VO 哈
-    // 入参是分页参数 + 时间范围 + 排序字段
-    public CommonResult<PageResult<ProductSpuStatisticsDO>> getProductSpuStatisticsPage() {
-        return null;
+    @GetMapping("/rank-page")
+    @Operation(summary = "获得商品统计排行榜分页(商品维度)")
+    @PreAuthorize("@ss.hasPermission('statistics:product:query')")
+    public CommonResult<PageResult<ProductStatisticsRespVO>> getProductStatisticsRankPage(@Valid ProductStatisticsReqVO reqVO,
+                                                                                          @Valid SortablePageParam pageParam) {
+        PageResult<ProductStatisticsDO> pageResult = productStatisticsService.getProductStatisticsRankPage(reqVO, pageParam);
+        // 处理商品信息
+        Set<Long> spuIds = convertSet(pageResult.getList(), ProductStatisticsDO::getSpuId);
+        Map<Long, ProductSpuRespDTO> spuMap = convertMap(productSpuApi.getSpuList(spuIds), ProductSpuRespDTO::getId);
+        // 拼接返回
+        return success(BeanUtils.toBean(pageResult, ProductStatisticsRespVO.class,
+                // 拼接商品信息
+                item -> Optional.ofNullable(spuMap.get(item.getSpuId())).ifPresent(spu -> {
+                    item.setName(spu.getName());
+                    item.setPicUrl(spu.getPicUrl());
+                })));
     }
 
-}
+}

+ 25 - 0
yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/controller/admin/product/vo/ProductStatisticsReqVO.java

@@ -0,0 +1,25 @@
+package cn.iocoder.yudao.module.statistics.controller.admin.product.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@Schema(description = "管理后台 - 商品统计分析 Request VO")
+@Data
+@ToString(callSuper = true)
+@AllArgsConstructor
+@NoArgsConstructor
+public class ProductStatisticsReqVO {
+
+    @Schema(description = "统计时间范围", example = "[2022-07-01 00:00:00, 2022-07-01 23:59:59]")
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    private LocalDateTime[] times;
+
+}

+ 81 - 0
yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/controller/admin/product/vo/ProductStatisticsRespVO.java

@@ -0,0 +1,81 @@
+package cn.iocoder.yudao.module.statistics.controller.admin.product.vo;
+
+import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
+import com.alibaba.excel.annotation.ExcelProperty;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDate;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY;
+
+@Schema(description = "管理后台 - 商品统计 Response VO")
+@Data
+@ExcelIgnoreUnannotated
+public class ProductStatisticsRespVO {
+
+    @Schema(description = "编号,主键自增", requiredMode = Schema.RequiredMode.REQUIRED, example = "12393")
+    private Long id;
+
+    @Schema(description = "统计日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2023-12-16")
+    @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY)
+    @ExcelProperty("统计日期")
+    private LocalDate time;
+
+    @Schema(description = "商品SPU编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "15114")
+    @ExcelProperty("商品SPU编号")
+    private Long spuId;
+
+    //region 商品信息
+
+    @Schema(description = "商品名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "商品名称")
+    @ExcelProperty("商品名称")
+    private String name;
+
+    @Schema(description = "商品封面图", requiredMode = Schema.RequiredMode.REQUIRED, example = "15114")
+    @ExcelProperty("商品封面图")
+    private String picUrl;
+
+    //endregion
+
+    @Schema(description = "浏览量", requiredMode = Schema.RequiredMode.REQUIRED, example = "17505")
+    @ExcelProperty("浏览量")
+    private Integer browseCount;
+
+    @Schema(description = "访客量", requiredMode = Schema.RequiredMode.REQUIRED, example = "11814")
+    @ExcelProperty("访客量")
+    private Integer browseUserCount;
+
+    @Schema(description = "收藏数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "20950")
+    @ExcelProperty("收藏数量")
+    private Integer favoriteCount;
+
+    @Schema(description = "加购数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "28493")
+    @ExcelProperty("加购数量")
+    private Integer cartCount;
+
+    @Schema(description = "下单件数", requiredMode = Schema.RequiredMode.REQUIRED, example = "18966")
+    @ExcelProperty("下单件数")
+    private Integer orderCount;
+
+    @Schema(description = "支付件数", requiredMode = Schema.RequiredMode.REQUIRED, example = "15142")
+    @ExcelProperty("支付件数")
+    private Integer orderPayCount;
+
+    @Schema(description = "支付金额,单位:分", requiredMode = Schema.RequiredMode.REQUIRED, example = "11595")
+    @ExcelProperty("支付金额,单位:分")
+    private Integer orderPayPrice;
+
+    @Schema(description = "退款件数", requiredMode = Schema.RequiredMode.REQUIRED, example = "2591")
+    @ExcelProperty("退款件数")
+    private Integer afterSaleCount;
+
+    @Schema(description = "退款金额,单位:分", requiredMode = Schema.RequiredMode.REQUIRED, example = "21709")
+    @ExcelProperty("退款金额,单位:分")
+    private Integer afterSaleRefundPrice;
+
+    @Schema(description = "访客支付转化率(百分比)", requiredMode = Schema.RequiredMode.REQUIRED, example = "15")
+    private Integer browseConvertPercent;
+
+}

+ 6 - 8
yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/controller/admin/trade/TradeStatisticsController.java

@@ -18,6 +18,9 @@ import cn.iocoder.yudao.module.trade.enums.delivery.DeliveryTypeEnum;
 import cn.iocoder.yudao.module.trade.enums.order.TradeOrderStatusEnum;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.validation.annotation.Validated;
@@ -25,9 +28,6 @@ import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
-import jakarta.annotation.Resource;
-import jakarta.servlet.http.HttpServletResponse;
-import jakarta.validation.Valid;
 import java.io.IOException;
 import java.util.List;
 
@@ -67,13 +67,11 @@ public class TradeStatisticsController {
         return success(TradeStatisticsConvert.INSTANCE.convert(yesterdayData, beforeYesterdayData, monthData, lastMonthData));
     }
 
-    // TODO @疯狂:【晚点再改和讨论;等首页的接口出来】这个要不还是叫 analyse,对比选中的时间段,和上一个时间段;类似 MemberStatisticsController 的 getMemberAnalyse
-    @GetMapping("/trend/summary")
+    @GetMapping("/analyse")
     @Operation(summary = "获得交易状况统计")
     @PreAuthorize("@ss.hasPermission('statistics:trade:query')")
-    public CommonResult<DataComparisonRespVO<TradeTrendSummaryRespVO>> getTradeTrendSummaryComparison(
-            TradeTrendReqVO reqVO) {
-        return success(tradeStatisticsService.getTradeTrendSummaryComparison(ArrayUtil.get(reqVO.getTimes(), 0),
+    public CommonResult<DataComparisonRespVO<TradeTrendSummaryRespVO>> getTradeStatisticsAnalyse(TradeTrendReqVO reqVO) {
+        return success(tradeStatisticsService.getTradeStatisticsAnalyse(ArrayUtil.get(reqVO.getTimes(), 0),
                 ArrayUtil.get(reqVO.getTimes(), 1)));
     }
 

+ 1 - 1
yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/controller/admin/trade/vo/TradeTrendSummaryRespVO.java

@@ -12,7 +12,7 @@ import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_
 @Data
 public class TradeTrendSummaryRespVO {
 
-    @Schema(description = "日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+    @Schema(description = "日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2023-12-16")
     @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY)
     private LocalDate date;
 

+ 80 - 0
yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/dataobject/product/ProductStatisticsDO.java

@@ -0,0 +1,80 @@
+package cn.iocoder.yudao.module.statistics.dal.dataobject.product;
+
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+
+import java.time.LocalDate;
+
+/**
+ * 商品统计 DO
+ *
+ * @author owen
+ */
+@TableName("product_statistics")
+@KeySequence("product_statistics_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ProductStatisticsDO extends BaseDO {
+
+    /**
+     * 编号,主键自增
+     */
+    @TableId
+    private Long id;
+    /**
+     * 统计日期
+     */
+    private LocalDate time;
+    /**
+     * 商品SPU编号
+     */
+    private Long spuId;
+    /**
+     * 浏览量
+     */
+    private Integer browseCount;
+    /**
+     * 访客量
+     */
+    private Integer browseUserCount;
+    /**
+     * 收藏数量
+     */
+    private Integer favoriteCount;
+    /**
+     * 加购数量
+     */
+    private Integer cartCount;
+    /**
+     * 下单件数
+     */
+    private Integer orderCount;
+    /**
+     * 支付件数
+     */
+    private Integer orderPayCount;
+    /**
+     * 支付金额,单位:分
+     */
+    private Integer orderPayPrice;
+    /**
+     * 退款件数
+     */
+    private Integer afterSaleCount;
+    /**
+     * 退款金额,单位:分
+     */
+    private Integer afterSaleRefundPrice;
+    /**
+     * 访客支付转化率(百分比)
+     */
+    private Integer browseConvertPercent;
+
+}

+ 0 - 74
yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/product/ProductSpuStatisticsDO.java

@@ -1,74 +0,0 @@
-package cn.iocoder.yudao.module.statistics.dal.mysql.product;
-
-import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
-import com.baomidou.mybatisplus.annotation.KeySequence;
-import com.baomidou.mybatisplus.annotation.TableId;
-import com.baomidou.mybatisplus.annotation.TableName;
-import lombok.*;
-
-import java.time.LocalDateTime;
-
-/**
- * 商品 SPU 统计 DO
- *
- * 以天为维度,统计商品 SPU 的数据
- *
- * @author 芋道源码
- */
-@TableName("product_spu_statistics")
-@KeySequence("product_spu_statistics_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
-@Data
-@EqualsAndHashCode(callSuper = true)
-@ToString(callSuper = true)
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class ProductSpuStatisticsDO extends BaseDO {
-
-    /**
-     * 编号,主键自增
-     */
-    @TableId
-    private Long id;
-
-    /**
-     * 商品 SPU 编号
-     *
-     * 关联 ProductSpuDO 的 id 字段
-     */
-    private Long spuId;
-    /**
-     * 统计日期
-     */
-    private LocalDateTime time;
-
-    /**
-     * 浏览量
-     */
-    private Integer browseCount;
-    /**
-     * 收藏量
-     */
-    private Integer favoriteCount;
-
-    /**
-     * 添加购物车次数
-     *
-     * 以商品被添加到购物车的 createTime 计算,后续多次添加,不会增加该值。
-     * 直到该次被下单、或者被删除,后续再次被添加到购物车。
-     */
-    private Integer addCartCount;
-    /**
-     * 创建订单商品数
-     */
-    private Integer createOrderCount;
-    /**
-     * 支付订单商品数
-     */
-    private Integer payOrderCount;
-    /**
-     * 总支付金额,单位:分
-     */
-    private Integer payPrice;
-
-}

+ 0 - 70
yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/product/ProductStatisticsDO.java

@@ -1,70 +0,0 @@
-package cn.iocoder.yudao.module.statistics.dal.mysql.product;
-
-import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
-import com.baomidou.mybatisplus.annotation.KeySequence;
-import com.baomidou.mybatisplus.annotation.TableId;
-import com.baomidou.mybatisplus.annotation.TableName;
-import lombok.*;
-
-import java.time.LocalDateTime;
-
-/**
- * 商品统计 DO
- *
- * 以天为维度,统计全部的数据
- *
- * 和 {@link ProductSpuStatisticsDO} 的差异是,它是全局的统计
- *
- * @author 芋道源码
- */
-@TableName("product_spu_statistics")
-@KeySequence("product_spu_statistics_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
-@Data
-@EqualsAndHashCode(callSuper = true)
-@ToString(callSuper = true)
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class ProductStatisticsDO extends BaseDO {
-
-    /**
-     * 编号,主键自增
-     */
-    @TableId
-    private Long id;
-
-    /**
-     * 统计日期
-     */
-    private LocalDateTime time;
-
-    /**
-     * 浏览量
-     */
-    private Integer browseCount;
-    /**
-     * 收藏量
-     */
-    private Integer favoriteCount;
-
-    /**
-     * 添加购物车次数
-     *
-     * 以商品被添加到购物车的 createTime 计算,后续多次添加,不会增加该值。
-     * 直到该次被下单、或者被删除,后续再次被添加到购物车。
-     */
-    private Integer addCartCount;
-    /**
-     * 创建订单商品数
-     */
-    private Integer createOrderCount;
-    /**
-     * 支付订单商品数
-     */
-    private Integer payOrderCount;
-    /**
-     * 总支付金额,单位:分
-     */
-    private Integer payPrice;
-
-}

+ 80 - 0
yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/dal/mysql/product/ProductStatisticsMapper.java

@@ -0,0 +1,80 @@
+package cn.iocoder.yudao.module.statistics.dal.mysql.product;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.pojo.SortablePageParam;
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX;
+import cn.iocoder.yudao.module.statistics.controller.admin.product.vo.ProductStatisticsReqVO;
+import cn.iocoder.yudao.module.statistics.controller.admin.product.vo.ProductStatisticsRespVO;
+import cn.iocoder.yudao.module.statistics.dal.dataobject.product.ProductStatisticsDO;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 商品统计 Mapper
+ *
+ * @author owen
+ */
+@Mapper
+public interface ProductStatisticsMapper extends BaseMapperX<ProductStatisticsDO> {
+
+    default PageResult<ProductStatisticsDO> selectPageGroupBySpuId(ProductStatisticsReqVO reqVO, SortablePageParam pageParam) {
+        return selectPage(pageParam, buildWrapper(reqVO)
+                .groupBy(ProductStatisticsDO::getSpuId)
+                .select(ProductStatisticsDO::getSpuId)
+        );
+    }
+
+    default List<ProductStatisticsDO> selectListByTimeBetween(ProductStatisticsReqVO reqVO) {
+        return selectList(buildWrapper(reqVO)
+                .groupBy(ProductStatisticsDO::getTime)
+                .select(ProductStatisticsDO::getTime));
+    }
+
+    default ProductStatisticsRespVO selectVoByTimeBetween(ProductStatisticsReqVO reqVO) {
+        return selectJoinOne(ProductStatisticsRespVO.class, buildWrapper(reqVO));
+    }
+
+    /**
+     * 构建 LambdaWrapper
+     *
+     * @param reqVO 查询参数
+     * @return LambdaWrapper
+     */
+    private static MPJLambdaWrapperX<ProductStatisticsDO> buildWrapper(ProductStatisticsReqVO reqVO) {
+        return new MPJLambdaWrapperX<ProductStatisticsDO>()
+                .betweenIfPresent(ProductStatisticsDO::getTime, reqVO.getTimes())
+                .selectSum(ProductStatisticsDO::getBrowseCount)
+                .selectSum(ProductStatisticsDO::getBrowseUserCount)
+                .selectSum(ProductStatisticsDO::getFavoriteCount)
+                .selectSum(ProductStatisticsDO::getCartCount)
+                .selectSum(ProductStatisticsDO::getOrderCount)
+                .selectSum(ProductStatisticsDO::getOrderPayCount)
+                .selectSum(ProductStatisticsDO::getOrderPayPrice)
+                .selectSum(ProductStatisticsDO::getAfterSaleCount)
+                .selectSum(ProductStatisticsDO::getAfterSaleRefundPrice)
+                .selectAvg(ProductStatisticsDO::getBrowseConvertPercent);
+    }
+
+    /**
+     * 根据时间范围统计商品信息
+     *
+     * @param page      分页参数
+     * @param beginTime 起始时间
+     * @param endTime   截止时间
+     * @return 统计
+     */
+    IPage<ProductStatisticsDO> selectStatisticsResultPageByTimeBetween(IPage<ProductStatisticsDO> page,
+                                                                       @Param("beginTime") LocalDateTime beginTime,
+                                                                       @Param("endTime") LocalDateTime endTime);
+
+    default Long selectCountByTimeBetween(LocalDateTime beginTime, LocalDateTime endTime) {
+        return selectCount(new LambdaQueryWrapperX<ProductStatisticsDO>().between(ProductStatisticsDO::getTime, beginTime, endTime));
+    }
+
+}

+ 48 - 0
yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/job/product/ProductStatisticsJob.java

@@ -0,0 +1,48 @@
+package cn.iocoder.yudao.module.statistics.job.product;
+
+import cn.hutool.core.convert.Convert;
+import cn.hutool.core.util.NumberUtil;
+import cn.hutool.core.util.ObjUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler;
+import cn.iocoder.yudao.framework.tenant.core.job.TenantJob;
+import cn.iocoder.yudao.module.statistics.service.product.ProductStatisticsService;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Component;
+
+// TODO 芋艿:缺个 Job 的配置;等和 Product 一起配置
+
+/**
+ * 商品统计 Job
+ *
+ * @author owen
+ */
+@Component
+public class ProductStatisticsJob implements JobHandler {
+
+    @Resource
+    private ProductStatisticsService productStatisticsService;
+
+    /**
+     * 执行商品统计任务
+     *
+     * @param param 要统计的天数,只能是正整数,1 代表昨日数据
+     * @return 统计结果
+     */
+    @Override
+    @TenantJob
+    public String execute(String param) {
+        // 默认昨日
+        param = ObjUtil.defaultIfBlank(param, "1");
+        // 校验参数的合理性
+        if (!NumberUtil.isInteger(param)) {
+            throw new RuntimeException("商品统计任务的参数只能为是正整数");
+        }
+        Integer days = Convert.toInt(param, 0);
+        if (days < 1) {
+            throw new RuntimeException("商品统计任务的参数只能为是正整数");
+        }
+        String result = productStatisticsService.statisticsProduct(days);
+        return StrUtil.format("商品统计:\n{}", result);
+    }
+}

+ 51 - 0
yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/product/ProductStatisticsService.java

@@ -0,0 +1,51 @@
+package cn.iocoder.yudao.module.statistics.service.product;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.pojo.SortablePageParam;
+import cn.iocoder.yudao.module.statistics.controller.admin.common.vo.DataComparisonRespVO;
+import cn.iocoder.yudao.module.statistics.controller.admin.product.vo.ProductStatisticsReqVO;
+import cn.iocoder.yudao.module.statistics.controller.admin.product.vo.ProductStatisticsRespVO;
+import cn.iocoder.yudao.module.statistics.dal.dataobject.product.ProductStatisticsDO;
+
+import java.util.List;
+
+/**
+ * 商品统计 Service 接口
+ *
+ * @author owen
+ */
+public interface ProductStatisticsService {
+
+    /**
+     * 获得商品统计排行榜分页
+     *
+     * @param reqVO     查询条件
+     * @param pageParam 分页排序查询
+     * @return 商品统计分页
+     */
+    PageResult<ProductStatisticsDO> getProductStatisticsRankPage(ProductStatisticsReqVO reqVO, SortablePageParam pageParam);
+
+    /**
+     * 获得商品状况统计分析
+     *
+     * @param reqVO 查询条件
+     * @return 统计数据对照
+     */
+    DataComparisonRespVO<ProductStatisticsRespVO> getProductStatisticsAnalyse(ProductStatisticsReqVO reqVO);
+
+    /**
+     * 获得商品状况明细
+     *
+     * @param reqVO 查询条件
+     * @return 统计数据对照
+     */
+    List<ProductStatisticsDO> getProductStatisticsList(ProductStatisticsReqVO reqVO);
+
+    /**
+     * 统计指定天数的商品数据
+     *
+     * @return 统计结果
+     */
+    String statisticsProduct(Integer days);
+
+}

+ 123 - 0
yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/product/ProductStatisticsServiceImpl.java

@@ -0,0 +1,123 @@
+package cn.iocoder.yudao.module.statistics.service.product;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.date.DatePattern;
+import cn.hutool.core.date.LocalDateTimeUtil;
+import cn.hutool.core.util.ArrayUtil;
+import cn.hutool.core.util.ObjUtil;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.pojo.SortablePageParam;
+import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
+import cn.iocoder.yudao.module.statistics.controller.admin.common.vo.DataComparisonRespVO;
+import cn.iocoder.yudao.module.statistics.controller.admin.product.vo.ProductStatisticsReqVO;
+import cn.iocoder.yudao.module.statistics.controller.admin.product.vo.ProductStatisticsRespVO;
+import cn.iocoder.yudao.module.statistics.dal.dataobject.product.ProductStatisticsDO;
+import cn.iocoder.yudao.module.statistics.dal.mysql.product.ProductStatisticsMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StopWatch;
+import org.springframework.validation.annotation.Validated;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+
+/**
+ * 商品统计 Service 实现类
+ *
+ * @author owen
+ */
+@Service
+@Validated
+public class ProductStatisticsServiceImpl implements ProductStatisticsService {
+
+    @Resource
+    private ProductStatisticsMapper productStatisticsMapper;
+
+
+    @Override
+    public PageResult<ProductStatisticsDO> getProductStatisticsRankPage(ProductStatisticsReqVO reqVO, SortablePageParam pageParam) {
+        // 默认浏览量倒序
+        MyBatisUtils.buildDefaultSortingField(pageParam, ProductStatisticsDO::getBrowseCount);
+        return productStatisticsMapper.selectPageGroupBySpuId(reqVO, pageParam);
+    }
+
+    @Override
+    public DataComparisonRespVO<ProductStatisticsRespVO> getProductStatisticsAnalyse(ProductStatisticsReqVO reqVO) {
+        LocalDateTime beginTime = ArrayUtil.get(reqVO.getTimes(), 0);
+        LocalDateTime endTime = ArrayUtil.get(reqVO.getTimes(), 1);
+
+        // 统计数据
+        ProductStatisticsRespVO value = productStatisticsMapper.selectVoByTimeBetween(reqVO);
+        // 对照数据
+        LocalDateTime referenceBeginTime = beginTime.minus(Duration.between(beginTime, endTime));
+        ProductStatisticsReqVO referenceReqVO = new ProductStatisticsReqVO(new LocalDateTime[]{referenceBeginTime, beginTime});
+        ProductStatisticsRespVO reference = productStatisticsMapper.selectVoByTimeBetween(referenceReqVO);
+        return new DataComparisonRespVO<>(value, reference);
+    }
+
+    @Override
+    public List<ProductStatisticsDO> getProductStatisticsList(ProductStatisticsReqVO reqVO) {
+        return productStatisticsMapper.selectListByTimeBetween(reqVO);
+    }
+
+    @Override
+    public String statisticsProduct(Integer days) {
+        LocalDateTime today = LocalDateTime.now();
+        return IntStream.rangeClosed(1, days)
+                .mapToObj(day -> statisticsProduct(today.minusDays(day)))
+                .sorted()
+                .collect(Collectors.joining("\n"));
+    }
+
+    /**
+     * 统计商品数据
+     *
+     * @param date 需要统计的日期
+     * @return 统计结果
+     */
+    private String statisticsProduct(LocalDateTime date) {
+        // 1. 处理统计时间范围
+        LocalDateTime beginTime = LocalDateTimeUtil.beginOfDay(date);
+        LocalDateTime endTime = LocalDateTimeUtil.endOfDay(date);
+        String dateStr = DatePattern.NORM_DATE_FORMATTER.format(date);
+        // 2. 检查该日是否已经统计过
+        Long count = productStatisticsMapper.selectCountByTimeBetween(beginTime, endTime);
+        if (count != null && count > 0) {
+            return dateStr + " 数据已存在,如果需要重新统计,请先删除对应的数据";
+        }
+
+        // 3. 统计数据
+        StopWatch stopWatch = new StopWatch(dateStr);
+        stopWatch.start();
+
+        // 分页统计,避免商品表数据较多时,出现超时问题
+        final int pageSize = 100;
+        for (int pageNo = 1; ; pageNo ++) {
+            IPage<ProductStatisticsDO> page = productStatisticsMapper.selectStatisticsResultPageByTimeBetween(
+                    Page.of(pageNo, pageSize, false), beginTime, endTime);
+            if (CollUtil.isEmpty(page.getRecords())) {
+                break;
+            }
+
+            for (ProductStatisticsDO record : page.getRecords()) {
+                record.setTime(date.toLocalDate());
+                // 计算 访客支付转化率(百分比)
+                if (record.getBrowseUserCount() != null && ObjUtil.notEqual(record.getBrowseUserCount(), 0)) {
+                    record.setBrowseConvertPercent(100 * record.getOrderPayCount() / record.getBrowseUserCount());
+                }
+            }
+
+            // 4. 插入数据
+            productStatisticsMapper.insertBatch(page.getRecords());
+        }
+
+        return stopWatch.prettyPrint();
+    }
+
+}

+ 1 - 1
yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/trade/TradeStatisticsService.java

@@ -20,7 +20,7 @@ public interface TradeStatisticsService {
      *
      * @return 统计数据对照
      */
-    DataComparisonRespVO<TradeTrendSummaryRespVO> getTradeTrendSummaryComparison(
+    DataComparisonRespVO<TradeTrendSummaryRespVO> getTradeStatisticsAnalyse(
             LocalDateTime beginTime, LocalDateTime endTime);
 
     /**

+ 2 - 2
yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/service/trade/TradeStatisticsServiceImpl.java

@@ -60,7 +60,7 @@ public class TradeStatisticsServiceImpl implements TradeStatisticsService {
     }
 
     @Override
-    public DataComparisonRespVO<TradeTrendSummaryRespVO> getTradeTrendSummaryComparison(LocalDateTime beginTime,
+    public DataComparisonRespVO<TradeTrendSummaryRespVO> getTradeStatisticsAnalyse(LocalDateTime beginTime,
                                                                                         LocalDateTime endTime) {
         // 统计数据
         TradeTrendSummaryRespVO value = tradeStatisticsMapper.selectVoByTimeBetween(beginTime, endTime);
@@ -99,7 +99,7 @@ public class TradeStatisticsServiceImpl implements TradeStatisticsService {
         // 1. 处理统计时间范围
         LocalDateTime beginTime = LocalDateTimeUtil.beginOfDay(date);
         LocalDateTime endTime = LocalDateTimeUtil.endOfDay(date);
-        String dateStr = DatePattern.NORM_DATE_FORMAT.format(date);
+        String dateStr = DatePattern.NORM_DATE_FORMATTER.format(date);
         // 2. 检查该日是否已经统计过
         TradeStatisticsDO entity = tradeStatisticsMapper.selectByTimeBetween(beginTime, endTime);
         if (entity != null) {

+ 64 - 0
yudao-module-mall/yudao-module-statistics-biz/src/main/resources/mapper/product/ProductStatisticsMapper.xml

@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="cn.iocoder.yudao.module.statistics.dal.mysql.product.ProductStatisticsMapper">
+
+    <select id="selectStatisticsResultPageByTimeBetween"
+            resultType="cn.iocoder.yudao.module.statistics.dal.dataobject.product.ProductStatisticsDO">
+        SELECT spu.id                                                       AS spuId
+             -- 浏览量:一个用户可以有多次
+             , (SELECT COUNT(1)
+                FROM product_browse_history
+                WHERE spu_id = spu.id
+                  AND create_time BETWEEN #{beginTime} AND #{endTime})      AS browse_count
+             -- 访客量:按用户去重计数
+             , (SELECT COUNT(DISTINCT user_id)
+                FROM product_browse_history
+                WHERE spu_id = spu.id
+                  AND create_time BETWEEN #{beginTime} AND #{endTime})      AS browse_user_count
+             -- 收藏数量:按用户去重计数
+             , (SELECT COUNT(DISTINCT user_id)
+                FROM product_favorite
+                WHERE spu_id = spu.id
+                  AND create_time BETWEEN #{beginTime} AND #{endTime})      AS favorite_count
+             -- 加购数量:按用户去重计数
+             , (SELECT COUNT(DISTINCT user_id)
+                FROM trade_cart
+                WHERE spu_id = spu.id
+                  AND create_time BETWEEN #{beginTime} AND #{endTime})      AS cart_count
+             -- 下单件数
+             , (SELECT IFNULL(SUM(count), 0)
+                FROM trade_order_item
+                WHERE spu_id = spu.id
+                  AND create_time BETWEEN #{beginTime} AND #{endTime})      AS order_count
+             -- 支付件数
+             , (SELECT IFNULL(SUM(item.count), 0)
+                FROM trade_order_item item
+                         JOIN trade_order o ON item.order_id = o.id
+                WHERE spu_id = spu.id
+                  AND o.pay_status = TRUE
+                  AND item.create_time BETWEEN #{beginTime} AND #{endTime}) AS order_pay_count
+             -- 支付金额
+             , (SELECT IFNULL(SUM(item.pay_price), 0)
+                FROM trade_order_item item
+                         JOIN trade_order o ON item.order_id = o.id
+                WHERE spu_id = spu.id
+                  AND o.pay_status = TRUE
+                  AND item.create_time BETWEEN #{beginTime} AND #{endTime}) AS order_pay_price
+             -- 退款件数
+             , (SELECT IFNULL(SUM(count), 0)
+                FROM trade_after_sale
+                WHERE spu_id = spu.id
+                  AND refund_time IS NOT NULL
+                  AND create_time BETWEEN #{beginTime} AND #{endTime})      AS after_sale_count
+             -- 退款金额
+             , (SELECT IFNULL(SUM(refund_price), 0)
+                FROM trade_after_sale
+                WHERE spu_id = spu.id
+                  AND refund_time IS NOT NULL
+                  AND create_time BETWEEN #{beginTime} AND #{endTime})      AS after_sale_refund_price
+        FROM product_spu spu
+        WHERE spu.deleted = FALSE
+        ORDER BY spu.id
+    </select>
+
+</mapper>