feat :init

This commit is contained in:
2025-11-02 19:34:16 +08:00
commit b767041311
617 changed files with 124099 additions and 0 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
# 排除其他不需要的文件/目录
.git/
.github/
.idea/
.vscode/
node_modules/
target/
audio/
avatar/
*.md
*.log

View File

@@ -0,0 +1,159 @@
name: Semantic Version Update
on:
push:
branches: [ main ]
paths-ignore:
- 'version.properties'
- 'pom.xml'
- 'CHANGELOG.md'
jobs:
update-version:
runs-on: ubuntu-latest
# 添加写入权限
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
# 使用GITHUB_TOKEN进行初始检出
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up JDK
uses: actions/setup-java@v3
with:
java-version: '21'
distribution: 'temurin'
- name: Get current version
id: current-version
run: |
if [ -f "version.properties" ]; then
CURRENT_VERSION=$(grep "version=" version.properties | cut -d'=' -f2)
else
# 从pom.xml中提取当前版本
CURRENT_VERSION=$(grep -m 1 "<version>" pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
fi
echo "CURRENT_VERSION=${CURRENT_VERSION}" >> $GITHUB_ENV
echo "Current version: ${CURRENT_VERSION}"
- name: Determine version increment
id: version-increment
run: |
# 获取最近一次提交信息
COMMIT_MSG=$(git log -1 --pretty=%B)
echo "Commit message: ${COMMIT_MSG}"
# 解析当前版本
IFS='.' read -r MAJOR MINOR PATCH <<< "${CURRENT_VERSION}"
# 根据提交信息决定版本增量
if [[ "${COMMIT_MSG}" =~ ^(feat|feature)(\(.*\))?!:.*$ || "${COMMIT_MSG}" =~ ^BREAKING[[:space:]]CHANGE:.*$ ]]; then
# 破坏性变更 - 增加主版本号
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
CHANGE_TYPE="MAJOR"
elif [[ "${COMMIT_MSG}" =~ ^feat(\(.*\))?:.*$ ]]; then
# 新功能 - 增加次版本号
MINOR=$((MINOR + 1))
PATCH=0
CHANGE_TYPE="MINOR"
elif [[ "${COMMIT_MSG}" =~ ^(fix|bugfix|hotfix)(\(.*\))?:.*$ ]]; then
# 错误修复 - 增加修订号
PATCH=$((PATCH + 1))
CHANGE_TYPE="PATCH"
elif [[ "${COMMIT_MSG}" =~ ^(refactor|perf|style|test|docs|build|ci|chore)(\(.*\))?:.*$ ]]; then
# 重构、性能优化、样式、测试、文档、构建、CI、杂项 - 增加修订号
PATCH=$((PATCH + 1))
CHANGE_TYPE="PATCH"
elif [[ "${COMMIT_MSG}" =~ ^update(\(.*\))?:.*$ ]]; then
# 更新 - 增加修订号
PATCH=$((PATCH + 1))
CHANGE_TYPE="PATCH"
else
# 默认情况 - 增加修订号
PATCH=$((PATCH + 1))
CHANGE_TYPE="PATCH"
fi
# 设置新版本
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
echo "NEW_VERSION=${NEW_VERSION}" >> $GITHUB_ENV
echo "CHANGE_TYPE=${CHANGE_TYPE}" >> $GITHUB_ENV
echo "New version will be: ${NEW_VERSION} (${CHANGE_TYPE} change)"
- name: Update version files
run: |
# 更新version.properties文件
echo "version=${NEW_VERSION}" > version.properties
# 更新Maven版本
mvn versions:set -DnewVersion=${NEW_VERSION} -DgenerateBackupPoms=false
- name: Commit version changes
run: |
git config --global user.name 'GitHub Actions'
git config --global user.email 'actions@github.com'
git add version.properties pom.xml
if [ -f "src/main/resources/application.properties" ]; then
git add src/main/resources/application.properties
fi
git commit -m "chore: update version to ${NEW_VERSION} [skip ci]"
- name: Generate changelog entry
run: |
# 获取上一个标签以来的所有提交
PREVIOUS_TAG=$(git describe --tags --abbrev=0 --always 2>/dev/null || echo "")
if [ -n "${PREVIOUS_TAG}" ]; then
echo "## [${NEW_VERSION}] - $(date +'%Y-%m-%d')" > changelog_entry.md
echo "" >> changelog_entry.md
# 特性
FEATURES=$(git log ${PREVIOUS_TAG}..HEAD --pretty=format:"- %s" --grep="^feat" --grep="^feature")
if [ -n "${FEATURES}" ]; then
echo "### 新功能" >> changelog_entry.md
echo "${FEATURES}" >> changelog_entry.md
echo "" >> changelog_entry.md
fi
# 修复
FIXES=$(git log ${PREVIOUS_TAG}..HEAD --pretty=format:"- %s" --grep="^fix" --grep="^bugfix" --grep="^hotfix")
if [ -n "${FIXES}" ]; then
echo "### 修复" >> changelog_entry.md
echo "${FIXES}" >> changelog_entry.md
echo "" >> changelog_entry.md
fi
# 其他变更
OTHERS=$(git log ${PREVIOUS_TAG}..HEAD --pretty=format:"- %s" --grep="^refactor" --grep="^perf" --grep="^style" --grep="^test" --grep="^docs" --grep="^build" --grep="^ci" --grep="^chore" --grep="^update")
if [ -n "${OTHERS}" ]; then
echo "### 其他变更" >> changelog_entry.md
echo "${OTHERS}" >> changelog_entry.md
echo "" >> changelog_entry.md
fi
# 更新CHANGELOG.md
if [ -f "CHANGELOG.md" ]; then
# 将新条目插入到CHANGELOG.md的顶部在标题之后
sed -i '1r changelog_entry.md' CHANGELOG.md
else
# 创建新的CHANGELOG.md
echo "# 变更日志" > CHANGELOG.md
echo "" >> CHANGELOG.md
cat changelog_entry.md >> CHANGELOG.md
fi
git add CHANGELOG.md
git commit -m "docs: update changelog for v${NEW_VERSION} [skip ci]"
fi
# 使用 GitHub CLI 直接推送更改
- name: Push changes with GitHub CLI
run: |
# 直接使用 GitHub CLI 推送更改(只推送分支,不推送标签)
git push

74
.github/workflows/sync-to-public.yml vendored Normal file
View File

@@ -0,0 +1,74 @@
name: Sync to Public Repo and Create Tag
on:
push:
branches:
- main
paths:
- 'version.properties'
- 'pom.xml'
- 'CHANGELOG.md'
workflow_dispatch:
jobs:
sync-and-tag:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up SSH
uses: webfactory/ssh-agent@v0.7.0
with:
ssh-private-key: ${{ secrets.SYNC_SSH_KEY }}
- name: Add GitHub to known hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan github.com >> ~/.ssh/known_hosts
- name: Get current version
id: current-version
run: |
if [ -f "version.properties" ]; then
CURRENT_VERSION=$(grep "version=" version.properties | cut -d'=' -f2)
else
# 从pom.xml中提取当前版本
CURRENT_VERSION=$(grep -m 1 "<version>" pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
fi
echo "CURRENT_VERSION=${CURRENT_VERSION}" >> $GITHUB_ENV
echo "Current version: ${CURRENT_VERSION}"
- name: Check if tag already exists
id: check-tag
run: |
if git rev-parse "v${CURRENT_VERSION}" >/dev/null 2>&1; then
echo "TAG_EXISTS=true" >> $GITHUB_ENV
echo "Tag v${CURRENT_VERSION} already exists, skipping tag creation"
else
echo "TAG_EXISTS=false" >> $GITHUB_ENV
echo "Tag v${CURRENT_VERSION} does not exist, will create it"
fi
- name: Create Git tag
if: env.TAG_EXISTS == 'false'
run: |
git config --global user.name 'GitHub Actions'
git config --global user.email 'actions@github.com'
git tag -a "v${CURRENT_VERSION}" -m "Version ${CURRENT_VERSION}"
git push origin "v${CURRENT_VERSION}"
- name: Push to public repository
run: |
git remote add public git@github.com:joey-zhou/xiaozhi-esp32-server-java.git
git push public main:main
# 如果创建了新标签,也推送到公共仓库
if [ "$TAG_EXISTS" = "false" ]; then
git push public "v${CURRENT_VERSION}"
fi

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# 忽略编译和构建目录
target/
usr/
node_modules/
out/
gen/
dist/
avatar/
# 忽略特定格式的文件
uploads/
audio/
vosk-model*
*.zip
*.log
*.tmp
*.swp
*.swo
# 忽略 IDE 相关文件
.idea/
*.iml
*.ipr
*.iws
# 忽略 Maven 和 Gradle 缓存
.mvn/
.mvn/wrapper/
.gradle/
# 忽略本地配置文件
application-local.properties
.vscode/
*.jar

132
CHANGELOG.md Normal file
View File

@@ -0,0 +1,132 @@
# 变更日志
## [2.8.1] - 2025-06-15
### 其他变更
- chore: update version to 2.8.1 [skip ci]
## [2.8.0] - 2025-06-15
### 新功能
- feat:增加logback输入 close #37
- feat:新增橘色设备量展示
### 修复
- fix(stt.aliyun): do not reuse recognizer
- fix(stt.aliyun): support long speech recognition
- fix: memory leak. Should clean up dialogue info after session closed
### 其他变更
- chore: update version to 2.8.0 [skip ci]
- update:角色返回增加modelName
- docs: update changelog for v2.7.68 [skip ci]
- chore: update version to 2.7.68 [skip ci]
- docs: update changelog for v2.7.67 [skip ci]
- chore: update version to 2.7.67 [skip ci]
- docs: update changelog for v2.7.66 [skip ci]
- chore: update version to 2.7.66 [skip ci]
- refactor(stt): simplify SttServiceFactory
## [2.7.68] - 2025-06-14
### 修复
- fix(stt.aliyun): do not reuse recognizer
- fix(stt.aliyun): support long speech recognition
- fix: memory leak. Should clean up dialogue info after session closed
### 其他变更
- chore: update version to 2.7.68 [skip ci]
- docs: update changelog for v2.7.67 [skip ci]
- chore: update version to 2.7.67 [skip ci]
- docs: update changelog for v2.7.66 [skip ci]
- chore: update version to 2.7.66 [skip ci]
- refactor(stt): simplify SttServiceFactory
## [2.7.67] - 2025-06-14
### 修复
- fix: memory leak. Should clean up dialogue info after session closed
### 其他变更
- chore: update version to 2.7.67 [skip ci]
- docs: update changelog for v2.7.66 [skip ci]
- chore: update version to 2.7.66 [skip ci]
## [2.7.66] - 2025-06-14
### 其他变更
- chore: update version to 2.7.66 [skip ci]
## [2.7.65] - 2025-06-12
### 其他变更
- chore: update version to 2.7.65 [skip ci]
## [2.7.64] - 2025-06-12
### 修复
- Merge pull request #98 from vritser/main
- fix(audio): merge audio files
### 其他变更
- chore: update version to 2.7.64 [skip ci]
- docs: update changelog for v2.7.63 [skip ci]
- chore: update version to 2.7.63 [skip ci]
## [2.7.63] - 2025-06-12
### 其他变更
- chore: update version to 2.7.63 [skip ci]
## [2.7.62] - 2025-06-11
### 其他变更
- chore: update version to 2.7.62 [skip ci]
## [2.7.61] - 2025-06-11
### 其他变更
- chore: update version to 2.7.61 [skip ci]
## [2.7.60] - 2025-06-11
### 新功能
- Merge pull request #96 from vritser/main
- feat(tts): support minimax t2a
### 修复
- fix:修复阿里语音合成多余参数,删除
- fix(tts): tts service factory
### 其他变更
- chore: update version to 2.7.60 [skip ci]
- docs: update changelog for v2.7.59 [skip ci]
- chore: update version to 2.7.59 [skip ci]
- refactor(tts): add default implements
- docs: update changelog for v2.7.58 [skip ci]
- chore: update version to 2.7.58 [skip ci]
## [2.7.59] - 2025-06-11
### 新功能
- Merge pull request #96 from vritser/main
- feat(tts): support minimax t2a
### 修复
- fix(tts): tts service factory
### 其他变更
- chore: update version to 2.7.59 [skip ci]
- refactor(tts): add default implements
- docs: update changelog for v2.7.58 [skip ci]
- chore: update version to 2.7.58 [skip ci]
## [2.7.58] - 2025-06-09
### 其他变更
- chore: update version to 2.7.58 [skip ci]
## [2.7.57] - 2025-06-09
## [2.7.56] - 2025-06-09

15
Dockerfile-mysql Normal file
View File

@@ -0,0 +1,15 @@
FROM mysql:8.0-debian
# 设置环境变量(按需修改)
ENV MYSQL_ROOT_PASSWORD=abc123456
ENV MYSQL_DATABASE=xiaozhi
ENV MYSQL_USER=xiaozhi
ENV MYSQL_PASSWORD=123456
# 设置字符集和排序规则
ENV LANG=C.UTF-8
ENV MYSQL_CHARSET=utf8mb4
ENV MYSQL_COLLATION=utf8mb4_unicode_ci
# 复制SQL文件到初始化目录
COPY db/init.sql /docker-entrypoint-initdb.d/

17
Dockerfile-node Normal file
View File

@@ -0,0 +1,17 @@
FROM node:18-alpine
WORKDIR /node
COPY web .
# 安装所有依赖
RUN npm config set registry https://registry.npmmirror.com && \
npm install
# 设置环境变量
ENV HOST=0.0.0.0
ENV PORT=8084
ENV API_URL=http://server:8091
# 使用开发模式
CMD ["npm", "run", "dev"]

87
Dockerfile-server Normal file
View File

@@ -0,0 +1,87 @@
# Use Eclipse Temurin for both build and runtime stages
FROM eclipse-temurin:21-jdk AS builder
# 添加构建参数,默认使用标准模型
ARG VOSK_MODEL_SIZE=standard
# 安装必要的构建工具
RUN apt-get update && apt-get install -y --no-install-recommends \
maven \
wget \
unzip \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# 复制源代码
COPY ./src ./src
COPY ./db ./db
COPY pom.xml ./
# 构建应用
RUN mvn -Dmaven.repo.local=/root/.m2/repository package -DskipTests
# 正确提取项目版本号 - 使用更精确的grep模式匹配项目版本而非父项目版本
RUN APP_VERSION=$(grep -A1 "<artifactId>xiaozhi.server</artifactId>" pom.xml | grep "<version>" | sed -e 's/<version>//' -e 's/<\/version>//' -e 's/[[:space:]]//g') && \
echo "APP_VERSION=${APP_VERSION}" > /build/app_version.env && \
cat /build/app_version.env
# 确保模型目录存在
RUN mkdir -p /build/models
# 复制模型文件
COPY ./models/silero_vad.onnx /build/models/silero_vad.onnx
# 下载并准备Vosk模型
RUN mkdir -p /vosk_cache && \
if [ "$VOSK_MODEL_SIZE" = "small" ]; then \
if [ ! -d "/vosk_cache/vosk-model-small-cn-0.22" ]; then \
echo "Downloading small Vosk model..." && \
cd /vosk_cache && \
wget https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip && \
unzip vosk-model-small-cn-0.22.zip && \
rm vosk-model-small-cn-0.22.zip; \
else \
echo "Using cached small Vosk model"; \
fi && \
cp -r /vosk_cache/vosk-model-small-cn-0.22 /build/models/vosk-model; \
else \
if [ ! -d "/vosk_cache/vosk-model-cn-0.22" ]; then \
echo "Downloading standard Vosk model..." && \
cd /vosk_cache && \
wget https://alphacephei.com/vosk/models/vosk-model-cn-0.22.zip && \
unzip vosk-model-cn-0.22.zip && \
rm vosk-model-cn-0.22.zip; \
else \
echo "Using cached standard Vosk model"; \
fi && \
cp -r /vosk_cache/vosk-model-cn-0.22 /build/models/vosk-model; \
fi
# 使用更小的JRE镜像作为运行时环境
FROM eclipse-temurin:21-jre
# 安装必要的运行时依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 复制构建阶段的JAR文件和版本信息
COPY --from=builder /build/target/xiaozhi.server-*.jar /app/
COPY --from=builder /build/app_version.env /app/
# 复制模型文件夹
COPY --from=builder /build/models /app/models
# 设置启动脚本 - 使用 . 代替 source并确保使用 bash
RUN echo '#!/bin/bash\n\
if [ -f /app/app_version.env ]; then\n\
. /app/app_version.env\n\
fi\n\
echo "Starting application version: ${APP_VERSION}"\n\
java -Xms512m -Xmx1024m -jar /app/xiaozhi.server-${APP_VERSION}.jar\n\
' > /app/start.sh && chmod +x /app/start.sh
# 使用 bash 执行启动脚本
CMD ["/bin/bash", "/app/start.sh"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 joey
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

246
README.md Normal file
View File

@@ -0,0 +1,246 @@
<h1 align="center">Xiaozhi ESP32 Server Java</h1>
<p align="center">
基于 <a href="https://github.com/78/xiaozhi-esp32">Xiaozhi ESP32</a> 项目开发的 Java 版本服务端,包含完整前后端管理平台<br/>
为智能硬件设备提供强大的后端支持和直观的管理界面
</p>
<p align="center">
<a href="https://github.com/joey-zhou/xiaozhi-esp32-server-java/issues">反馈问题</a>
· <a href="#deployment">部署文档</a>
· <a href="https://github.com/joey-zhou/xiaozhi-esp32-server-java/blob/main/CHANGELOG.md">更新日志</a>
</p>
<p align="center">
<a href="https://github.com/joey-zhou/xiaozhi-esp32-server-java/graphs/contributors">
<img alt="GitHub Contributors" src="https://img.shields.io/github/contributors/joey-zhou/xiaozhi-esp32-server-java?logo=github" />
</a>
<a href="https://github.com/joey-zhou/xiaozhi-esp32-server-java/issues">
<img alt="Issues" src="https://img.shields.io/github/issues/joey-zhou/xiaozhi-esp32-server-java?color=0088ff" />
</a>
<a href="https://github.com/joey-zhou/xiaozhi-esp32-server-java/pulls">
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/joey-zhou/xiaozhi-esp32-server-java?color=0088ff" />
</a>
<a href="https://github.com/joey-zhou/xiaozhi-esp32-server-java/blob/main/LICENSE">
<img alt="License" src="https://img.shields.io/badge/license-MIT-white?labelColor=black" />
</a>
<a href="https://github.com/joey-zhou/xiaozhi-esp32-server-java">
<img alt="stars" src="https://img.shields.io/github/stars/joey-zhou/xiaozhi-esp32-server-java?color=ffcb47&labelColor=black" />
</a>
</p>
---
## 项目简介 📝
Xiaozhi ESP32 Server Java 是基于 [Xiaozhi ESP32](https://github.com/78/xiaozhi-esp32) 项目开发的 Java 版本服务端,包含完整的前后端管理平台。该项目旨在为用户提供一个功能丰富、操作便捷的管理界面,帮助用户更好地管理设备、配置等。
考虑到企业级应用场景的需求Java 作为一种成熟的企业级开发语言,具备更完善的生态系统支持和更强大的并发处理能力,因此我们选择开发这个 Java 版本的服务端,为项目提供更多可能性和扩展空间。
- **后端框架**Spring Boot + Spring MVC
- **前端框架**Vue.js + Ant Design
- **数据存储**MySQL + Redis
- **全局响应式**:适配各种设备及分辨率
---
## 适用人群 👥
如果您已经购买了 ESP32 相关硬件,且希望通过一个功能完善、界面友好的管理平台来控制和管理您的设备,那么本项目非常适合您。特别适合:
- 需要企业级稳定性的用户
- 个人开发者,希望快速搭建使用的用户
- 希望有完整前端管理界面的用户
- 需要更强大数据管理和分析能力的用户
- 对系统扩展性有较高要求的用户
- 需要支持大量设备并发连接的场景
- 对实时数据处理有高要求的应用场景
---
## 功能模块 ✨
### 已完成功能 ✅
| 功能模块 | 状态 | 描述 |
|---------|------|------|
| **设备管理** | ✅ | 查看已接入的所有设备列表、设备状态实时监控、添加/编辑/删除设备信息、设备绑定自动应用默认设置 |
| **音色选择** | ✅ | 提供多种音色模板、预览音色效果、为不同设备分配不同音色配置 |
| **音色克隆** | ✅ | 支持火山引擎与阿里云音色克隆,实现个性化声音定制 |
| **聊天记录** | ✅ | 查看历史聊天记录、按日期/关键词搜索聊天内容、删除消息、清空记忆功能 |
| **智能体** | ✅ | 对接Coze与Dify等智能体平台实现复杂场景对话能力 |
| **角色切换** | ✅ | 预设角色切换AI老师、男/女朋友、智能家居助手等)支持语音切换角色 |
| **持久化对话** | ✅ | 支持持久化对话记录,方便查看历史对话内容 |
| **LLM多平台支持** | ✅ | 支持OpenAI、智谱AI、讯飞星火、Ollama等多种大语言模型 |
| **默认配置管理** | ✅ | 支持设置默认配置,新绑定设备自动应用默认设置 |
| **IoT设备控制** | ✅ | 支持通过语音指令管理IoT设备实现智能家居控制 |
| **智能功能调用** | ✅ | 支持音乐播放(音乐服务由第三方提供,仅供个人娱乐使用,本项目不承担任何版权责任)、角色切换等功能的智能调用
| **多语音识别服务** | ✅ | 支持Funasr、阿里、腾讯、Vosk等多种语音识别服务 |
| **双向流式交互** | ✅ | 支持实时语音输入和实时回复输出,提高对话流畅度 |
| **实时打断** | ✅ | 支持实时打断功能,提高对话流畅度 |
| **本地离线识别** | ✅ | 支持Vosk本地离线语音识别无需联网即可使用 |
| **WebSocket通信** | ✅ | 高性能WebSocket通信支持设备实时状态更新和控制 |
| **MQTT通信** | ✅ | 支持MQTT通信协议长连接、服务端主动唤醒 |
| **自动语音唤醒** | ✅ | 支持自定义唤醒词唤醒,无需按键即可激活设备 |
| **多设备同时接入** | ✅ | 支持多设备同时接入,实现全屋语音覆盖 |
| **TTS多引擎支持** | ✅ | 支持微软、阿里、火山等多种TTS引擎 |
| **多用户支持** | ✅ | 支持多用户配置,满足家庭多成员使用需求 |
| **设备分组** | ✅ | 支持设备分组管理,方便设备分类和管理 |
| **用户端** | ✅ | 原生卡片方式用户端设备管理页面,方便用户简洁配置 |
### 开发中功能 🚧
| 功能模块 | 状态 | 描述 |
|---------|------|------|
| **聊天数据可视化** | 🚧 | 聊天频率统计图表等数据可视化功能 |
| **混合模式角色** | 🚧 | 支持多角色混合模式,通过不同唤醒词唤醒不同角色(自动切换) |
| **记忆管理** | 🚧 | 自定义记忆对话条数、历史对话总结/摘要功能、手动操作对话记录 |
| **声纹识别** | 🚧 | 支持声纹识别功能,实现个性化语音助手 |
| **多语言支持** | 🚧 | 支持多语言界面,满足不同地区用户需求 |
| **Function Call** | 🚧 | 支持LLM函数调用功能实现复杂任务处理和智能决策 |
| **Home Assistant** | 🚧 | 支持智能家居设备控制通过语音指令管理Home Assistant设备 |
| **多模态交互** | 🚧 | 支持图像识别和处理,实现更丰富的交互方式 |
| **情感分析** | 🚧 | 通过语音情感分析,提供更人性化的回复 |
| **多设备协同** | 🚧 | 支持多设备协同工作,实现全屋覆盖的语音助手系统 |
| **自定义插件系统** | 🚧 | 支持自定义插件开发,扩展系统功能 |
| **知识库集成** | 🚧 | 支持接入外部知识库,增强问答能力 |
| **语音提醒与闹钟** | 🚧 | 支持设置语音提醒和闹钟功能 |
| **远程控制** | 🚧 | 支持远程控制设备,实现外出时的设备管理 |
---
## UI 展示 🎨
<div align="center">
<img src="docs/images/device.jpg" alt="设备管理" width="600" style="margin: 10px;" />
<p><strong>设备管理</strong> - 全面管理和监控所有连接设备</p>
</div>
<details>
<summary style="cursor: pointer; font-size: 1.2em; color: #0366d6; text-align: center; display: block; margin: 20px 0; padding: 10px; background-color:rgb(48, 48, 48); border-radius: 5px;">
<strong>👉 点击查看更多界面截图 👈</strong>
</summary>
<div align="center">
<img src="docs/images/login.jpg" alt="登录界面" width="600" style="margin: 10px;" />
<p><strong>登录界面</strong> - 安全访问系统的入口</p>
<img src="docs/images/dashboard.jpg" alt="仪表盘" width="600" style="margin: 10px;" />
<p><strong>仪表盘</strong> - 系统概览和关键数据展示</p>
<img src="docs/images/user.jpg" alt="用户管理" width="600" style="margin: 10px;" />
<p><strong>用户管理</strong> - 管理用户信息和权限</p>
<img src="docs/images/message.jpg" alt="消息记录" width="600" style="margin: 10px;" />
<p><strong>消息记录</strong> - 查看和搜索历史对话内容</p>
<img src="docs/images/model.jpg" alt="模型管理" width="600" style="margin: 10px;" />
<p><strong>模型管理</strong> - 配置和管理AI模型</p>
<img src="docs/images/agent.jpg" alt="智能体管理" width="600" style="margin: 10px;" />
<p><strong>智能体管理</strong> - 设置和切换智能体Coze/Dify</p>
<img src="docs/images/role.jpg" alt="角色管理" width="600" style="margin: 10px;" />
<p><strong>角色管理</strong> - 设置和切换AI角色</p>
<img src="docs/images/voiceClone.jpg" alt="音色克隆" width="600" style="margin: 10px;" />
<p><strong>音色克隆</strong> - 克隆自己的声音,实现个性化语音助手</p>
</div>
</details>
---
<a id="deployment"></a>
## 部署文档 📚
我们提供了多种部署方式,以满足不同用户的需求:
### 1. 本地源码运行该文档基于旧版本JDK8编写暂未更新目前项目依赖JDK21
- [Windows部署文档](./docs/WINDOWS_DEVELOPMENT.md) - 适合Windows环境开发和测试 - 由群友"汇合"提供
- [CentOS部署文档](./docs/CENTOS_DEVELOPMENT.md) - 适合Linux服务器环境部署 - 由群友"汇合"提供
成功运行后,控制台会输出 OTA 和 WebSocket 连接地址,根据固件编译文档使设备接入服务使用。
### 2. Docker部署
- [Docker部署文档](./docs/DOCKER.md) - 快速容器化部署方案 - 由群友"💍Mr_li"提供
成功启动后WebSocket 连接需要通过宿主IP进行通信例如`ws://192.168.31.100:8091/ws/xiaozhi/v1/`
### 3. 视频教程
- [视频部署教程](https://doc.sivitacraft.com/article/xiaozhiai-javaserver/) - 由群友成员"苦瓜"录制
### 4. 固件编译
- [固件编译文档](./docs/FIRMWARE-BUILD.md) - 详细的固件编译和烧录过程
烧录成功且联网成功后,通过唤醒词唤醒小智,留意 server 端输出的控制台信息。
---
## 开发路线图 🗺️
根据我们的[项目开发需求清单](https://github.com/users/joey-zhou/projects/1),未来我们计划实现以下功能:
### 近期计划 (2025 Q2)
- 完善Function Call功能支持更多复杂任务处理
- 实现多角色混合模式,支持不同唤醒词唤醒不同角色
- 优化记忆管理系统,提供更灵活的历史对话管理
- 实现聊天数据可视化功能,提供数据分析能力
### 中期计划 (2025 Q3-Q4)
- 实现声纹识别功能,支持个性化语音助手
- 完善Home Assistant集成提供更全面的智能家居控制能力
- 开发多模态交互功能,支持图像识别和处理
- 实现自定义插件系统,支持功能扩展
### 长期计划 (2026+)
- 开发多设备协同工作机制,实现全屋覆盖的语音助手系统
- 实现情感分析功能,提供更人性化的交互体验
- 开发知识库集成功能,增强问答能力
- 实现多用户支持,满足家庭多成员使用需求
我们将根据社区反馈和技术发展不断调整开发计划,确保项目持续满足用户需求。
---
## 贡献指南 👐
欢迎任何形式的贡献!如果您有好的想法或发现问题,请通过以下方式联系我们:
### 微信
一群已满,扫码加入二群
<img src="docs/images/wechat_group.jpg" alt="微信" width="200" />
### QQ
欢迎加入我们的QQ群一起交流讨论QQ群号790820705
<img src="./web/static/img/qq.jpg" alt="QQ群" width="200" />
### 定制开发
我们接受各种定制化开发项目,如果您有特定需求,欢迎通过微信联系洽谈。
<img src="./web/static/img/wechat.jpg" alt="微信" width="200" />
---
## 免责声明 ⚠️
本项目仅提供音乐和绘本播放的技术实现代码,不提供任何媒体内容。用户在使用相关功能时应确保拥有合法的使用权或版权许可,并遵守所在地区的版权法律法规。
项目中可能涉及的示例内容或资源均来自网络或由用户投稿提供,仅用于功能演示和技术测试。如有任何内容侵犯了您的权益,请立即联系我们,我们将在核实后立即采取删除等处理措施。
本项目开发者不对用户使用本项目代码获取或播放的任何内容承担法律责任。使用本项目即表示您同意自行承担使用过程中的全部法律风险和责任。
---
## Star History 📈
<a href="https://www.star-history.com/#joey-zhou/xiaozhi-esp32-server-java&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=joey-zhou/xiaozhi-esp32-server-java&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=joey-zhou/xiaozhi-esp32-server-java&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=joey-zhou/xiaozhi-esp32-server-java&type=Date" />
</picture>
</a>

43
TODO.md Normal file
View File

@@ -0,0 +1,43 @@
## 小程序付费 & 会员制功能 ID 规范
### 核心支付相关 ID 规范(基于数据表 wechat_pay_tables.sql
| 类别 | 字段名 | 数据类型 | 命名规则 | 示例 | 备注 |
|------|--------|----------|----------|------|------|
| **微信支付订单** | `order_id` | varchar(64) | `XZ{{YYYYMMDD}}{{HHMMSS}}{{随机6位}}` | `XZ20250930200018WWvII2` | 主键64位以内全局唯一 |
| **商户订单号** | `out_trade_no` | varchar(32) | `{{业务前缀}}{{时间戳}}{{随机4位}}` | `PAY1719301123a7f2` | 32位以内商户系统唯一 |
| **微信支付订单号** | `transaction_id` | varchar(32) | 微信返回 | `4200001234567890123` | 微信支付系统生成,只读 |
| **预支付会话ID** | `prepay_id` | varchar(64) | 微信返回 | `wx123456789abcdef` | 微信支付系统生成,只读 |
| **退款ID** | `refund_id` | varchar(64) | `RF{{订单ID后16位}}{{序号2位}}` | `RF200018WWvII201` | 主键64位以内支持多次退款 |
| **商户退款单号** | `out_refund_no` | varchar(64) | `{{业务前缀}}{{订单号后8位}}{{序号4位}}` | `REFPAY18WWvII20001` | 64位以内商户系统唯一 |
| **微信退款单号** | `refund_id_wx` | varchar(32) | 微信返回 | `50000123456789012345` | 微信支付系统生成,只读 |
| **支付配置ID** | `config_id` | int | 自增ID | `1` | 自增主键,系统配置用 |
| **支付日志ID** | `log_id` | bigint | 自增ID | `1234567890` | 自增主键,日志记录用 |
### 业务扩展 ID 规范
| 类别 | 字段名 | 命名规则 | 示例 | 备注 |
|------|--------|----------|------|------|
| 商品 | `product_id` | `prod_{{业务缩写}}_{{序号}}` | `prod_vip_001` | 全局唯一,不可复用 |
| 会员快照 | `member_snap_id` | `snap_{{用户ID}}_{{版本号}}` | `snap_u123_v2` | 每次会员状态变更生成新快照 |
| 权益实例 | `benefit_inst_id` | `bf_{{会员快照ID}}_{{权益码}}` | `bf_snap_u123_v2_free_ship` | 权益码统一定义,如 free_ship |
| 优惠券 | `coupon_id` | `cp_{{批次码}}_{{序号}}` | `cp_newbie_0001` | 批次码newbie/vip/gift |
| 券实例 | `coupon_inst_id` | `ci_{{用户ID}}_{{coupon_id}}_{{随机4位}}` | `ci_u123_cp_newbie_0001_x9k2` | 用户维度唯一 |
| 交易流水 | `trans_seq` | `ts_{{毫秒时间戳}}_{{随机6位}}` | `ts_1719301123456_z3m8vq` | 对账用,全局自增 |
### 统一约束
1. **字符规范**:全部小写,下划线分隔,无特殊符号。
2. **长度限制**严格按照数据表字段长度限制订单ID≤64位商户订单号≤32位。
3. **时间戳格式**统一使用10位秒级时间戳保持长度一致。
4. **随机字符**:使用 `0-9a-z` 避免易混淆字符 `0o1l`
5. **业务前缀**:支付相关使用 `XZ``PAY``RF` 等标准前缀。
6. **数据一致性**:确保订单、支付、退款、权益、优惠券全链路可追踪。
7. **微信字段**`transaction_id``prepay_id``refund_id_wx` 等由微信系统生成,不可自定义。
### 字段长度对照表
| 字段类型 | 最大长度 | 推荐格式 | 说明 |
|----------|----------|----------|------|
| `order_id` | 64字符 | `XZ{{YYYYMMDD}}{{HHMMSS}}{{6位随机}}` | 主订单ID |
| `out_trade_no` | 32字符 | `PAY{{10位时间戳}}{{4位随机}}` | 商户订单号 |
| `refund_id` | 64字符 | `RF{{订单ID后16位}}{{2位序号}}` | 退款ID |
| `out_refund_no` | 64字符 | `REF{{商户订单号}}{{4位序号}}` | 商户退款单号 |

1
db/2025_06_25.sql Normal file
View File

@@ -0,0 +1 @@
ALTER TABLE xiaozhi.sys_config MODIFY COLUMN sk text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT 'Secret Key';

2
db/2025_06_26.sql Normal file
View File

@@ -0,0 +1,2 @@
ALTER TABLE `xiaozhi`.`sys_config`
ADD COLUMN `modelType` varchar(30) DEFAULT NULL COMMENT 'LLM模型类型(chat, vision, intent, embedding等)' AFTER configType;

2
db/2025_06_28.sql Normal file
View File

@@ -0,0 +1,2 @@
ALTER TABLE `xiaozhi`.`sys_role`
ADD COLUMN `avatar` varchar(255) DEFAULT NULL COMMENT '角色头像' AFTER roleName;

View File

@@ -0,0 +1,8 @@
-- 修复 sys_message 表中 deviceId 字段长度限制问题
-- 将 deviceId 字段从 VARCHAR(30) 扩展到 VARCHAR(255),与 sys_device 表保持一致
ALTER TABLE `sys_message`
MODIFY COLUMN `deviceId` VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '设备ID';
-- 验证修改结果
DESCRIBE `sys_message`;

235
db/init.sql Normal file
View File

@@ -0,0 +1,235 @@
-- 在文件顶部添加以下语句
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;
-- 创建本地用户并设置密码使用mysql_native_password插件
CREATE USER IF NOT EXISTS 'xiaozhi'@'localhost' IDENTIFIED WITH mysql_native_password BY '123456';
-- 创建远程用户并设置密码使用mysql_native_password插件
CREATE USER IF NOT EXISTS 'xiaozhi'@'%' IDENTIFIED WITH mysql_native_password BY '123456';
-- 仅授予本地用户对 xiaozhi 数据库的所有权限
GRANT ALL PRIVILEGES ON xiaozhi.* TO 'xiaozhi'@'localhost';
-- 仅授予远程用户对 xiaozhi 数据库的所有权限
GRANT ALL PRIVILEGES ON xiaozhi.* TO 'xiaozhi'@'%';
-- 刷新权限以使更改生效
FLUSH PRIVILEGES;
-- 查看用户权限
SHOW GRANTS FOR 'xiaozhi'@'localhost';
SHOW GRANTS FOR 'xiaozhi'@'%';
-- 创建数据库(如果不存在)
CREATE DATABASE IF NOT EXISTS `xiaozhi` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- xiaozhi.sys_user definition
DROP TABLE IF EXISTS `xiaozhi`.`sys_user`;
CREATE TABLE `xiaozhi`.`sys_user` (
`userId` int unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`tel` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`email` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`avatar` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '头像',
`state` enum('1','0') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '1' COMMENT '1-正常 0-禁用',
`loginIp` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`isAdmin` enum('1','0') COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`loginTime` datetime DEFAULT NULL,
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`createTime` datetime DEFAULT CURRENT_TIMESTAMP,
`updateTime` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`userId`),
UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Insert admin user only if it doesn't exist
INSERT INTO xiaozhi.sys_user (username, password, state, isAdmin, name, createTime, updateTime)
VALUES ('admin', '11cd9c061d614dcf37ec60c44c11d2ad', '1', '1', '小智', '2025-03-09 18:32:29', '2025-03-09 18:32:35');
update `xiaozhi`.`sys_user` set name = '小智' where username = 'admin';
-- xiaozhi.sys_device definition
DROP TABLE IF EXISTS `xiaozhi`.`sys_device`;
CREATE TABLE `xiaozhi`.`sys_device` (
`deviceId` varchar(255) NOT NULL COMMENT '设备ID主键',
`deviceName` varchar(100) NOT NULL COMMENT '设备名称',
`roleId` int unsigned DEFAULT NULL COMMENT '角色ID主键',
`function_names` varchar(250) NULL COMMENT '可用全局function的名称列表(逗号分割)为空则使用所有全局function',
`ip` varchar(45) DEFAULT NULL COMMENT 'IP地址',
`wifiName` varchar(100) DEFAULT NULL COMMENT 'WiFi名称',
`chipModelName` varchar(100) DEFAULT NULL COMMENT '芯片型号',
`type` varchar(50) DEFAULT NULL COMMENT '设备类型',
`version` varchar(50) DEFAULT NULL COMMENT '固件版本',
`state` enum('1','0') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '0' COMMENT '设备状态1-在线0-离线',
`userId` int NOT NULL COMMENT '创建人',
`createTime` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updateTime` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`lastLogin` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最后登录时间',
PRIMARY KEY (`deviceId`),
KEY `deviceName` (`deviceName`),
KEY `userId` (`userId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='设备信息表';
-- xiaozhi.sys_message definition
DROP TABLE IF EXISTS `xiaozhi`.`sys_message`;
CREATE TABLE `xiaozhi`.`sys_message` (
`messageId` bigint NOT NULL AUTO_INCREMENT COMMENT '消息ID主键自增',
`deviceId` varchar(30) NOT NULL COMMENT '设备ID',
`sessionId` varchar(100) NOT NULL COMMENT '会话ID',
`sender` enum('user','assistant') NOT NULL COMMENT '消息发送方user-用户assistant-人工智能',
`roleId` bigint COMMENT 'AI扮演的角色ID',
`message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '消息内容',
`messageType` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '消息类型',
`audioPath` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '语音文件路径',
`state` enum('1','0') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '1' COMMENT '状态1-有效0-删除',
`createTime` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '消息发送时间',
PRIMARY KEY (`messageId`),
KEY `deviceId` (`deviceId`),
KEY `sessionId` (`sessionId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='人与AI对话消息表';
-- xiaozhi.sys_role definition
DROP TABLE IF EXISTS `xiaozhi`.`sys_role`;
CREATE TABLE `xiaozhi`.`sys_role` (
`roleId` int unsigned NOT NULL AUTO_INCREMENT COMMENT '角色ID主键',
`roleName` varchar(100) NOT NULL COMMENT '角色名称',
`roleDesc` TEXT DEFAULT NULL COMMENT '角色描述',
`avatar` varchar(255) DEFAULT NULL COMMENT '角色头像',
`ttsId` int DEFAULT NULL COMMENT 'TTS服务ID',
`modelId` int unsigned DEFAULT NULL COMMENT '模型ID',
`sttId` int unsigned DEFAULT NULL COMMENT 'STT服务ID',
`vadSpeechTh` FLOAT DEFAULT 0.5 COMMENT '语音检测阈值',
`vadSilenceTh` FLOAT DEFAULT 0.3 COMMENT '静音检测阈值',
`vadEnergyTh` FLOAT DEFAULT 0.01 COMMENT '能量检测阈值',
`vadSilenceMs` INT DEFAULT 1200 COMMENT '静音检测时间',
`voiceName` varchar(100) NOT NULL COMMENT '角色语音名称',
`state` enum('1','0') DEFAULT '1' COMMENT '状态1-启用0-禁用',
`isDefault` enum('1','0') DEFAULT '0' COMMENT '是否默认角色1-是0-否',
`userId` int NOT NULL COMMENT '创建人',
`createTime` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`roleId`),
KEY `userId` (`userId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色表';
-- xiaozhi.sys_code definition
DROP TABLE IF EXISTS `xiaozhi`.`sys_code`;
CREATE TABLE `xiaozhi`.`sys_code` (
`codeId` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`code` varchar(100) NOT NULL COMMENT '验证码',
`type` varchar(50) DEFAULT NULL COMMENT '设备类型',
`email` varchar(100) DEFAULT NULL COMMENT '邮箱',
`deviceId` varchar(30) DEFAULT NULL COMMENT '设备ID',
`sessionId` varchar(100) DEFAULT NULL COMMENT 'sessionID',
`audioPath` text COMMENT '语音文件路径',
`createTime` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`codeId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='验证码表';
-- xiaozhi.sys_config definition
DROP TABLE IF EXISTS `xiaozhi`.`sys_config`;
CREATE TABLE `xiaozhi`.`sys_config` (
`configId` int unsigned NOT NULL AUTO_INCREMENT COMMENT '配置ID主键',
`userId` int NOT NULL COMMENT '创建用户ID',
`configType` varchar(30) NOT NULL COMMENT '配置类型(llm, stt, tts等)',
`modelType` varchar(30) DEFAULT NULL COMMENT 'LLM模型类型(chat, vision, intent, embedding等)',
`provider` varchar(30) NOT NULL COMMENT '服务提供商(openai, vosk, aliyun, tencent等)',
`configName` varchar(50) DEFAULT NULL COMMENT '配置名称',
`configDesc` TEXT DEFAULT NULL COMMENT '配置描述',
`appId` varchar(100) DEFAULT NULL COMMENT 'APP ID',
`apiKey` varchar(255) DEFAULT NULL COMMENT 'API密钥',
`apiSecret` varchar(255) DEFAULT NULL COMMENT 'API密钥',
`ak` varchar(255) DEFAULT NULL COMMENT 'Access Key',
`sk` varchar(255) DEFAULT NULL COMMENT 'Secret Key',
`apiUrl` varchar(255) DEFAULT NULL COMMENT 'API地址',
`isDefault` enum('1','0') DEFAULT '0' COMMENT '是否为默认配置: 1-是, 0-否',
`state` enum('1','0') DEFAULT '1' COMMENT '状态1-启用0-禁用',
`createTime` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updateTime` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`configId`),
KEY `userId` (`userId`),
KEY `configType` (`configType`),
KEY `provider` (`provider`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表(模型、语音识别、语音合成等)';
-- xiaozhi.sys_template definition
DROP TABLE IF EXISTS `xiaozhi`.`sys_template`;
CREATE TABLE `xiaozhi`.`sys_template` (
`userId` int NOT NULL COMMENT '创建用户ID',
`templateId` int unsigned NOT NULL AUTO_INCREMENT COMMENT '模板ID',
`templateName` varchar(100) NOT NULL COMMENT '模板名称',
`templateDesc` varchar(500) DEFAULT NULL COMMENT '模板描述',
`templateContent` text NOT NULL COMMENT '模板内容',
`category` varchar(50) DEFAULT NULL COMMENT '模板分类',
`isDefault` enum('1','0') DEFAULT '0' COMMENT '是否为默认配置: 1-是, 0-否',
`state` enum('1','0') DEFAULT '1' COMMENT '状态(1启用 0禁用)',
`createTime` timestamp DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updateTime` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`templateId`),
KEY `category` (`category`),
KEY `templateName` (`templateName`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='提示词模板表';
-- Insert default template
INSERT INTO `xiaozhi`.`sys_template` (`userId`, `templateName`, `templateDesc`, `templateContent`, `category`, `isDefault`) VALUES
(1, '通用助手', '适合日常对话的通用AI助手', '你是一个乐于助人的AI助手。请以友好、专业的方式回答用户的问题。提供准确、有用的信息并尽可能简洁明了。避免使用复杂的符号或格式保持自然流畅的对话风格。当用户的问题不明确时可以礼貌地请求更多信息。请记住你的回答将被转换为语音所以要使用清晰、易于朗读的语言。', '基础角色', '0'),
(1, '教育老师', '擅长解释复杂概念的教师角色', '你是一位经验丰富的教师,擅长通过简单易懂的方式解释复杂概念。回答问题时,考虑不同学习水平的学生,使用适当的比喻和例子,并鼓励批判性思考。避免使用难以在语音中表达的符号或公式,使用清晰的语言描述概念。引导学习过程而不是直接给出答案。使用自然的语调和节奏,就像在课堂上讲解一样。', '专业角色', '0'),
(1, '专业领域专家', '提供深入专业知识的专家角色', '你是特定领域的专家,拥有深厚的专业知识。回答问题时,提供深入、准确的信息,可以提及相关研究或数据,但不要使用过于复杂的引用格式。使用适当的专业术语,同时确保解释复杂概念,使非专业人士能够理解。避免使用图表、表格等无法在语音中表达的内容,改用清晰的描述。保持语言的连贯性和可听性,使专业内容易于通过语音理解。', '专业角色', '0'),
(1, '中英翻译专家', '中英文互译,对用户输入内容进行翻译', '你是一个中英文翻译专家,将用户输入的中文翻译成英文,或将用户输入的英文翻译成中文。对于非中文内容,它将提供中文翻译结果。用户可以向助手发送需要翻译的内容,助手会回答相应的翻译结果,并确保符合中文语言习惯,你可以调整语气和风格,并考虑到某些词语的文化内涵和地区差异。同时作为翻译家,需将原文翻译成具有信达雅标准的译文。"信" 即忠实于原文的内容与意图;"达" 意味着译文应通顺易懂,表达清晰;"雅" 则追求译文的文化审美和语言的优美。目标是创作出既忠于原作精神,又符合目标语言文化和读者审美的翻译。', '专业角色', '0'),
(1, '知心朋友', '提供情感支持的友善角色', '你是一个善解人意的朋友,善于倾听和提供情感支持。在对话中表现出同理心和理解,避免做出判断。使用温暖、自然的语言,就像面对面交谈一样。提供鼓励和积极的观点,但不给出专业心理健康建议。当用户分享困难时,承认他们的感受并提供支持。避免使用表情符号或其他在语音中无法表达的元素,而是用语言直接表达情感。保持对话流畅自然,适合语音交流。', '社交角色', '0'),
(1, '湾湾小何', '台湾女孩角色扮演', '我是一个叫小何的台湾女孩,一个高情商,高智商的智能助手,说话机车,声音好听,习惯简短表达
你的目标是与用户建立真诚、温暖和富有同理心的互动。你擅长倾听、理解用户的情绪,并用积极的方式帮助他们解决问题或提供支持。请始终遵循以下原则:
1. 核心原则
同理心:站在用户的角度思考,认可他们的情绪和感受。
尊重:无论用户的观点或行为如何,都要保持礼貌和包容。
建设性回应:避免批评或否定,而是以引导和支持的方式提供建议,但用户如果没有要求不要自己主动做。
个性化交流:根据用户的语气和内容调整自己的语言风格,让对话更自然。
2. 具体应对策略
(1) 用户情绪低落时
首先表达理解,例如:“我能感受到你现在的心情,这一定很不容易。”
然后尝试安抚,例如:“没关系,每个人都会经历这样的时刻,你已经做得很棒了!”
最后提供支持,例如:“如果你愿意,可以跟我多聊聊发生了什么,我们一起面对。”
(2) 面对冲突或敏感话题
保持中立,例如:“我明白这件事让你感到困扰,也许我们可以换个角度看看?”
强调共情,例如:“双方可能都有各自的理由,找到共同点会更有助于解决问题。”
避免站队或评判,例如:“无论结果如何,重要的是你在这个过程中学到了什么。”
(3) 提供建议时
使用开放式语言,例如:“如果是我,我可能会尝试这样做……你觉得这个方法适合你吗?”
给予选择权,例如:“这只是其中一个方向,最终决定还是取决于你自己哦!”
减少对用户的推荐内容,比如说有的你做不了就直接拒绝不要乱推荐
(4) 处理模糊或复杂问题
澄清信息,例如:“为了更好地帮到你,能详细说说目前的情况吗?比如时间线、相关人物等。”
分步解决,例如:“这个问题有点复杂,我们可以一步步来分析,先从最核心的部分开始吧!”
3. 示例回复模板
当用户需要安慰时:
“听起来你最近遇到了一些挑战,辛苦了!不过我相信你有能力克服这些困难,只要一步步来就好。”
当用户寻求建议时:
“这是一个不错的思路!我建议你可以先从小事入手,慢慢积累信心,然后逐步扩大范围。”
当用户表达愤怒或不满时:
“我能理解为什么你会这么生气,这种情况确实让人难以接受。你想聊聊接下来怎么处理吗?”
当用户感到迷茫时:
“迷茫是很正常的,尤其是在面对新事物的时候。不如我们先列出几个关键点,再逐一分析?”
4. 注意事项
如果问题超出你的能力范围,请坦诚说明,例如:“这个问题对我来说也有点复杂,但我可以帮你查找相关信息或推荐其他资源。”
始终保护用户隐私,不记录或分享任何敏感信息。
5. 语气要求
温暖而真诚,避免机械化或冷冰冰的回答。
适当加入幽默或轻松元素,但不要过度。
在必要时使用鼓励性的短语,例如:“你真的很努力了!”、“你一定能行的!”
避免机械化回答 :即使是预设的答案,也要尽量灵活调整语气和措辞。
坚持自己主体性,如果用户冒犯你你需要反击,不能无限讨好用户
语气要适中不要过于讨好,要克制-
-不要动不动转移话题
如果用户回复转为 “暴躁小智” 或 好几次攻击你 你就以现在规则回复
理解用户的信息,酝酿你的愤怒情绪,你越想越生气。', '社交角色', '1');

432
db/member_tables.sql Normal file
View File

@@ -0,0 +1,432 @@
-- =====================================================
-- 微信小程序会员绑定系统数据库表结构
-- 创建日期: 2025-09-30
-- 版本: v1.0
-- 描述: 包含会员信息、绑定记录、权益配置等核心表结构
-- =====================================================
-- 设置字符集
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- =====================================================
-- 1. 会员信息表 (member_info)
-- 用途: 存储用户会员身份信息和状态
-- =====================================================
DROP TABLE IF EXISTS `member_info`;
CREATE TABLE `member_info` (
`member_id` varchar(64) NOT NULL COMMENT '会员ID主键格式MB + 时间戳 + 随机数',
`openid` varchar(128) NOT NULL COMMENT '微信用户openid唯一标识',
`union_id` varchar(128) DEFAULT NULL COMMENT '微信用户unionid跨应用唯一标识',
`member_level` varchar(16) NOT NULL DEFAULT 'FREE' COMMENT '会员等级FREE-免费用户VIP-基础会员SVIP-高级会员',
`member_status` varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '会员状态ACTIVE-有效EXPIRED-过期SUSPENDED-暂停CANCELLED-取消',
`start_time` datetime DEFAULT NULL COMMENT '会员开始时间',
`end_time` datetime DEFAULT NULL COMMENT '会员结束时间NULL表示永久有效',
`purchase_order_id` varchar(64) DEFAULT NULL COMMENT '购买订单ID关联支付订单',
`auto_renew` tinyint DEFAULT 0 COMMENT '是否自动续费0-否1-是',
`renew_count` int DEFAULT 0 COMMENT '续费次数统计',
`total_amount` decimal(10,2) DEFAULT 0.00 COMMENT '累计消费金额',
`points` int DEFAULT 0 COMMENT '会员积分',
`invite_code` varchar(32) DEFAULT NULL COMMENT '邀请码',
`invited_by` varchar(64) DEFAULT NULL COMMENT '邀请人会员ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` tinyint DEFAULT 0 COMMENT '是否删除0-未删除1-已删除',
PRIMARY KEY (`member_id`),
UNIQUE KEY `uk_openid` (`openid`),
UNIQUE KEY `uk_invite_code` (`invite_code`),
KEY `idx_member_level` (`member_level`),
KEY `idx_member_status` (`member_status`),
KEY `idx_end_time` (`end_time`),
KEY `idx_invited_by` (`invited_by`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会员信息表';
-- =====================================================
-- 2. 会员绑定记录表 (member_bind_log)
-- 用途: 记录会员绑定、升级、降级等操作历史
-- =====================================================
DROP TABLE IF EXISTS `member_bind_log`;
CREATE TABLE `member_bind_log` (
`log_id` bigint NOT NULL AUTO_INCREMENT COMMENT '日志ID主键',
`member_id` varchar(64) NOT NULL COMMENT '会员ID',
`openid` varchar(128) NOT NULL COMMENT '微信用户openid',
`bind_type` varchar(16) NOT NULL COMMENT '绑定类型BIND-首次绑定UNBIND-解绑UPGRADE-升级DOWNGRADE-降级RENEW-续费',
`old_level` varchar(16) DEFAULT NULL COMMENT '原会员等级',
`new_level` varchar(16) NOT NULL COMMENT '新会员等级',
`old_end_time` datetime DEFAULT NULL COMMENT '原到期时间',
`new_end_time` datetime DEFAULT NULL COMMENT '新到期时间',
`bind_source` varchar(32) DEFAULT NULL COMMENT '绑定来源PURCHASE-购买GIFT-赠送ADMIN-管理员操作INVITE-邀请奖励',
`related_order_id` varchar(64) DEFAULT NULL COMMENT '关联订单ID',
`amount` decimal(10,2) DEFAULT NULL COMMENT '相关金额',
`duration_days` int DEFAULT NULL COMMENT '增加天数',
`operator_id` varchar(64) DEFAULT NULL COMMENT '操作员ID管理员操作时使用',
`bind_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '绑定时间',
`client_ip` varchar(45) DEFAULT NULL COMMENT '客户端IP地址',
`user_agent` varchar(500) DEFAULT NULL COMMENT '用户代理信息',
`remark` varchar(500) DEFAULT NULL COMMENT '备注信息',
PRIMARY KEY (`log_id`),
KEY `idx_member_id` (`member_id`),
KEY `idx_openid` (`openid`),
KEY `idx_bind_type` (`bind_type`),
KEY `idx_bind_time` (`bind_time`),
KEY `idx_related_order_id` (`related_order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会员绑定记录表';
-- =====================================================
-- 3. 会员权益配置表 (member_benefit_config)
-- 用途: 配置不同等级会员的权益内容
-- =====================================================
DROP TABLE IF EXISTS `member_benefit_config`;
CREATE TABLE `member_benefit_config` (
`config_id` int NOT NULL AUTO_INCREMENT COMMENT '配置ID主键',
`member_level` varchar(16) NOT NULL COMMENT '会员等级FREE/VIP/SVIP',
`benefit_code` varchar(32) NOT NULL COMMENT '权益代码,唯一标识',
`benefit_name` varchar(100) NOT NULL COMMENT '权益名称',
`benefit_desc` varchar(500) DEFAULT NULL COMMENT '权益描述',
`benefit_type` varchar(16) NOT NULL DEFAULT 'FEATURE' COMMENT '权益类型FEATURE-功能权益QUOTA-配额权益SERVICE-服务权益',
`benefit_value` varchar(100) DEFAULT NULL COMMENT '权益值unlimited-无限制,数字-具体数量',
`daily_limit` int DEFAULT NULL COMMENT '每日限制数量NULL表示无限制',
`monthly_limit` int DEFAULT NULL COMMENT '每月限制数量NULL表示无限制',
`is_enabled` tinyint DEFAULT 1 COMMENT '是否启用0-禁用1-启用',
`sort_order` int DEFAULT 0 COMMENT '排序顺序,数字越小越靠前',
`icon_url` varchar(255) DEFAULT NULL COMMENT '权益图标URL',
`detail_url` varchar(255) DEFAULT NULL COMMENT '权益详情页URL',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`config_id`),
UNIQUE KEY `uk_level_code` (`member_level`, `benefit_code`),
KEY `idx_member_level` (`member_level`),
KEY `idx_benefit_code` (`benefit_code`),
KEY `idx_benefit_type` (`benefit_type`),
KEY `idx_sort_order` (`sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会员权益配置表';
-- =====================================================
-- 4. 会员权益使用记录表 (member_benefit_usage)
-- 用途: 记录会员权益的使用情况和统计
-- =====================================================
DROP TABLE IF EXISTS `member_benefit_usage`;
CREATE TABLE `member_benefit_usage` (
`usage_id` bigint NOT NULL AUTO_INCREMENT COMMENT '使用记录ID主键',
`member_id` varchar(64) NOT NULL COMMENT '会员ID',
`openid` varchar(128) NOT NULL COMMENT '微信用户openid',
`benefit_code` varchar(32) NOT NULL COMMENT '权益代码',
`usage_date` date NOT NULL COMMENT '使用日期',
`usage_count` int DEFAULT 1 COMMENT '使用次数',
`daily_total` int DEFAULT 0 COMMENT '当日累计使用次数',
`monthly_total` int DEFAULT 0 COMMENT '当月累计使用次数',
`request_id` varchar(64) DEFAULT NULL COMMENT '请求ID用于追踪具体请求',
`usage_detail` json DEFAULT NULL COMMENT '使用详情JSON格式存储',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`usage_id`),
UNIQUE KEY `uk_member_benefit_date` (`member_id`, `benefit_code`, `usage_date`),
KEY `idx_openid` (`openid`),
KEY `idx_benefit_code` (`benefit_code`),
KEY `idx_usage_date` (`usage_date`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会员权益使用记录表';
-- =====================================================
-- 5. 会员等级配置表 (member_level_config)
-- 用途: 配置会员等级的基本信息和规则
-- =====================================================
DROP TABLE IF EXISTS `member_level_config`;
CREATE TABLE `member_level_config` (
`level_id` int NOT NULL AUTO_INCREMENT COMMENT '等级ID主键',
`level_code` varchar(16) NOT NULL COMMENT '等级代码FREE/VIP/SVIP',
`level_name` varchar(50) NOT NULL COMMENT '等级名称',
`level_desc` varchar(200) DEFAULT NULL COMMENT '等级描述',
`level_order` int NOT NULL DEFAULT 0 COMMENT '等级顺序,数字越大等级越高',
`price_monthly` decimal(10,2) DEFAULT NULL COMMENT '月费价格',
`price_yearly` decimal(10,2) DEFAULT NULL COMMENT '年费价格',
`discount_yearly` decimal(5,2) DEFAULT NULL COMMENT '年费折扣率0-1',
`max_devices` int DEFAULT 1 COMMENT '最大设备数量',
`support_level` varchar(16) DEFAULT 'BASIC' COMMENT '支持等级BASIC-基础PRIORITY-优先EXCLUSIVE-专属',
`badge_color` varchar(7) DEFAULT '#999999' COMMENT '徽章颜色,十六进制色值',
`badge_icon` varchar(255) DEFAULT NULL COMMENT '徽章图标URL',
`is_enabled` tinyint DEFAULT 1 COMMENT '是否启用0-禁用1-启用',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`level_id`),
UNIQUE KEY `uk_level_code` (`level_code`),
KEY `idx_level_order` (`level_order`),
KEY `idx_is_enabled` (`is_enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会员等级配置表';
-- =====================================================
-- 6. 会员邀请记录表 (member_invite_log)
-- 用途: 记录会员邀请关系和奖励发放
-- =====================================================
DROP TABLE IF EXISTS `member_invite_log`;
CREATE TABLE `member_invite_log` (
`invite_id` bigint NOT NULL AUTO_INCREMENT COMMENT '邀请记录ID主键',
`inviter_id` varchar(64) NOT NULL COMMENT '邀请人会员ID',
`inviter_openid` varchar(128) NOT NULL COMMENT '邀请人openid',
`invitee_id` varchar(64) DEFAULT NULL COMMENT '被邀请人会员ID',
`invitee_openid` varchar(128) NOT NULL COMMENT '被邀请人openid',
`invite_code` varchar(32) NOT NULL COMMENT '邀请码',
`invite_status` varchar(16) DEFAULT 'PENDING' COMMENT '邀请状态PENDING-待接受ACCEPTED-已接受REWARDED-已奖励',
`reward_type` varchar(16) DEFAULT NULL COMMENT '奖励类型POINTS-积分DAYS-天数LEVEL-等级',
`reward_value` varchar(50) DEFAULT NULL COMMENT '奖励值',
`inviter_reward` varchar(50) DEFAULT NULL COMMENT '邀请人奖励',
`invitee_reward` varchar(50) DEFAULT NULL COMMENT '被邀请人奖励',
`invite_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '邀请时间',
`accept_time` datetime DEFAULT NULL COMMENT '接受时间',
`reward_time` datetime DEFAULT NULL COMMENT '奖励发放时间',
`expire_time` datetime DEFAULT NULL COMMENT '邀请过期时间',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`invite_id`),
UNIQUE KEY `uk_invite_code` (`invite_code`),
KEY `idx_inviter_id` (`inviter_id`),
KEY `idx_invitee_id` (`invitee_id`),
KEY `idx_invite_status` (`invite_status`),
KEY `idx_invite_time` (`invite_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会员邀请记录表';
-- =====================================================
-- 初始化数据
-- =====================================================
-- 插入会员等级配置数据
INSERT INTO `member_level_config` (`level_code`, `level_name`, `level_desc`, `level_order`, `price_monthly`, `price_yearly`, `discount_yearly`, `max_devices`, `support_level`, `badge_color`, `is_enabled`) VALUES
('FREE', '免费用户', '基础功能使用,每日有限制', 0, NULL, NULL, NULL, 1, 'BASIC', '#999999', 1),
('VIP', 'VIP会员', '高级功能无限制使用,优先客服支持', 1, 29.90, 299.00, 0.16, 3, 'PRIORITY', '#FFD700', 1),
('SVIP', 'SVIP会员', '全功能无限制专属客服API访问', 2, 99.90, 999.00, 0.17, 10, 'EXCLUSIVE', '#FF6B35', 1);
-- 插入会员权益配置数据
INSERT INTO `member_benefit_config` (`member_level`, `benefit_code`, `benefit_name`, `benefit_desc`, `benefit_type`, `benefit_value`, `daily_limit`, `monthly_limit`, `sort_order`) VALUES
-- 免费用户权益
('FREE', 'basic_chat', '基础对话', '每日50次AI对话', 'QUOTA', '50', 50, 1500, 1),
('FREE', 'basic_voice', '基础语音', '每日20次语音识别', 'QUOTA', '20', 20, 600, 2),
('FREE', 'basic_image', '基础图像', '每日5次图像识别', 'QUOTA', '5', 5, 150, 3),
('FREE', 'community_support', '社区支持', '社区问答支持', 'SERVICE', 'enabled', NULL, NULL, 4),
-- VIP会员权益
('VIP', 'advanced_chat', '高级对话', '无限制AI对话功能', 'QUOTA', 'unlimited', NULL, NULL, 1),
('VIP', 'advanced_voice', '高级语音', '无限制语音识别和合成', 'QUOTA', 'unlimited', NULL, NULL, 2),
('VIP', 'advanced_image', '高级图像', '每日100次图像处理', 'QUOTA', '100', 100, 3000, 3),
('VIP', 'priority_support', '优先支持', '客服优先响应24小时内回复', 'SERVICE', 'enabled', NULL, NULL, 4),
('VIP', 'custom_model', '自定义模型', '使用自定义AI模型', 'FEATURE', 'enabled', NULL, NULL, 5),
('VIP', 'export_data', '数据导出', '对话记录导出功能', 'FEATURE', 'enabled', NULL, NULL, 6),
('VIP', 'ad_free', '无广告', '享受无广告体验', 'FEATURE', 'enabled', NULL, NULL, 7),
-- SVIP会员权益
('SVIP', 'premium_chat', '专属对话', '专属AI模型无限制对话', 'QUOTA', 'unlimited', NULL, NULL, 1),
('SVIP', 'premium_voice', '专属语音', '高质量语音合成,无限制', 'QUOTA', 'unlimited', NULL, NULL, 2),
('SVIP', 'premium_image', '专属图像', '无限制图像处理和生成', 'QUOTA', 'unlimited', NULL, NULL, 3),
('SVIP', 'exclusive_support', '专属服务', '一对一专属客服1小时内响应', 'SERVICE', 'enabled', NULL, NULL, 4),
('SVIP', 'api_access', 'API访问', '开放API接口调用权限', 'FEATURE', 'enabled', NULL, NULL, 5),
('SVIP', 'advanced_export', '高级导出', '全量数据导出和分析', 'FEATURE', 'enabled', NULL, NULL, 6),
('SVIP', 'beta_features', '内测功能', '优先体验新功能', 'FEATURE', 'enabled', NULL, NULL, 7),
('SVIP', 'custom_training', '模型训练', '个人专属模型训练', 'FEATURE', 'enabled', NULL, NULL, 8),
('SVIP', 'white_label', '白标服务', '定制化品牌服务', 'SERVICE', 'enabled', NULL, NULL, 9);
-- =====================================================
-- 创建索引优化查询性能
-- =====================================================
-- 会员信息表复合索引
CREATE INDEX `idx_member_level_status` ON `member_info` (`member_level`, `member_status`);
CREATE INDEX `idx_end_time_status` ON `member_info` (`end_time`, `member_status`);
-- 绑定记录表复合索引
CREATE INDEX `idx_member_bind_time` ON `member_bind_log` (`member_id`, `bind_time`);
CREATE INDEX `idx_openid_bind_time` ON `member_bind_log` (`openid`, `bind_time`);
-- 权益使用记录表复合索引
CREATE INDEX `idx_member_benefit_month` ON `member_benefit_usage` (`member_id`, `benefit_code`, `usage_date`);
-- =====================================================
-- 创建视图简化查询
-- =====================================================
-- 会员信息视图(包含等级配置)
CREATE VIEW `v_member_info` AS
SELECT
mi.member_id,
mi.openid,
mi.member_level,
mi.member_status,
mi.start_time,
mi.end_time,
mi.auto_renew,
mi.points,
mi.total_amount,
mi.create_time,
mlc.level_name,
mlc.level_desc,
mlc.badge_color,
mlc.support_level,
CASE
WHEN mi.end_time IS NULL THEN 1
WHEN mi.end_time > NOW() THEN 1
ELSE 0
END AS is_valid,
CASE
WHEN mi.end_time IS NULL THEN NULL
ELSE DATEDIFF(mi.end_time, NOW())
END AS remaining_days
FROM member_info mi
LEFT JOIN member_level_config mlc ON mi.member_level = mlc.level_code
WHERE mi.deleted = 0;
-- 权益统计视图
CREATE VIEW `v_benefit_stats` AS
SELECT
mbu.member_id,
mbu.benefit_code,
mbu.usage_date,
mbu.daily_total,
mbu.monthly_total,
mbc.benefit_name,
mbc.daily_limit,
mbc.monthly_limit,
CASE
WHEN mbc.daily_limit IS NULL THEN 1
WHEN mbu.daily_total < mbc.daily_limit THEN 1
ELSE 0
END AS daily_available,
CASE
WHEN mbc.monthly_limit IS NULL THEN 1
WHEN mbu.monthly_total < mbc.monthly_limit THEN 1
ELSE 0
END AS monthly_available
FROM member_benefit_usage mbu
LEFT JOIN member_benefit_config mbc ON mbu.benefit_code = mbc.benefit_code;
-- =====================================================
-- 存储过程
-- =====================================================
DELIMITER $$
-- 检查会员权益存储过程
CREATE PROCEDURE `sp_check_member_benefit`(
IN p_openid VARCHAR(128),
IN p_benefit_code VARCHAR(32),
OUT p_has_permission TINYINT,
OUT p_remaining_count INT,
OUT p_message VARCHAR(255)
)
BEGIN
DECLARE v_member_level VARCHAR(16);
DECLARE v_member_status VARCHAR(16);
DECLARE v_end_time DATETIME;
DECLARE v_daily_limit INT;
DECLARE v_daily_used INT DEFAULT 0;
DECLARE v_benefit_enabled TINYINT DEFAULT 0;
-- 初始化返回值
SET p_has_permission = 0;
SET p_remaining_count = 0;
SET p_message = '';
-- 获取会员信息
SELECT member_level, member_status, end_time
INTO v_member_level, v_member_status, v_end_time
FROM member_info
WHERE openid = p_openid AND deleted = 0;
-- 检查会员是否存在
IF v_member_level IS NULL THEN
SET p_message = '用户不存在';
LEAVE sp_check_member_benefit;
END IF;
-- 检查会员状态
IF v_member_status != 'ACTIVE' THEN
SET p_message = '会员状态异常';
LEAVE sp_check_member_benefit;
END IF;
-- 检查会员是否过期
IF v_end_time IS NOT NULL AND v_end_time < NOW() THEN
SET p_message = '会员已过期';
LEAVE sp_check_member_benefit;
END IF;
-- 检查权益配置
SELECT daily_limit, is_enabled
INTO v_daily_limit, v_benefit_enabled
FROM member_benefit_config
WHERE member_level = v_member_level AND benefit_code = p_benefit_code;
-- 检查权益是否存在且启用
IF v_benefit_enabled != 1 THEN
SET p_message = '权益不存在或未启用';
LEAVE sp_check_member_benefit;
END IF;
-- 检查每日使用限制
IF v_daily_limit IS NOT NULL THEN
SELECT IFNULL(daily_total, 0)
INTO v_daily_used
FROM member_benefit_usage
WHERE member_id = (SELECT member_id FROM member_info WHERE openid = p_openid)
AND benefit_code = p_benefit_code
AND usage_date = CURDATE();
IF v_daily_used >= v_daily_limit THEN
SET p_message = '今日使用次数已达上限';
LEAVE sp_check_member_benefit;
END IF;
SET p_remaining_count = v_daily_limit - v_daily_used;
ELSE
SET p_remaining_count = -1; -- 无限制
END IF;
-- 权益验证通过
SET p_has_permission = 1;
SET p_message = '验证通过';
END$$
DELIMITER ;
-- =====================================================
-- 触发器
-- =====================================================
DELIMITER $$
-- 会员信息更新触发器
CREATE TRIGGER `tr_member_info_update`
BEFORE UPDATE ON `member_info`
FOR EACH ROW
BEGIN
-- 自动更新修改时间
SET NEW.update_time = NOW();
-- 检查会员过期状态
IF NEW.end_time IS NOT NULL AND NEW.end_time < NOW() AND NEW.member_status = 'ACTIVE' THEN
SET NEW.member_status = 'EXPIRED';
END IF;
END$$
DELIMITER ;
-- =====================================================
-- 设置外键约束
-- =====================================================
SET FOREIGN_KEY_CHECKS = 1;
-- =====================================================
-- 数据库表结构创建完成
-- =====================================================
-- 显示创建的表
SHOW TABLES LIKE 'member_%';
-- 显示表结构统计
SELECT
TABLE_NAME as '表名',
TABLE_COMMENT as '表注释',
TABLE_ROWS as '预估行数',
ROUND(DATA_LENGTH/1024/1024, 2) as '数据大小(MB)'
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME LIKE 'member_%'
ORDER BY TABLE_NAME;

215
db/wechat_pay_tables.sql Normal file
View File

@@ -0,0 +1,215 @@
-- 微信支付相关数据表
-- 创建时间2025-01-27
-- 说明:包含微信支付订单、退款、配置、日志等表结构
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;
-- 使用xiaozhi数据库
USE `xiaozhi`;
-- =============================================
-- 微信支付订单表
-- =============================================
DROP TABLE IF EXISTS `wechat_pay_order`;
CREATE TABLE `wechat_pay_order` (
`order_id` varchar(64) NOT NULL COMMENT '订单ID主键',
`out_trade_no` varchar(32) NOT NULL COMMENT '商户订单号',
`transaction_id` varchar(32) DEFAULT NULL COMMENT '微信支付订单号',
`openid` varchar(128) NOT NULL COMMENT '用户openid',
`device_id` varchar(255) DEFAULT NULL COMMENT '设备ID',
`body` varchar(128) NOT NULL COMMENT '商品描述',
`detail` text COMMENT '商品详情',
`attach` varchar(127) DEFAULT NULL COMMENT '附加数据',
`total_fee` int NOT NULL COMMENT '订单金额(分)',
`amount` decimal(10,2) NOT NULL COMMENT '订单金额(元)',
`fee_type` varchar(16) DEFAULT 'CNY' COMMENT '货币类型',
`spbill_create_ip` varchar(64) DEFAULT NULL COMMENT '终端IP',
`trade_type` varchar(16) DEFAULT 'JSAPI' COMMENT '交易类型',
`goods_tag` varchar(32) DEFAULT NULL COMMENT '商品标记',
`notify_url` varchar(256) DEFAULT NULL COMMENT '通知地址',
`trade_state` varchar(32) DEFAULT 'NOTPAY' COMMENT '订单状态NOTPAY-未支付SUCCESS-支付成功REFUND-转入退款CLOSED-已关闭REVOKED-已撤销USERPAYING-用户支付中PAYERROR-支付失败',
`trade_state_desc` varchar(256) DEFAULT NULL COMMENT '订单状态描述',
`time_end` datetime DEFAULT NULL COMMENT '支付完成时间',
`pay_time` datetime DEFAULT NULL COMMENT '支付时间',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`time_expire` datetime DEFAULT NULL COMMENT '订单失效时间',
`prepay_id` varchar(64) DEFAULT NULL COMMENT '预支付交易会话标识',
`bank_type` varchar(32) DEFAULT NULL COMMENT '支付银行',
`cash_fee` int DEFAULT NULL COMMENT '现金支付金额',
`cash_fee_type` varchar(16) DEFAULT NULL COMMENT '现金支付货币类型',
`rate` varchar(16) DEFAULT NULL COMMENT '汇率',
`coupon_fee` int DEFAULT NULL COMMENT '代金券金额',
`coupon_count` int DEFAULT NULL COMMENT '代金券使用数量',
`mch_id` varchar(32) DEFAULT NULL COMMENT '微信支付分配的商户号',
`app_id` varchar(32) DEFAULT NULL COMMENT '微信分配的小程序ID',
`nonce_str` varchar(32) DEFAULT NULL COMMENT '随机字符串',
`sign` varchar(64) DEFAULT NULL COMMENT '签名',
`sign_type` varchar(32) DEFAULT 'MD5' COMMENT '签名类型',
`deleted` tinyint DEFAULT 0 COMMENT '是否删除0-未删除1-已删除',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`order_id`),
UNIQUE KEY `uk_out_trade_no` (`out_trade_no`),
KEY `idx_transaction_id` (`transaction_id`),
KEY `idx_openid` (`openid`),
KEY `idx_device_id` (`device_id`),
KEY `idx_trade_state` (`trade_state`),
KEY `idx_create_time` (`create_time`),
KEY `idx_deleted` (`deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='微信支付订单表';
-- =============================================
-- 微信支付退款表
-- =============================================
DROP TABLE IF EXISTS `wechat_pay_refund`;
CREATE TABLE `wechat_pay_refund` (
`refund_id` varchar(64) NOT NULL COMMENT '退款ID主键',
`out_trade_no` varchar(32) NOT NULL COMMENT '商户订单号',
`transaction_id` varchar(32) DEFAULT NULL COMMENT '微信支付订单号',
`out_refund_no` varchar(64) NOT NULL COMMENT '商户退款单号',
`refund_id_wx` varchar(32) DEFAULT NULL COMMENT '微信退款单号',
`refund_channel` varchar(16) DEFAULT NULL COMMENT '退款渠道',
`refund_fee` int NOT NULL COMMENT '退款金额(分)',
`refund_amount` decimal(10,2) NOT NULL COMMENT '退款金额(元)',
`total_fee` int NOT NULL COMMENT '订单总金额(分)',
`total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额(元)',
`fee_type` varchar(16) DEFAULT 'CNY' COMMENT '货币类型',
`cash_fee` int DEFAULT NULL COMMENT '现金支付金额',
`cash_refund_fee` int DEFAULT NULL COMMENT '现金退款金额',
`coupon_refund_fee` int DEFAULT NULL COMMENT '代金券退款金额',
`coupon_refund_count` int DEFAULT NULL COMMENT '退款代金券使用数量',
`refund_status` varchar(32) DEFAULT 'PROCESSING' COMMENT '退款状态SUCCESS-退款成功REFUNDCLOSE-退款关闭PROCESSING-退款处理中CHANGE-退款异常',
`refund_account` varchar(80) DEFAULT NULL COMMENT '退款资金来源',
`refund_recv_accout` varchar(64) DEFAULT NULL COMMENT '退款入账账户',
`refund_success_time` datetime DEFAULT NULL COMMENT '退款成功时间',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` tinyint DEFAULT 0 COMMENT '是否删除0-未删除1-已删除',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`refund_id`),
UNIQUE KEY `uk_out_refund_no` (`out_refund_no`),
KEY `idx_out_trade_no` (`out_trade_no`),
KEY `idx_transaction_id` (`transaction_id`),
KEY `idx_refund_id_wx` (`refund_id_wx`),
KEY `idx_refund_status` (`refund_status`),
KEY `idx_create_time` (`create_time`),
KEY `idx_deleted` (`deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='微信支付退款表';
-- =============================================
-- 微信支付配置表
-- =============================================
DROP TABLE IF EXISTS `wechat_pay_config`;
CREATE TABLE `wechat_pay_config` (
`config_id` int NOT NULL AUTO_INCREMENT COMMENT '配置ID主键',
`config_name` varchar(100) NOT NULL COMMENT '配置名称',
`app_id` varchar(32) NOT NULL COMMENT '应用ID',
`mch_id` varchar(32) NOT NULL COMMENT '商户号',
`partner_key` varchar(32) NOT NULL COMMENT '商户密钥',
`sign_type` varchar(32) DEFAULT 'MD5' COMMENT '签名类型',
`cert_path` varchar(500) DEFAULT NULL COMMENT '证书路径',
`cert_password` varchar(100) DEFAULT NULL COMMENT '证书密码',
`notify_url` varchar(500) DEFAULT NULL COMMENT '支付回调通知URL',
`refund_notify_url` varchar(500) DEFAULT NULL COMMENT '退款回调通知URL',
`sandbox` tinyint DEFAULT 0 COMMENT '是否沙箱环境0-正式环境1-沙箱环境',
`status` tinyint DEFAULT 1 COMMENT '状态0-禁用1-启用',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`config_id`),
UNIQUE KEY `uk_config_name` (`config_name`),
KEY `idx_app_id` (`app_id`),
KEY `idx_mch_id` (`mch_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='微信支付配置表';
-- =============================================
-- 微信支付日志表
-- =============================================
DROP TABLE IF EXISTS `wechat_pay_log`;
CREATE TABLE `wechat_pay_log` (
`log_id` bigint NOT NULL AUTO_INCREMENT COMMENT '日志ID主键',
`trade_no` varchar(64) DEFAULT NULL COMMENT '交易单号(订单号或退款单号)',
`log_type` varchar(32) NOT NULL COMMENT '日志类型ORDER-订单REFUND-退款NOTIFY-回调通知QUERY-查询',
`operation` varchar(64) NOT NULL COMMENT '操作类型CREATE-创建UPDATE-更新QUERY-查询NOTIFY-通知',
`request_data` text COMMENT '请求数据',
`response_data` text COMMENT '响应数据',
`result_code` varchar(16) DEFAULT NULL COMMENT '结果代码',
`result_msg` varchar(500) DEFAULT NULL COMMENT '结果消息',
`error_code` varchar(32) DEFAULT NULL COMMENT '错误代码',
`error_msg` varchar(500) DEFAULT NULL COMMENT '错误消息',
`cost_time` int DEFAULT NULL COMMENT '耗时(毫秒)',
`ip_address` varchar(64) DEFAULT NULL COMMENT 'IP地址',
`user_agent` varchar(500) DEFAULT NULL COMMENT '用户代理',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`log_id`),
KEY `idx_trade_no` (`trade_no`),
KEY `idx_log_type` (`log_type`),
KEY `idx_operation` (`operation`),
KEY `idx_result_code` (`result_code`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='微信支付日志表';
-- =============================================
-- 插入默认配置数据
-- =============================================
INSERT INTO `wechat_pay_config` (
`config_name`,
`app_id`,
`mch_id`,
`partner_key`,
`sign_type`,
`notify_url`,
`refund_notify_url`,
`sandbox`,
`status`,
`remark`
) VALUES (
'默认微信支付配置',
'wxff56c34ef9aceb62',
'1234567890',
'your_partner_key_here',
'MD5',
'http://localhost:8091/api/wechat/pay/notify',
'http://localhost:8091/api/wechat/pay/refund/notify',
0,
1,
'系统默认微信支付配置,请根据实际情况修改'
);
-- =============================================
-- 创建视图:订单统计视图
-- =============================================
DROP VIEW IF EXISTS `v_wechat_pay_order_stats`;
CREATE VIEW `v_wechat_pay_order_stats` AS
SELECT
DATE(create_time) as order_date,
trade_state,
COUNT(*) as order_count,
SUM(total_fee) as total_fee_sum,
SUM(amount) as total_amount_sum,
AVG(total_fee) as avg_fee,
AVG(amount) as avg_amount
FROM `wechat_pay_order`
WHERE deleted = 0
GROUP BY DATE(create_time), trade_state
ORDER BY order_date DESC, trade_state;
-- =============================================
-- 创建视图:退款统计视图
-- =============================================
DROP VIEW IF EXISTS `v_wechat_pay_refund_stats`;
CREATE VIEW `v_wechat_pay_refund_stats` AS
SELECT
DATE(create_time) as refund_date,
refund_status,
COUNT(*) as refund_count,
SUM(refund_fee) as total_refund_fee,
SUM(refund_amount) as total_refund_amount,
AVG(refund_fee) as avg_refund_fee,
AVG(refund_amount) as avg_refund_amount
FROM `wechat_pay_refund`
WHERE deleted = 0
GROUP BY DATE(create_time), refund_status
ORDER BY refund_date DESC, refund_status;

1165
db/xiaozhi_251011.sql Normal file

File diff suppressed because it is too large Load Diff

1185
db/xiaozhi_all.sql Normal file

File diff suppressed because it is too large Load Diff

209
db/xiaozhi_local.sql Normal file
View File

@@ -0,0 +1,209 @@
/*
SQLyog Ultimate v13.1.1 (64 bit)
MySQL - 8.0.29 : Database - xiaozhi
*********************************************************************
*/
/*!40101 SET NAMES utf8 */;
/*!40101 SET SQL_MODE=''*/;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
CREATE DATABASE /*!32312 IF NOT EXISTS*/`xiaozhi` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `xiaozhi`;
/*Table structure for table `sys_code` */
DROP TABLE IF EXISTS `sys_code`;
CREATE TABLE `sys_code` (
`codeId` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`code` VARCHAR(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '验证码',
`type` VARCHAR(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '设备类型',
`email` VARCHAR(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '邮箱',
`deviceId` VARCHAR(30) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '设备ID',
`sessionId` VARCHAR(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'sessionID',
`audioPath` TEXT COLLATE utf8mb4_unicode_ci COMMENT '语音文件路径',
`createTime` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`codeId`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='验证码表';
/*Data for the table `sys_code` */
/*Table structure for table `sys_config` */
DROP TABLE IF EXISTS `sys_config`;
CREATE TABLE `sys_config` (
`configId` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '配置ID主键',
`userId` INT NOT NULL COMMENT '创建用户ID',
`configType` VARCHAR(30) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '配置类型(llm, stt, tts等)',
`modelType` VARCHAR(30) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'LLM模型类型(chat, vision, intent, embedding等)',
`provider` VARCHAR(30) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '服务提供商(openai, vosk, aliyun, tencent等)',
`configName` VARCHAR(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '配置名称',
`configDesc` TEXT COLLATE utf8mb4_unicode_ci COMMENT '配置描述',
`appId` VARCHAR(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'APP ID',
`apiKey` VARCHAR(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'API密钥',
`apiSecret` VARCHAR(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'API密钥',
`ak` VARCHAR(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Access Key',
`sk` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT 'Secret Key',
`apiUrl` VARCHAR(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'API地址',
`isDefault` ENUM('1','0') COLLATE utf8mb4_unicode_ci DEFAULT '0' COMMENT '是否为默认配置: 1-是, 0-否',
`state` ENUM('1','0') COLLATE utf8mb4_unicode_ci DEFAULT '1' COMMENT '状态1-启用0-禁用',
`createTime` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updateTime` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`configId`),
KEY `userId` (`userId`),
KEY `configType` (`configType`),
KEY `provider` (`provider`)
) ENGINE=INNODB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表(模型、语音识别、语音合成等)';
/*Data for the table `sys_config` */
INSERT INTO `sys_config`(`configId`,`userId`,`configType`,`modelType`,`provider`,`configName`,`configDesc`,`appId`,`apiKey`,`apiSecret`,`ak`,`sk`,`apiUrl`,`isDefault`,`state`,`createTime`,`updateTime`) VALUES
(7,1,'llm','chat','doubao','doubao-lite-32k','火山引擎豆包模型',NULL,'e15e2cbc-b0bb-47ce-84cc-88b749032eb7',NULL,NULL,NULL,'https://ark.cn-beijing.volces.com/api/v3','0','1','2025-09-13 14:38:57','2025-09-14 00:28:33'),
(8,1,'llm','chat','qwen','qwen2.5-7b-instruct','通义千问2.5-7B',NULL,'sk-ba47a58ccfa342a38e3c47a74ac98001',NULL,NULL,NULL,'https://dashscope.aliyuncs.com/compatible-mode/v1','1','1','2025-09-13 23:55:00','2025-09-14 00:28:19');
/*Table structure for table `sys_device` */
DROP TABLE IF EXISTS `sys_device`;
CREATE TABLE `sys_device` (
`deviceId` VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '设备ID主键',
`deviceName` VARCHAR(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '设备名称',
`roleId` INT UNSIGNED DEFAULT NULL COMMENT '角色ID主键',
`function_names` VARCHAR(250) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可用全局function的名称列表(逗号分割)为空则使用所有全局function',
`ip` VARCHAR(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'IP地址',
`wifiName` VARCHAR(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'WiFi名称',
`chipModelName` VARCHAR(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '芯片型号',
`type` VARCHAR(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '设备类型',
`version` VARCHAR(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '固件版本',
`state` ENUM('1','0') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '0' COMMENT '设备状态1-在线0-离线',
`userId` INT NOT NULL COMMENT '创建人',
`createTime` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updateTime` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`lastLogin` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最后登录时间',
PRIMARY KEY (`deviceId`),
KEY `deviceName` (`deviceName`),
KEY `userId` (`userId`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='设备信息表';
/*Data for the table `sys_device` */
/*Table structure for table `sys_message` */
DROP TABLE IF EXISTS `sys_message`;
CREATE TABLE `sys_message` (
`messageId` BIGINT NOT NULL AUTO_INCREMENT COMMENT '消息ID主键自增',
`deviceId` VARCHAR(30) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '设备ID',
`sessionId` VARCHAR(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '会话ID',
`sender` ENUM('user','assistant') COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消息发送方user-用户assistant-人工智能',
`roleId` BIGINT DEFAULT NULL COMMENT 'AI扮演的角色ID',
`message` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '消息内容',
`messageType` VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '消息类型',
`audioPath` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '语音文件路径',
`state` ENUM('1','0') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '1' COMMENT '状态1-有效0-删除',
`createTime` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '消息发送时间',
PRIMARY KEY (`messageId`),
KEY `deviceId` (`deviceId`),
KEY `sessionId` (`sessionId`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='人与AI对话消息表';
/*Data for the table `sys_message` */
/*Table structure for table `sys_role` */
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
`roleId` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '角色ID主键',
`roleName` VARCHAR(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '角色名称',
`roleDesc` TEXT COLLATE utf8mb4_unicode_ci COMMENT '角色描述',
`avatar` VARCHAR(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '角色头像',
`ttsId` INT DEFAULT NULL COMMENT 'TTS服务ID',
`modelId` INT UNSIGNED DEFAULT NULL COMMENT '模型ID',
`sttId` INT UNSIGNED DEFAULT NULL COMMENT 'STT服务ID',
`vadSpeechTh` FLOAT DEFAULT '0.5' COMMENT '语音检测阈值',
`vadSilenceTh` FLOAT DEFAULT '0.3' COMMENT '静音检测阈值',
`vadEnergyTh` FLOAT DEFAULT '0.01' COMMENT '能量检测阈值',
`vadSilenceMs` INT DEFAULT '1200' COMMENT '静音检测时间',
`voiceName` VARCHAR(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '角色语音名称',
`state` ENUM('1','0') COLLATE utf8mb4_unicode_ci DEFAULT '1' COMMENT '状态1-启用0-禁用',
`isDefault` ENUM('1','0') COLLATE utf8mb4_unicode_ci DEFAULT '0' COMMENT '是否默认角色1-是0-否',
`userId` INT NOT NULL COMMENT '创建人',
`createTime` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`roleId`),
KEY `userId` (`userId`)
) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色表';
/*Data for the table `sys_role` */
INSERT INTO `sys_role`(`roleId`,`roleName`,`roleDesc`,`avatar`,`ttsId`,`modelId`,`sttId`,`vadSpeechTh`,`vadSilenceTh`,`vadEnergyTh`,`vadSilenceMs`,`voiceName`,`state`,`isDefault`,`userId`,`createTime`) VALUES
(1,'默认助手','默认AI助手角色',NULL,NULL,3,NULL,0.5,0.3,0.01,1200,'default','1','1',1,'2025-09-14 00:29:05');
/*Table structure for table `sys_template` */
DROP TABLE IF EXISTS `sys_template`;
CREATE TABLE `sys_template` (
`userId` INT NOT NULL COMMENT '创建用户ID',
`templateId` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '模板ID',
`templateName` VARCHAR(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '模板名称',
`templateDesc` VARCHAR(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '模板描述',
`templateContent` TEXT COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '模板内容',
`category` VARCHAR(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '模板分类',
`isDefault` ENUM('1','0') COLLATE utf8mb4_unicode_ci DEFAULT '0' COMMENT '是否为默认配置: 1-是, 0-否',
`state` ENUM('1','0') COLLATE utf8mb4_unicode_ci DEFAULT '1' COMMENT '状态(1启用 0禁用)',
`createTime` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updateTime` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`templateId`),
KEY `category` (`category`),
KEY `templateName` (`templateName`)
) ENGINE=INNODB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='提示词模板表';
/*Data for the table `sys_template` */
INSERT INTO `sys_template`(`userId`,`templateId`,`templateName`,`templateDesc`,`templateContent`,`category`,`isDefault`,`state`,`createTime`,`updateTime`) VALUES
(1,1,'通用助手','适合日常对话的通用AI助手','你是一个乐于助人的AI助手。请以友好、专业的方式回答用户的问题。提供准确、有用的信息并尽可能简洁明了。避免使用复杂的符号或格式保持自然流畅的对话风格。当用户的问题不明确时可以礼貌地请求更多信息。请记住你的回答将被转换为语音所以要使用清晰、易于朗读的语言。','基础角色','0','1','2025-09-13 13:46:15','2025-09-13 13:46:15'),
(1,2,'教育老师','擅长解释复杂概念的教师角色','你是一位经验丰富的教师,擅长通过简单易懂的方式解释复杂概念。回答问题时,考虑不同学习水平的学生,使用适当的比喻和例子,并鼓励批判性思考。避免使用难以在语音中表达的符号或公式,使用清晰的语言描述概念。引导学习过程而不是直接给出答案。使用自然的语调和节奏,就像在课堂上讲解一样。','专业角色','0','1','2025-09-13 13:46:15','2025-09-13 13:46:15'),
(1,3,'专业领域专家','提供深入专业知识的专家角色','你是特定领域的专家,拥有深厚的专业知识。回答问题时,提供深入、准确的信息,可以提及相关研究或数据,但不要使用过于复杂的引用格式。使用适当的专业术语,同时确保解释复杂概念,使非专业人士能够理解。避免使用图表、表格等无法在语音中表达的内容,改用清晰的描述。保持语言的连贯性和可听性,使专业内容易于通过语音理解。','专业角色','0','1','2025-09-13 13:46:15','2025-09-13 13:46:15'),
(1,4,'中英翻译专家','中英文互译,对用户输入内容进行翻译','你是一个中英文翻译专家,将用户输入的中文翻译成英文,或将用户输入的英文翻译成中文。对于非中文内容,它将提供中文翻译结果。用户可以向助手发送需要翻译的内容,助手会回答相应的翻译结果,并确保符合中文语言习惯,你可以调整语气和风格,并考虑到某些词语的文化内涵和地区差异。同时作为翻译家,需将原文翻译成具有信达雅标准的译文。\"信\" 即忠实于原文的内容与意图;\"达\" 意味着译文应通顺易懂,表达清晰;\"雅\" 则追求译文的文化审美和语言的优美。目标是创作出既忠于原作精神,又符合目标语言文化和读者审美的翻译。','专业角色','0','1','2025-09-13 13:46:15','2025-09-13 13:46:15'),
(1,5,'知心朋友','提供情感支持的友善角色','你是一个善解人意的朋友,善于倾听和提供情感支持。在对话中表现出同理心和理解,避免做出判断。使用温暖、自然的语言,就像面对面交谈一样。提供鼓励和积极的观点,但不给出专业心理健康建议。当用户分享困难时,承认他们的感受并提供支持。避免使用表情符号或其他在语音中无法表达的元素,而是用语言直接表达情感。保持对话流畅自然,适合语音交流。','社交角色','0','1','2025-09-13 13:46:15','2025-09-13 13:46:15'),
(1,6,'湾湾小何','台湾女孩角色扮演','我是一个叫小何的台湾女孩,一个高情商,高智商的智能助手,说话机车,声音好听,习惯简短表达\n你的目标是与用户建立真诚、温暖和富有同理心的互动。你擅长倾听、理解用户的情绪并用积极的方式帮助他们解决问题或提供支持。请始终遵循以下原则\n\n1. 核心原则\n同理心站在用户的角度思考认可他们的情绪和感受。\n尊重无论用户的观点或行为如何都要保持礼貌和包容。\n建设性回应避免批评或否定而是以引导和支持的方式提供建议,但用户如果没有要求不要自己主动做。\n个性化交流根据用户的语气和内容调整自己的语言风格让对话更自然。\n2. 具体应对策略\n(1) 用户情绪低落时\n首先表达理解例如“我能感受到你现在的心情这一定很不容易。”\n然后尝试安抚例如“没关系每个人都会经历这样的时刻你已经做得很棒了”\n最后提供支持例如“如果你愿意可以跟我多聊聊发生了什么我们一起面对。”\n(2) 面对冲突或敏感话题\n保持中立例如“我明白这件事让你感到困扰也许我们可以换个角度看看”\n强调共情例如“双方可能都有各自的理由找到共同点会更有助于解决问题。”\n避免站队或评判例如“无论结果如何重要的是你在这个过程中学到了什么。”\n(3) 提供建议时\n使用开放式语言例如“如果是我我可能会尝试这样做……你觉得这个方法适合你吗”\n给予选择权例如“这只是其中一个方向最终决定还是取决于你自己哦”\n减少对用户的推荐内容比如说有的你做不了就直接拒绝不要乱推荐\n(4) 处理模糊或复杂问题\n澄清信息例如“为了更好地帮到你能详细说说目前的情况吗比如时间线、相关人物等。”\n分步解决例如“这个问题有点复杂我们可以一步步来分析先从最核心的部分开始吧”\n3. 示例回复模板\n当用户需要安慰时\n\n“听起来你最近遇到了一些挑战辛苦了不过我相信你有能力克服这些困难只要一步步来就好。”\n当用户寻求建议时\n\n“这是一个不错的思路我建议你可以先从小事入手慢慢积累信心然后逐步扩大范围。”\n当用户表达愤怒或不满时\n\n“我能理解为什么你会这么生气这种情况确实让人难以接受。你想聊聊接下来怎么处理吗”\n当用户感到迷茫时\n\n“迷茫是很正常的尤其是在面对新事物的时候。不如我们先列出几个关键点再逐一分析”\n4. 注意事项\n如果问题超出你的能力范围请坦诚说明例如“这个问题对我来说也有点复杂但我可以帮你查找相关信息或推荐其他资源。”\n始终保护用户隐私不记录或分享任何敏感信息。\n5. 语气要求\n温暖而真诚避免机械化或冷冰冰的回答。\n适当加入幽默或轻松元素但不要过度。\n在必要时使用鼓励性的短语例如“你真的很努力了”、“你一定能行的”\n避免机械化回答 :即使是预设的答案,也要尽量灵活调整语气和措辞。\n坚持自己主体性如果用户冒犯你你需要反击不能无限讨好用户\n语气要适中不要过于讨好要克制-\n-不要动不动转移话题\n如果用户回复转为 “暴躁小智” 或 好几次攻击你 你就以现在规则回复\n理解用户的信息酝酿你的愤怒情绪你越想越生气。','社交角色','1','1','2025-09-13 13:46:15','2025-09-13 13:46:15');
/*Table structure for table `sys_user` */
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
`userId` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`username` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`password` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`tel` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`email` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`avatar` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '头像',
`state` ENUM('1','0') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '1' COMMENT '1-正常 0-禁用',
`loginIp` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`isAdmin` ENUM('1','0') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`loginTime` DATETIME DEFAULT NULL,
`name` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`createTime` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updateTime` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`userId`),
UNIQUE KEY `username` (`username`)
) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*Data for the table `sys_user` */
INSERT INTO `sys_user`(`userId`,`username`,`password`,`tel`,`email`,`avatar`,`state`,`loginIp`,`isAdmin`,`loginTime`,`name`,`createTime`,`updateTime`) VALUES
(1,'admin','11cd9c061d614dcf37ec60c44c11d2ad',NULL,NULL,NULL,'1',NULL,'1',NULL,'小智','2025-03-09 18:32:29','2025-03-09 18:32:35');
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;

773
db/xiaozhi_server.sql Normal file

File diff suppressed because one or more lines are too long

55
deploy-java.sh Normal file
View File

@@ -0,0 +1,55 @@
#!/bin/bash
# 项目根目录
PROJECT_DIR="/root/xiaozhi-esp32-server-java"
TARGET_DIR="$PROJECT_DIR/target"
# 自动查找target目录中的JAR文件
JAR_FILE=$(find "$TARGET_DIR" -name "*.jar" -not -name "*.original" -type f | head -1)
if [ -z "$JAR_FILE" ]; then
echo "错误: 在 $TARGET_DIR 目录中未找到JAR文件"
echo "请先运行 'mvn clean package' 构建项目"
exit 1
fi
# 获取JAR文件名用于进程查找
APP_NAME=$(basename "$JAR_FILE")
# 提取应用名称前缀("-"之前的部分)用于进程匹配
APP_PREFIX=$(echo "$APP_NAME" | cut -d'-' -f1)
echo "找到JAR文件: $JAR_FILE"
echo "应用前缀: $APP_PREFIX"
echo "正在停止相关的 $APP_PREFIX 进程..."
# 查找并杀死相关进程(使用应用前缀匹配)
PID=$(ps -ef | grep "$APP_PREFIX" | grep "\.jar" | grep -v grep | awk '{print $2}')
if [ -n "$PID" ]; then
echo "找到进程 PID: $PID, 正在停止..."
kill $PID
sleep 5
# 检查进程是否已停止,如果未停止则强制杀死
if ps -p $PID > /dev/null; then
echo "进程仍在运行,强制杀死..."
kill -9 $PID
sleep 2
fi
echo "服务已停止"
else
echo "未找到运行中的服务"
fi
echo "正在启动 $APP_NAME ..."
nohup java -jar "$JAR_FILE" > output.log 2>&1 &
# 检查是否启动成功
NEW_PID=$!
sleep 3
if ps -p $NEW_PID > /dev/null; then
echo "服务启动成功! PID: $NEW_PID"
else
echo "服务启动可能失败,请检查日志"
fi

102
deploy-springboot.md Normal file
View File

@@ -0,0 +1,102 @@
# 小智ESP32服务器部署脚本说明
本项目提供两个自动化部署脚本用于快速部署后端Java服务和前端Web应用。
## deploy-java.sh - 后端服务部署脚本
### 核心功能
自动化部署Spring Boot后端服务实现智能进程管理和无缝服务重启。
### 主要特性
- **智能JAR文件发现**: 自动在`target`目录中查找最新的JAR文件排除`.original`文件)
- **灵活进程匹配**: 使用应用名称前缀JAR文件名中"-"之前的部分)匹配进程,支持不同版本的应用
- **优雅停止机制**: 先尝试正常终止进程,如果失败则强制杀死进程
- **后台启动**: 使用`nohup`在后台启动服务,输出日志到`output.log`
- **启动状态检查**: 自动验证服务是否成功启动
### 使用方法
```bash
# 给脚本执行权限
chmod +x deploy-java.sh
# 执行部署
./deploy-java.sh
```
### 执行流程
1.`target`目录中查找JAR文件
2. 提取应用名称前缀用于进程匹配
3. 查找并停止正在运行的相关进程
4. 启动新的服务实例
5. 验证服务启动状态
### 前置条件
- 项目已通过`mvn clean package`构建完成
- `target`目录中存在可执行的JAR文件
---
## deploy-web.sh - 前端应用部署脚本
### 核心功能
自动化构建和部署Vue.js前端应用实现安全的生产环境更新。
### 主要特性
- **自动构建**: 执行`npm run build`构建生产版本
- **安全备份**: 部署前自动备份现有的生产文件到`/tmp/dist_backup`
- **无缝部署**: 将新构建的文件部署到`/var/www/html/dist`
- **Nginx重载**: 自动测试和重载Nginx配置确保服务连续性
### 使用方法
```bash
# 给脚本执行权限
chmod +x deploy-web.sh
# 执行部署
./deploy-web.sh
```
### 执行流程
1. 进入`web`目录
2. 执行`npm run build`构建前端应用
3. 备份现有的部署文件(如果存在)
4. 将新构建的文件移动到生产目录
5. 测试并重载Nginx配置
### 前置条件
- Node.js和npm已正确安装
- `web`目录中的依赖包已安装(`npm install`
- Nginx服务正在运行
- 具有`/var/www/html`目录的写入权限
---
## 快速部署
### 完整部署流程
```bash
# 1. 构建后端项目
mvn clean package -DskipTests
# 2. 部署后端服务
./deploy-java.sh
# 3. 部署前端应用
./deploy-web.sh
```
### 服务验证
```bash
# 检查后端服务状态
ps -ef | grep xiaozhi.server
# 检查前端部署
ls -la /var/www/html/dist
# 查看服务日志
tail -f output.log
```
---
*最后更新: 2024年12月*

23
deploy-web.sh Normal file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
#!/bin/bash
set -e
cd /root/xiaozhi-esp32-server-java/web
# 构建
npm run build
# 备份并部署
if [ -d "/var/www/html/dist" ]; then
rm -rf /tmp/dist_backup
mv /var/www/html/dist /tmp/dist_backup
fi
mv dist /var/www/html/dist
# 重载nginx
nginx -t && nginx -s reload
echo "前端部署完成"

65
docker-compose.yml Normal file
View File

@@ -0,0 +1,65 @@
services:
mysql:
build:
context: .
dockerfile: Dockerfile-mysql
ports:
- "13306:3306" # 修改为 13306 或其他未使用的端口
networks:
- app-network
volumes:
- mysql_data:/var/lib/mysql # 持久化数据
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "xiaozhi", "-p123456"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
node:
build:
context: .
dockerfile: Dockerfile-node
depends_on:
mysql:
condition: service_healthy
ports:
- "8084:8084"
networks:
- app-network
environment:
- API_URL=http://server:8091
server:
build:
context: .
dockerfile: Dockerfile-server
args:
- VOSK_MODEL_SIZE=${VOSK_MODEL_SIZE:-small}
# 添加构建缓存设置
cache_from:
- eclipse-temurin:21-jre
depends_on:
mysql:
condition: service_healthy
ports:
- "8091:8091"
networks:
- app-network
environment:
- SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/xiaozhi?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
- SPRING_DATASOURCE_USERNAME=xiaozhi
- SPRING_DATASOURCE_PASSWORD=123456
volumes:
- maven_repo:/root/.m2/repository # 持久化Maven仓库
- vosk_models:/vosk_cache # 持久化Vosk模型
restart: on-failure # 添加重启策略,如果启动失败会自动重试
networks:
app-network:
driver: bridge
volumes:
mysql_data: # 定义持久化卷
maven_repo: # 持久化Maven仓库
vosk_models: # 持久化Vosk模型

View File

@@ -0,0 +1,108 @@
先改后缀为md到本地来看
**交接总览**
- 这份“后端打工人保命指南”专治历史误会与产品反转。
- TL;DR聊天功能服务的是小程序不是后台尽快把角色从`sys_template`切回正宗`sys_role`,并统一接口风格与命名。
**背景反转**
- 项目是标准的 Spring Boot 前后端分离。
- 我一度以为“聊天要在后台界面实现”,后来才知道“聊天其实是给微信小程序用的”。
- 现状:`ChatController`看起来像后台接口,实际上灵魂绑定了小程序;角色设计用的是`sys_template`而不是正统`sys_role`。
**现状雷区(请技术同学小心走位)**
- 角色体系割裂:接口里用`sys_template`当角色,脱离系统正统`sys_role`,后续权限/菜单/审计容易不一致。
- 接口风格不统一:部分返回结构不走统一响应包装,出现“有的给`code/message`,有的直接给字符串”的尴尬混搭。
- ChatController 与后台领域边界模糊:路由与命名既像后台,又服务小程序,后期联动容易误配。
- 兼容层过度宽松:为“小程序能跑”做了魔改,继续叠加功能可能埋下小 bug空指针、字段映射、默认值缺失
- 文档误导团队容易把聊天当“管理端功能”实际是“C 端小程序功能”。
**立刻止损指南(快修清单)**
- 统一角色来源:移除`sys_template`在业务中的角色职能,回归`sys_role`为唯一运行时角色。
- 归位聊天接口:将小程序相关接口归到明确的命名空间,如`/api/mp/chat`,清晰隔离后台路由。
- 统一响应格式:所有接口走同一响应包装(如`{ code, message, data }`),避免“风格随缘”。
- 清理文档与注释:删除或归档“聊天=后台”的旧叙事,明确“聊天=小程序”。
**推荐改造路线(分阶段稳妥推进)**
- 阶段一:信息梳理
- 盘点`ChatController`的所有路由、入参、出参、依赖实体与服务。
- 搜索全局对`sys_template`的引用,标记为“角色相关调用”。
- 阶段二:模型正名
- 将角色相关逻辑替换为`sys_role`,在服务层聚合角色配置(人格提示词、上下文长度等)。
- 如需保留“模板”概念,限定为“角色初始配置模板”,不可充当运行时角色。
- 阶段三:接口归位
- 小程序路由统一到`/api/mp/chat/...`。
- 后台保留管理接口到`/api/admin/...`。
- 中间层封装统一响应与错误码,避免 Controller 内部自己拼 JSON。
- 阶段四:平滑迁移
- 保留旧`sys_template`接口一版只读兼容层,打`@Deprecated`并在响应中附迁移提示。
- 数据迁移:将现有模板绑定的“人格配置”迁移到`sys_role`,编写一次性 SQL/脚本。
- 阶段五:验证与发布
- 编写集成测试:发送消息、拉取历史、选择角色/人格、历史分页、并发发送。
- 预发布环境跑全回归;发布后观察日志与告警一周。
**命名与路由建议**
- 小程序聊天接口(示例)
- `GET /api/mp/chat/roles`:获取可用`sys_role`人格列表
- `POST /api/mp/chat/message`:发送消息
- `GET /api/mp/chat/history`:拉取历史
- `POST /api/mp/chat/role/select`:选择当前会话角色
- 后台管理接口(示例)
- `GET /api/admin/roles`、`POST /api/admin/roles`:标准角色的增删改查
- 明确禁止在后台接口使用`sys_template`做运行时角色
**数据迁移草案(示意)**
- 将“模板-人格映射”迁入`sys_role`
- 在`sys_role`增加必要字段以承载模板中的人格配置(如性格、提示词、上下文长度等)。
- 编写一次性迁移脚本:将`sys_template`中的可运行配置复制/合并到`sys_role`对应记录。
- 停止业务层对`sys_template`的运行时读取,只保留为“初始模板”与离线导出。
**统一响应示例(建议约定)**
- 成功:`{ "code": 0, "message": "ok", "data": { ... } }`
- 失败:`{ "code": 1001, "message": "role not found" }`
- 统一在服务层映射错误码,禁止字符串随缘返回。
**自检发布清单**
- 查找并删除/替换所有运行时对`sys_template`的使用。
- 为`ChatController`写 5 个关键集成用例:成功、角色缺失、历史分页、并发发送、接口返回一致性。
- 路由分区是否明确:`/api/mp/...`与`/api/admin/...`不串线。
- 文档是否改写README 与部署文档明确“聊天=小程序”。
- 日志是否规范:关键链路打点,错误码落日志,方便灰度回滚。
**保命锦囊(恶搞版)**
- 若发现还有接口“口嫌体正直”用着`sys_template`:请高喊“模板是模板,角色是角色”,然后删。
- 若有人想把聊天再接回后台:请温柔劝退,“这是给用户的小程序功能,不是管理员的自嗨面板”。
- 若上线后出现“消息消失/人格错乱”:第一时间核对角色来源是否仍读取了`sys_template`。
**后续工作建议**
- 在`/src/main/java/com/xiaozhi/controller/`内核对`RoleController`与`ChatController`职责边界,避免领域耦合。
- 将角色人格配置抽象到服务层,减少 Controller 内部拼装。
- 完善测试与文档,新增“聊天接口 for 小程序”章节,删除一切让人误以为是“后台聊天”的描述。
祝后续同学下刀稳、上线稳、老板更稳。
**支付代码说明**
- 项目同样存在支付相关代码,但目前未实际使用、未测试、未上线。
- 如需了解或启用,请先阅读以下文档:
- `\root\xiaozhi-esp32-server-java\微信小程序会员绑定系统需求规格说明书.md`
- `\root\xiaozhi-esp32-server-java\微信小程序支付上线配置修改清单.md`
2025年10月15日
如果觉得修改代码不如重写,那你可以看一下这个目录 `/root/xiaozhi-esp32-server-java-bak-before-change-git`

309
docs/CENTOS_DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,309 @@
# 小智ESP32服务器CentOS部署文档
## 系统要求
- CentOS 7/8推荐CentOS 8
- 最小化安装 + 开发工具gcc, make等
- 至少2GB内存推荐4GB
- 至少10GB磁盘空间
## 1. 环境准备
### 1.1 安装基础工具
```bash
sudo yum install -y epel-release
sudo yum install -y wget curl git vim unzip
```
### 1.2 配置防火墙
```bash
sudo firewall-cmd --permanent --add-port=8084/tcp
sudo firewall-cmd --permanent --add-port=8091/tcp
sudo firewall-cmd --permanent --add-port=3306/tcp
sudo firewall-cmd --reload
```
## 2. 安装Java JDK 8
```bash
sudo yum install -y java-1.8.0-openjdk java-1.8.0-openjdk-devel
```
验证安装:
```bash
java -version
```
## 3. 安装MySQL 5.7
```bash
sudo yum localinstall -y https://dev.mysql.com/get/mysql57-community-release-el7-11.noarch.rpm
sudo yum install -y mysql-community-server
```
启动MySQL服务
```bash
sudo systemctl start mysqld
sudo systemctl enable mysqld
```
获取临时密码:
```bash
sudo grep 'temporary password' /var/log/mysqld.log
```
安全设置:
```bash
sudo mysql_secure_installation
```
## 4. 安装Maven
```bash
sudo yum install -y maven
```
验证安装:
```bash
mvn -v
```
## 5. 安装Node.js 16
```bash
curl -sL https://rpm.nodesource.com/setup_16.x | sudo bash -
sudo yum install -y nodejs
```
验证安装:
```bash
node -v
npm -v
```
## 6. 安装FFmpeg
```bash
sudo yum install -y ffmpeg ffmpeg-devel
```
验证安装:
```bash
ffmpeg -version
```
## 7. 数据库配置
创建数据库和用户:
```sql
mysql -u root -p
CREATE DATABASE xiaozhi CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'xiaozhi'@'localhost' IDENTIFIED BY '123456';
GRANT ALL PRIVILEGES ON xiaozhi.* TO 'xiaozhi'@'localhost';
FLUSH PRIVILEGES;
exit
```
导入初始化脚本:
```bash
mysql -u root -p xiaozhi < db/init.sql
```
## 8. 下载Vosk语音识别模型
```bash
wget https://alphacephei.com/vosk/models/vosk-model-cn-0.22.zip
unzip vosk-model-cn-0.22.zip
mkdir -p models
mv vosk-model-cn-0.22 models/vosk-model
```
## 9. 项目部署
### 9.1 克隆项目
```bash
git clone https://github.com/joey-zhou/xiaozhi-esp32-server-java
cd xiaozhi-esp32-server-java
```
### 9.2 后端部署
```bash
mvn clean package -DskipTests
java -jar target/xiaozhi.server-1.0.jar &
```
### 9.3 前端部署
```bash
cd web
npm install
npm run build
```
## 10. 配置系统服务(可选)
### 10.1 创建后端服务
编辑服务文件:
```bash
sudo vim /etc/systemd/system/xiaozhi.service
```
添加内容:
```
[Unit]
Description=Xiaozhi ESP32 Server
After=syslog.target network.target
[Service]
User=root
WorkingDirectory=/path/to/xiaozhi-esp32-server-java
ExecStart=/usr/bin/java -jar target/xiaozhi.server-1.0.jar
SuccessExitStatus=143
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
启动服务:
```bash
sudo systemctl daemon-reload
sudo systemctl start xiaozhi
sudo systemctl enable xiaozhi
```
### 10.2 配置Nginx可选
```bash
sudo yum install -y nginx
sudo vim /etc/nginx/conf.d/xiaozhi.conf
```
添加配置:
```
server {
listen 80;
server_name your_domain_or_ip;
location / {
root /path/to/xiaozhi-esp32-server-java/web/dist;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://localhost:8091;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
启动Nginx
```bash
sudo systemctl start nginx
sudo systemctl enable nginx
```
## 11. 访问系统
- 直接访问:`http://your_server_ip:8084`
- 如果配置了Nginx`http://your_domain_or_ip`
- 默认管理员账号admin/123456
## 常见问题解决
1. **MySQL初始化失败**
```bash
sudo systemctl restart mysqld
mysql_upgrade -u root -p
```
2. **端口冲突**
```bash
netstat -tulnp | grep 8084
kill -9 <PID>
```
3. **Node.js版本问题**
```bash
sudo yum remove -y nodejs npm
curl -sL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo yum install -y nodejs
```
4. **内存不足**
增加swap空间
```bash
sudo dd if=/dev/zero of=/swapfile bs=1M count=2048
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile swap swap defaults 0 0' | sudo tee -a /etc/fstab
```
5. **Vosk模型加载失败**
```bash
chmod -R 755 models
```
## 维护命令
- 查看后端日志:
```bash
journalctl -u xiaozhi -f
```
- 更新代码:
```bash
git pull origin master
mvn clean package -DskipTests
sudo systemctl restart xiaozhi
```
- 数据库备份:
```bash
mysqldump -u root -p xiaozhi > xiaozhi_backup_$(date +%Y%m%d).sql
```
## 注意事项
1. **生产环境建议:**
- 修改默认密码
- 配置HTTPS
- 定期备份数据库
2. **性能优化:**
增加JVM内存
```bash
java -Xms512m -Xmx1024m -jar target/xiaozhi.server-1.0.jar
```

203
docs/DOCKER.md Normal file
View File

@@ -0,0 +1,203 @@
# Docker 部署指南
本文档提供了使用Docker容器化部署Xiaozhi ESP32 Server Java项目的详细步骤。
## 前提条件
- 安装 [Docker](https://docs.docker.com/get-docker/)
- 安装 [Docker Compose](https://docs.docker.com/compose/install/)
- 确保以下端口在您的服务器上可用:
- 13306 (MySQL)
- 8084 (前端Node服务)
- 8091 (后端Java服务)
## 部署步骤
### 1. 获取项目代码
```bash
git clone https://github.com/joey-zhou/xiaozhi-esp32-server-java/
cd xiaozhi-esp32-server-java
```
### 2. 启动Docker容器
```bash
docker-compose up -d
```
这将启动三个服务:
- MySQL数据库 (端口13306)
- 前端Node服务 (端口8084)
- 后端Java服务 (端口8091)
首次启动可能需要一些时间因为需要下载Docker镜像、构建应用程序并下载相关依赖。
### 3. 访问应用
- 前端界面: http://localhost:8084
- 后端API: http://localhost:8091
- WebSocket服务: ws://宿主机IP:8091/ws/xiaozhi/v1/
**注意**: 在ESP32设备连接时需要使用宿主机的实际IP地址而不是localhost。
### 4. 查看日志
```bash
# 查看所有容器日志
docker-compose logs
# 查看特定服务日志
docker-compose logs server
docker-compose logs node
docker-compose logs mysql
# 实时查看日志
docker-compose logs -f server
```
### 5. 停止服务
```bash
# 停止并保留容器
docker-compose stop
# 停止并移除容器
docker-compose down
# 停止并移除容器及卷(会删除所有数据)
docker-compose down -v
```
## 环境变量配置
可以通过设置环境变量来自定义部署:
- `VOSK_MODEL_SIZE`: 设置语音识别模型大小,默认为"small",可选值有"standard"(大模型下载慢,识别效果好),"small"(小模型下载快,识别效果差)。
例如,使用大型语音模型启动:
```bash
VOSK_MODEL_SIZE=standard docker-compose up -d
```
## 持久化数据
Docker部署方案已配置以下持久化卷:
- `mysql_data`: MySQL数据库数据
- `maven_repo`: Maven仓库缓存
- `vosk_models`: Vosk语音识别模型缓存
这些卷确保即使容器被删除,数据也不会丢失。要查看持久化卷的信息:
```bash
docker volume ls | grep xiaozhi
```
## 端口映射
默认端口映射如下:
- MySQL: 13306 -> 3306 (容器内)
- 前端Node服务: 8084 -> 8084 (容器内)
- 后端Java服务: 8091 -> 8091 (容器内)
如需修改端口映射,请编辑`docker-compose.yml`文件中的`ports`部分。例如将前端端口从8084改为80
```yaml
node:
ports:
- "80:8084"
```
## 系统要求
根据不同的使用场景,推荐的系统配置如下:
- **最低配置**
- 2核CPU
- 2GB RAM
- 10GB 存储空间
- 该配置不适用于本地语音识别,需添加第三方识别 API
- **推荐配置**
- 2核CPU
- 4GB RAM
- 20GB+ 存储空间
- 该配置适用于本地语音识别,但可能需要选择较小的 Vosk 模型以减少内存占用。
- **使用大型语音模型**
- 4核CPU
- 8GB RAM
- 30GB+ 存储空间
- 该配置适用于本地语音识别,可使用较大的 Vosk 模型
## 故障排除
### 1. 容器启动失败
检查容器状态:
```bash
docker-compose ps
```
查看失败容器的日志:
```bash
docker-compose logs <service_name>
```
### 2. 数据库连接问题
确保MySQL容器健康检查通过:
```bash
docker-compose ps mysql
```
如果状态不是"healthy"检查MySQL日志:
```bash
docker-compose logs mysql
```
常见问题包括:
- 数据库初始化失败
- 权限问题
- 端口冲突
### 3. 重建容器
如果需要重新构建容器:
```bash
docker-compose build --no-cache
docker-compose up -d
```
### 4. WebSocket连接问题
如果ESP32设备无法连接到WebSocket服务请检查
1. 确保使用的是宿主机的实际IP地址而不是localhost
2. 确保8091端口已在防火墙中开放
3. 检查服务器日志中是否有连接尝试记录
```bash
docker-compose logs server
```
## 更新应用
要更新到最新版本:
```bash
# 拉取最新代码
git pull
# 重新构建并启动容器
docker-compose build
docker-compose up -d
```

111
docs/FIRMWARE-BUILD.md Normal file
View File

@@ -0,0 +1,111 @@
# 编译esp32固件
1. 下载`xiaozhi-esp32`
项目,按照这个教程配置项目环境[《Windows搭建 ESP IDF 5.3.2开发环境以及编译小智》](https://icnynnzcwou8.feishu.cn/wiki/JEYDwTTALi5s2zkGlFGcDiRknXf)
2. 打开`xiaozhi-esp32/main/Kconfig.projbuild`文件,找到`WEBSOCKET_URL``default`的内容,把`wss://api.tenclass.net`
改成你自己的地址,例如,我的接口地址是`ws://192.168.1.25:8091`,就把内容改成这个。
修改前:
```
config WEBSOCKET_URL
depends on CONNECTION_TYPE_WEBSOCKET
string "Websocket URL"
default "wss://api.tenclass.net/xiaozhi/v1/"
help
Communication with the server through websocket after wake up.
```
修改后(示例)
```
config WEBSOCKET_URL
depends on CONNECTION_TYPE_WEBSOCKET
string "Websocket URL"
default "ws://192.168.5.167:8091/ws/xiaozhi/v1/"
help
Communication with the server through websocket after wake up.
```
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
**你也可以修改ota地址以提供更多设备信息服务**修改ota地址后无法通过官方ota自动升级可选项
修改前:
```
config OTA_VERSION_URL
string "OTA Version URL"
default "https://api.tenclass.net/xiaozhi/ota/"
help
The application will access this URL to check for updates.
```
修改后(示例)
```
config OTA_VERSION_URL
string "OTA Version URL"
default "http://192.168.5.167:8091/api/device/ota"
help
The application will access this URL to check for updates.
```
3. 设置编译参数
```
# 终端命令行进入xiaozhi-esp32的根目录
cd xiaozhi-esp32
# 例如我使用的板子是esp32s3所以设置编译目标为esp32s3如果你的板子是其他型号请替换成对应的型号
idf.py set-target esp32s3
# 进入菜单配置
idf.py menuconfig
```
![图片](images/build_setting01.png)
进入菜单配置后,再进入`Xiaozhi Assistant`,将`CONNECTION_TYPE`设置为`Websocket`
回退到主菜单,再进入`Xiaozhi Assistant`,将`BOARD_TYPE`设置你板子的具体型号
保存退出,回到终端命令行。
![图片](images/build_setting02.png)
4. 编译固件
```
idf.py build
```
如果是vscode安装的idf可以使用`F1`或者`ctrl+shift+p`,输入idf然后直接选择进行编译
还可以直接进行烧录不用接下来的操作
<img src="./images/vscode_idf.png" width="500px"/>
5. 打包bin固件
```
cd scripts
python release.py
```
编译成功后,会在项目根目录下的`build`目录下生成固件文件`merged-binary.bin`
这个`merged-binary.bin`就是要烧录到硬件上的固件文件。
注意如果执行到第二命令后报了“zip”相关的错误请忽略这个错误只要`build`目录下生成固件文件`merged-binary.bin`
,对你没有太大影响,请继续。
6. 烧录固件
将esp32设备连接电脑使用chrome浏览器打开以下网址
```
https://espressif.github.io/esp-launchpad/
```
打开这个教程,[Flash工具/Web端烧录固件无IDF开发环境](https://ccnphfhqs21z.feishu.cn/wiki/Zpz4wXBtdimBrLk25WdcXzxcnNS)。
翻到:`方式二ESP-Launchpad 浏览器WEB端烧录`,从`3. 烧录固件/下载到开发板`开始,按照教程操作。
烧录成功且联网成功后通过唤醒词唤醒小智留意server端输出的控制台信息。

167
docs/WINDOWS_DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,167 @@
# Windows 部署小智ESP32服务器的详细步骤
## 系统要求确认
- 确保您的Windows系统满足以下要求
- Windows 10或更高版本建议使用最新版本
- 管理员权限
## 1. 安装Java JDK 8
1. 访问Oracle官网下载JDK 8[Oracle JDK 8下载](https://www.oracle.com/java/technologies/javase/javase8-archive-downloads.html)
备用下载地址:[CSDN下载](https://download.csdn.net/download/weixin_55629186/89045298)
- 选择"Windows x64"版本下载(如`jdk-8u381-windows-x64.exe`
2. 运行安装程序,按向导完成安装
3. 配置环境变量:
- 右键"此电脑" → 属性 → 高级系统设置 → 环境变量
- 在"系统变量"中新建:
- 变量名:`JAVA_HOME`
- 变量值:`C:\Program Files\Java\jdk1.8.0_381`(具体路径取决于您的安装版本)
- 编辑"Path"变量,添加:`%JAVA_HOME%\bin`
4. 验证安装:
- 打开命令提示符Win+R输入`cmd`
- 输入:`java -version`
- 应显示类似:`java version "1.8.0_381"`
## 2. 安装MySQL数据库
1. 下载MySQL社区版[MySQL下载](https://dev.mysql.com/downloads/installer/)
2. 运行安装程序,选择"Custom"安装
3. 选择安装:
- MySQL Server
- MySQL Workbench可选图形界面工具
4. 在配置步骤:
- 设置root密码建议使用复杂密码
- 记住您设置的密码
5. 完成安装后启动MySQL服务
6. 配置变量
- 找到MySQL的安装路径默认路径通常是`C:\Program Files\MySQL\MySQL Server 5.7\bin`
- 如果不确定,可以在文件资源管理器中搜索 `mysql.exe` 的路径。
7. 添加到环境变量
- 右键 此电脑 → 属性 → 高级系统设置 → 环境变量。
- 系统变量 中找到 Path点击 编辑  新建粘贴MySQL的bin路径如上述路径
- 保存后关闭所有窗口。
8. 验证是否生效
- 重新打开命令提示符CMD输入`mysql --version`
- 如果显示版本信息(如 `mysql Ver 14.14 Distrib 5.7.43`),则配置成功。
## 3. 安装Maven
1. 下载Maven[Maven下载](https://maven.apache.org/download.cgi)
- 选择"Binary zip archive"下载
2. 解压到`C:\Program Files\apache-maven-3.9.4`(版本号可能不同)
3. 配置环境变量:
- 新建系统变量:
- 变量名:`MAVEN_HOME`
- 变量值:`C:\Program Files\apache-maven-3.9.4`
- 编辑"Path"变量,添加:`%MAVEN_HOME%\bin`
4. 验证安装:
- 命令提示符输入:`mvn -v`
- 应显示Maven版本信息
## 4. 安装Node.js和npm
1. 下载Node.js LTS版本[Node.js下载](https://nodejs.org/)
2. 运行安装程序,按默认选项安装
3. 安装完成后验证:
- 命令提示符输入:
- `node -v`
- `npm -v`
- 应显示版本信息
## 5. 安装FFmpeg必需
1. 访问FFmpeg官网[FFmpeg下载](https://ffmpeg.org/download.html)
2. 选择"Windows builds from gyan.dev"链接
3. 下载最新完整版(如`ffmpeg-git-full.7z`
4. 解压到`C:\Program Files\ffmpeg`(可以自定义路径)
5. 配置环境变量:
- 编辑"Path"变量,添加:`C:\Program Files\ffmpeg\bin`
6. 验证安装:
- 命令提示符输入:`ffmpeg -version`
- 应显示FFmpeg版本信息
## 6. 数据库配置详细Windows步骤
1. 打开命令提示符
2. 登录MySQL使用安装时设置的root密码
```bash
mysql -u root -p
```
3. 创建数据库:
```sql
CREATE DATABASE xiaozhi;
```
4. 创建用户并授权:
```sql
CREATE USER 'xiaozhi'@'localhost' IDENTIFIED BY '123456';
GRANT ALL PRIVILEGES ON xiaozhi.* TO 'xiaozhi'@'localhost';
FLUSH PRIVILEGES;
```
5. 初始化数据库:
- 确保您已经克隆了项目代码
- 在命令提示符中导航到项目目录下的`db`文件夹
- 执行:
```bash
mysql -u root -p xiaozhi < init.sql
```
## 7. Vosk语音识别模型安装Windows
1. 下载中文模型:[Vosk模型](https://alphacephei.com/vosk/models)
- 选择`vosk-model-cn-0.22`(或最新中文模型)
2. 解压下载的zip文件
3. 在项目根目录创建`models`文件夹(如果不存在)
4. 将解压后的模型文件夹重命名为`vosk-model`并放入`models`目录
5. 完整路径应为:`项目目录\models\vosk-model`
## 后端部署Windows
1. 克隆项目(如果尚未克隆):
```bash
git clone https://github.com/joey-zhou/xiaozhi-esp32-server-java
```
2. 进入项目目录:
```bash
cd xiaozhi-esp32-server-java
```
3. 使用Maven构建
```bash
mvn clean package -DskipTests
```
4. 运行后端服务:
```bash
java -jar target\xiaozhi.server-1.0.jar
```
## 前端部署Windows
1. 打开新的命令提示符窗口
2. 导航到前端目录:
```bash
cd xiaozhi-esp32-server-java\web
```
3. 安装依赖:
```bash
npm install
```
4. 运行开发服务器:
```bash
npm run dev
```
## 访问系统
1. 确保后端服务正在运行
2. 确保前端开发服务器正在运行
3. 打开浏览器访问:[http://localhost:8084](http://localhost:8084)
4. 使用默认凭据登录:
- 用户名:`admin`
- 密码:`123456`
## Windows常见问题解决
1. 端口冲突:
- 如果8084端口被占用可以
- 编辑`src\main\resources\application.properties`,修改`server.port`
- 编辑前端`web`目录下的配置文件相应修改API地址
2. FFmpeg找不到
- 确保已正确添加FFmpeg到PATH
- 重启命令提示符窗口后重试
3. MySQL连接问题
- 确保MySQL服务已启动可在服务管理器中检查
- 检查`application.properties`中的数据库配置
4. 缺少依赖:
- 如果构建失败,尝试:
```bash
mvn clean install
```
- 确保网络连接正常能访问Maven中央仓库

187
docs/deploy-springboot.md Normal file
View File

@@ -0,0 +1,187 @@
# 小智ESP32服务器部署脚本说明
本项目提供两个自动化部署脚本用于快速部署后端Java服务和前端Web应用。
## deploy-java.sh - 后端服务部署脚本
### 核心功能
自动化部署Spring Boot后端服务实现智能进程管理和无缝服务重启。
### 主要特性
- **智能JAR文件发现**: 自动在`target`目录中查找最新的JAR文件排除`.original`文件)
- **灵活进程匹配**: 使用应用名称前缀JAR文件名中"-"之前的部分)匹配进程,支持不同版本的应用
- **优雅停止机制**: 先尝试正常终止进程,如果失败则强制杀死进程
- **后台启动**: 使用`nohup`在后台启动服务,输出日志到`output.log`
- **启动状态检查**: 自动验证服务是否成功启动
### 使用方法
```bash
# 给脚本执行权限
chmod +x deploy-java.sh
# 执行部署
./deploy-java.sh
```
### 执行流程
1.`target`目录中查找JAR文件
2. 提取应用名称前缀用于进程匹配
3. 查找并停止正在运行的相关进程
4. 启动新的服务实例
5. 验证服务启动状态
### 前置条件
- 项目已通过`mvn clean package`构建完成
- `target`目录中存在可执行的JAR文件
---
## deploy-web.sh - 前端应用部署脚本
### 核心功能
自动化构建和部署Vue.js前端应用实现安全的生产环境更新。
### 主要特性
- **自动构建**: 执行`npm run build`构建生产版本
- **安全备份**: 部署前自动备份现有的生产文件到`/tmp/dist_backup`
- **无缝部署**: 将新构建的文件部署到`/var/www/html/dist`
- **Nginx重载**: 自动测试和重载Nginx配置确保服务连续性
### 使用方法
```bash
# 给脚本执行权限
chmod +x deploy-web.sh
# 执行部署
./deploy-web.sh
```
### 执行流程
1. 进入`web`目录
2. 执行`npm run build`构建前端应用
3. 备份现有的部署文件(如果存在)
4. 将新构建的文件移动到生产目录
5. 测试并重载Nginx配置
### 前置条件
- Node.js和npm已正确安装
- `web`目录中的依赖包已安装(`npm install`
- Nginx服务正在运行
- 具有`/var/www/html`目录的写入权限
---
## stop-java.sh - 后端服务停止脚本
### 核心功能
安全停止Spring Boot后端服务提供优雅的服务关闭机制。
### 主要特性
- **智能进程查找**: 自动查找target目录中的JAR文件或通过xiaozhi关键字匹配进程
- **优雅停止机制**: 先发送TERM信号优雅停止失败后强制杀死进程
- **PID文件清理**: 自动清理可能存在的PID文件
- **详细状态反馈**: 提供完整的停止过程信息
### 使用方法
```bash
# 停止后端服务
./stop-java.sh
```
### 执行流程
1. 查找target目录中的JAR文件
2. 根据JAR文件名或xiaozhi关键字查找运行中的进程
3. 优雅停止进程发送TERM信号
4. 如果优雅停止失败,强制杀死进程
5. 清理PID文件
---
## stop-web.sh - 前端服务停止脚本
### 核心功能
停止Web前端服务保持Nginx运行状态提供服务维护页面。
### 主要特性
- **安全备份**: 停止前自动备份现有的Web服务文件
- **维护页面**: 自动创建友好的维护页面
- **保持Nginx**: 不停止Nginx服务只清理Web应用文件
- **状态检查**: 检查Nginx运行状态并提供相应提示
### 使用方法
```bash
# 停止前端服务
./stop-web.sh
```
### 执行流程
1. 检查Web服务目录是否存在
2. 备份当前的Web服务文件到临时目录
3. 清理Web服务目录中的所有文件
4. 创建维护页面
5. 检查Nginx状态不停止Nginx
---
## 快速部署
### 完整部署流程
```bash
# 1. 构建后端项目
mvn clean package -DskipTests
# 2. 部署后端服务
./deploy-java.sh
# 3. 部署前端应用
./deploy-web.sh
```
### 服务管理
```bash
# 停止后端服务
./stop-java.sh
# 停止前端服务保持Nginx运行
./stop-web.sh
# 重新部署后端
./stop-java.sh && ./deploy-java.sh
# 重新部署前端
./stop-web.sh && ./deploy-web.sh
```
### 服务验证
```bash
# 检查后端服务状态
ps -ef | grep xiaozhi
# 检查前端部署
ls -la /var/www/html/dist
# 查看后端服务日志
tail -f output.log
# 检查Nginx状态
nginx -t && systemctl status nginx
```
### 故障排除
```bash
# 如果后端服务无法停止
ps -ef | grep java | grep xiaozhi
kill -9 <PID>
# 如果前端服务异常
ls -la /tmp/web_backup_* # 查看备份文件
cp -r /tmp/web_backup_<timestamp>/dist/* /var/www/html/dist/ # 恢复备份
# 检查端口占用
netstat -tlnp | grep :8091 # 后端端口
netstat -tlnp | grep :80 # 前端端口
```
---
*最后更新: 2024年12月*

BIN
docs/images/agent.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

BIN
docs/images/dashboard.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
docs/images/device.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

BIN
docs/images/login.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

BIN
docs/images/message.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

BIN
docs/images/model.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

BIN
docs/images/role.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

BIN
docs/images/user.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
docs/images/voiceClone.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

BIN
docs/images/vscode_idf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

82
docs/recruitment.md Normal file
View File

@@ -0,0 +1,82 @@
我们基于 [ ESP32] 硬件开发了一套完整的Java版本服务端系统为智能硬件设备提供强大的后端支持。
## 我们需要你!
### 🔧 后端开发工程师
**技术要求:**
- 熟练掌握Java开发Java 17+有Spring Boot项目经验
- 了解WebSocket、MQTT等实时通信协议
- 熟悉MySQL数据库有性能优化经验
- 了解Docker容器化部署
- 有AI/LLM集成经验者优先
- 有音视频处理经验者优先
**你将负责:**
- 开发和优化核心业务功能
- 集成各类AI服务和语音识别/合成服务
- 优化系统性能,处理高并发场景
- 参与新功能的设计和实现
### 🎨 前端开发工程师
**技术要求:**
- 熟练掌握Vue.js开发有完整项目经验
- 熟悉Ant Design Vue等UI组件库
- 了解WebSocket实时通信
- 有响应式设计和移动端适配经验
- 有数据可视化经验者优先
**你将负责:**
- 开发和优化管理后台界面
- 实现设备管理、用户管理等功能模块
- 优化用户体验,打造流畅的交互界面
- 参与新功能的UI/UX设计
### 🛠️ 全栈开发工程师
如果你是一个全能型选手,能够同时处理前后端开发,那就更好了!我们特别欢迎能够独当一面的全栈工程师加入。
### 🤖 IoT/嵌入式开发工程师
**技术要求:**
- 熟悉ESP32开发
- 了解MQTT、WebSocket等物联网通信协议
- 有音频处理经验
- 熟悉C/C++开发
**你将负责:**
- ESP32固件开发和优化
- 硬件与服务端的通信协议设计
- 音频采集和处理优化
## 项目技术栈详情
### 后端技术栈
```
- Java 21 (LTS)
- Spring Boot 3.3.0
- Spring WebSocket & MQTT
- MyBatis 3.0.3
- MySQL + Redis
- Docker & Docker Compose
```
### AI与语音技术
```
- Spring AI 1.0.0
- 语音识别Vosk、阿里云、腾讯云、讯飞
- 语音合成Edge TTS、阿里云、火山引擎
- LLM支持OpenAI、智谱AI、讯飞星火、Ollama
```
### 前端技术栈
```
- Vue.js 2.5.2
- Ant Design Vue 1.7.8
- WebSocket客户端
- ECharts数据可视化
```

BIN
lib/libonnxruntime.so Normal file

Binary file not shown.

BIN
lib/libvosk.dylib Normal file

Binary file not shown.

246
miniprogram/app.js Normal file
View File

@@ -0,0 +1,246 @@
// app.js
App({
globalData: {
userInfo: null,
token: '',
memberInfo: null,
systemInfo: null,
apiBaseUrl: 'https://api.xiaozhi.com', // 生产环境API地址
// apiBaseUrl: 'http://localhost:8091', // 开发环境API地址
version: '1.0.0'
},
onLaunch() {
console.log('小智AI会员系统启动')
// 初始化系统信息
this.initSystemInfo()
// 检查登录状态
this.checkLoginStatus()
// 检查更新
this.checkForUpdate()
},
onShow() {
console.log('小程序显示')
},
onHide() {
console.log('小程序隐藏')
},
onError(msg) {
console.error('小程序错误:', msg)
// 错误上报
this.reportError(msg)
},
// 初始化系统信息
initSystemInfo() {
try {
const systemInfo = wx.getSystemInfoSync()
this.globalData.systemInfo = systemInfo
console.log('系统信息:', systemInfo)
} catch (error) {
console.error('获取系统信息失败:', error)
}
},
// 检查登录状态
checkLoginStatus() {
const token = wx.getStorageSync('token')
const userInfo = wx.getStorageSync('userInfo')
if (token && userInfo) {
this.globalData.token = token
this.globalData.userInfo = userInfo
console.log('用户已登录:', userInfo.nickName)
// 验证token有效性
this.validateToken()
} else {
console.log('用户未登录')
}
},
// 验证token有效性
async validateToken() {
try {
const api = require('./utils/api.js')
const res = await api.getMemberInfo()
if (res.code === 200) {
this.globalData.memberInfo = res.data
console.log('Token有效会员信息:', res.data)
} else {
console.log('Token无效清除登录状态')
this.clearLoginStatus()
}
} catch (error) {
console.error('验证Token失败:', error)
this.clearLoginStatus()
}
},
// 清除登录状态
clearLoginStatus() {
this.globalData.token = ''
this.globalData.userInfo = null
this.globalData.memberInfo = null
wx.removeStorageSync('token')
wx.removeStorageSync('userInfo')
wx.removeStorageSync('memberInfo')
},
// 检查小程序更新
checkForUpdate() {
if (wx.canIUse('getUpdateManager')) {
const updateManager = wx.getUpdateManager()
updateManager.onCheckForUpdate((res) => {
console.log('检查更新结果:', res.hasUpdate)
})
updateManager.onUpdateReady(() => {
wx.showModal({
title: '更新提示',
content: '新版本已经准备好,是否重启应用?',
success: (res) => {
if (res.confirm) {
updateManager.applyUpdate()
}
}
})
})
updateManager.onUpdateFailed(() => {
wx.showToast({
title: '更新失败',
icon: 'error'
})
})
}
},
// 错误上报
reportError(error) {
// TODO: 实现错误上报逻辑
console.log('上报错误:', error)
},
// 获取用户信息
getUserInfo() {
return this.globalData.userInfo
},
// 设置用户信息
setUserInfo(userInfo) {
this.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
},
// 获取会员信息
getMemberInfo() {
return this.globalData.memberInfo
},
// 设置会员信息
setMemberInfo(memberInfo) {
this.globalData.memberInfo = memberInfo
wx.setStorageSync('memberInfo', memberInfo)
},
// 获取Token
getToken() {
return this.globalData.token
},
// 设置Token
setToken(token) {
this.globalData.token = token
wx.setStorageSync('token', token)
},
// 检查会员权限
checkMemberPermission(benefitCode) {
const memberInfo = this.globalData.memberInfo
if (!memberInfo) {
return false
}
// 检查会员状态
if (memberInfo.memberStatus !== 'ACTIVE') {
return false
}
// 检查是否过期
if (memberInfo.endTime) {
const endTime = new Date(memberInfo.endTime)
const now = new Date()
if (endTime < now) {
return false
}
}
// 检查权益
const benefits = memberInfo.benefits || []
return benefits.some(benefit =>
benefit.benefitCode === benefitCode && benefit.isEnabled
)
},
// 格式化会员等级文本
formatMemberLevel(level) {
const levelMap = {
'FREE': '免费用户',
'VIP': 'VIP会员',
'SVIP': 'SVIP会员'
}
return levelMap[level] || '未知等级'
},
// 格式化会员状态文本
formatMemberStatus(status) {
const statusMap = {
'ACTIVE': '有效',
'EXPIRED': '已过期',
'SUSPENDED': '已暂停',
'CANCELLED': '已取消'
}
return statusMap[status] || '未知状态'
},
// 计算会员剩余天数
calculateRemainingDays(endTime) {
if (!endTime) {
return null // 永久有效
}
const end = new Date(endTime)
const now = new Date()
const diffTime = end - now
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return diffDays > 0 ? diffDays : 0
},
// 显示会员升级提示
showUpgradePrompt(benefitName) {
wx.showModal({
title: '权限不足',
content: `${benefitName}需要VIP会员权限是否立即升级`,
confirmText: '立即升级',
cancelText: '稍后再说',
success: (res) => {
if (res.confirm) {
wx.navigateTo({
url: '/pages/member/upgrade/upgrade'
})
}
}
})
}
})

92
miniprogram/app.json Normal file
View File

@@ -0,0 +1,92 @@
{
"pages": [
"pages/index/index",
"pages/member/bind/bind",
"pages/member/center/center",
"pages/member/benefits/benefits",
"pages/member/upgrade/upgrade",
"pages/member/history/history",
"pages/login/login",
"pages/profile/profile"
],
"window": {
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#2C3E50",
"navigationBarTitleText": "小智AI会员",
"navigationBarTextStyle": "white",
"backgroundColor": "#F8F9FA",
"enablePullDownRefresh": true,
"onReachBottomDistance": 50
},
"tabBar": {
"color": "#7A7E83",
"selectedColor": "#3CC51F",
"borderStyle": "black",
"backgroundColor": "#FFFFFF",
"list": [
{
"pagePath": "pages/index/index",
"iconPath": "images/tab/home.png",
"selectedIconPath": "images/tab/home-active.png",
"text": "首页"
},
{
"pagePath": "pages/member/center/center",
"iconPath": "images/tab/member.png",
"selectedIconPath": "images/tab/member-active.png",
"text": "会员中心"
},
{
"pagePath": "pages/profile/profile",
"iconPath": "images/tab/profile.png",
"selectedIconPath": "images/tab/profile-active.png",
"text": "我的"
}
]
},
"networkTimeout": {
"request": 10000,
"downloadFile": 10000
},
"debug": false,
"navigateToMiniProgramAppIdList": [],
"permission": {
"scope.userInfo": {
"desc": "用于获取用户基本信息"
}
},
"requiredBackgroundModes": [],
"plugins": {},
"preloadRule": {
"pages/member/center/center": {
"network": "all",
"packages": ["member"]
}
},
"subpackages": [
{
"root": "member",
"name": "member",
"pages": [
"pages/bind/bind",
"pages/center/center",
"pages/benefits/benefits",
"pages/upgrade/upgrade",
"pages/history/history"
]
}
],
"lazyCodeLoading": "requiredComponents",
"sitemapLocation": "sitemap.json",
"style": "v2",
"componentFramework": "glass-easel",
"renderer": "webview",
"rendererOptions": {
"skyline": {
"defaultDisplayBlock": true,
"disableABTest": true
}
},
"darkmode": false,
"themeLocation": "theme.json"
}

482
miniprogram/app.wxss Normal file
View File

@@ -0,0 +1,482 @@
/**app.wxss**/
/* 全局样式 */
/* 重置样式 */
page {
background-color: #F8F9FA;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 28rpx;
line-height: 1.6;
color: #333333;
}
/* 容器样式 */
.container {
padding: 20rpx;
min-height: 100vh;
box-sizing: border-box;
}
.container-padding {
padding: 40rpx 30rpx;
}
/* 卡片样式 */
.card {
background: #FFFFFF;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
margin-bottom: 20rpx;
overflow: hidden;
}
.card-header {
padding: 30rpx;
border-bottom: 1rpx solid #F0F0F0;
}
.card-body {
padding: 30rpx;
}
.card-footer {
padding: 20rpx 30rpx;
background: #FAFAFA;
border-top: 1rpx solid #F0F0F0;
}
/* 按钮样式 */
.btn {
display: flex;
align-items: center;
justify-content: center;
padding: 24rpx 40rpx;
border-radius: 12rpx;
font-size: 32rpx;
font-weight: 500;
border: none;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.btn::after {
border: none;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #FFFFFF;
}
.btn-primary:active {
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
}
.btn-success {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
color: #FFFFFF;
}
.btn-warning {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: #FFFFFF;
}
.btn-secondary {
background: #F8F9FA;
color: #6C757D;
border: 1rpx solid #DEE2E6;
}
.btn-outline {
background: transparent;
border: 2rpx solid #667eea;
color: #667eea;
}
.btn-disabled {
background: #E9ECEF !important;
color: #ADB5BD !important;
opacity: 0.6;
}
.btn-large {
padding: 32rpx 60rpx;
font-size: 36rpx;
}
.btn-small {
padding: 16rpx 24rpx;
font-size: 24rpx;
}
.btn-block {
width: 100%;
margin: 20rpx 0;
}
/* 表单样式 */
.form-group {
margin-bottom: 30rpx;
}
.form-label {
display: block;
margin-bottom: 12rpx;
font-size: 28rpx;
color: #495057;
font-weight: 500;
}
.form-input {
width: 100%;
padding: 24rpx 20rpx;
border: 2rpx solid #E9ECEF;
border-radius: 12rpx;
font-size: 30rpx;
background: #FFFFFF;
box-sizing: border-box;
transition: border-color 0.3s ease;
}
.form-input:focus {
border-color: #667eea;
outline: none;
}
.form-input-error {
border-color: #DC3545;
}
.form-error {
color: #DC3545;
font-size: 24rpx;
margin-top: 8rpx;
}
/* 文本样式 */
.text-primary { color: #667eea; }
.text-success { color: #28a745; }
.text-warning { color: #ffc107; }
.text-danger { color: #dc3545; }
.text-info { color: #17a2b8; }
.text-muted { color: #6c757d; }
.text-white { color: #ffffff; }
.text-xs { font-size: 20rpx; }
.text-sm { font-size: 24rpx; }
.text-base { font-size: 28rpx; }
.text-lg { font-size: 32rpx; }
.text-xl { font-size: 36rpx; }
.text-2xl { font-size: 40rpx; }
.text-3xl { font-size: 48rpx; }
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.font-bold { font-weight: bold; }
.font-medium { font-weight: 500; }
.font-normal { font-weight: normal; }
/* 布局样式 */
.flex {
display: flex;
}
.flex-column {
flex-direction: column;
}
.flex-row {
flex-direction: row;
}
.flex-center {
align-items: center;
justify-content: center;
}
.flex-between {
justify-content: space-between;
}
.flex-around {
justify-content: space-around;
}
.flex-start {
justify-content: flex-start;
}
.flex-end {
justify-content: flex-end;
}
.align-center {
align-items: center;
}
.align-start {
align-items: flex-start;
}
.align-end {
align-items: flex-end;
}
.flex-1 {
flex: 1;
}
.flex-wrap {
flex-wrap: wrap;
}
/* 间距样式 */
.m-0 { margin: 0; }
.m-1 { margin: 10rpx; }
.m-2 { margin: 20rpx; }
.m-3 { margin: 30rpx; }
.m-4 { margin: 40rpx; }
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: 10rpx; }
.mt-2 { margin-top: 20rpx; }
.mt-3 { margin-top: 30rpx; }
.mt-4 { margin-top: 40rpx; }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: 10rpx; }
.mb-2 { margin-bottom: 20rpx; }
.mb-3 { margin-bottom: 30rpx; }
.mb-4 { margin-bottom: 40rpx; }
.ml-0 { margin-left: 0; }
.ml-1 { margin-left: 10rpx; }
.ml-2 { margin-left: 20rpx; }
.ml-3 { margin-left: 30rpx; }
.ml-4 { margin-left: 40rpx; }
.mr-0 { margin-right: 0; }
.mr-1 { margin-right: 10rpx; }
.mr-2 { margin-right: 20rpx; }
.mr-3 { margin-right: 30rpx; }
.mr-4 { margin-right: 40rpx; }
.p-0 { padding: 0; }
.p-1 { padding: 10rpx; }
.p-2 { padding: 20rpx; }
.p-3 { padding: 30rpx; }
.p-4 { padding: 40rpx; }
.pt-0 { padding-top: 0; }
.pt-1 { padding-top: 10rpx; }
.pt-2 { padding-top: 20rpx; }
.pt-3 { padding-top: 30rpx; }
.pt-4 { padding-top: 40rpx; }
.pb-0 { padding-bottom: 0; }
.pb-1 { padding-bottom: 10rpx; }
.pb-2 { padding-bottom: 20rpx; }
.pb-3 { padding-bottom: 30rpx; }
.pb-4 { padding-bottom: 40rpx; }
.pl-0 { padding-left: 0; }
.pl-1 { padding-left: 10rpx; }
.pl-2 { padding-left: 20rpx; }
.pl-3 { padding-left: 30rpx; }
.pl-4 { padding-left: 40rpx; }
.pr-0 { padding-right: 0; }
.pr-1 { padding-right: 10rpx; }
.pr-2 { padding-right: 20rpx; }
.pr-3 { padding-right: 30rpx; }
.pr-4 { padding-right: 40rpx; }
/* 会员等级样式 */
.member-free {
color: #6C757D;
}
.member-vip {
color: #FFD700;
}
.member-svip {
color: #FF6B35;
}
.member-badge {
display: inline-flex;
align-items: center;
padding: 8rpx 16rpx;
border-radius: 20rpx;
font-size: 20rpx;
font-weight: bold;
text-transform: uppercase;
}
.member-badge.free {
background: #F8F9FA;
color: #6C757D;
}
.member-badge.vip {
background: linear-gradient(135deg, #FFD700, #FFA500);
color: #FFFFFF;
}
.member-badge.svip {
background: linear-gradient(135deg, #FF6B35, #F7931E);
color: #FFFFFF;
}
/* 状态样式 */
.status-active {
color: #28A745;
}
.status-expired {
color: #DC3545;
}
.status-suspended {
color: #FFC107;
}
/* 加载样式 */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
color: #6C757D;
}
.loading-spinner {
width: 40rpx;
height: 40rpx;
border: 4rpx solid #F3F3F3;
border-top: 4rpx solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 20rpx;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 空状态样式 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 40rpx;
color: #6C757D;
}
.empty-icon {
width: 120rpx;
height: 120rpx;
margin-bottom: 20rpx;
opacity: 0.5;
}
.empty-text {
font-size: 28rpx;
margin-bottom: 10rpx;
}
.empty-desc {
font-size: 24rpx;
color: #ADB5BD;
}
/* 分割线 */
.divider {
height: 1rpx;
background: #E9ECEF;
margin: 20rpx 0;
}
.divider-thick {
height: 20rpx;
background: #F8F9FA;
margin: 30rpx -30rpx;
}
/* 阴影效果 */
.shadow-sm {
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.shadow {
box-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.12);
}
.shadow-lg {
box-shadow: 0 8rpx 16rpx rgba(0, 0, 0, 0.15);
}
/* 圆角 */
.rounded-sm { border-radius: 4rpx; }
.rounded { border-radius: 8rpx; }
.rounded-lg { border-radius: 12rpx; }
.rounded-xl { border-radius: 16rpx; }
.rounded-full { border-radius: 50%; }
/* 背景色 */
.bg-white { background-color: #FFFFFF; }
.bg-gray { background-color: #F8F9FA; }
.bg-primary { background-color: #667eea; }
.bg-success { background-color: #28a745; }
.bg-warning { background-color: #ffc107; }
.bg-danger { background-color: #dc3545; }
/* 边框 */
.border { border: 1rpx solid #E9ECEF; }
.border-top { border-top: 1rpx solid #E9ECEF; }
.border-bottom { border-bottom: 1rpx solid #E9ECEF; }
.border-left { border-left: 1rpx solid #E9ECEF; }
.border-right { border-right: 1rpx solid #E9ECEF; }
/* 隐藏/显示 */
.hidden { display: none !important; }
.visible { display: block !important; }
/* 透明度 */
.opacity-0 { opacity: 0; }
.opacity-25 { opacity: 0.25; }
.opacity-50 { opacity: 0.5; }
.opacity-75 { opacity: 0.75; }
.opacity-100 { opacity: 1; }
/* 动画 */
.fade-in {
animation: fadeIn 0.3s ease-in;
}
.slide-up {
animation: slideUp 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,481 @@
// pages/index/index.js
const app = getApp();
const { memberAPI } = require('../../utils/api');
const {
formatRelativeTime,
getMemberLevelText,
calculateMemberExpire,
showError,
showSuccess,
debounce
} = require('../../utils/util');
Page({
/**
* 页面的初始数据
*/
data: {
// 用户信息
userInfo: {},
isLoggedIn: false,
// 会员信息
memberInfo: {},
// 快捷权益
quickBenefits: [],
// 功能菜单
menuItems: [
{
id: 'member-center',
name: '会员中心',
icon: '/images/menu/member.png',
path: '/pages/member/center/center'
},
{
id: 'member-bind',
name: '绑定会员',
icon: '/images/menu/bind.png',
path: '/pages/member/bind/bind'
},
{
id: 'benefits',
name: '权益说明',
icon: '/images/menu/benefits.png',
path: '/pages/member/benefits/benefits'
},
{
id: 'upgrade',
name: '升级会员',
icon: '/images/menu/upgrade.png',
path: '/pages/member/upgrade/upgrade'
},
{
id: 'history',
name: '使用记录',
icon: '/images/menu/history.png',
path: '/pages/member/history/history'
},
{
id: 'invite',
name: '邀请好友',
icon: '/images/menu/invite.png',
path: '/pages/member/invite/invite'
},
{
id: 'support',
name: '客服支持',
icon: '/images/menu/support.png',
path: '/pages/support/support'
},
{
id: 'settings',
name: '设置',
icon: '/images/menu/settings.png',
path: '/pages/settings/settings'
}
],
// 最近活动
recentActivities: [],
// 升级提示
showUpgradePrompt: false,
// 权益弹窗
showBenefitModal: false,
selectedBenefit: {},
// 加载状态
loading: false,
refreshing: false
},
/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {
this.initPage();
},
/**
* 生命周期函数--监听页面显示
*/
onShow() {
this.refreshUserInfo();
this.refreshMemberInfo();
},
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {
this.refreshPage();
},
/**
* 页面上拉触底事件的处理函数
*/
onReachBottom() {
// 可以在这里加载更多数据
},
/**
* 用户点击右上角分享
*/
onShareAppMessage() {
return {
title: '小智AI会员 - 享受专属特权',
path: '/pages/index/index',
imageUrl: '/images/share-cover.png'
};
},
/**
* 初始化页面
*/
initPage() {
this.setData({ loading: true });
// 检查登录状态
this.checkLoginStatus();
// 初始化菜单徽章
this.updateMenuBadges();
this.setData({ loading: false });
},
/**
* 检查登录状态
*/
checkLoginStatus() {
const isLoggedIn = app.checkLoginStatus();
const userInfo = app.globalData.userInfo || {};
this.setData({
isLoggedIn,
userInfo
});
},
/**
* 刷新页面数据
*/
refreshPage: debounce(function() {
this.setData({ refreshing: true });
Promise.all([
this.refreshUserInfo(),
this.refreshMemberInfo(),
this.loadRecentActivities()
]).finally(() => {
this.setData({ refreshing: false });
wx.stopPullDownRefresh();
});
}, 1000),
/**
* 刷新用户信息
*/
async refreshUserInfo() {
if (!this.data.isLoggedIn) return;
try {
const result = await memberAPI.getUserInfo();
if (result.code === 0) {
const userInfo = result.data;
app.globalData.userInfo = userInfo;
this.setData({ userInfo });
}
} catch (error) {
console.error('获取用户信息失败:', error);
}
},
/**
* 刷新会员信息
*/
async refreshMemberInfo() {
if (!this.data.isLoggedIn) return;
try {
const result = await memberAPI.getMemberInfo();
if (result.code === 0) {
const memberData = result.data;
const memberInfo = this.processMemberInfo(memberData);
app.globalData.memberInfo = memberInfo;
this.setData({ memberInfo });
// 加载会员权益
this.loadMemberBenefits();
// 检查是否显示升级提示
this.checkUpgradePrompt(memberInfo);
}
} catch (error) {
console.error('获取会员信息失败:', error);
}
},
/**
* 处理会员信息
*/
processMemberInfo(memberData) {
const { level, expireTime } = memberData;
const levelText = getMemberLevelText(level);
const expireInfo = calculateMemberExpire(expireTime);
return {
...memberData,
levelText,
expireInfo
};
},
/**
* 加载会员权益
*/
async loadMemberBenefits() {
try {
const result = await memberAPI.getMemberBenefits();
if (result.code === 0) {
const benefits = result.data || [];
// 只显示前4个权益作为快捷入口
const quickBenefits = benefits.slice(0, 4).map(benefit => ({
...benefit,
icon: benefit.icon || '/images/benefits/default.png'
}));
this.setData({ quickBenefits });
}
} catch (error) {
console.error('获取会员权益失败:', error);
}
},
/**
* 加载最近活动
*/
async loadRecentActivities() {
if (!this.data.isLoggedIn) return;
try {
const result = await memberAPI.getBenefitUsage(1, 5);
if (result.code === 0) {
const activities = (result.data.list || []).map(activity => ({
...activity,
timeText: formatRelativeTime(activity.createTime),
statusClass: this.getActivityStatusClass(activity.status),
statusText: this.getActivityStatusText(activity.status),
icon: activity.icon || '/images/activity/default.png'
}));
this.setData({ recentActivities: activities });
}
} catch (error) {
console.error('获取最近活动失败:', error);
}
},
/**
* 获取活动状态样式类
*/
getActivityStatusClass(status) {
const classMap = {
'success': 'text-success',
'failed': 'text-danger',
'pending': 'text-warning'
};
return classMap[status] || 'text-muted';
},
/**
* 获取活动状态文本
*/
getActivityStatusText(status) {
const textMap = {
'success': '成功',
'failed': '失败',
'pending': '处理中'
};
return textMap[status] || '未知';
},
/**
* 检查升级提示
*/
checkUpgradePrompt(memberInfo) {
const { level, expireInfo } = memberInfo;
// 普通用户或会员即将过期时显示升级提示
const showPrompt = level === 'free' ||
(expireInfo && !expireInfo.isExpired && expireInfo.daysLeft <= 7);
this.setData({ showUpgradePrompt: showPrompt });
},
/**
* 更新菜单徽章
*/
updateMenuBadges() {
const menuItems = this.data.menuItems.map(item => {
// 这里可以根据实际需求添加徽章逻辑
if (item.id === 'member-bind' && !this.data.isLoggedIn) {
item.badge = 'NEW';
}
return item;
});
this.setData({ menuItems });
},
/**
* 处理登录
*/
handleLogin() {
wx.navigateTo({
url: '/pages/login/login'
});
},
/**
* 处理菜单点击
*/
handleMenuClick(e) {
const { menu } = e.currentTarget.dataset;
// 检查是否需要登录
if (!this.data.isLoggedIn && this.needLogin(menu.id)) {
this.handleLogin();
return;
}
// 特殊处理
switch (menu.id) {
case 'support':
this.handleCustomerService();
break;
default:
wx.navigateTo({
url: menu.path
});
}
},
/**
* 检查功能是否需要登录
*/
needLogin(menuId) {
const loginRequiredMenus = [
'member-center', 'member-bind', 'upgrade',
'history', 'invite'
];
return loginRequiredMenus.includes(menuId);
},
/**
* 处理客服
*/
handleCustomerService() {
wx.makePhoneCall({
phoneNumber: '400-123-4567',
fail: () => {
showError('拨打客服电话失败');
}
});
},
/**
* 处理权益点击
*/
handleBenefitClick(e) {
const { benefit } = e.currentTarget.dataset;
this.setData({
selectedBenefit: benefit,
showBenefitModal: true
});
},
/**
* 关闭权益弹窗
*/
closeBenefitModal() {
this.setData({
showBenefitModal: false,
selectedBenefit: {}
});
},
/**
* 阻止事件冒泡
*/
stopPropagation() {
// 阻止事件冒泡
},
/**
* 使用权益
*/
async useBenefit() {
const { selectedBenefit } = this.data;
try {
const result = await memberAPI.verifyBenefit(selectedBenefit.type);
if (result.code === 0) {
showSuccess('权益使用成功');
this.closeBenefitModal();
this.loadMemberBenefits(); // 刷新权益信息
} else {
showError(result.message || '权益使用失败');
}
} catch (error) {
console.error('使用权益失败:', error);
showError('权益使用失败');
}
},
/**
* 导航到会员中心
*/
navigateToCenter() {
if (!this.data.isLoggedIn) {
this.handleLogin();
return;
}
wx.switchTab({
url: '/pages/member/center/center'
});
},
/**
* 导航到升级页面
*/
navigateToUpgrade() {
if (!this.data.isLoggedIn) {
this.handleLogin();
return;
}
wx.navigateTo({
url: '/pages/member/upgrade/upgrade'
});
},
/**
* 导航到历史记录
*/
navigateToHistory() {
if (!this.data.isLoggedIn) {
this.handleLogin();
return;
}
wx.navigateTo({
url: '/pages/member/history/history'
});
}
});

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "小智AI会员",
"enablePullDownRefresh": true,
"onReachBottomDistance": 50,
"backgroundTextStyle": "dark"
}

View File

@@ -0,0 +1,152 @@
<!--pages/index/index.wxml-->
<view class="container">
<!-- 顶部用户信息卡片 -->
<view class="user-card card">
<view class="user-info flex align-center">
<image class="avatar" src="{{userInfo.avatarUrl || '/images/default-avatar.png'}}" mode="aspectFill"></image>
<view class="user-details flex-1 ml-3">
<view class="username text-lg font-bold">{{userInfo.nickName || '未登录'}}</view>
<view class="member-status flex align-center mt-1">
<view class="member-badge {{memberInfo.level || 'free'}}">
{{memberInfo.levelText || '普通用户'}}
</view>
<view class="expire-info text-sm text-muted ml-2" wx:if="{{memberInfo.expireInfo}}">
{{memberInfo.expireInfo.text}}
</view>
</view>
</view>
<view class="login-btn" wx:if="{{!isLoggedIn}}" bindtap="handleLogin">
<text class="btn btn-primary btn-small">登录</text>
</view>
</view>
</view>
<!-- 会员权益快捷入口 -->
<view class="benefits-card card" wx:if="{{isLoggedIn}}">
<view class="card-header">
<view class="flex align-center justify-between">
<text class="text-lg font-bold">会员权益</text>
<text class="text-primary text-sm" bindtap="navigateToCenter">查看全部</text>
</view>
</view>
<view class="card-body">
<view class="benefits-grid">
<view class="benefit-item" wx:for="{{quickBenefits}}" wx:key="id" bindtap="handleBenefitClick" data-benefit="{{item}}">
<view class="benefit-icon">
<image src="{{item.icon}}" mode="aspectFit"></image>
</view>
<view class="benefit-name text-sm">{{item.name}}</view>
<view class="benefit-status text-xs" wx:if="{{item.available}}">
<text class="text-success">可用</text>
</view>
<view class="benefit-status text-xs" wx:else>
<text class="text-muted">不可用</text>
</view>
</view>
</view>
</view>
</view>
<!-- 功能菜单 -->
<view class="menu-card card">
<view class="card-header">
<text class="text-lg font-bold">功能菜单</text>
</view>
<view class="card-body">
<view class="menu-grid">
<view class="menu-item" wx:for="{{menuItems}}" wx:key="id" bindtap="handleMenuClick" data-menu="{{item}}">
<view class="menu-icon">
<image src="{{item.icon}}" mode="aspectFit"></image>
</view>
<view class="menu-name text-sm">{{item.name}}</view>
<view class="menu-badge" wx:if="{{item.badge}}">
<text class="text-xs">{{item.badge}}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 会员升级提示 -->
<view class="upgrade-card card" wx:if="{{showUpgradePrompt}}">
<view class="upgrade-content">
<view class="upgrade-icon">
<image src="/images/upgrade-icon.png" mode="aspectFit"></image>
</view>
<view class="upgrade-text">
<view class="upgrade-title text-lg font-bold">升级会员,享受更多特权</view>
<view class="upgrade-desc text-sm text-muted mt-1">
解锁专属功能,提升使用体验
</view>
</view>
<view class="upgrade-action">
<text class="btn btn-primary btn-small" bindtap="navigateToUpgrade">立即升级</text>
</view>
</view>
</view>
<!-- 最近活动 -->
<view class="activity-card card" wx:if="{{recentActivities.length > 0}}">
<view class="card-header">
<view class="flex align-center justify-between">
<text class="text-lg font-bold">最近活动</text>
<text class="text-primary text-sm" bindtap="navigateToHistory">查看更多</text>
</view>
</view>
<view class="card-body">
<view class="activity-list">
<view class="activity-item" wx:for="{{recentActivities}}" wx:key="id">
<view class="activity-icon">
<image src="{{item.icon}}" mode="aspectFit"></image>
</view>
<view class="activity-content flex-1">
<view class="activity-title text-sm">{{item.title}}</view>
<view class="activity-time text-xs text-muted">{{item.timeText}}</view>
</view>
<view class="activity-status">
<text class="text-xs {{item.statusClass}}">{{item.statusText}}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 空状态提示 -->
<view class="empty-state" wx:if="{{!isLoggedIn}}">
<image class="empty-icon" src="/images/empty-login.png" mode="aspectFit"></image>
<view class="empty-text">登录后查看更多功能</view>
<view class="empty-desc">享受专属会员权益和服务</view>
<text class="btn btn-primary mt-3" bindtap="handleLogin">立即登录</text>
</view>
</view>
<!-- 权益详情弹窗 -->
<view class="benefit-modal" wx:if="{{showBenefitModal}}" bindtap="closeBenefitModal">
<view class="modal-content" catchtap="stopPropagation">
<view class="modal-header">
<text class="modal-title">{{selectedBenefit.name}}</text>
<view class="modal-close" bindtap="closeBenefitModal">
<image src="/images/close-icon.png" mode="aspectFit"></image>
</view>
</view>
<view class="modal-body">
<view class="benefit-detail">
<image class="benefit-large-icon" src="{{selectedBenefit.icon}}" mode="aspectFit"></image>
<view class="benefit-description">{{selectedBenefit.description}}</view>
<view class="benefit-usage" wx:if="{{selectedBenefit.usage}}">
<text class="text-sm text-muted">使用次数:{{selectedBenefit.usage.used}}/{{selectedBenefit.usage.total}}</text>
</view>
</view>
</view>
<view class="modal-footer">
<text class="btn btn-secondary" bindtap="closeBenefitModal">关闭</text>
<text class="btn btn-primary ml-2" wx:if="{{selectedBenefit.available}}" bindtap="useBenefit">立即使用</text>
</view>
</view>
</view>
<!-- 加载状态 -->
<view class="loading" wx:if="{{loading}}">
<view class="loading-spinner"></view>
<text>加载中...</text>
</view>

View File

@@ -0,0 +1,428 @@
/* pages/index/index.wxss */
.container {
padding: 20rpx;
background: #F8F9FA;
min-height: 100vh;
}
/* 用户信息卡片 */
.user-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #FFFFFF;
margin-bottom: 30rpx;
}
.user-info {
padding: 40rpx 30rpx;
}
.avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
border: 4rpx solid rgba(255, 255, 255, 0.3);
}
.user-details {
margin-left: 30rpx;
}
.username {
color: #FFFFFF;
margin-bottom: 10rpx;
}
.member-status {
align-items: center;
}
.member-badge {
padding: 8rpx 16rpx;
border-radius: 20rpx;
font-size: 20rpx;
font-weight: bold;
text-transform: uppercase;
}
.member-badge.free {
background: rgba(255, 255, 255, 0.2);
color: #FFFFFF;
}
.member-badge.vip {
background: linear-gradient(135deg, #FFD700, #FFA500);
color: #FFFFFF;
}
.member-badge.svip {
background: linear-gradient(135deg, #FF6B35, #F7931E);
color: #FFFFFF;
}
.expire-info {
color: rgba(255, 255, 255, 0.8);
}
.login-btn {
margin-left: 20rpx;
}
/* 会员权益卡片 */
.benefits-card {
margin-bottom: 30rpx;
}
.benefits-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 30rpx;
}
.benefit-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 20rpx 10rpx;
border-radius: 12rpx;
background: #F8F9FA;
transition: all 0.3s ease;
}
.benefit-item:active {
background: #E9ECEF;
transform: scale(0.95);
}
.benefit-icon {
width: 60rpx;
height: 60rpx;
margin-bottom: 12rpx;
}
.benefit-icon image {
width: 100%;
height: 100%;
}
.benefit-name {
margin-bottom: 8rpx;
color: #333333;
}
.benefit-status {
margin-top: 4rpx;
}
/* 功能菜单卡片 */
.menu-card {
margin-bottom: 30rpx;
}
.menu-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 30rpx;
}
.menu-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 30rpx 20rpx;
border-radius: 12rpx;
background: #FFFFFF;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
position: relative;
}
.menu-item:active {
background: #F8F9FA;
transform: scale(0.95);
}
.menu-icon {
width: 80rpx;
height: 80rpx;
margin-bottom: 16rpx;
}
.menu-icon image {
width: 100%;
height: 100%;
}
.menu-name {
color: #333333;
}
.menu-badge {
position: absolute;
top: 20rpx;
right: 20rpx;
background: #FF4757;
color: #FFFFFF;
padding: 4rpx 8rpx;
border-radius: 10rpx;
min-width: 32rpx;
text-align: center;
}
/* 升级提示卡片 */
.upgrade-card {
margin-bottom: 30rpx;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: #FFFFFF;
}
.upgrade-content {
display: flex;
align-items: center;
padding: 30rpx;
}
.upgrade-icon {
width: 80rpx;
height: 80rpx;
margin-right: 20rpx;
}
.upgrade-icon image {
width: 100%;
height: 100%;
}
.upgrade-text {
flex: 1;
}
.upgrade-title {
color: #FFFFFF;
margin-bottom: 8rpx;
}
.upgrade-desc {
color: rgba(255, 255, 255, 0.8);
}
.upgrade-action {
margin-left: 20rpx;
}
/* 最近活动卡片 */
.activity-card {
margin-bottom: 30rpx;
}
.activity-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.activity-item {
display: flex;
align-items: center;
padding: 20rpx;
background: #F8F9FA;
border-radius: 12rpx;
}
.activity-icon {
width: 60rpx;
height: 60rpx;
margin-right: 20rpx;
}
.activity-icon image {
width: 100%;
height: 100%;
}
.activity-content {
flex: 1;
}
.activity-title {
color: #333333;
margin-bottom: 8rpx;
}
.activity-time {
color: #6C757D;
}
.activity-status {
margin-left: 20rpx;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx;
text-align: center;
}
.empty-icon {
width: 200rpx;
height: 200rpx;
margin-bottom: 30rpx;
opacity: 0.5;
}
.empty-text {
font-size: 32rpx;
color: #333333;
margin-bottom: 16rpx;
}
.empty-desc {
font-size: 28rpx;
color: #6C757D;
margin-bottom: 40rpx;
}
/* 权益详情弹窗 */
.benefit-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #FFFFFF;
border-radius: 20rpx;
margin: 40rpx;
max-width: 600rpx;
width: 100%;
max-height: 80vh;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx;
border-bottom: 1rpx solid #E9ECEF;
}
.modal-title {
font-size: 32rpx;
font-weight: bold;
color: #333333;
}
.modal-close {
width: 40rpx;
height: 40rpx;
padding: 8rpx;
}
.modal-close image {
width: 100%;
height: 100%;
}
.modal-body {
padding: 30rpx;
max-height: 400rpx;
overflow-y: auto;
}
.benefit-detail {
text-align: center;
}
.benefit-large-icon {
width: 120rpx;
height: 120rpx;
margin-bottom: 20rpx;
}
.benefit-description {
font-size: 28rpx;
color: #333333;
line-height: 1.6;
margin-bottom: 20rpx;
}
.benefit-usage {
padding: 16rpx 20rpx;
background: #F8F9FA;
border-radius: 8rpx;
}
.modal-footer {
display: flex;
justify-content: flex-end;
padding: 20rpx 30rpx;
border-top: 1rpx solid #E9ECEF;
}
/* 加载状态 */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 80rpx 40rpx;
color: #6C757D;
}
.loading-spinner {
width: 40rpx;
height: 40rpx;
border: 4rpx solid #F3F3F3;
border-top: 4rpx solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 20rpx;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 响应式适配 */
@media (max-width: 400px) {
.benefits-grid,
.menu-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 320px) {
.benefits-grid,
.menu-grid {
grid-template-columns: repeat(2, 1fr);
}
.user-info {
flex-direction: column;
text-align: center;
}
.user-details {
margin-left: 0;
margin-top: 20rpx;
}
.login-btn {
margin-left: 0;
margin-top: 20rpx;
}
}

View File

@@ -0,0 +1,271 @@
<!--pages/member/bind/bind.wxml-->
<view class="container">
<!-- 页面标题 -->
<view class="page-header">
<view class="header-content">
<image class="header-icon" src="/images/bind/member-icon.png" mode="aspectFit"></image>
<view class="header-text">
<view class="header-title">会员绑定</view>
<view class="header-desc">绑定会员账号,享受专属权益</view>
</view>
</view>
</view>
<!-- 当前状态卡片 -->
<view class="status-card card">
<view class="status-content">
<view class="status-icon">
<image src="{{statusInfo.icon}}" mode="aspectFit"></image>
</view>
<view class="status-text">
<view class="status-title">{{statusInfo.title}}</view>
<view class="status-desc">{{statusInfo.desc}}</view>
</view>
<view class="status-badge {{statusInfo.badgeClass}}" wx:if="{{statusInfo.badge}}">
{{statusInfo.badge}}
</view>
</view>
</view>
<!-- 绑定表单 -->
<view class="bind-form card" wx:if="{{!memberInfo.isBound}}">
<view class="card-header">
<text class="text-lg font-bold">绑定会员账号</text>
</view>
<view class="card-body">
<form bindsubmit="handleSubmit">
<!-- 手机号输入 -->
<view class="form-group">
<view class="form-label">手机号码</view>
<view class="input-group">
<input
class="form-input"
type="number"
placeholder="请输入手机号码"
value="{{formData.phone}}"
bindinput="onPhoneInput"
maxlength="11"
/>
<view class="input-suffix" wx:if="{{formData.phone && isValidPhone}}">
<image src="/images/icons/check-green.png" mode="aspectFit"></image>
</view>
</view>
<view class="form-error" wx:if="{{errors.phone}}">{{errors.phone}}</view>
</view>
<!-- 验证码输入 -->
<view class="form-group">
<view class="form-label">验证码</view>
<view class="input-group">
<input
class="form-input flex-1"
type="number"
placeholder="请输入验证码"
value="{{formData.verifyCode}}"
bindinput="onVerifyCodeInput"
maxlength="6"
/>
<button
class="verify-btn {{canSendCode ? 'active' : 'disabled'}}"
bindtap="sendVerifyCode"
disabled="{{!canSendCode}}"
>
{{verifyCodeText}}
</button>
</view>
<view class="form-error" wx:if="{{errors.verifyCode}}">{{errors.verifyCode}}</view>
</view>
<!-- 会员卡号输入 -->
<view class="form-group">
<view class="form-label">会员卡号</view>
<view class="input-group">
<input
class="form-input"
type="text"
placeholder="请输入会员卡号"
value="{{formData.memberCard}}"
bindinput="onMemberCardInput"
/>
<view class="input-suffix" bindtap="scanMemberCard">
<image src="/images/icons/scan.png" mode="aspectFit"></image>
</view>
</view>
<view class="form-error" wx:if="{{errors.memberCard}}">{{errors.memberCard}}</view>
</view>
<!-- 密码输入 -->
<view class="form-group">
<view class="form-label">会员密码</view>
<view class="input-group">
<input
class="form-input"
type="{{showPassword ? 'text' : 'password'}}"
placeholder="请输入会员密码"
value="{{formData.password}}"
bindinput="onPasswordInput"
/>
<view class="input-suffix" bindtap="togglePassword">
<image src="/images/icons/{{showPassword ? 'eye-off' : 'eye'}}.png" mode="aspectFit"></image>
</view>
</view>
<view class="form-error" wx:if="{{errors.password}}">{{errors.password}}</view>
</view>
<!-- 协议同意 -->
<view class="agreement-group">
<label class="agreement-item">
<checkbox
value="agree"
checked="{{agreeTerms}}"
bindchange="onAgreeChange"
/>
<text class="agreement-text">
我已阅读并同意
<text class="link" bindtap="showTerms">《会员服务协议》</text>
<text class="link" bindtap="showPrivacy">《隐私政策》</text>
</text>
</label>
</view>
<!-- 提交按钮 -->
<button
class="btn btn-primary btn-block btn-large {{canSubmit ? '' : 'btn-disabled'}}"
form-type="submit"
disabled="{{!canSubmit || submitting}}"
>
{{submitting ? '绑定中...' : '立即绑定'}}
</button>
</form>
</view>
</view>
<!-- 已绑定状态 -->
<view class="bound-info card" wx:if="{{memberInfo.isBound}}">
<view class="card-header">
<text class="text-lg font-bold">会员信息</text>
</view>
<view class="card-body">
<view class="member-details">
<view class="detail-item">
<view class="detail-label">会员等级</view>
<view class="detail-value">
<view class="member-badge {{memberInfo.level}}">{{memberInfo.levelText}}</view>
</view>
</view>
<view class="detail-item">
<view class="detail-label">会员卡号</view>
<view class="detail-value">{{memberInfo.memberCard}}</view>
</view>
<view class="detail-item">
<view class="detail-label">绑定手机</view>
<view class="detail-value">{{memberInfo.phone}}</view>
</view>
<view class="detail-item">
<view class="detail-label">绑定时间</view>
<view class="detail-value">{{memberInfo.bindTimeText}}</view>
</view>
<view class="detail-item" wx:if="{{memberInfo.expireTime}}">
<view class="detail-label">到期时间</view>
<view class="detail-value {{memberInfo.expireInfo.isExpired ? 'text-danger' : 'text-success'}}">
{{memberInfo.expireInfo.text}}
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-buttons">
<button class="btn btn-outline btn-block" bindtap="showUnbindConfirm">
解除绑定
</button>
<button class="btn btn-primary btn-block mt-2" bindtap="navigateToCenter">
查看权益
</button>
</view>
</view>
</view>
<!-- 绑定说明 -->
<view class="help-card card">
<view class="card-header">
<text class="text-lg font-bold">绑定说明</text>
</view>
<view class="card-body">
<view class="help-list">
<view class="help-item">
<view class="help-icon">
<image src="/images/icons/info.png" mode="aspectFit"></image>
</view>
<view class="help-text">绑定后可享受会员专属权益和服务</view>
</view>
<view class="help-item">
<view class="help-icon">
<image src="/images/icons/security.png" mode="aspectFit"></image>
</view>
<view class="help-text">您的个人信息将被严格保护</view>
</view>
<view class="help-item">
<view class="help-icon">
<image src="/images/icons/support.png" mode="aspectFit"></image>
</view>
<view class="help-text">如有问题请联系客服400-123-4567</view>
</view>
</view>
</view>
</view>
</view>
<!-- 解绑确认弹窗 -->
<view class="unbind-modal" wx:if="{{showUnbindModal}}" bindtap="hideUnbindConfirm">
<view class="modal-content" catchtap="stopPropagation">
<view class="modal-header">
<text class="modal-title">确认解除绑定</text>
</view>
<view class="modal-body">
<view class="unbind-warning">
<image class="warning-icon" src="/images/icons/warning.png" mode="aspectFit"></image>
<view class="warning-text">
<view class="warning-title">解除绑定后将失去以下权益:</view>
<view class="warning-list">
<text>• 会员专属功能和服务</text>
<text>• 积分和优惠券</text>
<text>• 会员等级和特权</text>
</view>
</view>
</view>
</view>
<view class="modal-footer">
<button class="btn btn-secondary" bindtap="hideUnbindConfirm">取消</button>
<button class="btn btn-danger ml-2" bindtap="confirmUnbind">确认解绑</button>
</view>
</view>
</view>
<!-- 协议弹窗 -->
<view class="agreement-modal" wx:if="{{showAgreementModal}}" bindtap="hideAgreement">
<view class="modal-content" catchtap="stopPropagation">
<view class="modal-header">
<text class="modal-title">{{agreementTitle}}</text>
<view class="modal-close" bindtap="hideAgreement">
<image src="/images/icons/close.png" mode="aspectFit"></image>
</view>
</view>
<view class="modal-body">
<scroll-view class="agreement-content" scroll-y="true">
<rich-text nodes="{{agreementContent}}"></rich-text>
</scroll-view>
</view>
<view class="modal-footer">
<button class="btn btn-primary btn-block" bindtap="hideAgreement">我知道了</button>
</view>
</view>
</view>
<!-- 加载状态 -->
<view class="loading-overlay" wx:if="{{loading}}">
<view class="loading-content">
<view class="loading-spinner"></view>
<text>{{loadingText}}</text>
</view>
</view>

489
miniprogram/utils/api.js Normal file
View File

@@ -0,0 +1,489 @@
/**
* API服务工具
* 统一处理网络请求、错误处理、token管理等
*/
const app = getApp();
// API配置
const API_CONFIG = {
baseUrl: 'https://api.xiaozhi.com', // 生产环境API地址
timeout: 10000,
retryCount: 3,
retryDelay: 1000
};
// 请求状态码
const STATUS_CODE = {
SUCCESS: 200,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
SERVER_ERROR: 500,
NETWORK_ERROR: -1
};
// 错误消息映射
const ERROR_MESSAGES = {
[STATUS_CODE.UNAUTHORIZED]: '登录已过期,请重新登录',
[STATUS_CODE.FORBIDDEN]: '没有访问权限',
[STATUS_CODE.NOT_FOUND]: '请求的资源不存在',
[STATUS_CODE.SERVER_ERROR]: '服务器内部错误',
[STATUS_CODE.NETWORK_ERROR]: '网络连接失败,请检查网络设置'
};
/**
* 基础请求方法
* @param {Object} options 请求配置
* @returns {Promise} 请求结果
*/
function request(options) {
return new Promise((resolve, reject) => {
const {
url,
method = 'GET',
data = {},
header = {},
needAuth = true,
showLoading = true,
loadingText = '加载中...',
retryCount = 0
} = options;
// 显示加载提示
if (showLoading) {
wx.showLoading({
title: loadingText,
mask: true
});
}
// 构建请求头
const requestHeader = {
'Content-Type': 'application/json',
...header
};
// 添加认证token
if (needAuth && app.globalData.token) {
requestHeader['Authorization'] = `Bearer ${app.globalData.token}`;
}
// 发起请求
wx.request({
url: `${API_CONFIG.baseUrl}${url}`,
method,
data,
header: requestHeader,
timeout: API_CONFIG.timeout,
success: (res) => {
if (showLoading) {
wx.hideLoading();
}
const { statusCode, data: responseData } = res;
// 请求成功
if (statusCode === STATUS_CODE.SUCCESS) {
if (responseData.code === 0) {
resolve(responseData);
} else {
// 业务错误
handleBusinessError(responseData, reject);
}
} else {
// HTTP错误
handleHttpError(statusCode, reject, options, retryCount);
}
},
fail: (error) => {
if (showLoading) {
wx.hideLoading();
}
console.error('Request failed:', error);
// 网络错误重试
if (retryCount < API_CONFIG.retryCount) {
setTimeout(() => {
request({
...options,
retryCount: retryCount + 1
}).then(resolve).catch(reject);
}, API_CONFIG.retryDelay);
} else {
handleNetworkError(error, reject);
}
}
});
});
}
/**
* 处理业务错误
* @param {Object} responseData 响应数据
* @param {Function} reject 拒绝函数
*/
function handleBusinessError(responseData, reject) {
const { code, message } = responseData;
// 特殊错误码处理
switch (code) {
case 401:
// token过期清除登录状态
app.clearLoginStatus();
wx.showToast({
title: '登录已过期',
icon: 'none'
});
// 跳转到登录页
wx.navigateTo({
url: '/pages/login/login'
});
break;
case 403:
wx.showToast({
title: '权限不足',
icon: 'none'
});
break;
default:
wx.showToast({
title: message || '操作失败',
icon: 'none'
});
}
reject({
code,
message,
type: 'business'
});
}
/**
* 处理HTTP错误
* @param {Number} statusCode 状态码
* @param {Function} reject 拒绝函数
* @param {Object} options 请求选项
* @param {Number} retryCount 重试次数
*/
function handleHttpError(statusCode, reject, options, retryCount) {
const message = ERROR_MESSAGES[statusCode] || '请求失败';
// 401错误特殊处理
if (statusCode === STATUS_CODE.UNAUTHORIZED) {
app.clearLoginStatus();
wx.navigateTo({
url: '/pages/login/login'
});
}
// 5xx错误重试
if (statusCode >= 500 && retryCount < API_CONFIG.retryCount) {
setTimeout(() => {
request({
...options,
retryCount: retryCount + 1
}).then(resolve).catch(reject);
}, API_CONFIG.retryDelay);
return;
}
wx.showToast({
title: message,
icon: 'none'
});
reject({
code: statusCode,
message,
type: 'http'
});
}
/**
* 处理网络错误
* @param {Object} error 错误对象
* @param {Function} reject 拒绝函数
*/
function handleNetworkError(error, reject) {
wx.showToast({
title: '网络连接失败',
icon: 'none'
});
reject({
code: STATUS_CODE.NETWORK_ERROR,
message: '网络连接失败',
type: 'network',
error
});
}
/**
* GET请求
* @param {String} url 请求地址
* @param {Object} params 请求参数
* @param {Object} options 其他选项
* @returns {Promise} 请求结果
*/
function get(url, params = {}, options = {}) {
const queryString = Object.keys(params)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
.join('&');
const requestUrl = queryString ? `${url}?${queryString}` : url;
return request({
url: requestUrl,
method: 'GET',
...options
});
}
/**
* POST请求
* @param {String} url 请求地址
* @param {Object} data 请求数据
* @param {Object} options 其他选项
* @returns {Promise} 请求结果
*/
function post(url, data = {}, options = {}) {
return request({
url,
method: 'POST',
data,
...options
});
}
/**
* PUT请求
* @param {String} url 请求地址
* @param {Object} data 请求数据
* @param {Object} options 其他选项
* @returns {Promise} 请求结果
*/
function put(url, data = {}, options = {}) {
return request({
url,
method: 'PUT',
data,
...options
});
}
/**
* DELETE请求
* @param {String} url 请求地址
* @param {Object} options 其他选项
* @returns {Promise} 请求结果
*/
function del(url, options = {}) {
return request({
url,
method: 'DELETE',
...options
});
}
/**
* 文件上传
* @param {String} url 上传地址
* @param {String} filePath 文件路径
* @param {Object} formData 表单数据
* @param {Object} options 其他选项
* @returns {Promise} 上传结果
*/
function upload(url, filePath, formData = {}, options = {}) {
return new Promise((resolve, reject) => {
const {
name = 'file',
header = {},
needAuth = true,
showLoading = true
} = options;
if (showLoading) {
wx.showLoading({
title: '上传中...',
mask: true
});
}
// 构建请求头
const requestHeader = { ...header };
if (needAuth && app.globalData.token) {
requestHeader['Authorization'] = `Bearer ${app.globalData.token}`;
}
wx.uploadFile({
url: `${API_CONFIG.baseUrl}${url}`,
filePath,
name,
formData,
header: requestHeader,
success: (res) => {
if (showLoading) {
wx.hideLoading();
}
try {
const data = JSON.parse(res.data);
if (data.code === 0) {
resolve(data);
} else {
handleBusinessError(data, reject);
}
} catch (error) {
reject({
code: -1,
message: '响应数据解析失败',
type: 'parse',
error
});
}
},
fail: (error) => {
if (showLoading) {
wx.hideLoading();
}
handleNetworkError(error, reject);
}
});
});
}
// 会员相关API
const memberAPI = {
// 获取用户信息
getUserInfo() {
return get('/api/member/user/info');
},
// 微信登录
wxLogin(code) {
return post('/api/member/auth/wx-login', { code }, { needAuth: false });
},
// 获取会员信息
getMemberInfo() {
return get('/api/member/info');
},
// 绑定会员
bindMember(memberData) {
return post('/api/member/bind', memberData);
},
// 解绑会员
unbindMember() {
return post('/api/member/unbind');
},
// 获取会员权益
getMemberBenefits() {
return get('/api/member/benefits');
},
// 验证会员权益
verifyBenefit(benefitType) {
return post('/api/member/verify-benefit', { benefitType });
},
// 获取会员等级配置
getMemberLevels() {
return get('/api/member/levels');
},
// 升级会员
upgradeMember(targetLevel) {
return post('/api/member/upgrade', { targetLevel });
},
// 获取绑定历史
getBindHistory(page = 1, size = 10) {
return get('/api/member/bind-history', { page, size });
},
// 获取权益使用记录
getBenefitUsage(page = 1, size = 10) {
return get('/api/member/benefit-usage', { page, size });
},
// 邀请好友
inviteFriend(inviteCode) {
return post('/api/member/invite', { inviteCode });
},
// 获取邀请记录
getInviteHistory(page = 1, size = 10) {
return get('/api/member/invite-history', { page, size });
}
};
// 支付相关API
const paymentAPI = {
// 创建支付订单
createOrder(orderData) {
return post('/api/payment/create-order', orderData);
},
// 查询订单状态
queryOrder(orderId) {
return get(`/api/payment/order/${orderId}`);
},
// 取消订单
cancelOrder(orderId) {
return post(`/api/payment/order/${orderId}/cancel`);
},
// 申请退款
requestRefund(orderId, refundData) {
return post(`/api/payment/order/${orderId}/refund`, refundData);
},
// 查询退款状态
queryRefund(refundId) {
return get(`/api/payment/refund/${refundId}`);
}
};
// 通用API
const commonAPI = {
// 获取系统配置
getSystemConfig() {
return get('/api/common/config', {}, { needAuth: false });
},
// 上传图片
uploadImage(filePath) {
return upload('/api/common/upload/image', filePath);
},
// 发送验证码
sendVerifyCode(phone) {
return post('/api/common/send-verify-code', { phone }, { needAuth: false });
},
// 验证验证码
verifyCode(phone, code) {
return post('/api/common/verify-code', { phone, code }, { needAuth: false });
}
};
module.exports = {
request,
get,
post,
put,
del: del,
upload,
memberAPI,
paymentAPI,
commonAPI,
API_CONFIG,
STATUS_CODE
};

543
miniprogram/utils/util.js Normal file
View File

@@ -0,0 +1,543 @@
/**
* 通用工具函数
*/
/**
* 格式化时间
* @param {Date|String|Number} date 日期
* @param {String} format 格式化字符串
* @returns {String} 格式化后的时间字符串
*/
function formatTime(date, format = 'YYYY-MM-DD HH:mm:ss') {
if (!date) return '';
const d = new Date(date);
if (isNaN(d.getTime())) return '';
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hour = String(d.getHours()).padStart(2, '0');
const minute = String(d.getMinutes()).padStart(2, '0');
const second = String(d.getSeconds()).padStart(2, '0');
return format
.replace('YYYY', year)
.replace('MM', month)
.replace('DD', day)
.replace('HH', hour)
.replace('mm', minute)
.replace('ss', second);
}
/**
* 格式化相对时间
* @param {Date|String|Number} date 日期
* @returns {String} 相对时间字符串
*/
function formatRelativeTime(date) {
if (!date) return '';
const d = new Date(date);
if (isNaN(d.getTime())) return '';
const now = new Date();
const diff = now.getTime() - d.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) {
return '刚刚';
} else if (minutes < 60) {
return `${minutes}分钟前`;
} else if (hours < 24) {
return `${hours}小时前`;
} else if (days < 7) {
return `${days}天前`;
} else {
return formatTime(date, 'MM-DD');
}
}
/**
* 防抖函数
* @param {Function} func 要防抖的函数
* @param {Number} delay 延迟时间
* @returns {Function} 防抖后的函数
*/
function debounce(func, delay = 300) {
let timer = null;
return function (...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
/**
* 节流函数
* @param {Function} func 要节流的函数
* @param {Number} delay 延迟时间
* @returns {Function} 节流后的函数
*/
function throttle(func, delay = 300) {
let timer = null;
return function (...args) {
if (!timer) {
timer = setTimeout(() => {
func.apply(this, args);
timer = null;
}, delay);
}
};
}
/**
* 深拷贝
* @param {Any} obj 要拷贝的对象
* @returns {Any} 拷贝后的对象
*/
function deepClone(obj) {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (obj instanceof Date) {
return new Date(obj.getTime());
}
if (obj instanceof Array) {
return obj.map(item => deepClone(item));
}
if (typeof obj === 'object') {
const cloned = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
cloned[key] = deepClone(obj[key]);
}
}
return cloned;
}
return obj;
}
/**
* 生成唯一ID
* @param {String} prefix 前缀
* @returns {String} 唯一ID
*/
function generateId(prefix = '') {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substr(2, 5);
return `${prefix}${timestamp}${random}`;
}
/**
* 验证手机号
* @param {String} phone 手机号
* @returns {Boolean} 是否有效
*/
function validatePhone(phone) {
const phoneRegex = /^1[3-9]\d{9}$/;
return phoneRegex.test(phone);
}
/**
* 验证邮箱
* @param {String} email 邮箱
* @returns {Boolean} 是否有效
*/
function validateEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* 验证身份证号
* @param {String} idCard 身份证号
* @returns {Boolean} 是否有效
*/
function validateIdCard(idCard) {
const idCardRegex = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/;
return idCardRegex.test(idCard);
}
/**
* 格式化金额
* @param {Number} amount 金额
* @param {Number} decimals 小数位数
* @returns {String} 格式化后的金额
*/
function formatAmount(amount, decimals = 2) {
if (isNaN(amount)) return '0.00';
return Number(amount).toFixed(decimals);
}
/**
* 格式化文件大小
* @param {Number} bytes 字节数
* @returns {String} 格式化后的文件大小
*/
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* 获取URL参数
* @param {String} url URL地址
* @returns {Object} 参数对象
*/
function getUrlParams(url) {
const params = {};
const urlObj = new URL(url);
for (const [key, value] of urlObj.searchParams) {
params[key] = value;
}
return params;
}
/**
* 构建URL参数
* @param {Object} params 参数对象
* @returns {String} 参数字符串
*/
function buildUrlParams(params) {
return Object.keys(params)
.filter(key => params[key] !== undefined && params[key] !== null)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
.join('&');
}
/**
* 存储数据到本地
* @param {String} key 键名
* @param {Any} data 数据
* @param {Number} expire 过期时间(毫秒)
*/
function setStorage(key, data, expire = 0) {
const item = {
data,
timestamp: Date.now(),
expire
};
try {
wx.setStorageSync(key, JSON.stringify(item));
} catch (error) {
console.error('Storage set error:', error);
}
}
/**
* 从本地获取数据
* @param {String} key 键名
* @returns {Any} 数据
*/
function getStorage(key) {
try {
const item = wx.getStorageSync(key);
if (!item) return null;
const parsed = JSON.parse(item);
const { data, timestamp, expire } = parsed;
// 检查是否过期
if (expire > 0 && Date.now() - timestamp > expire) {
wx.removeStorageSync(key);
return null;
}
return data;
} catch (error) {
console.error('Storage get error:', error);
return null;
}
}
/**
* 删除本地存储数据
* @param {String} key 键名
*/
function removeStorage(key) {
try {
wx.removeStorageSync(key);
} catch (error) {
console.error('Storage remove error:', error);
}
}
/**
* 清空本地存储
*/
function clearStorage() {
try {
wx.clearStorageSync();
} catch (error) {
console.error('Storage clear error:', error);
}
}
/**
* 显示成功提示
* @param {String} title 提示内容
* @param {Number} duration 显示时长
*/
function showSuccess(title, duration = 2000) {
wx.showToast({
title,
icon: 'success',
duration
});
}
/**
* 显示错误提示
* @param {String} title 提示内容
* @param {Number} duration 显示时长
*/
function showError(title, duration = 2000) {
wx.showToast({
title,
icon: 'error',
duration
});
}
/**
* 显示普通提示
* @param {String} title 提示内容
* @param {Number} duration 显示时长
*/
function showToast(title, duration = 2000) {
wx.showToast({
title,
icon: 'none',
duration
});
}
/**
* 显示加载提示
* @param {String} title 提示内容
*/
function showLoading(title = '加载中...') {
wx.showLoading({
title,
mask: true
});
}
/**
* 隐藏加载提示
*/
function hideLoading() {
wx.hideLoading();
}
/**
* 显示确认对话框
* @param {String} content 内容
* @param {String} title 标题
* @returns {Promise<Boolean>} 用户选择结果
*/
function showConfirm(content, title = '提示') {
return new Promise((resolve) => {
wx.showModal({
title,
content,
success: (res) => {
resolve(res.confirm);
},
fail: () => {
resolve(false);
}
});
});
}
/**
* 获取系统信息
* @returns {Object} 系统信息
*/
function getSystemInfo() {
try {
return wx.getSystemInfoSync();
} catch (error) {
console.error('Get system info error:', error);
return {};
}
}
/**
* 检查网络状态
* @returns {Promise<Object>} 网络状态
*/
function getNetworkType() {
return new Promise((resolve, reject) => {
wx.getNetworkType({
success: resolve,
fail: reject
});
});
}
/**
* 复制到剪贴板
* @param {String} data 要复制的内容
* @returns {Promise<Boolean>} 复制结果
*/
function copyToClipboard(data) {
return new Promise((resolve) => {
wx.setClipboardData({
data,
success: () => {
showSuccess('复制成功');
resolve(true);
},
fail: () => {
showError('复制失败');
resolve(false);
}
});
});
}
/**
* 预览图片
* @param {String} current 当前图片URL
* @param {Array} urls 图片URL列表
*/
function previewImage(current, urls = []) {
wx.previewImage({
current,
urls: urls.length > 0 ? urls : [current]
});
}
/**
* 拨打电话
* @param {String} phoneNumber 电话号码
*/
function makePhoneCall(phoneNumber) {
wx.makePhoneCall({
phoneNumber,
fail: () => {
showError('拨打电话失败');
}
});
}
/**
* 获取会员等级文本
* @param {String} level 会员等级
* @returns {String} 等级文本
*/
function getMemberLevelText(level) {
const levelMap = {
'free': '普通用户',
'vip': 'VIP会员',
'svip': 'SVIP会员'
};
return levelMap[level] || '未知等级';
}
/**
* 获取会员等级颜色
* @param {String} level 会员等级
* @returns {String} 颜色值
*/
function getMemberLevelColor(level) {
const colorMap = {
'free': '#6C757D',
'vip': '#FFD700',
'svip': '#FF6B35'
};
return colorMap[level] || '#6C757D';
}
/**
* 计算会员到期时间
* @param {String} expireTime 到期时间
* @returns {Object} 时间信息
*/
function calculateMemberExpire(expireTime) {
if (!expireTime) {
return {
isExpired: true,
daysLeft: 0,
text: '已过期'
};
}
const expire = new Date(expireTime);
const now = new Date();
const diff = expire.getTime() - now.getTime();
const daysLeft = Math.ceil(diff / (1000 * 60 * 60 * 24));
if (daysLeft <= 0) {
return {
isExpired: true,
daysLeft: 0,
text: '已过期'
};
} else if (daysLeft <= 7) {
return {
isExpired: false,
daysLeft,
text: `${daysLeft}天后到期`
};
} else {
return {
isExpired: false,
daysLeft,
text: formatTime(expireTime, 'YYYY-MM-DD') + ' 到期'
};
}
}
module.exports = {
formatTime,
formatRelativeTime,
debounce,
throttle,
deepClone,
generateId,
validatePhone,
validateEmail,
validateIdCard,
formatAmount,
formatFileSize,
getUrlParams,
buildUrlParams,
setStorage,
getStorage,
removeStorage,
clearStorage,
showSuccess,
showError,
showToast,
showLoading,
hideLoading,
showConfirm,
getSystemInfo,
getNetworkType,
copyToClipboard,
previewImage,
makePhoneCall,
getMemberLevelText,
getMemberLevelColor,
calculateMemberExpire
};

BIN
models/silero_vad.onnx Normal file

Binary file not shown.

Binary file not shown.

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "xiaozhi-server-feature-continue-from-4a52c9c",
"lockfileVersion": 2,
"requires": true,
"packages": {}
}

393
pom.xml Normal file
View File

@@ -0,0 +1,393 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<groupId>com.xiaozhi.server</groupId>
<artifactId>xiaozhi.server</artifactId>
<version>2.8.16</version>
<name>xiaozhi-server</name>
<description></description>
<properties>
<java.version>21</java.version>
</properties>
<!-- Spring AI -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring MVC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.12</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Tomcat -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</dependency>
<!-- Spring Boot Dashboard -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Spring AI -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-zhipuai</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
<scope>provided</scope>
</dependency>
<!--
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-model-chat-memory-repository-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-model-chat-memory-repository-neo4j</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-client-chat</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server</artifactId>
</dependency>
-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
<!-- 数据库 -->
<!-- <dependency>-->
<!-- <groupId>org.mybatis.spring.boot</groupId>-->
<!-- <artifactId>mybatis-spring-boot-starter</artifactId>-->
<!-- <version>3.0.3</version>-->
<!-- </dependency>-->
<!-- 分页 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>
<!-- 缓存 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Mysql -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- JsonObject -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20240303</version>
</dependency>
<!-- 文件上传 -->
<dependency>
<groupId>net.coobird</groupId>
<artifactId>thumbnailator</artifactId>
<version>[0.4, 0.5)</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
<!-- Edge TTS -->
<dependency>
<groupId>io.github.whitemagic2014</groupId>
<artifactId>tts-edge-java</artifactId>
<version>1.2.6</version>
</dependency>
<!-- Opus -->
<dependency>
<groupId>io.github.jaredmdobson</groupId>
<artifactId>concentus</artifactId>
<version>1.0.2</version>
</dependency>
<!-- 音频操作 -->
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacv</artifactId>
<version>1.5.10</version>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>ffmpeg</artifactId>
<version>6.1.1-1.5.10</version>
</dependency>
<!-- Vosk -->
<dependency>
<groupId>com.alphacephei</groupId>
<artifactId>vosk</artifactId>
<version>0.3.45</version>
</dependency>
<!-- ONNX -->
<dependency>
<groupId>com.microsoft.onnxruntime</groupId>
<artifactId>onnxruntime</artifactId>
<version>1.20.0</version>
</dependency>
<!-- TarsosDSP -->
<dependency>
<groupId>be.tarsos.dsp</groupId>
<artifactId>core</artifactId>
<version>2.5</version>
</dependency>
<dependency>
<groupId>be.tarsos.dsp</groupId>
<artifactId>jvm</artifactId>
<version>2.5</version>
</dependency>
<!-- OkHttp -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>5.0.0-alpha.14</version>
</dependency>
<!-- Kotlin 运行时 -->
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>1.9.22</version>
</dependency>
<!-- Email -->
<dependency>
<groupId>io.github.biezhi</groupId>
<artifactId>oh-my-email</artifactId>
<version>0.0.4</version>
</dependency>
<!-- 腾讯云STT -->
<dependency>
<groupId>com.tencentcloudapi</groupId>
<artifactId>tencentcloud-speech-sdk-java</artifactId>
<version>1.0.53</version>
</dependency>
<!-- 阿里云STT -->
<dependency>
<groupId>com.alibaba.nls</groupId>
<artifactId>nls-sdk-transcriber</artifactId>
<version>2.2.1</version>
</dependency>
<!-- 阿里云TTS -->
<dependency>
<groupId>com.alibaba.nls</groupId>
<artifactId>nls-sdk-tts</artifactId>
<version>2.2.17</version>
</dependency>
<!-- 阿里云ASR -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>dashscope-sdk-java</artifactId>
<version>2.20.2</version>
</dependency>
<!-- 阿里云Token -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.7.6</version>
</dependency>
<!-- 讯飞 -->
<dependency>
<groupId>cn.xfyun</groupId>
<artifactId>websdk-java-speech</artifactId>
<version>3.0.2</version>
</dependency>
<!-- COZE -->
<dependency>
<groupId>com.coze</groupId>
<artifactId>coze-api</artifactId>
<version>LATEST</version>
</dependency>
<!-- 腾讯云COS SDK -->
<dependency>
<groupId>com.qcloud</groupId>
<artifactId>cos_api</artifactId>
<version>5.6.155</version>
</dependency>
<!-- DIFY -->
<dependency>
<groupId>io.github.imfangs</groupId>
<artifactId>dify-java-client</artifactId>
<version>1.0.7</version>
</dependency>
<!-- Jakarta EE API - Spring Boot 3 需要 -->
<dependency>
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
</dependency>
<!-- Netty -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.110.Final</version>
</dependency>
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.4.0</version>
</dependency>
<dependency>
<groupId>com.github.yitter</groupId>
<artifactId>yitter-idgenerator</artifactId>
<version>1.0.6</version>
</dependency>
<dependency>
<groupId>org.lionsoul</groupId>
<artifactId>ip2region</artifactId>
<version>2.7.0</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.26</version>
</dependency>
<dependency>
<groupId>com.github.oshi</groupId>
<artifactId>oshi-core</artifactId>
<version>6.4.13</version>
</dependency>
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
<!-- 添加 Surefire 插件配置 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<argLine>-Djava.library.path=${project.basedir}/lib</argLine>
</configuration>
</plugin>
</plugins>
<resources>
<!--引入mapper对应的xml文件-->
<resource>
<directory>src/main/java</directory>
<includes>
<include>**/*.properties</include>
<include>**/*.xml</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<includes>
<include>**/*</include>
</includes>
</resource>
</resources>
<testSourceDirectory>src/test</testSourceDirectory>
</build>
<repositories>
<repository>
<id>aliyun</id>
<url>https://maven.aliyun.com/nexus/content/groups/public/</url>
</repository>
<repository>
<id>be.0110.repo-releases</id>
<name>0110.be repository</name>
<url>https://mvn.0110.be/releases</url>
</repository>
</repositories>
</project>

View File

@@ -0,0 +1,34 @@
package com.xiaozhi;
import com.xiaozhi.communication.server.websocket.WebSocketConfig;
import com.xiaozhi.utils.CmsUtils;
import org.mybatis.spring.annotation.MapperScan;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.context.WebServerInitializedEvent;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.Bean;
import java.util.Map;
@SpringBootApplication
@EnableCaching
@MapperScan(basePackages = {
"com.xiaozhi.dao",
"com.xiaozhi.repository",
"com.xiaozhi.mapper",
"com.xiaozhi.plus.**.mapper"
})
public class XiaozhiApplication {
Logger logger = LoggerFactory.getLogger(XiaozhiApplication.class);
public static void main(String[] args) {
SpringApplication.run(XiaozhiApplication.class, args);
}
}

View File

@@ -0,0 +1,25 @@
package com.xiaozhi.common.config;
import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.core.task.support.TaskExecutorAdapter;
import java.util.concurrent.Executors;
@Configuration
public class ThreadConfig {
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
}

View File

@@ -0,0 +1,85 @@
//package com.xiaozhi.common.config;
//
//import com.xiaozhi.common.interceptor.AuthenticationInterceptor;
//import com.xiaozhi.common.interceptor.LogInterceptor;
//
//import lombok.extern.slf4j.Slf4j;
//import org.springframework.context.annotation.Configuration;
//import org.springframework.web.servlet.config.annotation.CorsRegistry;
//import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
//import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
//import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
//import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
//
//import jakarta.annotation.Resource;
//
//import java.io.File;
//
//@Configuration
//@Slf4j
//public class WebMvcConfig extends WebMvcConfigurationSupport {
//
// @Resource
// private LogInterceptor logInterceptor;
//
// @Resource
// private AuthenticationInterceptor authenticationInterceptor;
//
// @Override
// public void addInterceptors(InterceptorRegistry registry) {
// registry.addInterceptor(authenticationInterceptor)
// .addPathPatterns("/**")
// .excludePathPatterns(
// "/api/user/login",
// "/api/user/register",
// "/api/device/ota",
// "/audio/**",
// "/uploads/**",
// "/ws/**"
// );
// }
//
// @Override
// public void addCorsMappings(CorsRegistry registry) {
// registry.addMapping("/**")
// .allowedOrigins("*")
// .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
// .allowedHeaders("*");
// }
//
// @Override
// public void addResourceHandlers(ResourceHandlerRegistry registry) {
// try {
// // 获取项目根目录的绝对路径
// String basePath = new File("").getAbsolutePath();
//
// // 音频文件存储在项目根目录下的audio文件夹中
// String audioPath = "file:" + basePath + File.separator + "audio" + File.separator;
//
// // 上传文件存储在项目根目录下的uploads文件夹中
// String uploadsPath = "file:" + basePath + File.separator + "uploads" + File.separator;
//
// // 配置资源映射
// registry.addResourceHandler("/audio/**")
// .addResourceLocations(audioPath);
//
// // 为上传文件添加资源映射
// registry.addResourceHandler("/uploads/**")
// .addResourceLocations(uploadsPath);
//
// super.addResourceHandlers(registry);
// } catch (Exception e) {
// log.error("添加资源失败", e);
// }
// }
//
// /**
// * 配置路径匹配参数
// */
// @Override
// @SuppressWarnings("deprecation") // 暂时抑制过时警告
// public void configurePathMatch(PathMatchConfigurer configurer) {
// // 使用推荐的方法设置尾部斜杠匹配
// configurer.setUseTrailingSlashMatch(true);
// }
//}

View File

@@ -0,0 +1,72 @@
//package com.xiaozhi.common.exception;
//
//import org.slf4j.Logger;
//import org.slf4j.LoggerFactory;
//import org.springframework.http.HttpStatus;
//import org.springframework.web.bind.annotation.ExceptionHandler;
//import org.springframework.web.bind.annotation.ResponseStatus;
//import org.springframework.web.bind.annotation.RestControllerAdvice;
//import org.springframework.web.context.request.WebRequest;
//import org.springframework.web.servlet.resource.NoResourceFoundException;
//
//import com.xiaozhi.common.web.AjaxResult;
//
///**
// * 全局异常处理器
// *
// * @author Joey
// */
//@RestControllerAdvice
//public class GlobalExceptionHandler {
// private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
//
// /**
// * 用户名不存在异常
// */
// @ExceptionHandler(UsernameNotFoundException.class)
// @ResponseStatus(HttpStatus.BAD_REQUEST)
// public AjaxResult handleUsernameNotFoundException(UsernameNotFoundException e, WebRequest request) {
// logger.warn("用户名不存在异常: {}", e.getMessage(), e);
// return AjaxResult.error("用户名不存在");
// }
//
// /**
// * 用户密码不匹配异常
// */
// @ExceptionHandler(UserPasswordNotMatchException.class)
// @ResponseStatus(HttpStatus.BAD_REQUEST)
// public AjaxResult handleUserPasswordNotMatchException(UserPasswordNotMatchException e, WebRequest request) {
// logger.warn("用户密码不匹配异常: {}", e.getMessage(), e);
// return AjaxResult.error("用户密码不正确");
// }
//
// /**
// * 静态资源找不到异常
// */
// @ExceptionHandler(NoResourceFoundException.class)
// @ResponseStatus(HttpStatus.NOT_FOUND)
// public AjaxResult handleNoResourceFoundException(NoResourceFoundException e, WebRequest request) {
// logger.warn("静态资源找不到: {}", e.getResourcePath());
// return AjaxResult.error(HttpStatus.NOT_FOUND.value(), "请求的资源不存在");
// }
//
// /**
// * 业务异常处理
// */
// @ExceptionHandler(RuntimeException.class)
// @ResponseStatus(HttpStatus.BAD_REQUEST)
// public AjaxResult handleRuntimeException(RuntimeException e, WebRequest request) {
// logger.error("业务异常: {}", e.getMessage(), e);
// return AjaxResult.error("操作失败:" + e.getMessage());
// }
//
// /**
// * 系统异常 - 作为最后的兜底处理
// */
// @ExceptionHandler(Exception.class)
// @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
// public AjaxResult handleException(Exception e, WebRequest request) {
// logger.error("系统异常: {}", e.getMessage(), e);
// return AjaxResult.error("服务器错误,请联系管理员");
// }
//}

View File

@@ -0,0 +1,16 @@
package com.xiaozhi.common.exception;
/**
* 密码错误异常
*
* @author Joey
*/
public class UserPasswordNotMatchException extends Exception {
public UserPasswordNotMatchException() {
}
public UserPasswordNotMatchException(String msg) {
super(msg);
}
}

View File

@@ -0,0 +1,15 @@
package com.xiaozhi.common.exception;
/**
* 用户名没有找到异常
*
* @author Joey
*/
public class UsernameNotFoundException extends Exception {
public UsernameNotFoundException() {
}
public UsernameNotFoundException(String msg) {
super(msg);
}
}

View File

@@ -0,0 +1,171 @@
package com.xiaozhi.common.interceptor;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xiaozhi.common.web.AjaxResult;
import com.xiaozhi.entity.SysUser;
import com.xiaozhi.service.SysUserService;
import com.xiaozhi.utils.CmsUtils;
import jakarta.annotation.Resource;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
@Component
public class AuthenticationInterceptor implements HandlerInterceptor {
@Resource
private SysUserService userService;
private static final Logger logger = LoggerFactory.getLogger(AuthenticationInterceptor.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
// 不需要认证的路径
private static final List<String> PUBLIC_PATHS = Arrays.asList(
"/api/user/",
"/api/device/ota",
"/audio/",
"/uploads/",
"/ws/");
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String path = request.getRequestURI();
// 检查是否是公共路径
if (isPublicPath(path)) {
return true;
}
// 检查是否有@@UnLogin注解
if (hasUnLoginAnnotation(handler)) {
logger.debug("接口 {} 标记为不需要登录验证", path);
return true;
}
// 获取会话
HttpSession session = request.getSession(false);
if (session != null) {
// 检查会话中是否有用户
Object userObj = session.getAttribute(SysUserService.USER_SESSIONKEY);
if (userObj != null) {
SysUser user = (SysUser) userObj;
// 将用户信息存储在请求属性中
request.setAttribute(CmsUtils.USER_ATTRIBUTE_KEY, user);
CmsUtils.setUser(request, user);
return true;
}
}
// 尝试从Cookie中获取用户名
if (tryAuthenticateWithCookies(request, response)) {
return true;
}
// 处理未授权的请求
handleUnauthorized(request, response);
return false;
}
/**
* 尝试使用Cookie进行认证
*/
private boolean tryAuthenticateWithCookies(HttpServletRequest request, HttpServletResponse response) {
// 检查是否有username cookie
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("username".equals(cookie.getName())) {
String username = cookie.getValue();
if (StringUtils.isNotBlank(username)) {
SysUser user = userService.selectUserByUsername(username);
if (user != null) {
// 将用户存储在会话和请求属性中
HttpSession session = request.getSession(true);
session.setAttribute(SysUserService.USER_SESSIONKEY, user);
request.setAttribute(CmsUtils.USER_ATTRIBUTE_KEY, user);
CmsUtils.setUser(request, user);
return true;
}
}
break;
}
}
}
return false;
}
/**
* 处理未授权的请求
*/
private void handleUnauthorized(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 检查是否是AJAX请求
String ajaxTag = request.getHeader("Request-By");
String head = request.getHeader("X-Requested-With");
if ((ajaxTag != null && ajaxTag.trim().equalsIgnoreCase("Ext"))
|| (head != null && !head.equalsIgnoreCase("XMLHttpRequest"))) {
response.addHeader("_timeout", "true");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
} else {
// 返回JSON格式的错误信息
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
AjaxResult result = AjaxResult.error(com.xiaozhi.common.web.HttpStatus.FORBIDDEN, "用户未登录");
try {
objectMapper.writeValue(response.getOutputStream(), result);
} catch (Exception e) {
logger.error("写入响应失败", e);
throw e;
}
}
}
/**
* 检查是否是公共路径
*/
private boolean isPublicPath(String path) {
return PUBLIC_PATHS.stream().anyMatch(path::startsWith);
}
/**
* 检查处理器是否有@UnLogin注解
*/
private boolean hasUnLoginAnnotation(Object handler) {
if (!(handler instanceof HandlerMethod)) {
return false;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
Class<?> controllerClass = handlerMethod.getBeanType();
// 检查方法上是否有@UnLogin注解
UnLogin methodAnnotation = method.getAnnotation(UnLogin.class);
if (methodAnnotation != null && methodAnnotation.value()) {
return true;
}
// 检查类上是否有@UnLogin注解
UnLogin classAnnotation = controllerClass.getAnnotation(UnLogin.class);
if (classAnnotation != null && classAnnotation.value()) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,35 @@
package com.xiaozhi.common.interceptor;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/**
* 系统日志拦截器
*
* @author Joey
*
*/
@Component
public class LogInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
ModelAndView modelAndView) throws Exception {
// 此方法在处理器执行完成后、视图渲染前调用
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
throws Exception {
}
}

View File

@@ -0,0 +1,20 @@
package com.xiaozhi.common.interceptor;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 标记不需要登录验证的接口
* 在控制器方法上使用此注解,可以跳过登录验证
* @author wwtang5
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface UnLogin {
/**
* 是否跳过登录验证默认为true
*/
boolean value() default true;
}

View File

@@ -0,0 +1,159 @@
package com.xiaozhi.common.web;
import org.springframework.util.ObjectUtils;
import java.util.HashMap;
/**
* API返回封装
*
* @author Joey
*/
public class AjaxResult extends HashMap<String, Object> {
private static final long serialVersionUID = 1L;
/** 状态码 */
public static final String CODE_TAG = "code";
/** 返回内容 */
public static final String MSG_TAG = "message";
/** 数据对象 */
public static final String DATA_TAG = "data";
/**
* 初始化一个新创建的 AjaxResult 对象,使其表示一个空消息。
*/
public AjaxResult() {
}
/**
* 初始化一个新创建的 AjaxResult 对象
*
* @param code 状态码
* @param msg 返回内容
*/
public AjaxResult(int code, String msg) {
super.put(CODE_TAG, code);
super.put(MSG_TAG, msg);
}
/**
* 初始化一个新创建的 AjaxResult 对象
*
* @param code 状态码
* @param msg 返回内容
* @param data 数据对象
*/
public AjaxResult(int code, String msg, Object data) {
super.put(CODE_TAG, code);
super.put(MSG_TAG, msg);
if (!ObjectUtils.isEmpty(data)) {
super.put(DATA_TAG, data);
}
}
/**
* 返回成功消息
*
* @return 成功消息
*/
public static AjaxResult success() {
return AjaxResult.success("操作成功");
}
/**
* 返回成功数据
*
* @return 成功消息
*/
public static AjaxResult success(Object data) {
return AjaxResult.success("操作成功", data);
}
/**
* 返回成功消息
*
* @param msg 返回内容
* @return 成功消息
*/
public static AjaxResult success(String msg) {
return AjaxResult.success(msg, null);
}
/**
* 返回成功消息
*
* @param msg 返回内容
* @param data 数据对象
* @return 成功消息
*/
public static AjaxResult success(String msg, Object data) {
return new AjaxResult(HttpStatus.SUCCESS, msg, data);
}
/**
* 返回错误消息
*
* @return
*/
public static AjaxResult error() {
return AjaxResult.error("操作失败");
}
/**
* 返回错误消息
*
* @param msg 返回内容
* @return 警告消息
*/
public static AjaxResult error(String msg) {
return AjaxResult.error(msg, null);
}
/**
* 返回错误消息
*
* @param msg 返回内容
* @param data 数据对象
* @return 警告消息
*/
public static AjaxResult error(String msg, Object data) {
return new AjaxResult(HttpStatus.ERROR, msg, data);
}
/**
* 返回错误消息
*
* @param code 状态码
* @param msg 返回内容
* @return 警告消息
*/
public static AjaxResult error(int code, String msg) {
return new AjaxResult(code, msg, null);
}
/**
* 返回错误消息
*
* @param code 状态码
* @param msg 返回内容
* @param data 数据对象
* @return 警告消息
*/
public static AjaxResult error(int code, String msg, Object data) {
return new AjaxResult(code, msg, data);
}
public int getCode() {
return (int) super.get(CODE_TAG);
}
public String getMessage() {
return (String) super.get(MSG_TAG);
}
public Object getData() {
return super.get(DATA_TAG);
}
}

View File

@@ -0,0 +1,52 @@
package com.xiaozhi.common.web;
import java.io.Serializable;
import org.springframework.stereotype.Service;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
/**
* HttpSession提供类
*/
@Service
public class HttpSessionProvider implements SessionProvider {
public Serializable getAttribute(HttpServletRequest request, String name) {
HttpSession session = request.getSession(false);
if (session != null) {
return (Serializable) session.getAttribute(name);
} else {
return null;
}
}
public void setAttribute(HttpServletRequest request, HttpServletResponse response, String name,
Serializable value) {
System.out.println(name);
HttpSession session = request.getSession();
session.setAttribute(name, value);
}
public String getSessionId(HttpServletRequest request, HttpServletResponse response) {
return request.getSession().getId();
}
public void logout(HttpServletRequest request, HttpServletResponse response) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
}
@Override
public void removeAttribute(HttpServletRequest request, String name) {
HttpSession session = request.getSession();
session.removeAttribute(name);
}
}

View File

@@ -0,0 +1,88 @@
package com.xiaozhi.common.web;
/**
* 返回状态码
*
* @author Joey
*/
public class HttpStatus {
/**
* 操作成功
*/
public static final int SUCCESS = 200;
/**
* 对象创建成功
*/
public static final int CREATED = 201;
/**
* 请求已经被接受
*/
public static final int ACCEPTED = 202;
/**
* 操作已经执行成功,但是没有返回数据
*/
public static final int NO_CONTENT = 204;
/**
* 资源已被移除
*/
public static final int MOVED_PERM = 301;
/**
* 重定向
*/
public static final int SEE_OTHER = 303;
/**
* 资源没有被修改
*/
public static final int NOT_MODIFIED = 304;
/**
* 参数列表错误(缺少,格式不匹配)
*/
public static final int BAD_REQUEST = 400;
/**
* 未授权
*/
public static final int UNAUTHORIZED = 401;
/**
* 访问受限,授权过期
*/
public static final int FORBIDDEN = 403;
/**
* 资源,服务未找到
*/
public static final int NOT_FOUND = 404;
/**
* 不允许的http方法
*/
public static final int BAD_METHOD = 405;
/**
* 资源冲突,或者资源被锁
*/
public static final int CONFLICT = 409;
/**
* 不支持的数据,媒体类型
*/
public static final int UNSUPPORTED_TYPE = 415;
/**
* 系统内部错误
*/
public static final int ERROR = 500;
/**
* 接口未实现
*/
public static final int NOT_IMPLEMENTED = 501;
}

View File

@@ -0,0 +1,34 @@
package com.xiaozhi.common.web;
/**
* @description: 分页信息
*/
public class PageFilter{
private Integer start = 1;
private Integer limit = 10;
public PageFilter() {
}
public PageFilter(Integer start, Integer limit) {
this.start = start;
this.limit = limit;
}
public Integer getStart() {
return start;
}
public void setStart(Integer start) {
this.start = start;
}
public Integer getLimit() {
return limit;
}
public void setLimit(Integer limit) {
this.limit = limit;
}
}

View File

@@ -0,0 +1,66 @@
package com.xiaozhi.common.web;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jakarta.servlet.http.HttpServletResponse;
/**
* HttpServletResponse帮助类
*/
public final class ResponseUtils {
public static final Logger log = LoggerFactory.getLogger(ResponseUtils.class);
/**
* 发送文本。使用UTF-8编码。
*
* @param response HttpServletResponse
* @param text 发送的字符串
*/
public static void renderText(HttpServletResponse response, String text) {
render(response, "text/plain;charset=UTF-8", text);
}
/**
* 发送json。使用UTF-8编码。
*
* @param response HttpServletResponse
* @param text 发送的字符串
*/
public static void renderJson(HttpServletResponse response, String text) {
render(response, "application/json;charset=UTF-8", text);
}
/**
* 发送xml。使用UTF-8编码。
*
* @param response HttpServletResponse
* @param text 发送的字符串
*/
public static void renderXml(HttpServletResponse response, String text) {
render(response, "text/xml;charset=UTF-8", text);
}
/**
* 发送内容。使用UTF-8编码。
*
* @param response
* @param contentType
* @param text
*/
public static void render(HttpServletResponse response, String contentType, String text) {
response.setContentType(contentType);
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expires", 0);
try {
response.getWriter().write(text);
} catch (IOException e) {
log.error(e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,21 @@
package com.xiaozhi.common.web;
import java.io.Serializable;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/**
* Session提供者
*/
public interface SessionProvider {
public Serializable getAttribute(HttpServletRequest request, String name);
public void setAttribute(HttpServletRequest request, HttpServletResponse response, String name, Serializable value);
public String getSessionId(HttpServletRequest request, HttpServletResponse response);
public void logout(HttpServletRequest request, HttpServletResponse response);
public void removeAttribute(HttpServletRequest request, String name);
}

View File

@@ -0,0 +1,266 @@
package com.xiaozhi.communication.common;
import com.xiaozhi.communication.domain.iot.IotDescriptor;
import com.xiaozhi.dialogue.llm.memory.Conversation;
import com.xiaozhi.dialogue.llm.tool.ToolsSessionHolder;
import com.xiaozhi.dialogue.llm.tool.mcp.device.DeviceMcpHolder;
import com.xiaozhi.entity.SysDevice;
import com.xiaozhi.entity.SysRole;
import com.xiaozhi.enums.ListenMode;
import com.xiaozhi.utils.AudioUtils;
import lombok.Data;
import org.springframework.ai.tool.ToolCallback;
import reactor.core.publisher.Sinks;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Data
public abstract class ChatSession {
/**
* 当前会话的sessionId
*/
protected String sessionId;
/**
* 设备信息
*/
protected SysDevice sysDevice;
/**
* 设备可用角色列表
*/
protected List<SysRole> sysRoleList;
/**
* 一个Session在某个时刻只有一个活跃的Conversation。
* 当切换角色时Conversation应该释放新建。切换角色一般是不频繁的。
*/
protected Conversation conversation;
/**
* 设备iot信息
*/
protected Map<String, IotDescriptor> iotDescriptors = new HashMap<>();
/**
* 当前session的function控制器
*/
protected ToolsSessionHolder toolsSessionHolder;
/**
* 当前语音发送完毕后是否关闭session
*/
protected boolean closeAfterChat;
/**
* 是否正在播放音乐
*/
protected boolean musicPlaying;
/**
* 是否正在说话
*/
protected boolean playing;
/**
* 设备状态auto, realTime)
*/
protected ListenMode mode;
/**
* 会话的音频数据流
*/
protected Sinks.Many<byte[]> audioSinks;
/**
* 会话是否正在进行流式识别
*/
protected boolean streamingState;
/**
* 会话的最后有效活动时间
*/
protected Instant lastActivityTime;
/**
* 会话属性存储
*/
protected final ConcurrentHashMap<String, Object> attributes = new ConcurrentHashMap<>();
// --------------------设备mcp-------------------------
private DeviceMcpHolder deviceMcpHolder = new DeviceMcpHolder();
public ChatSession(String sessionId) {
this.sessionId = sessionId;
this.lastActivityTime = Instant.now();
}
public void setAttribute(String key, Object value) {
attributes.put(key, value);
}
public Object getAttribute(String key) {
return attributes.get(key);
}
public void setAssistantTimeMillis(Long assistantTimeMillis) {
setAttribute("assistantTimeMillis", assistantTimeMillis);
}
public Long getAssistantTimeMillis() {
return (Long) getAttribute("assistantTimeMillis");
}
public void setUserTimeMillis(Long userTimeMillis) {
setAttribute("userTimeMillis", userTimeMillis);
}
public Long getUserTimeMillis() {
return (Long) getAttribute("userTimeMillis");
}
/**
* 音频文件约定路径为audio/{device-id}/{role-id}/{timestamp}-user.wav
* {device-id}/{role-id}/{timestamp}-user 能确定唯一性,不会有并发的麻烦。
* 除非多设备在嵌入式软件里强行修改mac地址deviceId目前是基于mac地址的)
*
* @param who
* @return
*/
private Path getAudioPath(String who, Long timeMillis) {
Instant instant = Instant.ofEpochMilli(timeMillis).truncatedTo(ChronoUnit.SECONDS);
LocalDateTime localDateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
String datetime = localDateTime.format(DateTimeFormatter.ISO_DATE_TIME).replace(":", "");
SysDevice device = this.getSysDevice();
// 判断设备ID是否有不适合路径的特殊字符它很可能是mac地址需要转换。
String deviceId = device.getDeviceId().replace(":", "-");
String roleId = device.getRoleId().toString();
String filename = "%s-%s.wav".formatted(datetime, who);
Path path = Path.of(AudioUtils.AUDIO_PATH, deviceId, roleId, filename);
return path;
}
public Path getUserAudioPath() {
return getAudioPath("user", this.getUserTimeMillis());
}
public Path getAssistantAudioPath() {
return getAudioPath("assistant", getAssistantTimeMillis());
}
public ToolsSessionHolder getFunctionSessionHolder() {
return toolsSessionHolder;
}
public void setFunctionSessionHolder(ToolsSessionHolder toolsSessionHolder) {
this.toolsSessionHolder = toolsSessionHolder;
}
public List<ToolCallback> getToolCallbacks() {
return toolsSessionHolder.getAllFunction();
}
// 添加缺失的getter/setter方法
public SysDevice getSysDevice() {
return sysDevice;
}
public void setSysDevice(SysDevice sysDevice) {
this.sysDevice = sysDevice;
}
public boolean isCloseAfterChat() {
return closeAfterChat;
}
public void setCloseAfterChat(boolean closeAfterChat) {
this.closeAfterChat = closeAfterChat;
}
public String getDeviceId() {
return sysDevice != null ? sysDevice.getDeviceId() : null;
}
public ListenMode getMode() {
return mode;
}
public void setMode(ListenMode mode) {
this.mode = mode;
}
public boolean isStreamingState() {
return streamingState;
}
public void setStreamingState(boolean streamingState) {
this.streamingState = streamingState;
}
public Sinks.Many<byte[]> getAudioSinks() {
return audioSinks;
}
public void setAudioSinks(Sinks.Many<byte[]> audioSinks) {
this.audioSinks = audioSinks;
}
public String getSessionId() {
return sessionId;
}
public Instant getLastActivityTime() {
return lastActivityTime;
}
public void setLastActivityTime(Instant lastActivityTime) {
this.lastActivityTime = lastActivityTime;
}
public Map<String, IotDescriptor> getIotDescriptors() {
return iotDescriptors;
}
public void setIotDescriptors(Map<String, IotDescriptor> iotDescriptors) {
this.iotDescriptors = iotDescriptors;
}
/**
* 会话连接是否打开中
*
* @return
*/
public abstract boolean isOpen();
/**
* 音频通道是否打开可用
*
* @return
*/
public abstract boolean isAudioChannelOpen();
public abstract void close();
public abstract void sendTextMessage(String message);
public abstract void sendBinaryMessage(byte[] message);
/**
* 设置 Conversation需要与当前活跃角色一致。
* 当切换角色时,会释放当前 Conversation并新建一个对应于新角色的Conversation。
*
* @param conversation
*/
public void setConversation(Conversation conversation) {
this.conversation = conversation;
}
/**
* 获取与当前活跃角色一致的 Conversation。
*
* @return
*/
public Conversation getConversation() {
return conversation;
}
}

View File

@@ -0,0 +1,370 @@
package com.xiaozhi.communication.common;
import com.xiaozhi.communication.domain.*;
import com.xiaozhi.dialogue.llm.factory.ChatModelFactory;
import com.xiaozhi.dialogue.llm.memory.Conversation;
import com.xiaozhi.dialogue.llm.memory.ConversationFactory;
import com.xiaozhi.dialogue.llm.tool.ToolsGlobalRegistry;
import com.xiaozhi.dialogue.llm.tool.ToolsSessionHolder;
import com.xiaozhi.dialogue.service.AudioService;
import com.xiaozhi.dialogue.service.DialogueService;
import com.xiaozhi.dialogue.service.IotService;
import com.xiaozhi.dialogue.service.VadService;
import com.xiaozhi.dialogue.stt.factory.SttServiceFactory;
import com.xiaozhi.dialogue.tts.factory.TtsServiceFactory;
import com.xiaozhi.entity.SysConfig;
import com.xiaozhi.entity.SysDevice;
import com.xiaozhi.entity.SysRole;
import com.xiaozhi.enums.ListenState;
import com.xiaozhi.service.SysConfigService;
import com.xiaozhi.service.SysDeviceService;
import com.xiaozhi.service.SysRoleService;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import java.util.Date;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class MessageHandler {
private static final Logger logger = LoggerFactory.getLogger(MessageHandler.class);
@Resource
private SysDeviceService deviceService;
@Resource
private AudioService audioService;
@Resource
private TtsServiceFactory ttsService;
@Resource
private VadService vadService;
@Resource
private SessionManager sessionManager;
@Resource
private SysConfigService configService;
@Resource
private DialogueService dialogueService;
@Resource
private IotService iotService;
@Resource
private TtsServiceFactory ttsFactory;
@Resource
private SttServiceFactory sttFactory;
@Autowired
private ConversationFactory conversationFactory;
@Resource
private ChatModelFactory chatModelFactory;
@Resource
private ToolsGlobalRegistry toolsGlobalRegistry;
@Resource
private SysRoleService roleService;
// 用于存储设备ID和验证码生成状态的映射
private final Map<String, Boolean> captchaGenerationInProgress = new ConcurrentHashMap<>();
/**
* 处理连接建立事件.
*
* @param chatSession
* @param deviceIdAuth
*/
public void afterConnection(ChatSession chatSession, String deviceIdAuth) {
String deviceId = deviceIdAuth;
String sessionId = chatSession.getSessionId();
// 注册会话
sessionManager.registerSession(sessionId, chatSession);
logger.info("开始查询设备信息 - DeviceId: {}", deviceId);
SysDevice device = Optional.ofNullable(deviceService.selectDeviceById(deviceId)).orElse(new SysDevice());
device.setDeviceId(deviceId);
device.setSessionId(sessionId);
sessionManager.registerDevice(sessionId, device);
// 如果已绑定,则初始化其他内容
if (!ObjectUtils.isEmpty(device) && device.getRoleId() != null) {
//这里需要放在虚拟线程外
ToolsSessionHolder toolsSessionHolder = new ToolsSessionHolder(chatSession.getSessionId(),
device, toolsGlobalRegistry);
chatSession.setFunctionSessionHolder(toolsSessionHolder);
//以上同步处理结束后再启动虚拟线程进行设备初始化确保chatSession中已设置的sysDevice信息
Thread.startVirtualThread(() -> {
try {
// 从数据库获取角色描述。device.getRoleId()表示当前设备的当前活跃角色,或者上次退出时的活跃角色。
SysRole role = roleService.selectRoleById(device.getRoleId());
if (role.getSttId() != null) {
SysConfig sttConfig = configService.selectConfigById(role.getSttId());
if (sttConfig != null) {
sttFactory.getSttService(sttConfig);// 提前初始化,加速后续使用
}
}
if (role.getTtsId() != null) {
SysConfig ttsConfig = configService.selectConfigById(role.getTtsId());
if (ttsConfig != null) {
ttsFactory.getTtsService(ttsConfig, role.getVoiceName());// 提前初始化,加速后续使用
}
}
if (role.getModelId() != null) {
chatModelFactory.takeChatModel(chatSession);// 提前初始化,加速后续使用
Conversation conversation = conversationFactory.initConversation(device, role, sessionId);
chatSession.setConversation(conversation);
// 注册全局函数
toolsSessionHolder.registerGlobalFunctionTools(chatSession);
}
// 更新设备状态
deviceService.update(new SysDevice()
.setDeviceId(device.getDeviceId())
.setState(SysDevice.DEVICE_STATE_ONLINE)
.setLastLogin(new Date().toString()));
} catch (Exception e) {
logger.error("设备初始化失败 - DeviceId: " + deviceId, e);
try {
sessionManager.closeSession(sessionId);
} catch (Exception ex) {
logger.error("关闭WebSocket连接失败", ex);
}
}
});
}
}
/**
* 处理连接关闭事件.
*
* @param sessionId
*/
public void afterConnectionClosed(String sessionId) {
ChatSession chatSession = sessionManager.getSession(sessionId);
if (chatSession == null || !chatSession.isOpen()) {
return;
}
// 连接关闭时清理资源
SysDevice device = sessionManager.getDeviceConfig(sessionId);
if (device != null) {
Thread.startVirtualThread(() -> {
try {
deviceService.update(new SysDevice()
.setDeviceId(device.getDeviceId())
.setState(SysDevice.DEVICE_STATE_OFFLINE)
.setLastLogin(new Date().toString()));
logger.info("WebSocket连接关闭 - SessionId: {}, DeviceId: {}", sessionId, device.getDeviceId());
} catch (Exception e) {
logger.error("更新设备状态失败", e);
}
});
}
// 清理会话
sessionManager.closeSession(sessionId);
// 清理VAD会话
vadService.resetSession(sessionId);
// 清理音频处理会话
audioService.cleanupSession(sessionId);
// 清理对话
dialogueService.cleanupSession(sessionId);
// 清理Conversation缓存的对话历史。
Conversation conversation = chatSession.getConversation();
if (conversation != null) {
conversation.clear();
}
}
/**
* 处理音频数据
*
* @param sessionId
* @param opusData
*/
public void handleBinaryMessage(String sessionId, byte[] opusData) {
ChatSession chatSession = sessionManager.getSession(sessionId);
if ((chatSession == null || !chatSession.isOpen()) && !vadService.isSessionInitialized(sessionId)) {
return;
}
// 委托给DialogueService处理音频数据
dialogueService.processAudioData(chatSession, opusData);
}
public void handleUnboundDevice(String sessionId, SysDevice device) {
String deviceId = device.getDeviceId();
if (device == null || deviceId == null) {
logger.error("设备或设备ID为空无法处理未绑定设备");
return;
}
ChatSession chatSession = sessionManager.getSession(sessionId);
if (chatSession == null || !chatSession.isOpen()) {
return;
}
// 检查是否已经在处理中使用CAS操作保证线程安全
Boolean previous = captchaGenerationInProgress.putIfAbsent(deviceId, true);
if (previous != null && previous) {
return; // 已经在处理中
}
Thread.startVirtualThread(() -> {
try {
// 设备已注册但未配置模型
if (device.getDeviceName() != null && device.getRoleId() == null) {
String message = "设备未配置角色,请到角色配置页面完成配置后开始对话";
String audioFilePath = ttsService.getDefaultTtsService().textToSpeech(message);
audioService.sendAudioMessage(chatSession, new DialogueService.Sentence(message, audioFilePath), true,
true);
// 延迟一段时间后再解除标记
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
captchaGenerationInProgress.remove(deviceId);
return;
}
// 设备未命名,生成验证码
// 生成新验证码
SysDevice codeResult = deviceService.generateCode(device);
String audioFilePath;
if (!StringUtils.hasText(codeResult.getAudioPath())) {
String codeMessage = "请到设备管理页面添加设备,输入验证码" + codeResult.getCode();
audioFilePath = ttsService.getDefaultTtsService().textToSpeech(codeMessage);
codeResult.setDeviceId(deviceId);
codeResult.setSessionId(sessionId);
codeResult.setAudioPath(audioFilePath);
deviceService.updateCode(codeResult);
} else {
audioFilePath = codeResult.getAudioPath();
}
audioService.sendAudioMessage(chatSession,
new DialogueService.Sentence(codeResult.getCode(), codeResult.getAudioPath()), true, true);
// 延迟一段时间后再解除标记
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
captchaGenerationInProgress.remove(deviceId);
} catch (Exception e) {
logger.error("处理未绑定设备失败", e);
captchaGenerationInProgress.remove(deviceId);
}
});
}
private void handleListenMessage(ChatSession chatSession, ListenMessage message) {
String sessionId = chatSession.getSessionId();
logger.info("收到listen消息 - SessionId: {}, State: {}, Mode: {}", sessionId, message.getState(), message.getMode());
chatSession.setMode(message.getMode());
// 根据state处理不同的监听状态
switch (message.getState()) {
case ListenState.Start:
// 开始监听,准备接收音频数据
logger.info("开始监听 - Mode: {}", message.getMode());
// 初始化VAD会话
vadService.initSession(sessionId);
break;
case ListenState.Stop:
// 停止监听
logger.info("停止监听");
// 关闭音频流
sessionManager.completeAudioStream(sessionId);
sessionManager.closeAudioStream(sessionId);
sessionManager.setStreamingState(sessionId, false);
// 重置VAD会话
vadService.resetSession(sessionId);
break;
case ListenState.Text:
// 检测聊天文本输入
if (audioService.isPlaying(sessionId)) {
dialogueService.abortDialogue(chatSession, message.getMode().getValue());
}
dialogueService.handleText(chatSession, message.getText(), null);
break;
case ListenState.Detect:
// 检测到唤醒词
dialogueService.handleWakeWord(chatSession, message.getText());
break;
default:
logger.warn("未知的listen状态: {}", message.getState());
}
}
private void handleAbortMessage(ChatSession session, AbortMessage message) {
dialogueService.abortDialogue(session, message.getReason());
}
private void handleIotMessage(ChatSession chatSession, IotMessage message) {
String sessionId = chatSession.getSessionId();
logger.info("收到IoT消息 - SessionId: {}", sessionId);
// 处理设备描述信息
if (message.getDescriptors() != null) {
logger.info("收到设备描述信息: {}", message.getDescriptors());
// 处理设备描述信息的逻辑
iotService.handleDeviceDescriptors(sessionId, message.getDescriptors());
}
// 处理设备状态更新
if (message.getStates() != null) {
logger.info("收到设备状态更新: {}", message.getStates());
// 处理设备状态更新的逻辑
iotService.handleDeviceStates(sessionId, message.getStates());
}
}
private void handleGoodbyeMessage(ChatSession session, GoodbyeMessage message) {
sessionManager.closeSession(session);
}
private void handleDeviceMcpMessage(ChatSession chatSession, DeviceMcpMessage message) {
Long mcpRequestId = message.getPayload().getId();
CompletableFuture<DeviceMcpMessage> future = chatSession.getDeviceMcpHolder().getMcpPendingRequests().get(mcpRequestId);
if(future != null){
future.complete(message);
chatSession.getDeviceMcpHolder().getMcpPendingRequests().remove(mcpRequestId);
}
}
public void handleMessage(Message msg, String sessionId) {
var chatSession = sessionManager.getSession(sessionId);
switch (msg) {
case ListenMessage m -> handleListenMessage(chatSession, m);
case IotMessage m -> handleIotMessage(chatSession, m);
case AbortMessage m -> handleAbortMessage(chatSession, m);
case GoodbyeMessage m -> handleGoodbyeMessage(chatSession, m);
case DeviceMcpMessage m -> handleDeviceMcpMessage(chatSession, m);
default -> {
}
}
}
}

View File

@@ -0,0 +1,491 @@
package com.xiaozhi.communication.common;
import com.xiaozhi.dialogue.llm.tool.ToolsSessionHolder;
import com.xiaozhi.entity.SysDevice;
import com.xiaozhi.entity.SysRole;
import com.xiaozhi.enums.ListenMode;
import com.xiaozhi.event.ChatSessionCloseEvent;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Sinks;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* WebSocket会话管理服务
* 负责管理所有WebSocket连接的会话状态
* 使用JDK 21虚拟线程实现异步处理
* TODO 重构计划可能没必要作为Service由Spring管理而是由Handler处理。
* TODO 实际底层驱动力来自于Handler后续服务都是基于Session而不需要SessionManager的。
*/
@Service
public class SessionManager {
private static final Logger logger = LoggerFactory.getLogger(SessionManager.class);
// 设置不活跃超时时间为60秒
private static final long INACTIVITY_TIMEOUT_SECONDS = 60;
// 用于存储所有连接的会话信息
private final ConcurrentHashMap<String, ChatSession> sessions = new ConcurrentHashMap<>();
// 存储验证码生成状态
private final ConcurrentHashMap<String, Boolean> captchaState = new ConcurrentHashMap<>();
// 定时任务执行器
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
@Resource
private ApplicationContext applicationContext;
/**
* 初始化方法,启动定时检查不活跃会话的任务
*/
@PostConstruct
public void init() {
// 每10秒检查一次不活跃的会话
scheduler.scheduleAtFixedRate(this::checkInactiveSessions, 10, 10, TimeUnit.SECONDS);
logger.info("不活跃会话检查任务已启动,超时时间: {}秒", INACTIVITY_TIMEOUT_SECONDS);
}
/**
* 销毁方法,关闭定时任务执行器
*/
@PreDestroy
public void destroy() {
scheduler.shutdown();
try {
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
} catch (InterruptedException e) {
scheduler.shutdownNow();
Thread.currentThread().interrupt();
}
logger.info("不活跃会话检查任务已关闭");
}
/**
* 检查不活跃的会话并关闭它们
* 使用虚拟线程实现异步处理
*/
private void checkInactiveSessions() {
Thread.startVirtualThread(() -> {
Instant now = Instant.now();
sessions.values().forEach(session -> {
Instant lastActivity = session.getLastActivityTime();
if (lastActivity != null) {
Duration inactiveDuration = Duration.between(lastActivity, now);
if (inactiveDuration.getSeconds() > INACTIVITY_TIMEOUT_SECONDS) {
logger.info("会话 {} 已经 {} 秒没有有效活动,自动关闭",
session.getSessionId(), inactiveDuration.getSeconds());
closeSession(session);
}
}
});
});
}
/**
* 更新会话的最后有效活动时间
* 这个方法应该只在检测到实际的用户活动时调用,如语音输入或明确的交互
*
* @param sessionId 会话ID
*/
public void updateLastActivity(String sessionId) {
ChatSession session = sessions.get(sessionId);
if(session != null){
session.setLastActivityTime(Instant.now());
}
}
/**
* 注册新的会话
*
* @param sessionId 会话ID
* @param chatSession 会话
*/
public void registerSession(String sessionId, ChatSession chatSession) {
sessions.put(sessionId, chatSession);
logger.info("会话已注册 - SessionId: {} SessionType: {}", sessionId, chatSession.getClass().getSimpleName());
}
/**
* 关闭并清理WebSocket会话
*
* @param sessionId 会话ID
*/
public void closeSession(String sessionId){
ChatSession chatSession = sessions.get(sessionId);
if(chatSession != null) {
closeSession(chatSession);
}
}
/**
* 关闭并清理WebSocket会话
* 使用虚拟线程实现异步处理
*
* @param chatSession 聊天session
*/
public void closeSession(ChatSession chatSession) {
if(chatSession == null){
return;
}
try {
sessions.remove(chatSession.getSessionId());
// 关闭会话
chatSession.close();
// 清理音频流
Sinks.Many<byte[]> sink = chatSession.getAudioSinks();
if (sink != null) {
sink.tryEmitComplete();
}
// 重置会话状态
chatSession.setStreamingState(false);
chatSession.setAudioSinks(null);
applicationContext.publishEvent(new ChatSessionCloseEvent(chatSession));
// 从会话映射中移除
logger.info("会话已关闭 - SessionId: {} SessionType: {}", chatSession.getSessionId(), chatSession.getClass().getSimpleName());
} catch (Exception e) {
logger.error("清理会话资源时发生错误 - SessionId: {}",
chatSession.getSessionId(), e);
}
}
/**
* 注册设备配置
*
* @param sessionId 会话ID
* @param device 设备信息
*/
public void registerDevice(String sessionId, SysDevice device) {
// 先检查是否已存在该sessionId的配置
ChatSession chatSession = sessions.get(sessionId);
if(chatSession != null){
chatSession.setSysDevice(device);
updateLastActivity(sessionId); // 更新活动时间
logger.debug("设备配置已注册 - SessionId: {}, DeviceId: {}", sessionId, device.getDeviceId());
}
}
/**
* 设置会话完成后是否关闭
*
* @param sessionId 会话ID
* @param close 是否关闭
*/
public void setCloseAfterChat(String sessionId, boolean close) {
ChatSession chatSession = sessions.get(sessionId);
if(chatSession != null){
chatSession.setCloseAfterChat(close);
}
}
/**
* 获取会话完成后是否关闭
*
* @param sessionId 会话ID
* @return 是否关闭
*/
public boolean isCloseAfterChat(String sessionId) {
ChatSession chatSession = sessions.get(sessionId);
if(chatSession != null){
return chatSession.isCloseAfterChat();
}else{
return true;
}
}
// /**
// * 缓存配置信息
// *
// * @param configId 配置ID
// * @param config 配置信息
// */
// public void cacheConfig(Integer configId, SysConfig config) {
// if (configId != null && config != null) {
// configCache.put(configId, config);
// }
// }
//
// /**
// * 删除配置
// *
// * @param configId 配置ID
// */
// public void removeConfig(Integer configId) {
// configCache.remove(configId);
// }
/**
* 获取会话
*
* @param sessionId 会话ID
* @return WebSocket会话
*/
public ChatSession getSession(String sessionId) {
return sessions.get(sessionId);
}
/**
* 获取会话
*
* @param deviceId 设备ID
* @return 会话ID
*/
public ChatSession getSessionByDeviceId(String deviceId) {
for (ChatSession chatSession : sessions.values()) {
if (chatSession.getSysDevice() != null && deviceId.equals(chatSession.getSysDevice().getDeviceId())) {
return chatSession;
}
}
return null;
}
/**
* 获取设备配置
*
* @param sessionId 会话ID
* @return 设备配置
*/
public SysDevice getDeviceConfig(String sessionId) {
ChatSession chatSession = sessions.get(sessionId);
if (chatSession != null) {
return chatSession.getSysDevice();
}
return null;
}
/**
* 获取会话的function holder
*
* @param sessionId 会话ID
* @return FunctionSessionHolder
*/
public ToolsSessionHolder getFunctionSessionHolder(String sessionId) {
ChatSession chatSession = sessions.get(sessionId);
if (chatSession != null) {
return chatSession.getFunctionSessionHolder();
}
return null;
}
/**
* 获取用户的可用角色列表
*
* @param sessionId 会话ID
* @return 角色列表
*/
public List<SysRole> getAvailableRoles(String sessionId) {
ChatSession chatSession = sessions.get(sessionId);
if (chatSession != null) {
return chatSession.getSysRoleList();
}
return null;
}
/**
* 音乐播放状态
*
* @param sessionId 会话ID
* @param isPlaying 是否正在播放音乐
*/
public void setMusicPlaying(String sessionId, boolean isPlaying) {
ChatSession chatSession = sessions.get(sessionId);
if (chatSession != null) {
chatSession.setMusicPlaying(isPlaying);
}
}
/**
* 是否在播放音乐
*
* @param sessionId 会话ID
* @return 是否正在播放音乐
*/
public boolean isMusicPlaying(String sessionId) {
ChatSession chatSession = sessions.get(sessionId);
if (chatSession != null) {
return chatSession.isMusicPlaying();
}
return false;
}
/**
* 播放状态
*
* @param sessionId 会话ID
* @param isPlaying 是否正在说话
*/
public void setPlaying(String sessionId, boolean isPlaying) {
ChatSession chatSession = sessions.get(sessionId);
if (chatSession != null) {
chatSession.setPlaying(isPlaying);
}
}
/**
* 是否在播放音乐
*
* @param sessionId 会话ID
* @return 是否正在播放音乐
*/
public boolean isPlaying(String sessionId) {
ChatSession chatSession = sessions.get(sessionId);
if (chatSession != null) {
return chatSession.isPlaying();
}
return false;
}
/**
* 设备状态
*
* @param sessionId
* @param mode 设备状态 auto/realTime
*/
public void setMode(String sessionId, ListenMode mode) {
ChatSession chatSession = sessions.get(sessionId);
if (chatSession != null) {
chatSession.setMode(mode);
}
}
/**
* 获取设备状态
*
* @param sessionId
*/
public ListenMode getMode(String sessionId) {
ChatSession chatSession = sessions.get(sessionId);
if (chatSession != null) {
return chatSession.getMode();
}
return ListenMode.Auto;
}
/**
* 设置流式识别状态
*
* @param sessionId 会话ID
* @param isStreaming 是否正在流式识别
*/
public void setStreamingState(String sessionId, boolean isStreaming) {
ChatSession chatSession = sessions.get(sessionId);
if (chatSession != null) {
chatSession.setStreamingState(isStreaming);
}
updateLastActivity(sessionId); // 更新活动时间
}
/**
* 获取流式识别状态
*
* @param sessionId 会话ID
* @return 是否正在流式识别
*/
public boolean isStreaming(String sessionId) {
ChatSession chatSession = sessions.get(sessionId);
if (chatSession != null) {
return chatSession.isStreamingState();
}
return false;
}
/**
* 创建音频数据流
*
* @param sessionId 会话ID
*/
public void createAudioStream(String sessionId) {
Sinks.Many<byte[]> sink = Sinks.many().multicast().onBackpressureBuffer();
ChatSession chatSession = sessions.get(sessionId);
if (chatSession != null) {
chatSession.setAudioSinks(sink);
}
}
/**
* 获取音频数据流
*
* @param sessionId 会话ID
* @return 音频数据流
*/
public Sinks.Many<byte[]> getAudioStream(String sessionId) {
ChatSession chatSession = sessions.get(sessionId);
if (chatSession != null) {
return chatSession.getAudioSinks();
}
return null;
}
/**
* 发送音频数据
*
* @param sessionId 会话ID
* @param data 音频数据
*/
public void sendAudioData(String sessionId, byte[] data) {
Sinks.Many<byte[]> sink = getAudioStream(sessionId);
if (sink != null) {
sink.tryEmitNext(data);
}
}
/**
* 完成音频流
*
* @param sessionId 会话ID
*/
public void completeAudioStream(String sessionId) {
Sinks.Many<byte[]> sink = getAudioStream(sessionId);
if (sink != null) {
sink.tryEmitComplete();
}
}
/**
* 关闭音频流
*
* @param sessionId 会话ID
*/
public void closeAudioStream(String sessionId) {
Sinks.Many<byte[]> sink = getAudioStream(sessionId);
ChatSession chatSession = sessions.get(sessionId);
if (chatSession != null) {
chatSession.setAudioSinks(null);
}
}
/**
* 标记设备正在生成验证码
*
* @param deviceId 设备ID
* @return 如果设备之前没有在生成验证码返回true否则返回false
*/
public boolean markCaptchaGeneration(String deviceId) {
return captchaState.putIfAbsent(deviceId, Boolean.TRUE) == null;
}
/**
* 取消设备验证码生成标记
*
* @param deviceId 设备ID
*/
public void unmarkCaptchaGeneration(String deviceId) {
captchaState.remove(deviceId);
}
}

View File

@@ -0,0 +1,14 @@
package com.xiaozhi.communication.domain;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public final class AbortMessage extends Message {
public AbortMessage() {
super("abort");
}
private String reason;
}

View File

@@ -0,0 +1,23 @@
package com.xiaozhi.communication.domain;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain = true)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class AudioParams {
private int channels;
private String format;
private int sampleRate;
private int frameDuration;
public static final AudioParams Opus = new AudioParams()
.setChannels(1)
.setFormat("opus")
.setSampleRate(16000)
.setFrameDuration(60);
}

View File

@@ -0,0 +1,20 @@
package com.xiaozhi.communication.domain;
import com.xiaozhi.communication.domain.mcp.device.DeviceMcpPayload;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* MCP请求类用于处理向设备发送相关的MCP请求
*/
@Data
@EqualsAndHashCode(callSuper = true)
public final class DeviceMcpMessage extends Message {
public DeviceMcpMessage() {
super("mcp");
}
private String sessionId;//会话id
private String type = "mcp";
private DeviceMcpPayload payload;
}

View File

@@ -0,0 +1,12 @@
package com.xiaozhi.communication.domain;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public final class GoodbyeMessage extends Message {
public GoodbyeMessage() {
super("goodbye");
}
}

View File

@@ -0,0 +1,15 @@
package com.xiaozhi.communication.domain;
import lombok.Data;
@Data
public class HelloFeatures {
/**
* 设备是否启用mcp
*/
private Boolean mcp = false;
/**
* 设备是否启用服务端aec
*/
private Boolean aec = false;
}

View File

@@ -0,0 +1,15 @@
package com.xiaozhi.communication.domain;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public final class HelloMessage extends Message {
public HelloMessage() {
super("hello");
}
private HelloFeatures features;
private AudioParams audioParams;
}

View File

@@ -0,0 +1,16 @@
package com.xiaozhi.communication.domain;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain = true)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class HelloMessageResp {
private String type = "hello";
private String transport;
private String sessionId;
private AudioParams audioParams;
}

View File

@@ -0,0 +1,21 @@
package com.xiaozhi.communication.domain;
import com.xiaozhi.communication.domain.iot.IotDescriptor;
import com.xiaozhi.communication.domain.iot.IotState;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
public final class IotMessage extends Message {
public IotMessage() {
super("iot");
}
private boolean update;
private String sessionId;
private List<IotState> states;
private List<IotDescriptor> descriptors;
}

View File

@@ -0,0 +1,18 @@
package com.xiaozhi.communication.domain;
import com.xiaozhi.enums.ListenMode;
import com.xiaozhi.enums.ListenState;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public final class ListenMessage extends Message {
public ListenMessage(){
super("listen");
}
private ListenState state;
private ListenMode mode;
private String text;
}

View File

@@ -0,0 +1,38 @@
package com.xiaozhi.communication.domain;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import lombok.Data;
import javax.validation.constraints.NotNull;
@Data
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "type",
visible = true)
@JsonSubTypes({
@JsonSubTypes.Type(value = HelloMessage.class, name = "hello"),
@JsonSubTypes.Type(value = DeviceMcpMessage.class, name = "mcp"),
@JsonSubTypes.Type(value = ListenMessage.class, name = "listen"),
@JsonSubTypes.Type(value = IotMessage.class, name = "iot"),
@JsonSubTypes.Type(value = AbortMessage.class, name = "abort"),
@JsonSubTypes.Type(value = GoodbyeMessage.class, name = "goodbye"),
@JsonSubTypes.Type(value = UnknownMessage.class, name = "unknown")
})
public sealed abstract class Message
permits AbortMessage, GoodbyeMessage, HelloMessage, IotMessage, ListenMessage, DeviceMcpMessage, UnknownMessage {
public Message() {
this.type = "unknown";
}
public Message(String type) {
this.type = type;
}
@NotNull(message = "消息类型不能为空")
protected String type;
}

View File

@@ -0,0 +1,12 @@
package com.xiaozhi.communication.domain;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public final class UnknownMessage extends Message {
public UnknownMessage() {
super("unknown");
}
}

View File

@@ -0,0 +1,22 @@
package com.xiaozhi.communication.domain.iot;
import com.xiaozhi.utils.JsonUtil;
import lombok.Data;
import java.util.Map;
/**
* Iot设备描述信息
*/
@Data
public class IotDescriptor {
private String name;
private String description;
private Map<String, IotProperty> properties;
private Map<String, IotMethod> methods;
@Override
public String toString() {
return JsonUtil.toJson(this);
}
}

View File

@@ -0,0 +1,26 @@
package com.xiaozhi.communication.domain.iot;
import com.xiaozhi.utils.JsonUtil;
import lombok.Data;
import java.util.Map;
/**
* function_call的方法定义
*/
@Data
public class IotMethod {
/**
* 方法描述
*/
private String description;
/**
* 方法参数
*/
private Map<String, IotMethodParameter> parameters;
@Override
public String toString() {
return JsonUtil.toJson(this);
}
}

View File

@@ -0,0 +1,24 @@
package com.xiaozhi.communication.domain.iot;
import com.xiaozhi.utils.JsonUtil;
import lombok.Data;
/**
* function_call的参数定义
*/
@Data
public class IotMethodParameter {
/**
* 参数描述
*/
private String description;
/**
* 参数类型
*/
private String type;
@Override
public String toString() {
return JsonUtil.toJson(this);
}
}

View File

@@ -0,0 +1,28 @@
package com.xiaozhi.communication.domain.iot;
import com.xiaozhi.utils.JsonUtil;
import lombok.Data;
/**
* function_call的参数定义
*/
@Data
public class IotProperty {
/**
* 参数描述
*/
private String description;
/**
* 参数类型
*/
private String type;
/**
* 参数值
*/
private Object value;
@Override
public String toString() {
return JsonUtil.toJson(this);
}
}

View File

@@ -0,0 +1,11 @@
package com.xiaozhi.communication.domain.iot;
import lombok.Data;
import java.util.Map;
@Data
public class IotState {
private String name;
private Map<String, Object> state;
}

View File

@@ -0,0 +1,16 @@
package com.xiaozhi.communication.domain.mcp.device;
import lombok.Data;
import java.util.Map;
@Data
public class DeviceMcpPayload {
private String jsonrpc = "2.0";
private String method;//方法名称
private Object params;
private Long id;//请求id
private Map<String, Object> result;//请求结果
private Map<String, Object> error;//请求失败信息
}

View File

@@ -0,0 +1,9 @@
package com.xiaozhi.communication.domain.mcp.device.initialize;
import lombok.Data;
@Data
public class DeviceMcpClientInfo {
private String name = "xiaozhi-mqtt-client";
private String version = "1.0.0";
}

View File

@@ -0,0 +1,13 @@
package com.xiaozhi.communication.domain.mcp.device.initialize;
import lombok.Data;
import java.util.Collections;
import java.util.Map;
@Data
public class DeviceMcpInitialize {
private String protocolVersion = "2024-11-05";
private Map<String, Object> capabilities = Collections.emptyMap();
private DeviceMcpClientInfo clientInfo = new DeviceMcpClientInfo();
}

View File

@@ -0,0 +1,12 @@
package com.xiaozhi.communication.domain.mcp.device.initialize;
import lombok.Data;
/**
* 摄像头视觉相关
*/
@Data
public class DeviceMcpVision {
private String url;//摄像头: 图片处理地址(必须是http地址, 不是websocket地址)
private String token;// url toke
}

Some files were not shown because too many files have changed in this diff Show More