iT邦幫忙

2025 iThome 鐵人賽

DAY 12
0

承接 Day11:我們已用 Flyway 建好 activities 資料表。今天把後端的 CRUD API 補齊,先從後端開始,因為這邊的CRUD比較簡單,讓使用者能建立、更新、刪除、查詢活動。

重點是所有請求都必須帶 JWT,且只能操作自己的活動資料。

API 設計

  • Base path: /api/activities
  • Endpoints
  • POST /api/activities:建立活動(名稱、目標分鐘、色碼、icon)
  • GET /api/activities:列出自己的活動清單
  • GET /api/activities/{id}:單筆查詢(限本人)
  • PUT /api/activities/{id}:更新(限本人)
  • DELETE /api/activities/{id}:刪除(限本人)

Entity 與 Repository

  • Activity:以 UUID 為主鍵,userId 標記擁有者;targetTime 對內以秒儲存(對外以分鐘顯示)。
@Entity
@Table(name = "activities")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Activity {

    @Id
    @GeneratedValue(generator = "uuid2")
    @GenericGenerator(name = "uuid2", strategy = "uuid2")
    @Column(name = "id", columnDefinition = "UUID")
    private UUID id;

    @Column(name = "user_id", nullable = false)
    private Long userId;

    @Column(name = "name", nullable = false, length = 100)
    private String name;

    @Min(0)
    @Column(name = "target_time", nullable = false)
    private Integer targetTime; // Weekly goal time in seconds

    @Column(name = "color", length = 16)
    private String color;

    @Column(name = "icon", length = 16)
    private String icon;

    @CreationTimestamp
    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;

    @UpdateTimestamp
    @Column(name = "updated_at", nullable = false)
    private Instant updatedAt;
}

Repository:依 userId 查詢、名稱唯一性檢查、彙總目標時間(轉分鐘)。

@Repository
public interface ActivityRepository extends JpaRepository<Activity, UUID> {
    
    /**
     * Find all activities by user ID
     */
    List<Activity> findByUserId(Long userId);
    
    /**
     * Check if activity exists by user ID and name
     */
    boolean existsByUserIdAndName(Long userId, String name);
    
    /**
     * Calculate sum of weekly target time (in minutes) for all user activities
     */
    @Query("SELECT COALESCE(SUM(a.targetTime), 0) / 60 FROM Activity a WHERE a.userId = :userId")
    Integer getWeeklyTargetSumInMinutes(@Param("userId") Long userId);
}

Service 層(程式碼 + 解釋)

  • 職責:封裝商業邏輯、套用擁有者規則、單位轉換(對外分鐘、對內儲存秒)。
  • 建立:自動帶入 userId,相同user,活動名稱不能重複。
public Activity createActivity(Long userId, CreateActivityRequest request) {
    log.info("Creating activity for user: {}, name: {}", userId, request.getName());
    
    // Check if activity with same name already exists for this user
    if (activityRepository.existsByUserIdAndName(userId, request.getName())) {
        throw new IllegalArgumentException("Activity with name '" + request.getName() + "' already exists for this user");
    }
    
    // Create new activity
    Activity activity = new Activity();
    activity.setUserId(userId);
    activity.setName(request.getName());
    activity.setTargetTime(request.getTargetTime() * 60); // Convert minutes to seconds
    activity.setColor(request.getColor());
    activity.setIcon(request.getIcon());
    
    Activity savedActivity = activityRepository.save(activity);
    log.info("Activity created successfully with ID: {}", savedActivity.getId());
    
    return savedActivity;
}
  • 查詢活動清單:只回傳該user的活動清單。
@Transactional(readOnly = true)
public List<ActivityResponse> getActivitiesByUserId(Long userId) {
    log.info("Fetching activities for user: {}", userId);
    
    List<Activity> activities = activityRepository.findByUserId(userId);
    log.info("Found {} activities for user: {}", activities.size(), userId);
    
    return activities.stream()
            .map(this::convertToActivityResponse)
            .collect(Collectors.toList());
}
  • 單筆查詢與擁有者驗證:非本人即拒絕(每個活動都綁定使用者)。
@Transactional(readOnly = true)
public Activity getActivityByIdAndUserId(UUID activityId, Long userId) {
    log.info("Fetching activity: {} for user: {}", activityId, userId);

    Activity activity = activityRepository.findById(activityId)
            .orElseThrow(() -> new IllegalArgumentException("Activity not found with ID: " + activityId));

    if (!activity.getUserId().equals(userId)) {
        throw new IllegalArgumentException("Activity does not belong to user: " + userId);
    }

    return activity;
}
  • 更新:名稱變更需檢查唯一性;分鐘→秒轉換。
public Activity updateActivity(UUID activityId, Long userId, String name, Integer goalTime, String color, String icon) {
    log.info("Updating activity: {} for user: {}", activityId, userId);

    Activity activity = getActivityByIdAndUserId(activityId, userId);

    // Check if new name conflicts with existing activity
    if (name != null && !name.equals(activity.getName()) &&
        activityRepository.existsByUserIdAndName(userId, name)) {
        throw new IllegalArgumentException("Activity with name '" + name + "' already exists for this user");
    }

    // Update fields if provided
    if (name != null) {
        activity.setName(name);
    }
    if (goalTime != null) {
        activity.setTargetTime(goalTime);
    }
    if (color != null) {
        activity.setColor(color);
    }
    if (icon != null) {
        activity.setIcon(icon);
    }

    Activity updatedActivity = activityRepository.save(activity);
    log.info("Activity updated successfully: {}", updatedActivity.getId());

    return updatedActivity;
}
  • 刪除:同樣驗證擁有者後刪除。
public void deleteActivity(UUID activityId, Long userId) {
    log.info("Deleting activity: {} for user: {}", activityId, userId);

    Activity activity = getActivityByIdAndUserId(activityId, userId);
    activityRepository.delete(activity);

    log.info("Activity deleted successfully: {}", activityId);
}

補充說明:

可以看到有些方法只是查詢,但卻有@Transactional

為什麼要加 @Transactional(readOnly = true)?

  1. 讀取操作優化:對於純查詢方法(如 getActivitiesByUserId、getActivityByIdAndUserId),使用 readOnly = true 可以:
  • 告訴 Hibernate 這是一個只讀操作,不需要檢查實體狀態變化
  • 優化資料庫連線使用,避免不必要的寫入鎖定
  • 提升查詢效能,特別是在高併發環境下
  1. 資料一致性保證:即使只是查詢,事務邊界確保:
  • 查詢過程中資料不會被其他事務修改
  • 提供一致的資料視圖(Read Committed 隔離級別)

那為什麼其他更新的方法沒有加 @Transactional? 如deleteActivity、updateActivity
因為我直接加在Service層

@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class ActivityService {
  • DTO 交換模型:對外維持分鐘;回傳含 KPI 欄位。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ActivityResponse {
    private String id;
    private String name;
    private Integer totalTime;    // 分鐘
    private Integer weeklyTime;   // 分鐘
    private Integer targetTime;   // 分鐘
    private String color;         // 例: "#3b82f6"
    private String icon;          // 例: "book"
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateActivityRequest {
    
    @NotBlank(message = "Activity name is required")
    private String name;
    
    @NotNull(message = "Target time is required")
    @Positive(message = "Target time must be positive")
    private Integer targetTime; // Target time in minutes
    
    @NotBlank(message = "Color is required")
    private String color;
    
    @NotBlank(message = "Icon is required")
    private String icon;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UpdateActivityRequest {
    private String name;
    
    @Positive(message = "Target time must be positive")
    private Integer targetTime;  // 分鐘
    
    private String color;
    private String icon;
}

Controller(程式碼 + 解釋)

  • 路由與安全宣告:以 /api/activities 為前綴,並以 @SecurityRequirement 標註。
@RestController
@RequestMapping("/api/activities")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Activity Management", description = "活動管理")
@SecurityRequirement(name = "bearerAuth")
public class ActivityController {

    private final ActivityService activityService;
    private final StatisticsService statisticsService;

為什麼要加 @SecurityRequirement(name = "bearerAuth")?

主要目的:讓 Swagger UI 知道這些 API 需要 JWT 認證

實際效果

  1. 在 Swagger UI 中顯示 🔒 鎖頭圖示
  2. 提供 "Authorize" 按鈕讓開發者輸入 JWT Token
  3. 測試 API 時自動帶入 Authorization: Bearer  header

https://ithelp.ithome.com.tw/upload/images/20250924/201602798HhpYrYrag.png

  • 建立:從 Authentication 取得 userId,呼叫 Service;回 201。
public ResponseEntity<ActivityResponse> createActivity(
        Authentication authentication,
        @Valid @RequestBody CreateActivityRequest request) {
    
    Long userId = (Long) authentication.getPrincipal();
    log.info("Creating activity for user: {}", userId);
    ActivityResponse createdActivity = activityService.createActivityResponse(userId, request);
    return ResponseEntity.status(HttpStatus.CREATED).body(createdActivity);
}
  • 列表:僅回傳本人活動。ActivityController.java
public ResponseEntity<Map<String, List<ActivityResponse>>> getActivities(Authentication authentication) {
    
    Long userId = (Long) authentication.getPrincipal();
    log.info("Fetching activities for user: {}", userId);
    List<ActivityResponse> activities = activityService.getActivitiesByUserId(userId);
    return ResponseEntity.ok(Map.of("data", activities));
}
  • 單筆/更新/刪除:皆以 userId 驗證擁有者。
public ResponseEntity<ActivityResponse> getActivity(
        Authentication authentication,
        @Parameter(description = "活動ID") @PathVariable("id") UUID id) {
    
    Long userId = (Long) authentication.getPrincipal();
    log.info("Fetching activity: {} for user: {}", id, userId);
    ActivityResponse activity = activityService.getActivityResponseByIdAndUserId(id, userId);
    return ResponseEntity.ok(activity);
}

總結

今天完成了活動 CRUD API:定義路由、實作 Entity/Repository/Service/Controller,並以 JWT 驗證與擁有者規則保護資料。接下來可把前端串上這些 API,讓使用者在 UI 上新增與管理活動;同時也能擴充更多查詢與統計視圖,讓使用體驗更完整。

  • 完成內容:CRUD 設計、JWT 保護、擁有者驗證、分鐘/秒單位轉換、DTO 輸入輸出。
  • 下一步:前端串接 /api/activities,接通列表、新增、編輯、刪除的操作與畫面。

上一篇
為什麼需要資料庫遷移?用 Flyway 管理 Spring Boot 專案的資料庫內容
下一篇
Day13 — 前端活動管理的 UI/UX 設計
系列文
我的時間到底去哪裡了!? – 個人時間數據系統開發挑戰15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言