Merge pull request #262 from nagisa77/codex/add-activity-module-and-endpoints

Add activity module with milk tea event
This commit is contained in:
Tim
2025-07-28 14:12:43 +08:00
committed by GitHub
12 changed files with 247 additions and 3 deletions

View File

@@ -32,6 +32,15 @@
<i class="menu-item-icon fas fa-info-circle"></i>
<span class="menu-item-text">关于</span>
</router-link>
<router-link
class="menu-item"
exact-active-class="selected"
to="/activities"
@click="handleItemClick"
>
<i class="menu-item-icon fas fa-gift"></i>
<span class="menu-item-text">活动</span>
</router-link>
<router-link
v-if="shouldShowStats"
class="menu-item"

View File

@@ -3,6 +3,7 @@ import HomePageView from '../views/HomePageView.vue'
import MessagePageView from '../views/MessagePageView.vue'
import AboutPageView from '../views/AboutPageView.vue'
import SiteStatsPageView from '../views/SiteStatsPageView.vue'
import ActivityListPageView from '../views/ActivityListPageView.vue'
import PostPageView from '../views/PostPageView.vue'
import LoginPageView from '../views/LoginPageView.vue'
import SignupPageView from '../views/SignupPageView.vue'
@@ -38,6 +39,11 @@ const routes = [
name: 'site-stats',
component: SiteStatsPageView
},
{
path: '/activities',
name: 'activities',
component: ActivityListPageView
},
{
path: '/new-post',
name: 'new-post',

View File

@@ -0,0 +1,31 @@
<template>
<div class="activity-list-page">
<h1>活动列表</h1>
<ul>
<li v-for="a in activities" :key="a.id">{{ a.title }}</li>
</ul>
</div>
</template>
<script>
import { API_BASE_URL } from '../main'
export default {
name: 'ActivityListPageView',
data() {
return { activities: [] }
},
async mounted() {
const res = await fetch(`${API_BASE_URL}/api/activities`)
if (res.ok) {
this.activities = await res.json()
}
}
}
</script>
<style scoped>
.activity-list-page {
padding: 10px;
}
</style>

View File

@@ -0,0 +1,24 @@
package com.openisle.config;
import com.openisle.model.Activity;
import com.openisle.model.ActivityType;
import com.openisle.repository.ActivityRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class ActivityInitializer implements CommandLineRunner {
private final ActivityRepository activityRepository;
@Override
public void run(String... args) {
if (activityRepository.findByType(ActivityType.MILK_TEA) == null) {
Activity a = new Activity();
a.setTitle("建站送奶茶活动");
a.setType(ActivityType.MILK_TEA);
activityRepository.save(a);
}
}
}

View File

@@ -109,6 +109,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/search/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/tags/**").authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN")
@@ -137,8 +138,9 @@ public class SecurityConfig {
boolean publicGet = "GET".equalsIgnoreCase(request.getMethod()) &&
(uri.startsWith("/api/posts") || uri.startsWith("/api/comments") ||
uri.startsWith("/api/categories") || uri.startsWith("/api/tags") ||
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config"));
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
uri.startsWith("/api/activities"));
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);

View File

@@ -0,0 +1,57 @@
package com.openisle.controller;
import com.openisle.model.Activity;
import com.openisle.model.ActivityType;
import com.openisle.model.User;
import com.openisle.service.ActivityService;
import com.openisle.service.UserService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/activities")
@RequiredArgsConstructor
public class ActivityController {
private final ActivityService activityService;
private final UserService userService;
@GetMapping
public List<Activity> list() {
return activityService.list();
}
@GetMapping("/milk-tea")
public MilkTeaInfo milkTea() {
Activity a = activityService.getByType(ActivityType.MILK_TEA);
long count = activityService.countLevel1Users();
if (!a.isEnded() && count > 50) {
activityService.end(a);
}
MilkTeaInfo info = new MilkTeaInfo();
info.setLevel1Count(count);
info.setEnded(a.isEnded());
return info;
}
@PostMapping("/milk-tea/redeem")
public void redeemMilkTea(@RequestBody RedeemRequest req, Authentication auth) {
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
Activity a = activityService.getByType(ActivityType.MILK_TEA);
activityService.redeem(a, user, req.getContact());
}
@Data
private static class MilkTeaInfo {
private long level1Count;
private boolean ended;
}
@Data
private static class RedeemRequest {
private String contact;
}
}

View File

@@ -0,0 +1,48 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
/** Generic activity entity. */
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "activities")
public class Activity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
private String icon;
@Column(name = "start_time", nullable = false)
@CreationTimestamp
private LocalDateTime startTime;
@Column(name = "end_time")
private LocalDateTime endTime;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ActivityType type = ActivityType.NORMAL;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "activity_participants",
joinColumns = @JoinColumn(name = "activity_id"),
inverseJoinColumns = @JoinColumn(name = "user_id"))
private Set<User> participants = new HashSet<>();
@Column(nullable = false)
private boolean ended = false;
}

View File

@@ -0,0 +1,7 @@
package com.openisle.model;
/** Activity type enumeration. */
public enum ActivityType {
NORMAL,
MILK_TEA
}

View File

@@ -29,5 +29,7 @@ public enum NotificationType {
/** A user you subscribe to created a post or comment */
USER_ACTIVITY,
/** A user requested registration approval */
REGISTER_REQUEST
REGISTER_REQUEST,
/** A user redeemed an activity reward */
ACTIVITY_REDEEM
}

View File

@@ -0,0 +1,8 @@
package com.openisle.repository;
import com.openisle.model.Activity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ActivityRepository extends JpaRepository<Activity, Long> {
Activity findByType(com.openisle.model.ActivityType type);
}

View File

@@ -9,4 +9,5 @@ public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
java.util.List<User> findByUsernameContainingIgnoreCase(String keyword);
java.util.List<User> findByRole(com.openisle.model.Role role);
long countByExperienceGreaterThanEqual(int experience);
}

View File

@@ -0,0 +1,49 @@
package com.openisle.service;
import com.openisle.exception.NotFoundException;
import com.openisle.model.*;
import com.openisle.repository.ActivityRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ActivityService {
private final ActivityRepository activityRepository;
private final UserRepository userRepository;
private final LevelService levelService;
private final NotificationService notificationService;
public List<Activity> list() {
return activityRepository.findAll();
}
public Activity getByType(ActivityType type) {
Activity a = activityRepository.findByType(type);
if (a == null) throw new NotFoundException("Activity not found");
return a;
}
public long countLevel1Users() {
int threshold = levelService.nextLevelExp(0);
return userRepository.countByExperienceGreaterThanEqual(threshold);
}
public void end(Activity activity) {
activity.setEnded(true);
activityRepository.save(activity);
}
public void redeem(Activity activity, User user, String contact) {
String content = user.getUsername() + " contact: " + contact;
for (User admin : userRepository.findByRole(Role.ADMIN)) {
notificationService.createNotification(admin, NotificationType.ACTIVITY_REDEEM,
null, null, null, user, null, content);
}
activity.getParticipants().add(user);
activityRepository.save(activity);
}
}