diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml new file mode 100644 index 0000000..60cd1a4 --- /dev/null +++ b/.github/workflows/backend-build.yml @@ -0,0 +1,32 @@ +name: Backend CI + +on: + push: + branches: [ main, TEST ] + pull_request: + branches: [ main, TEST ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'maven' + + - name: Build with Maven + working-directory: backend + run: mvn clean package + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: studyingspace-backend + path: backend/target/*.jar diff --git a/.github/workflows/backend-docker.yml b/.github/workflows/backend-docker.yml new file mode 100644 index 0000000..2305132 --- /dev/null +++ b/.github/workflows/backend-docker.yml @@ -0,0 +1,34 @@ +name: Build and Push Backend to Docker Hub + +on: + push: + branches: [ main ,TEST] + tags: + - 'v*' + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_HUB_USERNAME }}/studyingspace-backend + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./backend + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..12733bf --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,53 @@ +name: Build and Deploy to Server + +on: + push: + branches: [ main ] + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build backend image + run: | + cd backend + docker build -t hetianci/studyingspace-backend:TEST . + + - name: Build frontend image + run: | + cd frontend + docker build -t hetianci/studyingspace-frontend:TEST . + + - name: Save backend image to tar + run: | + docker save -o studyingspace-backend.tar hetianci/studyingspace-backend:TEST + + - name: Save frontend image to tar + run: | + docker save -o studyingspace-frontend.tar hetianci/studyingspace-frontend:TEST + + - name: Copy images to server + uses: appleboy/scp-action@v0.1.4 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_PRIVATE_KEY }} + source: "studyingspace-backend.tar,studyingspace-frontend.tar" + target: "my_project/StudyingSpace/" + + - name: Deploy to server + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_PRIVATE_KEY }} + script: | + cd my_project/StudyingSpace + git pull + docker load -i studyingspace-backend.tar + docker load -i studyingspace-frontend.tar + docker compose up -d + rm -f studyingspace-backend.tar studyingspace-frontend.tar diff --git a/.github/workflows/frontend-build.yml b/.github/workflows/frontend-build.yml new file mode 100644 index 0000000..f152f29 --- /dev/null +++ b/.github/workflows/frontend-build.yml @@ -0,0 +1,27 @@ +name: Frontend CI + +on: + push: + branches: [ main ,TEST] + pull_request: + branches: [ main ,TEST] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + working-directory: ./frontend + run: npm install + + - name: Build + working-directory: ./frontend + run: npm run build diff --git a/.github/workflows/frontend-docker.yml b/.github/workflows/frontend-docker.yml new file mode 100644 index 0000000..da02c17 --- /dev/null +++ b/.github/workflows/frontend-docker.yml @@ -0,0 +1,34 @@ +name: Build and Push Frontend to Docker Hub + +on: + push: + branches: [ main ,TEST] + tags: + - 'v*' + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_HUB_USERNAME }}/studyingspace-frontend + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./frontend + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/README.md b/README.md index 022a601..402ebfa 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,25 @@ # StudyingSpace - 个人学习空间 ## 📖 项目简介 + 个人学习空间,帮助学习者记录学习过程、管理学习笔记,打造个性化的学习环境。 ## ✨ 功能特性 ### 🔐 用户系统 + - [ ] 用户注册:创建个人账号 - [ ] 用户登录:安全登录个人空间 ### 📅 每日打卡 + - [ ] 每日学习打卡记录 - [ ] 打卡日历展示 - [ ] 连续打卡统计 - [ ] 学习时长记录 ### 📝 笔记管理 + - [ ] 笔记导入:支持从多种格式导入笔记 - [ ] 笔记导出:将笔记导出为常见格式 - [ ] 笔记分类与整理 @@ -23,15 +27,18 @@ ## 🛠️ 技术栈 ### 前端 + - **框架**: Vue 3 - **构建工具**: Vite ### 后端 + - **框架**: Spring Boot - **语言**: Java - **数据库**: PostgreSQL ## 📁 项目结构 + 项目采用前后端分离架构,代码分别存放于两个目录: ``` @@ -43,21 +50,86 @@ StudyingSpace/ ## 🚀 快速开始 ### 环境要求 + - Node.js (前端) - JDK 21+ (后端) - PostgreSQL ### 安装步骤 - ### 运行项目 +## 🐳 Docker 快速部署 -## 📖 使用文档 +你可以直接使用 Docker 一键部署整个应用,无需本地配置 Java/Node.js/PostgreSQL。 + +### 前置要求 + +- 安装 [Docker Desktop](https://www.docker.com/products/docker-desktop/) +- Docker Desktop 已启动 + +### 部署步骤 + +1. 克隆项目到本地: + +```bash +git clone https://github.com/hetianci/StudyingSpace.git +cd StudyingSpace +``` + +1. 直接运行: + +```bash +docker compose up -d +``` +项目中已经包含 `docker-compose.yml`,会自动从 Docker Hub 拉取镜像并启动。 + +### 访问应用 + +启动成功后,打开浏览器访问: + +- 后端 API: +- Swagger API 文档: + +### 常用命令 + +```bash +# 查看运行状态 +docker compose ps + +# 查看后端日志 +docker compose logs backend + +# 实时查看日志 +docker compose logs -f backend + +# 停止所有容器 +docker compose down + +# 停止但保留数据 +docker compose stop + +# 重新启动 +docker compose start + +# 更新到最新镜像 +docker compose pull +docker compose up -d +``` + +### 注意事项 + +- 如果本地 5432 端口已被占用,需要先停止本地 PostgreSQL 服务,或者修改 `docker-compose.yml` 中的端口映射 +- 数据库数据会保存在 Docker 卷 `postgres-data` 中,即使删除容器数据也不会丢失 +- 首次启动会自动拉取镜像,需要等待一段时间 + +## 📖 使用文档 ### 用户注册登录 + ### 每日打卡使用 + ### 笔记导入导出 ## 📈 开发计划 @@ -69,5 +141,4 @@ StudyingSpace/ ## 🤝 贡献指南 - ## 📄 许可证 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..2d04aa0 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM maven:3.9.6-eclipse-temurin-21-alpine AS builder + +WORKDIR /app + +COPY pom.xml . +RUN mvn dependency:go-offline -B + +COPY src ./src +RUN mvn clean package -DskipTests + +FROM eclipse-temurin:21-jre-alpine + +WORKDIR /app + +COPY --from=builder /app/target/*.jar app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend/README.md b/backend/README.md index bfc8831..b8b32dc 100644 --- a/backend/README.md +++ b/backend/README.md @@ -132,8 +132,8 @@ Authorization: Bearer ``` ## 📝 开发计划 - -- [ ] 用户认证模块 +- [x] 登录注册功能 +- [x] 用户认证模块 - [ ] 每日打卡模块 - [ ] 笔记导入导出模块 diff --git a/backend/src/main/java/com/studyingspace/config/SecurityConfig.java b/backend/src/main/java/com/studyingspace/config/SecurityConfig.java index d371069..5565bb3 100644 --- a/backend/src/main/java/com/studyingspace/config/SecurityConfig.java +++ b/backend/src/main/java/com/studyingspace/config/SecurityConfig.java @@ -1,11 +1,13 @@ package com.studyingspace.config; +import com.studyingspace.security.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -16,6 +18,12 @@ import java.util.Arrays; @EnableWebSecurity public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http @@ -24,7 +32,6 @@ public class SecurityConfig { .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers( - "/api/**", "/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", @@ -32,7 +39,8 @@ public class SecurityConfig { "/webjars/**" ).permitAll() .anyRequest().authenticated() - ); + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @@ -40,7 +48,7 @@ public class SecurityConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(Arrays.asList("http://localhost:5173")); + configuration.addAllowedOriginPattern("*"); configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(Arrays.asList("*")); configuration.setAllowCredentials(true); diff --git a/backend/src/main/java/com/studyingspace/controller/CheckInController.java b/backend/src/main/java/com/studyingspace/controller/CheckInController.java new file mode 100644 index 0000000..8974ed8 --- /dev/null +++ b/backend/src/main/java/com/studyingspace/controller/CheckInController.java @@ -0,0 +1,111 @@ +package com.studyingspace.controller; + +import com.studyingspace.common.Result; +import com.studyingspace.entity.CheckIn; +import com.studyingspace.entity.User; +import com.studyingspace.repository.CheckInRepository; +import com.studyingspace.repository.UserRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.*; + +@RestController +@RequestMapping("/api/checkin") +@Tag(name = "每日打卡", description = "每日学习打卡功能") +@RequiredArgsConstructor +public class CheckInController { + + private final CheckInRepository checkInRepository; + private final UserRepository userRepository; + + @GetMapping + @Operation(summary = "获取打卡记录", description = "获取当前用户的所有打卡记录,按时间倒序排列") + public Result> getCheckInList(Authentication authentication) { + Long userId = getCurrentUserId(authentication); + List checkInList = checkInRepository.findByUserIdOrderByCheckinDateDesc(userId); + return Result.success(checkInList); + } + + @PostMapping + @Operation(summary = "每日打卡", description = "今天打卡学习") + public Result dailyCheckIn( + Authentication authentication, + @RequestParam(required = false) String remark + ) { + Long userId = getCurrentUserId(authentication); + LocalDate today = LocalDate.now(); + + if (checkInRepository.findByUserIdAndCheckinDate(userId, today).isPresent()) { + return Result.error("今天已经打过卡了"); + } + + CheckIn checkIn = new CheckIn(); + checkIn.setUserId(userId); + checkIn.setCheckinDate(today); + checkIn.setRemark(remark); + checkInRepository.save(checkIn); + + return Result.success("打卡成功"); + } + + @GetMapping("/calendar") + @Operation(summary = "获取打卡日历数据", description = "获取当前用户所有打卡日期,用于日历展示") + public Result> getCheckinCalendar(Authentication authentication) { + Long userId = getCurrentUserId(authentication); + List dates = checkInRepository.findAllCheckinDatesByUserId(userId); + List result = dates.stream() + .map(LocalDate::toString) + .toList(); + return Result.success(result); + } + + @GetMapping("/stats") + @Operation(summary = "获取打卡统计", description = "获取连续打卡天数、总打卡天数等统计信息") + public Result> getStats(Authentication authentication) { + Long userId = getCurrentUserId(authentication); + long totalDays = checkInRepository.countByUserId(userId); + List checkIns = checkInRepository.findByUserIdOrderByCheckinDateDesc(userId); + + int consecutiveDays = calculateConsecutiveDays(checkIns); + + Map stats = new HashMap<>(); + stats.put("totalDays", totalDays); + stats.put("consecutiveDays", consecutiveDays); + + return Result.success(stats); + } + + private Long getCurrentUserId(Authentication authentication) { + String username = authentication.getName(); + Optional userOpt = userRepository.findByUsername(username); + if (userOpt.isPresent()) { + return userOpt.get().getId(); + } + return 1L; + } + + private int calculateConsecutiveDays(List checkIns) { + if (checkIns.isEmpty()) { + return 0; + } + + int consecutive = 0; + LocalDate current = LocalDate.now(); + + for (CheckIn checkIn : checkIns) { + if (checkIn.getCheckinDate().equals(current)) { + consecutive++; + current = current.minusDays(1); + } else { + break; + } + } + + return consecutive; + } +} diff --git a/backend/src/main/java/com/studyingspace/controller/FriendController.java b/backend/src/main/java/com/studyingspace/controller/FriendController.java new file mode 100644 index 0000000..ed3591b --- /dev/null +++ b/backend/src/main/java/com/studyingspace/controller/FriendController.java @@ -0,0 +1,166 @@ +package com.studyingspace.controller; + +import com.studyingspace.common.Result; +import com.studyingspace.entity.Friend; +import com.studyingspace.entity.FriendRequest; +import com.studyingspace.entity.User; +import com.studyingspace.repository.CheckInRepository; +import com.studyingspace.repository.FriendRepository; +import com.studyingspace.repository.FriendRequestRepository; +import com.studyingspace.repository.UserRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@RestController +@RequestMapping("/api/friend") +@Tag(name = "好友关系", description = "好友申请和好友列表功能") +@RequiredArgsConstructor +public class FriendController { + + private final FriendRepository friendRepository; + private final FriendRequestRepository friendRequestRepository; + private final UserRepository userRepository; + private final CheckInRepository checkInRepository; + + @PostMapping("/request") + @Operation(summary = "发送好友申请", description = "向指定用户发送好友申请") + public Result sendFriendRequest( + Authentication authentication, + @RequestParam String username + ) { + Long fromUserId = getCurrentUserId(authentication); + + Optional toUserOpt = userRepository.findByUsername(username); + if (toUserOpt.isEmpty()) { + return Result.error("用户不存在"); + } + + User toUser = toUserOpt.get(); + Long toUserId = toUser.getId(); + if (toUserId.equals(fromUserId)) { + return Result.error("不能添加自己为好友"); + } + + if (friendRepository.existsByUserIdAndFriendId(fromUserId, toUserId)) { + return Result.error("该用户已经是你的好友"); + } + + if (friendRequestRepository.existsByFromUserIdAndToUserIdAndStatus( + fromUserId, toUserId, FriendRequest.RequestStatus.PENDING)) { + return Result.error("已经发送过申请,请等待对方处理"); + } + + FriendRequest request = new FriendRequest(); + request.setFromUserId(fromUserId); + request.setToUserId(toUserId); + friendRequestRepository.save(request); + + return Result.success("好友申请已发送"); + } + + @PostMapping("/handle") + @Operation(summary = "处理好友申请", description = "同意或拒绝好友申请") + public Result handleFriendRequest( + Authentication authentication, + @RequestParam Long requestId, + @RequestParam boolean accept + ) { + Long toUserId = getCurrentUserId(authentication); + + Optional requestOpt = friendRequestRepository.findByIdAndToUserId(requestId, toUserId); + if (requestOpt.isEmpty()) { + return Result.error("申请不存在"); + } + + FriendRequest request = requestOpt.get(); + if (request.getStatus() != FriendRequest.RequestStatus.PENDING) { + return Result.error("该申请已经处理过了"); + } + + request.setHandleTime(LocalDateTime.now()); + if (accept) { + request.setStatus(FriendRequest.RequestStatus.ACCEPTED); + friendRequestRepository.save(request); + + Friend friend1 = new Friend(); + friend1.setUserId(toUserId); + friend1.setFriendId(request.getFromUserId()); + friendRepository.save(friend1); + + Friend friend2 = new Friend(); + friend2.setUserId(request.getFromUserId()); + friend2.setFriendId(toUserId); + friendRepository.save(friend2); + + return Result.success("已同意好友申请"); + } else { + request.setStatus(FriendRequest.RequestStatus.REJECTED); + friendRequestRepository.save(request); + return Result.success("已拒绝好友申请"); + } + } + + @GetMapping("/requests/pending") + @Operation(summary = "获取待处理的好友申请", description = "获取当前用户收到的待处理好友申请列表") + public Result>> getPendingRequests(Authentication authentication) { + Long userId = getCurrentUserId(authentication); + List requests = friendRequestRepository.findByToUserIdAndStatus( + userId, FriendRequest.RequestStatus.PENDING); + + List> result = new ArrayList<>(); + for (FriendRequest request : requests) { + User fromUser = userRepository.findById(request.getFromUserId()).orElse(null); + if (fromUser != null) { + Map item = new HashMap<>(); + item.put("id", request.getId()); + item.put("fromUserId", request.getFromUserId()); + item.put("fromUsername", fromUser.getUsername()); + item.put("createTime", request.getCreateTime().toString()); + result.add(item); + } + } + + return Result.success(result); + } + + @GetMapping("/list") + @Operation(summary = "获取好友列表", description = "获取当前用户的所有好友,包含好友打卡天数统计") + public Result>> getFriendList(Authentication authentication) { + Long userId = getCurrentUserId(authentication); + List friends = friendRepository.findByUserId(userId); + + List> result = new ArrayList<>(); + for (Friend friend : friends) { + User friendUser = userRepository.findById(friend.getFriendId()).orElse(null); + if (friendUser != null) { + Map item = new HashMap<>(); + item.put("id", friendUser.getId()); + item.put("username", friendUser.getUsername()); + long totalDays = checkInRepository.countByUserId(friendUser.getId()); + item.put("totalCheckinDays", totalDays); + result.add(item); + } + } + + return Result.success(result); + } + + private Long getCurrentUserId(Authentication authentication) { + String username = authentication.getName(); + Optional userOpt = userRepository.findByUsername(username); + if (userOpt.isPresent()) { + return userOpt.get().getId(); + } + return 1L; + } +} diff --git a/backend/src/main/java/com/studyingspace/entity/CheckIn.java b/backend/src/main/java/com/studyingspace/entity/CheckIn.java new file mode 100644 index 0000000..6d89f3b --- /dev/null +++ b/backend/src/main/java/com/studyingspace/entity/CheckIn.java @@ -0,0 +1,31 @@ +package com.studyingspace.entity; + +import jakarta.persistence.*; +import lombok.Data; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@Entity +@Table(name = "checkin") +public class CheckIn { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false) + private LocalDate checkinDate; + + private String remark; + + private LocalDateTime createTime; + + @PrePersist + protected void onCreate() { + createTime = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/studyingspace/entity/Friend.java b/backend/src/main/java/com/studyingspace/entity/Friend.java new file mode 100644 index 0000000..8e3d0fa --- /dev/null +++ b/backend/src/main/java/com/studyingspace/entity/Friend.java @@ -0,0 +1,28 @@ +package com.studyingspace.entity; + +import jakarta.persistence.*; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Entity +@Table(name = "friends") +public class Friend { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false) + private Long friendId; + + private LocalDateTime createTime; + + @PrePersist + protected void onCreate() { + createTime = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/studyingspace/entity/FriendRequest.java b/backend/src/main/java/com/studyingspace/entity/FriendRequest.java new file mode 100644 index 0000000..7d05bca --- /dev/null +++ b/backend/src/main/java/com/studyingspace/entity/FriendRequest.java @@ -0,0 +1,39 @@ +package com.studyingspace.entity; + +import jakarta.persistence.*; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Entity +@Table(name = "friend_requests") +public class FriendRequest { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long fromUserId; + + @Column(nullable = false) + private Long toUserId; + + @Enumerated(EnumType.STRING) + private RequestStatus status; + + private LocalDateTime createTime; + private LocalDateTime handleTime; + + public enum RequestStatus { + PENDING, + ACCEPTED, + REJECTED + } + + @PrePersist + protected void onCreate() { + createTime = LocalDateTime.now(); + status = RequestStatus.PENDING; + } +} diff --git a/backend/src/main/java/com/studyingspace/repository/CheckInRepository.java b/backend/src/main/java/com/studyingspace/repository/CheckInRepository.java new file mode 100644 index 0000000..37733a4 --- /dev/null +++ b/backend/src/main/java/com/studyingspace/repository/CheckInRepository.java @@ -0,0 +1,22 @@ +package com.studyingspace.repository; + +import com.studyingspace.entity.CheckIn; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface CheckInRepository extends JpaRepository { + + Optional findByUserIdAndCheckinDate(Long userId, LocalDate checkinDate); + + List findByUserIdOrderByCheckinDateDesc(Long userId); + + @Query("SELECT c.checkinDate FROM CheckIn c WHERE c.userId = :userId") + List findAllCheckinDatesByUserId(@Param("userId") Long userId); + + long countByUserId(Long userId); +} diff --git a/backend/src/main/java/com/studyingspace/repository/FriendRepository.java b/backend/src/main/java/com/studyingspace/repository/FriendRepository.java new file mode 100644 index 0000000..cc279a0 --- /dev/null +++ b/backend/src/main/java/com/studyingspace/repository/FriendRepository.java @@ -0,0 +1,20 @@ +package com.studyingspace.repository; + +import com.studyingspace.entity.Friend; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface FriendRepository extends JpaRepository { + List findByUserId(Long userId); + + Optional findByUserIdAndFriendId(Long userId, Long friendId); + + @Query("SELECT f.friendId FROM Friend f WHERE f.userId = :userId") + List findFriendIdsByUserId(@Param("userId") Long userId); + + boolean existsByUserIdAndFriendId(Long userId, Long friendId); +} diff --git a/backend/src/main/java/com/studyingspace/repository/FriendRequestRepository.java b/backend/src/main/java/com/studyingspace/repository/FriendRequestRepository.java new file mode 100644 index 0000000..31aa2cd --- /dev/null +++ b/backend/src/main/java/com/studyingspace/repository/FriendRequestRepository.java @@ -0,0 +1,19 @@ +package com.studyingspace.repository; + +import com.studyingspace.entity.FriendRequest; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface FriendRequestRepository extends JpaRepository { + List findByToUserIdAndStatus(Long toUserId, FriendRequest.RequestStatus status); + + List findByFromUserIdAndStatus(Long fromUserId, FriendRequest.RequestStatus status); + + boolean existsByFromUserIdAndToUserIdAndStatus(Long fromUserId, Long toUserId, FriendRequest.RequestStatus status); + + Optional findByIdAndToUserId(Long id, Long toUserId); +} diff --git a/backend/src/main/java/com/studyingspace/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/studyingspace/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..e377b17 --- /dev/null +++ b/backend/src/main/java/com/studyingspace/security/JwtAuthenticationFilter.java @@ -0,0 +1,55 @@ +package com.studyingspace.security; + +import com.studyingspace.utils.JwtUtils; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetails; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class); + + @Autowired + private JwtUtils jwtUtils; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + String jwt = getJwtFromRequest(request); + + if (jwt != null && !jwt.isEmpty() && jwtUtils.validateToken(jwt)) { + String username = jwtUtils.getUsernameFromToken(jwt); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(username, null, null); + authentication.setDetails(new WebAuthenticationDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception e) { + logger.error("Cannot set user authentication: {}", e.getMessage()); + } + + filterChain.doFilter(request, response); + } + + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index fa8b5f8..4046a07 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -7,9 +7,9 @@ spring: #数据库账号密码 datasource: driver-class-name: org.postgresql.Driver - url: jdbc:postgresql://localhost:5432/StudyingSystem - username: postgres - password: 123456 + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/StudyingSystem} + username: ${SPRING_DATASOURCE_USERNAME:postgres} + password: ${SPRING_DATASOURCE_PASSWORD:123456} jpa: hibernate: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..06067e8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + postgres: + image: postgres:15-alpine + container_name: studyingspace-postgres + environment: + POSTGRES_DB: studyingspace + POSTGRES_USER: postgres + POSTGRES_PASSWORD: 123456 + volumes: + - postgres-data:/var/lib/postgresql/data + ports: + - "5432:5432" + restart: unless-stopped + + backend: + image: hetianci/studyingspace-backend:TEST + container_name: studyingspace-backend + ports: + - "8081:8080" + depends_on: + - postgres + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/studyingspace + SPRING_DATASOURCE_USERNAME: postgres + SPRING_DATASOURCE_PASSWORD: 123456 + restart: unless-stopped + + frontend: + image: hetianci/studyingspace-frontend:TEST + container_name: studyingspace-frontend + ports: + - "8000:80" + depends_on: + - backend + restart: unless-stopped + +volumes: + postgres-data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..8174735 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:21-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html index 8f2cb37..dd744f2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,10 @@
+ + + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..8142de7 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://backend:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} diff --git a/frontend/package.json b/frontend/package.json index 8ef59a5..3cbc823 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,9 @@ "vue-router": "^4.2.5", "pinia": "^2.1.7", "axios": "^1.6.2", - "element-plus": "^2.4.4" + "element-plus": "^2.4.4", + "roughjs": "^4.5.0", + "gsap": "^3.12.0" }, "devDependencies": { "@vitejs/plugin-vue": "^4.5.2", diff --git a/frontend/src/api/checkin.js b/frontend/src/api/checkin.js new file mode 100644 index 0000000..66412c2 --- /dev/null +++ b/frontend/src/api/checkin.js @@ -0,0 +1,30 @@ +import request from '@/utils/request' + +export function getCheckinList() { + return request({ + url: '/checkin', + method: 'get' + }) +} + +export function dailyCheckin(remark) { + return request({ + url: '/checkin', + method: 'post', + params: { remark } + }) +} + +export function getCheckinCalendar() { + return request({ + url: '/checkin/calendar', + method: 'get' + }) +} + +export function getCheckinStats() { + return request({ + url: '/checkin/stats', + method: 'get' + }) +} diff --git a/frontend/src/api/friend.js b/frontend/src/api/friend.js new file mode 100644 index 0000000..c81b8d8 --- /dev/null +++ b/frontend/src/api/friend.js @@ -0,0 +1,31 @@ +import request from '@/utils/request' + +export function sendFriendRequest(username) { + return request({ + url: '/friend/request', + method: 'post', + params: { username } + }) +} + +export function handleFriendRequest(requestId, accept) { + return request({ + url: '/friend/handle', + method: 'post', + params: { requestId, accept } + }) +} + +export function getPendingFriendRequests() { + return request({ + url: '/friend/requests/pending', + method: 'get' + }) +} + +export function getFriendList() { + return request({ + url: '/friend/list', + method: 'get' + }) +} diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 54a5cdc..3a6e05a 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -15,6 +15,16 @@ const routes = [ path: '/', name: 'home', component: () => import('../views/home/Home.vue') + }, + { + path: '/checkin', + name: 'checkin', + component: () => import('../views/checkin/Checkin.vue') + }, + { + path: '/profile', + name: 'profile', + component: () => import('../views/profile/Profile.vue') } ] diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 9de88d5..e0274f3 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -9,5 +9,56 @@ body, #app { width: 100%; height: 100%; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', + 'Microsoft YaHei', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + color: #333; + background-color: #f7f8fa; +} + +:root { + --primary-green: #4caf50; + --primary-green-light: #e8f5e9; + --primary-blue: #42a5f5; + --primary-blue-light: #e3f2fd; + --primary-orange: #f5a623; + --gradient-orange: linear-gradient(135deg, #f7c948 0%, #f5a623 100%); + --gradient-orange-light: linear-gradient(135deg, #fbe9a0 0%, #f5c842 100%); + --bg-card: #ffffff; + --bg-page: #f7f8fa; + --text-primary: #1a1a1a; + --text-secondary: #666666; + --text-placeholder: #c0c0c0; + --border-light: #f0f0f0; + --shadow-card: 0 2px 12px rgba(0, 0, 0, 0.06); + --shadow-soft: 0 1px 6px rgba(0, 0, 0, 0.04); + --radius-lg: 16px; + --radius-md: 12px; + --radius-sm: 8px; + --radius-full: 9999px; +} + +input, button, textarea { + font-family: inherit; +} + +a { + text-decoration: none; + color: inherit; +} + +button { + border: none; + cursor: pointer; + background: none; +} + +ul, ol { + list-style: none; +} + +::-webkit-scrollbar { + width: 0; + height: 0; } diff --git a/frontend/src/views/checkin/Checkin.vue b/frontend/src/views/checkin/Checkin.vue new file mode 100644 index 0000000..a884844 --- /dev/null +++ b/frontend/src/views/checkin/Checkin.vue @@ -0,0 +1,598 @@ + + +