diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..0da67df --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,87 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "main", "development", "feature/*" ] + pull_request: + branches: [ "main", "development", "feature/*" ] + schedule: + - cron: '40 16 * * 4' + +jobs: + analyze: + name: Analyze + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners + # Consider using larger runners for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'java-kotlin' ] + # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] + # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v3 + with: + distribution: liberica + java-version: 21 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..4a16b68 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,112 @@ +name: Java CI/CD with Maven and Docker for Dev + +on: + push: + branches: + - 'development' + - 'main' +env: + NA_DB_URL: 'jdbc:mariadb://localhost:3306/NA' + NA_DB_USER: 'NA' + NA_DB_PASSWORD: 'NA' + REGISTRY_IMAGE: keke125/ntou-auction-java + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 + + steps: + - uses: actions/checkout@v3 + - name: Docker meta + id: meta + uses: docker/metadata-action@v4 + with: + # list of Docker images to use as base name for tags + images: | + keke125/ntou-auction-java + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + - name: Start MariaDB + uses: getong/mariadb-action@v1.1 + with: + mysql database: 'NA' + mysql user: 'NA' + mysql password: 'NA' + - name: Build with Maven + run: mvn -B package --file pom.xml + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build and push by digest + id: build + uses: docker/build-push-action@v4 + with: + context: . + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + - name: Upload digest + uses: actions/upload-artifact@v3 + with: + name: digests + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + merge: + runs-on: ubuntu-latest + needs: + - build + steps: + - + name: Download digests + uses: actions/download-artifact@v3 + with: + name: digests + path: /tmp/digests + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - + name: Docker meta + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY_IMAGE }} + - + name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - + name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) + - + name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..40be12d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM eclipse-temurin:21-jre + +COPY target/*.jar /app/na.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "/app/na.jar"] diff --git a/pom.xml b/pom.xml index 0d5aa0d..f5eabeb 100644 --- a/pom.xml +++ b/pom.xml @@ -61,6 +61,41 @@ bcprov-jdk18on 1.76 + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + + org.springframework.boot + spring-boot-starter-websocket + + + org.springframework + spring-messaging + + + org.springframework.boot + spring-boot-starter-mail + + + + jakarta.mail + jakarta.mail-api + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + org.springframework.boot spring-boot-starter-test diff --git a/src/main/java/ntou/auction/spring/NtouAuctionJavaApplication.java b/src/main/java/ntou/auction/spring/NtouAuctionJavaApplication.java index ed9e574..4bedc9e 100644 --- a/src/main/java/ntou/auction/spring/NtouAuctionJavaApplication.java +++ b/src/main/java/ntou/auction/spring/NtouAuctionJavaApplication.java @@ -1,15 +1,17 @@ package ntou.auction.spring; -import ntou.auction.spring.data.service.UserRepository; +import ntou.auction.spring.account.repository.UserRepository; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.sql.init.SqlDataSourceScriptDatabaseInitializer; import org.springframework.boot.autoconfigure.sql.init.SqlInitializationProperties; import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; import javax.sql.DataSource; @SpringBootApplication +@EnableScheduling public class NtouAuctionJavaApplication { public static void main(String[] args) { diff --git a/src/main/java/ntou/auction/spring/account/config/JWTRequestFilter.java b/src/main/java/ntou/auction/spring/account/config/JWTRequestFilter.java new file mode 100644 index 0000000..0aeaf71 --- /dev/null +++ b/src/main/java/ntou/auction/spring/account/config/JWTRequestFilter.java @@ -0,0 +1,55 @@ +package ntou.auction.spring.account.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import ntou.auction.spring.account.service.JWTService; +import ntou.auction.spring.account.service.UserDetailsServiceImpl; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JWTRequestFilter extends OncePerRequestFilter { + + private final UserDetailsServiceImpl userDetailsServiceImpl; + + public JWTRequestFilter(UserDetailsServiceImpl userDetailsServiceImpl) { + this.userDetailsServiceImpl = userDetailsServiceImpl; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + // look for AUTHORIZATION + final String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (header == null || !header.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + final String token = header.substring(7); + final String username = JWTService.validateTokenAndGetUsername(token); + if (username == null) { + // validation failed or token expired + filterChain.doFilter(request, response); + return; + } + + // set user details on spring security context + final UserDetails userDetails = userDetailsServiceImpl.loadUserByUsername(username); + final UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + + // continue with authenticated user + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/ntou/auction/spring/account/config/SecurityConfiguration.java b/src/main/java/ntou/auction/spring/account/config/SecurityConfiguration.java new file mode 100644 index 0000000..5af12bb --- /dev/null +++ b/src/main/java/ntou/auction/spring/account/config/SecurityConfiguration.java @@ -0,0 +1,94 @@ +package ntou.auction.spring.account.config; + +import ntou.auction.spring.util.AppConfig; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.DelegatingPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsUtils; + +import java.util.HashMap; +import java.util.Map; + +@EnableWebSecurity +@Configuration +public class SecurityConfiguration { + private final AppConfig appConfig; + private final JWTRequestFilter jwtRequestFilter; + + public SecurityConfiguration(AppConfig appConfig, JWTRequestFilter jwtRequestFilter) { + this.appConfig = appConfig; + this.jwtRequestFilter = jwtRequestFilter; + } + + @Bean + public PasswordEncoder passwordEncoder() { + // the following value can be changed to meet your need + // pbkdf2 + String secret = ""; + // byte + int pbkdf2SaltLength = 16; + int pbkdf2Iterations = 310000; + Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm secretKeyFactoryAlgorithm = + Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA256; + // argon2 + int argon2SaltLength = 16; + int hashLength = 32; + int parallelism = 1; + int memory = 1 << 14; + int argon2Iterations = 2; + Map encoders = new HashMap<>(); + encoders.put("BCrypt", new BCryptPasswordEncoder()); + encoders.put("pbkdf2", new Pbkdf2PasswordEncoder(secret, + pbkdf2SaltLength, pbkdf2Iterations, secretKeyFactoryAlgorithm)); + encoders.put("pbkdf2@SpringSecurity_v5_8", + Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8()); + encoders.put("argon2", new Argon2PasswordEncoder(argon2SaltLength, + hashLength, parallelism, memory, argon2Iterations)); + encoders.put("argon2@SpringSecurity_v5_8", + Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()); + return new DelegatingPasswordEncoder(appConfig.getIdForEncode(), + encoders); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers(CorsUtils::isPreFlightRequest).permitAll() + .requestMatchers(HttpMethod.POST, "/api/v1/auth/log-in").permitAll() + .requestMatchers(HttpMethod.POST, "/api/v1/auth/sign-up").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/product/products").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/product/product/name/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/product/product/classification/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/product/product/{ID}").permitAll() + .requestMatchers("/ws/**").permitAll() + .requestMatchers("/sockjs/**").permitAll() + .anyRequest().authenticated()) + .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class) + .sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ); + return http.build(); + } + + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } +} diff --git a/src/main/java/ntou/auction/spring/account/controller/AuthController.java b/src/main/java/ntou/auction/spring/account/controller/AuthController.java new file mode 100644 index 0000000..ffef66b --- /dev/null +++ b/src/main/java/ntou/auction/spring/account/controller/AuthController.java @@ -0,0 +1,75 @@ +package ntou.auction.spring.account.controller; + +import jakarta.validation.Valid; +import ntou.auction.spring.account.entity.Role; +import ntou.auction.spring.account.entity.User; +import ntou.auction.spring.account.service.UserService; +import ntou.auction.spring.account.request.AuthRequest; +import ntou.auction.spring.account.service.JWTService; +import ntou.auction.spring.account.request.SignupRequest; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +@RestController +@RequestMapping(value = "/api/v1/auth", produces = MediaType.APPLICATION_JSON_VALUE) +@CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true") +public class AuthController { + + private final UserService userService; + + public AuthController(UserService userService) { + this.userService = userService; + } + + + @PostMapping("/log-in") + public ResponseEntity> issueToken(@Valid @RequestBody AuthRequest request) { + String token = JWTService.generateJWT(request); + Map response = Collections.singletonMap("token", token); + return ResponseEntity.ok(response); + } + + @PostMapping("/sign-up") + public ResponseEntity> signUp(@Valid @RequestBody SignupRequest request) { + String successMessage = "成功註冊"; + String usernameDuplicatedMessage = "輸入的帳號已被其他人使用,請使用別的帳號註冊!"; + String emailDuplicatedMessage = "輸入的電子信箱已被其他人使用,請使用別的信箱註冊!"; + String emailAndUsernameDuplicatedMessage = "輸入的帳號及電子信箱皆已被其他人使用,請重新註冊!"; + String passwordMessage = "至少需要8位密碼,且不超過128位"; + Map successResponse = Collections.singletonMap("message", successMessage); + Map usernameDuplicatedResponse = Collections.singletonMap("message", usernameDuplicatedMessage); + Map emailDuplicatedResponse = Collections.singletonMap("message", emailDuplicatedMessage); + Map emailAndUsernameDuplicatedResponse = Collections.singletonMap("message", emailAndUsernameDuplicatedMessage); + Map passwordResponse = Collections.singletonMap("message", passwordMessage); + User newUser = new User(); + HashSet roles = new HashSet<>(); + roles.add(Role.USER); + newUser.setEmail(request.getEmail()); + newUser.setUsername(request.getUsername()); + newUser.setName(request.getName()); + if(request.getPassword().length() < 8 || request.getPassword().length() > 128){ + return ResponseEntity.badRequest().body(passwordResponse); + } + newUser.setHashedPassword(userService.getPasswordEncoder().encode(request.getPassword())); + newUser.setRoles(roles); + newUser.setEnabled(true); + newUser.setAccountNonExpired(true); + newUser.setAccountNonLocked(true); + newUser.setCredentialsNonExpired(true); + if(!userService.isUsernameNonExist(request.getUsername())){ + if(!userService.isEmailNonExist(request.getEmail())){ + return ResponseEntity.badRequest().body(emailAndUsernameDuplicatedResponse); + } + return ResponseEntity.badRequest().body(usernameDuplicatedResponse); + } + if(!userService.isEmailNonExist(request.getEmail())){ + return ResponseEntity.badRequest().body(emailDuplicatedResponse); + } + userService.store(newUser); + return ResponseEntity.ok(successResponse); + } + +} diff --git a/src/main/java/ntou/auction/spring/account/controller/UserController.java b/src/main/java/ntou/auction/spring/account/controller/UserController.java new file mode 100644 index 0000000..237606e --- /dev/null +++ b/src/main/java/ntou/auction/spring/account/controller/UserController.java @@ -0,0 +1,119 @@ +package ntou.auction.spring.account.controller; + +import jakarta.validation.Valid; +import ntou.auction.spring.account.request.FavoriteRequest; +import ntou.auction.spring.product.entity.Product; +import ntou.auction.spring.account.entity.User; +import ntou.auction.spring.product.service.ProductService; +import ntou.auction.spring.account.response.UserIdentity; +import ntou.auction.spring.account.service.UserService; +import ntou.auction.spring.account.request.SignupRequest; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +@RestController +@RequestMapping(value = "/api/v1/account", produces = MediaType.APPLICATION_JSON_VALUE) +@CrossOrigin(origins = "http://localhost:3000") +public class UserController { + private final UserService userService; + + private final UserIdentity userIdentity; + private final ProductService productService; + + public UserController(UserService userService, UserIdentity userIdentity, ProductService productService) { + this.userService = userService; + this.userIdentity = userIdentity; + this.productService = productService; + } + + // for admin usage + /* + @GetMapping("/users/{username}") + @ResponseBody + User getByUser(@PathVariable String username) { + return userService.findByUsername(username); + } + */ + + @GetMapping("/users") + @ResponseBody + User getUserProfileByJWT() { + return userService.findByUsername(userIdentity.getUsername()); + } + + @GetMapping("/favorite") + @ResponseBody + List getFavorite() { + Set favoriteProductIds = userService.getFavoriteProducts(userService.findByUsername(userIdentity.getUsername()).getId()); + List favoriteProducts = new ArrayList<>(); + for (Long favoriteProductId : favoriteProductIds) { + favoriteProducts.add(productService.getID(favoriteProductId)); + } + return favoriteProducts; + } + + @PostMapping("/favorite") + @ResponseBody + ResponseEntity> addFavorite(@Valid @RequestBody FavoriteRequest request) { + Map duplicatedProduct = Collections.singletonMap("message", "商品已在我的最愛"); + Map productNotFound = Collections.singletonMap("message", "找不到商品"); + Map success = Collections.singletonMap("message", "成功將商品加入我的最愛"); + if (productService.getID(request.getProductId()) == null) { + return ResponseEntity.badRequest().body(productNotFound); + } + if (userService.addFavoriteProducts(userService.findByUsername(userIdentity.getUsername()).getId(), request.getProductId())) { + return ResponseEntity.ok(success); + } else { + return ResponseEntity.badRequest().body(duplicatedProduct); + } + } + + @DeleteMapping("/favorite") + @ResponseBody + ResponseEntity> removeFavorite(@Valid @RequestBody FavoriteRequest request) { + Map failed = Collections.singletonMap("message", "因為商品不存在,無法將商品從我的最愛移除"); + Map success = Collections.singletonMap("message", "成功將商品從我的最愛移除"); + if (userService.removeFavoriteProducts(userService.findByUsername(userIdentity.getUsername()).getId(), request.getProductId())) { + return ResponseEntity.ok(success); + } else { + return ResponseEntity.badRequest().body(failed); + } + } + + @PatchMapping("/users") + public ResponseEntity> signUp(@Valid @RequestBody SignupRequest request) { + String successMessage = "成功更新"; + String usernameDuplicatedMessage = "更新失敗,輸入的帳號已被其他人使用!"; + String emailDuplicatedMessage = "更新失敗,輸入的電子信箱已被其他人使用!"; + String emailAndUsernameDuplicatedMessage = "更新失敗,輸入的帳號及電子信箱皆已被其他人使用!"; + String passwordMessage = "至少需要8位密碼,且不超過128位"; + Map successResponse = Collections.singletonMap("message", successMessage); + Map usernameDuplicatedResponse = Collections.singletonMap("message", usernameDuplicatedMessage); + Map emailDuplicatedResponse = Collections.singletonMap("message", emailDuplicatedMessage); + Map emailAndUsernameDuplicatedResponse = Collections.singletonMap("message", emailAndUsernameDuplicatedMessage); + Map passwordResponse = Collections.singletonMap("message", passwordMessage); + User user = userService.findByUsername(userIdentity.getUsername()); + user.setEmail(request.getEmail()); + user.setUsername(request.getUsername()); + user.setName(request.getName()); + if(request.getPassword().length() < 8 || request.getPassword().length() > 128){ + return ResponseEntity.badRequest().body(passwordResponse); + } + user.setHashedPassword(userService.getPasswordEncoder().encode(request.getPassword())); + if(!userService.isUsernameNonExist(request.getUsername())){ + if(!userService.isEmailNonExist(request.getEmail())){ + return ResponseEntity.badRequest().body(emailAndUsernameDuplicatedResponse); + } + return ResponseEntity.badRequest().body(usernameDuplicatedResponse); + } + if(!userService.isEmailNonExist(request.getEmail())){ + return ResponseEntity.badRequest().body(emailDuplicatedResponse); + } + userService.update(user); + return ResponseEntity.ok(successResponse); + } + +} diff --git a/src/main/java/ntou/auction/spring/account/entity/Role.java b/src/main/java/ntou/auction/spring/account/entity/Role.java new file mode 100644 index 0000000..abaa4a5 --- /dev/null +++ b/src/main/java/ntou/auction/spring/account/entity/Role.java @@ -0,0 +1,5 @@ +package ntou.auction.spring.account.entity; + +public enum Role { + USER, ADMIN +} diff --git a/src/main/java/ntou/auction/spring/account/entity/User.java b/src/main/java/ntou/auction/spring/account/entity/User.java new file mode 100644 index 0000000..fea61f9 --- /dev/null +++ b/src/main/java/ntou/auction/spring/account/entity/User.java @@ -0,0 +1,99 @@ +package ntou.auction.spring.account.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import ntou.auction.spring.util.AbstractEntity; +import org.hibernate.validator.constraints.Length; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + + +import java.util.Collection; +import java.util.Set; + +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "user") +public class User extends AbstractEntity implements UserDetails { + + @Length(min = 1, max = 128, message = "帳號長度限制為1~32位!") + @Column(unique = true) + private String username; + + @Length(min = 1, max = 128, message = "暱稱長度限制為1~32位!") + private String name; + + @JsonIgnore + @NotBlank(message = "密碼不可為空!") + private String hashedPassword; + + @JsonIgnore + private String password; + + @NotNull + @Enumerated(EnumType.STRING) + @ElementCollection(fetch = FetchType.EAGER) + private Set roles; + + // if avatar is more than 5MB, need to modify column length + @Lob + @Column(length = 5242880) + private byte[] avatarImage; + + private String avatarImageName; + + @ElementCollection + @CollectionTable(name = "favorite_products") + private Set favoriteProducts; + + @NotBlank(message = "電子信箱不可為空!") + @Email(message = "電子信箱格式錯誤!") + @Column(unique = true) + private String email; + + private boolean enabled; + + private boolean isAccountNonExpired; + + private boolean isAccountNonLocked; + + private boolean isCredentialsNonExpired; + + @Override + public Collection getAuthorities() { + return null; + } + + @Override + public String getPassword() { + return this.hashedPassword; + } + + public boolean isAdmin() { + return roles.contains(Role.ADMIN); + } + + public void setAdmin(boolean bool) { + if (!bool && isAdmin()) { + roles.remove(Role.ADMIN); + } else if (bool && !isAdmin()) { + roles.add(Role.ADMIN); + } + } + + public @NotNull String getUsername() { + return username; + } + + public void setUsername(@NotNull String username) { + this.username = username; + } +} diff --git a/src/main/java/ntou/auction/spring/account/repository/UserRepository.java b/src/main/java/ntou/auction/spring/account/repository/UserRepository.java new file mode 100644 index 0000000..06e697d --- /dev/null +++ b/src/main/java/ntou/auction/spring/account/repository/UserRepository.java @@ -0,0 +1,23 @@ +package ntou.auction.spring.account.repository; + +import ntou.auction.spring.account.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface UserRepository extends JpaRepository, + JpaSpecificationExecutor { + + User findByUsername(String username); + + User findById(long id); + + @Query("select u from User u " + + "where u.email like :email") + List findAllByEmail(@Param("email") String email); +} diff --git a/src/main/java/ntou/auction/spring/account/request/AuthRequest.java b/src/main/java/ntou/auction/spring/account/request/AuthRequest.java new file mode 100644 index 0000000..6a0617b --- /dev/null +++ b/src/main/java/ntou/auction/spring/account/request/AuthRequest.java @@ -0,0 +1,18 @@ +package ntou.auction.spring.account.request; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AuthRequest { + + @NotNull + private String username; + + @NotNull + private String password; +} diff --git a/src/main/java/ntou/auction/spring/account/request/FavoriteRequest.java b/src/main/java/ntou/auction/spring/account/request/FavoriteRequest.java new file mode 100644 index 0000000..d8a3b0e --- /dev/null +++ b/src/main/java/ntou/auction/spring/account/request/FavoriteRequest.java @@ -0,0 +1,14 @@ +package ntou.auction.spring.account.request; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class FavoriteRequest { + @NotNull + private Long productId; +} diff --git a/src/main/java/ntou/auction/spring/account/request/SignupRequest.java b/src/main/java/ntou/auction/spring/account/request/SignupRequest.java new file mode 100644 index 0000000..91c3f9a --- /dev/null +++ b/src/main/java/ntou/auction/spring/account/request/SignupRequest.java @@ -0,0 +1,35 @@ +package ntou.auction.spring.account.request; + +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.Length; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SignupRequest { + + @Length(min = 1, max = 128, message = "帳號長度限制為1~32位!") + @Column(unique = true) + private String username; + + @Length(min = 1, max = 128, message = "暱稱長度限制為1~32位!") + private String name; + + @Length(min = 8, max = 512, message = "密碼長度限制為8~128位!") + private String password; + + // if avatar is more than 5MB, need to modify column length + @Lob + @Column(length = 5243000) + private byte[] avatarImage; + + @NotBlank(message = "電子信箱不可為空!") + @Email(message = "電子信箱格式錯誤!") + @Column(unique = true) + private String email; +} diff --git a/src/main/java/ntou/auction/spring/account/response/AuthResponse.java b/src/main/java/ntou/auction/spring/account/response/AuthResponse.java new file mode 100644 index 0000000..0fc1a5c --- /dev/null +++ b/src/main/java/ntou/auction/spring/account/response/AuthResponse.java @@ -0,0 +1,8 @@ +package ntou.auction.spring.account.response; + +import lombok.Data; + +@Data +public class AuthResponse { + private String accessToken; +} diff --git a/src/main/java/ntou/auction/spring/account/response/UserIdentity.java b/src/main/java/ntou/auction/spring/account/response/UserIdentity.java new file mode 100644 index 0000000..77f3c7d --- /dev/null +++ b/src/main/java/ntou/auction/spring/account/response/UserIdentity.java @@ -0,0 +1,30 @@ +package ntou.auction.spring.account.response; + +import ntou.auction.spring.account.service.UserService; +import ntou.auction.spring.account.entity.Role; +import ntou.auction.spring.account.entity.User; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +@Component +public class UserIdentity { + private final User EMPTY_USER = new User(); + private final UserService userService; + + public UserIdentity(UserService userService) { + this.userService = userService; + } + + private User getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + UserDetails principal = (UserDetails) authentication.getPrincipal(); + return userService.findByUsername(principal.getUsername()).getRoles().contains(Role.USER) ? userService.findByUsername(principal.getUsername()) : EMPTY_USER; + } + + public String getUsername() { + return getCurrentUser().getUsername(); + } + +} \ No newline at end of file diff --git a/src/main/java/ntou/auction/spring/account/service/JWTService.java b/src/main/java/ntou/auction/spring/account/service/JWTService.java new file mode 100644 index 0000000..a4bc4bd --- /dev/null +++ b/src/main/java/ntou/auction/spring/account/service/JWTService.java @@ -0,0 +1,81 @@ +package ntou.auction.spring.account.service; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import ntou.auction.spring.account.request.AuthRequest; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.UUID; + +@Service +public class JWTService { + + private static AuthenticationManager authenticationManager; + + public JWTService(AuthenticationManager authenticationManager) { + JWTService.authenticationManager = authenticationManager; + } + + // base64 encoded string + // privateKey + private static final String TOKEN_SECRET = "cuAihCz53DZRjZwbsGcZJ2Ai6At+T142uphtJMsk7iQ="; + + public static SecretKey getSigningKey() { + byte[] encodeKey = Decoders.BASE64.decode(JWTService.TOKEN_SECRET); + return Keys.hmacShaKeyFor(encodeKey); + + } + + public static String generateJWT(AuthRequest request) { + Authentication authentication = + new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()); + authentication = authenticationManager.authenticate(authentication); + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + // millisecond + // one day + long expireTime = 1440 * 60 * 1000; + Date current = new Date(); + Date expiration = new Date(current.getTime() + expireTime); + + SecretKey secretKey = getSigningKey(); + return Jwts.builder() + .issuer("ntou.auction.spring") + .subject(userDetails.getUsername()) + .expiration(expiration) + .notBefore(current) + .issuedAt(current) + .id(UUID.randomUUID().toString()) + .signWith(secretKey) + .compact(); + } + + public static Jws parseJWT(String jwt) { + SecretKey secretKey = getSigningKey(); + + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(jwt); + + } + + public static String validateTokenAndGetUsername(final String token) { + try { + Jws claims = JWTService.parseJWT(token); + System.out.println("解析成功" + claims.getPayload().getSubject()); + return claims.getPayload().getSubject(); + + } catch (JwtException ex) { + System.out.println("解析失敗:"); + return null; + } + } + +} diff --git a/src/main/java/ntou/auction/spring/account/service/UserDetailsServiceImpl.java b/src/main/java/ntou/auction/spring/account/service/UserDetailsServiceImpl.java new file mode 100644 index 0000000..e752fdd --- /dev/null +++ b/src/main/java/ntou/auction/spring/account/service/UserDetailsServiceImpl.java @@ -0,0 +1,50 @@ +package ntou.auction.spring.account.service; + +import ntou.auction.spring.account.entity.User; +import ntou.auction.spring.account.repository.UserRepository; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class UserDetailsServiceImpl implements UserDetailsService { + + private final UserRepository userRepository; + + public UserDetailsServiceImpl(UserRepository userRepository) { + this.userRepository = userRepository; + } + + private static List getAuthorities(User user) { + return user.getRoles().stream().map(role -> + new SimpleGrantedAuthority("ROLE_" + role)) + .collect(Collectors.toList()); + + } + + @Override + public UserDetails loadUserByUsername(String username) + throws UsernameNotFoundException { + User user = userRepository.findByUsername(username); + if (user != null) { + return new org.springframework.security.core.userdetails.User + (user.getUsername(), user.getHashedPassword(), + user.isEnabled(), + user.isAccountNonExpired(), + user.isCredentialsNonExpired(), + user.isAccountNonLocked() + , getAuthorities(user)); + } else { + throw new UsernameNotFoundException("No user present with " + + "username: " + username); + } + } + +} diff --git a/src/main/java/ntou/auction/spring/account/service/UserService.java b/src/main/java/ntou/auction/spring/account/service/UserService.java new file mode 100644 index 0000000..92bd490 --- /dev/null +++ b/src/main/java/ntou/auction/spring/account/service/UserService.java @@ -0,0 +1,113 @@ +package ntou.auction.spring.account.service; + +import ntou.auction.spring.account.entity.User; + +import java.util.Optional; +import java.util.Set; + +import ntou.auction.spring.account.repository.UserRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +public class UserService { + + private final UserRepository repository; + + private final PasswordEncoder passwordEncoder; + + + public UserService(UserRepository repository, PasswordEncoder passwordEncoder) { + this.repository = repository; + this.passwordEncoder = passwordEncoder; + } + + public Optional get(Long id) { + return repository.findById(id); + } + + public User update(User entity) { + return repository.save(entity); + } + + public void delete(Long id) { + Optional maybeUser = repository.findById(id); + if (maybeUser.isPresent()) { + //User user = maybeUser.get(); + repository.deleteById(id); + } + } + + public Page list(Pageable pageable) { + return repository.findAll(pageable); + } + + public Page list(Pageable pageable, Specification filter) { + return repository.findAll(filter, pageable); + } + + public int count() { + return (int) repository.count(); + } + + public void store(User user) { + repository.save(user); + } + + public PasswordEncoder getPasswordEncoder() { + return passwordEncoder; + } + + public boolean isUsernameNonExist(String username) { + return repository.findByUsername(username) == null; + } + + public boolean isEmailNonExist(String email) { + return repository.findAllByEmail(email).isEmpty(); + } + + public User findByUsername(String userName) { + return repository.findByUsername(userName); + } + + public Set getFavoriteProducts(Long userId) { + if (repository.findById(userId).isPresent()) { + return repository.findById(userId).get().getFavoriteProducts(); + } else { + return null; + } + } + + public boolean addFavoriteProducts(Long userId, Long productId) { + if (repository.findById(userId).isPresent()) { + User user = repository.findById(userId).get(); + Set favoriteProducts = user.getFavoriteProducts(); + if (!favoriteProducts.add(productId)) { + return false; + } + user.setFavoriteProducts(favoriteProducts); + repository.save(user); + return true; + } else { + return false; + } + } + + public boolean removeFavoriteProducts(Long userId, Long productId) { + if (repository.findById(userId).isPresent()) { + User user = repository.findById(userId).get(); + Set favoriteProducts = user.getFavoriteProducts(); + if (!favoriteProducts.remove(productId)) { + return false; + } + user.setFavoriteProducts(favoriteProducts); + repository.save(user); + return true; + } else { + return false; + } + } +} diff --git a/src/main/java/ntou/auction/spring/chat/config/AuthChannelInterceptor.java b/src/main/java/ntou/auction/spring/chat/config/AuthChannelInterceptor.java new file mode 100644 index 0000000..c31e6d1 --- /dev/null +++ b/src/main/java/ntou/auction/spring/chat/config/AuthChannelInterceptor.java @@ -0,0 +1,67 @@ +package ntou.auction.spring.chat.config; + +import io.micrometer.common.util.StringUtils; +import ntou.auction.spring.account.service.JWTService; +import ntou.auction.spring.account.service.UserDetailsServiceImpl; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 99) +public class AuthChannelInterceptor implements ChannelInterceptor { + + private final UserDetailsServiceImpl userDetailsServiceImpl; + + public AuthChannelInterceptor(UserDetailsServiceImpl userDetailsServiceImpl) { + this.userDetailsServiceImpl = userDetailsServiceImpl; + } + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = + MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + // 第一次連線 + if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) { + // 驗證token + List header = accessor.getNativeHeader("Authorization"); + // header裡面有沒有token + if (header != null && !header.isEmpty()) { + String token = header.getFirst(); + if (StringUtils.isNotBlank(token)) { + String username = JWTService.validateTokenAndGetUsername(token); + System.out.println("username:" + username); + // token有效 + if (username != null) { + UserDetails userDetails = userDetailsServiceImpl.loadUserByUsername(username); + Authentication authentication = + new UsernamePasswordAuthenticationToken(userDetails.getUsername(), userDetails.getPassword()); + accessor.setUser(authentication); + return message; + }else{ + System.out.println("WebSocket連線驗證失敗"); + throw new AccessDeniedException("WebSocket連線驗證失敗"); + } + } + }else{ + System.out.println("WebSocket連線驗證失敗"); + throw new AccessDeniedException("WebSocket連線驗證失敗"); + } + } + System.out.println("message:" + message); + // 非第一次連線,不用驗證 + return message; + } +} diff --git a/src/main/java/ntou/auction/spring/chat/config/WebSocketMessageConfig.java b/src/main/java/ntou/auction/spring/chat/config/WebSocketMessageConfig.java new file mode 100644 index 0000000..fa3ed8c --- /dev/null +++ b/src/main/java/ntou/auction/spring/chat/config/WebSocketMessageConfig.java @@ -0,0 +1,61 @@ +package ntou.auction.spring.chat.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.converter.DefaultContentTypeResolver; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.util.MimeTypeUtils; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +import java.util.List; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketMessageConfig implements WebSocketMessageBrokerConfigurer { + + private final AuthChannelInterceptor authChannelInterceptor; + + public WebSocketMessageConfig(AuthChannelInterceptor authChannelInterceptor) { + this.authChannelInterceptor = authChannelInterceptor; + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + // server端推送給client端 + config.enableSimpleBroker("/user"); + // client端發送給server端 + config.setApplicationDestinationPrefixes("/app"); + config.setUserDestinationPrefix("/user"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws") + .setAllowedOriginPatterns("*"); + registry.addEndpoint("/sockjs") + .setAllowedOriginPatterns("*") + .withSockJS(); + } + + @Override + public boolean configureMessageConverters(List messageConverters) { + DefaultContentTypeResolver resolver = new DefaultContentTypeResolver(); + resolver.setDefaultMimeType(MimeTypeUtils.APPLICATION_JSON); + MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter(); + converter.setObjectMapper(new ObjectMapper()); + converter.setContentTypeResolver(resolver); + messageConverters.add(converter); + return false; + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(authChannelInterceptor); + } + +} diff --git a/src/main/java/ntou/auction/spring/chat/controller/ChatController.java b/src/main/java/ntou/auction/spring/chat/controller/ChatController.java new file mode 100644 index 0000000..7a9c3e9 --- /dev/null +++ b/src/main/java/ntou/auction/spring/chat/controller/ChatController.java @@ -0,0 +1,106 @@ +package ntou.auction.spring.chat.controller; + + +import ntou.auction.spring.chat.entity.ChatMessage; +import ntou.auction.spring.chat.entity.ChatNotification; +import ntou.auction.spring.chat.service.ChatMessageService; +import ntou.auction.spring.chat.service.ChatRoomService; +import ntou.auction.spring.account.response.UserIdentity; +import ntou.auction.spring.account.service.UserService; +import org.springframework.http.ResponseEntity; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +@Controller +@CrossOrigin(origins = "http://localhost:3000") +public class ChatController { + + private final UserService userService; + + private final UserIdentity userIdentity; + + private final SimpMessagingTemplate messageTemplate; + private final ChatMessageService chatMessageService; + private final ChatRoomService chatRoomService; + + public ChatController(UserService userService, UserIdentity userIdentity, SimpMessagingTemplate messageTemplate, ChatMessageService chatMessageService, ChatRoomService chatRoomService) { + this.userService = userService; + this.userIdentity = userIdentity; + this.messageTemplate = messageTemplate; + this.chatMessageService = chatMessageService; + this.chatRoomService = chatRoomService; + } + + @MessageMapping("/send") + public void sendMessage(@Payload ChatMessage chatMessage, Principal principal) { + + // token無效 + if (principal.getName() == null) { + return; + } + // 傳給不存在的使用者 + if (userService.get(chatMessage.getReceiverId()).isEmpty()) { + return; + } + + Optional chatId = chatRoomService.getChatId(userService.findByUsername(principal.getName()).getId(), chatMessage.getReceiverId(), true); + System.out.println(chatId); + chatId.ifPresent(chatMessage::setChatId); + chatMessage.setSenderId(userService.findByUsername(principal.getName()).getId()); + chatMessage.setTimestamp(LocalDateTime.now()); + chatMessage.setSenderUserName(principal.getName()); + chatMessage.setReceiverUserName(userService.get(chatMessage.getReceiverId()).get().getUsername()); + ChatMessage saved = chatMessageService.save(chatMessage); + + messageTemplate.convertAndSendToUser(saved.getReceiverId().toString(), "/queue/messages", new ChatNotification( + saved.getId(), + saved.getSenderId(), + principal.getName() + )); + } + + @GetMapping("/api/v1/chat/message/{id}") + public ResponseEntity findMessage(@PathVariable Long id) { + return ResponseEntity + .ok(chatMessageService.findById(id)); + } + + @GetMapping("/api/v1/chat/messages/{recipientId}/count") + public ResponseEntity countNewMessages( + @PathVariable String recipientId) { + return ResponseEntity + .ok(chatMessageService.countNewMessages(userService.findByUsername(userIdentity.getUsername()).getId(), Long.parseLong(recipientId))); + } + + @GetMapping("/api/v1/chat/messages/{recipientId}") + public ResponseEntity findChatMessages( + @PathVariable Long recipientId) { + return ResponseEntity + .ok(chatMessageService.findChatMessages(userService.findByUsername(userIdentity.getUsername()).getId(), recipientId)); + } + + @GetMapping("/api/v1/chat/contact") + public ResponseEntity findContact() { + Set contacts = chatMessageService.getContact(userService.findByUsername(userIdentity.getUsername()).getId()); + Map contactList = new HashMap<>(); + for(Long contactId: contacts){ + if(userService.get(contactId).isPresent()) { + contactList.put(contactId, userService.get(contactId).get().getUsername()); + } + } + return ResponseEntity + .ok(contactList); + } +} diff --git a/src/main/java/ntou/auction/spring/chat/entity/ChatMessage.java b/src/main/java/ntou/auction/spring/chat/entity/ChatMessage.java new file mode 100644 index 0000000..ba28cca --- /dev/null +++ b/src/main/java/ntou/auction/spring/chat/entity/ChatMessage.java @@ -0,0 +1,25 @@ +package ntou.auction.spring.chat.entity; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.persistence.Entity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import ntou.auction.spring.util.AbstractEntity; +import java.time.LocalDateTime; + +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ChatMessage extends AbstractEntity { + private Long senderId; + private Long receiverId; + private String senderUserName; + private String receiverUserName; + private Long chatId; + private String content; + @JsonFormat(pattern="yyyy-MM-dd HH:mm:ss") + private LocalDateTime timestamp; + private ChatMessageStatus chatMessageStatus; +} diff --git a/src/main/java/ntou/auction/spring/chat/entity/ChatMessageStatus.java b/src/main/java/ntou/auction/spring/chat/entity/ChatMessageStatus.java new file mode 100644 index 0000000..28ee876 --- /dev/null +++ b/src/main/java/ntou/auction/spring/chat/entity/ChatMessageStatus.java @@ -0,0 +1,5 @@ +package ntou.auction.spring.chat.entity; + +public enum ChatMessageStatus { + RECEIVED, DELIVERED +} diff --git a/src/main/java/ntou/auction/spring/chat/entity/ChatNotification.java b/src/main/java/ntou/auction/spring/chat/entity/ChatNotification.java new file mode 100644 index 0000000..bb8003b --- /dev/null +++ b/src/main/java/ntou/auction/spring/chat/entity/ChatNotification.java @@ -0,0 +1,15 @@ +package ntou.auction.spring.chat.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import ntou.auction.spring.util.AbstractEntity; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ChatNotification extends AbstractEntity { + private Long id; + private Long senderId; + private String senderName; +} diff --git a/src/main/java/ntou/auction/spring/chat/entity/ChatRoom.java b/src/main/java/ntou/auction/spring/chat/entity/ChatRoom.java new file mode 100644 index 0000000..534ea40 --- /dev/null +++ b/src/main/java/ntou/auction/spring/chat/entity/ChatRoom.java @@ -0,0 +1,16 @@ +package ntou.auction.spring.chat.entity; + +import jakarta.persistence.Entity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import ntou.auction.spring.util.AbstractEntity; +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ChatRoom extends AbstractEntity { + private Long chatId; + private Long senderId; + private Long receiverId; +} diff --git a/src/main/java/ntou/auction/spring/chat/exception/MessageNotFound.java b/src/main/java/ntou/auction/spring/chat/exception/MessageNotFound.java new file mode 100644 index 0000000..1706be2 --- /dev/null +++ b/src/main/java/ntou/auction/spring/chat/exception/MessageNotFound.java @@ -0,0 +1,12 @@ +package ntou.auction.spring.chat.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class MessageNotFound extends RuntimeException { + + public MessageNotFound(String errorMessage){ + super(errorMessage); + } +} diff --git a/src/main/java/ntou/auction/spring/chat/repository/ChatMessageRepository.java b/src/main/java/ntou/auction/spring/chat/repository/ChatMessageRepository.java new file mode 100644 index 0000000..242a91d --- /dev/null +++ b/src/main/java/ntou/auction/spring/chat/repository/ChatMessageRepository.java @@ -0,0 +1,26 @@ +package ntou.auction.spring.chat.repository; + + +import jakarta.validation.constraints.NotNull; +import ntou.auction.spring.chat.entity.ChatMessageStatus; +import ntou.auction.spring.chat.entity.ChatMessage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ChatMessageRepository extends JpaRepository, JpaSpecificationExecutor { + + List findByChatId(@NotNull Long chatId); + + List findBySenderIdAndReceiverId(Long senderId, Long receiverId); + + List findAllBySenderId(Long senderId); + + List findAllByReceiverId(Long receiverId); + + Long countBySenderIdAndReceiverIdAndAndChatMessageStatus(Long senderId, Long receiverId, ChatMessageStatus ChatMessageStatus); + +} diff --git a/src/main/java/ntou/auction/spring/chat/repository/ChatRoomRepository.java b/src/main/java/ntou/auction/spring/chat/repository/ChatRoomRepository.java new file mode 100644 index 0000000..205e778 --- /dev/null +++ b/src/main/java/ntou/auction/spring/chat/repository/ChatRoomRepository.java @@ -0,0 +1,15 @@ +package ntou.auction.spring.chat.repository; + +import ntou.auction.spring.chat.entity.ChatRoom; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ChatRoomRepository extends JpaRepository, + JpaSpecificationExecutor { + + Optional findChatRoomBySenderIdAndReceiverId(Long senderId, Long receiverId); +} diff --git a/src/main/java/ntou/auction/spring/chat/service/ChatMessageService.java b/src/main/java/ntou/auction/spring/chat/service/ChatMessageService.java new file mode 100644 index 0000000..a2f4354 --- /dev/null +++ b/src/main/java/ntou/auction/spring/chat/service/ChatMessageService.java @@ -0,0 +1,81 @@ +package ntou.auction.spring.chat.service; + +import ntou.auction.spring.chat.entity.ChatMessage; +import ntou.auction.spring.chat.entity.ChatMessageStatus; +import ntou.auction.spring.chat.exception.MessageNotFound; +import ntou.auction.spring.chat.repository.ChatMessageRepository; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Service +public class ChatMessageService { + private final ChatMessageRepository repository; + private final ChatRoomService chatRoomService; + + + public ChatMessageService(ChatMessageRepository repository, ChatRoomService chatRoomService) { + this.repository = repository; + this.chatRoomService = chatRoomService; + } + + public ChatMessage save(ChatMessage chatMessage) { + chatMessage.setChatMessageStatus(ChatMessageStatus.RECEIVED); + repository.save(chatMessage); + return chatMessage; + } + + public Long countNewMessages(Long senderId, Long receiverId) { + return repository.countBySenderIdAndReceiverIdAndAndChatMessageStatus( + senderId, receiverId, ChatMessageStatus.RECEIVED); + } + + public List findChatMessages(Long senderId, Long receiverId) { + Optional chatId = chatRoomService.getChatId(senderId, receiverId, false); + + if(chatId.isEmpty()){ + return new ArrayList<>(); + } + + List messages = + chatId.map(repository::findByChatId).orElse(new ArrayList<>()); + + if(!messages.isEmpty()) { + updateStatuses(senderId, receiverId, ChatMessageStatus.DELIVERED); + } + + return messages; + } + + public ChatMessage findById(Long id) { + return repository + .findById(id) + .map(chatMessage -> { + chatMessage.setChatMessageStatus(ChatMessageStatus.DELIVERED); + return repository.save(chatMessage); + }) + .orElseThrow(() -> + new MessageNotFound("無法找到 ID為 " + id + " 的聊天紀錄")); + } + + public void updateStatuses(Long senderId, Long receiverId, ChatMessageStatus status) { + List chatMessages = repository.findBySenderIdAndReceiverId(senderId,receiverId); + for(ChatMessage chatMessage:chatMessages){ + chatMessage.setChatMessageStatus(status); + repository.save(chatMessage); + } + } + + public Set getContact(Long userId){ + List sendByUser = repository.findAllBySenderId(userId); + List receiveByUser = repository.findAllByReceiverId(userId); + Set contact = new HashSet<>(); + for(ChatMessage message: sendByUser){ + contact.add(message.getReceiverId()); + } + for(ChatMessage message: receiveByUser){ + contact.add(message.getSenderId()); + } + return contact; + } +} diff --git a/src/main/java/ntou/auction/spring/chat/service/ChatRoomService.java b/src/main/java/ntou/auction/spring/chat/service/ChatRoomService.java new file mode 100644 index 0000000..3334105 --- /dev/null +++ b/src/main/java/ntou/auction/spring/chat/service/ChatRoomService.java @@ -0,0 +1,43 @@ +package ntou.auction.spring.chat.service; + +import ntou.auction.spring.chat.entity.ChatRoom; +import ntou.auction.spring.chat.repository.ChatRoomRepository; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +public class ChatRoomService { + private final ChatRoomRepository repository; + + public ChatRoomService(ChatRoomRepository chatRoomRepository) { + this.repository = chatRoomRepository; + } + + public Optional getChatId(Long senderId, Long receiverId, boolean createIfNotExist) { + + return repository.findChatRoomBySenderIdAndReceiverId(senderId, receiverId).map(ChatRoom::getChatId).or(() -> { + if (!createIfNotExist) { + return Optional.empty(); + } + + String chatId = String.format("%s%s", senderId, receiverId); + + ChatRoom senderRecipient = new ChatRoom(); + senderRecipient.setChatId(Long.parseLong(chatId)); + senderRecipient.setSenderId(senderId); + senderRecipient.setReceiverId(receiverId); + + repository.save(senderRecipient); + + ChatRoom recipientSender = new ChatRoom(); + recipientSender.setChatId(Long.parseLong(chatId)); + recipientSender.setSenderId(receiverId); + recipientSender.setReceiverId(senderId); + + repository.save(recipientSender); + + return Optional.of(Long.parseLong(chatId)); + }); + } +} diff --git a/src/main/java/ntou/auction/spring/mail/EmailService.java b/src/main/java/ntou/auction/spring/mail/EmailService.java new file mode 100644 index 0000000..afa1752 --- /dev/null +++ b/src/main/java/ntou/auction/spring/mail/EmailService.java @@ -0,0 +1,182 @@ +package ntou.auction.spring.mail; + +import jakarta.mail.Message; +import jakarta.mail.internet.InternetAddress; +import ntou.auction.spring.account.entity.User; +import ntou.auction.spring.account.service.UserService; +import ntou.auction.spring.order.entity.Order; +import ntou.auction.spring.product.entity.Product; +import ntou.auction.spring.util.AppConfig; +import org.springframework.mail.MailException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessagePreparator; +import org.springframework.stereotype.Service; + +import java.util.Date; + +@Service +public class EmailService { + private final JavaMailSender mailSender; + private final UserService userService; + private final AppConfig appConfig; + + public EmailService(JavaMailSender mailSender, UserService userService, AppConfig appConfig) { + this.mailSender = mailSender; + this.userService = userService; + this.appConfig = appConfig; + } + + public void sendMailBidSuccess(Long userId, Product product) { + + if (userService.get(userId).isEmpty()) { + System.err.println("找不到ID為 " + userId + " 的使用者,無法寄出得標成功通知"); + return; + } + User customer = userService.get(userId).get(); + + MimeMessagePreparator preparator = mimeMessage -> { + mimeMessage.setSubject("[NTOU Auction] 得標通知", "UTF-8"); + mimeMessage.setRecipient(Message.RecipientType.TO, + new InternetAddress(customer.getEmail())); + mimeMessage.setFrom(new InternetAddress(appConfig.getMailUsername())); + mimeMessage.setText("親愛的 " + customer.getName() + + " (@" + customer.getUsername() + ") 您好:" + "\n" + + "您已成功標得 " + product.getProductName() + " 商品," + + "目前商品已加入購物車,為了能夠盡早取得您心儀的商品,麻煩您盡早結帳。" + "\n\n" + + "感謝您使用 NTOU Auction,祝您購物愉快!" + "\n\n" + + "此為系統自動發送之郵件,請勿回覆!", "UTF-8" + + ); + mimeMessage.setSentDate(new Date()); + }; + + try { + this.mailSender.send(preparator); + } catch (MailException ex) { + System.err.println(ex.getMessage()); + } + } + + /* + public void sendMailBidFailed(Long userId, Product product) { + + if (userService.get(userId).isEmpty()) { + System.err.println("找不到ID為 " + userId + " 的使用者,無法寄出商品下架通知"); + return; + } + User customer = userService.get(userId).get(); + + MimeMessagePreparator preparator = mimeMessage -> { + mimeMessage.setSubject("[NTOU Auction] 商品下架通知", "UTF-8"); + mimeMessage.setRecipient(Message.RecipientType.TO, + new InternetAddress(customer.getEmail())); + mimeMessage.setFrom(new InternetAddress(appConfig.getMailUsername())); + mimeMessage.setText("親愛的 " + customer.getName() + + " (@" + customer.getUsername() + ") 您好:" + "\n" + + "您之前參加競標的 " + product.getProductName() + " 商品" + + "目前已由賣家下架,造成您的不便還請見諒。" + "\n\n" + + "感謝您使用 NTOU Auction,歡迎選購其他商品!" + "\n\n" + + "此為系統自動發送之郵件,請勿回覆!", "UTF-8" + + ); + mimeMessage.setSentDate(new Date()); + }; + + try { + this.mailSender.send(preparator); + } catch (MailException ex) { + System.err.println(ex.getMessage()); + } + } + */ + public void sendMailOrderEstablished(Long userId, Order order) { + + if (userService.get(userId).isEmpty() || userService.get(order.getSellerid()).isEmpty()) { + System.err.println("找不到ID為 " + userId + " 的使用者,或查無賣家,無法寄出訂單成立通知"); + return; + } + User customer = userService.get(userId).get(); + User seller = userService.get(order.getSellerid()).get(); + + MimeMessagePreparator buyerPreparator = mimeMessage -> { + mimeMessage.setSubject("[NTOU Auction] 訂單成立通知", "UTF-8"); + mimeMessage.setRecipient(Message.RecipientType.TO, + new InternetAddress(customer.getEmail())); + mimeMessage.setFrom(new InternetAddress(appConfig.getMailUsername())); + mimeMessage.setText("親愛的 " + customer.getName() + + " (@" + customer.getUsername() + ") 您好:" + "\n" + + "您已成功購買賣家為 @" + seller.getUsername() + " 的商品," + + "您這次購買了 " + order.getProductAddAmountList().size() + " 個品項的商品" + "\n" + + "目前訂單狀態為等待賣家確認,訂單詳細資訊請上NTOU Auction確認。" + "\n\n" + + "感謝您使用 NTOU Auction,祝您購物愉快!" + "\n\n" + + "此為系統自動發送之郵件,請勿回覆!", "UTF-8" + + ); + mimeMessage.setSentDate(new Date()); + }; + + MimeMessagePreparator sellerPreparator = mimeMessage -> { + mimeMessage.setSubject("[NTOU Auction] 訂單成立通知", "UTF-8"); + mimeMessage.setRecipient(Message.RecipientType.TO, + new InternetAddress(seller.getEmail())); + mimeMessage.setFrom(new InternetAddress(appConfig.getMailUsername())); + mimeMessage.setText("親愛的 " + seller.getName() + + " (@" + seller.getUsername() + ") 您好:" + "\n" + + "買家 @" + customer.getUsername() + " 已下訂您的商品," + + "目前訂單狀態為等待確認,請您盡快上NTOU Auction更新訂單狀態。" + "\n\n" + + "感謝您使用 NTOU Auction,祝您交易愉快!" + "\n\n" + + "此為系統自動發送之郵件,請勿回覆!", "UTF-8" + + ); + mimeMessage.setSentDate(new Date()); + }; + + try { + this.mailSender.send(buyerPreparator); + this.mailSender.send(sellerPreparator); + } catch (MailException ex) { + System.err.println(ex.getMessage()); + } + } + + public void sendMailOrderUpdate(Long userId, Order order) { + + if (userService.get(userId).isEmpty() || userService.get(order.getSellerid()).isEmpty()) { + System.err.println("找不到ID為 " + userId + " 的使用者,或查無賣家,無法寄出訂單狀態更新通知"); + return; + } + User customer = userService.get(userId).get(); + User seller = userService.get(order.getSellerid()).get(); + String status; + if (order.getStatus() == 0L) { + status = "賣家拒絕您的訂單"; + } else if (order.getStatus() == 2L) { + status = "賣家同意您的訂單"; + } else { + status = "未知"; + } + + MimeMessagePreparator buyerPreparator = mimeMessage -> { + mimeMessage.setSubject("[NTOU Auction] 訂單狀態更新通知", "UTF-8"); + mimeMessage.setRecipient(Message.RecipientType.TO, + new InternetAddress(customer.getEmail())); + mimeMessage.setFrom(new InternetAddress(appConfig.getMailUsername())); + mimeMessage.setText("親愛的 " + customer.getName() + + " (@" + customer.getUsername() + ") 您好:" + "\n" + + "您之前購買賣家為 @" + seller.getUsername() + " 的商品," + + "目前訂單狀態為 " + status + " ,訂單詳細資訊請上NTOU Auction確認。" + "\n\n" + + "感謝您使用 NTOU Auction,祝您購物愉快!" + "\n\n" + + "此為系統自動發送之郵件,請勿回覆!", "UTF-8" + + ); + mimeMessage.setSentDate(new Date()); + }; + + try { + this.mailSender.send(buyerPreparator); + } catch (MailException ex) { + System.err.println(ex.getMessage()); + } + } + +} diff --git a/src/main/java/ntou/auction/spring/order/controller/OrderController.java b/src/main/java/ntou/auction/spring/order/controller/OrderController.java new file mode 100644 index 0000000..afe469c --- /dev/null +++ b/src/main/java/ntou/auction/spring/order/controller/OrderController.java @@ -0,0 +1,253 @@ +package ntou.auction.spring.order.controller; + +import jakarta.validation.Valid; +import ntou.auction.spring.account.response.UserIdentity; +import ntou.auction.spring.account.service.UserService; +import ntou.auction.spring.mail.EmailService; +import ntou.auction.spring.order.entity.Order; +import ntou.auction.spring.order.request.AddOrderRequest; +import ntou.auction.spring.order.request.OperateOrderRequest; +import ntou.auction.spring.order.response.OrderWithProductDetail; +import ntou.auction.spring.order.service.OrderService; +import ntou.auction.spring.product.entity.Product; +import ntou.auction.spring.product.service.ProductService; +import ntou.auction.spring.shoppingcart.service.ShoppingcartService; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@RestController +@RequestMapping(value = "/api/v1/order", produces = MediaType.APPLICATION_JSON_VALUE) +@CrossOrigin(origins = "http://localhost:3000") +public class OrderController { + private final OrderService orderService; + private final ProductService productService; + + private final ShoppingcartService shoppingcartService; + private final UserService userService; + private final EmailService emailService; + private final UserIdentity userIdentity; + + private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final Map successMessage = Collections.singletonMap("message", "成功"); + private static final Map failMessage = Collections.singletonMap("message", "操作失敗"); + + private static final Map tooManySellerMessage = Collections.singletonMap("message", "訂單的賣家只能來自同一位"); + + private static final Map orderNotFound = Collections.singletonMap("message", "訂單不存在"); + + private static final Map statusError = Collections.singletonMap("message", "無法對目前訂單進行操作"); + + private static final Map identityError = Collections.singletonMap("message", "該狀身分下無法進行操作"); + + private static final Map formatError = Collections.singletonMap("message", "格式錯誤"); + + private static final Map notFoundInShoppingCartError = Collections.singletonMap("message", "商品不在購物車中或購買數量過多"); + + private static final Map selfBuyingError = Collections.singletonMap("message", "不可以購買自己的商品"); + + public OrderController(OrderService orderService, ProductService productService, ShoppingcartService shoppingcartService, UserService userService, EmailService emailService, UserIdentity userIdentity) { + this.orderService = orderService; + this.productService = productService; + this.shoppingcartService = shoppingcartService; + this.userService = userService; + this.emailService = emailService; + this.userIdentity = userIdentity; + } + + @GetMapping("/order/all") + List getAllByBuyer() { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + List getOrder = orderService.findAllByBuyerId(userId); + return orderService.orderToOrderWithProductDetail(getOrder); + } + + @GetMapping("/order/reject") + List getRejectByBuyer() { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + return orderService.orderToOrderWithProductDetail(orderService.findRejectByBuyerId(userId)); + } + + @GetMapping("/order/waiting") + List getWaitingByBuyer() { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + return orderService.orderToOrderWithProductDetail(orderService.findWaitingByBuyerId(userId)); + } + + @GetMapping("/order/submitted") + List getSubmitByBuyer() { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + return orderService.orderToOrderWithProductDetail(orderService.findSubmittedByBuyerId(userId)); + } + + @GetMapping("/order/done") + List getDoneByBuyer() { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + return orderService.orderToOrderWithProductDetail(orderService.findDoneByBuyerId(userId)); + } + + @GetMapping("/check/all") + List getAllBySeller() { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + return orderService.orderToOrderWithProductDetail(orderService.findAllBySellerId(userId)); + } + + @GetMapping("/check/reject") + List getRejectBySeller() { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + return orderService.orderToOrderWithProductDetail(orderService.findRejectBySellerId(userId)); + } + + @GetMapping("/check/waiting") + List getWaitingBySeller() { + // filter Waited order with seller + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + return orderService.orderToOrderWithProductDetail(orderService.findWaitingBySellerId(userId)); + } + + @GetMapping("/check/submitted") + List getSubmittedBySeller() { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + return orderService.orderToOrderWithProductDetail(orderService.findSubmittedBySellerId(userId)); + } + + @GetMapping("/check/done") + List getDoneBySeller() { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + return orderService.orderToOrderWithProductDetail(orderService.findDoneBySellerId(userId)); + } + + + @PostMapping("/create") + ResponseEntity> addOrder(@Valid @RequestBody AddOrderRequest request) { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + List> getrequest = request.getProductList(); + + for (List eachProductAddAmount : getrequest) { + Long productId = eachProductAddAmount.getFirst(); + Product getProduct = productService.getID(productId); + // Id error + if (getProduct == null) { + Map ErrorIdMessage = Collections.singletonMap("message", "商品(ID:" + productId + ")不存在"); + return ResponseEntity.badRequest().body(ErrorIdMessage); + } + } + + // checkInShoppingCart -> -1: format error, 0: false, 1: true + Long checkInShoppingCart = shoppingcartService.checkIsProductAllInShoppingCart(getrequest, userId); + if (checkInShoppingCart.equals(-1L)) return ResponseEntity.badRequest().body(formatError); + if (checkInShoppingCart.equals(0L)) return ResponseEntity.badRequest().body(notFoundInShoppingCartError); + + for (List eachProductAddAmount : getrequest) { + Long productId = eachProductAddAmount.get(0); + Long amount = eachProductAddAmount.get(1); + Product getProduct = productService.getID(productId); + // amount exceed + if (amount > getProduct.getProductAmount()) { + Map amountExceedReturn = Collections.singletonMap("message", "商品數量(" + getProduct.getProductName() + ")過多"); + return ResponseEntity.badRequest().body(amountExceedReturn); + } + } + + // Same seller + boolean checkSameSeller = orderService.checkIsSameSeller(getrequest); + if (!checkSameSeller) return ResponseEntity.badRequest().body(tooManySellerMessage); + + // Self buying + boolean checkSelfBuying = shoppingcartService.checkIsViolateSelfBuying(getrequest, userId); + if (checkSelfBuying) return ResponseEntity.badRequest().body(selfBuyingError); + + // order status -> 0: reject, 1: waiting for submit, 2: submitted but not paid, 3: order done + Order order = new Order(); + order.setBuyerid(userId); + order.setUpdateTime(LocalDateTime.parse(LocalDateTime.now().format(formatter), formatter)); + order.setStatus(1L); + + for (List eachProductAddAmount : getrequest) { + Long productId = eachProductAddAmount.get(0); + Long amount = eachProductAddAmount.get(1); + Product getProduct = productService.getID(productId); + order.setSellerid(getProduct.getSellerID()); + List input = new ArrayList<>(); + input.add(productId); + input.add(amount); + order.addProductAddAmount(input); + } + for (List eachProductAddAmount : getrequest) { + Long productId = eachProductAddAmount.get(0); + Long amount = eachProductAddAmount.get(1); + + // decrease product's amount by amount + productService.productAmountDecrease(productId, amount); + + // delete Product amount in Shopping cart + shoppingcartService.decreaseProductByUserId(userId, productId, amount); + } + orderService.addOrder(order); + emailService.sendMailOrderEstablished(order.getBuyerid(),order); + return ResponseEntity.ok(successMessage); + } + + @PostMapping("/makesubmit") + ResponseEntity> makeSubmit(@Valid @RequestBody OperateOrderRequest request) { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + Long orderId = request.getOrderId(); + if (orderId == null) return ResponseEntity.badRequest().body(failMessage); + // result -> 0: orderNotFound, 1: statusError, 2: idError, 3: success + Long result = orderService.submitOrder(orderId, userId); + if (result.equals(0L)) return ResponseEntity.badRequest().body(orderNotFound); + if (result.equals(1L)) return ResponseEntity.badRequest().body(statusError); + if (result.equals(2L)) return ResponseEntity.badRequest().body(identityError); + return ResponseEntity.ok(successMessage); + } + + @PostMapping("/makedone") + ResponseEntity> makeDone(@Valid @RequestBody OperateOrderRequest request) { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + Long orderId = request.getOrderId(); + if (orderId == null) return ResponseEntity.badRequest().body(failMessage); + // result -> 0: orderNotFound, 1: statusError, 2: idError, 3: success + Long result = orderService.doneOrder(orderId, userId); + if (result.equals(0L)) return ResponseEntity.badRequest().body(orderNotFound); + if (result.equals(1L)) return ResponseEntity.badRequest().body(statusError); + if (result.equals(2L)) return ResponseEntity.badRequest().body(identityError); + return ResponseEntity.ok(successMessage); + } + + @PostMapping("/makereject") + ResponseEntity> makeReject(@Valid @RequestBody OperateOrderRequest request) { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + Long orderId = request.getOrderId(); + if (orderId == null) return ResponseEntity.badRequest().body(failMessage); + // 0: orderNotFound, 1: statusError, 2: idError, 3: success + Long result = orderService.rejectOrder(orderId, userId); + if (result.equals(0L)) return ResponseEntity.badRequest().body(orderNotFound); + if (result.equals(1L)) return ResponseEntity.badRequest().body(statusError); + if (result.equals(2L)) return ResponseEntity.badRequest().body(identityError); + boolean check = orderService.addAmountToProduct(orderService.findOrderById(orderId)); + if (!check) return ResponseEntity.badRequest().body(orderNotFound); //this may not be happened + return ResponseEntity.ok(successMessage); + } + + @PostMapping("/makecancel") + ResponseEntity> makeCancel(@Valid @RequestBody OperateOrderRequest request) { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + Long orderId = request.getOrderId(); + if (orderId == null) return ResponseEntity.badRequest().body(failMessage); + // 0: orderNotFound, 1: statusError, 2: idError, 3: success, -1: expired + Long result = orderService.cancelOrder(orderId, userId); + if (result.equals(0L)) return ResponseEntity.badRequest().body(orderNotFound); + if (result.equals(1L)) return ResponseEntity.badRequest().body(statusError); + if (result.equals(2L)) return ResponseEntity.badRequest().body(identityError); + Map expiredError = Collections.singletonMap("message", "超過7天無法取消訂單"); + if (result.equals(-1L)) return ResponseEntity.badRequest().body(expiredError); + Order thisOrder = orderService.findOrderById(orderId); + boolean check = orderService.addAmountToProduct(thisOrder); + if (!check) return ResponseEntity.badRequest().body(orderNotFound); // this may not be happened + return ResponseEntity.ok(successMessage); + } +} diff --git a/src/main/java/ntou/auction/spring/order/entity/Order.java b/src/main/java/ntou/auction/spring/order/entity/Order.java new file mode 100644 index 0000000..3caf4ea --- /dev/null +++ b/src/main/java/ntou/auction/spring/order/entity/Order.java @@ -0,0 +1,37 @@ +package ntou.auction.spring.order.entity; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import ntou.auction.spring.util.AbstractEntity; + +import java.time.LocalDateTime; +import java.util.*; + +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "orders") +public class Order extends AbstractEntity { + @NotNull + private Long buyerid; + + @NotNull + private Long sellerid; + + private List> productAddAmountList = new ArrayList<>(); + + @NotNull + private Long status; // 0: reject, 1: waiting for submit, 2: submitted but not paid, 3: order done + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; + + public void addProductAddAmount(List product) { + productAddAmountList.add(product); + } +} diff --git a/src/main/java/ntou/auction/spring/order/repository/OrderRepository.java b/src/main/java/ntou/auction/spring/order/repository/OrderRepository.java new file mode 100644 index 0000000..02b0dd9 --- /dev/null +++ b/src/main/java/ntou/auction/spring/order/repository/OrderRepository.java @@ -0,0 +1,45 @@ +package ntou.auction.spring.order.repository; + +import ntou.auction.spring.order.entity.Order; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface OrderRepository extends JpaRepository, JpaSpecificationExecutor { + Optional findById(Long id); + @Query(value = "select * from orders o where o.buyerid = ?1", nativeQuery = true) + List findAllByBuyerid(Long buyer); + + @Query(value = "select * from orders o where o.buyerid = ?1 and o.status = 0", nativeQuery = true) + List findRejectByBuyerid(Long buyer); + + @Query(value = "select * from orders o where o.buyerid = ?1 and o.status = 1", nativeQuery = true) + List findWaitingByBuyerid(Long buyer); + + @Query(value = "select * from orders o where o.buyerid = ?1 and o.status = 2", nativeQuery = true) + List findSubmittedByBuyerid(Long buyer); + + @Query(value = "select * from orders o where o.buyerid = ?1 and o.status = 3", nativeQuery = true) + List findDoneByBuyerid(Long buyer); + + @Query(value = "select * from orders o where o.sellerid = ?1", nativeQuery = true) + List findAllBySellerid(Long seller); + + @Query(value = "select * from orders o where o.sellerid = ?1 and o.status = 0", nativeQuery = true) + List findRejectBySellerid(Long seller); + @Query(value = "select * from orders o where o.sellerid = ?1 and o.status = 1", nativeQuery = true) + List findWaitingBySellerid(Long seller); + + @Query(value = "select * from orders o where o.sellerid = ?1 and o.status = 2", nativeQuery = true) + List findSubmittedBySellerid(Long seller); + + @Query(value = "select * from orders o where o.sellerid = ?1 and o.status = 3", nativeQuery = true) + List findDoneBySellerid(Long seller); + @Modifying + @Query(value = "insert into shoppingcart(userId, productId) values (?1, ?2)", nativeQuery = true) + public void addShoppingCart(Long userId, List productId); +} diff --git a/src/main/java/ntou/auction/spring/order/request/AddOrderRequest.java b/src/main/java/ntou/auction/spring/order/request/AddOrderRequest.java new file mode 100644 index 0000000..8f6c6e4 --- /dev/null +++ b/src/main/java/ntou/auction/spring/order/request/AddOrderRequest.java @@ -0,0 +1,17 @@ +package ntou.auction.spring.order.request; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AddOrderRequest { + //List -> (productid, amount) + @NotNull + List> productList; +} diff --git a/src/main/java/ntou/auction/spring/order/request/OperateOrderRequest.java b/src/main/java/ntou/auction/spring/order/request/OperateOrderRequest.java new file mode 100644 index 0000000..76a425b --- /dev/null +++ b/src/main/java/ntou/auction/spring/order/request/OperateOrderRequest.java @@ -0,0 +1,14 @@ +package ntou.auction.spring.order.request; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OperateOrderRequest { + @NotNull + Long orderId; +} diff --git a/src/main/java/ntou/auction/spring/order/response/OrderWithProductDetail.java b/src/main/java/ntou/auction/spring/order/response/OrderWithProductDetail.java new file mode 100644 index 0000000..1aa7e49 --- /dev/null +++ b/src/main/java/ntou/auction/spring/order/response/OrderWithProductDetail.java @@ -0,0 +1,35 @@ +package ntou.auction.spring.order.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import ntou.auction.spring.shoppingcart.response.ProductAddAmount; + +import java.time.LocalDateTime; +import java.util.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class OrderWithProductDetail { + @NotNull + private Long orderid; + + @NotNull + private Long buyerid; + + @NotNull + private Long sellerid; + + private List productAddAmountList = new ArrayList<>(); + + @NotNull + private Long status; // 0: reject, 1: waiting for submit, 2: submitted but not paid, 3: order done + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; +} diff --git a/src/main/java/ntou/auction/spring/order/service/OrderService.java b/src/main/java/ntou/auction/spring/order/service/OrderService.java new file mode 100644 index 0000000..0584e4a --- /dev/null +++ b/src/main/java/ntou/auction/spring/order/service/OrderService.java @@ -0,0 +1,157 @@ +package ntou.auction.spring.order.service; + +import ntou.auction.spring.mail.EmailService; +import ntou.auction.spring.order.entity.Order; +import ntou.auction.spring.order.response.OrderWithProductDetail; +import ntou.auction.spring.order.repository.OrderRepository; +import ntou.auction.spring.shoppingcart.response.ProductAddAmount; +import ntou.auction.spring.product.service.ProductService; +import ntou.auction.spring.shoppingcart.service.ShoppingcartService; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@Service +public class OrderService { + private final OrderRepository repository; + + private final ProductService productService; + + private final ShoppingcartService shoppingcartService; + private final EmailService emailService; + + private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + public OrderService(OrderRepository repository, ProductService productService, ShoppingcartService shoppingcartService, EmailService emailService) { + this.repository = repository; + this.productService = productService; + this.shoppingcartService = shoppingcartService; + this.emailService = emailService; + } + public Order findOrderById(Long Id) { + return repository.findById(Id).orElse(null); + } + public List findAllByBuyerId(Long buyerId) { + return repository.findAllByBuyerid(buyerId); + } + + public List findRejectByBuyerId(Long buyerId) { + return repository.findRejectByBuyerid(buyerId); + } + + public List findWaitingByBuyerId(Long buyerId) { + return repository.findWaitingByBuyerid(buyerId); + } + + public List findSubmittedByBuyerId(Long buyerId) {return repository.findSubmittedByBuyerid(buyerId);} + + public List findDoneByBuyerId(Long buyerId) { + return repository.findDoneByBuyerid(buyerId); + } + + public List findAllBySellerId(Long sellerId) { return repository.findAllBySellerid(sellerId);} + + public List findRejectBySellerId(Long sellerId) { return repository.findRejectBySellerid(sellerId); } + + public List findWaitingBySellerId(Long sellerId) { return repository.findWaitingBySellerid(sellerId);} + + public List findSubmittedBySellerId(Long sellerId) { return repository.findSubmittedBySellerid(sellerId);} + + public List findDoneBySellerId(Long sellerId) { return repository.findDoneBySellerid(sellerId);} + + public Long submitOrder(Long orderId, Long userId) { + // for status -> 0: reject, 1: waiting for submit, 2: submitted but not paid, 3: order done + // for return -> 0: orderNotFound, 1: statusError, 2: idError, 3: success, -1: expired + Order getorder = repository.findById(orderId).orElse(null); + if(getorder == null) return 0L; + if(!getorder.getStatus().equals(1L)) return 1L; + if(!Objects.equals(findOrderById(orderId).getSellerid(), userId)) return 2L; + getorder.setStatus(2L); + repository.save(getorder); + emailService.sendMailOrderUpdate(getorder.getBuyerid(),getorder); + return 3L; + } + + public Long rejectOrder(Long orderId, Long userId) { + // 0: reject, 1: waiting for submit, 2: submitted but not paid, 3: order done + // for return -> 0: orderNotFound, 1: statusError, 2: idError, 3: success, -1: expired + Order getorder = repository.findById(orderId).orElse(null); + if(getorder == null) return 0L; + if(!getorder.getStatus().equals(1L)) return 1L; + if(!Objects.equals(findOrderById(orderId).getSellerid(), userId)) return 2L; + getorder.setStatus(0L); + repository.save(getorder); + emailService.sendMailOrderUpdate(getorder.getBuyerid(),getorder); + return 3L; + } + + public Long cancelOrder(Long orderId, Long userId) { + // 0: reject, 1: waiting for submit, 2: submitted but not paid, 3: order done + // for return -> 0: orderNotFound, 1: statusError, 2: idError, 3: success, -1: expired + Order getorder = repository.findById(orderId).orElse(null); + if(getorder == null) return 0L; + if(getorder.getStatus().equals(3L) || getorder.getStatus().equals(0L)) return 1L; + if(!Objects.equals(findOrderById(orderId).getBuyerid(), userId)) return 2L; + if(Duration.between(getorder.getUpdateTime(), LocalDateTime.parse(LocalDateTime.now().format(formatter), formatter)).toSeconds()>(86400*7L)) return -1L; + getorder.setStatus(0L); + repository.save(getorder); + return 3L; + } + // make order be done + public Long doneOrder(Long orderId, Long userId) { + // 0: reject, 1: waiting for submit, 2: submitted but not paid, 3: order done + // for return -> 0: orderNotFound, 1: statusError, 2: idError, 3: success, -1: expired + Order getorder = repository.findById(orderId).orElse(null); + if(getorder == null) return 0L; + if(!getorder.getStatus().equals(2L)) return 1L; + if(!Objects.equals(findOrderById(orderId).getSellerid(), userId)) return 2L; + getorder.setStatus(3L); + repository.save(getorder); + return 3L; + } + + public void addOrder(Order order) { + repository.save(order); + } + + public boolean checkIsSameSeller(List> list) { + Set check = new HashSet<>(); + for(List productAddAmount: list) { + check.add(productService.getID(productAddAmount.getFirst()).getSellerID()); + } + return check.size()==1; + } + + public List orderToOrderWithProductDetail(List getOrder) { + List result = new ArrayList<>(); + for(Order order: getOrder) { + OrderWithProductDetail addOrder = new OrderWithProductDetail(); + addOrder.setSellerid(order.getSellerid()); + addOrder.setBuyerid(order.getBuyerid()); + addOrder.setUpdateTime(order.getUpdateTime()); + addOrder.setStatus(order.getStatus()); + addOrder.setOrderid(order.getId()); + List temp = new ArrayList<>(); + for (List product : order.getProductAddAmountList()) { + temp.add(new ProductAddAmount(productService.getID(product.get(0)), product.get(1))); + } + addOrder.setProductAddAmountList(temp); + result.add(addOrder); + } + return result; + } + + public boolean addAmountToProduct(Order order) { + // (order == null) this may not be happened + if(order==null) return false; + // add product amount with amount + for(List eachProduct: order.getProductAddAmountList()) { + productService.productAmountIncrease(eachProduct.get(0), eachProduct.get(1)); + } + return true; + } + +} diff --git a/src/main/java/ntou/auction/spring/product/controller/ProductController.java b/src/main/java/ntou/auction/spring/product/controller/ProductController.java new file mode 100644 index 0000000..7a9a7d1 --- /dev/null +++ b/src/main/java/ntou/auction/spring/product/controller/ProductController.java @@ -0,0 +1,289 @@ +package ntou.auction.spring.product.controller; + +import jakarta.validation.Valid; +import ntou.auction.spring.product.service.ProductService; +import ntou.auction.spring.shoppingcart.service.ShoppingcartService; +import ntou.auction.spring.account.response.UserIdentity; +import ntou.auction.spring.account.service.UserService; +import ntou.auction.spring.product.entity.*; +import ntou.auction.spring.product.request.*; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + + +@RestController +@RequestMapping(value = "/api/v1/product", produces = MediaType.APPLICATION_JSON_VALUE) +@CrossOrigin(origins = "http://localhost:3000") +public class ProductController { + private final ProductService productService; + private final UserIdentity userIdentity; + private final UserService userService; + private final ShoppingcartService shoppingcartService; + + public ProductController(ProductService productService, UserIdentity userIdentity, UserService userService, ShoppingcartService shoppingcartService) { + this.productService = productService; + this.userIdentity = userIdentity; + this.userService = userService; + this.shoppingcartService = shoppingcartService; + } + + + @GetMapping("/product/name/{name}") + @ResponseBody + public List getProductName(@PathVariable String name) { + return productService.findByProductName(name); + } + + @GetMapping("/product/classification/{classification}") + @ResponseBody + public List getProductClassification(@PathVariable String classification) { + return productService.findByProductClassification(classification); + } + + @GetMapping("/products") + @ResponseBody + List getProductProfile() { + return productService.list(); + } + + @GetMapping("/product/{ID}") + @ResponseBody + Product getProduct(@PathVariable long ID) { + return productService.getID(ID); + } + + @PostMapping("/fixedproduct") + ResponseEntity> postProduct(@Valid @RequestBody PostFixedPriceProductRequest request) { //productrequest的限制 + + Map successMessage = Collections.singletonMap("message", "成功上架"); + + + Product product = new Product(); + + product.setProductName(request.getProductName()); + product.setProductDescription(request.getProductDescription()); + product.setIsFixedPrice(true); + product.setProductImage(request.getProductImage()); + product.setProductType(request.getProductType()); + product.setCurrentPrice(request.getCurrentPrice()); + product.setUpsetPrice(null); + product.setBidIncrement(null); + product.setProductAmount(request.getProductAmount()); + product.setIsAuction(false); + product.setVisible(true); + product.setSellerID(userService.findByUsername(userIdentity.getUsername()).getId()); + product.setSellerName(userIdentity.getUsername()); + + product.setUpdateTime(LocalDateTime.now()); + + + productService.store(product); + return ResponseEntity.ok(successMessage); + } + + @PostMapping("/nonfixedproduct") + ResponseEntity> postProduct(@Valid @RequestBody PostNonFixedPriceProductRequest request) { //productrequest的限制 + + Map successMessage = Collections.singletonMap("message", "成功上架"); + Map fail = Collections.singletonMap("message", "截止時間錯誤"); + + Product product = new Product(); + + product.setProductName(request.getProductName()); + product.setProductDescription(request.getProductDescription()); + product.setIsFixedPrice(false); + product.setProductImage(request.getProductImage()); + product.setProductType(request.getProductType()); + product.setCurrentPrice(request.getUpsetPrice()); + product.setUpsetPrice(request.getUpsetPrice()); + product.setBidIncrement(request.getBidIncrement()); + product.setProductAmount(1L); + product.setIsAuction(false); + product.setVisible(true); + + LocalDateTime now = LocalDateTime.now(); + + product.setSellerID(userService.findByUsername(userIdentity.getUsername()).getId()); + product.setSellerName(userIdentity.getUsername()); + product.setUpdateTime(now); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + LocalDateTime dateTime = LocalDateTime.parse(request.getFinishTime(), formatter); + + if (!now.isBefore(dateTime)) { + return ResponseEntity.badRequest().body(fail); + } + product.setFinishTime(dateTime); + + productService.store(product); + return ResponseEntity.ok(successMessage); + } + + @PatchMapping("/bid") + ResponseEntity> bidProduct(@Valid @RequestBody BidRequest request) { + + Map successMessage = Collections.singletonMap("message", "成功出價"); + Map failMessage = Collections.singletonMap("message", "出價不合理,出價需比當前最高價高" + productService.getID(request.getProductID()).getBidIncrement()); + Map expired = Collections.singletonMap("message", "競標已結束"); + + LocalDateTime now = LocalDateTime.now(); + if (!now.isBefore(productService.getID(request.getProductID()).getFinishTime())) { + return ResponseEntity.badRequest().body(expired); + } + + if (!productService.isBidReasonable(request.getBid(), request.getProductID())) { + return ResponseEntity.badRequest().body(failMessage); + } + System.out.println(userIdentity.getUsername()); + productService.bid(request.getBid(), request.getProductID(), userService.findByUsername(userIdentity.getUsername()).getId()); + + return ResponseEntity.ok(successMessage); + } + + + @PostMapping("/buy") + ResponseEntity> buyProduct(@Valid @RequestBody BuyProductRequest request) { + + Map successMessage = Collections.singletonMap("message", "成功加入購物車"); + Map notEnoughMessage = Collections.singletonMap("message", "商品剩餘數量不足"); + Map errorMessage = Collections.singletonMap("message", "只能將不二價商品加入購物車"); + Map productNotExistMessage = Collections.singletonMap("message", "商品不存在或無法購買"); + + // 商品是否存在 + if (productService.getID(request.getProductID()) == null) { + return ResponseEntity.badRequest().body(productNotExistMessage); + } + + // 購物車是空的 + // 只檢查request送來的加入數量 + if (shoppingcartService.getByUserId(userService.findByUsername(userIdentity.getUsername()).getId()) == null) { + if (request.getProductAmount() > productService.getID(request.getProductID()).getProductAmount()) { + return ResponseEntity.badRequest().body(notEnoughMessage); + } else { + shoppingcartService.addProductByUserId(userService.findByUsername(userIdentity.getUsername()).getId(), request.getProductID(), request.getProductAmount()); + return ResponseEntity.ok(successMessage); + } + } + // 購物車裡面還沒有要加入的商品 + // 只檢查request送來的加入數量 + if (shoppingcartService.getByUserId(userService.findByUsername(userIdentity.getUsername()).getId()).getProductItems().get(request.getProductID()) == null) { + if (request.getProductAmount() > productService.getID(request.getProductID()).getProductAmount()) { + return ResponseEntity.badRequest().body(notEnoughMessage); + } else { + shoppingcartService.addProductByUserId(userService.findByUsername(userIdentity.getUsername()).getId(), request.getProductID(), request.getProductAmount()); + return ResponseEntity.ok(successMessage); + } + } + + // 購物車裡面已經有要加入的商品 + // 檢查request送來的加入數量加上原先購物車內的商品數量 + if (request.getProductAmount() + shoppingcartService.getByUserId(userService.findByUsername(userIdentity.getUsername()).getId()).getProductItems().get(request.getProductID()) > productService.getID(request.getProductID()).getProductAmount()) { //要買的數量 > 商品剩餘數量 + return ResponseEntity.badRequest().body(notEnoughMessage); + } + if (!productService.getID(request.getProductID()).getIsFixedPrice()) { + return ResponseEntity.badRequest().body(errorMessage); + } + //public void addProductByUserId(Long userId, Long productId, Long amount) { + shoppingcartService.addProductByUserId(userService.findByUsername(userIdentity.getUsername()).getId(), request.getProductID(), request.getProductAmount()); + return ResponseEntity.ok(successMessage); + } + + @GetMapping("/sellercenter") + @ResponseBody + List getProductInSellerCenter() { + return productService.findBySellerID(userService.findByUsername(userIdentity.getUsername()).getId()); + } + + @DeleteMapping("/{ID}") + ResponseEntity> deleteProduct(@PathVariable long ID) { + Map successMessage = Collections.singletonMap("message", "成功刪除"); + Map failMessage = Collections.singletonMap("message", "刪錯商品嚕"); + + Product p = productService.getID(ID); + + if (!Objects.equals(userService.findByUsername(userIdentity.getUsername()).getId(), p.getSellerID())) { + return ResponseEntity.badRequest().body(failMessage); + } + p.setProductAmount(0L); + p.setVisible(false); + productService.store(p); + + return ResponseEntity.ok(successMessage); + } + + @PutMapping("/fixedproduct/{ID}") + ResponseEntity> putFixedProduct(@PathVariable long ID, @Valid @RequestBody UpdateFixedPriceProductRequest request) { + + Map successMessage = Collections.singletonMap("message", "成功更新不二價商品"); + Map failMessage = Collections.singletonMap("message", "更新錯商品嚕"); + + Product product = productService.getID(ID); + if (!Objects.equals(userService.findByUsername(userIdentity.getUsername()).getId(), product.getSellerID())) { + return ResponseEntity.badRequest().body(failMessage); + } + product.setProductName(request.getProductName()); + product.setProductDescription(request.getProductDescription()); + product.setProductImage(request.getProductImage()); + product.setProductType(request.getProductType()); + product.setCurrentPrice(request.getCurrentPrice()); + product.setProductAmount(request.getProductAmount()); + + productService.store(product); + return ResponseEntity.ok(successMessage); + } + + @PutMapping("/nonfixedproduct/{ID}") + ResponseEntity> putNonFixedProduct(@PathVariable long ID, @Valid @RequestBody UpdateNonFixedPriceProductRequest request) { + + Map successMessage = Collections.singletonMap("message", "成功更新競標商品"); + Map failToPostponeAuction = Collections.singletonMap("message", "延長競標截止時間失敗,因為有人得標嚕"); + Map fail = Collections.singletonMap("message", "截止時間錯誤"); + Map failToSetUpsetPrice = Collections.singletonMap("message", "底價不得更改,因為有人出價了"); + Map failToSetBidIncrement = Collections.singletonMap("message", "每次增加金額不得更改,因為有人出價了"); + Map failMessage = Collections.singletonMap("message", "更新錯商品嚕阿"); + Product product = productService.getID(ID); + + if (!Objects.equals(userService.findByUsername(userIdentity.getUsername()).getId(), product.getSellerID())) { + return ResponseEntity.badRequest().body(failMessage); + } + product.setProductName(request.getProductName()); + product.setProductDescription(request.getProductDescription()); + product.setProductImage(request.getProductImage()); + product.setProductType(request.getProductType()); + + + Map productMap = product.getBidInfo(); + if (!productMap.isEmpty() && !Objects.equals(request.getUpsetPrice(), product.getUpsetPrice())) { //map不為空,有人出價過了。且更改的底價 != 原本底價 + return ResponseEntity.badRequest().body(failToSetUpsetPrice); + } + product.setUpsetPrice(request.getUpsetPrice()); + + if (!productMap.isEmpty() && !Objects.equals(request.getBidIncrement(), product.getBidIncrement())) { //map不為空,有人出價過了。且被更改每口叫價 + return ResponseEntity.badRequest().body(failToSetBidIncrement); + } + product.setBidIncrement(request.getBidIncrement()); + + LocalDateTime now = LocalDateTime.now(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + LocalDateTime dateTime = LocalDateTime.parse(request.getFinishTime(), formatter); + if (!now.isBefore(dateTime)) { + return ResponseEntity.badRequest().body(fail); + } + if (product.getIsAuction()) { //代表競標結束且有被加入購物車 + return ResponseEntity.badRequest().body(failToPostponeAuction); + } + + product.setFinishTime(dateTime); + + productService.store(product); + return ResponseEntity.ok(successMessage); + } +} diff --git a/src/main/java/ntou/auction/spring/product/entity/Product.java b/src/main/java/ntou/auction/spring/product/entity/Product.java new file mode 100644 index 0000000..ac5e7da --- /dev/null +++ b/src/main/java/ntou/auction/spring/product/entity/Product.java @@ -0,0 +1,84 @@ +package ntou.auction.spring.product.entity; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import ntou.auction.spring.util.AbstractEntity; +import org.hibernate.validator.constraints.Length; + +import java.time.LocalDateTime; +import java.util.Map; + +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "product") +public class Product extends AbstractEntity { + + @NotNull + @Length(min = 1, max = 128) + private String productName; + + + @Length(min = 1, max = 32) + private String productType; + + + @NotNull + private Boolean isFixedPrice; + + + @Length(min = 1, max = 20971520) + private String productDescription; + + @NotNull + private Long sellerID; + + private String sellerName; + + private Long productAmount; + + //followings are non-isFixedPrice feature + + @JsonIgnore + @ElementCollection + @CollectionTable(name = "bidInfo") + private Map bidInfo; + + private Long upsetPrice; //lowest requested price + + @NotNull + private Long currentPrice; + + private Long bidIncrement; + + private Boolean isAuction; //競標商品已經被加進購物車? + + private Boolean visible; + + @NotNull + @JsonFormat(pattern="yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; + + @JsonFormat(pattern="yyyy-MM-dd HH:mm:ss") + private LocalDateTime finishTime; + + // if avatar is more than 5MB, need to modify column length + @Lob + @Column(length = 5242880) + @Length(min = 1, max = 5242880) + private String productImage; + + public boolean isExpired() { + if(isFixedPrice){ + return false; + } + LocalDateTime now = LocalDateTime.now(); + return !now.isBefore(this.finishTime); + } +} diff --git a/src/main/java/ntou/auction/spring/product/repository/ProductRepository.java b/src/main/java/ntou/auction/spring/product/repository/ProductRepository.java new file mode 100644 index 0000000..e7c31ac --- /dev/null +++ b/src/main/java/ntou/auction/spring/product/repository/ProductRepository.java @@ -0,0 +1,33 @@ +package ntou.auction.spring.product.repository; + +import ntou.auction.spring.product.entity.Product; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ProductRepository extends JpaRepository, + JpaSpecificationExecutor { + + Product findByProductName(String productName); + + List findAllByIsFixedPriceFalseAndIsAuctionFalse(); + + List findAllByVisibleTrue(); + Product findById(long id); + + @Query("select p from Product p " + + "where p.productName like %?1% and p.visible = true") //string-like + List findAllByFuzzyProductName(@Param("productName") String productName); + // ?1:productName + + List findBySellerIDAndVisibleTrue(long ID); + + List findAllByProductTypeAndVisibleTrue(String productType); + + +} diff --git a/src/main/java/ntou/auction/spring/product/request/BidRequest.java b/src/main/java/ntou/auction/spring/product/request/BidRequest.java new file mode 100644 index 0000000..aea0b4b --- /dev/null +++ b/src/main/java/ntou/auction/spring/product/request/BidRequest.java @@ -0,0 +1,21 @@ +package ntou.auction.spring.product.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BidRequest { + + @NotNull (message="商品ID不得為空") + private Long productID; + + @NotNull (message="出價不得為空") + @Min (value = 1,message = "出價須為正整數") + private Long bid; + +} diff --git a/src/main/java/ntou/auction/spring/product/request/BuyProductRequest.java b/src/main/java/ntou/auction/spring/product/request/BuyProductRequest.java new file mode 100644 index 0000000..059e62a --- /dev/null +++ b/src/main/java/ntou/auction/spring/product/request/BuyProductRequest.java @@ -0,0 +1,21 @@ +package ntou.auction.spring.product.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BuyProductRequest { + + @NotNull (message="商品ID不得為空") + private Long productID; + + @NotNull (message="商品數量不得為空") + @Min (value = 1,message = "商品至少一個") + private Long productAmount; + +} diff --git a/src/main/java/ntou/auction/spring/product/request/PostFixedPriceProductRequest.java b/src/main/java/ntou/auction/spring/product/request/PostFixedPriceProductRequest.java new file mode 100644 index 0000000..a48a3c7 --- /dev/null +++ b/src/main/java/ntou/auction/spring/product/request/PostFixedPriceProductRequest.java @@ -0,0 +1,41 @@ +package ntou.auction.spring.product.request; + +import jakarta.persistence.Column; +import jakarta.persistence.Lob; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.Length; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PostFixedPriceProductRequest { + + @NotNull (message="商品名稱不得為空") + @Length(min = 1, max = 128 , message = "商品名稱至多32個中文字") + private String productName; + + @NotNull (message="價格不得為空") + @Min (value = 1,message = "價格須為正整數") + private Long currentPrice; + + + @Length(min = 1, max = 32) + private String productType; + + @Length(min = 1, max = 20971520,message = "商品敘述過長") + private String productDescription; + + @Lob + @Column(length = 5242880) + @Length(min = 1, max = 5242880 ,message = "圖片檔案過大,請重新上傳") + private String productImage; + + @NotNull (message="商品數量不得為空") + @Min (value = 1,message = "商品至少一個") + private Long productAmount; + +} diff --git a/src/main/java/ntou/auction/spring/product/request/PostNonFixedPriceProductRequest.java b/src/main/java/ntou/auction/spring/product/request/PostNonFixedPriceProductRequest.java new file mode 100644 index 0000000..377b611 --- /dev/null +++ b/src/main/java/ntou/auction/spring/product/request/PostNonFixedPriceProductRequest.java @@ -0,0 +1,48 @@ +package ntou.auction.spring.product.request; + +import jakarta.persistence.Column; +import jakarta.persistence.Lob; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.Length; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PostNonFixedPriceProductRequest { + + @NotNull (message="商品名稱不得為空") + @Length(min = 1, max = 128 , message = "商品名稱至多32個中文字") + private String productName; + + @NotNull + @Min (value = 1,message = "價格須為正整數") + private Long upsetPrice; //lowest requested price + + @NotNull + @Min (value = 1,message = "每口叫價須為正整數") + private Long bidIncrement; + + @NotNull (message="商品數量不得為空") + private Long productAmount; + + @NotNull + private String finishTime; + + @Length(min = 1, max = 32) + private String productType; + + @Length(min = 1, max = 20971520,message = "商品敘述過長") + private String productDescription; + + @Lob + @Column(length = 5242880) + @Length(min = 1, max = 5242880 ,message = "圖片檔案過大,請重新上傳") + private String productImage; + + + +} diff --git a/src/main/java/ntou/auction/spring/product/request/ProductRequestGet.java b/src/main/java/ntou/auction/spring/product/request/ProductRequestGet.java new file mode 100644 index 0000000..a74b1ee --- /dev/null +++ b/src/main/java/ntou/auction/spring/product/request/ProductRequestGet.java @@ -0,0 +1,24 @@ +package ntou.auction.spring.product.request; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.Length; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ProductRequestGet { + + + + @Length(min = 1, max = 128) + private String productName; + + @Length(min = 1, max = 32) + private String productType; + + @NotNull(message = "請填寫搜尋方式") + private String searchType; +} diff --git a/src/main/java/ntou/auction/spring/product/request/UpdateFixedPriceProductRequest.java b/src/main/java/ntou/auction/spring/product/request/UpdateFixedPriceProductRequest.java new file mode 100644 index 0000000..7fba4ae --- /dev/null +++ b/src/main/java/ntou/auction/spring/product/request/UpdateFixedPriceProductRequest.java @@ -0,0 +1,41 @@ +package ntou.auction.spring.product.request; + +import jakarta.persistence.Column; +import jakarta.persistence.Lob; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.Length; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UpdateFixedPriceProductRequest { + + @NotNull + @Length(min = 1, max = 128 , message = "商品名稱至多32個中文字") + private String productName; + + @NotNull + @Min (value = 1,message = "價格須為正整數") + private Long currentPrice; + + + @Length(min = 1, max = 32) + private String productType; + + @Length(min = 1, max = 20971520,message = "商品敘述過長") + private String productDescription; + + @Lob + @Column(length = 5242880) + @Length(min = 1, max = 5242880 ,message = "圖片檔案過大,請重新上傳") + private String productImage; + + @NotNull + @Min (value = 1,message = "商品至少一個") + private Long productAmount; + +} diff --git a/src/main/java/ntou/auction/spring/product/request/UpdateNonFixedPriceProductRequest.java b/src/main/java/ntou/auction/spring/product/request/UpdateNonFixedPriceProductRequest.java new file mode 100644 index 0000000..36f27bf --- /dev/null +++ b/src/main/java/ntou/auction/spring/product/request/UpdateNonFixedPriceProductRequest.java @@ -0,0 +1,48 @@ +package ntou.auction.spring.product.request; + +import jakarta.persistence.Column; +import jakarta.persistence.Lob; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.Length; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UpdateNonFixedPriceProductRequest { + + @NotNull (message="商品名稱不得為空") + @Length(min = 1, max = 128 , message = "商品名稱至多32個中文字") + private String productName; + + @NotNull + @Min (value = 1,message = "價格須為正整數") + private Long upsetPrice; //lowest requested price + + @NotNull + @Min (value = 1,message = "每口叫價須為正整數") + private Long bidIncrement; + + @NotNull (message="商品數量不得為空") + private Long productAmount; + + @NotNull + private String finishTime; + + @Length(min = 1, max = 32) + private String productType; + + @Length(min = 1, max = 20971520,message = "商品敘述過長") + private String productDescription; + + @Lob + @Column(length = 5242880) + @Length(min = 1, max = 5242880 ,message = "圖片檔案過大,請重新上傳") + private String productImage; + + + +} diff --git a/src/main/java/ntou/auction/spring/product/service/ProductService.java b/src/main/java/ntou/auction/spring/product/service/ProductService.java new file mode 100644 index 0000000..8f3eaa6 --- /dev/null +++ b/src/main/java/ntou/auction/spring/product/service/ProductService.java @@ -0,0 +1,102 @@ +package ntou.auction.spring.product.service; +import ntou.auction.spring.product.repository.ProductRepository; +import ntou.auction.spring.product.entity.Product; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + + +@Service +public class ProductService { + + private final ProductRepository repository; + + + public ProductService(ProductRepository repository) { + this.repository = repository; + } + + public Product get(String productName) { + return repository.findByProductName(productName); + } +/* + public Product update(Product entity) { + return repository.save(entity); + } + + public void delete(Long id) { + Optional maybeUser = repository.findById(id); + if (maybeUser.isPresent()) { + Product product = maybeUser.get(); + repository.deleteById(id); + } + } +*/ + public List list() { + return repository.findAllByVisibleTrue(); + } //browse homepage + + public Product getID(Long id){ + return repository.findById(id).orElse(null); + } + + public int count() { + return (int) repository.count(); + } + + public void store(Product product) { + repository.save(product); + } + + + public boolean isBidReasonable(Long bid, Long id) { + Product pr = this.getID(id); + if(pr.getCurrentPrice().equals(pr.getUpsetPrice()) && bid >= pr.getUpsetPrice() && pr.getBidInfo().isEmpty()){ + return true; + } + return (bid - pr.getCurrentPrice()) >= pr.getBidIncrement(); + } + public void bid(Long bid,Long id,Long userID){ + if (this.isBidReasonable(bid,id)){ + System.out.println("合理"); + Product product = this.getID(id); + product.setCurrentPrice(bid); + Map bidInfo = product.getBidInfo(); + bidInfo.put(userID,bid); + this.store(product); + } + } + + public void productAmountDecrease(Long id,Long decrement){ + Product product = this.getID(id); + Long productAmount = product.getProductAmount(); + product.setProductAmount(productAmount - decrement); + this.store(product); + } + public void productAmountIncrease(Long id,Long increment){ + Product product = this.getID(id); + Long productAmount = product.getProductAmount(); + product.setProductAmount(productAmount + increment); + this.store(product); + } + public void deleteProduct(Long id){ + Product product = this.getID(id); + product.setVisible(false); + this.store(product); + } + + public List findByProductName(String productName) { + return repository.findAllByFuzzyProductName(productName); + } + + public List findByProductClassification(String productType){ + return repository.findAllByProductTypeAndVisibleTrue(productType); + } + + public List findByProductNonFixed(){ + return repository.findAllByIsFixedPriceFalseAndIsAuctionFalse(); + } + + public List findBySellerID(Long sellerID){return repository.findBySellerIDAndVisibleTrue(sellerID);}//賣家中心 +} diff --git a/src/main/java/ntou/auction/spring/product/service/TimerTask.java b/src/main/java/ntou/auction/spring/product/service/TimerTask.java new file mode 100644 index 0000000..1d739c5 --- /dev/null +++ b/src/main/java/ntou/auction/spring/product/service/TimerTask.java @@ -0,0 +1,49 @@ +package ntou.auction.spring.product.service; + +import ntou.auction.spring.mail.EmailService; +import ntou.auction.spring.shoppingcart.service.ShoppingcartService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import ntou.auction.spring.product.entity.Product; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Component +public class TimerTask { + + private final ProductService productService; + private final ShoppingcartService shoppingcartService; + private final EmailService emailService; + + public TimerTask(ProductService productService, ShoppingcartService shoppingcartService, EmailService emailService) { + this.productService = productService; + this.shoppingcartService = shoppingcartService; + this.emailService = emailService; + } + + @Transactional + @Scheduled(cron = "0 * * * * ?") //每分鐘的第0秒 + public void execute() { + List productList = productService.findByProductNonFixed(); + + for (Product product : productList) { + System.out.println(product.getId()); + if (product.isExpired()) { //競標結束 + Map productMap = product.getBidInfo(); + + + Optional> max0 = productMap.entrySet() + .stream().max(Map.Entry.comparingByValue()); + if (max0.isPresent()) { + shoppingcartService.addProductByUserId(max0.get().getKey(), product.getId(), 1L); + product.setIsAuction(true); + productService.store(product); + emailService.sendMailBidSuccess(max0.get().getKey(), product); + } + } + } + } +} diff --git a/src/main/java/ntou/auction/spring/shoppingcart/controller/ShoppingcartController.java b/src/main/java/ntou/auction/spring/shoppingcart/controller/ShoppingcartController.java new file mode 100644 index 0000000..3b61154 --- /dev/null +++ b/src/main/java/ntou/auction/spring/shoppingcart/controller/ShoppingcartController.java @@ -0,0 +1,115 @@ +package ntou.auction.spring.shoppingcart.controller; + +import jakarta.validation.Valid; +import ntou.auction.spring.account.entity.User; +import ntou.auction.spring.product.service.ProductService; +import ntou.auction.spring.shoppingcart.service.ShoppingcartService; +import ntou.auction.spring.account.response.UserIdentity; +import ntou.auction.spring.account.service.UserService; +import ntou.auction.spring.product.entity.Product; +import ntou.auction.spring.shoppingcart.entity.Shoppingcart; +import ntou.auction.spring.shoppingcart.response.ProductAddAmount; +import ntou.auction.spring.shoppingcart.response.ProductClassificatedBySeller; +import ntou.auction.spring.shoppingcart.request.ShoppingcartRequest; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +@RestController +@RequestMapping(value = "/api/v1/shoppingcart", produces = MediaType.APPLICATION_JSON_VALUE) +@CrossOrigin(origins = "http://localhost:3000") +public class ShoppingcartController { + private final ShoppingcartService shoppingcartService; + private final ProductService productService; + private static final Map successMessage = Collections.singletonMap("message","成功"); + private static final Map failMessage = Collections.singletonMap("message","操作失敗"); + + private static final Map ErrorIdMessage = Collections.singletonMap("message","商品不存在"); + + private static final Map ErrorAmountZeroMessage = Collections.singletonMap("message","商品數量不可變為負的"); + + private static final Map ErrorAmountExceedMessage = Collections.singletonMap("message","加入的商品數量過多"); + + private final UserService userService; + + private final UserIdentity userIdentity; + + public ShoppingcartController(ShoppingcartService shoppingcartService, ProductService productService, UserService userService, UserIdentity userIdentity) { + this.shoppingcartService = shoppingcartService; + this.productService = productService; + this.userService = userService; + this.userIdentity = userIdentity; + } + /* + @GetMapping("/view") + @ResponseBody + List getShoppingcartProfile() { return shoppingcartService.list(); } + */ + @GetMapping("/shoppingcart") + @ResponseBody + ProductClassificatedBySeller getProduct() { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + Shoppingcart userShoppingcart = shoppingcartService.getByUserId(userId); + if(userShoppingcart==null) return null; + Map> result = new HashMap<>(); + for(Map.Entry product: userShoppingcart.getProductItems().entrySet()) { + System.out.println(product.getKey() + " " + product.getValue()); + Product nowProduct = productService.getID(product.getKey()); + Long sellerId = nowProduct.getSellerID(); + Optional sellerUser = userService.get(sellerId); + if (sellerUser.isEmpty()) { + shoppingcartService.deleteProductByUserId(userId, product.getKey()); + continue; + } + String sellerName = sellerUser.get().getUsername(); + if (!result.containsKey("@" + sellerName)) { + result.put("@" + sellerName, new ArrayList<>()); + } + List getProducts = result.get("@" + sellerName); + if (getProducts == null) getProducts = new ArrayList<>(); + getProducts.add(new ProductAddAmount(nowProduct, product.getValue())); + result.replace(sellerName, getProducts); + } + return new ProductClassificatedBySeller(result); + } + @PostMapping("/increase") + ResponseEntity> addProduct(@Valid @RequestBody ShoppingcartRequest request) { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + Long addProductId = request.getProductId(); + Long amount = request.getAmount(); + if(productService.getID(addProductId)==null) return ResponseEntity.badRequest().body(ErrorIdMessage); + boolean result = shoppingcartService.addProductByUserId(userId, addProductId, amount==null?1L:amount); + return result?ResponseEntity.ok(successMessage):ResponseEntity.badRequest().body(ErrorAmountExceedMessage); + } + + @DeleteMapping("/decrease") + ResponseEntity> decreaseProduct(@Valid @RequestBody ShoppingcartRequest request) { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + Long addProductId = request.getProductId(); + Long amount = request.getAmount(); + Long result = shoppingcartService.decreaseProductByUserId(userId, addProductId, amount==null?1L:amount); + // 0: exist error, 1: amount error, 2: OK + if(result.equals(0L)) return ResponseEntity.badRequest().body(ErrorIdMessage); //shoppingcart does not exist + if(result.equals(1L)) return ResponseEntity.badRequest().body(ErrorAmountZeroMessage); //amount error + return ResponseEntity.ok(successMessage); + } + + + @DeleteMapping("/delete") + ResponseEntity> deleteProduct(@Valid @RequestBody ShoppingcartRequest request) { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + Long addProductId = request.getProductId(); + boolean result = shoppingcartService.deleteProductByUserId(userId, addProductId); + return (result?ResponseEntity.ok(successMessage):ResponseEntity.badRequest().body(ErrorIdMessage)); + } + + @DeleteMapping("/deleteall") + ResponseEntity> deleteAllProduct() { + Long userId = userService.findByUsername(userIdentity.getUsername()).getId(); + boolean result = shoppingcartService.deleteShoppingcartByUserId(userId); + if(!result) return ResponseEntity.badRequest().body(failMessage); + return ResponseEntity.ok(successMessage); + } +} diff --git a/src/main/java/ntou/auction/spring/shoppingcart/entity/Shoppingcart.java b/src/main/java/ntou/auction/spring/shoppingcart/entity/Shoppingcart.java new file mode 100644 index 0000000..50d332f --- /dev/null +++ b/src/main/java/ntou/auction/spring/shoppingcart/entity/Shoppingcart.java @@ -0,0 +1,63 @@ +package ntou.auction.spring.shoppingcart.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import ntou.auction.spring.util.AbstractEntity; + +import java.util.HashMap; +import java.util.Map; + +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "shoppingcart") +public class Shoppingcart extends AbstractEntity { + + @NotNull + private Long userid; + + @ElementCollection + @CollectionTable(name = "productId") + private Map productItems = new HashMap<>(); + + public @NotNull Long getUserId() { + return userid; + } + + public void addProductId(Long product, Long amount) { + if(!productItems.containsKey(product)) productItems.put(product, 0L); + productItems.replace(product, productItems.get(product)+amount); + } + + public Long decreaseProduct(Long product, Long amount) { + if (productItems.get(product) == null) return 0L; + if (productItems.get(product) == 0L) { + productItems.remove(product); + return 0L; + } + if(productItems.get(product) < amount) return 1L; + productItems.replace(product, productItems.get(product) - amount); + if (productItems.get(product) == 0L) productItems.remove(product); + return 2L; + } + + public boolean deleteProduct(Long product) { + if (productItems.get(product) == null) return false; + if (productItems.get(product) == 0L) { + productItems.remove(product); + return false; + } + productItems.remove(product); + return true; + } + + public boolean checkIsEnoughAmountInProductItems(Long product, Long amount) { + if(amount.equals(0L)) return true; // this may not be happened + if(!productItems.containsKey(product)) return false; + return productItems.get(product) >= amount; + } +} diff --git a/src/main/java/ntou/auction/spring/shoppingcart/repository/ShoppingcartRepository.java b/src/main/java/ntou/auction/spring/shoppingcart/repository/ShoppingcartRepository.java new file mode 100644 index 0000000..f03c37c --- /dev/null +++ b/src/main/java/ntou/auction/spring/shoppingcart/repository/ShoppingcartRepository.java @@ -0,0 +1,23 @@ +package ntou.auction.spring.shoppingcart.repository; + +import jakarta.transaction.Transactional; +import ntou.auction.spring.shoppingcart.entity.Shoppingcart; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface ShoppingcartRepository extends JpaRepository, JpaSpecificationExecutor { + @Modifying + @Query(value = "insert into shoppingcart(userId, productId) values (?1, ?2)", nativeQuery = true) + public void addShoppingCart(Long userId, List productId); + + Shoppingcart findById(long id); + //@Query(value = "select s from Shoppingcart s where s.id = ?1") + Optional findByUserid(Long id); + @Transactional + public List deleteByUserid(Long UserId); +} diff --git a/src/main/java/ntou/auction/spring/shoppingcart/request/ShoppingcartRequest.java b/src/main/java/ntou/auction/spring/shoppingcart/request/ShoppingcartRequest.java new file mode 100644 index 0000000..eae457e --- /dev/null +++ b/src/main/java/ntou/auction/spring/shoppingcart/request/ShoppingcartRequest.java @@ -0,0 +1,16 @@ +package ntou.auction.spring.shoppingcart.request; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ShoppingcartRequest { + @NotNull + private Long productId; + @NotNull + private Long amount; +} diff --git a/src/main/java/ntou/auction/spring/shoppingcart/response/ProductAddAmount.java b/src/main/java/ntou/auction/spring/shoppingcart/response/ProductAddAmount.java new file mode 100644 index 0000000..e358e97 --- /dev/null +++ b/src/main/java/ntou/auction/spring/shoppingcart/response/ProductAddAmount.java @@ -0,0 +1,14 @@ +package ntou.auction.spring.shoppingcart.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import ntou.auction.spring.product.entity.Product; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ProductAddAmount{ + private Product product; + private Long amount; +} diff --git a/src/main/java/ntou/auction/spring/shoppingcart/response/ProductClassificatedBySeller.java b/src/main/java/ntou/auction/spring/shoppingcart/response/ProductClassificatedBySeller.java new file mode 100644 index 0000000..d9b2e55 --- /dev/null +++ b/src/main/java/ntou/auction/spring/shoppingcart/response/ProductClassificatedBySeller.java @@ -0,0 +1,18 @@ +package ntou.auction.spring.shoppingcart.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ProductClassificatedBySeller { + private Map> ProductShowBySeller = new HashMap<>(); +} diff --git a/src/main/java/ntou/auction/spring/shoppingcart/service/ShoppingcartService.java b/src/main/java/ntou/auction/spring/shoppingcart/service/ShoppingcartService.java new file mode 100644 index 0000000..6394c86 --- /dev/null +++ b/src/main/java/ntou/auction/spring/shoppingcart/service/ShoppingcartService.java @@ -0,0 +1,105 @@ +package ntou.auction.spring.shoppingcart.service; + +import ntou.auction.spring.product.entity.Product; +import ntou.auction.spring.shoppingcart.entity.Shoppingcart; +import ntou.auction.spring.product.service.ProductService; +import ntou.auction.spring.shoppingcart.repository.ShoppingcartRepository; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Service +public class ShoppingcartService { + private final ShoppingcartRepository repository; + private final ProductService productService; + + public ShoppingcartService(ShoppingcartRepository repository, ProductService productService) { + this.repository = repository; + this.productService = productService; + } + + public Shoppingcart getByUserId(Long userId) { + return repository.findByUserid(userId).orElse(null); + } + + public List list() { + return repository.findAll(); + } + + public void addUser(Long userId) { + List product = new ArrayList<>(); + repository.addShoppingCart(userId, product); + } + + public int count() { + return (int) repository.count(); + } + + public boolean deleteShoppingcartByUserId(Long userId) { + if (repository.findByUserid(userId).isEmpty()) return false; + repository.deleteByUserid(userId); + return true; + } + + public boolean addProductByUserId(Long userId, Long productId, Long amount) { + Shoppingcart userShoppingcart = getByUserId(userId); + if (userShoppingcart == null) { + Map product = new HashMap<>(); + Shoppingcart newShoppingcart = new Shoppingcart(userId, product); + repository.save(newShoppingcart); + userShoppingcart = getByUserId(userId); + } + Product product = productService.getID(productId); + if (product == null) return false; //already been checked + Long alreadyAmount = userShoppingcart.getProductItems().get(productId) == null ? 0L : userShoppingcart.getProductItems().get(productId); + if (alreadyAmount + amount > product.getProductAmount()) return false; + userShoppingcart.addProductId(productId, amount); + repository.save(userShoppingcart); + return true; + } + + public Long decreaseProductByUserId(Long userId, Long productId, Long amount) { + Shoppingcart userShoppingcart = getByUserId(userId); + if (userShoppingcart == null) return 0L; + Long result = userShoppingcart.decreaseProduct(productId, amount); + if (!result.equals(2L)) return result; + repository.save(userShoppingcart); + if (userShoppingcart.getProductItems().isEmpty()) repository.deleteByUserid(userId); + return 2L; + } + + public boolean deleteProductByUserId(Long userId, Long productId) { + Shoppingcart userShoppingcart = getByUserId(userId); + if (userShoppingcart == null) return false; + boolean result = userShoppingcart.deleteProduct(productId); + if (!result) return false; + repository.save(userShoppingcart); + if (userShoppingcart.getProductItems().isEmpty()) repository.deleteByUserid(userId); + return true; + } + + public boolean checkIsEnoughAmount(Long userId, Long productId, Long amount) { + Shoppingcart userShoppingcart = getByUserId(userId); + if (userShoppingcart == null) return false; + return userShoppingcart.checkIsEnoughAmountInProductItems(productId, amount); + } + + public Long checkIsProductAllInShoppingCart(List> order, Long userid) { + // -1: format error, 0: false, 1: true + for(List product: order) { + if(product.size()!=2) return -1L; + if(!checkIsEnoughAmount(userid, product.get(0), product.get(1))) { + return 0L; + } + } + return 1L; + } + + public boolean checkIsViolateSelfBuying(List> order, Long userid) { + for(List product: order) { + Product nowProduct = productService.getID(product.getFirst()); + if(nowProduct.getSellerID().equals(userid)) return true; + } + return false; + } +} diff --git a/src/main/java/ntou/auction/spring/util/AbstractEntity.java b/src/main/java/ntou/auction/spring/util/AbstractEntity.java new file mode 100644 index 0000000..115a47c --- /dev/null +++ b/src/main/java/ntou/auction/spring/util/AbstractEntity.java @@ -0,0 +1,54 @@ +package ntou.auction.spring.util; + +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Version; + +@MappedSuperclass +public abstract class AbstractEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = + "idgenerator") + // The initial value is to account for data.sql demo data ids + @SequenceGenerator(name = "idgenerator", initialValue = 2) + private Long id; + + @Version + private int version; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public int getVersion() { + return version; + } + + @Override + public int hashCode() { + if (getId() != null) { + return getId().hashCode(); + } + return super.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof AbstractEntity other)) { + return false; // null or other class + } + + if (getId() != null) { + return getId().equals(other.getId()); + } + return super.equals(other); + } +} diff --git a/src/main/java/ntou/auction/spring/util/AppConfig.java b/src/main/java/ntou/auction/spring/util/AppConfig.java new file mode 100644 index 0000000..dc1479f --- /dev/null +++ b/src/main/java/ntou/auction/spring/util/AppConfig.java @@ -0,0 +1,40 @@ +package ntou.auction.spring.util; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + + +@ConfigurationProperties(prefix = "app") +@Component +@Data +public class AppConfig { + + // default password encoder can be set by idForEncode + // BCrypt pbkdf2 argon2 + // @Value("{security.password.encoder:argon2}") + private String idForEncode; + + // only file size below 10MB can be uploaded + // you can modify this value, but the limit is 2047MB + // @Value("{upload.image.size:10}") + private int maxImageSizeInMegaBytes; + + // max 3 files can be uploaded + // you can modify this value + // @Value("{upload.image.number:3}") + private int maxImageFiles; + + // only file size below 3MB can be uploaded + // you can modify this value, but the limit is 2047MB + // @Value("{upload.avatar.size:3}") + private int maxAvatarSizeInMegaBytes; + + // The default image size limit for new sign-up users + private int newSignupImageSizeLimit; + + private String mailUsername; + + private String JWTKey; + +} \ No newline at end of file diff --git a/src/main/java/ntou/auction/spring/util/RestResponseEntityExceptionHandler.java b/src/main/java/ntou/auction/spring/util/RestResponseEntityExceptionHandler.java new file mode 100644 index 0000000..b5ea398 --- /dev/null +++ b/src/main/java/ntou/auction/spring/util/RestResponseEntityExceptionHandler.java @@ -0,0 +1,54 @@ +package ntou.auction.spring.util; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import ntou.auction.spring.chat.exception.MessageNotFound; +import org.apache.tomcat.util.buf.StringUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import java.util.*; + +@ControllerAdvice +public class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler { + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolationException(ConstraintViolationException ex) { + ConstraintViolation constraintViolation = ex.getConstraintViolations().iterator().next(); + String errorMessage = constraintViolation.getMessage(); + Map response = Collections.singletonMap("message", errorMessage); + return ResponseEntity.badRequest().body(response); + } + + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(MessageNotFound.class) + public ResponseEntity> handleNoHandlerFoundException(MessageNotFound ex) { + String errorMessage = ex.getMessage(); + Map response = Collections.singletonMap("message", errorMessage); + return ResponseEntity.badRequest().body(response); + } + + @Override + protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { + List errors = new ArrayList<>(); + for (FieldError error : ex.getBindingResult().getFieldErrors()) { + errors.add(error.getDefaultMessage()); + } + for (ObjectError error : ex.getBindingResult().getGlobalErrors()) { + errors.add(error.getDefaultMessage()); + } + Map response = Collections.singletonMap("message", StringUtils.join(errors,'\n')); + return handleExceptionInternal( + ex, response, headers, status, request); + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..1fd37a7 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,46 @@ +# port number +server.port=${PORT:8080} + +# mariadb database +# For persistent storage without dropping the database when the app stops, choose the "update" option. +# For development mode where the database is created when the app starts and dropped when it stops, choose the "create-drop" option. +spring.jpa.hibernate.ddl-auto=update +spring.datasource.url=${NA_DB_URL} +spring.datasource.username=${NA_DB_USER} +spring.datasource.password=${NA_DB_PASSWORD} +spring.datasource.driver-class-name=org.mariadb.jdbc.Driver +spring.jpa.defer-datasource-initialization=true +spring.sql.init.mode=always + +# mail +spring.mail.host=${NA_MAIL_PROVIDER} +spring.mail.port=587 +spring.mail.username=${NA_MAIL_USER} +spring.mail.password=${NA_MAIL_PASSWORD} +app.mail-username=${NA_MAIL_USER} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true + +# upload limit +# you need modify this value if you want upload bigger image +spring.servlet.multipart.max-file-size=30MB +spring.servlet.multipart.max-request-size=30MB +# only file size below 10MB can be uploaded +# you can modify this value, but the limit is 2047MB +app.maxImageSizeInMegaBytes=10 +# max 3 files can be uploaded +# you can modify this value +app.maxImageFiles=3 +# only file size below 3MB can be uploaded +# you can modify this value, but the limit is 5MB +app.maxAvatarSizeInMegaBytes=3 +# The default image size limit for new sign-up users +# you can modify this value +app.newSignupImageSizeLimit=30 +# security +# password encoder +# BCrypt pbkdf2 argon2 +app.idForEncode=argon2 +app.JWTKey=${NA_JWT_KEY} + + diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..ebcf5fa --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,35 @@ +insert into user (version, id, username, name, hashed_password, avatar_image, avatar_image_name, email, + enabled, is_account_non_expired, is_account_non_locked, is_credentials_non_expired) +values (1, 1, 'admin', 'admin', + '{argon2}$argon2id$v=19$m=16384,t=2,p=1$S9swkCEbXj5O8cwbZdyrtQ$4RkxTGl3xkEfm51P/gc3FORCysPStozAsm6smOnQR18', + null, null, 'admin@example.com', true, true, true, true); +/* +insert into user (version, id, username, name, hashed_password, avatar_image, avatar_image_name, email, + enabled, is_account_non_expired, is_account_non_locked, is_credentials_non_expired) +values (1, 2, 'shit', 'admin', + '{argon2}$argon2id$v=19$m=16384,t=2,p=1$S9swkCEbXj5O8cwbZdyrtQ$4RkxTGl3xkEfm51P/gc3FORCysPStozAsm6smOnQR18', + null, null, 'weichun@example.com', true, true, true, true); +*/ +insert into user_roles (user_id, roles) +values (1, 'USER'); +/* +insert into user_roles (user_id, roles) +values (2, 'USER'); +*/ +insert into user_roles (user_id, roles) +values (1, 'ADMIN'); +/* +insert into product (version,id,product_name,product_type,is_fixed_price,product_description,sellerid,seller_name,upset_price,product_amount,current_price,update_time,finish_time,bid_increment,product_image,visible) +values (1,1,'IPhone13','3C產品',true,'ggggg',1,'admin',null,3,1500,"2023-11-04 19:45:00",null,null,null,true); + + +insert into product (version,id,product_name,product_type,is_fixed_price,product_description,sellerid,seller_name,upset_price,product_amount,current_price,update_time,finish_time,bid_increment,product_image,visible) +values (1,2,'IPhone18','3C產品',true,'ggggg',1,'admin',null,3,1500,"2023-11-04 19:45:00",null,null,null,true); + + +insert into product (version,id,product_name,product_type,is_fixed_price,product_description,sellerid,upset_price,product_amount,current_price,update_time,finish_time,bid_increment,product_image,visible) +values (1,3,'IPhone13','3C產品',false,'ggggg',1,1000,1,1500,"2023-11-04 19:45:00","2023-11-04 20:45:00",1000,null,true); + +insert into product (version,id,product_name,product_type,is_fixed_price,product_description,sellerid,upset_price,product_amount,current_price,update_time,finish_time,bid_increment,product_image,visible) +values (1,4,'IPhone13','3C產品',false,'ggggg',1,1000,1,1500,"2023-11-04 19:45:00","2023-11-30 18:45:00",1000,null,true); +*/ \ No newline at end of file diff --git a/src/test/java/ntou/auction/spring/NtouAuctionJavaApplicationTests.java b/src/test/java/ntou/auction/spring/NtouAuctionJavaApplicationTests.java deleted file mode 100644 index d6b285b..0000000 --- a/src/test/java/ntou/auction/spring/NtouAuctionJavaApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package ntou.auction.spring; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class NtouAuctionJavaApplicationTests { - - @Test - void contextLoads() { - } - -}