diff --git a/1-sql/2025-12-30-todo-/file_storage.sql b/1-sql/2025-12-30-todo-/file_storage.sql new file mode 100644 index 00000000..ec63c095 --- /dev/null +++ b/1-sql/2025-12-30-todo-/file_storage.sql @@ -0,0 +1,3 @@ +ALTER TABLE file_storage + MODIFY COLUMN createYearMonth VARCHAR(7) + GENERATED ALWAYS AS (DATE_FORMAT(createTime, '%Y-%m')) STORED; \ No newline at end of file diff --git a/common/src/main/java/com/sdm/common/entity/enums/DirTypeEnum.java b/common/src/main/java/com/sdm/common/entity/enums/DirTypeEnum.java index bdc67981..10030d45 100644 --- a/common/src/main/java/com/sdm/common/entity/enums/DirTypeEnum.java +++ b/common/src/main/java/com/sdm/common/entity/enums/DirTypeEnum.java @@ -37,7 +37,13 @@ public enum DirTypeEnum { TRAIN_MODEL_DIR("trainModel", 5), @Schema(description = "脚本文件夹", example = "6") - SCRIPT_DIR("script", 6); + SCRIPT_DIR("script", 6), + + /** + * 视频库 + */ + @Schema(description = "视频库文件夹", example = "7") + VIDEO_DIR("video", 7); @@ -70,7 +76,7 @@ public enum DirTypeEnum { // 初始化用户业务库目录 private static final List INIT_SPMD_DIR = List.of( DirTypeEnum.KNOWLEDGE_BASE_DIR, DirTypeEnum.PROJECT_NODE_DIR, - DirTypeEnum.AVATAR_DIR, DirTypeEnum.SIMULATION_PARAMETER_DIR, DirTypeEnum.TRAIN_MODEL_DIR,DirTypeEnum.SCRIPT_DIR); + DirTypeEnum.AVATAR_DIR, DirTypeEnum.SIMULATION_PARAMETER_DIR, DirTypeEnum.TRAIN_MODEL_DIR,DirTypeEnum.SCRIPT_DIR, DirTypeEnum.VIDEO_DIR); public static final List getInitSpmdDir() { return INIT_SPMD_DIR; } diff --git a/common/src/main/java/com/sdm/common/utils/FileSizeUtils.java b/common/src/main/java/com/sdm/common/utils/FileSizeUtils.java index 252b2819..ce0d924c 100644 --- a/common/src/main/java/com/sdm/common/utils/FileSizeUtils.java +++ b/common/src/main/java/com/sdm/common/utils/FileSizeUtils.java @@ -52,7 +52,7 @@ public class FileSizeUtils { public static String formatFileSizeToGB(BigDecimal bytes) { // 处理null值和非正值的情况 if (bytes == null || bytes.compareTo(BigDecimal.ZERO) <= 0) { - return "0.00000000 GB"; + return "0.00000000"; } // 1 GB = 1024^3 字节 diff --git a/data/src/main/java/com/sdm/data/model/req/FileSearchReq.java b/data/src/main/java/com/sdm/data/model/req/FileSearchReq.java index c8217f0e..2fd56cac 100644 --- a/data/src/main/java/com/sdm/data/model/req/FileSearchReq.java +++ b/data/src/main/java/com/sdm/data/model/req/FileSearchReq.java @@ -51,6 +51,13 @@ public class FileSearchReq extends BaseReq { @Schema(description = "文件后缀") private String fileSuffix; + + /** + * 目录类型 DirTypeEnum + */ + @Schema(description = "目录类型") + private Integer dirType; + /** * 文件大小 */ diff --git a/data/src/main/java/com/sdm/data/model/req/QueryBigFileReq.java b/data/src/main/java/com/sdm/data/model/req/QueryBigFileReq.java index 60826941..cf01af02 100644 --- a/data/src/main/java/com/sdm/data/model/req/QueryBigFileReq.java +++ b/data/src/main/java/com/sdm/data/model/req/QueryBigFileReq.java @@ -15,6 +15,11 @@ public class QueryBigFileReq extends BaseReq { */ private Long dirId; + /** + * 目录类型 DirTypeEnum + */ + private Integer dirType; + /** * 文件名称 */ diff --git a/data/src/main/java/com/sdm/data/service/DataStorageAnalysis.java b/data/src/main/java/com/sdm/data/service/DataStorageAnalysis.java index 43d4b064..43a74027 100644 --- a/data/src/main/java/com/sdm/data/service/DataStorageAnalysis.java +++ b/data/src/main/java/com/sdm/data/service/DataStorageAnalysis.java @@ -53,8 +53,6 @@ public interface DataStorageAnalysis { SdmResponse batchUpdateUserQuota(List addUserQuota); - List getListBigFileId(QueryBigFileReq queryBigFileReq); - SdmResponse> listAllUserQuotaForJob(); } diff --git a/data/src/main/java/com/sdm/data/service/impl/DataStorageAnalysisImpl.java b/data/src/main/java/com/sdm/data/service/impl/DataStorageAnalysisImpl.java index d58f303d..85cb79e0 100644 --- a/data/src/main/java/com/sdm/data/service/impl/DataStorageAnalysisImpl.java +++ b/data/src/main/java/com/sdm/data/service/impl/DataStorageAnalysisImpl.java @@ -74,7 +74,7 @@ public class DataStorageAnalysisImpl implements DataStorageAnalysis { if (ObjectUtils.isEmpty(nodeList)) { log.error("获取节点信息失败,节点类型: {}, 标识符: {}", queryNodeType, queryNodeName); - return getEmptyNodeSize(queryNodeName); + return getEmptyNodeSize(queryNodeName,intervalMonths,targetYm); } Long tenantId = ThreadLocalContext.getTenantId(); @@ -98,7 +98,7 @@ public class DataStorageAnalysisImpl implements DataStorageAnalysis { .list().stream().collect(Collectors.toMap(FileMetadataInfo::getRelatedResourceUuid, FileMetadataInfo::getId)); if (uuidToDirIdMap.isEmpty()) { log.error("获取节点ID映射失败,节点类型: {}, 标识符: {}", queryNodeType, queryNodeName); - return getEmptyNodeSize(queryNodeName); + return getEmptyNodeSize(queryNodeName,intervalMonths,targetYm); } // fileMetadIds: uuid对应的fileid结合 @@ -109,45 +109,72 @@ public class DataStorageAnalysisImpl implements DataStorageAnalysis { if (ObjectUtils.isNotEmpty(intervalMonths)) { // 近几个月 nodeSizeDTOS = fileStorageService.selectNodeSizeByNodeType(fileMetadIds, intervalMonths,tenantId ); - } - if (ObjectUtils.isNotEmpty(targetYm)) { + + if (ObjectUtils.isEmpty(nodeSizeDTOS)) { + // 空间为空 也要返回nodeName + return getEmptyNodeSize(queryNodeName,intervalMonths,targetYm); + } + + // nodeSizeDTOMaps: 节点的fileid --> filesize + Map nodeSizeDTOMaps = nodeSizeDTOS.stream().collect(Collectors.toMap(NodeSizeDTO::getDirId, NodeSizeDTO::getTotalSize)); + nodeNameToUuidListMap.forEach((nodeName, uuidList) -> { + AtomicLong totalSize = new AtomicLong(); + uuidList.forEach(uuidItem -> { + Long dirId = uuidToDirIdMap.get(uuidItem); + Long filesize = nodeSizeDTOMaps.get(dirId); + if (ObjectUtils.isNotEmpty(filesize)) { + totalSize.addAndGet(filesize); + } + }); + JSONObject jsonObject = new JSONObject(); + jsonObject.put("nodeName", nodeName); + jsonObject.put("totalSize", FileSizeUtils.formatFileSizeToGB(new BigDecimal(totalSize.toString()))); + result.add(jsonObject); + }); + }else if (ObjectUtils.isNotEmpty(targetYm)) { //查询增量的 nodeSizeDTOS = fileStorageService.statDirStorageByTargetYm(fileMetadIds, targetYm,tenantId); - } - if (ObjectUtils.isEmpty(nodeSizeDTOS)) { - // 空间为空 也要返回nodeName - return getEmptyNodeSize(queryNodeName); - } + if (ObjectUtils.isEmpty(nodeSizeDTOS)) { + // 空间为空 也要返回nodeName + return getEmptyNodeSize(queryNodeName,intervalMonths,targetYm); + } + Map> nodeSizeDTOMaps = nodeSizeDTOS.stream().collect(Collectors.groupingBy(NodeSizeDTO::getDirId, + Collectors.toMap(NodeSizeDTO::getStatDimension, NodeSizeDTO::getTotalSize))); + nodeNameToUuidListMap.forEach((nodeName, uuidList) -> { + Map totalSizeMap = new HashMap<>(); + uuidList.forEach(uuidItem -> { + Long dirId = uuidToDirIdMap.get(uuidItem); + Map statDimensionSizeMap = nodeSizeDTOMaps.get(dirId); + totalSizeMap.put("BEFORE", totalSizeMap.getOrDefault("BEFORE",0L) + statDimensionSizeMap.getOrDefault("BEFORE",0L)); + totalSizeMap.put("INCREMENT", totalSizeMap.getOrDefault("INCREMENT",0L) + statDimensionSizeMap.getOrDefault("INCREMENT",0L)); + }); - // nodeSizeDTOMaps: 节点的fileid --> filesize - Map nodeSizeDTOMaps = nodeSizeDTOS.stream().collect(Collectors.toMap(NodeSizeDTO::getDirId, NodeSizeDTO::getTotalSize)); - nodeNameToUuidListMap.forEach((nodeName, uuidList) -> { - AtomicLong totalSize = new AtomicLong(); - uuidList.forEach(uuidItem -> { - Long dirId = uuidToDirIdMap.get(uuidItem); - Long filesize = nodeSizeDTOMaps.get(dirId); - if (ObjectUtils.isNotEmpty(filesize)) { - totalSize.addAndGet(filesize); - } + JSONObject jsonObject = new JSONObject(); + jsonObject.put("nodeName", nodeName); + jsonObject.put("BEFORE", FileSizeUtils.formatFileSizeToGB(new BigDecimal(totalSizeMap.get("BEFORE").toString()))); + jsonObject.put("INCREMENT", FileSizeUtils.formatFileSizeToGB(new BigDecimal(totalSizeMap.get("INCREMENT").toString()))); + result.add(jsonObject); }); - JSONObject jsonObject = new JSONObject(); - jsonObject.put("nodeName", nodeName); - jsonObject.put("totalSize", FileSizeUtils.formatFileSizeToGB(new BigDecimal(totalSize.toString()))); - result.add(jsonObject); - }); + } } return SdmResponse.success(result); } @NotNull - private static SdmResponse> getEmptyNodeSize(String actualNodeName) { + private static SdmResponse> getEmptyNodeSize(String actualNodeName,Integer intervalMonths, String targetYm) { JSONObject jsonObject = new JSONObject(); - jsonObject.put("nodeName", actualNodeName); - jsonObject.put("totalSize", 0); - return SdmResponse.success(Arrays.asList(jsonObject)); + if (ObjectUtils.isNotEmpty(intervalMonths)) { + jsonObject.put("nodeName", actualNodeName); + jsonObject.put("totalSize", 0); + }else if (ObjectUtils.isNotEmpty(targetYm)) { + jsonObject.put("nodeName", actualNodeName); + jsonObject.put("BEFORE", 0); + jsonObject.put("INCREMENT", 0); + } + return SdmResponse.success(List.of(jsonObject)); } @Override @@ -169,8 +196,6 @@ public class DataStorageAnalysisImpl implements DataStorageAnalysis { return SdmResponse.success(new ArrayList<>()); } - Map userIdToTotalSizeMap = totalFileSizeByCreator.stream().collect(Collectors.toMap(UserTotalFileSizeDTO::getUserId, UserTotalFileSizeDTO::getTotalSize)); - // 2. 批量获取用户信息 (性能优化核心) // 提取所有涉及到的 userId List targetUserIds = new ArrayList<>(); @@ -183,17 +208,28 @@ public class DataStorageAnalysisImpl implements DataStorageAnalysis { targetUserIds = userIds; } - Map userIdToNicknameMap = userNameCacheService.batchGetUserNames(new HashSet<>(targetUserIds)); // 3. 组装结果 List result = new ArrayList<>(); - for (Map.Entry entry : userIdToNicknameMap.entrySet()){ - JSONObject jsonObject = new JSONObject(); - jsonObject.put("userName", entry.getValue()); - Long totalSize = userIdToTotalSizeMap.getOrDefault(entry.getKey(), 0L); - jsonObject.put("totalFileSize", FileSizeUtils.formatFileSizeToGB(new BigDecimal(totalSize))); - result.add(jsonObject); + if (ObjectUtils.isNotEmpty(intervalMonths)) { + Map userIdToTotalSizeMap = totalFileSizeByCreator.stream().collect(Collectors.toMap(UserTotalFileSizeDTO::getUserId, UserTotalFileSizeDTO::getTotalSize)); + for (Map.Entry entry : userIdToNicknameMap.entrySet()) { + JSONObject jsonObject = new JSONObject(); + jsonObject.put("userName", entry.getValue()); + Long totalSize = userIdToTotalSizeMap.getOrDefault(entry.getKey(), 0L); + jsonObject.put("totalFileSize", FileSizeUtils.formatFileSizeToGB(new BigDecimal(totalSize))); + result.add(jsonObject); + } + } else if (ObjectUtils.isNotEmpty(targetYm)) { + Map> userSizeDTOMaps = totalFileSizeByCreator.stream().collect(Collectors.groupingBy(UserTotalFileSizeDTO::getUserId, Collectors.toMap(UserTotalFileSizeDTO::getStatDimension, UserTotalFileSizeDTO::getTotalSize))); + userSizeDTOMaps.forEach((userId, sizeMap) -> { + JSONObject jsonObject = new JSONObject(); + jsonObject.put("userName", userIdToNicknameMap.getOrDefault(userId,"未知用户")); + jsonObject.put("BEFORE", FileSizeUtils.formatFileSizeToGB(new BigDecimal(sizeMap.getOrDefault("BEFORE", 0L)))); + jsonObject.put("INCREMENT", FileSizeUtils.formatFileSizeToGB(new BigDecimal(sizeMap.getOrDefault("INCREMENT", 0L)))); + result.add(jsonObject); + }); } return SdmResponse.success(result); @@ -293,12 +329,11 @@ public class DataStorageAnalysisImpl implements DataStorageAnalysis { @Override public SdmResponse>> listBigFile(QueryBigFileReq queryBigFileReq) { - List list = getFileStorages(queryBigFileReq); - PageInfo page = new PageInfo<>(list); - return PageUtils.getJsonObjectSdmResponse(list, page); + PageInfo fileStorages = getFileStorages(queryBigFileReq); + return PageUtils.getJsonObjectSdmResponse(fileStorages.getList(), fileStorages); } - private List getFileStorages(QueryBigFileReq queryBigFileReq) { + private PageInfo getFileStorages(QueryBigFileReq queryBigFileReq) { // 将前端传入的 fileSize 和 fileSizeUnit 转换为字节(B) Long fileSizeInBytes = null; if (queryBigFileReq.getFileSize() != null && queryBigFileReq.getFileSizeUnit() != null) { @@ -307,7 +342,7 @@ public class DataStorageAnalysisImpl implements DataStorageAnalysis { Long tenantId = ThreadLocalContext.getTenantId(); PageHelper.startPage(queryBigFileReq.getCurrent(), queryBigFileReq.getSize()); List list = fileStorageService.selectBigFiles(queryBigFileReq, fileSizeInBytes, tenantId); - return list; + return new PageInfo<>(list); } /** @@ -348,7 +383,7 @@ public class DataStorageAnalysisImpl implements DataStorageAnalysis { delFileReq.setDelFileId(fileId); dataFileService.delFile(delFileReq); } - return null; + return SdmResponse.success("删除成功"); } @Override @@ -492,16 +527,6 @@ public class DataStorageAnalysisImpl implements DataStorageAnalysis { return SdmResponse.success("更新成功"); } - /* - * 根据条件查询文件存储表的文件id - * */ - @Override - public List getListBigFileId(QueryBigFileReq queryBigFileReq) { - return getFileStorages(queryBigFileReq).stream() - .map(FileStorage::getFileId) - .collect(Collectors.toList()); - } - @Override public SdmResponse> listAllUserQuotaForJob() { List quotaList = fileStorageQuotaService.list(); diff --git a/data/src/main/java/com/sdm/data/service/impl/MinioFileIDataFileServiceImpl.java b/data/src/main/java/com/sdm/data/service/impl/MinioFileIDataFileServiceImpl.java index cd0ef779..86b1c25a 100644 --- a/data/src/main/java/com/sdm/data/service/impl/MinioFileIDataFileServiceImpl.java +++ b/data/src/main/java/com/sdm/data/service/impl/MinioFileIDataFileServiceImpl.java @@ -601,24 +601,48 @@ public class MinioFileIDataFileServiceImpl implements IDataFileService { public SdmResponse fileSearch(FileSearchReq minioFileSearchReq) { QueryBigFileReq queryBigFileReq = new QueryBigFileReq(); - FileMetadataInfo fileMetadataInfo = null; + Long dirId; + Integer dirType; if (ObjectUtils.isNotEmpty(minioFileSearchReq.getParentUuid())) { // 项目节点下搜索文件 - fileMetadataInfo = fileMetadataInfoService.lambdaQuery().eq(FileMetadataInfo::getRelatedResourceUuid, minioFileSearchReq.getParentUuid()).one(); + FileMetadataInfo fileMetadataInfo = fileMetadataInfoService.lambdaQuery().eq(FileMetadataInfo::getRelatedResourceUuid, minioFileSearchReq.getParentUuid()).one(); + dirId = fileMetadataInfo.getId(); + dirType = fileMetadataInfo.getDirType(); } else if (ObjectUtils.isNotEmpty(minioFileSearchReq.getParentDirId())) { // 知识库的文件查询 - fileMetadataInfo = fileMetadataInfoService.getById(minioFileSearchReq.getParentDirId()); + FileMetadataInfo fileMetadataInfo = fileMetadataInfoService.getById(minioFileSearchReq.getParentDirId()); + dirId = fileMetadataInfo.getId(); + dirType = fileMetadataInfo.getDirType(); + } else if (ObjectUtils.isNotEmpty(minioFileSearchReq.getDirType())) { + dirType = minioFileSearchReq.getDirType(); + DirTypeEnum dirTypeByValue = DirTypeEnum.getDirTypeByValue(dirType); + if (dirTypeByValue == null) { + return SdmResponse.failed("请选择正确的目录类型:1 知识库文件夹,2 项目节点文件夹,3 头像库文件夹,4 仿真参数库文件夹,5 训练模型文件夹"); + } + + // 先检查根目录是否已存在 + String rootDirMinioObjectKey = getDirMinioObjectKey(dirTypeByValue.getDirName()); + Optional fileMetadataInfoByObjectKey = getFileMetadataInfoByObjectKey(rootDirMinioObjectKey, null); + // 检查目录是否已存在 + if (!fileMetadataInfoByObjectKey.isPresent()) { + return SdmResponse.failed("知识库、项目根目录不存在,等待initSystemDirectory 初始化完成"); + } + + // 获取根目录的 id + dirId = fileMetadataInfoByObjectKey.get().getId(); + }else { + return SdmResponse.failed("请选择目录类型:1 知识库文件夹,2 项目节点文件夹,3 头像库文件夹,4 仿真参数库文件夹,5 训练模型文件夹"); } BeanUtils.copyProperties(minioFileSearchReq, queryBigFileReq); - if (ObjectUtils.isNotEmpty(fileMetadataInfo)) { - queryBigFileReq.setDirId(fileMetadataInfo.getId()); - queryBigFileReq.setCurrent(minioFileSearchReq.getCurrent()); - queryBigFileReq.setSize(minioFileSearchReq.getSize()); - } - // 这里是知识库文件:排除新增在审批的文件 - queryBigFileReq.setApproveTypeList(fileDatdList); queryBigFileReq.setIsLatest(true); + queryBigFileReq.setCurrent(minioFileSearchReq.getCurrent()); + queryBigFileReq.setSize(minioFileSearchReq.getSize()); + queryBigFileReq.setDirId(dirId); + if (Objects.equals(DirTypeEnum.KNOWLEDGE_BASE_DIR.getValue(), dirType)) { + // 知识库文件:排除新增在审批的文件 + queryBigFileReq.setApproveTypeList(fileDatdList); + } List creatorIds = org.apache.commons.lang3.StringUtils.isBlank(minioFileSearchReq.getUploadUserId()) ? new ArrayList<>() @@ -628,7 +652,10 @@ public class MinioFileIDataFileServiceImpl implements IDataFileService { .collect(Collectors.toList()); queryBigFileReq.setUploadUserId(creatorIds); - List fileIdList =dataStorageAnalysis.getListBigFileId(queryBigFileReq); + + SdmResponse>> searchResult = dataStorageAnalysis.listBigFile(queryBigFileReq); + List fileIdList =searchResult.getData().getData().stream().map(FileStorage::getFileId).collect(Collectors.toList()); + if(CollectionUtils.isEmpty(fileIdList)){ return SdmResponse.success(); } @@ -641,9 +668,7 @@ public class MinioFileIDataFileServiceImpl implements IDataFileService { setProjectName(files); setAnalysisDirectionName(files); setSimulationPoolAndTaskInfo(files); - PageInfo page = new PageInfo<>(files); - long total = page.getTotal(); List dtoList = files.stream().map(entity -> { FileMetadataInfoResp dto = new FileMetadataInfoResp(); BeanUtils.copyProperties(entity, dto); @@ -653,9 +678,12 @@ public class MinioFileIDataFileServiceImpl implements IDataFileService { dto.setPermissionValue(fileUserPermissionService.getMergedPermission(entity.getId(), ThreadLocalContext.getUserId())); return dto; }).collect(Collectors.toList()); - PageInfo page1 = new PageInfo<>(dtoList); - page1.setTotal(total); - return PageUtils.getJsonObjectSdmResponse(dtoList, page1); + PageDataResp> pageDataResp = searchResult.getData(); + PageInfo page = new PageInfo(); + page.setPageNum(pageDataResp.getCurrentPage()); + page.setPageSize(pageDataResp.getPageSize()); + page.setTotal(pageDataResp.getTotal()); + return PageUtils.getJsonObjectSdmResponse(dtoList, page); } @Override @@ -887,6 +915,7 @@ public class MinioFileIDataFileServiceImpl implements IDataFileService { String bucketName = minioService.getCurrentTenantBucketName(); // 这里会触发 MinioService 的懒加载创建桶 for (DirTypeEnum dirType : DirTypeEnum.getInitSpmdDir()) { + log.info("租户:{},开始初始化目录: {}",tenantId, dirType.getDirName()); // 目录路径,如 "knowledge/" String dirPath = getDirMinioObjectKey(dirType.getDirName()); @@ -897,6 +926,7 @@ public class MinioFileIDataFileServiceImpl implements IDataFileService { .exists(); if (exists) { + log.info("目录已存在: {},跳过", dirType.getDirName()); continue; // 已初始化过,跳过 } diff --git a/data/src/main/resources/mapper/FileStorageMapper.xml b/data/src/main/resources/mapper/FileStorageMapper.xml index 358aa4ed..4d01858c 100644 --- a/data/src/main/resources/mapper/FileStorageMapper.xml +++ b/data/src/main/resources/mapper/FileStorageMapper.xml @@ -54,7 +54,7 @@ SELECT dirId, - BEFORE AS statDimension, -- 示例:BEFORE_202410 + 'BEFORE' AS statDimension, -- 示例:BEFORE_202410 SUM(fileSize) AS totalSize -- 总占用字节数(原始单位) FROM file_storage WHERE @@ -72,7 +72,7 @@ SELECT dirId, - INCREMENT AS statDimension, -- 示例:INCREMENT_202410 + 'INCREMENT' AS statDimension, -- 示例:INCREMENT_202410 SUM(fileSize) AS totalSize FROM file_storage WHERE @@ -135,30 +135,31 @@ SELECT t.userId, s.statDimension, - SUM(s.totalSize) as totalSize + s.totalSize FROM TargetUsers t INNER JOIN ( - -- 历史累计 - SELECT userId, 'BEFORE' as statDimension, fileSize + -- 1. 历史累计 (内部先 SUM) + SELECT userId, 'BEFORE' as statDimension, SUM(fileSize) as totalSize FROM file_storage WHERE tenantId = #{tenantId} AND createYearMonth < #{targetYm} + GROUP BY userId UNION ALL - -- 当月增量 - SELECT userId, 'INCREMENT' as statDimension, fileSize + -- 2. 当月增量 (内部先 SUM) + SELECT userId, 'INCREMENT' as statDimension, SUM(fileSize) as totalSize FROM file_storage WHERE tenantId = #{tenantId} AND createYearMonth = #{targetYm} + GROUP BY userId ) s ON t.userId = s.userId - GROUP BY t.userId, s.statDimension