From 72fe3a3ab90a832e5da0d4592da1df8f77ab1e19 Mon Sep 17 00:00:00 2001 From: "B.E.N.S.O.N" Date: Mon, 20 Apr 2026 13:13:58 +0800 Subject: [PATCH] Update --- .../modules/usage/FeatureUsageLogService.java | 58 ++++++++++ .../usage/web/FeatureUsageLogController.java | 103 ++++++++++++++++++ .../01_feature_usage_log.sql | 16 +++ 3 files changed, 177 insertions(+) create mode 100644 src/main/java/com/ffii/fpsms/modules/usage/FeatureUsageLogService.java create mode 100644 src/main/java/com/ffii/fpsms/modules/usage/web/FeatureUsageLogController.java create mode 100644 src/main/resources/db/changelog/changes/20260420_01_Benson/01_feature_usage_log.sql diff --git a/src/main/java/com/ffii/fpsms/modules/usage/FeatureUsageLogService.java b/src/main/java/com/ffii/fpsms/modules/usage/FeatureUsageLogService.java new file mode 100644 index 0000000..d6308a9 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/usage/FeatureUsageLogService.java @@ -0,0 +1,58 @@ +package com.ffii.fpsms.modules.usage; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import com.ffii.core.support.AbstractService; +import com.ffii.core.support.JdbcDao; +import com.ffii.fpsms.modules.user.entity.User; + +@Service +public class FeatureUsageLogService extends AbstractService { + + public static final String FEATURE_REPORT_MANAGEMENT = "REPORT_MANAGEMENT"; + public static final String FEATURE_TRUCK_ROUTING_SUMMARY = "TRUCK_ROUTING_SUMMARY"; + + public static final String ACTION_PAGE_VIEW = "PAGE_VIEW"; + public static final String ACTION_DOWNLOAD = "DOWNLOAD"; + public static final String ACTION_PRINT = "PRINT"; + + public FeatureUsageLogService(JdbcDao jdbcDao) { + super(jdbcDao); + } + + @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = Exception.class, readOnly = false) + public void insert(User user, String featureCode, String actionType, String detail) { + String sql = "INSERT INTO feature_usage_log (`user_id`, `username`, `feature_code`, `action_type`, `detail`) " + + "VALUES (:userId, :username, :featureCode, :actionType, :detail)"; + Map args = new HashMap<>(8); + args.put("userId", user.getId()); + args.put("username", user.getUsername()); + args.put("featureCode", featureCode); + args.put("actionType", actionType); + args.put("detail", StringUtils.isBlank(detail) ? null : StringUtils.left(detail, 512)); + jdbcDao.executeUpdate(sql, args); + } + + @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = Exception.class, readOnly = true) + public List> summarizeByFeature(String featureCode) { + String sql = """ + SELECT user_id AS userId, username, + SUM(CASE WHEN action_type = 'PAGE_VIEW' THEN 1 ELSE 0 END) AS pageViews, + SUM(CASE WHEN action_type = 'DOWNLOAD' THEN 1 ELSE 0 END) AS downloads, + SUM(CASE WHEN action_type = 'PRINT' THEN 1 ELSE 0 END) AS prints + FROM feature_usage_log + WHERE feature_code = :featureCode + GROUP BY user_id, username + ORDER BY username + """; + return jdbcDao.queryForList(sql, Map.of("featureCode", featureCode)); + } + +} diff --git a/src/main/java/com/ffii/fpsms/modules/usage/web/FeatureUsageLogController.java b/src/main/java/com/ffii/fpsms/modules/usage/web/FeatureUsageLogController.java new file mode 100644 index 0000000..a3ecadf --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/usage/web/FeatureUsageLogController.java @@ -0,0 +1,103 @@ +package com.ffii.fpsms.modules.usage.web; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.ffii.core.exception.BadRequestException; +import com.ffii.core.response.DataRes; +import com.ffii.fpsms.modules.common.SecurityUtils; +import com.ffii.fpsms.modules.usage.FeatureUsageLogService; +import com.ffii.fpsms.modules.user.entity.User; + +@RestController +@RequestMapping("/feature-usage") +public class FeatureUsageLogController { + + private final FeatureUsageLogService featureUsageLogService; + + public FeatureUsageLogController(FeatureUsageLogService featureUsageLogService) { + this.featureUsageLogService = featureUsageLogService; + } + + public static class LogRequest { + private String featureCode; + private String actionType; + private String detail; + + public String getFeatureCode() { + return featureCode; + } + + public void setFeatureCode(String featureCode) { + this.featureCode = featureCode; + } + + public String getActionType() { + return actionType; + } + + public void setActionType(String actionType) { + this.actionType = actionType; + } + + public String getDetail() { + return detail; + } + + public void setDetail(String detail) { + this.detail = detail; + } + } + + @PostMapping("/log") + public ResponseEntity log(@RequestBody LogRequest body) { + User user = SecurityUtils.getUser().orElseThrow(() -> new BadRequestException("Not authenticated")); + validateLogRequest(body); + featureUsageLogService.insert(user, body.getFeatureCode(), body.getActionType(), body.getDetail()); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + private void validateLogRequest(LogRequest body) { + if (body == null || StringUtils.isBlank(body.getFeatureCode()) || StringUtils.isBlank(body.getActionType())) { + throw new BadRequestException("featureCode and actionType are required"); + } + String fc = body.getFeatureCode(); + String at = body.getActionType(); + if (!FeatureUsageLogService.FEATURE_REPORT_MANAGEMENT.equals(fc) + && !FeatureUsageLogService.FEATURE_TRUCK_ROUTING_SUMMARY.equals(fc)) { + throw new BadRequestException("Invalid featureCode"); + } + if (!FeatureUsageLogService.ACTION_PAGE_VIEW.equals(at) + && !FeatureUsageLogService.ACTION_DOWNLOAD.equals(at) + && !FeatureUsageLogService.ACTION_PRINT.equals(at)) { + throw new BadRequestException("Invalid actionType"); + } + if (FeatureUsageLogService.ACTION_PRINT.equals(at) + && !FeatureUsageLogService.FEATURE_TRUCK_ROUTING_SUMMARY.equals(fc)) { + throw new BadRequestException("PRINT is only valid for TRUCK_ROUTING_SUMMARY"); + } + } + + @GetMapping("/summary") + @PreAuthorize("hasAnyAuthority('TESTING','ADMIN')") + public DataRes>>> summary() { + Map>> data = new HashMap<>(2); + data.put("reportManagement", + featureUsageLogService.summarizeByFeature(FeatureUsageLogService.FEATURE_REPORT_MANAGEMENT)); + data.put("truckRoutingSummary", + featureUsageLogService.summarizeByFeature(FeatureUsageLogService.FEATURE_TRUCK_ROUTING_SUMMARY)); + return new DataRes<>(data); + } + +} diff --git a/src/main/resources/db/changelog/changes/20260420_01_Benson/01_feature_usage_log.sql b/src/main/resources/db/changelog/changes/20260420_01_Benson/01_feature_usage_log.sql new file mode 100644 index 0000000..44d8eed --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260420_01_Benson/01_feature_usage_log.sql @@ -0,0 +1,16 @@ +--liquibase formatted sql + +--changeset codex:feature_usage_log +--comment: Track report management and truck routing summary page views, downloads, and prints +CREATE TABLE `feature_usage_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `username` varchar(64) NOT NULL, + `feature_code` varchar(64) NOT NULL, + `action_type` varchar(32) NOT NULL, + `detail` varchar(512) DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_feature_usage_feature_created` (`feature_code`, `created_at`), + KEY `idx_feature_usage_user_feature` (`user_id`, `feature_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;