MultiBallSquare 产品开发文档
一、产品简介
1.产品目的
本产品为MultiBallSquare —— 花旗ESG评估平台的网站,目标:让投资者快速、清晰、全面了解到亚洲ESG全景;并获取有建设性的ESG投资建议和相关支撑数据。
2.产品功能
- 为投资者提供亚洲esg图景(基本政策动态,集中的行业实时动态,符合亚洲实际情况的esg指标框架);
- 为投资者提供客观的esg数据库,便于其灵活考察关注的数据。
- 以行业为单位为投资者提供有效的esg评估方案和esg评分排名,并实时监控esg指标权重的更新。
- 提供评估公司详细的esg评估文档。
- 可拓展但未开发业务构思:实地数据调研&采集【第三方合作外包业务或者自身提供专业团队】、负面信息监控&处理、ESG个性化定制服务。
二、开发流程
1.开发流程 - 需求分析,撰写产品需求文档
- 技术选型
- 书写接口文档
- 数据库设计
- 导入jar包
- 编码开发
- 测试
- 运维
2.技术选型
产品分前后端分离开发+算法端计算指标权重。
前端分为web和android端。 - 前端:HTML,CSS,JavaScript,Vue,ElementUI,Echart开发。【开发工具:Webstorm】
- 后端:Springboot+Mybatis。【开发工具:IDEA,Navicat,PowerDesigner,Postman】
- 算法:神经网络模型——多层感知机,调用MLP库函数。【开发工具:Pycharm】
- 服务器:
- 服务器:阿里云服务器ECS
- 服务器操作系统:CentOS 8.5 64位
- 公网IP:47.113.230.20
- 数据库:MySQL
三、文档说明 - 本文档为实际开发过程产品细节设计文档,参考产品需求文档,撰写实际开发产品时所使用的的数据类型、函数逻辑、界面设计。
- 阅读撰写文档的人员包括:前端(web,app)、后端、算法开发人员。
四、产品开发内容
1.前端
1.1.界面总体设计
MultiBallSquare采用VUE+Element+Echarts进行前端开发,布局采用导航栏在左,winth=180px,标题栏在右上,信息展示页面在右下。导航栏分首页、ESG简介、ESG数据、ESG信息来源、ESG产品与咨询、个人中心和管理员登录7个模块。标题栏有登录,联系我们,帮助,全局搜索,个人信息几个标签。点击导航栏对应子模块,在信息页面展示相应内容。信息页面总体采用栅栏布局,元素定位方式主要采用相对定位margin,padding。
1.2.首页设计
首页信息展示页面共6部分:
<div name="div1" id="div1" ref="div1">
<div style="margin-top: 50px" name="div2" id="div2" ref="div2">
<div style="margin-top: 50px" name="div3" id="div3" ref="div3">
<div style="margin-top: 50px" name="div4" id="div4" ref="div4">
<div style="margin-top: 50px" name="div5" id="div5" ref="div5">
技术点:采用栅栏布局一行4栏4个div小卡片。定位方式采用相对定位margin,padding。
代码如下:
…
…
…
…
首页中子页面采用分页查询方式展示信息,采用get请求发送每页信息展示条目数和当前页面给后端,后端返回数据,前端渲染数据到页面。代码如下:
//请求分页查询数据
this.request.get(“/intr/policy/page”, {
params: {
pageNum: this.pageNum,
pageSize: this.pageSize,
}
}).then(res => {
//后端返回给前端json对象,前端发送给后端对象,后端映射,可以json,parm,url
this.Data = res.records
this.total = res.total
});
},
1.3.ESG数据展示页
载入页面时,向后端发送请求,若用户未登陆,报“401”异常,发出警告,跳转登录页面。若用户已登陆,后端返回数据,通过Echarts绘制图形。前端渲染,展示数据信息。代码如下:
this.request.get(“/echarts/members”).then(res=>{
//当权限验证不通过时给出提示
if(res.code == ‘401’){
alert(“请登录后查看数据!”)
// router.push(“/login”)
}
// option.xAxis.data = res.data.x
option.series[0].data = res.data
//数据准备完毕后再set
myChart.setOption(option)
})
页面布局如下:
1.4.ESG评估流程页面
展示MBS ESG评估流程,最后提供MBS评估报考下载,点击下载可以查看MBS评估的宁德时代ESG报告。布局如下:
技术点:通过标签,实现选择文件并携带文件参数访问http://‘+serverIp+’:9091/file/upload’接口地址实现文件上传功能。代码如下:
上传文件
通过在子路由file页面中,打开文件的url地址实现文件下载。访问下载接口的url地址为:/file/url。代码如下
download(url) {
//file后接url
window.open(url)
},
1.5.注册登录页面
前端通过 标签获取登录注册页面的用户信息,发送用户信息到/user/login和/user/register接口。后端处理数据,返回信息。
login() {
//校验表单
this.KaTeX parse error: Expected '}', got 'EOF' at end of input: … this.router.push(“/home”)
} else {
//失败返回失败信息
this.$message.error(res.msg)
}
})
}
});
}
register() {
//校验表单
this.$refs['userForm'].validate((valid) => {
if (valid) { // 表单校验合法
if(this.user.password != this.user.confirmpassword){
this.$message.error("两次输入密码不一致")
return false;
}
this.request.post("/user/register", this.user).then(res => {
//判断返回状态码
if (res.code === '200') {
this.$message.success("注册成功")
} else {
//失败返回失败信息
this.$message.error(res.msg)
}
})
}
});
}
1.6.个人信息修改页面
采用标签选择本地图片,发送用户信息和头像给后端,后端更改数据库信息。最后返回数据,前端渲染展示数据,更改信息修改页面的用户信息,并更新父级组件的用户信息。代码和页面布局如下:
save() {
this.request.post(“/user”, this.form).then(res => {
if (res.code === ‘200’) {
this.
m
e
s
s
a
g
e
.
s
u
c
c
e
s
s
(
"
保存成功
"
)
/
/
触发父级更新
U
s
e
r
的方法
t
h
i
s
.
message.success("保存成功") // 触发父级更新User的方法 this.
message.success("保存成功")//触发父级更新User的方法this.emit(“refreshUser”)
// 更新浏览器存储的用户信息
this.getUser().then(res => {
res.token = JSON.parse(localStorage.getItem(“user”)).token
localStorage.setItem(“user”, JSON.stringify(res))
})
} else {
this.$message.error(“保存失败”)
}
})
},
1.7.搜索功能
技术点:前端输入不完整信息,发送数据给后端,后端进行模糊查询,返回数据给前端。代码如下:
2.Android
1.
2.后端【按分层说明数据变量、函数逻辑、接口设计以及异常处理】
1)数据库设计:
2)接口设计
以接口 /home/foreign_news 为例,HomeForeignNewsMapper 继承于BaseMapper,可以实现自动拼接sql语句,所有的mapper都不需要编写一些通用方法也就是不用编写sql语句。可以提高开发效率。
国外新闻请求接口(分页查询):
@RestController
@RequestMapping(“/home”)
@Autowired
private HomeForeignNewsMapper homeForeignNewsMapper;
public class HomeController {
@PostMapping(“/foreign_news”)
public Result listForeignNews(@RequestBody PageParams pageParams){
Page page = new Page<>(pageParams.getPage(), pageParams.getPageSize());
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
Page<HomeForeignNews> newsPage = homeForeignNewsMapper.selectPage(page, queryWrapper);
List<HomeForeignNews> records = newsPage.getRecords();
Integer total = homeForeignNewsMapper.selectCount(queryWrapper);
return Result.success(new Records(total, records));
}
当接收到前端包含请求体为 {“page”:,“pageSize”:5} 的接口请求后,使用Mabatis-plus提供的接口创建一个Page,并进行分页查询;因为最后还要向前端返回一个总条数total,所以再进行一次count查询,最后对结果进行封装并返回前端。
3)JWT进行跨域登录
Controller层代码:
@RestController
@RequestMapping(“login”)
public class LoginController {
@Autowired
private LoginService loginService;
@PostMapping
public Result login(@RequestBody LoginParam loginParam){
return loginService.login(loginParam);
}
}
Service 层代码和逻辑:
1.检查参数是否合法
2.根据用户名和密码去 user 表中查询 是否存在
3.如果不存在,登录失败
4.如果存在,使用jwt,生成token 返回给前端
5.token放入redis中,redis token:user信息 设置过期时间
(登录认证时候 先认证token字符串是否合法,去redis认证是否存在)
@Override
public Result login(LoginParam loginParam) {
String account = loginParam.getAccount();
String password = loginParam.getPassword();
if (StringUtils.isBlank(account) || StringUtils.isBlank(password)){
return Result.fail(ErrorCode.PARAMS_ERROR.getCode(), ErrorCode.PARAMS_ERROR.getMsg());
}
password = DigestUtils.md5Hex(password + slat);
SysUser sysUser = sysUserService.findUser(account, password);
if (sysUser == null){
return Result.fail(ErrorCode.ACCOUNT_PWD_NOT_EXIST.getCode(), ErrorCode.ACCOUNT_PWD_NOT_EXIST.getMsg());
}
String token = JWTUtils.createToken(sysUser.getId());
redisTemplate.opsForValue().set("TOKEN_"+token, JSON.toJSONString(sysUser),1, TimeUnit.DAYS);
return Result.success(token);
}
最后返回token给前端
4)邮箱注册
具体思路:
1.判断参数是否合法
2.判断账户是否存在,存在则返回:账号已经被注册
3.不存在,则注册用户
4.生成token
5.存入redis 并返回
6.注意加上事务,一旦中间的任何过程出现问题,注册的用户需要回滚
@Override
public Result register(LoginParam loginParam) {
String account = loginParam.getAccount();
String password = loginParam.getPassword();
String nickname = loginParam.getNickname();
String captcha = loginParam.getCaptcha();
String email = loginParam.getEmail();
if (StringUtils.isBlank(account)
|| StringUtils.isBlank(password)
|| StringUtils.isBlank(nickname)
|| StringUtils.isBlank(captcha)
|| StringUtils.isBlank(email)
){
return Result.fail(ErrorCode.PARAMS_ERROR.getCode(), ErrorCode.PARAMS_ERROR.getMsg());//参数错误
}
SysUser sysUser = sysUserService.findUserByAccount(account);
if (sysUser != null){
return Result.fail(ErrorCode.ACCOUNT_EXIST.getCode(), ErrorCode.ACCOUNT_EXIST.getMsg());//账号已存在
}
String captchaJson = redisTemplate.opsForValue().get("CAPTCHA_"+email);
if (StringUtils.isBlank(captcha)){ //过期
return Result.fail(ErrorCode.EMAIL_ERROR.getCode(), "验证码过期");
}else if (!captchaJson.equals(captcha)){
return Result.fail(ErrorCode.EMAIL_ERROR.getCode(), ErrorCode.EMAIL_ERROR.getMsg());
}
sysUser = new SysUser();
sysUser.setNickname(nickname);
sysUser.setAccount(account);
sysUser.setPassword(DigestUtils.md5Hex(password + slat));
sysUser.setCreateDate(System.currentTimeMillis());
sysUser.setLastLogin(System.currentTimeMillis());
sysUser.setEmail(email);
this.sysUserService.save(sysUser);
String token = JWTUtils.createToken(sysUser.getId());
redisTemplate.opsForValue().set("TOKEN_"+token, JSON.toJSONString(sysUser),1, TimeUnit.MINUTES);
return Result.success(token);
}
5)登录拦截(未登录用户不能访问资源)
1.需要判断 请求的接口路径是否为 HandlerMethod(controller方法)
2.判断 token 是否为空, 如果为空:未登录
3.如果 token 不为空,登录验证 loginService checkToken
4.如果 认证成功 放行
@Component
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private LoginService loginService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)){//还可能是 ResourceHttpRequestHandler,用来处理静态资源请求的handler,默认去classpath下的static目录去查询
return true;
}
String token = request.getHeader("Authorization");
if (StringUtils.isBlank(token)){
Result result = Result.fail(ErrorCode.NO_LOGIN.getCode(), ErrorCode.NO_LOGIN.getMsg());
response.setContentType("application/json;charset=utf-8");
response.getWriter().print(JSON.toJSONString(result));
return false;
}
SysUser sysUser = loginService.checkToken(token);
if (sysUser == null){
Result result = Result.fail(ErrorCode.NO_LOGIN.getCode(), ErrorCode.NO_LOGIN.getMsg());
response.setContentType("application/json;charset=utf-8");
response.getWriter().print(JSON.toJSONString(result));
return false;
}
//验证成功,放行
//我希望在controller中 直接获取用户的信息 怎么获取?
UserThreadLocal.put(sysUser);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//如果不删除 ThreadLocal中用完的信息,会有内存泄露的风险
//四种:强引用(不会被回收)、软引用(发生gc且内存不足,才会被回收)、弱引用(gc时直接回收)、虚引用(差不多不存在的引用)
UserThreadLocal.remove();
}
}
登陆前:
登录后(每次请求携带token)
6)文件上传下载
1.逻辑思路:上传文件时,若文件内容不一样 得到不同标识码,先根据文件唯一标识码获取唯一的md5,根据唯一md5在数据库中查询是否有相同的文件,如果没有, 则上传文件到磁盘,记录url地址,如果有,直接返回url地址。最后存储url地址到数据库中。下载文件时,通过url获取硬盘中文件,再通过 ServletOutputStream输出流下载到本地浏览器。
2.代码实现
上传:
public String upload(@RequestParam MultipartFile file) throws IOException {
String originalFilename = file.getOriginalFilename();
String type = FileUtil.extName(originalFilename);
long size = file.getSize();
// 定义一个文件唯一的标识码
String fileUUID = IdUtil.fastSimpleUUID() + StrUtil.DOT + type;
File uploadFile = new File(fileUploadPath + fileUUID);
// 判断配置的文件目录是否存在,若不存在则创建一个新的文件目录
File parentFile = uploadFile.getParentFile();
if(!parentFile.exists()) {
parentFile.mkdirs();
}
String url;
// 获取文件的md5,磁盘只有一份
String md5 = SecureUtil.md5(file.getInputStream());
// 从数据库查询是否存在相同的记录
Files dbFiles = getFileByMd5(md5);
if (dbFiles != null) {
url = dbFiles.getUrl();
} else {
// 上传文件到磁盘
file.transferTo(uploadFile);
// 数据库若不存在重复文件,则不删除刚才上传的文件
//本地下载文件的接口地址
url = "http://localhost:9091/file/" + fileUUID;
//阿里云服务器下载文件的接口地址
// url = “http://47.113.230.20:9091/file/” + fileUUID;
}
// 存储数据库
Files saveFile = new Files();
saveFile.setName(originalFilename);
saveFile.setType(type);
saveFile.setSize(size/1024); // 单位 kb
saveFile.setUrl(url);
saveFile.setMd5(md5);
fileMapper.insert(saveFile);
return url;
}
下载
public void download(@PathVariable String fileUUID, HttpServletResponse response) throws IOException {
// 根据文件的唯一标识码获取文件
File uploadFile = new File(fileUploadPath + fileUUID);
System.out.println(uploadFile);
// 设置输出流的格式
ServletOutputStream os = response.getOutputStream();
response.addHeader(“Content-Disposition”, “attachment;filename=” + URLEncoder.encode(fileUUID, “UTF-8”));
response.setContentType(“application/octet-stream”);
// 读取文件的字节流
os.write(FileUtil.readBytes(uploadFile));
os.flush();
os.close();
}
3.算法
- 模型构建思路
- 项目提出的问题背景:
在不同的行业内同样的指标数据对于行业而言重要程度是不同的,不能一概而论;而在同一个行业内,不同的指标对于行业的影响力也有所不同,有些是关键问题,有些又不是那么重要。但是如何能够实现提供不同的指标数据之后,得到准确合理的esg评分呢?换句话说我们需要得到每个指标的权重,来计算得到esg分数。
我们想到了可以以行业为单位,设计出我们(数据分析师)认为足够重要的指标,获取这些数据之后,构建模型获取每个指标的权重。模型暂且构建为y = wx。【x指标数据,维度为指标个数,w表示每个指标的权重,y为评估分数】 - 项目目标:
首先规定一系列指标x_header和反应企业经营效果的评估标准y;每个指标和标准分别给定数据,假定指标数据为X(x1,x2,……xn)【一共n个属性】,标准为y。构建关系y=Wx。企图求出权重W。最终可以实现给定一组指标数据x_test,可以直接得出最终的评分。 - 模型选取:
想要训练得到得到恰当的权重值,可以使用多元线性回归或者感知机来计算,本项目选取MLP感知机来实现。因为感知机可以有多个隐藏层,当隐藏层有多个节点,隐藏层有多层的时候,其权重不具备特备深刻的含义【不能直观感受某些标签更加重要,而某些标签不重要】。因此我们设计隐藏层只有一层,对比隐藏层只有一个节点(权重只有1个)和20个节点(每个属性有20个权重)的情况。 - 计算结果处理过程:
由于源数据量较小,分析工程量大,只完成19、20年数据分析。针对面对数据量小的难题分别做出以下尝试并进行结果分析: - 利用源数据进行分析,标准化处理之后输入MLP模型,MLP模型:一层隐藏层:分别设置20个节点和1个节点。当设置20个节点由于一个属性会拥有20个权重,最终权重为20个权重的和。
- 设置5次不同的随机种子得出5轮权重,求出权重值的平均值。
- 对平均权值做一个偏移——使权重都变成正数。偏移1
- 求出权值对应权值总和所占比例。——》最终该标签对应的权重。
- 围绕源数据将数据进行随机化操作(波动范围在-1.5~1.5倍源数据),随机波动不具有一致性,分别构造出202个和20002(包含源数据),标准化处理之后,输入MLP模型,MLP模型:一层隐藏层:分别设置20个节点和1个节点。当设置20个节点由于一个属性会拥有20个权重,最终权重为20个权重的和。
- 设置5次不同的随机种子得出5轮权重,求出权重值的平均值。
- 真实的2条源数据作为测试集:利用平均权重*真实的2条源数据对比标签label得分,判断得出最相近的模型设置。
- 求出5个随机种子结果的平均权重、权重偏移、权重比例,将权重比例作为最后每个标签的权重。
- 每个指标的权重*源数据打分得出最终实际的评分。
- 编码准备
- 指标设计:选取新能源行业进行该行业指标设计,并且标注指标得分如何计算【根据什么数字,如何计算出结果】。
- 确定标签label:百分制:净利润(1/3)+基本每股收益(1/3)+总市值(1/3)。
- 获取数据:分析企业年报和社会责任报告获取数据,查询相关数据库。得到对应指标的评分。
- 数据标准化处理:为了模型模拟准确,应该将所有数据(指标评分)缩放到同一范围内:0-100。有些由于无法获得行业水平直接利用数据比例为得分的情况,最终使用最大最小放缩比例来来使所有数据被放缩到0-1之间。
- 编码
-
数据构造
for i in range(10000):
temp = numpy.random.rand(1, len(x_train[0])) + 0.5
for j in (range(temp.shape[1])):
temp[0][j] = temp[0][j] * x_train[0][j]
x_train = numpy.row_stack((x_train,temp))
for i in range(10000):
temp = numpy.random.rand(1, len(x_train[1])) + 0.5
for j in range(temp.shape[1]):
temp[0][j] = temp[0][j] * x_train[1][j]
x_train = numpy.row_stack((x_train,temp))
for i in range(10000):
temp = numpy.random.rand(1,1) + 0.5
a = y_train[0] * temp[0][0]
y_train.append(a)
for i in range(10000):
temp = numpy.random.rand(1,1) + 0.5
a = y_train[1]* temp[0][0]
y_train.append(a)
2. # 提取数据
with open(‘test2.csv’, mode=‘r’, newline=“”, encoding=“utf-8”) as csv_file:
reader = csv.reader(csv_file)
reader = list(reader)
reader.remove(reader[0])
x_train = reader
数据预处理
for item in x_train:
for j in range(len(item)):
item[j] = float(item[j])
x_train = numpy.array(x_train)
y_train = numpy.array(y_train)
y_train = y_train.astype(‘int’)
数据标准化处理
min_max_scaler = preprocessing.MinMaxScaler()
x_train = min_max_scaler.fit_transform(x_train)
搭建模型
clf = MLPClassifier(activation=‘relu’, alpha=1e-05, batch_size=‘auto’, beta_1=0.9,
beta_2=0.999, early_stopping=False, epsilon=1e-08,
hidden_layer_sizes=(20), learning_rate=‘constant’,
learning_rate_init=0.001, max_iter=3000, momentum=0.9,
nesterovs_momentum=True, power_t=0.5, random_state=1, shuffle=True,
solver=‘lbfgs’, tol=0.0001, validation_fraction=0.1, verbose=False,
warm_start=False)
训练数据
clf.fit(x_train, y_train)
提取权重
weight = clf.coefs_
result = []
array = weight[0]
r = []
for i in range(len(array)):
sum = 0
for item in array[i]:
sum += item
r.append(sum)
for i in range(len(array[0])):
temp = []
for j in range(len(array)):
sum += array[j][i]
temp.append(array[j][i])
result.append(temp)
result.append®
数据输出
with open(“result.csv”,mode=‘w’,newline=“”, encoding=“utf-8”) as csv_file:
writer = csv.writer(csv_file)
writer.writerows(result)
- 结果评估
- 首先对比2个源数据,202,20002个构造数据的权重测试得分,对比发现源数据2的趋势最匹配结果。分析:虽然构造数据量足够大,但是由于数据随机构造,其排布规律并不符合实际企业得分规律,也就是基本都是噪声数据,没有有效规律可学习,因此模型得到的数据并不合理。
- 接着分别对比20个结点和1个结点的情况,对比发现,20个结点趋势【得分值相近对比;二者得分数据差别趋势对比;5次随机种子权重波动是否明显差异】明显好于1个结点。
- 最终选择只有两个源数据,隐藏层有20个结点的权重结果进行更进一步的分析。
- 可行性分析
- 逻辑合理,有一定可行性。
- 数据量过小可能造成模型欠拟合,无法得出更加准确的模型。
- 评分合理性有待考量,部分指标为非强制计算型数据,多根据自身客观判断打分,可能存在片面性、不合理性。
- 难点分析
- 指标设计是否具有针对性。
- 持有行业平均水平的数据,才能给一家公司打相对合理的分数,但是整个行业的相关数据难以获取。
- 训练人工神经网络模型必须要大量的数据才能得出合理的模型——恰当的权重。但是关键问题在于有效数据量很小【有确定年份的准确披露数据几乎为0】。只能保证模型理论自洽,但是实操的准确性有待考量。
- 而且由于模型训练具有特征提取的目的,必须保证源数据规律一致性贴近实际、反应真实企业发展情况,才能学习其背后的变化规律,提取出准确完整的标签权重。