课程结构
基础数据模块
- 项目概述:系统架构全景
- 环境搭建:开发基石
- 员工管理:权限体系构建
- 分类管理:业务脉络梳理
- 菜品管理:核心数据建模
- 套餐管理实战:组合艺术
点餐业务模块
- 店铺营业状态设置 → 业务开关
- 微信登录 → 无缝认证
- 缓存商品 → 性能优化
- 购物车 → 临时存储区
- 用户下单 → 交易核心
- 订单支付与管理 → 资金流
- 历史订单 → 数据沉淀
- 订单状态定时处理 → 自动化
- 来电提醒和催单 → 即时通讯
统计报表模块
- 图形报表:数据可视化
- Excel报表:结构化输出
软件开发整体介绍
- 软件开发流程
- 需求分析
- 需求规格说明书
- 产品原型
- 设计阶段
- ui设计
- 数据库设计
- 接口设计
- 编码阶段
- 编写项目代码
- 单元测试
- 测试
- 测试用例
- 测试报告
- 上线运维
- 软件环境安装
- 配置
- 需求分析
- 角色分工
- 项目经理 对整个项目负责 人物分配 把控进度
- 产品经理 根据需求调研 输出调研文档 产品原型
- ui设计师 根据产品原型输出界面效果图
- 架构师 项目整体架构实现 技术选型等
- 开发工程师 代码实现
- 测试工程师 编写软件测试用例 输出测试报告
- 运维工程师 软件环境搭建 项目上线
- 软件环境
- 开发环境(developement) 开发人员在开发阶段使用的环境 一般外部用户无法访问
- 测试环境(testing) 专门给测试人员使用的环境 用于测试项目 一般外部用户无法访问
- 生产环境(production) 正式对外提供服务的环境
项目介绍
本项目(苍穹外卖)是专门为餐饮企业(餐厅、饭店)定制的一款软件产品,包括 系统管理后台 和 小程序端应用 两部分。其中系统管理后台主要提供给餐饮企业内部员工使用,可以对餐厅的分类、菜品、套餐、订单、员工等进行管理维护,对餐厅的各类数据进行统计,同时也可进行来单语音播报功能。小程序端主要提供给消费者使用,可以在线浏览菜品、添加购物车、下单、支付、催单等。
- 管理端
餐饮企业内部员工使用
员工登录/退出 , 员工信息管理 , 分类管理 , 菜品管理 , 套餐管理 , 菜品口味管理 , 订单管理 ,数据统计,来单提醒。
模块 | 描述 |
---|---|
登录/退出 | 内部员工必须登录后,才可以访问系统管理后台 |
员工管理 | 管理员可以在系统后台对员工信息进行管理,包含查询、新增、编辑、禁用等功能 |
分类管理 | 主要对当前餐厅经营的 菜品分类 或 套餐分类 进行管理维护, 包含查询、新增、修改、删除等功能 |
菜品管理 | 主要维护各个分类下的菜品信息,包含查询、新增、修改、删除、启售、停售等功能 |
套餐管理 | 主要维护当前餐厅中的套餐信息,包含查询、新增、修改、删除、启售、停售等功能 |
订单管理 | 主要维护用户在移动端下的订单信息,包含查询、取消、派送、完成,以及订单报表下载等功能 |
数据统计 | 主要完成对餐厅的各类数据统计,如营业额、用户数量、订单等 |
- 用户端
移动端应用主要提供给消费者使用。
微信登录 , 收件人地址管理 , 用户历史订单查询 , 菜品规格查询 , 购物车功能 , 下单 , 支付、分类及菜品浏览。
模块 | 描述 |
---|---|
登录/退出 | 用户需要通过微信授权后登录使用小程序进行点餐 |
点餐-菜单 | 在点餐界面需要展示出菜品分类/套餐分类, 并根据当前选择的分类加载其中的菜品信息, 供用户查询选择 |
点餐-购物车 | 用户选中的菜品就会加入用户的购物车, 主要包含 查询购物车、加入购物车、删除购物车、清空购物车等功能 |
订单支付 | 用户选完菜品/套餐后, 可以对购物车菜品进行结算支付, 这时就需要进行订单的支付 |
个人信息 | 在个人中心页面中会展示当前用户的基本信息, 用户可以管理收货地址, 也可以查询历史订单数据 |
技术栈全景
分层架构
层级 | 技术组件 |
---|---|
用户层 | Vue.js, 微信小程序, ElementUI |
网关层 | Nginx(反向代理+负载均衡) |
应用层 | SpringBoot, JWT, WebSocket |
数据层 | MySQL, Redis, MyBatis |
关键技术点
- 安全认证:JWT令牌机制
- 性能优化:Redis缓存+Spring Cache
- 异步处理:Spring Task定时任务
- 文件存储:阿里云OSS对象存储
- 文档管理:Swagger自动化接口文档
- 数据导出:POI Excel操作
开发环境搭建
前端环境搭建
- 基于nginx
- 从黑马提供的资料中找到nginx保存到非中文目录即可
- debug日志
- 启动nginx服务器后 访问官方提供的localhost:80无法正常进入前端页面
- 查看error.log错误日志文件得到报错信息
bind() to 0.0.0.0:80 failed
- 了解到端口80被占用
- 在nginx.conf文件中将监听端口修改为82
- 重启nginx服务器 使用http://localhost:82正常访问到了前端页面
- debug日志
后端环境搭建
- 基于maven进行项目构建 并且进行分模块开发
- 了解项目结构
序号 | 名称 | 说明 |
---|---|---|
1 | sky-take-out | maven父工程,统一管理依赖版本,聚合其他子模块 |
2 | sky-common | 子模块,存放公共类,例如:工具类、常量类、异常类等 |
3 | sky-pojo | 子模块,存放实体类、VO、DTO等 |
4 | sky-server | 子模块,后端服务,存放配置文件、Controller、Service、Mapper等 |
分析sky-common模块的每个包的作用: |
名称 | 说明 |
---|---|
constant | 存放相关常量类 |
context | 存放上下文类 |
enumeration | 项目的枚举类存储 |
exception | 存放自定义异常类 |
json | 处理json转换的类 |
properties | 存放SpringBoot相关的配置属性类 |
result | 返回结果类的封装 |
utils | 常用工具类 |
分析sky-pojo模块的每个包的作用: |
名称 | 说明 |
---|---|
Entity | 实体,通常和数据库中的表对应 |
DTO | 数据传输对象,通常用于程序中各层之间传递数据 |
VO | 视图对象,为前端展示数据提供的对象 |
POJO | 普通Java对象,只有属性和对应的getter和setter |
分析sky-server模块的每个包的作用:
名称 | 说明 |
---|---|
config | 存放配置类 |
controller | 存放controller类 |
interceptor | 存放拦截器类 |
mapper | 存放mapper接口 |
service | 存放service类 |
SkyApplication | 启动类 |
使用git进行版本控制
- 创建本地仓库
- 在gitignore中配置我们不希望看见的文件
- ```
**/target/
*.idea
*.iml
*.class
*.Test.java
**/test/
- 创建本地git项目
- 将git项目同步至github
- debug日志
- 尝试将代码push至github时报错
SSL 证书问题:无法获取本地颁发者证书
- 使用以下代码更新 Git 的证书捆绑包 成功解决
-
# 重置 Git 的 SSL 验证设置
git config --global http.sslBackend schannel # Windows 系统专用
git config --global http.sslVerify true
# 或者使用 OpenSSL 后端
git config --global http.sslBackend openssl
数据库环境搭建
- 通过数据库表语句创建数据库表结构
- 使用提供的sky.sql脚本
- 在datagrip中执行
- 创建sky-take-out架构 共11张表
前后端联调
- 在maven中先进行编译
- debug日志
- 第一次启动项目报错`java: java.lang.NoSuchFieldError: Class com.sun.tools.javac.tree.JCTree$JCImport does not have member field ‘com.sun.tools.javac.tree.JCTree qualid’
- 应该是lombok版本不兼容
- 在pom.xml中添加最新的lombok依赖
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version> <!-- 使用最新版本 -->
<scope>provided</scope>
</dependency>
-
重新编译运行 项目正常启动
-
debug日志
- 启动再次报错`Identify and stop the process that’s listening on port 8080 or configure this application to listen on another port.
- 端口8080已经被其他进程占用。我们需要解决端口冲突问题。
- 很好解决 修改端口即可
- 在application.yml中修改端口号即可
- 注:对应前端配置文件也需要修改端口号
- 同时记得修改数据库在yml中的配置
登录逻辑三层架构
Controller层:
在sky-server模块中,com.sky.controller.admin.EmployeeController
/**
* 登录
*
* @param employeeLoginDTO
* @return
*/
@PostMapping("/login")
public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) {
log.info("员工登录:{}", employeeLoginDTO);
//调用service方法查询数据库
Employee employee = employeeService.login(employeeLoginDTO);
//登录成功后,生成jwt令牌
Map<String, Object> claims = new HashMap<>();
claims.put(JwtClaimsConstant.EMP_ID, employee.getId());
String token = JwtUtil.createJWT(
jwtProperties.getAdminSecretKey(),
jwtProperties.getAdminTtl(),
claims);
EmployeeLoginVO employeeLoginVO = EmployeeLoginVO.builder()
.id(employee.getId())
.userName(employee.getUsername())
.name(employee.getName())
.token(token)
.build();
return Result.success(employeeLoginVO);
}
Service层:
在sky-server模块中,com.sky.service.impl.EmployeeServiceImpl
/**
* 员工登录
*
* @param employeeLoginDTO
* @return
*/
public Employee login(EmployeeLoginDTO employeeLoginDTO) {
String username = employeeLoginDTO.getUsername();
String password = employeeLoginDTO.getPassword();
//1、根据用户名查询数据库中的数据
Employee employee = employeeMapper.getByUsername(username);
//2、处理各种异常情况(用户名不存在、密码不对、账号被锁定)
if (employee == null) {
//账号不存在
throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND);
}
//密码比对
if (!password.equals(employee.getPassword())) {
//密码错误
throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);
}
if (employee.getStatus() == StatusConstant.DISABLE) {
//账号被锁定
throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED);
}
//3、返回实体对象
return employee;
}
Mapper层:
在sky-server模块中,com.sky.mapper.EmployeeMapper
package com.sky.mapper;
import com.sky.entity.Employee;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface EmployeeMapper {
/**
* 根据用户名查询员工
* @param username
* @return
*/
@Select("select * from employee where username = #{username}")
Employee getByUsername(String username);
}
前后端如何联通?
使用nginx反向代理 将前端发送的动态请求由nginx转发到后端服务器
使用反向代理的好处
- 提高访问速度
- nginx可以进行缓存 相同接口情况下不需要访问服务端即可返回数据
- 进行负载均衡
- 可以将大量的请求按照我们指定的方式均衡的分配给每台服务器
- 保证后端服务安全
- 后台服务地址不会直接暴露 而是通过nginx转发
如何配置反向代理?
server{
listen 80;
server_name localhost;
location /api/{
proxy_pass http://localhost:8080/admin/; #反向代理
}
}
如上代码的含义是:监听80端口号, 然后当我们访问 http://localhost:80/api/../…这样的接口的时候,它会通过 location /api/ {} 这样的反向代理到 http://localhost:8080/admin/上来。
同样 在nginx.conf中
# 反向代理,处理管理端发送的请求
location /api/ {
proxy_pass http://localhost:8080/admin/;
#proxy_pass http://webservers/admin/;
}
负载均衡配置
upstream webservers{
server 192.168.100.128:8080;
server 192.168.100.129:8080;
}
server{
listen 80;
server_name localhost;
location /api/{
proxy_pass http://webservers/admin;#负载均衡
}
}
**upstream:**如果代理服务器是一组服务器的话,我们可以使用upstream指令配置后端服务器组。
如上代码的含义是:监听80端口号, 然后当我们访问 http://localhost:80/api/../…这样的接口的时候,它会通过 location /api/ {} 这样的反向代理到 http://webservers/admin,根据webservers名称找到一组服务器,根据设置的负载均衡策略(默认是轮询)转发到具体的服务器。
**注:**upstream后面的名称可自定义,但要上下保持一致。
nginx 负载均衡策略:
名称 | 说明 |
---|---|
轮询 | 默认方式 |
weight | 权重方式,默认为1,权重越高,被分配的客户端请求就越多 |
ip_hash | 依据ip分配方式,这样每个访客可以固定访问一个后端服务 |
least_conn | 依据最少连接方式,把请求优先分配给连接数少的后端服务 |
url_hash | 依据url分配方式,这样相同的url会被分配到同一个后端服务 |
fair | 依据响应时间方式,响应时间短的服务将会被优先分配 |
具体配置方式:
轮询:
upstream webservers{
server 192.168.100.128:8080;
server 192.168.100.129:8080;
}
weight:
upstream webservers{
server 192.168.100.128:8080 weight=90;
server 192.168.100.129:8080 weight=10;
}
ip_hash:
upstream webservers{
ip_hash;
server 192.168.100.128:8080;
server 192.168.100.129:8080;
}
least_conn:
upstream webservers{
least_conn;
server 192.168.100.128:8080;
server 192.168.100.129:8080;
}
url_hash:
upstream webservers{
hash &request_uri;
server 192.168.100.128:8080;
server 192.168.100.129:8080;
}
fair:
upstream webservers{
server 192.168.100.128:8080;
server 192.168.100.129:8080;
fair;
}
完善登录功能
- 问题: 员工表中密码是明文存储 安全性太低
- 解决方案 使用md5加密方式对明文密码加密
- 实现步骤
- 在employee表中修改秘密
- 修改Java代码,前端提交的密码进行MD5加密后再跟数据库中密码比对
- 打开EmployeeServiceImpl.java,修改比对密码
导入接口文档
-
前后端分离开发流程
- 定义接口 确定接口的路径 请求方式 传入参数 返回参数
- 前端开发人员和后端开发人员并行开发 同时也可以自测
- 前后端人员进行联调测试
- 提交给测试人员进行最终测试
-
导入步骤
- 黑马官方教程使用yapi
- 笔者使用自己更为熟悉的apifox
- 考虑到黑马提供的接口文档格式为yapi独有格式 apifox无法识别
- 写了一个脚本将接口文档转换为openAPI 3.0 格式
import json
import re
from datetime import datetime
def convert_yapi_to_openapi(yapi_data, title="Converted API", version="1.0.0"):
"""
将 YAPI 格式转换为 OpenAPI 3.0 格式
"""
# 创建 OpenAPI 基本结构
openapi = {
"openapi": "3.0.0",
"info": {
"title": title,
"version": version,
"description": f"Converted from YAPI on {datetime.now().strftime('%Y-%m-%d')}"
},
"servers": [{"url": "http://localhost:8989"}],
"paths": {},
"components": {
"schemas": {},
"securitySchemes": {
"BearerAuth": {
"type": "http",
"scheme": "bearer"
}
}
},
"security": [{"BearerAuth": []}]
}
# 提取所有组件定
schema_definitions = {}
# 遍历每个分类
for category in yapi_data:
category_name = category.get("name", "Uncategorized")
print(f"Processing category: {category_name}")
# 遍历分类中的接口
for api in category.get("list", []):
try:
path = api.get("path", "")
method = api.get("method", "get").lower()
if not path or not method:
print(f" Skipping invalid API: missing path or method")
continue
# 创建路径对象
if path not in openapi["paths"]:
openapi["paths"][path] = {}
# 处理参数
parameters = []
# 1. 处理查询参数
for query_param in api.get("req_query", []):
parameters.append({
"name": query_param.get("name", ""),
"in": "query",
"description": query_param.get("desc", ""),
"required": query_param.get("required") == "1",
"schema": {
"type": _infer_type(query_param.get("type", "string")),
"example": query_param.get("value", "")
}
})
# 2. 处理路径参数 (从路径中提取)
path_params = re.findall(r'\{(\w+)\}', path)
for param_name in path_params:
parameters.append({
"name": param_name,
"in": "path",
"required": True,
"schema": {"type": "string"}
})
# 3. 处理请求头
for header in api.get("req_headers", []):
if header.get("name", "").lower() == "authorization":
# 跳过授权头,使用全局安全方案
continue
parameters.append({
"name": header.get("name", ""),
"in": "header",
"description": header.get("desc", ""),
"required": header.get("required") == "1",
"schema": {"type": "string"}
})
# 处理请求体
request_body = None
req_body_type = api.get("req_body_type", "")
if req_body_type == "json" and api.get("req_body_other"):
try:
# 解析 JSON Schema
body_schema = json.loads(api["req_body_other"])
# 提取并存储组件定义
if "$$ref" in body_schema:
ref_name = body_schema["$$ref"].split('/')[-1]
schema_definitions[ref_name] = body_schema
body_schema = {"$ref": f"#/components/schemas/{ref_name}"}
request_body = {
"description": "Request body",
"required": True,
"content": {
"application/json": {
"schema": body_schema
}
}
}
except json.JSONDecodeError:
print(f" Error parsing request body for {path}: {api['req_body_other']}")
# 处理响应体
responses = {}
if api.get("res_body"):
try:
res_body = json.loads(api["res_body"])
# 提取并存储组件定义
if "$$ref" in res_body:
ref_name = res_body["$$ref"].split('/')[-1]
schema_definitions[ref_name] = res_body
res_body = {"$ref": f"#/components/schemas/{ref_name}"}
responses["200"] = {
"description": "Successful response",
"content": {
"application/json": {
"schema": res_body
}
}
}
except json.JSONDecodeError:
print(f" Error parsing response body for {path}: {api['res_body']}")
# 如果没有响应体,添加默认响应
if not responses:
responses["200"] = {
"description": "Successful response",
"content": {}
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {"type": "integer"},
"msg": {"type": "string"},
"data": {}
}
}
}
}
}
# 创建接口对象
openapi["paths"][path][method] = {
"summary": api.get("title", "Untitled"),
"description": api.get("desc", ""),
"tags": [category_name],
"parameters": parameters,
"responses": responses
}
if request_body:
openapi["paths"][path][method]["requestBody"] = request_body
print(f" Added {method.upper()} {path}")
except Exception as e:
print(f" Error processing API {api.get('title')}: {str(e)}")
# 添加组件定义
openapi["components"]["schemas"] = schema_definitions
return openapi
def _infer_type(type_str):
"""推断参数类型"""
type_str = type_str.lower()
if "int" in type_str:
return "integer"
if "bool" in type_str:
return "boolean"
if "file" in type_str:
return "string"
return "string"
def main():
# 输入和输出文件路径
input_file = "苍穹外卖-用户端接口.json"
output_file = "苍穹外卖-用户端接口open.json"
print(f"Starting conversion from {input_file} to {output_file}")
try:
# 读取 YAPI 导出的 JSON
with open(input_file, "r", encoding="utf-8") as f:
yapi_data = json.load(f)
# 执行转换
openapi_data = convert_yapi_to_openapi(
yapi_data,
title="苍穹外卖API",
version="1.0.0"
)
# 保存转换结果
with open(output_file, "w", encoding="utf-8") as f:
json.dump(openapi_data, f, ensure_ascii=False, indent=2)
print(f"Conversion completed successfully! Saved to {output_file}")
except Exception as e:
print(f"Error during conversion: {str(e)}")
if __name__ == "__main__":
main()
Swagger
使用他的规范定义接口以及接口相关的信息
就可以做到生成接口文档 以及在线调试接口页面
Knife4j 是为java MVC框架集成Swagger生成api文档的增强解决方案
- 使用步骤
- 导入坐标
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
</dependency>
- 在配置类总加入knife4j的相关配置
- 在WebMvcConfiguration.java中添加
/**
* 通过knife4j生成接口文档
* @return
*/
@Bean
public Docket docket() {
ApiInfo apiInfo = new ApiInfoBuilder()
.title("苍穹外卖项目接口文档")
.version("2.0")
.description("苍穹外卖项目接口文档")
.build();
Docket docket = new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo)
.select()
.apis(RequestHandlerSelectors.basePackage("com.sky.controller"))
.paths(PathSelectors.any())
.build();
return docket;
}
- 设置静态资源映射 否则接口文档无法访问
- 在相同类中添加
/**
* 设置静态资源映射
* @param registry
*/
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/doc.html").addResourceLocations("classpath:/META-INF/resources/");
registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
}
- Apifox / Yapi 主要是设计阶段使用到的工具 用于管理和维护接口
- Swagger是在开发阶段使用的框架 帮助后端开发人眼做后端的接口测试
常用注解
通过注解可以控制生成的接口文档,使接口文档拥有更好的可读性,常用注解如下:
注解 | 说明 |
---|---|
@Api | 用在类上,例如Controller,表示对类的说明 |
@ApiModel | 用在类上,例如entity、DTO、VO |
@ApiModelProperty | 用在属性上,描述属性信息 |
@ApiOperation | 用在方法上,例如Controller的方法,说明方法的用途、作用 |
接下来,使用上述注解,生成可读性更好的接口文档
在sky-pojo模块中
EmployeeLoginDTO.java
package com.sky.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
@Data
@ApiModel(description = "员工登录时传递的数据模型")
public class EmployeeLoginDTO implements Serializable {
@ApiModelProperty("用户名")
private String username;
@ApiModelProperty("密码")
private String password;
}
EmployeeLoginVo.java
package com.sky.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ApiModel(description = "员工登录返回的数据格式")
public class EmployeeLoginVO implements Serializable {
@ApiModelProperty("主键值")
private Long id;
@ApiModelProperty("用户名")
private String userName;
@ApiModelProperty("姓名")
private String name;
@ApiModelProperty("jwt令牌")
private String token;
}
在sky-server模块中
EmployeeController.java
package com.sky.controller.admin;
import com.sky.constant.JwtClaimsConstant;
import com.sky.dto.EmployeeLoginDTO;
import com.sky.entity.Employee;
import com.sky.properties.JwtProperties;
import com.sky.result.Result;
import com.sky.service.EmployeeService;
import com.sky.utils.JwtUtil;
import com.sky.vo.EmployeeLoginVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
/**
* 员工管理
*/
@RestController
@RequestMapping("/admin/employee")
@Slf4j
@Api(tags = "员工相关接口")
public class EmployeeController {
@Autowired
private EmployeeService employeeService;
@Autowired
private JwtProperties jwtProperties;
/**
* 登录
*
* @param employeeLoginDTO
* @return
*/
@PostMapping("/login")
@ApiOperation(value = "员工登录")
public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) {
//..............
}
/**
* 退出
*
* @return
*/
@PostMapping("/logout")
@ApiOperation("员工退出")
public Result<String> logout() {
return Result.success();
}
}