Spring Boot2 實戰系列之登錄註冊(三) - 郵件激活賬號和密碼重置

前言

在前面的博文 Spring Boot2 實戰系列之登錄註冊(二) - 登錄實現 中實現了登錄功能。這次繼續完善常用的功能,就是在註冊的時候可以向註冊郵箱發送一個鏈接,打開該鏈接才能激活該賬戶。還有就是密碼重置的功能。

項目架構

項目結構圖如下:
在這裏插入圖片描述

pom 依賴如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.2.5.RELEASE</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>top.yekongle</groupId>
	<artifactId>springboot-activate-account-sample</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>springboot-activate-account-sample</name>
	<description>Activate account by email for Spring Boot</description>

	<properties>
		<java.version>1.8</java.version>
		<passay.version>1.5.0</passay.version>
		<guava.version>29.0-jre</guava.version>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-mail</artifactId>
		</dependency>
		<dependency>
			<groupId>org.passay</groupId>
			<artifactId>passay</artifactId>
			<version>${passay.version}</version>
		</dependency>
		<dependency>
			<groupId>com.google.guava</groupId>
			<artifactId>guava</artifactId>
			<version>${guava.version}</version>
		</dependency>
		<dependency>
			<groupId>com.h2database</groupId>
			<artifactId>h2</artifactId>
			<scope>runtime</scope>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<scope>runtime</scope>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
			<exclusions>
				<exclusion>
					<groupId>org.junit.vintage</groupId>
					<artifactId>junit-vintage-engine</artifactId>
				</exclusion>
			</exclusions>
		</dependency>
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>

代碼編寫

application.properties

spring.mail.host=smtp.163.com
spring.mail.username=your_username
spring.mail.password=your_password
spring.mail.default-encoding=UTF-8
spring.mail.protocol=smtp
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.ssl.enable=true
support.email=your_username

# 國際化i18n配置,(包名.基礎名)
spring.messages.basename=i18n.messages
spring.messages.encoding=UTF-8


# Thymeleaf 配置
# 禁止緩存
spring.thymeleaf.cache=false

這裏主要寫出改動或新增的類,其他的則和登錄實現篇基本一致, 具體請查看倉庫

PasswordResetToken.java, 密碼重置校驗 Token

package top.yekongle.activate.entity;

import java.util.Calendar;
import java.util.Date;

import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.OneToOne;

@Entity
public class PasswordResetToken {

    private static final int EXPIRATION = 60 * 24;

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String token;

    @OneToOne(targetEntity = User.class, fetch = FetchType.EAGER)
    @JoinColumn(nullable = false, name = "user_id")
    private User user;

    private Date expiryDate;

    public PasswordResetToken() {
        super();
    }

    public PasswordResetToken(final String token) {
        super();

        this.token = token;
        this.expiryDate = calculateExpiryDate(EXPIRATION);
    }

    public PasswordResetToken(final String token, final User user) {
        super();

        this.token = token;
        this.user = user;
        this.expiryDate = calculateExpiryDate(EXPIRATION);
    }

    //
    public Long getId() {
        return id;
    }

    public String getToken() {
        return token;
    }

    public void setToken(final String token) {
        this.token = token;
    }

    public User getUser() {
        return user;
    }

    public void setUser(final User user) {
        this.user = user;
    }

    public Date getExpiryDate() {
        return expiryDate;
    }

    public void setExpiryDate(final Date expiryDate) {
        this.expiryDate = expiryDate;
    }

    private Date calculateExpiryDate(final int expiryTimeInMinutes) {
        final Calendar cal = Calendar.getInstance();
        cal.setTimeInMillis(new Date().getTime());
        cal.add(Calendar.MINUTE, expiryTimeInMinutes);
        return new Date(cal.getTime().getTime());
    }

    public void updateToken(final String token) {
        this.token = token;
        this.expiryDate = calculateExpiryDate(EXPIRATION);
    }

    //

    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + ((expiryDate == null) ? 0 : expiryDate.hashCode());
        result = prime * result + ((token == null) ? 0 : token.hashCode());
        result = prime * result + ((user == null) ? 0 : user.hashCode());
        return result;
    }

    @Override
    public boolean equals(final Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        final PasswordResetToken other = (PasswordResetToken) obj;
        if (expiryDate == null) {
            if (other.expiryDate != null) {
                return false;
            }
        } else if (!expiryDate.equals(other.expiryDate)) {
            return false;
        }
        if (token == null) {
            if (other.token != null) {
                return false;
            }
        } else if (!token.equals(other.token)) {
            return false;
        }
        if (user == null) {
            if (other.user != null) {
                return false;
            }
        } else if (!user.equals(other.user)) {
            return false;
        }
        return true;
    }

    @Override
    public String toString() {
        final StringBuilder builder = new StringBuilder();
        builder.append("Token [String=").append(token).append("]").append("[Expires").append(expiryDate).append("]");
        return builder.toString();
    }

}

UserController.java, 註冊用戶,激活賬號,重置密碼

package top.yekongle.activate.controller;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.MessageSource;
import org.springframework.core.env.Environment;
import org.springframework.mail.MailAuthenticationException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.ModelAndView;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.servlet.mvc.support.RedirectAttributesModelMap;
import top.yekongle.activate.dto.PasswordDto;
import top.yekongle.activate.dto.UserDTO;
import top.yekongle.activate.entity.PasswordResetToken;
import top.yekongle.activate.entity.User;
import top.yekongle.activate.entity.VerificationToken;
import top.yekongle.activate.event.OnRegistrationCompleteEvent;
import top.yekongle.activate.exception.UserAlreadyExistException;
import top.yekongle.activate.service.UserService;
import top.yekongle.activate.util.GenericResponse;

import java.util.Calendar;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;

/**
* @Author: Yekongle 
* @Date: 2020年5月5日
*/
@Slf4j
@Controller
public class UserController {
	
	@Autowired UserService userService;

	@Autowired
	private MessageSource messages;

	@Autowired
	LocaleResolver localeResolver;

	@Autowired
	ApplicationEventPublisher eventPublisher;

	@Autowired
	private JavaMailSender mailSender;

	@Autowired
	private Environment env;

	@Autowired
	private UserDetailsService userDetailsService;

	// 註冊頁面
	@GetMapping("/registration")
	public String registration(Model model) {
		model.addAttribute("formTitle", "註冊");
		return "registration";
	}

	// 用戶註冊
	@PostMapping("/user/registration")
	@ResponseBody
	public GenericResponse registerUserAccount(@Valid UserDTO userDTO, HttpServletRequest request) {
		User registered = userService.registerNewUserAccount(userDTO);
		eventPublisher.publishEvent(new OnRegistrationCompleteEvent(registered, request.getLocale(), getAppUrl(request)));
		return new GenericResponse("success");
	}

	// 激活用戶賬戶
	@GetMapping("/registrationConfirm.html")
	public String confirmRegistration(HttpServletRequest request, HttpServletResponse response, RedirectAttributesModelMap model
			, @RequestParam("token") String token) {
		log.info("confirmRegistration");
		Locale locale = localeResolver.resolveLocale(request);
		log.info("token:{}" + token);
		VerificationToken verificationToken = userService.getVerificationToken(token);
		if (verificationToken == null) {
	        String message = messages.getMessage("auth.message.invalidToken", null, locale);
	        log.info("message:" + message);
			model.addFlashAttribute("errMsg", message);
			return "redirect:/badUser.html";
		}

		User user = verificationToken.getUser();
		Calendar cal = Calendar.getInstance();
		if ((verificationToken.getExpiryDate().getTime() - cal.getTime().getTime()) <= 0) {
			model.addFlashAttribute("message", messages.getMessage("auth.message.expired", null, locale));
			model.addFlashAttribute("expired", true);
			model.addFlashAttribute("token", token);
			return "redirect:/badUser.html";
		}

		user.setEnabled(true);
		userService.saveRegisteredUser(user);
		model.addFlashAttribute("message", messages.getMessage("message.accountVerified", null, locale));

		return "redirect:/login";
	}

	// 重新發送註冊令牌
	@GetMapping("/user/resendRegistrationToken")
	@ResponseBody
	public GenericResponse resendRegistrationToken(final HttpServletRequest request, final RedirectAttributesModelMap model, @RequestParam("token") final String existingToken) {
		log.info("resendRegistrationToken");
		final Locale locale = request.getLocale();
		final VerificationToken newToken = userService.generateNewVerificationToken(existingToken);
		final User user = userService.getUser(newToken.getToken());
			final SimpleMailMessage email = constructResetVerificationTokenEmail(getAppUrl(request), request.getLocale(), newToken, user);
			mailSender.send(email);
		log.info("message: {}", messages.getMessage("message.resendToken", null, locale));
		return new GenericResponse(messages.getMessage("message.resendToken", null, locale));
	}

	// 重置密碼
	@PostMapping("/user/resetPassword")
	@ResponseBody
	public GenericResponse resetPassword(final HttpServletRequest request, final RedirectAttributesModelMap model
			, @RequestParam("email") final String userEmail) {
		log.info("resetPassword: {}", userEmail);
		final User user = userService.findUserByEmail(userEmail);
		if (user == null) {
			return new GenericResponse(messages.getMessage("message.userNotFound", null, request.getLocale()));
		}

		final String token = UUID.randomUUID().toString();
		userService.createPasswordResetTokenForUser(user, token);
	 	final SimpleMailMessage email = constructResetTokenEmail(getAppUrl(request), request.getLocale(), token, user);
	 	mailSender.send(email);
		return new GenericResponse(messages.getMessage("message.resetPasswordEmail", null, request.getLocale()));
	}

	// 更換密碼頁面
	@GetMapping("/user/changePassword")
	public String showChangePassword(final HttpServletRequest request, final RedirectAttributesModelMap model, @RequestParam("id") final long id, @RequestParam("token") final String token) {
		log.info("showChangePassword, id:{}, token:{}", id, token);
		final Locale locale = request.getLocale();
		String result = userService.validatePasswordResetToken(token);
		log.info("result:{}", result);
		if(result != null) {
			String message = messages.getMessage("auth.message." + result, null, locale);
			log.info("message:{}", message);
			model.addFlashAttribute("errMsg", message);
			return "redirect:/login.html?";
		} else {
			model.addFlashAttribute("token", token);
			return "redirect:/updatePassword.html";
		}
	}

	@PostMapping("/user/savePassword")
	@ResponseBody
	public GenericResponse savePassword(final Locale locale, @Valid PasswordDto passwordDto) {
		log.info("savePassword");
		String result = userService.validatePasswordResetToken(passwordDto.getToken());

		if(result != null) {
			return new GenericResponse(messages.getMessage(
					"auth.message." + result, null, locale));
		}

		Optional<User> user = userService.getUserByPasswordResetToken(passwordDto.getToken());
		if(user.isPresent()) {
			userService.changeUserPassword(user.get(), passwordDto.getNewPassword());
			String message = messages.getMessage(
					"message.resetPasswordSuc", null, locale);
			log.info("message:{}", message);
			return new GenericResponse(message);
		} else {
			return new GenericResponse(messages.getMessage(
					"auth.message.invalid", null, locale));
		}
	}


	private String getAppUrl(HttpServletRequest request) {
		String appUrl = "http://" + request.getServerName() + ":" + request.getServerPort() + request.getContextPath();
		return appUrl;
	}

	private final SimpleMailMessage constructResetVerificationTokenEmail(final String contextPath, final Locale locale
			, final VerificationToken newToken, final User user) {
		final String confirmationUrl = contextPath + "/registrationConfirm.html?token=" + newToken.getToken();
		log.info("Url: {}", confirmationUrl);
		final String message = messages.getMessage("message.resendToken", null, locale);
		final SimpleMailMessage email = new SimpleMailMessage();
		email.setSubject("Resend Registration Token");
		email.setText(message + " \r\n" + confirmationUrl);
		email.setTo(user.getEmail());
		email.setFrom(env.getProperty("support.email"));
		log.info("support.email:{}", env.getProperty("support.email"));
		return email;
	}

	private final SimpleMailMessage constructResetTokenEmail(final String contextPath, final Locale locale, final String token, final User user) {
		final String url = contextPath + "/user/changePassword?id=" + user.getId() + "&token=" + token;
		log.info("url:{}", url);
		final String message = messages.getMessage("message.resetPassword", null, locale);
		final SimpleMailMessage email = new SimpleMailMessage();
		email.setTo(user.getEmail());
		email.setSubject("Reset Password");
		email.setText(message + " \r\n" + url);
		email.setFrom(env.getProperty("support.email"));
		return email;
	}
}

UserServiceImpl.java, 用戶賬號操作業務類

package top.yekongle.activate.service.impl;

import java.util.Arrays;
import java.util.Calendar;
import java.util.Optional;
import java.util.UUID;

import javax.transaction.Transactional;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import lombok.extern.slf4j.Slf4j;
import top.yekongle.activate.dto.UserDTO;
import top.yekongle.activate.entity.PasswordResetToken;
import top.yekongle.activate.entity.User;
import top.yekongle.activate.entity.UserAuthority;
import top.yekongle.activate.entity.VerificationToken;
import top.yekongle.activate.exception.UserAlreadyExistException;
import top.yekongle.activate.repository.PasswordResetTokenRepository;
import top.yekongle.activate.repository.UserAuthorityRepository;
import top.yekongle.activate.repository.UserRepository;
import top.yekongle.activate.repository.VerificationTokenRepository;
import top.yekongle.activate.service.UserService;

/**
 * @Description:
 * @Author: Yekongle
 * @Date: 2020年5月5日
 */
@Slf4j
@Service
public class UserServiceImpl implements UserService {

	@Autowired
	private UserRepository userRepository;

	@Autowired
	private UserAuthorityRepository userAuthorityRepository;

	@Autowired
	private VerificationTokenRepository tokenRepository;

	@Autowired
	private PasswordEncoder passwordEncoder;

	@Autowired
	private PasswordResetTokenRepository passwordTokenRepository;

	@Override
	public User registerNewUserAccount(UserDTO userDTO) throws UserAlreadyExistException {
		if (emailExists(userDTO.getEmail())) {
			throw new UserAlreadyExistException("該郵箱已被註冊:" + userDTO.getEmail());
		}
		log.info("UserDTO:" + userDTO.toString());
		User user = new User();
		user.setEmail(userDTO.getEmail());
		user.setPassword(passwordEncoder.encode(userDTO.getPassword()));
		userRepository.save(user);

		UserAuthority userAuthority = new UserAuthority();
		userAuthority.setUsername(userDTO.getEmail());
		userAuthority.setRole("ROLE_USER");
		userAuthorityRepository.save(userAuthority);

		return user;
	}

	@Override
	public VerificationToken getVerificationToken(String verificationToken) {
		return tokenRepository.findByToken(verificationToken);
	}

	@Override
	public VerificationToken generateNewVerificationToken(String token) {
		VerificationToken vToken = tokenRepository.findByToken(token);
		vToken.updateToken(UUID.randomUUID()
				.toString());
		vToken = tokenRepository.save(vToken);
		return vToken;
	}

	@Override
	public void saveRegisteredUser(User user) {
		userRepository.save(user);
	}

	@Override
	public void createVerificationToken(User user, String token) {
		VerificationToken myToken = new VerificationToken(token, user);
		tokenRepository.save(myToken);
	}

	@Override
	public User getUser(String verificationToken) {
		User user = tokenRepository.findByToken(verificationToken).getUser();
		return user;
	}

	@Override
	public PasswordResetToken getPasswordResetToken(String token) {
		return passwordTokenRepository.findByToken(token);
	}

	@Override
	public User findUserByEmail(String email) {
		return userRepository.findByEmail(email);
	}

	@Override
	public void createPasswordResetTokenForUser(User user, String token) {
		final PasswordResetToken myToken = new PasswordResetToken(token, user);
		passwordTokenRepository.save(myToken);
	}

	public String validatePasswordResetToken(String token) {
		final PasswordResetToken passToken = passwordTokenRepository.findByToken(token);

		return !isTokenFound(passToken) ? "invalidToken"
				: isTokenExpired(passToken) ? "expired"
				: null;
	}

	@Override
	public void changeUserPassword(User user, String password) {
		user.setPassword(passwordEncoder.encode(password));
		userRepository.save(user);
	}

	@Override
	public Optional<User> getUserByPasswordResetToken(String token) {
		return Optional.ofNullable(passwordTokenRepository.findByToken(token).getUser());
	}

	private boolean emailExists(String email) {
		return userRepository.findByEmail(email) != null;
	}

	private boolean isTokenFound(PasswordResetToken passToken) {
		return passToken != null;
	}

	private boolean isTokenExpired(PasswordResetToken passToken) {
		final Calendar cal = Calendar.getInstance();
		return passToken.getExpiryDate().before(cal.getTime());
	}


}

OnRegistrationCompleteEvent.java, 註冊監聽事件

package top.yekongle.activate.event;

import java.util.Locale;

import lombok.*;
import org.springframework.context.ApplicationEvent;

import top.yekongle.activate.entity.User;

/** 
* @Description: 
* @Author: Yekongle 
* @Date: 2020年6月6日
*/
@Getter
@Setter
public class OnRegistrationCompleteEvent extends ApplicationEvent {

	private static final long serialVersionUID = 1L;
	
	private String appUrl;
	private Locale locale;
	private User user;

	public OnRegistrationCompleteEvent(User user, Locale locale, String appUrl) {
		super(user);
 		this.user = user;
 		this.locale = locale;
		this.appUrl = appUrl;
	}
}

RegistrationListener.java, 註冊事件監聽, 發送激活郵件到用戶郵箱

package top.yekongle.activate.listener;

import java.util.UUID;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationListener;
import org.springframework.context.MessageSource;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Component;

import lombok.extern.slf4j.Slf4j;
import top.yekongle.activate.entity.User;
import top.yekongle.activate.event.OnRegistrationCompleteEvent;
import top.yekongle.activate.service.UserService;;
/** 
* @Description: 
* @Author: Yekongle 
* @Date: 2020年6月6日
*/
@Slf4j
@Component
public class RegistrationListener implements ApplicationListener<OnRegistrationCompleteEvent> {

	@Autowired
    private UserService service;
  
    @Autowired
    private MessageSource messages;
  
    @Autowired
    private JavaMailSender mailSender;
    
    @Value("${spring.mail.username}")
    private String emailFrom;
	
	@Override
	public void onApplicationEvent(OnRegistrationCompleteEvent event) {
		 this.confirmRegistration(event);
	}

	 private void confirmRegistration(OnRegistrationCompleteEvent event) {
	        User user = event.getUser();
	        String token = UUID.randomUUID().toString();
	        service.createVerificationToken(user, token);
	         
	        String recipientAddress = user.getEmail();
	        String subject = "Registration Confirmation";
	        String confirmationUrl = event.getAppUrl() + "/registrationConfirm.html?token=" + token;
	        log.info("confirmationUrl: {}" + confirmationUrl);
	        String message = messages.getMessage("message.regSucc", null, event.getLocale());
	        log.info("recipientAddress: {}", user.getEmail());
	        SimpleMailMessage email = new SimpleMailMessage();
	        email.setFrom(emailFrom);
	        email.setTo(recipientAddress);
	        email.setSubject(subject);
	        email.setText(message + "\r\n" + confirmationUrl);
	        mailSender.send(email);
	    }
}

運行演示

啓動項目

  1. 訪問 http://localhost:8080,會自動跳到登錄頁面,先點擊跳到註冊頁面
    在這裏插入圖片描述

  2. 點擊註冊,將發送激活郵件
    在這裏插入圖片描述

  3. 打開註冊郵箱查看激活郵件
    在這裏插入圖片描述

  4. 在瀏覽器打開該鏈接,激活該賬戶
    在這裏插入圖片描述

  5. 登錄該賬戶
    在這裏插入圖片描述

  6. 如果需要重置密碼,則點擊重置密碼
    在這裏插入圖片描述

在這裏插入圖片描述
7. 郵箱中查看重置密碼鏈接
在這裏插入圖片描述

  1. 在瀏覽器中打開重置密碼鏈接
    在這裏插入圖片描述

  2. 點擊更改密碼
    在這裏插入圖片描述

項目已上傳至 Github: https://github.com/yekongle/springboot-code-samples/tree/master/springboot-activate-account-sample , 希望對小夥伴們有幫助哦。

參考鏈接:

  • https://v4.bootcss.com/docs/getting-started/introduction/
  • https://github.com/Baeldung/spring-security-registration
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章