Fms-Java版开发实录:14 创建管理员功能

Fms-Java版开发实录:14 创建管理员功能

编写了设置数据表,实现了初始化创建管理员的功能,看来我的水平又提高了。

用户功能编写好了,不过还缺少一块内容,就是在系统第一次启动的时候,强制让用户创建一个管理员账号,这需要用到初始化数据库和加入一个配置表的功能,这次我就来实现一下这个功能。

一些重构工作

这里的重构其实就是重新组织一下包,看了一下已经编写的代码,还算解耦的比较重复,控制器除了数据校验之外没有做太多的工作。

现在就把包重新整理一下:

  1. config弄一个properties包,把EmailProperties类放进去。
  2. controller中设置account目录,然后把AccountController放进去,由于目前还没有根目录的控制器,所以要再创建一个main包,将来编写首页的控制器就塞到里边去。
  3. dao包中也是如此,创建account目录,然后把目前的四个DAO类都放进去。
  4. service包中更是如此,创建account目录,把目前相关的三个业务类都放进去。
  5. dataobject包中创建account目录,然后把其下的businessobjectentityvalueobject都移动进去。

还有一个内容就是需要把所有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

这个是配置一个DataSourceInitializerBean,我们在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的内容是固定添加ADMINSUPERUSERUSER三条记录:

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表来存储项目的配置,我现在能想到的配置没有那么多,但也先做一个,以后可以再添加。

HALOoptions表中,option_keyoption_value都是字符串,外加上常用的create_timeupdate_time,我也打算如此来设计。

dao包下创建option包,这个包用来存放所有通用的设置,除了将要创建的POOption,我打算把增值税率,印花税率之类的东西,也存放在这个包里,等编写到业务的时候再看需要添加哪些记录。

创建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类都没添加上创建时间和修改时间两个类,CreationTimestampUpdateTimestamp非常方便,直接添加在TIMESTAMP类型的属性上即可。这里我给UserUserInfo两个类也都添加了创建和修改时间两个属性。

这个Option类比较简单,其核心就是字符串形式的键值对,用于保存配置。

管理员初始化功能

初始化超级管理员的逻辑如下:

  1. 首先在Spring Security中放开对于首页的访问。
  2. 在首页的控制器中,需要读取配置,看是否已经完成初始化工作
  3. 如果完成初始化工作,则跳转至其他页面(跳转到哪里再编写)
  4. 如果未完成初始化工作,则需要给出一个页面供用户填写资料
  5. 用户填写资料完毕之后,保存此用户资料,但特别要将其设置为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_TIMESTAMPPostgreSQL的函数,用于获取当前时间戳。在初始化的时候插入了一个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控制器编写

这个控制器的逻辑如下:

  1. 验证表单,错误就返回
  2. 验证两个密码是否一致,不一致就返回
  3. 通过验证后,组装UserData对象,然后要设置上enabledtrue,之后保存进数据库,并且将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">高效&nbsp;协同&nbsp;简洁&nbsp;现代</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">&copy; 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>

初始化前的页面效果:
fms-firstpage
初始化后的页面效果:
fms-initialized

因为这个功能改了不少东西,不过初始化创建管理员的功能总算编写完成了。首页也搞完了。目前项目的用户模块全部编写好了,也与业务完全解耦,以后再用这套系统只需要从首页修改起就可以了,我也把代码保存一份留以后使用。

用户部分的功能回顾

现在基本上把用户相关的内容写完了。除了基本的用户注册、登录、修改信息、重置密码功能之外,还添加了项目启动之后强制注册管理员的功能。

现在回顾一下这块功能,攒点exp:

  1. 业务层功能随时用到随时开发,没有通过接口规范。这个是一个比较大的问题。以后编写业务层的时候,还是自己编写接口比较好。
  2. 用户所有数据表采用规范化数据库设计,即使用外键关联。这一块其实也可以通过业务来进行约束,而无需使用外键连接,这样的效率会比每次查询约束要快一些。
  3. 没有使用Bootstrap的验证样式,这个后来发现是比较好的验证样式,可以来使用。
  4. 加强控制器和业务层的分离,控制器尽量只进行数据的校验和展示,由于后边的计算过程比较多了,所以尽量把计算密集型业务都放在业务层。
LICENSED UNDER CC BY-NC-SA 4.0
Comment