스프링수업 10일차
한 일
- category sorting
- 상품페이지 만들기
- 상품추가
- 상품 수정/삭제
category sorting
- AdminCategoryController -
@GetMapping
private String index(Model model) {
List<Category> categories = categoryRepo.findAllByOrderBySortingAsc();
model.addAttribute("categories", categories);
return "admin/categories/index";
}
...
@PostMapping("/reorder")
public @ResponseBody String reorder(@RequestParam("id[]") int[] id) {
int count = 1;
Category category;
for (int categoryId : id) {
category = categoryRepo.getById(categoryId); // DB에서 id로 category객체 검색
category.setSorting(count); // setSorting에 count값을 넣어줌
categoryRepo.save(category); //sorting 값을 순서대로 저장
count++;
}
return "ok"; // view페이지가 아니라 문자열 ok로 리턴
}
- CategoryRepository.inferface -
List<Category> findAllByOrderBySortingAsc();
=> 이름이 명시되어있는 메서드이므로 메서드명의 변경될부분과 변경되면 안되는 부분을 유의할것.
처음에 List<Category> findAllOrderBySortingAsc();
로 작성해서 오류발생함.
순서변경시 sorting에 저장되어 그대로 출력됨.
상품페이지 만들기
products테이블 생성
CREATE TABLE IF NOT EXISTS products (
id int not null aUTO_INCREMENT,
name VARCHAR(45) not null,
slug VARCHAR(45) not null,
description text not null,
image VARCHAR(45) not null,
price INT not null,
category_id int not null,
create_at timestamp not null,
update_at timestamp not null,
PRIMARY KEY (id)
);
text: 용량이 큰 문자열자료형
decimal(x,y): 소수점까지 나타내는 숫자형. 정수 x자리, 소수 y자리까지 표현
timestamp: 날짜, 시간까지 표현하는 자료형
새 클래스 생성
- Product -
@Entity
@Table(name = "products")
@Data //겟,셋 생성자, toString 생성됨
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@NotBlank(message = "품명을 입력해 주세요.")
@Size(min = 2, message = "품명은 2자 이상")
private String name;
private String slug;
private String description; // 상품설명
private String image; // 상품이미지 주소
private String price; // 문자열로 지정 후 변환해서 사용
@Column(name = "category_id") // DB의 테이블명과 다를경우 @column을 이용해 매핑
private String categoryId; // 상품의 카테고리ID
@Column(name = "create_at")
@CreationTimestamp // insert시 자동으로 시각이 입력됨
private LocalDateTime createAt; // 상품 등록 시간
@Column(name = "update_at") // update시 자동으로 시각이 입력됨
private LocalDateTime updateAt; // 상품 업데이트 시간
}
새 인터페이스 생성
- ProductRepository -
public interface ProductRepository extends JpaRepository<Product, Integer>{
}
새 클래스 생성
- AdminProductController -
@Controller
@RequestMapping("/admin/products")
public class AdminProductController {
@Autowired
private ProductRepository productRepo;
@GetMapping
public String index(Model model) {
List<Product> products = productRepo.findAll();
model.addAttribute("products", products);
return "admin/products/index";
}
}
인덱스 페이지 생성
- index.html -
<main role="main" class="container">
<div class="display-2">Products</div>
<a th:href="@{/admin/products/add}" class="btn btn-primary my-3">추가하기</a>
<div th:if="${message}" th:class="${'alert ' + alertClass}" th:text="${message}"></div>
<div th:if="${!products.empty}">
<table class="table" id="products">
<tr>
<th>상품명</th>
<th>이미지</th>
<th>카테고리</th>
<th>가 격</th>
<th>수 정</th>
<th>삭 제</th>
</tr>
<tr th:each="product : ${products}">
<td th:text="${product.name}"></td>
<td th:text="${product.image}"></td>
<td th:text="${product.categoryId}"></td>
<td th:text="${product.price}"></td>
<td><a th:href="@{'/admin/products/edit/' + ${product.id}}">수정</a></td>
<td><a class="deleteConfirm" th:href="@{'/admin/products/delete/' + ${product.id}}">삭제</a></td>
</tr>
</table>
</div>
<div th:if="${products.empty}">
<div class="display-4">등록된 상품이 없습니다...</div>
</div>
</main>
http://localhost:8080/admin/products
상품페이지 정상출력 확인.
상품추가
상품추가 페이지
앞서 만든 admin categorycontroller를 가져와 수정.
- AdminProductController -
@Autowired
private CategoryRepository categoryRepo;
...
@GetMapping("/add")
public String add(@ModelAttribute Product product, Model model) {
List<Category> categories = categoryRepo.findAll();
model.addAttribute("categories", categories);
// 상품을 추가하는 add 페이지에 상품객체와 상품의 카테고리를 선택할수있도록 카테고리 리스트도 전달
return "admin/products/add";
}
=> 상품을 추가할 때 카테고리정보도 필요하므로 CategoryRepository
도 가져옴.
- add.html -
<div class="container">
<div class="display-2">카테고리 추가</div>
<a th:href="@{/admin/products}" class="btn btn-primary my-3">돌아가기</a>
<form method="post" enctype="multipart/form-data" th:object="${product}" th:action="@{/admin/products/add}">
<div th:if="${#fields.hasErrors('*')}" class="alert alert-danger">에러 발생</div>
<div th:if="${message}" th:class="${'alert ' + alertClass}" th:text="${message}"></div>
<div class="form-group">
<label for="">상품명</label>
<input type="text" class="form-control" th:field="*{name}" placeholder="상품명" />
<span class="error" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></span>
</div>
<div class="form-group">
<label for="">상품설명</label>
<input type="text" class="form-control" th:field="*{description}" placeholder="상품설명" />
<span class="error" th:if="${#fields.hasErrors('description')}" th:errors="*{description}"></span>
</div>
<div class="form-group">
<label for="file">이미지</label>
<input type="file" class="form-control" th:id="file" placeholder="상품 이미지" />
<!-- 미리 올릴 이미지를 표시 -->
<img src="#" id="imgPreview" />
</div>
<div class="form-group">
<label for="">가 격</label>
<input type="text" class="form-control" th:field="*{price}" placeholder="가격" />
<span class="error" th:if="${#fields.hasErrors('price')}" th:errors="*{price}"></span>
</div>
<div class="form-group">
<label for="">카테고리</label>
<select th:field="*{categoryId}" class="form-control">
<option value="0">카테고리 선택</option>
<option th:each="category : ${categories}" th:value="${category.id}" th:th:text="${category.name}"></option>
</select>
<span class="error" th:if="${#fields.hasErrors('categoryId')}" th:error="*{categoryId}"></span>
</div>
<button type="submit" class="btn btn-danger">추 가</button>
</form>
</div>
<form enctype="multipart/form-data">
: 서버전송시 데이터 인코딩, 변환을 명시할때 사용. 파일, 이미지 서버전송시 multipart/form-data 사용.
http://localhost:8080/admin/products/add
유효성 검사
- Product -
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@NotBlank(message = "품명을 입력해 주세요.")
@Size(min = 2, message = "품명은 2자 이상")
private String name;
private String slug;
@NotBlank(message = "상품설명을 입력해 주세요.")
@Size(min = 2, message = "설명은 2자 이상")
private String description; // 상품설명
private String image; // 상품이미지 주소
@Pattern(regexp = "^[1-9][0-9]*") // 1 ~ 999999999 까지 사용가능
private String price; // 문자열로 지정 후 변환해서 사용
@Pattern(regexp = "^[1-9][0-9]*", message = "카테고리를 선택해주세요")
@Column(name = "category_id") // DB의 테이블명과 다를경우 @column을 이용해 매핑
private String categoryId; // 상품의 카테고리ID
@Column(name = "create_at", updatable = false)
@CreationTimestamp // insert시 자동으로 시각이 입력됨
private LocalDateTime createAt; // 상품 등록 시간
@Column(name = "update_at")
@UpdateTimestamp // update시 자동으로 시각이 입력됨
private LocalDateTime updateAt; // 상품 업데이트 시간
@Pattern(regexp = "^[1-9][0-9]*")
: 첫 문자는 1 ~ 9중 하나만 사용가능. 두번째 문자부터는 0 ~ 9까지의 문자 중 하나 사용가능. * 는 여러번 반복을 허용한다는 의미.
이외에는 모두 에러처리.
[자바 정규표현식://coding-factory.tistory.com/529)
@Column(name = "create_at", updatable = false)
: 등록했을때 최초 한번의 시각만 입력하도록 함. 즉, 시각은 업데이트x.
- add.html -
script태그 추가
<script>
$(function () {
$('#imgPreview').hide(); // 처음에 숨김
$('#file').change(function () {
// 파일 변경시(새로올림, 교체) 이벤트 발생
readURL(this); // readURL 함수 실행
});
});
function readURL(input) {
// 파일(이미지)이 있을 경우 실행
if (input.files && input.files[0]) {
let reader = new FileReader(); // 파일리더 객체생성
reader.readAsDataURL(input.files[0]); // 파일리더로 첫번째 파일경로 읽기
// 파일리더가 주소를 다 읽으면 onload 이벤트가 발생하고 이때 화면에 img를 출력
reader.onload = function (e) {
$('imgPreview').attr('src', e.target.result).width(200).show();
};
}
}
</script>
상품 추가하기
- AdminProductController -
@PostMapping("/add")
public String add(@Valid Product product, BindingResult bindingResult, MultipartFile file,
RedirectAttributes attr, Model model) throws IOException {
if (bindingResult.hasErrors()) {
List<Category> categories = categoryRepo.findAll();
model.addAttribute("categories", categories);
return "admin/products/add"; // 유효성검사 에러발생시 되돌아감
}
boolean fileOk = false;
byte[] bytes = file.getBytes(); // 업로드된 img파일의 데이터
String fileName = file.getOriginalFilename(); // 파일의 이름
Path path = Paths.get("src/main/resources/static/media/" + fileName); // 파일을 저장할 위치와 이름까지
if (fileName.endsWith("jpg") || fileName.endsWith("png")) {
fileOk = true; // 확장자가 jpg, png인 파일만 true
}
// 성공적으로 추가됨
attr.addFlashAttribute("message", "상품이 성공적으로 추가됨!");
attr.addFlashAttribute("alertClass", "alert-success");
// 슬러그 만들기
String slug = product.getName().toLowerCase().replace(" ", "-");
// 동일한 상품명이 있는지 검사
Product productExists = productRepo.findByName(product.getName());
if(!fileOk) { // 파일 업로드가 안됐거나 확장자가 jpg, png가 아닌경우
attr.addFlashAttribute("message", "이미지는 jpg나 png파일을 사용해주세요!");
attr.addFlashAttribute("alertClass", "alert-danger");
attr.addFlashAttribute("product", product);
} else if (productExists != null) { // 동일한 상품명이 DB에 존재
attr.addFlashAttribute("message", "이미 존재하는 상품명입니다.");
attr.addFlashAttribute("alertClass", "alert-danger");
attr.addFlashAttribute("product", product);
} else { // 상품과 이미지 파일을 저장함
product.setSlug(slug);
product.setImage(fileName); // img는 파일의 이름만 입력(주소는 /media/폴더 이므로 동일)
productRepo.save(product);
Files.write(path, bytes); // (저장주소, 데이터)
}
return "redirect:/admin/products/add";
}
매개변수 MultipartFile
: 파일을 전달받으므로 필요
String fileName = file.getOriginalFilename();
: 매개변수로 받아온 MultipartFile의 객체 file의 이름을 fileName에 저장
fileName.endsWith("jpg")||fileName.endsWith("png")
: 파일의 확장자가 .jpg, .png인 파일만 통과
- ProductRepository -
Product findByName(String name);
여기서 상품의 이미지를 영문으로 준비해야 아래 이미지를 불러오는 작업에서 에러가 발생하지 않음.
저장된 이미지를 불러올 경로설정
- WebConfig -
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 저장된 이미지파일을 불러올 경로 지정
registry.addResourceHandler("/media/**")
.addResourceLocations("file:///C:/java502/SpringWorkspace/shoppingmall/src/main/resources/static/media/");
}
- application.properties -
서버부하를 막기위해 최대 파일크기, (이미지를 여러개 올릴 때) 총 파일 크기를 지정해줌.
# File upload setting
spring.servlet.multipart.max-file-size = 10MB
spring.servlet.multipart.max-request-size = 30MB
- index.html -
<table class="table" id="products">
<tr>
<th>상품명</th>
<th>이미지</th>
<th>카테고리</th>
<th>가 격</th>
<th>수 정</th>
<th>삭 제</th>
</tr>
<tr th:each="product : ${products}">
<td th:text="${product.name}"></td>
<td>
<img th:src="@{'/media/' + ${product.image}}" style="height: 2em" />
</td>
<td th:text="${product.categoryId}"></td>
<td th:text="${product.price}"></td>
<td><a th:href="@{'/admin/products/edit/' + ${product.id}}">수정</a></td>
<td><a class="deleteConfirm" th:href="@{'/admin/products/delete/' + ${product.id}}">삭제</a></td>
</tr>
</table>
상품의 이미지를 상품전체 테이블에서 미리보기로 볼 수 있도록 img부분 수정.
상품전체 리스트의 카테고리 이름변경
DB에 외래키설정을 해두지 않았으므로 product의 id와 category를 map으로 담아 매칭시킴
- AdminProductController -
index메서드 수정
@GetMapping
public String index(Model model) {
List<Product> products = productRepo.findAll();
List<Category> categories = categoryRepo.findAll();
HashMap<Integer, String> cateIdAndName = new HashMap<>();
for (Category category : categories) {
cateIdAndName.put(category.getId(), category.getName());
}
model.addAttribute("products", products);
model.addAttribute("cateIdAndName", cateIdAndName);
return "admin/products/index";
}
- index.html -
<table class="table" id="products">
<tr>
<th>상품명</th>
<th>이미지</th>
<th>카테고리</th>
<th>가 격</th>
<th>수 정</th>
<th>삭 제</th>
</tr>
<tr th:each="product : ${products}">
<td th:text="${product.name}"></td>
<td>
<img th:src="@{'/media/'+${product.image}}" style="height: 2em" />
</td>
<td th:text="${cateIdAndName[__${product.categoryId}__]}"></td>
<td th:text="${product.price}+' 원'"></td>
<td><a th:href="@{'/admin/products/edit/' + ${product.id}}">수정</a></td>
<td><a class="deleteConfirm" th:href="@{'/admin/products/delete/' + ${product.id}}">삭제</a></td>
</tr>
</table>
${}
안에 ${}
가 중복으로 들어가야할 경우 내부의 ${}
에 앞뒤로 __
을 추가하여 ${__${}__}
의 형태로 사용한다.
=> category_id를 넣으면 그 id에 해당하는 카테고리 이름이 출력됨.
@ModelAttribute
에 대한 개념정리 필요
상품 수정/삭제
상품수정
- AdminProductController -
@GetMapping("/edit/{id}")
public String edit(@PathVariable int id, Model model) {
Product product = productRepo.getById(id);
List<Category> categories = categoryRepo.findAll();
model.addAttribute("categories", categories);
model.addAttribute("product", product);
return "admin/products/edit";
}
@PostMapping("/edit")
public String edit(@Valid Product product, BindingResult bindingResult, MultipartFile file,
RedirectAttributes redirectAttributes, Model model) throws IOException {
//우선 수정하기전의 상품의 객체를 DB에서 읽어오기 ( id 로 검색 )
Product currentProduct = productRepo.getById(product.getId());
if (bindingResult.hasErrors()) {
List<Category> categories = categoryRepo.findAll();
model.addAttribute("categories", categories);
if(product.getImage() == null) product.setImage(currentProduct.getImage()); // 저장된 이미지 불러오기
return "/admin/products/edit";
}
boolean fileOk = false;
byte[] bytes = file.getBytes(); // 업로드한 파일의 데이터
String fileName = file.getOriginalFilename(); // 업로드한 파일의 이름
Path path = Paths.get("src/main/resources/static/media/" + fileName); // 파일을 저장할 컨텍스트 안의 경로
if (!file.isEmpty()) { // 새 이미지 파일이 있을경우
if (fileName.endsWith("jpg") || fileName.endsWith("png")) { // 파일의 확장자 jpg , png
fileOk = true;
}
} else { // 파일이 없을경우 ( 수정 이므로 이미지 파일이 없어도 OK )
fileOk = true;
}
// 성공적으로 product 수정 되는 경우
redirectAttributes.addFlashAttribute("message", "상품이 수정됨");
redirectAttributes.addFlashAttribute("alertClass", "alert-success");
String slug = product.getName().toLowerCase().replace(" ", "-");
//제품이름을 수정했을 경우에 slug가 다름 제품과 같지 않는지 검사
Product productExists = productRepo.findBySlugAndIdNot(slug, product.getId());
if (!fileOk) { // file 업로드 안되거나 확장자가 틀림
redirectAttributes.addFlashAttribute("message", "이미지는 jpg나 png를 사용해 주세요");
redirectAttributes.addFlashAttribute("alertClass", "alert-danger");
redirectAttributes.addFlashAttribute("product", product);
} else if (productExists != null) { // 이미 등록된 상품 있음
redirectAttributes.addFlashAttribute("message", "상품이 이미 있습니다. 다른것을 고르세요");
redirectAttributes.addFlashAttribute("alertClass", "alert-danger");
redirectAttributes.addFlashAttribute("product", product);
} else { // 상품과 이미지 파일을 저장한다.
product.setSlug(slug); // 슬러그 저장
if (!file.isEmpty()) { // 수정할 이미지 파일이 있을 경우에만 저장(이때 이전 파일 삭제)
Path currentPath = Paths.get("src/main/resources/static/media/" + currentProduct.getImage());
Files.delete(currentPath);
product.setImage(fileName);
Files.write(path, bytes); //Files 클래스를 사용해 파일을 저장
} else {
product.setImage(currentProduct.getImage());
}
productRepo.save(product);
}
return "redirect:/admin/products/edit/" + product.getId();
}
- ProductRepository -
Product findBySlugAndIdNot(String slug, int id);
- edit.html -
나머지는 add.html과 동일
<div class="form-group">
<label for="">이미지</label>
<input type="file" class="form-control" th:id="file" th:name="file" />
<img src="#" id="imgPreview" />
<br /><br />
<label for="">현재 이미지</label>
<img th:src="@{'/media/'+${product.image}}" style="width: 200px" />
</div>
<div class="form-group">
<label for="">가 격</label>
<input type="text" class="form-control" th:field="*{price}" placeholder="가격(원)" />
<span class="error" th:if="${#fields.hasErrors('price')}" th:errors="*{price}"></span>
</div>
<div class="form-group">
<label for="">카테고리</label>
<select th:field="*{categoryId}" class="form-control">
<option value="0">카테고리 선택</option>
<option th:each="category : ${categories}" th:value="${category.id}" th:text="${category.name}"></option>
</select>
<span class="error" th:if="${#fields.hasErrors('categoryId')}" th:errors="*{categoryId}"></span>
</div>
<input type="hidden" th:field="*{id}" th:value="${product.id}" />
<button class="btn btn-danger">수정 하기</button>
상품 삭제
- AdminProductController -
@GetMapping("/delete/{id}")
public String delete(@PathVariable int id, RedirectAttributes redirectAttributes) throws IOException {
// id로 상품을 삭제하기 전 먼저 id로 상품객체를 불러와 이미지파일을 삭제한 후 삭제진행
Product currentProduct = productRepo.getById(id) ;
Path currentPath = Paths.get("src/main/resources/static/media/" + currentProduct.getImage());
Files.delete(currentPath); // 파일먼저 삭제
productRepo.deleteById(id); // 상품 삭제
redirectAttributes.addFlashAttribute("message", "성공적으로 삭제 되었습니다.");
redirectAttributes.addFlashAttribute("alertClass", "alert-success");
return "redirect:/admin/products";
}
Author And Source
이 문제에 관하여(스프링수업 10일차), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@0829kuj/스프링수업-10일차저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)