用户功能编写好了,不过还缺少一块内容,就是在系统第一次启动的时候,强制让用户创建一个管理员账号,这需要用到初始化数据库和加入一个配置表的功能,这次我就来实现一下这个功能。
一些重构工作
这里的重构其实就是重新组织一下包,看了一下已经编写的代码,还算解耦的比较重复,控制器除了数据校验之外没有做太多的工作。
现在就把包重新整理一下:
config
弄一个properties
包,把EmailProperties
类放进去。controller
中设置account
目录,然后把AccountController
放进去,由于目前还没有根目录的控制器,所以要再创建一个main
包,将来编写首页的控制器就塞到里边去。dao
包中也是如此,创建account
目录,然后把目前的四个DAO
类都放进去。service
包中更是如此,创建account
目录,把目前相关的三个业务类都放进去。dataobject
包中创建account
目录,然后把其下的businessobject
,entity
,valueobject
都移动进去。
还有一个内容就是需要把所有PO
类的主键生成策略从GenerationType.SEQUENCE
改成IDENTITY
,否则其实是共用一个序列,不好。
初始化数据库
由于系统的一些功能是固定的,我现在打算要在系统启动之后,自动写入role
表中的内容。搜索了一下,有两种解决方案。
application.properties
中添加设置
第一种方法是在application.properties
中添加如下的设置:
spring.datasource.initialization-mode=always
spring.datasource.schema=classpath:dataInitialize.sql
第一条表示在什么时候进行初始化,always
表示每次启动项目的时候都会初始化。第二行表示初始化采用的SQL
文件的路径,在Spring Boot
项目中,classpath
的位置其实包含了/resources/
,/resources/static/
目录以及放源代码的main
目录,这里一般是把文件放在/resources/
之下。文件的名称为dataInitialize.sql
。但是这个方法在Spring Boot
启动的时候会报错,原因是这个初始化语句发生在Hibernate
创建数据表之前,所以会出错。
使用DataSourceInitializer
这个是配置一个DataSourceInitializer
的Bean
,我们在config包下创建一个ProjectConfig
用来存放这些通用的配置,在其中创建一个加载dataInitialize.sql
文件的@Bean
,代码其实是样板代码,一看就懂:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.init.DataSourceInitializer;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import javax.sql.DataSource;
@Configuration
public class ProjectConfig {
@Bean
public DataSourceInitializer dataSourceInitializer(DataSource dataSource) {
ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
populator.addScript(new ClassPathResource("dataInitialize.sql"));
DataSourceInitializer initializer = new DataSourceInitializer();
initializer.setDataSource(dataSource);
initializer.setDatabasePopulator(populator);
return initializer;
}
}
这段代码也是从classpath
中加载dataInitialize.sql
,经过我试验,这个
初始化过程是在Hibernate
建表之后,所以可以正常操作。
dataInitialize.sql
的内容
目前dataInitialize.sql
的内容是固定添加ADMIN
,SUPERUSER
和USER
三条记录:
INSERT INTO role(id, role)
VALUES (1, 'ADMIN')
ON CONFLICT(role) DO NOTHING;
INSERT INTO role(id, role)
VALUES (2, 'SUPERUSER')
ON CONFLICT (role) DO NOTHING;
INSERT INTO role(id, role)
VALUES (3, 'USER')
ON CONFLICT (role) DO NOTHING;
options
表
我决定要设计一个初始化功能,用于系统在一个空白数据库上刚运行的时候,用来让第一个登陆的用户创建一个管理员账号。为此,需要在项目的数据库中设置一个数据表,用来存放项目的一些基本配置,就像本博客所用的HALO
系统,其在数据库中就有一个options
表来存储项目的配置,我现在能想到的配置没有那么多,但也先做一个,以后可以再添加。
HALO
的options
表中,option_key
和option_value
都是字符串,外加上常用的create_time
和update_time
,我也打算如此来设计。
在dao
包下创建option
包,这个包用来存放所有通用的设置,除了将要创建的PO
类Option
,我打算把增值税率,印花税率之类的东西,也存放在这个包里,等编写到业务的时候再看需要添加哪些记录。
创建dataobject/option/entity/
,在其中创建Option
这个Entity
类:
package cc.conyli.fms.dataobject.option.entity;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.hibernate.validator.constraints.Length;
import javax.persistence.*;
import javax.validation.constraints.NotEmpty;
import java.io.Serializable;
import java.util.Date;
import java.util.Objects;
@Entity
@Table(name = "options")
public class Option implements Serializable {
private static final long serialVersionUID = 5L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Integer id;
@Temporal(TemporalType.TIMESTAMP)
@Column(name = "create_time", updatable = false)
@CreationTimestamp
private Date createTime;
@Temporal(TemporalType.TIMESTAMP)
@Column(name = "update_time")
@UpdateTimestamp
protected Date updateTime;
@NotEmpty(message = "配置名不能为空")
@Length(max = 30, message = "长度不能超过30个字符")
@Column(name = "option_key", nullable = false, unique = true, length = 30)
private String optionKey;
@NotEmpty(message = "属性不能为空")
@Length(max = 255, message = "长度不能超过255个字符")
@Column(name = "option_value", nullable = false, length = 255)
private String optionValue;
public Option(String optionKey, String optionValue) {
this.optionKey = optionKey;
this.optionValue = optionValue;
}
public Option() {
}
public String getOptionKey() {
return optionKey;
}
public void setOptionKey(String optionKey) {
this.optionKey = optionKey;
}
public String getOptionValue() {
return optionValue;
}
public void setOptionValue(String optionValue) {
this.optionValue = optionValue;
}
@Override
public String toString() {
return "Option{" +
"id=" + id +
", optionKey='" + optionKey + '\'' +
", optionValue='" + optionValue + '\'' +
'}';
}
......
}
写到这里,突然发现之前的PO
类都没添加上创建时间和修改时间两个类,CreationTimestamp
和UpdateTimestamp
非常方便,直接添加在TIMESTAMP
类型的属性上即可。这里我给User
和UserInfo
两个类也都添加了创建和修改时间两个属性。
这个Option
类比较简单,其核心就是字符串形式的键值对,用于保存配置。
管理员初始化功能
初始化超级管理员的逻辑如下:
- 首先在Spring Security中放开对于首页的访问。
- 在首页的控制器中,需要读取配置,看是否已经完成初始化工作
- 如果完成初始化工作,则跳转至其他页面(跳转到哪里再编写)
- 如果未完成初始化工作,则需要给出一个页面供用户填写资料
- 用户填写资料完毕之后,保存此用户资料,但特别要将其设置为
ADMIN
,同时将options
表中的userInitialized
=false
修改为true
。
项目启动时自动写入配置属性
我们需要在dataInitialize.sql
中向options
表中写入一个数据:
INSERT INTO options(create_time, option_key, option_value, update_time)
VALUES (CURRENT_TIMESTAMP, 'userInitialized','false',CURRENT_TIMESTAMP)
ON CONFLICT (option_key) DO NOTHING;
CURRENT_TIMESTAMP
是PostgreSQL
的函数,用于获取当前时间戳。在初始化的时候插入了一个userInitialized
=false
的配置属性,如果该配置名称已经存在,就不进行任何操作。下边就要根据这个属性来搞事了。
Spring Security
中开放相关路径
这个简单,只需要添加一条
.antMatchers("/").permitAll()
准备工作
在controller
包下创建main
包,然后在其中创建MainPageController
,作为首页的控制器,如下:
@Controller
@RequestMapping("/")
public class MainPageController {
@RequestMapping("/")
public String mainPage() {
return "/main/index";
}
}
创建templates/main/index.html
作为首页,这个文件是正常状态的首页。
创建templates/option/admin.html
作为输入管理员信息的页面。
创建dao/option/OptionDao
类如下:
import cc.conyli.fms.dataobject.option.entity.Option;
import org.springframework.data.repository.CrudRepository;
public interface OptionDao extends CrudRepository<Option, Integer> {
Option findByOptionKey(String key);
}
创建service/option/OptionService
业务类如下:
import cc.conyli.fms.dao.option.OptionDao;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class OptionService {
private final OptionDao optionDao;
@Autowired
public OptionService(OptionDao optionDao) {
this.optionDao = optionDao;
}
@Transactional
public Option getOptionByKey(String key) {
return optionDao.findByOptionKey(key);
}
}
GET
控制器逻辑 - 返回页面
在控制器中,可以获取这个配置检查其内容,如果为false
,表示尚未初始化,则返回创建管理员的页面。如果为true
,则显示正常首页即可。
控制器的逻辑比较简单,注入OptionService
之后根据取出的配置结果,进行简单的判断,然后返回不同的页面:
@Controller
@RequestMapping("/")
public class MainPageController {
private final OptionService optionService;
@Autowired
public MainPageController(OptionService optionService) {
this.optionService = optionService;
}
@RequestMapping("/")
public String mainPage() {
Option userInitilized = optionService.getOptionByKey("userInitialized");
if (userInitilized.getOptionValue().equals("false")) {
return "redirect:/initial/admin";
} else {
return "/main/index";
}
}
}
然后我们还需要一个InitializationController
,和MainPageController
放在一起:
@Controller
@RequestMapping("/initial")
public class InitializationController {
private final OptionService optionService;
@Autowired
public InitializationController(OptionService optionService) {
this.optionService = optionService;
}
@GetMapping("/admin")
public String adminInitialPage() {
if (optionService.getOptionByKey("userInitialized").getOptionValue().equals("false")) {
return "/option/admin";
} else {
return "redirect:/";
}
}
}
InitializationController
在已经初始化的情况下,会跳回首页。然后把/initial/
的路径也放开:
.antMatchers("/initial/**").permitAll()
输入管理员信息页面
index.html
目前是空白的,我们要来编写admin.html
,这次由于不是用户登录注册和修改密码,我决定来尝试使用一下Bootstrap
的验证通过和失败样式。
admin.html
的页面如下:
<!doctype html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org"
th:replace="~{/shared/standard::layout(~{::title}, _, ~{::main}, _)}">
<head>
<title>创建管理员</title>
<style></style>
</head>
<body>
<main class="container">
<form action="/initial/admin" th:action="@{/initial/admin}" method="post" th:object="${userData}">
<div class="row">
<div class="col-sm-2"></div>
<div class="col-sm-8">
<h3 class="text-center mt-3">创建管理员</h3>
<hr>
<div class="row">
<div class="col-sm-6 mt-2">
<label for="username" class="form-label">用户名</label>
<input type="text" th:classappend="${#fields.hasErrors('username')}? 'is-invalid'"
class="form-control" id="username" th:field="*{username}"
aria-describedby="validationusername">
<div id="validationusername" th:if="${#fields.hasErrors('username')}" th:errors="*{username}"
class="invalid-feedback"></div>
</div>
<div class="col-sm-6 mt-2">
<label for="email" class="form-label">电子邮件</label>
<input type="email" th:classappend="${#fields.hasErrors('email')}? 'is-invalid'"
class="form-control" id="email" th:field="*{email}"
aria-describedby="validationemail">
<div id="validationemail" th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="invalid-feedback"></div>
</div>
<div class="col-sm-6 mt-2">
<label for="password1" class="form-label">密码</label>
<input type="password" th:classappend="${#fields.hasErrors('password1')}? 'is-invalid'"
class="form-control" id="password1" th:field="*{password1}"
aria-describedby="validationpwd1">
<div id="validationpwd1" th:if="${#fields.hasErrors('password1')}" th:errors="*{password1}"
class="invalid-feedback"></div>
</div>
<div class="col-sm-6 mt-2">
<label for="password2" class="form-label">确认密码</label>
<input type="password" th:classappend="${#fields.hasErrors('password2') or passwordError!=null }? 'is-invalid'"
class="form-control" id="password2" th:field="*{password2}"
aria-describedby="validationpwd2">
<div id="validationpwd2" th:if="${#fields.hasErrors('password2')}" th:errors="*{password2}"
class="invalid-feedback"></div>
<div id="validationpwd2" th:if="${passwordError}!=null" th:text="${passwordError}"
class="invalid-feedback"></div>
</div>
</div>
</div>
<div class="col-sm-2"></div>
</div>
<div class="text-center mt-3">
<button type="submit" class="btn btn-primary">提交</button>
</div>
</form>
</main>
<script src=""></script>
</body>
</html>
这里利用了BootStrap
的验证效果,比之前自己编写的效果好一些。特别提一下的是th:classappend
标签,用于动态添加类。
POST
控制器编写
这个控制器的逻辑如下:
- 验证表单,错误就返回
- 验证两个密码是否一致,不一致就返回
- 通过验证后,组装
UserData
对象,然后要设置上enabled
为true
,之后保存进数据库,并且将userInitialized
设置为true
(不等于false
)就行。
编写如下:
@Controller
@RequestMapping("/initial")
public class InitializationController {
private final OptionService optionService;
private final UserService userService;
private final AccountController accountController;
@Autowired
public InitializationController(OptionService optionService, UserService userService, AccountController accountController) {
this.optionService = optionService;
this.userService = userService;
this.accountController = accountController;
}
@PostMapping("/admin")
public String adminRegister(@ModelAttribute("userData") @Valid UserRegistration userRegistration, BindingResult result, Model model) {
// 在尚未初始化之前防止注册用户,所以不会有用户名已存在的状况
if (result.hasErrors()) {
System.out.println(userRegistration);
System.out.println("有错");
return "/option/admin";
}
//两个密码不一致
if (!userRegistration.getPassword1().equals(userRegistration.getPassword2())) {
System.out.println("两个密码不一致");
model.addAttribute("passwordError", "两个密码不一致");
return "/option/admin";
}
//组装用户对象,写入数据库,设置初始化为true
UserData userData = accountController.assembleUserData(userRegistration.getUsername(), userRegistration.getPassword1(), "ADMIN", userRegistration.getEmail());
userData.setEnabled(true);
Option userInitialized = optionService.getOptionByKey("userInitialized");
userInitialized.setOptionValue("true");
userService.saveUserData(userData);
optionService.saveOrUpdate(userInitialized);
System.out.println("成功初始化管理员");
return "redirect:/";
}
}
这里由于需要使用到AccountController
中编写的组装UserData
的方法,所以将其注入,同时还得修改那两个方法为public
,还要在OptionService
中添加保存Option
的方法。同时还要修改用户注册的功能,在初始化未完成之前,不允许访问注册用户的界面。
编写首页
最后简单再修改一下首页的页面,内部使用一个判断来决定渲染初始化管理员功能,还是展示首页,同步把控制器也修改一下:
@RequestMapping("/")
public String mainPage(HttpServletRequest request, Model model) {
if (optionService.getOptionByKey("userInitialized").getOptionValue().equals("false")) {
model.addAttribute("initialized", false);
}else{
model.addAttribute("initialized", true);
}
if (request.getUserPrincipal() != null) {
model.addAttribute("logged", true);
} else {
model.addAttribute("logged", false);
}
return "/main/index";
}
页面采用了的上半部分和脚注的样式作为首页,也蛮漂亮,页面代码如下:
<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org"
th:replace="/shared/standard::layout(~{::title}, ~{::style}, ~{::main}, _)">
<head>
<title>首页</title>
<style>
.container {
max-width: 960px;
}
.site-header {
background-color: rgba(0, 0, 0, .85);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
}
.site-header a {
color: #8e8e8e;
transition: color .15s ease-in-out;
}
.site-header a:hover {
color: #fff;
text-decoration: none;
}
.product-device {
position: absolute;
right: 10%;
bottom: -30%;
width: 300px;
height: 540px;
background-color: #333;
border-radius: 21px;
transform: rotate(30deg);
}
.product-device::before {
position: absolute;
top: 10%;
right: 10px;
bottom: 10%;
left: 10px;
content: "";
background-color: rgba(255, 255, 255, .1);
border-radius: 5px;
}
.product-device-2 {
top: -25%;
right: auto;
bottom: 0;
left: 5%;
background-color: #e5e5e5;
}
.flex-equal > * {
flex: 1;
}
.full-screen {
height: 100vh;
width: 100vh;
}
@media (min-width: 768px) {
.flex-md-equal > * {
flex: 1;
}
}
</style>
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}</style>
</head>
<body>
<main>
<div class="position-relative overflow-hidden p-3 p-md-5 m-md-3 text-center bg-light">
<div class="col-md-5 p-lg-5 mx-auto my-5">
<h1 class="display-4 fw-normal">财务管理系统</h1>
<p th:if="${initialized}" class="lead fw-normal">高效 协同 简洁 现代</p>
<p th:if="${!initialized}" >系统尚未初始化 请创建管理员账号</p>
<a th:if="${initialized}" class="btn btn-outline-secondary" href="#">进入系统</a>
<a th:if="${!initialized}" class="btn btn-outline-secondary" th:href="@{/initial/admin}" href="/initial/admin">创建管理员</a>
</div>
<div class="product-device shadow-sm d-none d-md-block"></div>
<div class="product-device product-device-2 shadow-sm d-none d-md-block"></div>
</div>
<footer class="container py-5">
<div class="row">
<div class="col-12 col-md">
<small class="d-block mb-3 text-muted">Developed By</small>
<small class="d-block mb-3 text-muted"><a class="link-secondary text-decoration-none" href="https://conyli.cc">https://conyli.cc</a></small>
<small class="d-block mb-3 text-muted">© 2020–2021</small>
</div>
<div th:if="${initialized}" class="col-6 col-md">
<h5>功能</h5>
<ul class="list-unstyled text-small">
<li><a class="link-secondary text-decoration-none" href="#">项目管理</a></li>
<li><a class="link-secondary text-decoration-none" href="#">其他功能</a></li>
<li><a class="link-secondary text-decoration-none" href="#">其他功能</a></li>
</ul>
</div>
<div th:if="${initialized}" class="col-6 col-md">
<h5>用户</h5>
<ul class="list-unstyled text-small">
<li><a th:if="${!logged}" class="link-secondary text-decoration-none" th:href="@{/account/login}"
href="/account/login">登录</a></li>
<li><a th:if="${logged}" class="link-secondary text-decoration-none" th:href="@{/account/logout}"
href="/account/logout">登出</a></li>
<li><a th:if="${logged}" class="link-secondary text-decoration-none" th:href="@{/account/profile}" href="/account/profile">修改用户信息</a></li>
<li><a th:if="${!logged}" class="link-secondary text-decoration-none" th:href="@{/account/reset}" href="/account/reset">重置密码</a></li>
</ul>
</div>
<div class="col-6 col-md">
<h5>链接</h5>
<ul class="list-unstyled text-small">
<li><a class="link-secondary text-decoration-none" target="_blank" href="http://10.160.224.11:8080/portal/pt/home/index?lrid=1">报销系统</a>
</li>
<li><a class="link-secondary text-decoration-none" target="_blank" href="http://report.sinochem.com/netrep/login.jsp">报表平台</a></li>
<li><a class="link-secondary text-decoration-none" target="_blank"
href="http://easy.sinochem.com/wui/theme/ecology8/page/main.jsp">办公OA</a></li>
<li><a class="link-secondary text-decoration-none" target="_blank" href="https://ebank.sfc.com/NASApp/iTreasury-ebank/Index.jsp">财务公司</a>
</li>
</ul>
</div>
<div class="col-6 col-md">
<h5>开发者</h5>
<ul class="list-unstyled text-small">
<li><a class="text-decoration-none link-secondary" target="_blank" href="https://github.com/minkolee"><i class="fab fa-github"></i> Github</a> </li>
<li><a class="text-decoration-none link-secondary" target="_blank" href="mailto:lee0709@vip.sina.com"><i class="fas fa-envelope"></i> Mail</a></li>
<li><a class="text-decoration-none link-secondary" target="_blank" href="https://conyli.cc/"><i class="fas fa-link"></i> Site</a></li>
</ul>
</div>
</div>
</footer>
</main>
</body>
</html>
初始化前的页面效果:
初始化后的页面效果:
因为这个功能改了不少东西,不过初始化创建管理员的功能总算编写完成了。首页也搞完了。目前项目的用户模块全部编写好了,也与业务完全解耦,以后再用这套系统只需要从首页修改起就可以了,我也把代码保存一份留以后使用。
用户部分的功能回顾
现在基本上把用户相关的内容写完了。除了基本的用户注册、登录、修改信息、重置密码功能之外,还添加了项目启动之后强制注册管理员的功能。
现在回顾一下这块功能,攒点exp
:
- 业务层功能随时用到随时开发,没有通过接口规范。这个是一个比较大的问题。以后编写业务层的时候,还是自己编写接口比较好。
- 用户所有数据表采用规范化数据库设计,即使用外键关联。这一块其实也可以通过业务来进行约束,而无需使用外键连接,这样的效率会比每次查询约束要快一些。
- 没有使用
Bootstrap
的验证样式,这个后来发现是比较好的验证样式,可以来使用。 - 加强控制器和业务层的分离,控制器尽量只进行数据的校验和展示,由于后边的计算过程比较多了,所以尽量把计算密集型业务都放在业务层。