承接 Day11:我們已用 Flyway 建好 activities 資料表。今天把後端的 CRUD API 補齊,先從後端開始,因為這邊的CRUD比較簡單,讓使用者能建立、更新、刪除、查詢活動。
重點是所有請求都必須帶 JWT,且只能操作自己的活動資料。
@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);
}
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;
}
@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)?
那為什麼其他更新的方法沒有加 @Transactional? 如deleteActivity、updateActivity
因為我直接加在Service層
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class ActivityService {
@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;
}
@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 認證
實際效果:
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);
}
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));
}
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 上新增與管理活動;同時也能擴充更多查詢與統計視圖,讓使用體驗更完整。