Manager web (#237)

* update:增加前端设计图

* 前端代码优化

* update:底部信息纠正

* 增加:flyio

* update:去除org.quartz

* update:登陆功能

* aliyunTTS常联token

* 新增aliyunTTS长期Token方式

* 获取用户信息和已绑设备

* update:验证码,登录,注册

* update:去掉依赖错误代码

---------

Co-authored-by: hrz <1710360675@qq.com>
Co-authored-by: CGD <3030332422@qq.com>
Co-authored-by: Ken <ulxiping@qq.com>
This commit is contained in:
欣南科技
2025-03-07 21:19:41 +08:00
committed by GitHub
co-authored by hrz CGD Ken
parent dc4b8a5002
commit 8b74dec910
28 changed files with 2120 additions and 1157 deletions
Executable → Regular
View File
+1 -1
View File
@@ -23,7 +23,6 @@
<mybatisplus.version>3.5.5</mybatisplus.version> <mybatisplus.version>3.5.5</mybatisplus.version>
<hutool.version>5.8.24</hutool.version> <hutool.version>5.8.24</hutool.version>
<jsoup.version>1.19.1</jsoup.version> <jsoup.version>1.19.1</jsoup.version>
<jasypt.version>3.0.5</jasypt.version>
<knife4j.version>4.6.0</knife4j.version> <knife4j.version>4.6.0</knife4j.version>
<shiro.version>2.0.2</shiro.version> <shiro.version>2.0.2</shiro.version>
<captcha.version>1.6.2</captcha.version> <captcha.version>1.6.2</captcha.version>
@@ -67,6 +66,7 @@
</exclusion> </exclusion>
</exclusions> </exclusions>
</dependency> </dependency>
<!-- 验证码工具包 -->
<dependency> <dependency>
<groupId>com.github.whvcse</groupId> <groupId>com.github.whvcse</groupId>
<artifactId>easy-captcha</artifactId> <artifactId>easy-captcha</artifactId>
@@ -40,4 +40,6 @@ public interface ErrorCode {
int PASSWORD_LENGTH_ERROR = 10030; int PASSWORD_LENGTH_ERROR = 10030;
int PASSWORD_WEAK_ERROR = 10031; int PASSWORD_WEAK_ERROR = 10031;
int DEL_MYSELF_ERROR = 10032; int DEL_MYSELF_ERROR = 10032;
// 验证码错误
int VERIFICATION_CODE = 10033;
} }
@@ -0,0 +1,24 @@
package xiaozhi.modules.sys.service;
import jakarta.servlet.http.HttpServletResponse;
/**
* @author zjy
* @since 2025-3-7
*/
public interface CaptchaService {
/**
* 生成验证码
* @param response 响应对象
* @param uuid 验证码唯一id
*/
void create(HttpServletResponse response, String uuid);
/**
* 验证码验证
* @param uuid 验证码唯一id
* @param code 验证码内容
* @return
*/
void validate(String uuid, String code);
}
@@ -0,0 +1,11 @@
package xiaozhi.modules.sys.service;
public interface TokenService {
/**
* 生成token
*
* @param userId
* @return
*/
String createToken(long userId);
}
@@ -0,0 +1,65 @@
package xiaozhi.modules.sys.service.impl;
import com.wf.captcha.SpecCaptcha;
import com.wf.captcha.base.Captcha;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.sys.service.CaptchaService;
import java.io.IOException;
/**
* @author zjy
* @since 2025-3-7
*/
@AllArgsConstructor
@Service("captchaService")
public class CaptchaServiceImpl implements CaptchaService {
private final RedisUtils redisUtils;
@Value("${captcha.expire}")
private Integer captchaExpireTime;
@Override
public void create(HttpServletResponse response, String uuid) {
response.setContentType("image/png");
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expires", 0);
// 创建验证码对象 宽度、高度、字符长度
SpecCaptcha captcha = new SpecCaptcha(130, 48, 5);
// 默认字符类型
captcha.setCharType(Captcha.TYPE_DEFAULT);
// 缓存验证码
redisUtils.set(uuid,captcha.text(),captchaExpireTime);
// 输出验证码图片
try {
captcha.out(response.getOutputStream());
} catch (IOException e) {
throw new RenException(ErrorCode.VERIFICATION_CODE,"验证码生成错误");
}
}
@Override
public void validate(String uuid, String code) {
// 从缓存中获取验证码
Object storedCode = redisUtils.get(uuid);
//对验证码进行验证过程
if(storedCode == null){
throw new RenException(ErrorCode.VERIFICATION_CODE,"验证码过期,请从新获取");
}else if(code.equalsIgnoreCase(storedCode.toString())){
throw new RenException(ErrorCode.VERIFICATION_CODE,"验证码错误");
}
// 从缓存删除验证码
redisUtils.delete(uuid);
}
}
@@ -0,0 +1,31 @@
package xiaozhi.modules.sys.service.impl;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.security.oauth2.TokenGenerator;
import xiaozhi.modules.sys.service.TokenService;
import java.util.Date;
@AllArgsConstructor
@Service
public class TokenServiceImpl implements TokenService {
private final RedisUtils redisUtils;
/**
* 3小时无操作过期
*/
private final static int EXPIRE = 60 * 60 * 3;
@Override
public String createToken(long userId) {
//生成一个token
String token = TokenGenerator.generateValue();
//当前时间
Date now = new Date();
//过期时间
Date expireTime = new Date(now.getTime() + EXPIRE * 1000);
return token;
}
}
@@ -15,7 +15,7 @@ spring:
druid: druid:
#MySQL #MySQL
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/xiaozhi_esp32_server?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true url: jdbc:mysql://192.168.110.130:3306/xiaozhi_esp32_server?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true
username: root username: root
password: 123456 password: 123456
initial-size: 10 initial-size: 10
@@ -47,3 +47,6 @@ logging:
level: level:
org.flowable.engine.impl.persistence.entity.*: debug org.flowable.engine.impl.persistence.entity.*: debug
org.flowable.task.service.impl.persistence.entity.*: debug org.flowable.task.service.impl.persistence.entity.*: debug
# 登录验证码过期时间(秒)
captcha:
expire: 300
@@ -1,25 +0,0 @@
package xiaozhi;
import org.jasypt.encryption.StringEncryptor;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
/**
* 单元测试
*/
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class DbEncTest {
@Autowired
StringEncryptor stringEncryptor;
@Test
public void jiami() {
System.out.println("username:" + stringEncryptor.encrypt("07e43e8d669fb946e31ccd4ef5f32c9f2287619c79b766a5985d2c99ad7b7c7e"));
System.out.println("password:" + stringEncryptor.encrypt("042e94093fd2c2765ea45cf13ddbfd38e93026df4b6d5e4206ea5ac90956d63ab73e8b82c6daf7829f9aea7e27e1db5bb0a90944c4c4985af44db0ef49c46d6ad6"));
}
}
@@ -1,27 +0,0 @@
package xiaozhi;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.sys.entity.SysUserEntity;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class RedisTest {
@Resource
private RedisUtils redisUtils;
@Test
public void contextLoads() {
SysUserEntity user = new SysUserEntity();
user.setEmail("123456@qq.com");
redisUtils.set("user", user);
System.out.println(ToStringBuilder.reflectionToString(redisUtils.get("user")));
}
}
@@ -1,24 +0,0 @@
package xiaozhi.service;
import xiaozhi.modules.sys.dao.SysUserDao;
import xiaozhi.modules.sys.entity.SysUserEntity;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
/**
* 测试多数据源
*/
@Service
public class DynamicDataSourceTestService {
@Resource
private SysUserDao sysUserDao;
//@Transactional
public void updateUser(Long id) {
SysUserEntity user = new SysUserEntity();
user.setId(id);
user.setMobile("13500000000");
//sysUserDao.updateById(user);
System.out.println(sysUserDao.selectById(id));
}
}
+1074 -307
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -7,11 +7,13 @@
"build": "vue-cli-service build" "build": "vue-cli-service build"
}, },
"dependencies": { "dependencies": {
"axios": "^1.8.1",
"element-ui": "^2.15.14", "element-ui": "^2.15.14",
"flyio": "^0.6.14", "flyio": "^0.6.14",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"vue": "^2.6.14", "vue": "^2.6.14",
"vue-router": "^3.5.1", "vue-axios": "^3.5.2",
"vue-router": "^3.6.5",
"vuex": "^3.6.2" "vuex": "^3.6.2"
}, },
"devDependencies": { "devDependencies": {
+6 -4
View File
@@ -1,5 +1,6 @@
// 引入各个模块的请求 // 引入各个模块的请求
import user from './module/user.js' import user from './module/user.js'
/** /**
* 接口地址 * 接口地址
* 在开发阶段,如果地址写的是相对路径,请与vue.config.js的devServer配置相结合,方便跨域请求 * 在开发阶段,如果地址写的是相对路径,请与vue.config.js的devServer配置相结合,方便跨域请求
@@ -11,12 +12,13 @@ const DEV_API_SERVICE = 'https://apifoxmock.com/m1/5931378-5618560-default'
* 根据开发环境返回接口url * 根据开发环境返回接口url
* @returns {string} * @returns {string}
*/ */
export function getServiceUrl () { export function getServiceUrl() {
return DEV_API_SERVICE return DEV_API_SERVICE
} }
/** request服务封装 */ /** request服务封装 */
export default { export default {
getServiceUrl, getServiceUrl,
user user,
} }
+27 -1
View File
@@ -16,5 +16,31 @@ export default {
this.login(loginForm, callback) this.login(loginForm, callback)
}) })
}).send() }).send()
} },
// 获取用户信息
getUserInfo(callback) {
RequestService.sendRequest().url(`${getServiceUrl()}/api/v1/user/info`).method('GET')
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail(() => {
RequestService.reAjaxFun(() => {
this.getUserInfo()
})
}).send()
},
// 获取设备信息
getHomeList(callback) {
RequestService.sendRequest().url(`${getServiceUrl()}/api/v1/user/device/bind`).method('GET')
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail(() => {
RequestService.reAjaxFun(() => {
this.getUserInfo()
})
}).send()
},
} }
+41 -13
View File
@@ -3,7 +3,7 @@
<el-container style="height: 100%;"> <el-container style="height: 100%;">
<el-header class="header"> <el-header class="header">
<div style="display: flex;justify-content: space-between;"> <div style="display: flex;justify-content: space-between;">
<div style="display: flex;align-items: center;gap: 8px;"> <div style="display: flex;align-items: center;gap: 8px;margin-top: 10px;">
<img src="@/assets/xiaozhi-logo.png" alt="" style="width: 45px;height: 45px;" /> <img src="@/assets/xiaozhi-logo.png" alt="" style="width: 45px;height: 45px;" />
<img src="@/assets/xiaozhi-ai.png" alt="" style="width: 70px;height: 13px;" /> <img src="@/assets/xiaozhi-ai.png" alt="" style="width: 70px;height: 13px;" />
<div class="equipment-management" @click="settingDevice=false"> <div class="equipment-management" @click="settingDevice=false">
@@ -19,7 +19,7 @@
<img src="@/assets/home/close.png" alt="" style="width: 6px;height: 6px;" /> <img src="@/assets/home/close.png" alt="" style="width: 6px;height: 6px;" />
</div> </div>
</div> </div>
<div style="display: flex;align-items: center;gap: 8px;"> <div style="display: flex;align-items: center;gap: 8px;margin-top: 10px">
<div class="serach-box"> <div class="serach-box">
<el-input placeholder="输入名称搜索.." v-model="serach" style="border: none; background: transparent;" /> <el-input placeholder="输入名称搜索.." v-model="serach" style="border: none; background: transparent;" />
<img src="@/assets/home/search.png" alt="" <img src="@/assets/home/search.png" alt=""
@@ -27,7 +27,8 @@
</div> </div>
<img src="@/assets/home/avatar.png" alt="" style="width: 21px;height: 21px;" /> <img src="@/assets/home/avatar.png" alt="" style="width: 21px;height: 21px;" />
<div class="user-info"> <div class="user-info">
158 3632 4642</div> {{ userInfo.mobile }}
</div>
</div> </div>
</div> </div>
</el-header> </el-header>
@@ -56,10 +57,11 @@
</div> </div>
<div <div
style="display: flex;flex-wrap: wrap;margin-top: 15px;gap: 15px;justify-content: space-between;box-sizing: border-box;"> style="display: flex;flex-wrap: wrap;margin-top: 15px;gap: 15px;justify-content: space-between;box-sizing: border-box;">
<div class="device-item" v-for="(item,index) in 10" :key="index"> <div class="device-item" v-for="(item,index) in deviceList" :key="index">
<div style="display: flex;justify-content: space-between;"> <div style="display: flex;justify-content: space-between;">
<div style="font-weight: 700;font-size: 18px;text-align: left;color: #3d4566;"> <div style="font-weight: 700;font-size: 18px;text-align: left;color: #3d4566;">
CC:ba:97:11:a6:ac <!-- CC:ba:97:11:a6:ac-->
{{item.list[0]?.mac_address}}
</div> </div>
<div> <div>
<img src="@/assets/home/delete.png" alt="" <img src="@/assets/home/delete.png" alt=""
@@ -68,7 +70,7 @@
</div> </div>
</div> </div>
<div class="device-name"> <div class="device-name">
设备型号:esp32-s3-touch-amoled-1.8 设备型号:{{item.list[0]?.device_type}}
</div> </div>
<div style="display: flex;gap: 8px;align-items: center;"> <div style="display: flex;gap: 8px;align-items: center;">
<div class="settings-btn" @click="clickSettingDevice"> <div class="settings-btn" @click="clickSettingDevice">
@@ -77,12 +79,12 @@
声纹识别</div> 声纹识别</div>
<div class="settings-btn"> <div class="settings-btn">
历史对话</div> 历史对话</div>
<el-switch v-model="switchValue" inactive-text="OTA升级:" :width="32" <el-switch :value="item.list[0]?.ota_upgrade && true || false" inactive-text="OTA升级:" :width="32"
style="margin-left: auto;" /> style="margin-left: auto;" />
</div> </div>
<div class="version-info"> <div class="version-info">
<div>最近对话:6天前</div> <div>最近对话:{{item.list[0]?.recent_chat_time}}</div>
<div>APP版本:1.1.0</div> <div>APP版本:{{item.list[0]?.app_version}}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -185,7 +187,8 @@
</div> </div>
<div <div
style="font-size: 12px;font-weight: 400;margin-top: auto;padding-top: 30px;color: #979db1;"> style="font-size: 12px;font-weight: 400;margin-top: auto;padding-top: 30px;color: #979db1;">
©2025 xiaozhi-esp32-server</div> ©2025 xiaozhi-esp32-server
</div>
</el-main> </el-main>
</el-container> </el-container>
<el-dialog :visible.sync="addDeviceDialogVisible" width="400px" center> <el-dialog :visible.sync="addDeviceDialogVisible" width="400px" center>
@@ -220,6 +223,8 @@
<script> <script>
// @ is an alias to /src // @ is an alias to /src
import Api from '@/apis/api';
export default { export default {
name: 'home', name: 'home',
data() { data() {
@@ -242,8 +247,12 @@ export default {
}, { }, {
value: '选项2', value: '选项2',
label: '双皮奶' label: '双皮奶'
}] }],
} userInfo: {
mobile: '' // 初始化用户信息
},
deviceList:[]
};
}, },
methods: { methods: {
showAddDialog() { showAddDialog() {
@@ -251,10 +260,28 @@ export default {
}, },
clickSettingDevice() { clickSettingDevice() {
this.settingDevice = true this.settingDevice = true
},
// 获取用户信息
fetchUserInfo() {
Api.user.getUserInfo(({data}) => {
this.userInfo = data.data
});
},
// 获取已绑设备
getList(){
Api.user.getHomeList(({data})=>{
console.log(data.data)
this.deviceList = data.data
})
} }
},
mounted() {
this.fetchUserInfo(); // 组件加载时获取用户信息
this.getList()
} }
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.welcome { .welcome {
min-width: 900px; min-width: 900px;
@@ -464,7 +491,7 @@ export default {
} }
} }
.device-item { .device-item {
width: 350px; width: 345px;
border-radius: 15px; border-radius: 15px;
background: #fafcfe; background: #fafcfe;
padding: 22px; padding: 22px;
@@ -507,6 +534,7 @@ audio::-webkit-media-controls-panel {
line-height: 34px; line-height: 34px;
box-sizing: border-box; box-sizing: border-box;
cursor: pointer; cursor: pointer;
font-size: 12px;
} }
.save-btn { .save-btn {
border-radius: 23px; border-radius: 23px;
+4 -1
View File
@@ -305,8 +305,11 @@ TTS:
type: aliyun type: aliyun
output_file: tmp/ output_file: tmp/
appkey: 你的阿里云智能语音交互服务项目Appkey appkey: 你的阿里云智能语音交互服务项目Appkey
token: 你的阿里云智能语音交互服务AccessToken token: 你的阿里云智能语音交互服务AccessToken,临时的24小时,要长期用下方的access_key_idaccess_key_secret
voice: xiaoyun voice: xiaoyun
access_key_id: 你的阿里云账号access_key_id
access_key_secret: 你的阿里云账号access_key_secret
# 以下可不用设置,使用默认设置 # 以下可不用设置,使用默认设置
# format: wav # format: wav
# sample_rate: 16000 # sample_rate: 16000
@@ -1,19 +1,94 @@
import os import os
import uuid import uuid
import json import json
import hmac
import hashlib
import base64
import requests import requests
from datetime import datetime from datetime import datetime
from core.providers.tts.base import TTSProviderBase from core.providers.tts.base import TTSProviderBase
import http.client import http.client
import urllib.parse import urllib.parse
import time
import uuid
from urllib import parse
class AccessToken:
@staticmethod
def _encode_text(text):
encoded_text = parse.quote_plus(text)
return encoded_text.replace('+', '%20').replace('*', '%2A').replace('%7E', '~')
@staticmethod
def _encode_dict(dic):
keys = dic.keys()
dic_sorted = [(key, dic[key]) for key in sorted(keys)]
encoded_text = parse.urlencode(dic_sorted)
return encoded_text.replace('+', '%20').replace('*', '%2A').replace('%7E', '~')
@staticmethod
def create_token(access_key_id, access_key_secret):
parameters = {'AccessKeyId': access_key_id,
'Action': 'CreateToken',
'Format': 'JSON',
'RegionId': 'cn-shanghai',
'SignatureMethod': 'HMAC-SHA1',
'SignatureNonce': str(uuid.uuid1()),
'SignatureVersion': '1.0',
'Timestamp': time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
'Version': '2019-02-28'}
# 构造规范化的请求字符串
query_string = AccessToken._encode_dict(parameters)
print('规范化的请求字符串: %s' % query_string)
# 构造待签名字符串
string_to_sign = 'GET' + '&' + AccessToken._encode_text('/') + '&' + AccessToken._encode_text(query_string)
print('待签名的字符串: %s' % string_to_sign)
# 计算签名
secreted_string = hmac.new(bytes(access_key_secret + '&', encoding='utf-8'),
bytes(string_to_sign, encoding='utf-8'),
hashlib.sha1).digest()
signature = base64.b64encode(secreted_string)
print('签名: %s' % signature)
# 进行URL编码
signature = AccessToken._encode_text(signature)
print('URL编码后的签名: %s' % signature)
# 调用服务
full_url = 'http://nls-meta.cn-shanghai.aliyuncs.com/?Signature=%s&%s' % (signature, query_string)
print('url: %s' % full_url)
# 提交HTTP GET请求
response = requests.get(full_url)
if response.ok:
root_obj = response.json()
key = 'Token'
if key in root_obj:
token = root_obj[key]['Id']
expire_time = root_obj[key]['ExpireTime']
return token, expire_time
print(response.text)
return None, None
class TTSProvider(TTSProviderBase): class TTSProvider(TTSProviderBase):
def __init__(self, config, delete_audio_file): def __init__(self, config, delete_audio_file):
super().__init__(config, delete_audio_file) super().__init__(config, delete_audio_file)
# 新增空值判断逻辑
access_key_id = config.get("access_key_id")
access_key_secret = config.get("access_key_secret")
if access_key_id and access_key_secret:
# 使用密钥对生成临时token
token, expire_time = AccessToken.create_token(access_key_id, access_key_secret)
else:
# 直接使用预生成的长期token
token = config.get("token")
expire_time = None
print('token: %s, expire time(s): %s' % (token, expire_time))
self.appkey = config.get("appkey") self.appkey = config.get("appkey")
self.token = config.get("token") self.token = token
self.format = config.get("format", "wav") self.format = config.get("format", "wav")
self.sample_rate = config.get("sample_rate", 16000) self.sample_rate = config.get("sample_rate", 16000)
self.voice = config.get("voice", "xiaoyun") self.voice = config.get("voice", "xiaoyun")