Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4dc09b580 | |||
| f10b650a8f | |||
| f7413acd3e | |||
| f42b4a1036 | |||
| 7d3d71e0e3 | |||
| 6a44d27fd3 | |||
| a90a081973 | |||
| 4736273902 | |||
| fba01f4909 | |||
| 42aa934493 | |||
| fe73fdd5bd | |||
| c112f02fcc | |||
| 4d2467ae36 | |||
| 0b6e6914fd | |||
| 0f76b61152 | |||
| 6cfff15c62 | |||
| 6d3887ec06 | |||
| 5af0b07775 | |||
| 8ae10dd2c3 | |||
| 17c1ce2cc6 | |||
| a209dc8080 | |||
| 1d4fff13a1 | |||
| a811e36635 | |||
| d8a0dd5d31 | |||
| 0ff979917c | |||
| fc5615a9c7 | |||
| cdcd9eeffa | |||
| c0da89735c | |||
| fc34d83692 | |||
| 8ceb252d79 | |||
| 8da4563435 | |||
| 045e6ab3df | |||
| 5591e17446 | |||
| 8f783fd765 | |||
| 6b41e46f30 | |||
| ff89fdf5e9 | |||
| 402c425575 | |||
| ee2785f972 | |||
| bbb0271a5e | |||
| 75ff4bb0a4 | |||
| 776f0ed029 | |||
| eef56291eb | |||
| 8e159edf1d | |||
| 1793e4b2aa | |||
| a793ed541b | |||
| 09ed1bd427 | |||
| ff666975da | |||
| b4403cedd9 | |||
| 98d2eb8a2a | |||
| ae5f56c16f | |||
| d374034694 | |||
| b0f399c814 | |||
| 8489ef35cb | |||
| 7d8c2d4e37 | |||
| ec60eee8fe | |||
| dac15250a2 | |||
| fec0198537 | |||
| f5ac4d10c0 | |||
| ca07a6cea5 | |||
| b5d89aef72 | |||
| d151d45e99 | |||
| 3525af81bf | |||
| 01c86ce0a3 | |||
| e5e619a241 | |||
| d9b10c1621 | |||
| c6e72e5b79 | |||
| 980effc420 | |||
| e4040a27e7 | |||
| 23170dcc3f | |||
| 64c92857a6 | |||
| 4346bfebb7 | |||
| c24271c075 | |||
| 526982c431 | |||
| c161bc55e5 | |||
| eb79ad260c | |||
| ce8e59902c | |||
| b8ed400d12 | |||
| 5f48980d31 | |||
| b81e7b8b1b | |||
| a468c0919d | |||
| 1ae5b46523 | |||
| ecac45a74a | |||
| 51c4cb7767 | |||
| ae7cfebb44 | |||
| e4ccbbcbd1 | |||
| bdfcd1cedb | |||
| 6a7b465944 | |||
| 41ac96630c | |||
| 3a8fbc3e1b | |||
| e3e57ee154 | |||
| 0883c1318b | |||
| fe79d04343 | |||
| 2857283558 | |||
| 981779126c | |||
| 39ce5882cb | |||
| e51f6c6544 | |||
| 3a7f510e19 | |||
| 1c9e72e28d | |||
| 9d79b2585e | |||
| 1fc9a39ffe | |||
| e2d6a02860 | |||
| 1f8504f002 | |||
| 9ff2492962 | |||
| 88355a3f48 | |||
| 3effc03a19 | |||
| be65996398 | |||
| c58bf929b1 | |||
| ec1b93e473 | |||
| 8a78cea36d | |||
| 96f0d9b602 | |||
| ee884a3737 | |||
| 326dbb1c3c | |||
| f03dbbfc60 | |||
| c1170aafa8 | |||
| 198871d3c9 | |||
| caaf85290f |
27
.env.development
Normal file
27
.env.development
Normal file
@@ -0,0 +1,27 @@
|
||||
# 项目配置, 请根据实际情况修改
|
||||
PROJECT_NAME=newshop
|
||||
|
||||
# ThinkPHP 6.x 配置, 请根据实际情况修改
|
||||
APP_ENV=development
|
||||
|
||||
# PHP/PHP-FPM 配置
|
||||
PHP_VERSION=7.4
|
||||
PHP_FPM_VERSION=7.4-fpm
|
||||
PHP_FPM_PORT=9105
|
||||
XDEBUG_POST=9108
|
||||
|
||||
# 数据库配置
|
||||
MYSQL_ROOT_HOST=%
|
||||
MYSQL_DATABASE=shop_mallnew
|
||||
MYSQL_USER=shop_mallnew
|
||||
MYSQL_PASSWORD=shop_mallnew
|
||||
MYSQL_PORT=3326
|
||||
|
||||
# Redis 绑定端口及密码
|
||||
REDIS_PASSWORD=luckyshop123!@#
|
||||
REDIS_PORT=6499
|
||||
|
||||
# Nginx 暴漏端口
|
||||
NGINX_PORT=8050
|
||||
NGINX_SSL_PORT=8052
|
||||
|
||||
16
.env.example
16
.env.example
@@ -1,24 +1,26 @@
|
||||
# 项目配置, 请根据实际情况修改
|
||||
PROJECT_NAME=newshop
|
||||
|
||||
# ThinkPHP 6.x 配置, 请根据实际情况修改
|
||||
APP_ENV=development
|
||||
|
||||
# PHP/PHP-FPM 配置
|
||||
PHP_VERSION=7.4
|
||||
PHP_FPM_VERSION=7.4-fpm
|
||||
PHP_FPM_PORT=9000
|
||||
XDEBUG_POST=9003
|
||||
PHP_FPM_PORT=9100
|
||||
XDEBUG_POST=9103
|
||||
|
||||
# 数据库配置
|
||||
MYSQL_ROOT_HOST=%
|
||||
MYSQL_DATABASE=shop_mallnew
|
||||
MYSQL_USER=shop_mallnew
|
||||
MYSQL_PASSWORD=shop_mallnew
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_PORT=3316
|
||||
|
||||
# Redis 配置
|
||||
REDIS_PASSWORD=luckyshop123!@#
|
||||
REDIS_PORT=6379
|
||||
REDIS_PORT=6399
|
||||
|
||||
# Nginx 配置
|
||||
NGINX_PORT=80
|
||||
NGINX_SSL_PORT=443
|
||||
|
||||
NGINX_PORT=8010
|
||||
NGINX_SSL_PORT=8012
|
||||
|
||||
27
.env.local
Normal file
27
.env.local
Normal file
@@ -0,0 +1,27 @@
|
||||
# 项目配置, 请根据实际情况修改
|
||||
PROJECT_NAME=newshop
|
||||
|
||||
# ThinkPHP 6.x 配置, 请根据实际情况修改
|
||||
APP_ENV=local
|
||||
|
||||
# PHP/PHP-FPM 配置
|
||||
PHP_VERSION=7.4
|
||||
PHP_FPM_VERSION=7.4-fpm
|
||||
PHP_FPM_PORT=9100
|
||||
XDEBUG_POST=9103
|
||||
|
||||
# 数据库配置
|
||||
MYSQL_ROOT_HOST=%
|
||||
MYSQL_DATABASE=shop_dev
|
||||
MYSQL_USER=shop_mallnew
|
||||
MYSQL_PASSWORD=shop_mallnew
|
||||
MYSQL_PORT=3316
|
||||
|
||||
# Redis 配置
|
||||
REDIS_PASSWORD=luckyshop123!@#
|
||||
REDIS_PORT=6399
|
||||
|
||||
# Nginx 配置
|
||||
NGINX_PORT=8010
|
||||
NGINX_SSL_PORT=8012
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -18,6 +18,9 @@ __pycache__
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# 环境变量
|
||||
.env
|
||||
|
||||
# 源码结构
|
||||
debug.txt
|
||||
.travis.yml
|
||||
|
||||
43
README.md
Normal file
43
README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 在线商城PHP项目
|
||||
|
||||
|
||||
## Docker 部署
|
||||
|
||||
```bash
|
||||
cp .env.example .env.development
|
||||
```
|
||||
|
||||
**注意**
|
||||
|
||||
- 在同一目录下面,执行 `docker-compose` 命令时,需要指定项目名称。用来区分不同的环境。如 `shop_local`、`shop_dev` 等。
|
||||
- 本地部署时,需要将 `APP_ENV` 设置为 `local`。
|
||||
- 开发环境部署时,需要将 `APP_ENV` 设置为 `development`。
|
||||
|
||||
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `APP_ENV`: 应用环境,默认值为 `development`。
|
||||
|
||||
## 开发环境-local 部署
|
||||
|
||||
```bash
|
||||
# 本地部署时,需要将 APP_ENV 设置为 local, 并指定 docker-compose.local.yml 文件
|
||||
docker-compose --env-file .env.local -f docker-compose.local.yml up -d
|
||||
# docker-compose --project-name shop_local --env-file .env.local -f docker-compose.local.yml up -d
|
||||
|
||||
# docker-compose down 命令,用来停止并删除容器
|
||||
docker-compose -f docker-compose.local.yml down -v
|
||||
# docker-compose --project-name shop_local down -v
|
||||
```
|
||||
|
||||
|
||||
## 开发环境-development 部署
|
||||
|
||||
```bash
|
||||
# 默认使用 docker-compose.yml 文件
|
||||
docker-compose --project-name shop_development --env-file .env.development up -d
|
||||
|
||||
# docker-compose down 命令,用来停止并删除容器
|
||||
docker-compose --project-name shop_development down -v
|
||||
```
|
||||
126
docker-compose.local.yml
Normal file
126
docker-compose.local.yml
Normal file
@@ -0,0 +1,126 @@
|
||||
# 特别说明,本地local环境,方便操作,所以未使用的统一的docker-compose.yml文件,只保留了local环境的配置
|
||||
|
||||
x-shared-env: &shared-api-env
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword}
|
||||
MYSQL_ROOT_HOST: ${MYSQL_ROOT_HOST:-'%'} # 允许root从任何主机连接
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-shop_mallnew}
|
||||
MYSQL_USER: ${MYSQL_USER:-shop_mallnew}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-shop_mallnew}
|
||||
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-luckyshop123!@#}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
|
||||
# 将服务归类到目录 A 中
|
||||
services:
|
||||
php-fpm:
|
||||
build:
|
||||
context: ./docker/php
|
||||
dockerfile: Dockerfile
|
||||
container_name: ${PROJECT_NAME}_php
|
||||
restart: always
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway" # 支持主机名解析
|
||||
environment:
|
||||
PHP_ENV: ${PHP_ENV:-development}
|
||||
# 环境变量, APP_ENV 应用于 ThinkPHP 6.x 框架, .env.local 要想启用,需要在项目根目录下创建 .env.local 文件,并将 APP_ENV 设置为 local
|
||||
# 同理,如果要启用开发环境,则将 APP_ENV 设置为 development,如果要启用生产环境,则将 APP_ENV 设置为 production
|
||||
# 不然,ThinkPHP 6.x 系列,会只加载 .env 文件,而不会加载 .env.local 文件,导致 .env.local 文件中的配置不会生效
|
||||
APP_ENV: ${APP_ENV:-local}
|
||||
APP_DEBUG: ${APP_DEBUG:-true}
|
||||
XDEBUG_CONFIG: ${XDEBUG_CONFIG:-client_host=host.docker.internal client_port=9003}
|
||||
PHP_IDE_CONFIG: serverName=docker-php
|
||||
ports:
|
||||
- "${PHP_FPM_PORT:-9000}:9000" # PHP-FPM
|
||||
- "${XDEBUG_POST:-9003}:9003" # Xdebug
|
||||
volumes:
|
||||
- ./:/var/www/all_source
|
||||
- ./src:/var/www/html
|
||||
# 更新下载源列表以加速apt-get
|
||||
- ./docker/debian/sources.list:/etc/apt/sources.list:ro
|
||||
- ./docker/php/php.ini:/usr/local/etc/php/php.ini:ro
|
||||
- ./docker/php/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini
|
||||
- xdebug_logs:/tmp # Xdebug 日志目录
|
||||
depends_on:
|
||||
- db
|
||||
healthcheck:
|
||||
test: ["CMD", "bash", "-c", "curl -f http://localhost:9000/status && ps aux | grep '[p]hp think cron:schedule'"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
networks:
|
||||
- sass-platform-net
|
||||
labels:
|
||||
- "com.docker.compose.project.working_dir=${PROJECT_NAME}"
|
||||
|
||||
nginx:
|
||||
build:
|
||||
context: ./docker/nginx
|
||||
dockerfile: Dockerfile
|
||||
container_name: ${PROJECT_NAME}_nginx
|
||||
restart: always
|
||||
ports:
|
||||
- "${NGINX_PORT:-80}:80"
|
||||
- "${NGINX_SSL_PORT:-443}:443"
|
||||
volumes:
|
||||
# 挂载项目代码到 Nginx 容器中
|
||||
- ./src:/var/www/html:rw
|
||||
# 更新下载源列表以加速apt-get
|
||||
- ./docker/debian/sources.list:/etc/apt/sources.list:ro
|
||||
# 创建临时目录
|
||||
- /var/www/server/nginx/proxy_temp_dir
|
||||
- /var/www/server/nginx/proxy_cache_dir
|
||||
depends_on:
|
||||
- php-fpm
|
||||
networks:
|
||||
- sass-platform-net
|
||||
labels:
|
||||
- "com.docker.compose.project.working_dir=${PROJECT_NAME}"
|
||||
|
||||
db:
|
||||
image: mysql:5.7.44
|
||||
container_name: ${PROJECT_NAME}_mysql
|
||||
environment:
|
||||
<<: *shared-api-env
|
||||
volumes:
|
||||
- mysql_db_data:/var/lib/mysql
|
||||
- ./docker/mysql/init:/docker-entrypoint-initdb.d
|
||||
- ./docker/mysql/my.cnf:/etc/mysql/conf.d/custom.cnf
|
||||
ports:
|
||||
- ${MYSQL_PORT:-3306}:3306
|
||||
networks:
|
||||
- sass-platform-net
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
- --innodb_buffer_pool_size=256M
|
||||
labels:
|
||||
- "com.docker.compose.project.working_dir=${PROJECT_NAME}"
|
||||
|
||||
# Redis 服务(可选)
|
||||
redis:
|
||||
image: redis:8.2
|
||||
container_name: ${PROJECT_NAME}_redis
|
||||
environment:
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-luckyshop123!@#}
|
||||
REDISCLI_AUTH: ${REDIS_PASSWORD:-luckyshop123!@#}
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
- ./docker/redis/redis.conf:/etc/redis/redis.conf
|
||||
networks:
|
||||
- sass-platform-net
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "com.docker.compose.project.working_dir=${PROJECT_NAME}"
|
||||
|
||||
volumes:
|
||||
mysql_db_data:
|
||||
redis_data:
|
||||
xdebug_logs:
|
||||
|
||||
networks:
|
||||
sass-platform-net:
|
||||
driver: bridge
|
||||
@@ -14,7 +14,7 @@ services:
|
||||
build:
|
||||
context: ./docker/php
|
||||
dockerfile: Dockerfile
|
||||
container_name: ${PROJECT_NAME}_php
|
||||
container_name: ${PROJECT_NAME}_${APP_ENV}_php
|
||||
restart: always
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway" # 支持主机名解析
|
||||
@@ -49,19 +49,20 @@ services:
|
||||
networks:
|
||||
- sass-platform-net
|
||||
labels:
|
||||
- "com.docker.compose.project.working_dir=${PROJECT_NAME}"
|
||||
- "com.docker.compose.project.working_dir=${PROJECT_NAME}_${APP_ENV}"
|
||||
|
||||
nginx:
|
||||
build:
|
||||
context: ./docker/nginx
|
||||
dockerfile: Dockerfile
|
||||
container_name: ${PROJECT_NAME}_nginx
|
||||
container_name: ${PROJECT_NAME}_${APP_ENV}_nginx
|
||||
restart: always
|
||||
ports:
|
||||
- "${NGINX_PORT:-80}:80"
|
||||
- "${NGINX_SSL_PORT:-443}:443"
|
||||
volumes:
|
||||
- ./src:/var/www/html:ro
|
||||
# 挂载项目代码到 Nginx 容器中
|
||||
- ./src:/var/www/html:rw
|
||||
# 更新下载源列表以加速apt-get
|
||||
- ./docker/debian/sources.list:/etc/apt/sources.list:ro
|
||||
# 创建临时目录
|
||||
@@ -72,11 +73,11 @@ services:
|
||||
networks:
|
||||
- sass-platform-net
|
||||
labels:
|
||||
- "com.docker.compose.project.working_dir=${PROJECT_NAME}"
|
||||
- "com.docker.compose.project.working_dir=${PROJECT_NAME}_${APP_ENV}"
|
||||
|
||||
db:
|
||||
image: mysql:5.7.44
|
||||
container_name: ${PROJECT_NAME}_mysql
|
||||
container_name: ${PROJECT_NAME}_${APP_ENV}_mysql
|
||||
environment:
|
||||
<<: *shared-api-env
|
||||
volumes:
|
||||
@@ -93,12 +94,12 @@ services:
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
- --innodb_buffer_pool_size=256M
|
||||
labels:
|
||||
- "com.docker.compose.project.working_dir=${PROJECT_NAME}"
|
||||
- "com.docker.compose.project.working_dir=${PROJECT_NAME}_${APP_ENV}"
|
||||
|
||||
# Redis 服务(可选)
|
||||
redis:
|
||||
image: redis:8.2
|
||||
container_name: ${PROJECT_NAME}_redis
|
||||
container_name: ${PROJECT_NAME}_${APP_ENV}_redis
|
||||
environment:
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-luckyshop123!@#}
|
||||
REDISCLI_AUTH: ${REDIS_PASSWORD:-luckyshop123!@#}
|
||||
@@ -111,13 +112,32 @@ services:
|
||||
- sass-platform-net
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "com.docker.compose.project.working_dir=${PROJECT_NAME}"
|
||||
- "com.docker.compose.project.working_dir=${PROJECT_NAME}_${APP_ENV}"
|
||||
|
||||
volumes:
|
||||
mysql_db_data:
|
||||
name: ${PROJECT_NAME}_${APP_ENV}_mysql_db_data
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ./docker/mysql_db_data/${APP_ENV}
|
||||
redis_data:
|
||||
name: ${PROJECT_NAME}_${APP_ENV}_redis_data
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ./docker/redis_data/${APP_ENV}
|
||||
xdebug_logs:
|
||||
name: ${PROJECT_NAME}_${APP_ENV}_xdebug_logs
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ./docker/xdebug_logs/${APP_ENV}
|
||||
|
||||
networks:
|
||||
sass-platform-net:
|
||||
name: ${PROJECT_NAME}_${APP_ENV}_net
|
||||
driver: bridge
|
||||
File diff suppressed because one or more lines are too long
10
docker/mysql_db_data/.gitignore
vendored
Normal file
10
docker/mysql_db_data/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# 忽略目录下所有文件和子目录
|
||||
*
|
||||
# 忽略所有子目录
|
||||
*/
|
||||
# 但不忽略 .gitkeep 文件
|
||||
!.gitkeep
|
||||
# 不忽略 .gitignore 文件自身
|
||||
!.gitignore
|
||||
# 不忽略 development/.gitkeep 文件
|
||||
!development/.gitkeep
|
||||
0
docker/mysql_db_data/.gitkeep
Normal file
0
docker/mysql_db_data/.gitkeep
Normal file
@@ -27,6 +27,7 @@ RUN apt-get update && apt-get install -y \
|
||||
libfreetype6-dev \
|
||||
libjpeg62-turbo-dev \
|
||||
libpng-dev \
|
||||
iputils-ping \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装 PHP 扩展
|
||||
@@ -66,21 +67,9 @@ RUN echo "zend_extension=xdebug.so" > /usr/local/etc/php/conf.d/xdebug.ini
|
||||
# RUN composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/
|
||||
# RUN composer install --no-dev --optimize-autoloader --working-dir=/var/www/html
|
||||
|
||||
# # 创建非 root 用户
|
||||
# RUN useradd -m -u 1000 phpuser && chown -R phpuser:phpuser /var/www/html
|
||||
|
||||
# 设置权限, 防止以下目录无法写入的问题
|
||||
RUN chmod -R a+rw /var/www/html/runtime
|
||||
RUN chmod -R a+rw /var/www/html/uploads
|
||||
RUN chmod -R a+rw /var/www/html/tmp
|
||||
RUN chmod -R a+rw /var/www/html/temp
|
||||
|
||||
# USER phpuser
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 9000 9003
|
||||
|
||||
|
||||
############ 查看 cron 进程
|
||||
## 查看 cron 进程
|
||||
# ps aux | grep "think cron:schedule"
|
||||
@@ -95,4 +84,10 @@ EXPOSE 9000 9003
|
||||
#######################################
|
||||
|
||||
# 启动Supervisor
|
||||
# 添加在Dockerfile末尾,CMD命令之前
|
||||
COPY ./entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
# 修改CMD命令
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
59
docker/php/entrypoint.sh
Normal file
59
docker/php/entrypoint.sh
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# 设置全局umask
|
||||
umask 0002
|
||||
|
||||
echo "=== ThinkPHP Docker权限初始化 ==="
|
||||
|
||||
# 修复目录所有权和权限
|
||||
fix_directory_permissions() {
|
||||
local dir=$1
|
||||
echo "修复目录权限: $dir"
|
||||
|
||||
# 确保目录存在
|
||||
mkdir -p "$dir"
|
||||
|
||||
# 设置所有权
|
||||
chown -R www-data:www-data "$dir"
|
||||
|
||||
# 设置权限
|
||||
chmod -R 775 "$dir"
|
||||
|
||||
# 设置setgid权限
|
||||
chmod g+s "$dir"
|
||||
|
||||
# 尝试设置ACL(如果支持)
|
||||
if command -v setfacl >/dev/null 2>&1; then
|
||||
setfacl -d -m u:www-data:rwx -m u:root:rwx "$dir" 2>/dev/null || true
|
||||
setfacl -Rm u:www-data:rwx "$dir" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "✅ $dir 权限设置完成"
|
||||
}
|
||||
|
||||
# 处理所有需要权限的目录
|
||||
directories=("runtime" "upload")
|
||||
for dir in "${directories[@]}"; do
|
||||
fix_directory_permissions "/var/www/html/$dir"
|
||||
done
|
||||
|
||||
# 验证权限
|
||||
echo "=== 权限验证 ==="
|
||||
echo "当前用户: $(whoami)"
|
||||
echo "当前UID: $(id -u), GID: $(id -g)"
|
||||
echo "当前umask: $(umask)"
|
||||
|
||||
# 测试写入权限
|
||||
sudo -u www-data mkdir -p /var/www/html/runtime/test_dir 2>/dev/null && \
|
||||
echo "✅ runtime目录新建子目录测试通过" || \
|
||||
echo "❌ runtime目录新建子目录失败"
|
||||
|
||||
sudo -u www-data mkdir -p /var/www/html/upload/test_dir 2>/dev/null && \
|
||||
echo "✅ upload目录新建子目录测试通过" || \
|
||||
echo "❌ upload目录新建子目录失败"
|
||||
|
||||
echo "=== 启动应用 ==="
|
||||
|
||||
# 执行原有的启动命令
|
||||
exec "$@"
|
||||
@@ -6,24 +6,40 @@ logfile_backups=10
|
||||
loglevel=info
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:chmod]
|
||||
command=/bin/bash -c "while true; do chmod -R 775 /var/www/html/runtime/ /var/www/html/upload/ 2>/dev/null || true; sleep 30; done"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
|
||||
[program:php-fpm]
|
||||
command=php-fpm
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startretries=3
|
||||
startsecs=1
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
stdout_logfile=/var/log/supervisor/php-fpm.log
|
||||
stdout_logfile_maxbytes=10MB
|
||||
stdout_logfile_backups=10
|
||||
stderr_logfile=/var/log/supervisor/php-fpm-error.log
|
||||
stderr_logfile_maxbytes=10MB
|
||||
stderr_logfile_backups=10
|
||||
|
||||
[program:think-cron]
|
||||
command=php /var/www/html/think cron:schedule
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
numprocs=1
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startretries=5
|
||||
startsecs=2
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
startretries=3
|
||||
stdout_logfile=/var/log/supervisor/think-cron.log
|
||||
stdout_logfile_maxbytes=10MB
|
||||
stdout_logfile_backups=10
|
||||
stderr_logfile=/var/log/supervisor/think-cron-error.log
|
||||
stderr_logfile_maxbytes=10MB
|
||||
stderr_logfile_backups=10
|
||||
startsecs=3
|
||||
stopwaitsecs=10
|
||||
10
docker/redis_data/.gitignore
vendored
Normal file
10
docker/redis_data/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# 忽略目录下所有文件和子目录
|
||||
*
|
||||
# 忽略所有子目录
|
||||
*/
|
||||
# 但不忽略 .gitkeep 文件
|
||||
!.gitkeep
|
||||
# 不忽略 .gitignore 文件自身
|
||||
!.gitignore
|
||||
# 不忽略 development/.gitkeep 文件
|
||||
!development/.gitkeep
|
||||
0
docker/redis_data/.gitkeep
Normal file
0
docker/redis_data/.gitkeep
Normal file
10
docker/xdebug_logs/.gitignore
vendored
Normal file
10
docker/xdebug_logs/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# 忽略目录下所有文件和子目录
|
||||
*
|
||||
# 忽略所有子目录
|
||||
*/
|
||||
# 但不忽略 .gitkeep 文件
|
||||
!.gitkeep
|
||||
# 不忽略 .gitignore 文件自身
|
||||
!.gitignore
|
||||
# 不忽略 development/.gitkeep 文件
|
||||
!development/.gitkeep
|
||||
0
docker/xdebug_logs/.gitkeep
Normal file
0
docker/xdebug_logs/.gitkeep
Normal file
212
docs/CHINESE_ENCODING_SOLUTIONS.md
Normal file
212
docs/CHINESE_ENCODING_SOLUTIONS.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# PHP 中文字符编码解决方案
|
||||
|
||||
## 🎯 问题描述
|
||||
|
||||
在使用 `json_encode()` 处理包含中文字符的数据时,默认会将中文字符转换为 Unicode 编码:
|
||||
|
||||
```php
|
||||
// ❌ 默认行为 - 中文转为Unicode
|
||||
$data = ['message' => '请检查计划任务配置'];
|
||||
echo json_encode($data);
|
||||
// 输出:{"message":"\u8bf7\u68c0\u67e5\u8ba1\u5212\u4efb\u52a1\u914d\u7f6e"}
|
||||
```
|
||||
|
||||
这在日志文件中查看很不方便,影响调试效率。
|
||||
|
||||
## 🛠️ 解决方案对比
|
||||
|
||||
### 方案一:`JSON_UNESCAPED_UNICODE` 标志 ⭐⭐⭐⭐⭐⭐
|
||||
|
||||
```php
|
||||
// ✅ 推荐方案 - 保持中文原样
|
||||
$data = ['message' => '请检查计划任务配置'];
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
// 输出:{"message":"请检查计划任务配置"}
|
||||
```
|
||||
|
||||
**优点:**
|
||||
- ✅ 中文完全可读
|
||||
- ✅ 保持JSON格式
|
||||
- ✅ 兼容性好
|
||||
- ✅ 性能优秀
|
||||
|
||||
**使用场景:**
|
||||
- 日志记录
|
||||
- API响应
|
||||
- 配置文件
|
||||
- 调试输出
|
||||
|
||||
### 方案二:`var_export()` 函数 ⭐⭐⭐
|
||||
|
||||
```php
|
||||
// ✅ 完全避免Unicode转义
|
||||
$data = ['message' => '请检查计划任务配置'];
|
||||
echo var_export($data, true);
|
||||
// 输出:array (
|
||||
// 'message' => '请检查计划任务配置',
|
||||
// )
|
||||
```
|
||||
|
||||
**优点:**
|
||||
- ✅ 完全可读
|
||||
- ✅ 数组结构清晰
|
||||
- ✅ 无需额外参数
|
||||
|
||||
**缺点:**
|
||||
- ❌ 不是标准JSON格式
|
||||
- ❌ 输出较冗长
|
||||
|
||||
**使用场景:**
|
||||
- 开发调试
|
||||
- 临时日志
|
||||
- 数组检查
|
||||
|
||||
### 方案三:`print_r()` + 捕获输出 ⭐⭐
|
||||
|
||||
```php
|
||||
// ✅ 可读性好的数组输出
|
||||
$data = ['message' => '请检查计划任务配置'];
|
||||
ob_start();
|
||||
print_r($data);
|
||||
$result = ob_get_clean();
|
||||
```
|
||||
|
||||
**优点:**
|
||||
- ✅ 格式美观
|
||||
- ✅ 易于阅读
|
||||
|
||||
**缺点:**
|
||||
- ❌ 需要输出缓冲
|
||||
- ❌ 性能较差
|
||||
|
||||
### 方案四:自定义序列化函数 ⭐⭐⭐
|
||||
|
||||
```php
|
||||
function serializeArray($data) {
|
||||
if (!is_array($data)) return $data;
|
||||
|
||||
$result = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$result[$key] = serializeArray($value);
|
||||
} else {
|
||||
$result[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 最佳实践组合
|
||||
|
||||
### 在 Cron 类中的实现
|
||||
|
||||
```php
|
||||
class Cron extends BaseModel
|
||||
{
|
||||
/**
|
||||
* 格式化数据为日志友好的字符串(保持中文可读性)
|
||||
*/
|
||||
private static function formatForLog($data): string
|
||||
{
|
||||
if (is_array($data) || is_object($data)) {
|
||||
// JSON_UNESCAPED_UNICODE 保持中文可读性
|
||||
// JSON_PRETTY_PRINT 增加格式化
|
||||
// JSON_UNESCAPED_SLASHES 避免斜杠转义
|
||||
return json_encode($data,
|
||||
JSON_UNESCAPED_UNICODE |
|
||||
JSON_PRETTY_PRINT |
|
||||
JSON_UNESCAPED_SLASHES
|
||||
);
|
||||
}
|
||||
|
||||
return (string) $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完全避免Unicode转义的格式化
|
||||
*/
|
||||
private static function exportForLog($data): string
|
||||
{
|
||||
if (is_array($data) || is_object($data)) {
|
||||
return var_export($data, true);
|
||||
}
|
||||
|
||||
return (string) $data;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```php
|
||||
// 日志记录 - 使用 formatForLog
|
||||
$detail = [
|
||||
'error' => $error,
|
||||
'remark' => $remark,
|
||||
'suggestion' => ScheduleDict::getSuggestion($cronType)
|
||||
];
|
||||
log_write('异常信息:' . self::formatForLog($detail), 'warning');
|
||||
|
||||
// 调试输出 - 使用 exportForLog
|
||||
$debugData = ['config' => $config, 'status' => $status];
|
||||
echo self::exportForLog($debugData);
|
||||
```
|
||||
|
||||
## 🔧 环境配置
|
||||
|
||||
### 确保 PHP 版本支持
|
||||
```php
|
||||
// PHP 5.4+ 基本支持
|
||||
json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
// PHP 5.3+ 需要 polyfill
|
||||
if (!defined('JSON_UNESCAPED_UNICODE')) {
|
||||
define('JSON_UNESCAPED_UNICODE', 256);
|
||||
}
|
||||
```
|
||||
|
||||
### 文件编码设置
|
||||
```php
|
||||
// 确保文件本身是 UTF-8 编码
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
mb_internal_encoding('UTF-8');
|
||||
```
|
||||
|
||||
## 📊 性能对比
|
||||
|
||||
| 方法 | 执行时间 | 内存使用 | 可读性 | JSON兼容 |
|
||||
|------|----------|----------|---------|-----------|
|
||||
| `json_encode()` | 快 | 低 | ⭐⭐⭐⭐⭐ | ✅ |
|
||||
| `var_export()` | 中 | 中 | ⭐⭐⭐⭐ | ❌ |
|
||||
| `print_r()` | 慢 | 高 | ⭐⭐⭐⭐ | ❌ |
|
||||
| 自定义序列化 | 慢 | 高 | ⭐⭐ | ❌ |
|
||||
|
||||
## 🎉 推荐配置
|
||||
|
||||
### 生产环境(推荐)
|
||||
```php
|
||||
// 使用 JSON_UNESCAPED_UNICODE 保持中文可读性
|
||||
json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
```
|
||||
|
||||
### 开发环境
|
||||
```php
|
||||
// 使用格式化JSON增强可读性
|
||||
json_encode($data,
|
||||
JSON_UNESCAPED_UNICODE |
|
||||
JSON_PRETTY_PRINT |
|
||||
JSON_UNESCAPED_SLASHES
|
||||
);
|
||||
```
|
||||
|
||||
### 调试模式
|
||||
```php
|
||||
// 使用 var_export 获得最佳可读性
|
||||
var_export($data, true);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**总结:** 对于您的需求,使用 `JSON_UNESCAPED_UNICODE` 标志是最佳选择,既保持了JSON格式的标准性,又确保了中文字符的可读性。
|
||||
310
docs/CRON_ENV_SETUP.md
Normal file
310
docs/CRON_ENV_SETUP.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Cron 任务环境变量配置指南
|
||||
|
||||
## 🎯 问题:如何为 `nohup php think cron:schedule` 指定 `.env` 文件
|
||||
|
||||
## ✅ 解决方案
|
||||
|
||||
**最重要的部分**
|
||||
|
||||
我们修改了项目目录下的 `think` 文件的内容,增加了读取环境变量的代码
|
||||
|
||||
```php
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
namespace think;
|
||||
|
||||
// 命令行入口文件
|
||||
// 加载基础文件
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
// 创建应用程序
|
||||
$app = new App();
|
||||
|
||||
// 您的代码使用APP_ENV
|
||||
$appEnv = getenv('APP_ENV') ?: '';
|
||||
if ($appEnv) {
|
||||
$envFile = __DIR__ . '/.env.' . $appEnv;
|
||||
if (is_file($envFile)) {
|
||||
$app->env->load($envFile);
|
||||
}
|
||||
}
|
||||
|
||||
// 应用初始化
|
||||
$app->console->run();
|
||||
```
|
||||
|
||||
|
||||
### 方案一:使用环境变量(推荐)
|
||||
|
||||
```bash
|
||||
# 1. 临时设置环境变量(当前会话有效)
|
||||
export THINK_ENV=production
|
||||
nohup php think cron:schedule > /dev/null 2>&1 &
|
||||
|
||||
# 2. 永久设置环境变量(写入 ~/.bashrc)
|
||||
echo 'export THINK_ENV=production' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
nohup php think cron:schedule > /dev/null 2>&1 &
|
||||
|
||||
# 3. 直接传递环境变量
|
||||
THINK_ENV=production nohup php think cron:schedule > /dev/null 2>&1 &
|
||||
```
|
||||
|
||||
### 方案二:指定不同的 .env 文件
|
||||
|
||||
```bash
|
||||
# 1. 复制环境配置文件
|
||||
cp .env .env.production
|
||||
# 编辑 .env.production 修改相应配置
|
||||
|
||||
# 2. 使用 APP_ENV 指定环境
|
||||
APP_ENV=production nohup php think cron:schedule > /dev/null 2>&1 &
|
||||
|
||||
# 3. 或者在运行时指定
|
||||
php think cron:schedule --env=production
|
||||
```
|
||||
|
||||
### 方案三:自定义启动脚本(最佳实践)
|
||||
|
||||
创建专用启动脚本 `start_cron.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
# 设置环境变量
|
||||
export THINK_ENV=${1:-production}
|
||||
|
||||
# 设置工作目录
|
||||
cd /path/to/your/project
|
||||
|
||||
# 日志文件路径
|
||||
LOG_FILE="/var/log/cron_${THINK_ENV}.log"
|
||||
|
||||
# 启动 cron 任务
|
||||
echo "Starting cron with environment: $THINK_ENV"
|
||||
nohup php think cron:schedule > $LOG_FILE 2>&1 &
|
||||
|
||||
# 记录进程ID
|
||||
echo $! > /var/run/cron_${THINK_ENV}.pid
|
||||
|
||||
echo "Cron started with PID: $(cat /var/run/cron_${THINK_ENV}.pid)"
|
||||
```
|
||||
|
||||
使用方法:
|
||||
```bash
|
||||
chmod +x start_cron.sh
|
||||
|
||||
# 启动生产环境 cron
|
||||
./start_cron.sh production
|
||||
|
||||
# 启动开发环境 cron
|
||||
./start_cron.sh development
|
||||
```
|
||||
|
||||
## 🔧 ThinkPHP 6.x 环境变量加载机制
|
||||
|
||||
### ThinkPHP 自动加载顺序:
|
||||
|
||||
1. **服务器环境变量**: `$_SERVER['APP_ENV']`
|
||||
2. **系统环境变量**: `getenv('APP_ENV')`
|
||||
3. **.env 文件**: 根据环境变量加载对应的 `.env.*` 文件
|
||||
|
||||
### 支持的环境变量:
|
||||
|
||||
| 变量名 | 作用 | 示例 |
|
||||
|--------|------|-------|
|
||||
| `APP_ENV` | 指定运行环境 | `production` |
|
||||
| `THINK_ENV` | ThinkPHP 6.x 环境标识 | `production` |
|
||||
| `ENV_PATH` | 自定义 .env 文件路径 | `/custom/path/.env` |
|
||||
|
||||
## 📁 环境配置文件结构
|
||||
|
||||
```
|
||||
project/
|
||||
├── .env # 默认配置
|
||||
├── .env.development # 开发环境
|
||||
├── .env.testing # 测试环境
|
||||
├── .env.staging # 预发布环境
|
||||
└── .env.production # 生产环境
|
||||
```
|
||||
|
||||
## 🚀 实际使用示例
|
||||
|
||||
### Docker 环境中的使用
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile 中设置环境变量
|
||||
ENV THINK_ENV=production
|
||||
|
||||
# 或者在 docker-compose.yml 中
|
||||
environment:
|
||||
- THINK_ENV=production
|
||||
- MYSQL_HOST=mysql
|
||||
- MYSQL_PORT=3306
|
||||
```
|
||||
|
||||
### Supervisor 配置
|
||||
|
||||
```ini
|
||||
[program:cron-production]
|
||||
command=/usr/bin/php /var/www/html/think cron:schedule
|
||||
environment=THINK_ENV=production
|
||||
directory=/var/www/html
|
||||
autostart=true
|
||||
autorestart=true
|
||||
user=www-data
|
||||
stdout_logfile=/var/log/supervisor/cron-production.log
|
||||
stderr_logfile=/var/log/supervisor/cron-production-error.log
|
||||
|
||||
[program:cron-development]
|
||||
command=/usr/bin/php /var/www/html/think cron:schedule
|
||||
environment=THINK_ENV=development
|
||||
directory=/var/www/html
|
||||
autostart=false
|
||||
autorestart=true
|
||||
user=www-data
|
||||
stdout_logfile=/var/log/supervisor/cron-development.log
|
||||
stderr_logfile=/var/log/supervisor/cron-development-error.log
|
||||
```
|
||||
|
||||
### 系统服务配置
|
||||
|
||||
```bash
|
||||
# 创建 systemd 服务文件
|
||||
sudo tee /etc/systemd/system/think-cron.service > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=ThinkPHP Cron Scheduler
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/var/www/html
|
||||
Environment=THINK_ENV=production
|
||||
ExecStart=/usr/bin/php /var/www/html/think cron:schedule
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# 启用并启动服务
|
||||
sudo systemctl enable think-cron
|
||||
sudo systemctl start think-cron
|
||||
```
|
||||
|
||||
## 🔍 验证配置
|
||||
|
||||
### 检查当前环境
|
||||
|
||||
```bash
|
||||
# 1. 在 cron 任务中添加日志
|
||||
php think cron:schedule --verbose
|
||||
|
||||
# 2. 检查环境变量
|
||||
php -r "echo 'APP_ENV: ' . getenv('APP_ENV') . PHP_EOL;"
|
||||
php -r "echo 'THINK_ENV: ' . getenv('THINK_ENV') . PHP_EOL;"
|
||||
|
||||
# 3. 检查 ThinkPHP 加载的配置
|
||||
php think config:get app.env
|
||||
```
|
||||
|
||||
### 测试不同环境
|
||||
|
||||
```bash
|
||||
# 测试生产环境
|
||||
APP_ENV=production php think cron:schedule --verbose
|
||||
|
||||
# 测试开发环境
|
||||
APP_ENV=development php think cron:schedule --verbose
|
||||
|
||||
# 使用指定配置文件
|
||||
THINK_ENV=staging php think cron:schedule --verbose
|
||||
```
|
||||
|
||||
## 📝 日志和监控
|
||||
|
||||
### 日志配置
|
||||
|
||||
```bash
|
||||
# 按环境分离日志
|
||||
nohup php think cron:schedule > logs/cron_${APP_ENV:-default}.log 2>&1 &
|
||||
|
||||
# 带时间戳的日志
|
||||
nohup php think cron:schedule > logs/cron_$(date +%Y%m%d).log 2>&1 &
|
||||
```
|
||||
|
||||
### 进程监控
|
||||
|
||||
```bash
|
||||
# 检查 cron 进程
|
||||
ps aux | grep "think cron:schedule" | grep -v grep
|
||||
|
||||
# 使用 pgrep 查找进程ID
|
||||
pgrep -f "think cron:schedule"
|
||||
|
||||
# 监控脚本
|
||||
#!/bin/bash
|
||||
while true; do
|
||||
if ! pgrep -f "think cron:schedule"; then
|
||||
echo "$(date): Cron process died, restarting..."
|
||||
nohup php think cron:schedule > logs/cron.log 2>&1 &
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
```
|
||||
|
||||
## 🎯 推荐配置
|
||||
|
||||
### 生产环境最佳实践
|
||||
|
||||
```bash
|
||||
# 1. 使用环境变量指定配置
|
||||
export APP_ENV=production
|
||||
|
||||
# 2. 设置适当的日志级别
|
||||
nohup php think cron:schedule --quiet > /var/log/cron.log 2>&1 &
|
||||
|
||||
# 3. 定期检查进程状态
|
||||
*/5 * * * * * /usr/bin/pgrep -f "think cron:schedule" || /usr/bin/nohup /usr/bin/php /var/www/html/think cron:schedule > /var/log/cron.log 2>&1 &
|
||||
```
|
||||
|
||||
### 开发环境配置
|
||||
|
||||
```bash
|
||||
# 1. 使用开发环境配置
|
||||
export APP_ENV=development
|
||||
|
||||
# 2. 显示详细输出用于调试
|
||||
nohup php think cron:schedule > /dev/null 2>&1 &
|
||||
|
||||
# 3. 实时查看日志
|
||||
tail -f logs/cron_dev.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 本地开发环境配置
|
||||
|
||||
```bash
|
||||
# 1. 使用开发环境配置
|
||||
export APP_ENV=local
|
||||
|
||||
# 2. 显示详细输出用于调试
|
||||
nohup php think cron:schedule > /dev/null 2>&1 &
|
||||
|
||||
# 3. 实时查看日志
|
||||
tail -f logs/cron_dev.log
|
||||
```
|
||||
|
||||
## ✅ 总结
|
||||
|
||||
**答案:是的,可以为 `nohup php think cron:schedule` 指定 `.env` 文件!**
|
||||
|
||||
**推荐方法:**
|
||||
1. 使用 `APP_ENV=production` 环境变量
|
||||
2. 创建对应的 `.env.production` 文件
|
||||
3. 使用自定义启动脚本管理不同环境
|
||||
|
||||
这样可以让同一套代码在不同环境中使用不同的数据库配置、缓存配置等。
|
||||
128
docs/GIT_REALEASE.md
Normal file
128
docs/GIT_REALEASE.md
Normal file
@@ -0,0 +1,128 @@
|
||||
适用于中大型团队的方案主要有两种:
|
||||
|
||||
---
|
||||
|
||||
## ✅ 推荐方案一:**GitFlow(适合版本发布节奏明确的项目)**
|
||||
|
||||
### 📌 核心分支说明
|
||||
|
||||
| 分支 | 作用 | 生命周期 | 是否长期存在 |
|
||||
|------|------|--------|------------|
|
||||
| `main`(或 `master`) | **生产环境代码**,每个 commit 对应一个可发布版本 | 永久 | ✅ |
|
||||
| `develop` | **集成开发分支**,最新开发成果,用于测试环境部署 | 永久 | ✅ |
|
||||
| `feature/*` | 功能开发分支(如 `feature/user-auth`) | 临时 | ❌ |
|
||||
| `release/*` | 发布准备分支(如 `release/v1.2.0`) | 临时 | ❌ |
|
||||
| `hotfix/*` | 紧急线上修复分支(如 `hotfix/login-bug`) | 临时 | ❌ |
|
||||
|
||||
### 🔁 典型流程
|
||||
|
||||
1. **日常开发**
|
||||
- 从 `develop` 拉出 `feature/xxx`
|
||||
- 开发完成后,**PR/MR 合并回 `develop`**
|
||||
|
||||
2. **准备发布**
|
||||
- 从 `develop` 拉出 `release/vX.Y.Z`
|
||||
- 在此分支修复 bug、更新版本号、生成 changelog
|
||||
- 测试通过后:
|
||||
- 合并到 `main`(打 tag,如 `v1.2.0`)
|
||||
- 合并回 `develop`(同步修复)
|
||||
|
||||
3. **紧急修复**
|
||||
- 从 `main`(或对应 tag)拉出 `hotfix/xxx`
|
||||
- 修复后:
|
||||
- 合并到 `main`(打新 patch tag,如 `v1.2.1`)
|
||||
- 合并到 `develop`
|
||||
|
||||
### ✅ 优点
|
||||
- 版本清晰,适合有明确发布周期的系统(如每月发版)
|
||||
- 支持并行开发与紧急修复
|
||||
- `main` 始终代表线上状态
|
||||
|
||||
### ⚠️ 缺点
|
||||
- 分支较多,对小型团队略显复杂
|
||||
- 不适合持续部署(CI/CD 频繁上线)场景
|
||||
|
||||
---
|
||||
|
||||
## ✅ 推荐方案二:**Trunk-Based Development + Release Branches(适合 DevOps / 持续交付)**
|
||||
|
||||
> 越来越多互联网公司(如 Google、Facebook、Netflix)采用此模式,尤其适合**高频发布、自动化 CI/CD** 的后台服务。
|
||||
|
||||
### 📌 核心分支说明
|
||||
|
||||
| 分支 | 作用 |
|
||||
|------|------|
|
||||
| `main`(或 `trunk`) | **唯一主干分支**,所有开发直接或间接流向这里,保持可随时发布状态 |
|
||||
| `release/*`(可选) | 仅在需要维护多个线上版本时使用(如 `release/v1.3`) |
|
||||
| `feature/*`(短生命周期) | 功能分支,**必须短(<1天~2天)**,通过 PR 快速合并到 `main` |
|
||||
|
||||
> 💡 实践中常配合 **Feature Toggle(功能开关)**,即使未完成的功能也可合入 `main`,但默认关闭。
|
||||
|
||||
### 🔁 典型流程
|
||||
|
||||
1. 开发者从 `main` 拉出短命 `feature/xxx`
|
||||
2. 提交 PR → 自动化测试(单元、集成、安全扫描)→ Code Review
|
||||
3. 合并到 `main`
|
||||
4. **CI/CD 自动部署到测试/预发环境**
|
||||
5. 人工验证后,**一键发布到生产**(或自动金丝雀发布)
|
||||
6. 若需回滚,直接回退 `main` 的 commit 或触发回滚流程
|
||||
|
||||
### ✅ 优点
|
||||
- 极简分支模型,减少合并冲突
|
||||
- 支持每天多次发布
|
||||
- 与现代 CI/CD 工具链(Jenkins, GitLab CI, ArgoCD 等)天然契合
|
||||
|
||||
### ⚠️ 要求
|
||||
- 强大的自动化测试覆盖(>80%)
|
||||
- 快速 Code Review 文化
|
||||
- 监控与快速回滚能力
|
||||
|
||||
---
|
||||
|
||||
## 🏆 企业后台服务推荐选择
|
||||
|
||||
| 场景 | 推荐策略 |
|
||||
|------|--------|
|
||||
| **传统企业、月度/季度发版、强合规要求** | ✅ **GitFlow** |
|
||||
| **互联网公司、SaaS 服务、每日/每小时发布** | ✅ **Trunk-Based + Short-lived Feature Branches** |
|
||||
| **混合模式(主干开发 + 定期大版本)** | 主干开发为主,大版本前切 `release` 分支 |
|
||||
|
||||
> 🔔 **当前趋势**:越来越多企业后台服务(尤其是微服务架构)倾向于 **Trunk-Based**,因为:
|
||||
> - 后台服务通常无“客户端版本”约束
|
||||
> - 可独立部署、灰度发布
|
||||
> - 自动化程度高
|
||||
|
||||
---
|
||||
|
||||
## 🔐 补充建议(无论哪种策略)
|
||||
|
||||
1. **保护关键分支**
|
||||
- 在 GitHub/GitLab 中设置:
|
||||
- `main` / `develop` 为 **protected branch**
|
||||
- 要求 **PR/MR + 至少1人审批 + CI 通过**
|
||||
- (可选)要求 **GPG 签名提交**
|
||||
|
||||
2. **标准化 Commit & PR 模板**
|
||||
- 使用 Conventional Commits(如 `feat:`, `fix:`, `chore:`)
|
||||
- 自动生成 changelog 和版本号(配合 semantic-release)
|
||||
|
||||
3. **Tag 语义化版本**
|
||||
- 所有生产发布必须打 tag:`v1.2.3`
|
||||
- 格式遵循 [SemVer](https://semver.org/)
|
||||
|
||||
4. **禁止直接 push 到主干**
|
||||
- 所有代码必须通过 PR/MR 合并
|
||||
|
||||
---
|
||||
|
||||
## 📌 总结:最优实践(推荐)
|
||||
|
||||
> **对于大多数现代企业后台服务,采用:**
|
||||
> **✅ Trunk-Based Development(主干开发) + 短生命周期 feature 分支 + 自动化 CI/CD**
|
||||
> 是最高效、可扩展、符合 DevOps 理念的方式。
|
||||
|
||||
只有在**强版本管控、多客户定制、无法频繁上线**等特殊场景下,才考虑 GitFlow。
|
||||
|
||||
---
|
||||
|
||||
如果你能提供更多信息(如团队规模、发布频率、是否微服务、CI/CD 成熟度),我可以给出更定制化的建议!
|
||||
26
docs/addon/huaweipay/README.md
Normal file
26
docs/addon/huaweipay/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 华为支付插件
|
||||
|
||||
|
||||
## Demo
|
||||
|
||||
```
|
||||
安卓快应用ID:115644647
|
||||
安卓快应用包名:com.jieganfsj.fivegshop
|
||||
安卓快应用名称:秸秆粉碎机
|
||||
|
||||
|
||||
商户名称:徐州明文机械有限公司
|
||||
商户号:102751500028
|
||||
|
||||
开发者ID:10086000901972225
|
||||
支付ID:10086000901972225
|
||||
公钥:
|
||||
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA9g1+QcqvC4f1pUiwJ1um1iBUlNn6hRDJrNdv5zB77l5DNo6S6hE4w7VyhkMnkIk89i8kTej1m1ByjRpo7B5OPqafNqI9JBQyQ26A1Zp71zSfe/UicAFiMtF4lWNnAHBYH06sUTvybwYllDVybpi6lL2i8VAGIN8YgoK36lPaYsxWZ911lPCegy7B3kDj1xhBe41cNHgu8wYmjqLU7njleY5Pseherx+Kb58aQvB5xQr8w7KgAyMrsfRH30Btpg/ZWRn8qOXd/DW6eEla3djah4ug8jKdi0qUkA24FLDdOZST4vb5qhgQDVXpqJhYmBIU14YOHsCX9Olu6b7DDjQo/dvOaY3vzWROfV+sV60fUVIps8Vy1EpS/UXeHUxg6r37U8WAxUbSV8d6e4VylLuiIgbX5JpSC1s7jq/cwUwXfSJmKzaCj+C+LJ958IM17FYxIz5xWJtZEzWsPAH7WVCP3b1m4MHU/UwGuMu/Gfdzusnr+Qtan6Wqn9AqUyJP/JfrAgMBAAE=
|
||||
|
||||
支付私钥:
|
||||
MIIG/QIBADANBgkqhkiG9w0BAQEFAASCBucwggbjAgEAAoIBgQD2DX5Byq8Lh/WlSLAnW6bWIFSU2fqFEMms12/nMHvuXkM2jpLqETjDtXKGQyeQiTz2LyRN6PWbUHKNGmjsHk4+pp82oj0kFDJDboDVmnvXNJ979SJwAWIy0XiVY2cAcFgfTqxRO/JvBiWUNXJumLqUvaLxUAYg3xiCgrfqU9pizFZn3XWU8J6DLsHeQOPXGEF7jVw0eC7zBiaOotTueOV5jk+x6F6vH4pvnxpC8HnFCvzDsqADIyux9EffQG2mD9lZGfyo5d38Nbp4SVrd2NqHi6DyMp2LSpSQDbgUsN05lJPi9vmqGBANVemomFiYEhTXhg4ewJf06W7pvsMONCj9285pje/NZE59X6xXrR9RUimzxXLUSlL9Rd4dTGDqvftTxYDFRtJXx3p7hXKUu6IiBtfkmlILWzuOr9zBTBd9ImYrNoKP4L4sn3nwgzXsVjEjPnFYm1kTNaw8AftZUI/dvWbgwdT9TAa4y78Z93O6yev5C1qfpaqf0CpTIk/8l+sCAwEAAQKCAYAVlJFiS9iWdlJBMOLiUNONLEC+3W9vhE1r72lNKZ91BKd4fYC9Ls1/vMZSqEksEB1cqj3Q54HDIYcqgQp6yx2puQt1yzz5kRvndiWulmIOOftS7+kZUcW/F0gwMguyqifQdyH97fgRbMSW/ykOMi8LJKbJ627eKzMHH1fqIXih+bIKYg4SBhihANTYHXDeSK5Vm8xefbwAbKWtFPMAB3J4+tZakDrduTJ3H8k53cWQVqpcr6oBHHCUpww2tHvpeLI3a/FXyHYBqrx8ErnXCjkVBHBtwQf+43H+buyDjrYUwJUi3RSJgeefcyyJoO0I9GwGb6nY8kK5kZ0aeIIkfiVOexMYS9w11FVl96LjC7HjJQrNY4jOLI/X76xyEwBFy9LNogRTafjZVZmVRj/9Kembx6+/eDxvyEv5FnelsHqfFbv1KPkR6e7FvwpDbYgJBpfKTE6SICqo52bHxewpSL7KHRlTpp0IfMM/IOOs8CCwI37ixKQun6W4en457j/tP4ECgcEA+0pa3omCKJfiVR4PpyXY+t9FEqdcZkdYEDtCSX9vqu6yE4sdt7rNnZ6cIiD6ViC2xvQ5VLry1y57JvP57svt5OJYhmILuj3jJ7pdcfKRAVaV4W7C8vGMit6ssckEOnOSj/bvEazfHorjXKU9cn9uCymczRQC9azaRkN+1LLk2mBbczghxd7rSJyXtbt9NVNUi9Gr11AzJsrWN/Bqqna5BizBLIaGYthJ/04LYTl1AjWPEJXjXI72/WvMdQ/w6itxAoHBAPqqAi5mbvjRDitmLQchc/R3Yl329OaB3GSz3YtFqfk9lLJIyl91+MzxBg1ChHnRFC8MB1s1Z05ZkFmcy8AVqvJ6MSZhUCLNjq8HyVgp24CqQTr8ciomF03ncXnKKSsH7tidInkQ8R3e7qRRGQMnDvV+Hs3FjlOk8vJyZCcGkUCVA0kkwkk/95qIfrZsthtewAxJR/rY63FnG+MUayEBB1lwyJZ7xDi/GyX6SAnPBl6bLRA1BdBeTV8K0MEwwIqzGwKBwBqo+9UKT7XQz2FqbAy2tjt/fouJGAN95DjsoI69p3JCGsB6DPAWMIRddIEmcIi8tceL151GrEbqFoS+c7DDD/0timjPdCEROc1YN1vEeV/j+MjPAH3X5KpDD51ZD0rIQi9l6l08svtBjvegTFGedWVXx9v2GI5KBWpY9NbKF/+XI3yo4uRkTyAIBQxx1MnYimq/FvUj/BlMgceziQ2GxQCDtQbtSsqn2cntVMW+28wdNI106YdDX67pRerRgyTE8QKBwHAwLxG9XuWWC5V5AaYzXsaHuEr+ANY6QP4BUqLG5zBaU3cIBSt8jYKMTX0ZzFkJLtNvussjt7zlcSnqd3bdO8mSzvSykT9CaR4FiiQfd9K6YL+ZxS8AJWYEtFEiHhLYVho1Gfy9jG0mHgEFGwDCNnvBmt/WD8F4DhRdBl5BHjmdd/8AqMRIEPXlKXFUbp0JZ0MYeVLYS2hSEbUsqlX3M+bgB6bydfw/7FKvFhbtxZgKM70RPizoSBDFsnEE9OgfCQKBwQD4M4f2f678YScvrwQnV2J8SSnJhQbZqht1Rlb34zU0JIcfpwOKpoNvRyZz1BxvzPSm5S3TLDytK16uvAsMFCHVByyCajPozlWnLzeEqvE4DZXZcWeY+/+MZ4nsajLTTeJWos6UrazZcd8/2c301x4OUKfgZWW1tS65MA48X0+Y4uPyOuH365wQ0obBfg4aB85sZl71v7Rveq+COHBstp/BdDVAsdufWCr7Nkbeu/3qh886ZumpYe+89So1oDIuoys=
|
||||
|
||||
|
||||
```
|
||||
|
||||
支付密钥用于交易过程中的签名认证,请妥善保管,谨防泄露,签名认证规范参考开发指南服务端开发。
|
||||
665
docs/api_kefu.md
Normal file
665
docs/api_kefu.md
Normal file
@@ -0,0 +1,665 @@
|
||||
# 智能客服API接口文档
|
||||
|
||||
## 一、接口说明
|
||||
|
||||
本接口用于连接微信小程序与Dify聊天机器人,实现智能客服功能。所有接口都采用事件驱动架构,支持高并发和模块化扩展。
|
||||
|
||||
## 二、配置说明
|
||||
|
||||
### 1. 安装插件
|
||||
|
||||
在ThinkPHP后台的插件管理页面中,找到智能客服插件(aikefu)并点击安装按钮。
|
||||
|
||||
### 2. 配置插件
|
||||
|
||||
1. 进入智能客服配置页面
|
||||
2. 输入从Dify平台获取的API密钥
|
||||
3. 配置API基础地址(默认:https://api.dify.ai/v1)
|
||||
4. 配置聊天接口端点(默认:/chat-messages)
|
||||
5. 启用智能客服功能
|
||||
|
||||
### 3. 获取Dify API密钥
|
||||
|
||||
1. 登录Dify平台
|
||||
2. 进入工作台
|
||||
3. 选择您的聊天机器人项目
|
||||
4. 点击"发布"按钮
|
||||
5. 在API访问页面获取API密钥
|
||||
|
||||
## 三、接口列表
|
||||
|
||||
### 1. 系统健康检查
|
||||
|
||||
**接口地址**:`/api/kefu/health`
|
||||
|
||||
**请求方式**:GET
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| uniacid | int | 是 | 站点ID |
|
||||
| check_type | string | 否 | 检查类型:full(完整)、basic(基础)、ai_service(AI服务),默认full |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "healthy",
|
||||
"data": {
|
||||
"status": "healthy",
|
||||
"check_id": "health_63a8f9c1234abcd",
|
||||
"timestamp": "2023-12-25 10:30:45",
|
||||
"total_checks": 4,
|
||||
"passed_checks": 4,
|
||||
"failed_checks": 0,
|
||||
"response_time_ms": 156.78,
|
||||
"components": {
|
||||
"database": {
|
||||
"status": "healthy",
|
||||
"message": "数据库连接正常",
|
||||
"response_time_ms": 12.34,
|
||||
"details": {
|
||||
"connection": "success",
|
||||
"query_test": "passed"
|
||||
}
|
||||
},
|
||||
"ai_service_config": {
|
||||
"status": "healthy",
|
||||
"message": "AI服务配置正常",
|
||||
"response_time_ms": 8.56,
|
||||
"details": {
|
||||
"configured": true,
|
||||
"complete": true,
|
||||
"enabled": true,
|
||||
"base_url": "https://api.example.com"
|
||||
}
|
||||
},
|
||||
"ai_service_connection": {
|
||||
"status": "healthy",
|
||||
"message": "AI服务连接正常",
|
||||
"response_time_ms": 45.67,
|
||||
"details": {
|
||||
"http_status": 200,
|
||||
"url": "https://api.example.com"
|
||||
}
|
||||
},
|
||||
"system_resources": {
|
||||
"status": "healthy",
|
||||
"message": "系统资源正常",
|
||||
"response_time_ms": 2.34,
|
||||
"details": {
|
||||
"php_version": "8.1.0",
|
||||
"memory_usage": "45.67 MB",
|
||||
"memory_limit": "512.00 MB",
|
||||
"memory_usage_percent": "8.92%",
|
||||
"max_execution_time": "30s"
|
||||
}
|
||||
}
|
||||
},
|
||||
"warnings": [],
|
||||
"errors": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 获取服务配置信息
|
||||
|
||||
**接口地址**:`/api/kefu/info`
|
||||
|
||||
**请求方式**:GET
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| uniacid | int | 否 | 站点ID |
|
||||
| member_id | int | 否 | 会员ID |
|
||||
| token | string | 否 | 访问令牌 |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"service_info": {
|
||||
"name": "智能客服",
|
||||
"version": "1.0.0",
|
||||
"enabled": true,
|
||||
"status": "enabled"
|
||||
},
|
||||
"features": {
|
||||
"chat": true,
|
||||
"chat_stream": true,
|
||||
"conversation_management": true,
|
||||
"history_management": true
|
||||
},
|
||||
"limits": {
|
||||
"max_message_length": 4000,
|
||||
"max_conversation_history": 100,
|
||||
"rate_limit": {
|
||||
"requests_per_minute": 60,
|
||||
"requests_per_hour": 1000
|
||||
}
|
||||
},
|
||||
"endpoints": {
|
||||
"chat": "/api/kefu/chat",
|
||||
"chat_stream": "/api/kefu/chatStream",
|
||||
"create_conversation": "/api/kefu/createConversation",
|
||||
"get_history": "/api/kefu/getHistory",
|
||||
"clear_conversation": "/api/kefu/clearConversation",
|
||||
"health": "/api/kefu/health",
|
||||
"info": "/api/kefu/info"
|
||||
},
|
||||
"api_config": {
|
||||
"base_url": "https://api.dify.ai/v1",
|
||||
"chat_endpoint": "/chat-messages",
|
||||
"supports_streaming": true,
|
||||
"authentication": "bearer_token"
|
||||
},
|
||||
"client_info": {
|
||||
"user_agent": "Mozilla/5.0...",
|
||||
"ip": "192.168.1.100",
|
||||
"timestamp": 1703505845
|
||||
},
|
||||
"server_info": {
|
||||
"php_version": "8.1.0",
|
||||
"server_time": "2023-12-25 10:30:45",
|
||||
"timezone": "Asia/Shanghai"
|
||||
},
|
||||
"user_stats": {
|
||||
"can_use_service": true,
|
||||
"member_id": 123,
|
||||
"site_id": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 智能客服聊天接口
|
||||
|
||||
**接口地址**:`/api/kefu/chat`
|
||||
|
||||
**请求方式**:POST
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| uniacid | int | 是 | 站点ID |
|
||||
| message | string | 是 | 用户输入的消息内容 |
|
||||
| user_id | string | 否 | 用户ID,默认使用当前登录会员ID |
|
||||
| conversation_id | string | 否 | 会话ID,第一次聊天可不传,系统会自动创建 |
|
||||
| stream | bool | 否 | 是否使用流式响应,默认false |
|
||||
| member_id | int | 否 | 会员ID |
|
||||
| token | string | 否 | 访问令牌 |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"conversation_id": "conv_123456789",
|
||||
"reply": "您好,我是智能客服,有什么可以帮助您的?",
|
||||
"message_id": "msg_123456789",
|
||||
"finish_reason": "stop",
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 智能客服流式聊天接口
|
||||
|
||||
**接口地址**:`/api/kefu/chatStream`
|
||||
|
||||
**请求方式**:POST
|
||||
|
||||
**请求参数**:同 `/api/kefu/chat` 接口
|
||||
|
||||
**响应格式**:Server-Sent Events (SSE)
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```javascript
|
||||
// 开始事件
|
||||
event: start
|
||||
data: {
|
||||
"id": "unique_id",
|
||||
"event": "start",
|
||||
"timestamp": 1703505845,
|
||||
"data": {
|
||||
"request_id": "stream_123",
|
||||
"message": "开始处理请求"
|
||||
}
|
||||
}
|
||||
|
||||
// 内容块事件
|
||||
event: message
|
||||
data: {
|
||||
"id": "unique_id",
|
||||
"event": "message",
|
||||
"timestamp": 1703505845,
|
||||
"data": {
|
||||
"content": "您",
|
||||
"conversation_id": "conv_123",
|
||||
"finished": false
|
||||
}
|
||||
}
|
||||
|
||||
// 完成事件
|
||||
event: complete
|
||||
data: {
|
||||
"id": "unique_id",
|
||||
"event": "complete",
|
||||
"timestamp": 1703505845,
|
||||
"data": {
|
||||
"conversation_id": "conv_123",
|
||||
"message_id": "msg_456",
|
||||
"usage": {},
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
}
|
||||
|
||||
// 结束事件
|
||||
event: end
|
||||
data: {
|
||||
"id": "unique_id",
|
||||
"event": "end",
|
||||
"timestamp": 1703505845,
|
||||
"data": {
|
||||
"request_id": "stream_123",
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 创建新会话
|
||||
|
||||
**接口地址**:`/api/kefu/createConversation`
|
||||
|
||||
**请求方式**:POST
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| uniacid | int | 是 | 站点ID |
|
||||
| user_id | string | 否 | 用户ID,默认使用当前登录会员ID |
|
||||
| member_id | int | 否 | 会员ID |
|
||||
| token | string | 否 | 访问令牌 |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"conversation_id": "conv_123456789",
|
||||
"name": "智能客服会话",
|
||||
"created_at": "2023-12-25 10:30:45"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 获取会话历史
|
||||
|
||||
**接口地址**:`/api/kefu/getHistory`
|
||||
|
||||
**请求方式**:POST
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| uniacid | int | 是 | 站点ID |
|
||||
| conversation_id | string | 是 | 会话ID |
|
||||
| user_id | string | 否 | 用户ID,默认使用当前登录会员ID |
|
||||
| limit | int | 否 | 每页条数,默认20 |
|
||||
| offset | int | 否 | 偏移量,默认0 |
|
||||
| member_id | int | 否 | 会员ID |
|
||||
| token | string | 否 | 访问令牌 |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"messages": [
|
||||
{
|
||||
"id": 1,
|
||||
"role": "user",
|
||||
"content": "你好",
|
||||
"create_time": "2023-12-25 10:30:45"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"role": "assistant",
|
||||
"content": "您好,我是智能客服,有什么可以帮助您的?",
|
||||
"create_time": "2023-12-25 10:30:46"
|
||||
}
|
||||
],
|
||||
"total": 2,
|
||||
"limit": 20,
|
||||
"offset": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. 清除会话历史
|
||||
|
||||
**接口地址**:`/api/kefu/clearConversation`
|
||||
|
||||
**请求方式**:POST
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| uniacid | int | 是 | 站点ID |
|
||||
| conversation_id | string | 否 | 会话ID(与user_id二选一) |
|
||||
| user_id | string | 否 | 用户ID,用于清除该用户所有会话(与conversation_id二选一) |
|
||||
| member_id | int | 否 | 会员ID |
|
||||
| token | string | 否 | 访问令牌 |
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"deleted_messages": 15,
|
||||
"deleted_conversations": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 四、前端调用示例
|
||||
|
||||
### Uniapp调用示例
|
||||
|
||||
```javascript
|
||||
// 引入请求封装(根据项目实际情况调整)
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
// 1. 获取服务配置信息
|
||||
async function getAIInfo() {
|
||||
try {
|
||||
const res = await request({
|
||||
url: '/api/kefu/info',
|
||||
method: 'GET',
|
||||
data: {
|
||||
uniacid: 1
|
||||
}
|
||||
});
|
||||
|
||||
if (res.code === 0) {
|
||||
console.log('AI服务状态:', res.data.service_info);
|
||||
console.log('可用功能:', res.data.features);
|
||||
return res.data;
|
||||
} else {
|
||||
console.error('获取配置失败:', res.message);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取配置请求失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 智能客服聊天(普通模式)
|
||||
async function chatWithAI(message, conversationId = '') {
|
||||
try {
|
||||
const res = await request({
|
||||
url: '/api/kefu/chat',
|
||||
method: 'POST',
|
||||
data: {
|
||||
uniacid: 1,
|
||||
message: message,
|
||||
conversation_id: conversationId
|
||||
// user_id: 'your-user-id', // 可选
|
||||
// stream: false // 可选
|
||||
}
|
||||
});
|
||||
|
||||
if (res.code === 0) {
|
||||
return res.data;
|
||||
} else {
|
||||
console.error('聊天失败:', res.message);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('聊天请求失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 智能客服聊天(流式模式)
|
||||
async function chatWithAIStream(message, conversationId = '', onMessage, onComplete, onError) {
|
||||
try {
|
||||
const response = await uni.request({
|
||||
url: '/api/kefu/chatStream',
|
||||
method: 'POST',
|
||||
data: {
|
||||
uniacid: 1,
|
||||
message: message,
|
||||
conversation_id: conversationId
|
||||
},
|
||||
responseType: 'text'
|
||||
});
|
||||
|
||||
// 处理流式响应
|
||||
if (response.statusCode === 200) {
|
||||
const lines = response.data.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.substring(6));
|
||||
if (data.event === 'message' && onMessage) {
|
||||
onMessage(data.data);
|
||||
} else if (data.event === 'complete' && onComplete) {
|
||||
onComplete(data.data);
|
||||
} else if (data.event === 'error' && onError) {
|
||||
onError(data.data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('解析流式数据失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('流式聊天请求失败:', error);
|
||||
if (onError) onError({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 创建新会话
|
||||
async function createNewConversation() {
|
||||
try {
|
||||
const res = await request({
|
||||
url: '/api/kefu/createConversation',
|
||||
method: 'POST',
|
||||
data: {
|
||||
uniacid: 1
|
||||
// user_id: 'your-user-id', // 可选
|
||||
}
|
||||
});
|
||||
|
||||
if (res.code === 0) {
|
||||
return res.data.conversation_id;
|
||||
} else {
|
||||
console.error('创建会话失败:', res.message);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建会话请求失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 获取会话历史
|
||||
async function getChatHistory(conversationId, limit = 20, offset = 0) {
|
||||
try {
|
||||
const res = await request({
|
||||
url: '/api/kefu/getHistory',
|
||||
method: 'POST',
|
||||
data: {
|
||||
uniacid: 1,
|
||||
conversation_id: conversationId,
|
||||
limit: limit,
|
||||
offset: offset
|
||||
// user_id: 'your-user-id', // 可选
|
||||
}
|
||||
});
|
||||
|
||||
if (res.code === 0) {
|
||||
return res.data;
|
||||
} else {
|
||||
console.error('获取历史记录失败:', res.message);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取历史记录请求失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 清除会话历史
|
||||
async function clearConversation(conversationId = '', userId = '') {
|
||||
try {
|
||||
const res = await request({
|
||||
url: '/api/kefu/clearConversation',
|
||||
method: 'POST',
|
||||
data: {
|
||||
uniacid: 1,
|
||||
conversation_id: conversationId,
|
||||
user_id: userId
|
||||
}
|
||||
});
|
||||
|
||||
if (res.code === 0) {
|
||||
return res.data;
|
||||
} else {
|
||||
console.error('清除会话失败:', res.message);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清除会话请求失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 健康检查
|
||||
async function checkHealth(checkType = 'full') {
|
||||
try {
|
||||
const res = await request({
|
||||
url: '/api/kefu/health',
|
||||
method: 'GET',
|
||||
data: {
|
||||
uniacid: 1,
|
||||
check_type: checkType
|
||||
}
|
||||
});
|
||||
|
||||
if (res.code === 0) {
|
||||
return res.data;
|
||||
} else {
|
||||
console.error('健康检查失败:', res.message);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('健康检查请求失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 五、使用流程
|
||||
|
||||
1. **初始化检查**:小程序端启动时,调用`health`和`info`接口检查服务状态
|
||||
2. **创建会话**:进入客服页面时,调用`createConversation`接口创建新会话,或使用本地存储的会话ID
|
||||
3. **发送消息**:用户输入消息后,调用`chat`或`chatStream`接口发送消息,获取机器人回复
|
||||
4. **显示消息**:将用户消息和机器人回复显示在聊天界面
|
||||
5. **加载历史记录**:需要时调用`getHistory`接口加载历史消息
|
||||
6. **维护会话**:保持会话ID,用于后续消息交流
|
||||
7. **清理数据**:根据用户需求调用`clearConversation`接口清理历史数据
|
||||
|
||||
## 六、注意事项
|
||||
|
||||
1. **必填参数**:所有接口都需要`uniacid`(站点ID)参数
|
||||
2. **事件驱动**:后端采用事件驱动架构,所有业务逻辑通过事件处理器执行
|
||||
3. **安全性**:请确保Dify API密钥的安全性,不要泄露给前端
|
||||
4. **用户标识**:建议对用户ID进行加密处理,避免直接使用敏感信息
|
||||
5. **流式支持**:推荐使用`chatStream`接口获得更好的用户体验
|
||||
6. **会话管理**:建议实现会话管理机制,定期清理过期会话
|
||||
7. **频率限制**:建议添加请求频率限制,防止恶意请求
|
||||
8. **生产环境**:在生产环境中,建议关闭DEBUG模式
|
||||
|
||||
## 七、测试建议
|
||||
|
||||
1. **基础检查**:首先调用`health`接口检查系统状态
|
||||
2. **配置验证**:调用`info`接口验证配置信息
|
||||
3. **接口测试**:使用Postman或类似工具测试各个API接口
|
||||
4. **流式测试**:测试`chatStream`接口的流式响应
|
||||
5. **完整流程**:在小程序端集成并测试完整流程
|
||||
6. **边界测试**:模拟不同场景下的用户输入,测试机器人回复效果
|
||||
7. **压力测试**:测试接口在高并发情况下的表现
|
||||
|
||||
## 八、常见问题
|
||||
|
||||
### 1. 接口返回400错误
|
||||
|
||||
**原因**:缺少必填参数`uniacid`或参数格式错误
|
||||
**解决方法**:确保请求中包含有效的站点ID
|
||||
|
||||
### 2. 健康检查返回503错误
|
||||
|
||||
**原因**:AI服务配置不完整或服务异常
|
||||
**解决方法**:检查插件配置和Dify API服务状态
|
||||
|
||||
### 3. 接口返回401错误
|
||||
|
||||
**原因**:Dify API密钥无效或过期
|
||||
**解决方法**:重新获取有效的API密钥并更新插件配置
|
||||
|
||||
### 4. 接口返回500错误
|
||||
|
||||
**原因**:后端服务器错误或Dify API服务异常
|
||||
**解决方法**:查看服务器日志,检查Dify API服务状态
|
||||
|
||||
### 5. 机器人回复为空
|
||||
|
||||
**原因**:Dify聊天机器人配置问题或请求参数错误
|
||||
**解决方法**:检查Dify机器人配置,验证请求参数是否正确
|
||||
|
||||
### 6. 流式响应无法解析
|
||||
|
||||
**原因**:客户端不支持SSE或解析方式错误
|
||||
**解决方法**:使用正确的方式解析Server-Sent Events格式
|
||||
|
||||
### 7. 会话ID无效
|
||||
|
||||
**原因**:会话已过期或不存在
|
||||
**解决方法**:创建新会话,获取新的会话ID
|
||||
|
||||
## 九、性能优化建议
|
||||
|
||||
1. **缓存配置**:可对`info`接口返回的配置信息进行客户端缓存
|
||||
2. **连接复用**:HTTP请求使用连接池,减少建立连接的开销
|
||||
3. **压缩传输**:启用gzip压缩减少传输数据量
|
||||
4. **分页加载**:历史记录使用分页加载,避免一次性加载大量数据
|
||||
5. **CDN加速**:静态资源使用CDN加速访问
|
||||
6. **监控告警**:建立接口性能监控和告警机制
|
||||
670
docs/common/CLodop.md
Normal file
670
docs/common/CLodop.md
Normal file
@@ -0,0 +1,670 @@
|
||||
好的!我来详细介绍一下**Web打印服务CLodop**,这是一个非常强大的浏览器打印解决方案。
|
||||
|
||||
## 🖨️ **CLodop - 专业Web打印服务详解**
|
||||
|
||||
### **1. CLodop 是什么?**
|
||||
|
||||
**CLodop**(Cloud Lodop)是**C-Lodop 云打印服务**的简称,是 Lodop 技术的一个轻量级版本,专门为 Web 应用提供强大的打印功能。
|
||||
|
||||
#### **核心特点**
|
||||
- ✅ **跨浏览器兼容** - 支持所有主流浏览器
|
||||
- ✅ **无需安装插件** - 基于云服务架构
|
||||
- ✅ **精确打印控制** - 像素级打印精度
|
||||
- ✅ **批量打印支持** - 高效处理大量打印任务
|
||||
- ✅ **多种打印方式** - 直接打印、预览打印、设计打印
|
||||
|
||||
### **2. CLodop 与 Lodop 的区别**
|
||||
|
||||
| 特性 | CLodop(云打印) | Lodop(传统打印) |
|
||||
|------|-----------------|------------------|
|
||||
| **架构** | 云服务模式,无需安装 | 需要安装本地插件 |
|
||||
| **部署** | 服务端部署,客户端零配置 | 每台客户端都需要安装 |
|
||||
| **更新** | 服务端统一更新 | 每台客户端单独更新 |
|
||||
| **兼容性** | 支持所有现代浏览器 | 对高版本浏览器支持有限 |
|
||||
| **适用场景** | Web应用、移动端 | 企业内部系统、局域网 |
|
||||
|
||||
### **3. CLodop 核心架构**
|
||||
|
||||
#### **系统架构图**
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Web应用 │ -> │ CLodop服务 │ -> │ 打印机 │
|
||||
│ (浏览器) │ │ (服务端) │ │ (本地/网络) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│ │ │
|
||||
JavaScript 打印服务 打印输出
|
||||
API 任务队列
|
||||
```
|
||||
|
||||
### **4. 完整安装和配置**
|
||||
|
||||
#### **4.1 服务端安装(Windows)**
|
||||
```bash
|
||||
# 下载CLodop服务端
|
||||
# 官方下载地址:http://www.c-lodop.com/download.html
|
||||
|
||||
# 安装步骤:
|
||||
1. 下载 CLodop_Setup.exe
|
||||
2. 以管理员身份运行安装
|
||||
3. 默认安装目录:C:\Program Files (x86)\CLodop
|
||||
4. 服务自动启动,监听端口:8000、18000
|
||||
```
|
||||
|
||||
#### **4.2 服务端配置文件**
|
||||
```json
|
||||
// CLodop 服务配置 (config.json)
|
||||
{
|
||||
"server": {
|
||||
"port": 8000,
|
||||
"sslPort": 8443,
|
||||
"host": "0.0.0.0"
|
||||
},
|
||||
"print": {
|
||||
"defaultPaper": "A4",
|
||||
"dpi": 300,
|
||||
"timeout": 30000
|
||||
},
|
||||
"security": {
|
||||
"allowedOrigins": ["http://localhost:3000", "https://yourdomain.com"],
|
||||
"authRequired": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### **4.3 验证安装**
|
||||
```bash
|
||||
# 检查服务状态
|
||||
netstat -ano | findstr :8000
|
||||
|
||||
# 访问测试页面
|
||||
http://localhost:8000
|
||||
```
|
||||
|
||||
### **5. 前端集成完整代码**
|
||||
|
||||
#### **5.1 基础集成方案**
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>CLodop 打印示例</title>
|
||||
<!-- 引入CLodop JS文件 -->
|
||||
<script src="http://localhost:8000/CLodopfuncs.js"></script>
|
||||
<script>
|
||||
// 检查CLodop服务状态
|
||||
function checkCLodop() {
|
||||
try {
|
||||
if (getCLodop()) {
|
||||
console.log('CLodop服务已就绪');
|
||||
return true;
|
||||
} else {
|
||||
console.warn('CLodop服务未启动');
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('CLodop检查失败:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取CLodop对象
|
||||
function getCLodop() {
|
||||
if (window.getCLodop) {
|
||||
return window.getLodop();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body onload="checkCLodop()">
|
||||
<!-- 打印内容 -->
|
||||
<div id="printContent">
|
||||
<h1>可打印内容</h1>
|
||||
<table border="1" style="width:100%">
|
||||
<tr><th>姓名</th><th>年龄</th><th>职位</th></tr>
|
||||
<tr><td>张三</td><td>30</td><td>工程师</td></tr>
|
||||
<tr><td>李四</td><td>25</td><td>设计师</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button onclick="printDirect()">直接打印</button>
|
||||
<button onclick="printPreview()">打印预览</button>
|
||||
<button onclick="printDesign()">打印设计</button>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
#### **5.2 高级打印控制器**
|
||||
```javascript
|
||||
// clodop-manager.js
|
||||
class CLodopManager {
|
||||
constructor() {
|
||||
this.lodop = null;
|
||||
this.isReady = false;
|
||||
this.init();
|
||||
}
|
||||
|
||||
// 初始化CLodop
|
||||
init() {
|
||||
if (typeof window.getCLodop === 'undefined') {
|
||||
console.error('CLodop未加载,请检查脚本引入');
|
||||
return;
|
||||
}
|
||||
|
||||
this.lodop = window.getLodop();
|
||||
if (this.lodop) {
|
||||
this.isReady = true;
|
||||
console.log('CLodop初始化成功');
|
||||
} else {
|
||||
console.error('CLodop初始化失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 检查服务状态
|
||||
checkStatus() {
|
||||
if (!this.lodop) return false;
|
||||
|
||||
try {
|
||||
const status = this.lodop.PRINT_STATUS;
|
||||
return status === 'READY';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建打印任务
|
||||
createPrintJob(title = '打印文档') {
|
||||
if (!this.isReady) {
|
||||
throw new Error('CLodop未就绪');
|
||||
}
|
||||
|
||||
this.lodop.PRINT_INIT(title);
|
||||
return this;
|
||||
}
|
||||
|
||||
// 设置打印内容
|
||||
setContent(htmlContent, options = {}) {
|
||||
const config = {
|
||||
top: options.top || '10mm',
|
||||
left: options.left || '10mm',
|
||||
width: options.width || '190mm',
|
||||
height: options.height || '277mm',
|
||||
...options
|
||||
};
|
||||
|
||||
this.lodop.ADD_PRINT_HTML(
|
||||
config.top, config.left, config.width, config.height,
|
||||
htmlContent
|
||||
);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
// 设置打印机
|
||||
setPrinter(printerName = '') {
|
||||
if (printerName) {
|
||||
this.lodop.SET_PRINTER_INDEX(printerName);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// 设置纸张大小
|
||||
setPaperSize(paperName = 'A4') {
|
||||
this.lodop.SET_PRINT_PAGESIZE(1, 0, 0, paperName);
|
||||
return this;
|
||||
}
|
||||
|
||||
// 设置打印份数
|
||||
setCopies(copies = 1) {
|
||||
this.lodop.SET_PRINT_COPIES(copies);
|
||||
return this;
|
||||
}
|
||||
|
||||
// 直接打印
|
||||
printDirect() {
|
||||
if (!this.isReady) return false;
|
||||
|
||||
try {
|
||||
this.lodop.PRINT();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('打印失败:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 打印预览
|
||||
printPreview() {
|
||||
if (!this.isReady) return false;
|
||||
|
||||
try {
|
||||
this.lodop.PREVIEW();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('预览失败:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 打印设计
|
||||
printDesign() {
|
||||
if (!this.isReady) return false;
|
||||
|
||||
try {
|
||||
this.lodop.PRINT_DESIGN();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('设计失败:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 批量打印
|
||||
batchPrint(documents = []) {
|
||||
if (!this.isReady) return false;
|
||||
|
||||
try {
|
||||
documents.forEach((doc, index) => {
|
||||
this.lodop.PRINT_INIT(`文档${index + 1}`);
|
||||
this.lodop.ADD_PRINT_HTML("10mm", "10mm", "190mm", "277mm", doc);
|
||||
|
||||
if (index < documents.length - 1) {
|
||||
this.lodop.NEWPAGE(); // 分页
|
||||
}
|
||||
});
|
||||
|
||||
this.lodop.PREVIEW();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('批量打印失败:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全局实例
|
||||
window.clodopManager = new CLodopManager();
|
||||
```
|
||||
|
||||
### **6. 实际应用示例**
|
||||
|
||||
#### **6.1 票据打印**
|
||||
```javascript
|
||||
// receipt-print.js
|
||||
function printReceipt(orderData) {
|
||||
const manager = window.clodopManager;
|
||||
if (!manager.isReady) {
|
||||
alert('打印服务未就绪');
|
||||
return;
|
||||
}
|
||||
|
||||
const receiptHTML = `
|
||||
<div style="width:80mm;font-family:'宋体';font-size:12px;">
|
||||
<h3 style="text-align:center;">销售小票</h3>
|
||||
<hr>
|
||||
<p><strong>订单号:</strong>${orderData.orderNo}</p>
|
||||
<p><strong>时间:</strong>${new Date().toLocaleString()}</p>
|
||||
<hr>
|
||||
<table style="width:100%;">
|
||||
${orderData.items.map(item => `
|
||||
<tr>
|
||||
<td>${item.name}</td>
|
||||
<td>×${item.quantity}</td>
|
||||
<td>¥${item.price}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
<hr>
|
||||
<p style="text-align:right;"><strong>总计:¥${orderData.total}</strong></p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
manager.createPrintJob('销售小票')
|
||||
.setPaperSize(1) // 1=80mm 小票
|
||||
.setContent(receiptHTML, {
|
||||
width: '80mm',
|
||||
height: 'auto'
|
||||
})
|
||||
.printDirect();
|
||||
}
|
||||
```
|
||||
|
||||
#### **6.2 报表打印**
|
||||
```javascript
|
||||
// report-print.js
|
||||
function printReport(reportData) {
|
||||
const manager = window.clodopManager;
|
||||
|
||||
const reportHTML = `
|
||||
<div style="font-family:'微软雅黑';padding:20px;">
|
||||
<h1 style="text-align:center;">${reportData.title}</h1>
|
||||
<table border="1" style="width:100%;border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="background:#f5f5f5;">
|
||||
${reportData.headers.map(header =>
|
||||
`<th style="padding:8px;">${header}</th>`
|
||||
).join('')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${reportData.rows.map(row => `
|
||||
<tr>
|
||||
${row.map(cell =>
|
||||
`<td style="padding:6px;">${cell}</td>`
|
||||
).join('')}
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="margin-top:20px;">
|
||||
<p>打印时间:${new Date().toLocaleString()}</p>
|
||||
<p>打印人:${reportData.printUser}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
manager.createPrintJob(reportData.title)
|
||||
.setPaperSize('A4')
|
||||
.setContent(reportHTML)
|
||||
.setPrinter(reportData.printer || '')
|
||||
.printPreview();
|
||||
}
|
||||
```
|
||||
|
||||
#### **6.3 标签打印**
|
||||
```javascript
|
||||
// label-print.js
|
||||
function printLabels(labelsData) {
|
||||
const manager = window.clodopManager;
|
||||
|
||||
manager.createPrintJob('产品标签');
|
||||
|
||||
labelsData.forEach((label, index) => {
|
||||
const labelHTML = `
|
||||
<div style="width:50mm;height:30mm;border:1px dotted #ccc;padding:5mm;font-size:10px;">
|
||||
<div style="text-align:center;font-weight:bold;">${label.productName}</div>
|
||||
<div>规格:${label.spec}</div>
|
||||
<div>批次:${label.batchNo}</div>
|
||||
<div>有效期:${label.expiryDate}</div>
|
||||
<div style="text-align:center;margin-top:2mm;">
|
||||
<img src="${label.barcodeUrl}" style="height:15mm;">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
manager.setContent(labelHTML, {
|
||||
top: `${(index % 5) * 32}mm`,
|
||||
left: `${Math.floor(index / 5) * 52}mm`,
|
||||
width: '50mm',
|
||||
height: '30mm'
|
||||
});
|
||||
|
||||
if (index < labelsData.length - 1) {
|
||||
manager.lodop.NEWPAGE();
|
||||
}
|
||||
});
|
||||
|
||||
manager.printPreview();
|
||||
}
|
||||
```
|
||||
|
||||
### **7. 高级功能实现**
|
||||
|
||||
#### **7.1 打印状态监控**
|
||||
```javascript
|
||||
// print-monitor.js
|
||||
class PrintMonitor {
|
||||
constructor() {
|
||||
this.printJobs = new Map();
|
||||
this.initEvents();
|
||||
}
|
||||
|
||||
initEvents() {
|
||||
// 监听打印开始
|
||||
window.addEventListener('beforeprint', (e) => {
|
||||
console.log('打印开始');
|
||||
this.onPrintStart(e);
|
||||
});
|
||||
|
||||
// 监听打印结束
|
||||
window.addEventListener('afterprint', (e) => {
|
||||
console.log('打印结束');
|
||||
this.onPrintEnd(e);
|
||||
});
|
||||
}
|
||||
|
||||
onPrintStart(jobId) {
|
||||
this.printJobs.set(jobId, {
|
||||
startTime: new Date(),
|
||||
status: 'printing'
|
||||
});
|
||||
}
|
||||
|
||||
onPrintEnd(jobId, success = true) {
|
||||
const job = this.printJobs.get(jobId);
|
||||
if (job) {
|
||||
job.endTime = new Date();
|
||||
job.status = success ? 'completed' : 'failed';
|
||||
job.duration = job.endTime - job.startTime;
|
||||
|
||||
// 发送打印统计
|
||||
this.sendPrintStatistics(job);
|
||||
}
|
||||
}
|
||||
|
||||
sendPrintStatistics(job) {
|
||||
// 发送到服务器记录
|
||||
fetch('/api/print/log', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(job)
|
||||
});
|
||||
}
|
||||
|
||||
// 获取打印机列表
|
||||
getPrinterList() {
|
||||
const lodop = getCLodop();
|
||||
if (!lodop) return [];
|
||||
|
||||
const printers = [];
|
||||
const count = lodop.GET_PRINTER_COUNT();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
printers.push({
|
||||
name: lodop.GET_PRINTER_NAME(i),
|
||||
status: lodop.GET_PRINTER_STATUS(i)
|
||||
});
|
||||
}
|
||||
|
||||
return printers;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### **7.2 错误处理和重试机制**
|
||||
```javascript
|
||||
// error-handler.js
|
||||
class PrintErrorHandler {
|
||||
static async printWithRetry(printFunction, maxRetries = 3) {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const result = await printFunction();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`打印尝试 ${attempt} 失败:`, error);
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
throw new Error(`打印失败,已重试 ${maxRetries} 次`);
|
||||
}
|
||||
|
||||
// 等待后重试
|
||||
await this.delay(1000 * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
static handleCommonErrors(error) {
|
||||
const errorMap = {
|
||||
'PRINTER_NOT_FOUND': '打印机未找到,请检查打印机连接',
|
||||
'OUT_OF_PAPER': '打印机缺纸,请添加纸张',
|
||||
'SERVICE_UNAVAILABLE': '打印服务不可用,请检查CLodop服务',
|
||||
'TIMEOUT': '打印超时,请重试'
|
||||
};
|
||||
|
||||
const message = errorMap[error.code] || `打印错误: ${error.message}`;
|
||||
this.showErrorDialog(message);
|
||||
}
|
||||
|
||||
static showErrorDialog(message) {
|
||||
// 可以替换为更友好的UI提示
|
||||
alert(`打印错误: ${message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **8. 部署和配置最佳实践**
|
||||
|
||||
#### **8.1 Docker 部署**
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM windows/servercore:ltsc2019
|
||||
|
||||
# 安装CLodop服务
|
||||
COPY CLodop_Setup.exe /setup/
|
||||
RUN /setup/CLodop_Setup.exe /S
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000 18000
|
||||
|
||||
# 启动服务
|
||||
CMD ["C:\\Program Files (x86)\\CLodop\\CLodop.exe", "-start"]
|
||||
```
|
||||
|
||||
#### **8.2 Nginx 反向代理配置**
|
||||
```nginx
|
||||
# nginx.conf
|
||||
server {
|
||||
listen 80;
|
||||
server_name print.yourdomain.com;
|
||||
|
||||
location /CLodop/ {
|
||||
proxy_pass http://localhost:8000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# WebSocket 支持
|
||||
location /CLodop/ws {
|
||||
proxy_pass http://localhost:8000/ws;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **9. 安全配置**
|
||||
|
||||
#### **9.1 访问控制**
|
||||
```javascript
|
||||
// security-middleware.js
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
|
||||
// IP白名单
|
||||
const allowedIPs = ['192.168.1.0/24', '10.0.0.0/8'];
|
||||
|
||||
app.use('/print', (req, res, next) => {
|
||||
const clientIP = req.ip || req.connection.remoteAddress;
|
||||
|
||||
if (!isIPAllowed(clientIP, allowedIPs)) {
|
||||
return res.status(403).json({ error: '访问被拒绝' });
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
function isIPAllowed(ip, allowedRanges) {
|
||||
// IP地址验证逻辑
|
||||
return allowedRanges.some(range => {
|
||||
// 简单的IP范围检查实现
|
||||
return ip.startsWith(range.split('.')[0]);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### **10. 性能优化**
|
||||
|
||||
#### **10.1 打印任务队列**
|
||||
```javascript
|
||||
// print-queue.js
|
||||
class PrintQueue {
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
this.isProcessing = false;
|
||||
this.maxConcurrent = 1; // 串行打印
|
||||
}
|
||||
|
||||
async addJob(printJob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.queue.push({ printJob, resolve, reject });
|
||||
this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
async processQueue() {
|
||||
if (this.isProcessing || this.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessing = true;
|
||||
|
||||
while (this.queue.length > 0) {
|
||||
const { printJob, resolve, reject } = this.queue.shift();
|
||||
|
||||
try {
|
||||
const result = await printJob();
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
|
||||
// 延迟一下,避免打印机过载
|
||||
await this.delay(500);
|
||||
}
|
||||
|
||||
this.isProcessing = false;
|
||||
}
|
||||
|
||||
delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 💡 **总结**
|
||||
|
||||
### **CLodop 核心优势**
|
||||
1. **零客户端安装** - 基于云服务架构
|
||||
2. **完美浏览器兼容** - 支持所有现代浏览器
|
||||
3. **企业级功能** - 批量打印、精确控制、多种纸张支持
|
||||
4. **易于集成** - 简单的 JavaScript API
|
||||
|
||||
### **适用场景**
|
||||
- ✅ **企业ERP系统** - 报表、单据打印
|
||||
- ✅ **电商平台** - 订单、发货单打印
|
||||
- ✅ **医疗系统** - 处方、报告打印
|
||||
- ✅ **政府办公** - 公文、证件打印
|
||||
- ✅ **教育系统** - 成绩单、证书打印
|
||||
|
||||
### **部署建议**
|
||||
1. **开发环境**:本地安装CLodop服务
|
||||
2. **测试环境**:内网服务器部署
|
||||
3. **生产环境**:高可用集群部署,配合负载均衡
|
||||
|
||||
CLodop 是解决Web打印难题的优秀方案,特别适合需要精确控制打印格式的企业级应用!
|
||||
605
docs/db/PROCEDURE_DEV_v2.0.md
Normal file
605
docs/db/PROCEDURE_DEV_v2.0.md
Normal file
@@ -0,0 +1,605 @@
|
||||
# 开发中使用的存储过程
|
||||
|
||||
|
||||
## 统计一下,数据库中哪些表中有site_id字段或者store_id字段,而且表中是有数据的
|
||||
|
||||
```sql
|
||||
|
||||
-- 检查符合条件的表
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE CheckTablesWithHasData()
|
||||
BEGIN
|
||||
DECLARE done INT DEFAULT FALSE;
|
||||
DECLARE tbl_name VARCHAR(255);
|
||||
DECLARE cur CURSOR FOR
|
||||
SELECT DISTINCT TABLE_NAME
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE COLUMN_NAME IN ('site_id', 'store_id')
|
||||
AND TABLE_SCHEMA = DATABASE(); -- 使用当前数据库
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
|
||||
|
||||
CREATE TEMPORARY TABLE tmp_results (table_name VARCHAR(255));
|
||||
|
||||
OPEN cur;
|
||||
|
||||
read_loop: LOOP
|
||||
FETCH cur INTO tbl_name;
|
||||
IF done THEN
|
||||
LEAVE read_loop;
|
||||
END IF;
|
||||
|
||||
SET @sql = CONCAT('INSERT INTO tmp_results SELECT ''', tbl_name, ''' FROM ', tbl_name, ' HAVING COUNT(*) > 0');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
END LOOP;
|
||||
|
||||
CLOSE cur;
|
||||
|
||||
SELECT table_name FROM tmp_results;
|
||||
DROP TEMPORARY TABLE tmp_results;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
-- 执行
|
||||
CALL CheckTablesWithHasData();
|
||||
|
||||
--- 结果
|
||||
+-----------------+
|
||||
| table_name |
|
||||
+-----------------+
|
||||
| t_order_info |
|
||||
| t_order_item |
|
||||
| t_order_payment |
|
||||
| t_order_refund |
|
||||
+-----------------+
|
||||
```
|
||||
|
||||
## 统计一下,数据库中哪些表中是有数据的
|
||||
|
||||
```sql
|
||||
|
||||
-- 检查符合条件的表
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE CheckTablesIsNotEmpty()
|
||||
BEGIN
|
||||
DECLARE done INT DEFAULT FALSE;
|
||||
DECLARE tbl_name VARCHAR(255);
|
||||
DECLARE cur CURSOR FOR
|
||||
SELECT DISTINCT TABLE_NAME
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE(); -- 使用当前数据库
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
|
||||
|
||||
CREATE TEMPORARY TABLE tmp_results (table_name VARCHAR(255));
|
||||
|
||||
OPEN cur;
|
||||
|
||||
read_loop: LOOP
|
||||
FETCH cur INTO tbl_name;
|
||||
IF done THEN
|
||||
LEAVE read_loop;
|
||||
END IF;
|
||||
|
||||
SET @sql = CONCAT('INSERT INTO tmp_results SELECT ''', tbl_name, ''' FROM ', tbl_name, ' HAVING COUNT(*) > 0');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
END LOOP;
|
||||
|
||||
CLOSE cur;
|
||||
|
||||
SELECT table_name FROM tmp_results;
|
||||
DROP TEMPORARY TABLE tmp_results;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
-- 执行
|
||||
CALL CheckTablesIsNotEmpty();
|
||||
```
|
||||
|
||||
|
||||
## 对于空表或者无数据表,重置 AUTO_INCREMENT
|
||||
|
||||
```sql
|
||||
|
||||
-- 定义存储过程
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE ResetAutoIncrementForEmptyTables()
|
||||
BEGIN
|
||||
DECLARE done INT DEFAULT FALSE;
|
||||
DECLARE tbl_name VARCHAR(255);
|
||||
DECLARE cnt BIGINT;
|
||||
|
||||
-- 声明游标:获取所有表名
|
||||
DECLARE cur CURSOR FOR
|
||||
SELECT DISTINCT c.TABLE_NAME
|
||||
FROM information_schema.COLUMNS c
|
||||
INNER JOIN information_schema.TABLES t
|
||||
ON c.TABLE_SCHEMA = t.TABLE_SCHEMA
|
||||
AND c.TABLE_NAME = t.TABLE_NAME
|
||||
WHERE
|
||||
c.TABLE_SCHEMA = DATABASE()
|
||||
AND t.TABLE_TYPE = 'BASE TABLE'; -- 排除视图
|
||||
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
|
||||
|
||||
OPEN cur;
|
||||
|
||||
read_loop: LOOP
|
||||
FETCH cur INTO tbl_name;
|
||||
IF done THEN
|
||||
LEAVE read_loop;
|
||||
END IF;
|
||||
|
||||
-- 动态获取行数
|
||||
SET @sql = CONCAT('SELECT COUNT(*) INTO @cnt FROM `', tbl_name, '`');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 如果为空,重置 AUTO_INCREMENT
|
||||
IF @cnt = 0 THEN
|
||||
SET @reset_sql = CONCAT('ALTER TABLE `', tbl_name, '` AUTO_INCREMENT = 1');
|
||||
PREPARE reset_stmt FROM @reset_sql;
|
||||
EXECUTE reset_stmt;
|
||||
DEALLOCATE PREPARE reset_stmt;
|
||||
SELECT CONCAT('Reset AUTO_INCREMENT for table: ', tbl_name) AS message;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
CLOSE cur;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
-- 执行
|
||||
|
||||
CALL ResetAutoIncrementForEmptyTables();
|
||||
```
|
||||
|
||||
|
||||
## 数据重置到初始化状态
|
||||
|
||||
|
||||
### 1. 删除数据,保留表结构,当表中有site_id字段时,只删除site_id不为0的数据
|
||||
|
||||
```sql
|
||||
|
||||
-- 定义存储过程
|
||||
-- 增强版数据库重置脚本
|
||||
-- 支持 MySQL/PostgreSQL/SQL Server
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
DROP PROCEDURE IF EXISTS reset_tables_has_site_id_where;
|
||||
CREATE PROCEDURE reset_tables_has_site_id_where(
|
||||
IN p_preserve_site_zero BOOLEAN, -- 是否保留 site_id = 0 的数据
|
||||
IN p_dry_run BOOLEAN, -- 是否试运行(不实际执行)
|
||||
IN p_exclude_tables TEXT -- 排除的表列表,逗号分隔
|
||||
)
|
||||
BEGIN
|
||||
DECLARE v_done INT DEFAULT FALSE;
|
||||
DECLARE v_table_name VARCHAR(255);
|
||||
DECLARE v_has_site_id INT DEFAULT 0;
|
||||
DECLARE v_table_count INT DEFAULT 0;
|
||||
DECLARE v_processed_count INT DEFAULT 0;
|
||||
DECLARE v_skipped_count INT DEFAULT 0;
|
||||
DECLARE v_error_count INT DEFAULT 0;
|
||||
DECLARE v_no_site_id_tables TEXT DEFAULT '';
|
||||
DECLARE v_excluded_tables TEXT DEFAULT '';
|
||||
|
||||
-- 游标声明
|
||||
DECLARE table_cursor CURSOR FOR
|
||||
SELECT TABLE_NAME
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_TYPE = 'BASE TABLE'
|
||||
AND TABLE_NAME NOT IN (
|
||||
'sys_operation_log', 'sys_login_log', 'system_parameters' -- 系统表默认排除
|
||||
)
|
||||
ORDER BY TABLE_NAME;
|
||||
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = TRUE;
|
||||
|
||||
-- 创建详细的日志表
|
||||
DROP TEMPORARY TABLE IF EXISTS reset_operation_log;
|
||||
CREATE TEMPORARY TABLE reset_operation_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
log_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
table_name VARCHAR(255) NOT NULL,
|
||||
action_type ENUM('CLEARED', 'SKIPPED', 'ERROR', 'EXCLUDED') NOT NULL,
|
||||
records_affected INT DEFAULT 0,
|
||||
sql_statement TEXT,
|
||||
error_message TEXT,
|
||||
execution_time_ms INT DEFAULT 0
|
||||
);
|
||||
|
||||
-- 开始事务(确保原子性)
|
||||
START TRANSACTION;
|
||||
|
||||
OPEN table_cursor;
|
||||
|
||||
process_tables: LOOP
|
||||
FETCH table_cursor INTO v_table_name;
|
||||
IF v_done THEN
|
||||
LEAVE process_tables;
|
||||
END IF;
|
||||
|
||||
SET v_table_count = v_table_count + 1;
|
||||
|
||||
-- 检查是否在排除列表中
|
||||
IF FIND_IN_SET(v_table_name, p_exclude_tables) > 0 OR
|
||||
FIND_IN_SET(v_table_name, v_excluded_tables) > 0 THEN
|
||||
INSERT INTO reset_operation_log (table_name, action_type, records_affected, sql_statement)
|
||||
VALUES (v_table_name, 'EXCLUDED', 0, '表在排除列表中,跳过处理');
|
||||
SET v_skipped_count = v_skipped_count + 1;
|
||||
ITERATE process_tables;
|
||||
END IF;
|
||||
|
||||
-- 检查表是否有 site_id 字段
|
||||
SELECT COUNT(*) INTO v_has_site_id
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = v_table_name
|
||||
AND COLUMN_NAME = 'site_id';
|
||||
|
||||
SET @start_time = UNIX_TIMESTAMP(NOW(3)) * 1000;
|
||||
|
||||
IF v_has_site_id > 0 THEN
|
||||
-- 构建动态SQL
|
||||
IF p_preserve_site_zero THEN
|
||||
SET @sql = CONCAT('DELETE FROM `', v_table_name, '` WHERE site_id != 0');
|
||||
ELSE
|
||||
SET @sql = CONCAT('TRUNCATE TABLE `', v_table_name, '`');
|
||||
END IF;
|
||||
|
||||
BEGIN
|
||||
DECLARE EXIT HANDLER FOR SQLEXCEPTION
|
||||
BEGIN
|
||||
GET DIAGNOSTICS CONDITION 1 @sqlstate = RETURNED_SQLSTATE, @errmsg = MESSAGE_TEXT;
|
||||
INSERT INTO reset_operation_log
|
||||
(table_name, action_type, records_affected, sql_statement, error_message)
|
||||
VALUES (v_table_name, 'ERROR', 0, @sql, CONCAT(@sqlstate, ' - ', @errmsg));
|
||||
SET v_error_count = v_error_count + 1;
|
||||
END;
|
||||
|
||||
IF p_dry_run THEN
|
||||
-- 试运行模式,只记录不执行
|
||||
INSERT INTO reset_operation_log
|
||||
(table_name, action_type, records_affected, sql_statement)
|
||||
VALUES (v_table_name, 'SKIPPED', 0, CONCAT('试运行: ', @sql));
|
||||
SET v_skipped_count = v_skipped_count + 1;
|
||||
ELSE
|
||||
-- 实际执行
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @row_count = ROW_COUNT();
|
||||
SET @end_time = UNIX_TIMESTAMP(NOW(3)) * 1000;
|
||||
|
||||
INSERT INTO reset_operation_log
|
||||
(table_name, action_type, records_affected, sql_statement, execution_time_ms)
|
||||
VALUES (v_table_name, 'CLEARED', @row_count, @sql, (@end_time - @start_time));
|
||||
|
||||
SET v_processed_count = v_processed_count + 1;
|
||||
END IF;
|
||||
END;
|
||||
ELSE
|
||||
-- 表没有 site_id 字段
|
||||
SET v_no_site_id_tables = CONCAT_WS(', ', v_no_site_id_tables, v_table_name);
|
||||
|
||||
INSERT INTO reset_operation_log
|
||||
(table_name, action_type, records_affected, sql_statement)
|
||||
VALUES (v_table_name, 'SKIPPED', 0, '表没有 site_id 字段,跳过处理');
|
||||
|
||||
SET v_skipped_count = v_skipped_count + 1;
|
||||
END IF;
|
||||
|
||||
SET v_has_site_id = 0;
|
||||
END LOOP;
|
||||
|
||||
CLOSE table_cursor;
|
||||
|
||||
IF p_dry_run THEN
|
||||
ROLLBACK; -- 试运行模式回滚
|
||||
SELECT '=== 试运行模式 - 未实际执行任何操作 ===' as notice;
|
||||
ELSE
|
||||
COMMIT; -- 提交事务
|
||||
SELECT '=== 数据库重置完成 ===' as summary;
|
||||
END IF;
|
||||
|
||||
-- 生成总结报告
|
||||
SELECT
|
||||
CONCAT('数据库: ', DATABASE()) as database_info,
|
||||
CONCAT('开始时间: ', DATE_FORMAT(MIN(log_time), '%Y-%m-%d %H:%i:%s')) as start_time,
|
||||
CONCAT('结束时间: ', DATE_FORMAT(MAX(log_time), '%Y-%m-%d %H:%i:%s')) as end_time,
|
||||
CONCAT('总表数: ', v_table_count) as total_tables,
|
||||
CONCAT('已处理表: ', v_processed_count) as processed_tables,
|
||||
CONCAT('跳过表: ', v_skipped_count) as skipped_tables,
|
||||
CONCAT('错误数: ', v_error_count) as error_count,
|
||||
CONCAT('总耗时: ', SUM(execution_time_ms), 'ms') as total_duration
|
||||
FROM reset_operation_log;
|
||||
|
||||
-- 显示没有 site_id 字段的表
|
||||
IF v_no_site_id_tables != '' THEN
|
||||
SELECT
|
||||
'以下表没有 site_id 字段,已被跳过:' as warning_title,
|
||||
v_no_site_id_tables as skipped_tables;
|
||||
END IF;
|
||||
|
||||
-- 显示错误详情
|
||||
IF v_error_count > 0 THEN
|
||||
SELECT '=== 错误详情 ===' as error_title;
|
||||
SELECT table_name, error_message, sql_statement
|
||||
FROM reset_operation_log
|
||||
WHERE action_type = 'ERROR'
|
||||
ORDER BY log_time;
|
||||
END IF;
|
||||
|
||||
-- 显示处理详情
|
||||
SELECT '=== 处理详情 ===' as details_title;
|
||||
SELECT
|
||||
table_name as '表名',
|
||||
CASE action_type
|
||||
WHEN 'CLEARED' THEN '已清空'
|
||||
WHEN 'SKIPPED' THEN '已跳过'
|
||||
WHEN 'ERROR' THEN '错误'
|
||||
WHEN 'EXCLUDED' THEN '已排除'
|
||||
END as '操作状态',
|
||||
records_affected as '影响行数',
|
||||
execution_time_ms as '耗时(ms)',
|
||||
CASE
|
||||
WHEN error_message IS NOT NULL THEN error_message
|
||||
ELSE LEFT(sql_statement, 100)
|
||||
END as '详情'
|
||||
FROM reset_operation_log
|
||||
ORDER BY action_type, table_name;
|
||||
|
||||
-- 性能统计
|
||||
SELECT '=== 性能统计 ===' as performance_title;
|
||||
SELECT
|
||||
action_type as '操作类型',
|
||||
COUNT(*) as '表数量',
|
||||
SUM(records_affected) as '总影响行数',
|
||||
AVG(execution_time_ms) as '平均耗时(ms)',
|
||||
SUM(execution_time_ms) as '总耗时(ms)'
|
||||
FROM reset_operation_log
|
||||
GROUP BY action_type;
|
||||
|
||||
-- 清理
|
||||
DROP TEMPORARY TABLE IF EXISTS reset_operation_log;
|
||||
END
|
||||
$$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
-- 使用示例
|
||||
-- 试运行(不实际执行)
|
||||
CALL reset_tables_has_site_id_where(TRUE, TRUE, 'user,config');
|
||||
|
||||
-- 实际执行(保留site_id=0的数据,排除user和config表)
|
||||
CALL reset_tables_has_site_id_where(TRUE, FALSE, '');
|
||||
|
||||
-- 完全清空所有表(不保留任何数据)
|
||||
CALL reset_tables_has_site_id_where(FALSE, FALSE, '');
|
||||
|
||||
```
|
||||
|
||||
## 删除数据,保留表结构,当表中有store_id字段时,只删除store_id不为0的数据
|
||||
|
||||
```sql
|
||||
DELIMITER $$
|
||||
|
||||
DROP PROCEDURE IF EXISTS reset_tables_has_store_id_where;
|
||||
CREATE PROCEDURE reset_tables_has_store_id_where(
|
||||
IN p_preserve_site_zero BOOLEAN, -- 是否保留 store_id = 0 的数据
|
||||
IN p_dry_run BOOLEAN, -- 是否试运行(不实际执行)
|
||||
IN p_exclude_tables TEXT -- 排除的表列表,逗号分隔
|
||||
)
|
||||
BEGIN
|
||||
DECLARE v_done INT DEFAULT FALSE;
|
||||
DECLARE v_table_name VARCHAR(255);
|
||||
DECLARE v_has_store_id INT DEFAULT 0;
|
||||
DECLARE v_table_count INT DEFAULT 0;
|
||||
DECLARE v_processed_count INT DEFAULT 0;
|
||||
DECLARE v_skipped_count INT DEFAULT 0;
|
||||
DECLARE v_error_count INT DEFAULT 0;
|
||||
DECLARE v_no_store_id_tables TEXT DEFAULT '';
|
||||
DECLARE v_excluded_tables TEXT DEFAULT '';
|
||||
|
||||
-- 游标声明
|
||||
DECLARE table_cursor CURSOR FOR
|
||||
SELECT TABLE_NAME
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_TYPE = 'BASE TABLE'
|
||||
AND TABLE_NAME NOT IN (
|
||||
'sys_operation_log', 'sys_login_log', 'system_parameters' -- 系统表默认排除
|
||||
)
|
||||
ORDER BY TABLE_NAME;
|
||||
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = TRUE;
|
||||
|
||||
-- 创建详细的日志表
|
||||
DROP TEMPORARY TABLE IF EXISTS reset_operation_log;
|
||||
CREATE TEMPORARY TABLE reset_operation_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
log_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
table_name VARCHAR(255) NOT NULL,
|
||||
action_type ENUM('CLEARED', 'SKIPPED', 'ERROR', 'EXCLUDED') NOT NULL,
|
||||
records_affected INT DEFAULT 0,
|
||||
sql_statement TEXT,
|
||||
error_message TEXT,
|
||||
execution_time_ms INT DEFAULT 0
|
||||
);
|
||||
|
||||
-- 开始事务(确保原子性)
|
||||
START TRANSACTION;
|
||||
|
||||
OPEN table_cursor;
|
||||
|
||||
process_tables: LOOP
|
||||
FETCH table_cursor INTO v_table_name;
|
||||
IF v_done THEN
|
||||
LEAVE process_tables;
|
||||
END IF;
|
||||
|
||||
SET v_table_count = v_table_count + 1;
|
||||
|
||||
-- 检查是否在排除列表中
|
||||
IF FIND_IN_SET(v_table_name, p_exclude_tables) > 0 OR
|
||||
FIND_IN_SET(v_table_name, v_excluded_tables) > 0 THEN
|
||||
INSERT INTO reset_operation_log (table_name, action_type, records_affected, sql_statement)
|
||||
VALUES (v_table_name, 'EXCLUDED', 0, '表在排除列表中,跳过处理');
|
||||
SET v_skipped_count = v_skipped_count + 1;
|
||||
ITERATE process_tables;
|
||||
END IF;
|
||||
|
||||
-- 检查表是否有 store_id 字段
|
||||
SELECT COUNT(*) INTO v_has_store_id
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = v_table_name
|
||||
AND COLUMN_NAME = 'store_id';
|
||||
|
||||
SET @start_time = UNIX_TIMESTAMP(NOW(3)) * 1000;
|
||||
|
||||
IF v_has_store_id > 0 THEN
|
||||
-- 构建动态SQL
|
||||
IF p_preserve_site_zero THEN
|
||||
SET @sql = CONCAT('DELETE FROM `', v_table_name, '` WHERE store_id != 0');
|
||||
ELSE
|
||||
SET @sql = CONCAT('TRUNCATE TABLE `', v_table_name, '`');
|
||||
END IF;
|
||||
|
||||
BEGIN
|
||||
DECLARE EXIT HANDLER FOR SQLEXCEPTION
|
||||
BEGIN
|
||||
GET DIAGNOSTICS CONDITION 1 @sqlstate = RETURNED_SQLSTATE, @errmsg = MESSAGE_TEXT;
|
||||
INSERT INTO reset_operation_log
|
||||
(table_name, action_type, records_affected, sql_statement, error_message)
|
||||
VALUES (v_table_name, 'ERROR', 0, @sql, CONCAT(@sqlstate, ' - ', @errmsg));
|
||||
SET v_error_count = v_error_count + 1;
|
||||
END;
|
||||
|
||||
IF p_dry_run THEN
|
||||
-- 试运行模式,只记录不执行
|
||||
INSERT INTO reset_operation_log
|
||||
(table_name, action_type, records_affected, sql_statement)
|
||||
VALUES (v_table_name, 'SKIPPED', 0, CONCAT('试运行: ', @sql));
|
||||
SET v_skipped_count = v_skipped_count + 1;
|
||||
ELSE
|
||||
-- 实际执行
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @row_count = ROW_COUNT();
|
||||
SET @end_time = UNIX_TIMESTAMP(NOW(3)) * 1000;
|
||||
|
||||
INSERT INTO reset_operation_log
|
||||
(table_name, action_type, records_affected, sql_statement, execution_time_ms)
|
||||
VALUES (v_table_name, 'CLEARED', @row_count, @sql, (@end_time - @start_time));
|
||||
|
||||
SET v_processed_count = v_processed_count + 1;
|
||||
END IF;
|
||||
END;
|
||||
ELSE
|
||||
-- 表没有 store_id 字段
|
||||
SET v_no_store_id_tables = CONCAT_WS(', ', v_no_store_id_tables, v_table_name);
|
||||
|
||||
INSERT INTO reset_operation_log
|
||||
(table_name, action_type, records_affected, sql_statement)
|
||||
VALUES (v_table_name, 'SKIPPED', 0, '表没有 store_id 字段,跳过处理');
|
||||
|
||||
SET v_skipped_count = v_skipped_count + 1;
|
||||
END IF;
|
||||
|
||||
SET v_has_store_id = 0;
|
||||
END LOOP;
|
||||
|
||||
CLOSE table_cursor;
|
||||
|
||||
IF p_dry_run THEN
|
||||
ROLLBACK; -- 试运行模式回滚
|
||||
SELECT '=== 试运行模式 - 未实际执行任何操作 ===' as notice;
|
||||
ELSE
|
||||
COMMIT; -- 提交事务
|
||||
SELECT '=== 数据库重置完成 ===' as summary;
|
||||
END IF;
|
||||
|
||||
-- 生成总结报告
|
||||
SELECT
|
||||
CONCAT('数据库: ', DATABASE()) as database_info,
|
||||
CONCAT('开始时间: ', DATE_FORMAT(MIN(log_time), '%Y-%m-%d %H:%i:%s')) as start_time,
|
||||
CONCAT('结束时间: ', DATE_FORMAT(MAX(log_time), '%Y-%m-%d %H:%i:%s')) as end_time,
|
||||
CONCAT('总表数: ', v_table_count) as total_tables,
|
||||
CONCAT('已处理表: ', v_processed_count) as processed_tables,
|
||||
CONCAT('跳过表: ', v_skipped_count) as skipped_tables,
|
||||
CONCAT('错误数: ', v_error_count) as error_count,
|
||||
CONCAT('总耗时: ', SUM(execution_time_ms), 'ms') as total_duration
|
||||
FROM reset_operation_log;
|
||||
|
||||
-- 显示没有 store_id 字段的表
|
||||
IF v_no_store_id_tables != '' THEN
|
||||
SELECT
|
||||
'以下表没有 store_id 字段,已被跳过:' as warning_title,
|
||||
v_no_store_id_tables as skipped_tables;
|
||||
END IF;
|
||||
|
||||
-- 显示错误详情
|
||||
IF v_error_count > 0 THEN
|
||||
SELECT '=== 错误详情 ===' as error_title;
|
||||
SELECT table_name, error_message, sql_statement
|
||||
FROM reset_operation_log
|
||||
WHERE action_type = 'ERROR'
|
||||
ORDER BY log_time;
|
||||
END IF;
|
||||
|
||||
-- 显示处理详情
|
||||
SELECT '=== 处理详情 ===' as details_title;
|
||||
SELECT
|
||||
table_name as '表名',
|
||||
CASE action_type
|
||||
WHEN 'CLEARED' THEN '已清空'
|
||||
WHEN 'SKIPPED' THEN '已跳过'
|
||||
WHEN 'ERROR' THEN '错误'
|
||||
WHEN 'EXCLUDED' THEN '已排除'
|
||||
END as '操作状态',
|
||||
records_affected as '影响行数',
|
||||
execution_time_ms as '耗时(ms)',
|
||||
CASE
|
||||
WHEN error_message IS NOT NULL THEN error_message
|
||||
ELSE LEFT(sql_statement, 100)
|
||||
END as '详情'
|
||||
FROM reset_operation_log
|
||||
ORDER BY action_type, table_name;
|
||||
|
||||
-- 性能统计
|
||||
SELECT '=== 性能统计 ===' as performance_title;
|
||||
SELECT
|
||||
action_type as '操作类型',
|
||||
COUNT(*) as '表数量',
|
||||
SUM(records_affected) as '总影响行数',
|
||||
AVG(execution_time_ms) as '平均耗时(ms)',
|
||||
SUM(execution_time_ms) as '总耗时(ms)'
|
||||
FROM reset_operation_log
|
||||
GROUP BY action_type;
|
||||
|
||||
-- 清理
|
||||
DROP TEMPORARY TABLE IF EXISTS reset_operation_log;
|
||||
END
|
||||
$$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
-- 实际执行(保留site_id=0的数据,排除user和config表)
|
||||
CALL reset_tables_has_store_id_where(TRUE, FALSE, '');
|
||||
```
|
||||
375
docs/db/compare_sql_tables.py
Normal file
375
docs/db/compare_sql_tables.py
Normal file
@@ -0,0 +1,375 @@
|
||||
import re
|
||||
import os
|
||||
|
||||
# 解析SQL文件,提取表结构
|
||||
def parse_sql_file(file_path, ignore_prefix=None):
|
||||
tables = {}
|
||||
|
||||
# 读取文件内容
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 匹配CREATE TABLE语句
|
||||
table_pattern = re.compile(r'CREATE TABLE\s+`?([^`\s]+)`?\s*\(([^;]+)\)\s*(?:[^;]+);', re.DOTALL | re.IGNORECASE)
|
||||
|
||||
matches = table_pattern.findall(content)
|
||||
|
||||
for full_table_name, table_def in matches:
|
||||
# 处理表名,忽略前缀
|
||||
table_name = full_table_name
|
||||
if ignore_prefix and table_name.startswith(ignore_prefix):
|
||||
table_name = table_name[len(ignore_prefix):]
|
||||
|
||||
# 提取列定义
|
||||
columns = []
|
||||
# 匹配列定义,包括列名、类型、约束等
|
||||
column_pattern = re.compile(r'\s*`?([^`\s,]+)`?\s+([^\s,]+)\s*([^,]+)(?:,|$)', re.DOTALL)
|
||||
column_matches = column_pattern.findall(table_def)
|
||||
|
||||
for col_name, col_type, col_constraints in column_matches:
|
||||
# 清理约束中的换行符和多余空格
|
||||
col_constraints = re.sub(r'\s+', ' ', col_constraints.strip())
|
||||
columns.append((col_name, col_type, col_constraints))
|
||||
|
||||
# 提取主键
|
||||
primary_key = None
|
||||
pk_pattern = re.compile(r'PRIMARY\s+KEY\s*\(`?([^`\s,]+)`?\)', re.IGNORECASE)
|
||||
pk_match = pk_pattern.search(table_def)
|
||||
if pk_match:
|
||||
primary_key = pk_match.group(1)
|
||||
|
||||
# 提取索引
|
||||
indexes = []
|
||||
index_pattern = re.compile(r'INDEX\s+`?([^`\s]+)`?\s*\(([^)]+)\)', re.IGNORECASE)
|
||||
index_matches = index_pattern.findall(table_def)
|
||||
for index_name, index_cols in index_matches:
|
||||
indexes.append((index_name, index_cols.strip()))
|
||||
|
||||
# 提取唯一约束
|
||||
unique_constraints = []
|
||||
unique_pattern = re.compile(r'UNIQUE\s+KEY\s*`?([^`\s]+)`?\s*\(([^)]+)\)', re.IGNORECASE)
|
||||
unique_matches = unique_pattern.findall(table_def)
|
||||
for unique_name, unique_cols in unique_matches:
|
||||
unique_constraints.append((unique_name, unique_cols.strip()))
|
||||
|
||||
tables[table_name] = {
|
||||
'full_name': full_table_name,
|
||||
'columns': columns,
|
||||
'primary_key': primary_key,
|
||||
'indexes': indexes,
|
||||
'unique_constraints': unique_constraints
|
||||
}
|
||||
|
||||
return tables
|
||||
|
||||
# 比较两个数据库表结构
|
||||
def compare_databases(db1, db2, db1_name, db2_name):
|
||||
diffs = {
|
||||
'only_in_db1': [],
|
||||
'only_in_db2': [],
|
||||
'column_diffs': {},
|
||||
'primary_key_diffs': {},
|
||||
'index_diffs': {},
|
||||
'unique_constraint_diffs': {}
|
||||
}
|
||||
|
||||
# 找出只在db1中存在的表
|
||||
for table_name in db1:
|
||||
if table_name not in db2:
|
||||
diffs['only_in_db1'].append(table_name)
|
||||
|
||||
# 找出只在db2中存在的表
|
||||
for table_name in db2:
|
||||
if table_name not in db1:
|
||||
diffs['only_in_db2'].append(table_name)
|
||||
|
||||
# 比较共同存在的表
|
||||
common_tables = set(db1.keys()) & set(db2.keys())
|
||||
for table_name in common_tables:
|
||||
table1 = db1[table_name]
|
||||
table2 = db2[table_name]
|
||||
|
||||
# 比较列
|
||||
col_diffs = {
|
||||
'only_in_db1': [],
|
||||
'only_in_db2': [],
|
||||
'type_diffs': [],
|
||||
'constraint_diffs': []
|
||||
}
|
||||
|
||||
# 列名集合
|
||||
cols1 = {col[0]: col for col in table1['columns']}
|
||||
cols2 = {col[0]: col for col in table2['columns']}
|
||||
|
||||
# 只在db1中的列
|
||||
for col_name in cols1:
|
||||
if col_name not in cols2:
|
||||
col_diffs['only_in_db1'].append(col_name)
|
||||
|
||||
# 只在db2中的列
|
||||
for col_name in cols2:
|
||||
if col_name not in cols1:
|
||||
col_diffs['only_in_db2'].append(col_name)
|
||||
|
||||
# 比较列类型和约束
|
||||
common_cols = set(cols1.keys()) & set(cols2.keys())
|
||||
for col_name in common_cols:
|
||||
col1 = cols1[col_name]
|
||||
col2 = cols2[col_name]
|
||||
|
||||
# 类型差异
|
||||
if col1[1] != col2[1]:
|
||||
col_diffs['type_diffs'].append((col_name, col1[1], col2[1]))
|
||||
|
||||
# 约束差异
|
||||
if col1[2] != col2[2]:
|
||||
col_diffs['constraint_diffs'].append((col_name, col1[2], col2[2]))
|
||||
|
||||
if any(col_diffs.values()):
|
||||
diffs['column_diffs'][table_name] = col_diffs
|
||||
|
||||
# 比较主键
|
||||
if table1['primary_key'] != table2['primary_key']:
|
||||
diffs['primary_key_diffs'][table_name] = (table1['primary_key'], table2['primary_key'])
|
||||
|
||||
# 比较索引
|
||||
index_diffs = {
|
||||
'only_in_db1': [],
|
||||
'only_in_db2': [],
|
||||
'definition_diffs': []
|
||||
}
|
||||
|
||||
indexes1 = {idx[0]: idx[1] for idx in table1['indexes']}
|
||||
indexes2 = {idx[0]: idx[1] for idx in table2['indexes']}
|
||||
|
||||
# 只在db1中的索引
|
||||
for idx_name in indexes1:
|
||||
if idx_name not in indexes2:
|
||||
index_diffs['only_in_db1'].append((idx_name, indexes1[idx_name]))
|
||||
|
||||
# 只在db2中的索引
|
||||
for idx_name in indexes2:
|
||||
if idx_name not in indexes1:
|
||||
index_diffs['only_in_db2'].append((idx_name, indexes2[idx_name]))
|
||||
|
||||
# 比较索引定义
|
||||
common_indexes = set(indexes1.keys()) & set(indexes2.keys())
|
||||
for idx_name in common_indexes:
|
||||
if indexes1[idx_name] != indexes2[idx_name]:
|
||||
index_diffs['definition_diffs'].append((idx_name, indexes1[idx_name], indexes2[idx_name]))
|
||||
|
||||
if any(index_diffs.values()):
|
||||
diffs['index_diffs'][table_name] = index_diffs
|
||||
|
||||
# 比较唯一约束
|
||||
unique_diffs = {
|
||||
'only_in_db1': [],
|
||||
'only_in_db2': [],
|
||||
'definition_diffs': []
|
||||
}
|
||||
|
||||
unique1 = {uc[0]: uc[1] for uc in table1['unique_constraints']}
|
||||
unique2 = {uc[0]: uc[1] for uc in table2['unique_constraints']}
|
||||
|
||||
# 只在db1中的唯一约束
|
||||
for uc_name in unique1:
|
||||
if uc_name not in unique2:
|
||||
unique_diffs['only_in_db1'].append((uc_name, unique1[uc_name]))
|
||||
|
||||
# 只在db2中的唯一约束
|
||||
for uc_name in unique2:
|
||||
if uc_name not in unique1:
|
||||
unique_diffs['only_in_db2'].append((uc_name, unique2[uc_name]))
|
||||
|
||||
# 比较唯一约束定义
|
||||
common_unique = set(unique1.keys()) & set(unique2.keys())
|
||||
for uc_name in common_unique:
|
||||
if unique1[uc_name] != unique2[uc_name]:
|
||||
unique_diffs['definition_diffs'].append((uc_name, unique1[uc_name], unique2[uc_name]))
|
||||
|
||||
if any(unique_diffs.values()):
|
||||
diffs['unique_constraint_diffs'][table_name] = unique_diffs
|
||||
|
||||
return diffs
|
||||
|
||||
# 打印差异报告
|
||||
# 生成Markdown格式的差异报告
|
||||
def generate_markdown_report(diffs, db1_name, db2_name, db1_table_count, db2_table_count):
|
||||
report = []
|
||||
|
||||
# 报告标题
|
||||
report.append(f"# 数据库差异报告: {db1_name} vs {db2_name}")
|
||||
report.append("\n## 1. 表数量统计")
|
||||
report.append("| 数据库文件 | 表数量 |")
|
||||
report.append("|------------|--------|")
|
||||
report.append(f"| {db1_name} | {db1_table_count} |")
|
||||
report.append(f"| {db2_name} | {db2_table_count} |")
|
||||
|
||||
# 表存在性差异
|
||||
report.append("\n## 2. 表存在性差异")
|
||||
|
||||
# 仅在db1中的表
|
||||
if diffs['only_in_db1']:
|
||||
report.append(f"\n### 2.1 仅在 {db1_name} 中存在的表 ({len(diffs['only_in_db1'])} 个)")
|
||||
report.append("| 表名 |")
|
||||
report.append("|------|")
|
||||
for table in sorted(diffs['only_in_db1']):
|
||||
report.append(f"| {table} |")
|
||||
|
||||
# 仅在db2中的表
|
||||
if diffs['only_in_db2']:
|
||||
report.append(f"\n### 2.2 仅在 {db2_name} 中存在的表 ({len(diffs['only_in_db2'])} 个)")
|
||||
report.append("| 表名 |")
|
||||
report.append("|------|")
|
||||
for table in sorted(diffs['only_in_db2']):
|
||||
report.append(f"| {table} |")
|
||||
|
||||
# 列结构差异
|
||||
if diffs['column_diffs']:
|
||||
report.append(f"\n## 3. 列结构差异的表 ({len(diffs['column_diffs'])} 个)")
|
||||
|
||||
for table, col_diffs in diffs['column_diffs'].items():
|
||||
report.append(f"\n### 3.1 表: {table}")
|
||||
|
||||
# 仅在db1中的列
|
||||
if col_diffs['only_in_db1']:
|
||||
report.append(f"\n#### 3.1.1 仅在 {db1_name} 中存在的列")
|
||||
report.append("| 列名 |")
|
||||
report.append("|------|")
|
||||
for col in col_diffs['only_in_db1']:
|
||||
report.append(f"| {col} |")
|
||||
|
||||
# 仅在db2中的列
|
||||
if col_diffs['only_in_db2']:
|
||||
report.append(f"\n#### 3.1.2 仅在 {db2_name} 中存在的列")
|
||||
report.append("| 列名 |")
|
||||
report.append("|------|")
|
||||
for col in col_diffs['only_in_db2']:
|
||||
report.append(f"| {col} |")
|
||||
|
||||
# 列类型差异
|
||||
if col_diffs['type_diffs']:
|
||||
report.append(f"\n#### 3.1.3 列类型差异")
|
||||
report.append(f"| 列名 | {db1_name} | {db2_name} |")
|
||||
report.append("|------|------------|------------|")
|
||||
for col_name, type1, type2 in col_diffs['type_diffs']:
|
||||
report.append(f"| {col_name} | {type1} | {type2} |")
|
||||
|
||||
# 列约束差异
|
||||
if col_diffs['constraint_diffs']:
|
||||
report.append(f"\n#### 3.1.4 列约束差异")
|
||||
report.append(f"| 列名 | {db1_name} | {db2_name} |")
|
||||
report.append("|------|------------|------------|")
|
||||
for col_name, constraint1, constraint2 in col_diffs['constraint_diffs']:
|
||||
report.append(f"| {col_name} | {constraint1} | {constraint2} |")
|
||||
|
||||
# 主键差异
|
||||
if diffs['primary_key_diffs']:
|
||||
report.append(f"\n## 4. 主键差异的表 ({len(diffs['primary_key_diffs'])} 个)")
|
||||
report.append(f"| 表名 | {db1_name} | {db2_name} |")
|
||||
report.append("|------|------------|------------|")
|
||||
for table, (pk1, pk2) in diffs['primary_key_diffs'].items():
|
||||
report.append(f"| {table} | {pk1} | {pk2} |")
|
||||
|
||||
# 索引差异
|
||||
if diffs['index_diffs']:
|
||||
report.append(f"\n## 5. 索引差异的表 ({len(diffs['index_diffs'])} 个)")
|
||||
|
||||
for table, idx_diffs in diffs['index_diffs'].items():
|
||||
report.append(f"\n### 5.1 表: {table}")
|
||||
|
||||
# 仅在db1中的索引
|
||||
if idx_diffs['only_in_db1']:
|
||||
report.append(f"\n#### 5.1.1 仅在 {db1_name} 中存在的索引")
|
||||
report.append("| 索引名 | 索引列 |")
|
||||
report.append("|--------|--------|")
|
||||
for idx_name, idx_cols in idx_diffs['only_in_db1']:
|
||||
report.append(f"| {idx_name} | {idx_cols} |")
|
||||
|
||||
# 仅在db2中的索引
|
||||
if idx_diffs['only_in_db2']:
|
||||
report.append(f"\n#### 5.1.2 仅在 {db2_name} 中存在的索引")
|
||||
report.append("| 索引名 | 索引列 |")
|
||||
report.append("|--------|--------|")
|
||||
for idx_name, idx_cols in idx_diffs['only_in_db2']:
|
||||
report.append(f"| {idx_name} | {idx_cols} |")
|
||||
|
||||
# 索引定义差异
|
||||
if idx_diffs['definition_diffs']:
|
||||
report.append(f"\n#### 5.1.3 索引定义差异")
|
||||
report.append(f"| 索引名 | {db1_name} | {db2_name} |")
|
||||
report.append("|--------|------------|------------|")
|
||||
for idx_name, idx1, idx2 in idx_diffs['definition_diffs']:
|
||||
report.append(f"| {idx_name} | {idx1} | {idx2} |")
|
||||
|
||||
# 唯一约束差异
|
||||
if diffs['unique_constraint_diffs']:
|
||||
report.append(f"\n## 6. 唯一约束差异的表 ({len(diffs['unique_constraint_diffs'])} 个)")
|
||||
|
||||
for table, uc_diffs in diffs['unique_constraint_diffs'].items():
|
||||
report.append(f"\n### 6.1 表: {table}")
|
||||
|
||||
# 仅在db1中的唯一约束
|
||||
if uc_diffs['only_in_db1']:
|
||||
report.append(f"\n#### 6.1.1 仅在 {db1_name} 中存在的唯一约束")
|
||||
report.append("| 约束名 | 约束列 |")
|
||||
report.append("|--------|--------|")
|
||||
for uc_name, uc_cols in uc_diffs['only_in_db1']:
|
||||
report.append(f"| {uc_name} | {uc_cols} |")
|
||||
|
||||
# 仅在db2中的唯一约束
|
||||
if uc_diffs['only_in_db2']:
|
||||
report.append(f"\n#### 6.1.2 仅在 {db2_name} 中存在的唯一约束")
|
||||
report.append("| 约束名 | 约束列 |")
|
||||
report.append("|--------|--------|")
|
||||
for uc_name, uc_cols in uc_diffs['only_in_db2']:
|
||||
report.append(f"| {uc_name} | {uc_cols} |")
|
||||
|
||||
# 唯一约束定义差异
|
||||
if uc_diffs['definition_diffs']:
|
||||
report.append(f"\n#### 6.1.3 唯一约束定义差异")
|
||||
report.append(f"| 约束名 | {db1_name} | {db2_name} |")
|
||||
report.append("|--------|------------|------------|")
|
||||
for uc_name, uc1, uc2 in uc_diffs['definition_diffs']:
|
||||
report.append(f"| {uc_name} | {uc1} | {uc2} |")
|
||||
|
||||
report.append("\n## 7. 总结")
|
||||
report.append("差异比较完成!")
|
||||
|
||||
return '\n'.join(report)
|
||||
|
||||
# 主函数
|
||||
def main():
|
||||
# 文件路径
|
||||
db1_path = r'D:\projects\shop-projects\backend\docs\db\niushop_database.sql'
|
||||
db2_path = r'D:\projects\shop-projects\backend\docs\db\init_v2.0_with_data.sql'
|
||||
report_path = r'D:\projects\shop-projects\backend\docs\db\database_diff_report.md'
|
||||
|
||||
# 解析数据库结构
|
||||
print(f"正在解析 {db1_path}...")
|
||||
db1 = parse_sql_file(db1_path)
|
||||
db1_table_count = len(db1)
|
||||
print(f"解析完成,共 {db1_table_count} 个表")
|
||||
|
||||
print(f"\n正在解析 {db2_path}...")
|
||||
db2 = parse_sql_file(db2_path, ignore_prefix='lucky_')
|
||||
db2_table_count = len(db2)
|
||||
print(f"解析完成,共 {db2_table_count} 个表")
|
||||
|
||||
# 比较差异
|
||||
print("\n正在比较数据库差异...")
|
||||
diffs = compare_databases(db1, db2, 'niushop_database.sql', 'init_v2.0_with_data.sql')
|
||||
|
||||
# 生成Markdown差异报告
|
||||
print("\n正在生成Markdown差异报告...")
|
||||
report = generate_markdown_report(diffs, 'niushop_database.sql', 'init_v2.0_with_data.sql', db1_table_count, db2_table_count)
|
||||
|
||||
# 保存报告到文件
|
||||
with open(report_path, 'w', encoding='utf-8') as f:
|
||||
f.write(report)
|
||||
|
||||
print(f"\n差异报告已生成: {report_path}")
|
||||
print("差异比较完成!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
5278
docs/db/database_diff_report.md
Normal file
5278
docs/db/database_diff_report.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
11578
docs/db/niushop_database.sql
Normal file
11578
docs/db/niushop_database.sql
Normal file
File diff suppressed because it is too large
Load Diff
117
docs/db/update_sql_comments.py
Normal file
117
docs/db/update_sql_comments.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import re
|
||||
import os
|
||||
|
||||
# 读取 SQL 文件内容
|
||||
def read_sql_file(file_path):
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
|
||||
# 解析 database.sql,提取表结构和注释
|
||||
def parse_database_sql(sql_content):
|
||||
tables = {}
|
||||
|
||||
# 匹配 CREATE TABLE 语句,更灵活的格式
|
||||
# 匹配所有 CREATE TABLE 语句,不依赖于 ENGINE、CHARACTER SET 等子句的顺序
|
||||
table_pattern = re.compile(r"CREATE TABLE\s+`?([^`\s]+)`?\s*\(([^;]+)\)\s*(?:[^;]+COMMENT\s*=\s*'([^']+)'[^;]*)?\s*;", re.DOTALL | re.IGNORECASE)
|
||||
|
||||
matches = table_pattern.findall(sql_content)
|
||||
|
||||
for table_name, table_def, table_comment in matches:
|
||||
if not table_comment:
|
||||
# 如果没有匹配到表注释,尝试从其他位置获取
|
||||
comment_match = re.search(r"COMMENT\s*=\s*'([^']+)'", table_def, re.IGNORECASE)
|
||||
if comment_match:
|
||||
table_comment = comment_match.group(1)
|
||||
else:
|
||||
table_comment = ''
|
||||
|
||||
# 解析列定义和注释
|
||||
columns = {}
|
||||
# 匹配列定义,包括 COMMENT
|
||||
column_lines = table_def.split('\n')
|
||||
for line in column_lines:
|
||||
# 匹配列名、类型和注释
|
||||
column_match = re.search(r"\s*([^\s,]+)\s+([^\s,]+)\s*(?:[^,]+COMMENT\s*=\s*'([^']+)'[^,]*|[^,]*)", line)
|
||||
if column_match:
|
||||
column_name = column_match.group(1)
|
||||
column_comment = column_match.group(3) or ''
|
||||
if column_comment:
|
||||
columns[column_name] = column_comment
|
||||
|
||||
tables[table_name] = {
|
||||
'comment': table_comment,
|
||||
'columns': columns
|
||||
}
|
||||
|
||||
return tables
|
||||
|
||||
# 更新 init_v2.0.sql 文件中的注释
|
||||
def update_init_sql(init_sql_path, database_tables):
|
||||
# 读取 init_v2.0.sql 内容
|
||||
init_content = read_sql_file(init_sql_path)
|
||||
|
||||
# 匹配 CREATE TABLE 语句,适应 init_v2.0.sql 的格式
|
||||
table_pattern = re.compile(r"(create table if not exists lucky_([^\s]+)\s*\(([^;]+)\)\s*comment\s*=\s*'[^']*'\s*(.*?);)", re.DOTALL | re.IGNORECASE)
|
||||
|
||||
def replace_table(match):
|
||||
full_match = match.group(0)
|
||||
table_name = match.group(2)
|
||||
table_def = match.group(3)
|
||||
table_suffix = match.group(4)
|
||||
|
||||
if table_name in database_tables:
|
||||
# 获取数据库表的注释和列注释
|
||||
db_table = database_tables[table_name]
|
||||
table_comment = db_table['comment']
|
||||
columns = db_table['columns']
|
||||
|
||||
# 更新列注释
|
||||
new_table_def = table_def
|
||||
for column_name, column_comment in columns.items():
|
||||
# 匹配列定义,替换注释
|
||||
# 格式:列名 类型 default 默认值 not null comment '注释'
|
||||
column_pattern = re.compile(r"(\s*" + column_name + r"\s+[^\s,]+\s*(?:default\s+[^\s,]+\s*)?(?:not null\s*)?comment\s*=\s*')([^']*)'([^,]*)", re.IGNORECASE)
|
||||
new_table_def = column_pattern.sub(r"\1" + column_comment + r"'\3", new_table_def)
|
||||
|
||||
# 重新构建 CREATE TABLE 语句
|
||||
new_full_match = f"create table if not exists lucky_{table_name} ({new_table_def}) comment = '{table_comment}' {table_suffix};"
|
||||
return new_full_match
|
||||
|
||||
return full_match
|
||||
|
||||
# 替换所有表
|
||||
updated_content = table_pattern.sub(replace_table, init_content)
|
||||
|
||||
# 写回文件
|
||||
with open(init_sql_path, 'w', encoding='utf-8') as f:
|
||||
f.write(updated_content)
|
||||
|
||||
print(f"Updated {init_sql_path}")
|
||||
|
||||
# 主函数
|
||||
def main():
|
||||
# 文件路径
|
||||
database_sql_path = r'./niushop_database.sql'
|
||||
init_v20_sql_path = r'./init_v2.0.sql'
|
||||
init_v20_with_data_sql_path = r'./init_v2.0_with_data.sql'
|
||||
|
||||
# 解析 database.sql
|
||||
print("Parsing database.sql...")
|
||||
database_content = read_sql_file(database_sql_path)
|
||||
database_tables = parse_database_sql(database_content)
|
||||
print(f"Found {len(database_tables)} tables in database.sql")
|
||||
|
||||
# 更新 init_v2.0.sql
|
||||
if os.path.exists(init_v20_sql_path):
|
||||
print("Updating init_v2.0.sql...")
|
||||
update_init_sql(init_v20_sql_path, database_tables)
|
||||
|
||||
# 更新 init_v2.0_with_data.sql
|
||||
if os.path.exists(init_v20_with_data_sql_path):
|
||||
print("Updating init_v2.0_with_data.sql...")
|
||||
update_init_sql(init_v20_with_data_sql_path, database_tables)
|
||||
|
||||
print("All files updated successfully!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
27
docs/diy/RADEME.md
Normal file
27
docs/diy/RADEME.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 自定义页面
|
||||
|
||||
|
||||
## 对应的组件存放到数据表中
|
||||
|
||||
表: lucky_diy_view_util
|
||||
|
||||
```sql
|
||||
create table if not exists lucky_diy_view_util
|
||||
(
|
||||
id int auto_increment
|
||||
primary key,
|
||||
name varchar(50) default '' not null comment '标识',
|
||||
title varchar(50) default '' not null comment '组件名称',
|
||||
type varchar(50) default 'SYSTEM' not null comment '组件类型',
|
||||
value text null comment '配置:json格式',
|
||||
addon_name varchar(50) default '' not null comment '插件标识',
|
||||
sort int default 0 not null comment '排序号',
|
||||
support_diy_view varchar(500) default '' not null comment '支持的自定义页面(为空表示公共组件都支持)',
|
||||
max_count int default 0 not null comment '限制添加次数',
|
||||
is_delete int default 0 not null comment '是否可以删除,0 允许,1 禁用',
|
||||
icon varchar(255) default '' not null comment '组件图标',
|
||||
icon_type int default 0 not null comment '0图片1图标',
|
||||
constraint name
|
||||
unique (name)
|
||||
)
|
||||
```
|
||||
170
replace_comments.py
Normal file
170
replace_comments.py
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import argparse
|
||||
|
||||
# 定义要查找和替换的注释内容
|
||||
# 使用原始字符串数组,支持多个旧内容,避免Unicode转义问题
|
||||
OLD_COMMENTS = [
|
||||
r"""
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
""".strip(),
|
||||
r"""
|
||||
<?php
|
||||
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
""".strip(),
|
||||
# 格式1:标准注释格式
|
||||
r"""
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
""".strip(),
|
||||
r"""
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Niushop商城系统 - 团队十年电商经验汇集巨献!
|
||||
* =========================================================
|
||||
* Copy right 2019-2029 杭州牛之云科技有限公司, 保留所有权利。
|
||||
* ----------------------------------------------
|
||||
* 官方网址: https://www.niushop.com
|
||||
* =========================================================
|
||||
*/
|
||||
""".strip(),
|
||||
# 格式1:标准注释格式
|
||||
r"""
|
||||
<?php
|
||||
/**
|
||||
* Niushop商城系统 - 团队十年电商经验汇集巨献!
|
||||
* =========================================================
|
||||
* Copy right 2019-2029 杭州牛之云科技有限公司, 保留所有权利。
|
||||
* ----------------------------------------------
|
||||
* 官方网址: https://www.niushop.com
|
||||
* =========================================================
|
||||
*/""".strip(),
|
||||
# 格式2:带有额外空行的注释格式
|
||||
r"""
|
||||
<?php
|
||||
/**
|
||||
* Niushop商城系统 - 团队十年电商经验汇集巨献!
|
||||
* =========================================================
|
||||
* Copy right 2019-2029 杭州牛之云科技有限公司, 保留所有权利。
|
||||
* ----------------------------------------------
|
||||
* 官方网址: https://www.niushop.com
|
||||
|
||||
* =========================================================
|
||||
*/""".strip(),
|
||||
]
|
||||
|
||||
NEW_COMMENT = r"""
|
||||
<?php
|
||||
""".strip()
|
||||
|
||||
# 定义要处理的文件类型
|
||||
FILE_TYPES = [".php", ".js", ".css", ".html", ".vue", ".ts", ".tsx", ".jsx", ".scss", ".less"]
|
||||
|
||||
def replace_comments(file_path):
|
||||
"""
|
||||
替换文件中的注释
|
||||
"""
|
||||
try:
|
||||
# 读取文件内容
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 检查文件是否包含任何旧注释
|
||||
has_old_comment = False
|
||||
for old_comment in OLD_COMMENTS:
|
||||
if old_comment in content:
|
||||
has_old_comment = True
|
||||
break
|
||||
|
||||
if has_old_comment:
|
||||
# 替换所有旧注释
|
||||
new_content = content
|
||||
for old_comment in OLD_COMMENTS:
|
||||
if old_comment in new_content:
|
||||
new_content = new_content.replace(old_comment, NEW_COMMENT)
|
||||
|
||||
# 写回文件
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
|
||||
print(f"✓ 已处理: {file_path}")
|
||||
return True
|
||||
return False
|
||||
|
||||
except UnicodeDecodeError:
|
||||
# 跳过二进制文件
|
||||
print(f"✗ 跳过二进制文件: {file_path}")
|
||||
return False
|
||||
except PermissionError:
|
||||
# 跳过没有权限的文件
|
||||
print(f"✗ 权限不足: {file_path}")
|
||||
return False
|
||||
except Exception as e:
|
||||
# 处理其他异常
|
||||
print(f"✗ 处理失败 {file_path}: {str(e)}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""
|
||||
主函数
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="替换Niushop商城系统的注释")
|
||||
parser.add_argument("--path", type=str, default=".", help="要遍历的目录路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
root_path = args.path
|
||||
total_files = 0
|
||||
processed_files = 0
|
||||
|
||||
print(f"开始遍历目录: {root_path}")
|
||||
print(f"将处理的文件类型: {', '.join(FILE_TYPES)}")
|
||||
print("=" * 60)
|
||||
|
||||
# 遍历所有文件
|
||||
for root, dirs, files in os.walk(root_path):
|
||||
# 跳过某些目录(如.git、vendor、node_modules等)
|
||||
dirs[:] = [d for d in dirs if d not in ['.git', 'vendor', 'node_modules', 'runtime', 'upload', 'public', 'static']]
|
||||
|
||||
for file in files:
|
||||
# 检查文件类型
|
||||
if any(file.endswith(ext) for ext in FILE_TYPES):
|
||||
total_files += 1
|
||||
file_path = os.path.join(root, file)
|
||||
if replace_comments(file_path):
|
||||
processed_files += 1
|
||||
|
||||
print("=" * 60)
|
||||
print(f"处理完成!")
|
||||
print(f"总文件数: {total_files}")
|
||||
print(f"已处理文件数: {processed_files}")
|
||||
print(f"替换率: {processed_files / total_files * 100:.2f}%" if total_files > 0 else "未找到匹配的文件")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
22
scripts/generate_key_pair/main.js
Normal file
22
scripts/generate_key_pair/main.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const crypto = require('crypto');
|
||||
// 生成密钥对
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 3072, // 密钥长度,不少于3072
|
||||
publicKeyEncoding: {
|
||||
type: 'spki', // 公钥编码格式
|
||||
format: 'pem' // 公钥输出格式
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8', // 私钥编码格式
|
||||
format: 'pem' // 私钥输出格式
|
||||
}
|
||||
});
|
||||
console.info('生成的公钥:');
|
||||
console.info(publicKey);
|
||||
console.info('生成的私钥:');
|
||||
console.info(privateKey);
|
||||
|
||||
// 保存密钥对到文件
|
||||
const fs = require('fs');
|
||||
fs.writeFileSync('merchant_public_key.pem', publicKey);
|
||||
fs.writeFileSync('merchant_private_key.pem', privateKey);
|
||||
@@ -1,25 +1,22 @@
|
||||
APP_DEBUG = true
|
||||
APP_TRACE = true
|
||||
|
||||
[APP]
|
||||
DEFAULT_TIMEZONE = Asia/Shanghai
|
||||
ENV_MODE = development
|
||||
|
||||
[LANG]
|
||||
default_lang = zh-cn
|
||||
|
||||
[DATABASE]
|
||||
TYPE = mysql
|
||||
HOSTNAME = 127.0.0.1
|
||||
DATABASE = shop_mallnew_dev
|
||||
USERNAME = root
|
||||
PASSWORD = root
|
||||
HOSTNAME = db
|
||||
DATABASE = shop_mallnew
|
||||
USERNAME = shop_mallnew
|
||||
PASSWORD = shop_mallnew
|
||||
HOSTPORT = 3306
|
||||
CHARSET = utf8
|
||||
DEBUG = true
|
||||
|
||||
[RRDATABASE]
|
||||
HOSTNAME = host.docker.internal
|
||||
[redis]
|
||||
HOST = 127.0.0.1
|
||||
HOST = redis
|
||||
PORT = 6379
|
||||
PASSWORD = ''
|
||||
PASSWORD = 'luckyshop123!@#'
|
||||
EXPIRY = 604800
|
||||
@@ -6,7 +6,7 @@ DEFAULT_TIMEZONE = Asia/Shanghai
|
||||
default_lang = zh-cn
|
||||
[DATABASE]
|
||||
TYPE = mysql
|
||||
HOSTNAME = newshop_mysql
|
||||
HOSTNAME = db
|
||||
DATABASE = shop_dev
|
||||
USERNAME = shop_mallnew
|
||||
PASSWORD = shop_mallnew
|
||||
@@ -14,9 +14,9 @@ HOSTPORT = 3306
|
||||
CHARSET = utf8
|
||||
DEBUG = true
|
||||
[RRDATABASE]
|
||||
HOSTNAME = 192.168.2.64
|
||||
HOSTNAME = redis
|
||||
[redis]
|
||||
HOST = newshop_redis
|
||||
HOST = redis
|
||||
PORT = 6379
|
||||
PASSWORD = 'luckyshop123!@#'
|
||||
EXPIRY = 604800
|
||||
@@ -1 +0,0 @@
|
||||
HUIqMBxOaiPqppGTd9WxmjJwFwX3x-hpbnI1xujpUW4.m7oE98dVL-9XQE2gAJ9VpnsbTfOJ2csnVDIzFqe9osw
|
||||
@@ -1 +0,0 @@
|
||||
39aLknx0mLrzjinZRvjc5pYAXUkRq0eo
|
||||
201
src/LICENSE
Normal file
201
src/LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2020 上海牛之云网络科技有限公司
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
144
src/README.md
Normal file
144
src/README.md
Normal file
@@ -0,0 +1,144 @@
|
||||

|
||||
|
||||
### 产品介绍
|
||||
|
||||
### Niushop开源商城单商户V4
|
||||
**快速搭建专属店铺,迅速展开线上业务** <br/>
|
||||
NIUSHOP开源商城B2C单商户V4,功能强大,安全便捷,框架成熟稳定便于扩展,源码100%开源,支持二次开发定制,让企业用更低的成本、更少的人力,更快的速度构建自己的商城,开启网上商城销售业务。
|
||||
|
||||
### 公司介绍
|
||||
|
||||
上海牛之云网络科技有限公司成立于2016年,是一家从事移动互联网,电商软件为主导的技术研发型企业。公司总部位于上海,研发中心设立于锦绣龙城太原市,目前有团队成员50多人,产品研发实力雄厚。拥有NIUSHOP开源商城,NIUCLOUD开源小程序应用市场,牛客云商企业级SAAS电商服务平台等产品。业务遍及全国26个省市,在北京、广州、上海、深圳等地区拥有多家合作企业。我们始终保持专业、专注、专一的原则,旨在为用户提供最好用的全功能型电商软件产品,是您转型新零售,新媒体,掘金千亿电商市场的首选。
|
||||
|
||||
### 操作指南
|
||||
|
||||
[Niushop开源商城单商户V4使用手册](https://www.kancloud.cn/niucloud/niushop_b2c_v4/1842076)
|
||||
| [api文档地址](https://www.kancloud.cn/niucloud/niushop_b2c_v4_api/1830441)
|
||||
| [二开手册](https://www.kancloud.cn/niucloud/niushop_b2c_v4_develop/1830902)
|
||||
| [论坛地址](https://bbs.niushop.com.cn/forum.php)
|
||||
| [官网地址](https://www.niushop.com)
|
||||
- - -
|
||||
|
||||
|
||||
### 推荐阿里云服务器配置
|
||||
|
||||
阿里云2000元代金劵:<a href="https://www.aliyun.com/minisite/goods?userCode=hvxtm3ee">马上领取</a>
|
||||
|
||||

|
||||
|
||||
### 商城特色:
|
||||
强大的营销功能模块,丰富的行业模板和装修组件,快速搭建最适合自己的电商平台,轻松获客、裂变。开启电商运营之路。
|
||||
|
||||
1. <img src="https://images.gitee.com/uploads/images/2020/0724/121556_a96bd648_6569472.png"/> ThinkPhp6 + LayUi + ElementUi,学习维护成本低<br/>
|
||||
2. <img src="https://images.gitee.com/uploads/images/2020/0724/121615_f801f981_6569472.png"/> 前端由UNI-APP框架编写,支持多端,易于维护<br/>
|
||||
3. <img src="https://images.gitee.com/uploads/images/2020/0724/121635_e51987c4_6569472.png"/> 钩子 + 插件,组件化开发,可复用,开发便捷<br/>
|
||||
4. <img src="https://images.gitee.com/uploads/images/2020/0724/121645_df103f55_6569472.png"/> 标准API接口,前后端分离,二次开发更方便<br/>
|
||||
5. <img src="https://images.gitee.com/uploads/images/2020/0724/121708_74c55984_6569472.png"/> 代码全部开源,方便企业扩展自身业务需求
|
||||
|
||||
### Niushop官方群
|
||||
Niushop商城qq开发群1:<a href="https://jq.qq.com/?_wv=1027&k=VrVzi1FI" target="_blank"></a>| qq开发群2:<a href="https://jq.qq.com/?_wv=1027&k=MCtjz6B9" target="_blank"> </a>| qq开发群3:<a href="https://jq.qq.com/?_wv=1027&k=H9FLIfTP" target="_blank"></a>
|
||||
|
||||
### 体验演示二维码
|
||||

|
||||
|
||||
#### :fire: 体验演示站后台:[<a href='https://uniapp.v4.niuteam.cn/' target="_blank"> 查看 </a>]
|
||||
<a href='https://uniapp.v4.niuteam.cn/' target="_blank">https://uniapp.v4.niuteam.cn/</a> 账号:test 密码:123456
|
||||
|
||||
### 开源版使用须知
|
||||
|
||||
1.仅允许用于个人学习研究使用;
|
||||
|
||||
2.开源版不建议商用,如果商用必须保留版权信息,望自觉遵守;
|
||||
|
||||
3.禁止将本开源的代码和资源进行任何形式任何名义的出售,否则产生的一切任何后果责任由侵权者自负。
|
||||
|
||||
4.推荐3名会员关注公众号,将免费获得单商户V4基础版授权。
|
||||
|
||||
5.绑定码云评论并点赞支持可获得单商户V4基础版授权
|
||||
|
||||
6.本基础版本后台源码全部开源,小程序为uniapp编译版,如需小程序源码,请点击下方图片马上获取。
|
||||
|
||||
<a href='https://www.niushop.com/web/index/promotion' target="_blank"><img src="https://images.gitee.com/uploads/images/2020/0805/103536_c7d1b001_6569472.png"/></a>
|
||||
|
||||
### 技术亮点
|
||||
|
||||
1.框架采用全新thinkphp6+事件开发设计+layui+uniapp进行设计,代码完全重构,采用支持百万级!
|
||||
|
||||
2.前端以layui + uniapp模块化开发;
|
||||
|
||||
3.数据导出采用phpExcel,使数据更加直观,更方便于管理统计;
|
||||
|
||||
4.插件钩子机制,功能模块独立,更有助于二次开发;
|
||||
|
||||
5.后台采用ECharts,直观体现关系数据可视化的图,支持图与图之间的混搭。实现完善的数据统计和分析;
|
||||
|
||||
6.EasyWeChat部署微信开发,微信接入更加快捷,简单;
|
||||
|
||||
7.内置强大灵活的权限管理体系,有利于专人专项运营;
|
||||
|
||||
8.内置组合数据统计,系统配置,管理碎片化数据统计;
|
||||
|
||||
9.客户端完善的交互效果和动画,提升用户端视觉体验;
|
||||
|
||||
10.可以完美对接公众号和小程序,并且数据同步,实现真正意义上的一端开发,多端使用;
|
||||
|
||||
11.内置客服系统,可以对接微信客服,客服在线实时聊天;
|
||||
|
||||
12.高频数据缓存,数据库读写分离,很大程度减轻服务器压力,提升访问速度;
|
||||
|
||||
13.后台设置菜单中可以一键数据备份和恢复,完全傻瓜式操作就可以轻松升级备份;
|
||||
|
||||
14.在线一键升级,轻松跨越到最新版本;
|
||||
|
||||
15.标准Api接口、前后端分离,二次开发更方便快捷;
|
||||
|
||||
16.支持数据库结构、数据、模板在线缓存清除,提升用户体验;
|
||||
|
||||
17.可视化DIY店铺装修,方便、快捷、直观,可以随心所欲装扮自己的店铺;
|
||||
|
||||
18.无缝事件机制行为扩展更方便,方便二次开发;
|
||||
|
||||
19.支持队列降低流量高峰,解除代码耦合性,高可用性;
|
||||
|
||||
20.在线一键安装部署,自动检测系统环境一键安装,省时省力快捷部署;
|
||||
|
||||
|
||||
### 系统功能
|
||||

|
||||
|
||||
### 页面展示
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
### 合作伙伴
|
||||
.png")
|
||||
|
||||
|
||||
### 版权信息
|
||||
|
||||
版权所有Copyright © 2015-2020 NiuShop开源商城 版权所有
|
||||
|
||||
All rights reserved。
|
||||
|
||||
上海牛之云网络科技有限公司 提供技术支持
|
||||
313
src/addon/aikefu/api/controller/Kefu.php
Normal file
313
src/addon/aikefu/api/controller/Kefu.php
Normal file
@@ -0,0 +1,313 @@
|
||||
<?php
|
||||
|
||||
namespace addon\aikefu\api\controller;
|
||||
|
||||
use addon\aikefu\model\Config as KefuConfigModel;
|
||||
use addon\aikefu\model\Conversation as KefuConversationModel;
|
||||
use addon\aikefu\model\Message as KefuMessageModel;
|
||||
use app\api\controller\BaseApi;
|
||||
|
||||
|
||||
class Kefu extends BaseApi
|
||||
{
|
||||
/**
|
||||
* 封装curl请求方法
|
||||
* @param string $url 请求URL
|
||||
* @param string $method 请求方法
|
||||
* @param array $data 请求数据
|
||||
* @param array $headers 请求头
|
||||
* @return string 响应内容
|
||||
*/
|
||||
private function curlRequest($url, $method = 'GET', $data = [], $headers = [])
|
||||
{
|
||||
$ch = curl_init();
|
||||
|
||||
// 设置URL
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
|
||||
// 设置请求方法
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
|
||||
// 设置POST数据
|
||||
if ($method === 'POST' && !empty($data)) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, is_array($data) ? json_encode($data) : $data);
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
if (!empty($headers)) {
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
}
|
||||
|
||||
// 设置返回值
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
|
||||
// 执行请求
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
// 关闭连接
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
throw new \Exception('Curl请求失败');
|
||||
}
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
throw new \Exception('HTTP请求失败,状态码:' . $httpCode);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
/**
|
||||
* 为事件调用初始化属性
|
||||
* @param array $data 事件数据
|
||||
*/
|
||||
public function initializeForEvent($data)
|
||||
{
|
||||
if (!empty($data['site_id'])) {
|
||||
$this->site_id = $data['site_id'] ?? 0;
|
||||
}
|
||||
|
||||
if (!empty($data['member_id'])) {
|
||||
$this->member_id = $data['member_id'] ?? 0;
|
||||
}
|
||||
|
||||
if (!empty($data['token'])) {
|
||||
$this->token = $data['token'] ?? '';
|
||||
}
|
||||
|
||||
$this->params = [
|
||||
'message' => $data['message'] ?? '',
|
||||
'user_id' => $data['user_id'] ?? '',
|
||||
'conversation_id' => $data['conversation_id'] ?? '',
|
||||
'stream' => $data['stream'] ?? false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能客服聊天接口
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function chat()
|
||||
{
|
||||
// 获取请求参数
|
||||
$message = $this->params['message'] ?? '';
|
||||
$user_id = $this->params['user_id'] ?? $this->member_id;
|
||||
$conversation_id = $this->params['conversation_id'] ?? '';
|
||||
$stream = $this->params['stream'] ?? false;
|
||||
|
||||
// 验证参数
|
||||
if (empty($message)) {
|
||||
return $this->response($this->error('请输入消息内容'));
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取智能客服配置
|
||||
$kefu_config_model = new KefuConfigModel();
|
||||
$config_info = $kefu_config_model->getConfig($this->site_id)['data']['value'] ?? [];
|
||||
|
||||
if (empty($config_info) || $config_info['status'] != 1) {
|
||||
return $this->response($this->error('智能客服暂未启用'));
|
||||
}
|
||||
|
||||
$config = $config_info;
|
||||
$apiKey = $config['api_key'];
|
||||
$baseUrl = $config['base_url'];
|
||||
$chatEndpoint = $config['chat_endpoint'];
|
||||
|
||||
// 构建请求数据
|
||||
$requestData = [
|
||||
'inputs' => [],
|
||||
'query' => $message,
|
||||
'response_mode' => $stream ? 'streaming' : 'blocking',
|
||||
'user' => $user_id,
|
||||
];
|
||||
|
||||
// 如果有会话ID,添加到请求中
|
||||
if (!empty($conversation_id)) {
|
||||
$requestData['conversation_id'] = $conversation_id;
|
||||
}
|
||||
|
||||
// 构建请求头
|
||||
$headers = [
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'Content-Type: application/json',
|
||||
];
|
||||
|
||||
// 发送请求到Dify API
|
||||
$url = $baseUrl . $chatEndpoint;
|
||||
$response = $this->curlRequest($url, 'POST', $requestData, $headers);
|
||||
|
||||
// 解析响应
|
||||
$result = json_decode($response, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return $this->response($this->error('解析响应失败'));
|
||||
}
|
||||
|
||||
// 保存消息记录
|
||||
$kefu_message_model = new KefuMessageModel();
|
||||
$kefu_conversation_model = new KefuConversationModel();
|
||||
|
||||
// 保存用户消息
|
||||
$kefu_message_model->addMessage([
|
||||
'site_id' => $this->site_id,
|
||||
'user_id' => $user_id,
|
||||
'conversation_id' => $result['conversation_id'] ?? $conversation_id,
|
||||
'message_id' => $result['message_id'] ?? '',
|
||||
'role' => 'user',
|
||||
'content' => json_encode($message),
|
||||
]);
|
||||
|
||||
// 保存机器人回复
|
||||
$kefu_message_model->addMessage([
|
||||
'site_id' => $this->site_id,
|
||||
'user_id' => $user_id,
|
||||
'conversation_id' => $result['conversation_id'] ?? $conversation_id,
|
||||
'message_id' => $result['id'] ?? '',
|
||||
'role' => 'assistant',
|
||||
'content' => json_encode($result['answer'] ?? ''),
|
||||
]);
|
||||
|
||||
// 更新会话状态或创建新会话
|
||||
$conversation_info = $kefu_conversation_model->getConversationInfo([
|
||||
['site_id', '=', $this->site_id],
|
||||
['conversation_id', '=', $result['conversation_id'] ?? $conversation_id],
|
||||
]);
|
||||
|
||||
if (empty($conversation_info['data'])) {
|
||||
// 创建新会话
|
||||
$kefu_conversation_model->addConversation([
|
||||
'site_id' => $this->site_id,
|
||||
'user_id' => $user_id,
|
||||
'conversation_id' => $result['conversation_id'] ?? '',
|
||||
'name' => '智能客服会话',
|
||||
]);
|
||||
} else {
|
||||
// 更新会话状态
|
||||
$kefu_conversation_model->updateConversation([
|
||||
'status' => 1,
|
||||
], [
|
||||
['id', '=', $conversation_info['data']['id']],
|
||||
]);
|
||||
}
|
||||
|
||||
// 返回成功响应
|
||||
return $this->response($this->success([
|
||||
'conversation_id' => $result['conversation_id'] ?? '',
|
||||
'reply' => $result['answer'] ?? '',
|
||||
'message_id' => $result['message_id'] ?? '',
|
||||
'finish_reason' => $result['finish_reason'] ?? '',
|
||||
'usage' => $result['usage'] ?? [],
|
||||
]));
|
||||
} catch (\Exception $e) {
|
||||
return $this->response($this->error('请求失败:' . $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话历史
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function getHistory()
|
||||
{
|
||||
// 获取请求参数
|
||||
$conversation_id = $this->params['conversation_id'] ?? '';
|
||||
$user_id = $this->params['user_id'] ?? $this->member_id;
|
||||
$limit = $this->params['limit'] ?? 20;
|
||||
$offset = $this->params['offset'] ?? 0;
|
||||
|
||||
// 验证参数
|
||||
if (empty($conversation_id)) {
|
||||
return $this->response($this->error('会话ID不能为空'));
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取会话历史记录
|
||||
$kefu_message_model = new KefuMessageModel();
|
||||
$message_list = $kefu_message_model->getMessageList([
|
||||
['site_id', '=', $this->site_id],
|
||||
['user_id', '=', $user_id],
|
||||
['conversation_id', '=', $conversation_id],
|
||||
], 'id, role, content, create_time', 'create_time asc', $limit, $offset);
|
||||
|
||||
// 返回成功响应
|
||||
return $this->response($this->success([
|
||||
'messages' => $message_list['data'] ?? [],
|
||||
'total' => $message_list['total'] ?? 0,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
]));
|
||||
} catch (\Exception $e) {
|
||||
return $this->response($this->error('请求失败:' . $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新会话
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function createConversation()
|
||||
{
|
||||
// 获取请求参数
|
||||
$user_id = $this->params['user_id'] ?? $this->member_id;
|
||||
|
||||
try {
|
||||
// 获取智能客服配置
|
||||
$kefu_config_model = new KefuConfigModel();
|
||||
$config_info = $kefu_config_model->getConfig($this->site_id)['data']['value'] ?? [];
|
||||
|
||||
if (empty($config_info) || $config_info['status'] != 1) {
|
||||
return $this->response($this->error('智能客服暂未启用'));
|
||||
}
|
||||
|
||||
$config = $config_info;
|
||||
$apiKey = $config['api_key'];
|
||||
$baseUrl = $config['base_url'];
|
||||
|
||||
// 构建请求数据
|
||||
$requestData = [
|
||||
'name' => '智能客服会话',
|
||||
'user' => $user_id,
|
||||
];
|
||||
|
||||
// 构建请求头
|
||||
$headers = [
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'Content-Type: application/json',
|
||||
];
|
||||
|
||||
// 发送请求到Dify API
|
||||
$url = $baseUrl . '/conversations';
|
||||
$response = $this->curlRequest($url, 'POST', $requestData, $headers);
|
||||
|
||||
// 解析响应
|
||||
$result = json_decode($response, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return $this->response($this->error('解析响应失败'));
|
||||
}
|
||||
|
||||
// 保存会话记录
|
||||
$kefu_conversation_model = new KefuConversationModel();
|
||||
$kefu_conversation_model->addConversation([
|
||||
'site_id' => $this->site_id,
|
||||
'user_id' => $user_id,
|
||||
'conversation_id' => $result['id'] ?? '',
|
||||
'name' => $result['name'] ?? '智能客服会话',
|
||||
]);
|
||||
|
||||
// 返回成功响应
|
||||
return $this->response($this->success([
|
||||
'conversation_id' => $result['id'] ?? '',
|
||||
'name' => $result['name'] ?? '',
|
||||
'created_at' => $result['created_at'] ?? '',
|
||||
]));
|
||||
} catch (\Exception $e) {
|
||||
return $this->response($this->error('请求失败:' . $e->getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/addon/aikefu/config/event.php
Normal file
36
src/addon/aikefu/config/event.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/**
|
||||
* 智能客服扩展事件配置
|
||||
*/
|
||||
return [
|
||||
'bind' => [
|
||||
|
||||
],
|
||||
|
||||
'listen' => [
|
||||
'KefuChat' => [
|
||||
'addon\aikefu\event\KefuChat'
|
||||
],
|
||||
'KefuCreateConversation' => [
|
||||
'addon\aikefu\event\KefuCreateConversation'
|
||||
],
|
||||
'KefuGetHistory' => [
|
||||
'addon\aikefu\event\KefuGetHistory'
|
||||
],
|
||||
'KefuClearConversation' => [
|
||||
'addon\aikefu\event\KefuClearConversation'
|
||||
],
|
||||
'KefuHealthCheck' => [
|
||||
'addon\aikefu\event\KefuHealthCheck'
|
||||
],
|
||||
'KefuChatStream' => [
|
||||
'addon\aikefu\event\KefuChatStream'
|
||||
],
|
||||
'KefuGetInfo' => [
|
||||
'addon\aikefu\event\KefuGetInfo'
|
||||
],
|
||||
],
|
||||
|
||||
'subscribe' => [
|
||||
],
|
||||
];
|
||||
13
src/addon/aikefu/config/info.php
Normal file
13
src/addon/aikefu/config/info.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'aikefu',
|
||||
'title' => '智能客服',
|
||||
'description' => '基于Dify的智能客服系统',
|
||||
'type' => 'system', //插件类型 system :系统插件(自动安装), business:业务插件 promotion:扩展营销插件 tool:工具插件
|
||||
'status' => 1,
|
||||
'author' => '',
|
||||
'version' => '1.0.0',
|
||||
'version_no' => '525231212001',
|
||||
'content' => '',
|
||||
];
|
||||
42
src/addon/aikefu/data/install.sql
Normal file
42
src/addon/aikefu/data/install.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- 智能客服插件安装脚本
|
||||
-- 1. 智能客服插件使用系统配置表存储配置信息,无需创建独立数据表
|
||||
-- 2. 会话和消息数据存储在独立数据表中
|
||||
|
||||
-- 创建智能客服会话表
|
||||
CREATE TABLE IF NOT EXISTS `lucky_aikefu_conversation` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`site_id` int(11) NOT NULL COMMENT '站点ID',
|
||||
`user_id` varchar(50) NOT NULL COMMENT '用户ID',
|
||||
`conversation_id` varchar(100) NOT NULL COMMENT 'Dify会话ID',
|
||||
`name` varchar(255) NOT NULL COMMENT '会话名称',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:1活跃,0结束',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `site_id` (`site_id`),
|
||||
KEY `user_id` (`user_id`),
|
||||
KEY `conversation_id` (`conversation_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4_unicode_ci COMMENT='智能客服会话表';
|
||||
|
||||
-- 创建智能客服消息表
|
||||
CREATE TABLE IF NOT EXISTS `lucky_aikefu_message` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`site_id` int(11) NOT NULL COMMENT '站点ID',
|
||||
`user_id` varchar(50) NOT NULL COMMENT '用户ID',
|
||||
`conversation_id` varchar(100) NOT NULL COMMENT '会话ID',
|
||||
`message_id` varchar(100) NOT NULL COMMENT '消息ID',
|
||||
`role` varchar(20) NOT NULL COMMENT '角色:user用户,assistant助手',
|
||||
`content` text NOT NULL COMMENT '消息内容',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `site_id` (`site_id`),
|
||||
KEY `user_id` (`user_id`),
|
||||
KEY `conversation_id` (`conversation_id`),
|
||||
KEY `message_id` (`message_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4_unicode_ci COMMENT='智能客服消息表';
|
||||
|
||||
-- 修改表字符集,utf8mb4_unicode_ci, 兼容emoji表情
|
||||
|
||||
ALTER TABLE lucky_aikefu_message MODIFY content TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE lucky_aikefu_conversation CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE lucky_aikefu_message CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
4
src/addon/aikefu/data/uninstall.sql
Normal file
4
src/addon/aikefu/data/uninstall.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- 智能客服插件卸载脚本
|
||||
-- 删除智能客服相关表(配置信息存储在系统配置表中,无需单独删除)
|
||||
DROP TABLE IF EXISTS `lucky_aikefu_message`;
|
||||
DROP TABLE IF EXISTS `lucky_aikefu_conversation`;
|
||||
50
src/addon/aikefu/event/Install.php
Normal file
50
src/addon/aikefu/event/Install.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace addon\aikefu\event;
|
||||
|
||||
use app\model\system\Addon as AddonModel;
|
||||
|
||||
/**
|
||||
* 智能客服插件安装
|
||||
*/
|
||||
class Install
|
||||
{
|
||||
public function handle()
|
||||
{
|
||||
$addon_model = new AddonModel();
|
||||
$info = $addon_model->getAddonInfo(['name' => 'aikefu']);
|
||||
|
||||
if (empty($info['data'])) {
|
||||
// 插件未安装,执行安装逻辑
|
||||
$addon_model->addAddon([
|
||||
'name' => 'aikefu',
|
||||
'title' => '智能客服',
|
||||
'description' => '基于Dify的智能客服系统',
|
||||
'author' => 'admin',
|
||||
'version' => '1.0.0',
|
||||
'scene' => 'web',
|
||||
'state' => 0,
|
||||
'category' => 'business',
|
||||
'need_install' => 1,
|
||||
'need_cache' => 1,
|
||||
'create_time' => time(),
|
||||
'update_time' => time()
|
||||
]);
|
||||
} else {
|
||||
// 插件已存在,更新插件信息
|
||||
$addon_model->updateAddon([
|
||||
'title' => '智能客服',
|
||||
'description' => '基于Dify的智能客服系统',
|
||||
'author' => 'admin',
|
||||
'version' => '1.0.0',
|
||||
'scene' => 'web',
|
||||
'category' => 'business',
|
||||
'need_install' => 1,
|
||||
'need_cache' => 1,
|
||||
'update_time' => time()
|
||||
], ['name' => 'aikefu']);
|
||||
}
|
||||
|
||||
return success(1);
|
||||
}
|
||||
}
|
||||
39
src/addon/aikefu/event/KefuChat.php
Normal file
39
src/addon/aikefu/event/KefuChat.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace addon\aikefu\event;
|
||||
|
||||
use addon\aikefu\api\controller\Kefu as KefuApi;
|
||||
|
||||
/**
|
||||
* 客服聊天
|
||||
*/
|
||||
class KefuChat
|
||||
{
|
||||
/**
|
||||
* 处理智能客服聊天事件
|
||||
* @param array $data 事件数据
|
||||
* @return array
|
||||
*/
|
||||
public function handle($data)
|
||||
{
|
||||
try {
|
||||
// 创建addon的KefuApi实例
|
||||
$kefu_api = new KefuApi();
|
||||
|
||||
// 调用初始化方法设置属性
|
||||
$kefu_api->initializeForEvent($data);
|
||||
|
||||
// 调用addon的chat方法
|
||||
$response = $kefu_api->chat();
|
||||
|
||||
// 返回响应数据
|
||||
return json_decode($response->getContent(), true);
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'code' => -1,
|
||||
'message' => '聊天失败:' . $e->getMessage(),
|
||||
'data' => []
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
235
src/addon/aikefu/event/KefuChatStream.php
Normal file
235
src/addon/aikefu/event/KefuChatStream.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace addon\aikefu\event;
|
||||
|
||||
use addon\aikefu\model\Config as KefuConfigModel;
|
||||
use addon\aikefu\model\Conversation as KefuConversationModel;
|
||||
use addon\aikefu\model\Message as KefuMessageModel;
|
||||
|
||||
/**
|
||||
* 智能客服流式聊天
|
||||
*/
|
||||
class KefuChatStream
|
||||
{
|
||||
/**
|
||||
* 处理智能客服流式聊天事件
|
||||
* @param array $data 事件数据
|
||||
* @return array
|
||||
*/
|
||||
public function handle($data)
|
||||
{
|
||||
$message = $data['message'] ?? '';
|
||||
$user_id = $data['user_id'] ?? '';
|
||||
$conversation_id = $data['conversation_id'] ?? '';
|
||||
$site_id = $data['site_id'] ?? 0;
|
||||
$request_id = $data['request_id'] ?? '';
|
||||
|
||||
try {
|
||||
// 验证参数
|
||||
if (empty($message)) {
|
||||
return [
|
||||
[
|
||||
'type' => 'error',
|
||||
'message' => '消息内容不能为空'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// 获取智能客服配置
|
||||
$kefu_config_model = new KefuConfigModel();
|
||||
$config_info = $kefu_config_model->getConfig($site_id)['data']['value'] ?? [];
|
||||
|
||||
if (empty($config_info) || $config_info['status'] != 1) {
|
||||
return [
|
||||
[
|
||||
'type' => 'error',
|
||||
'message' => '智能客服暂未启用'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$config = $config_info;
|
||||
$apiKey = $config['api_key'];
|
||||
$baseUrl = $config['base_url'];
|
||||
$chatEndpoint = $config['chat_endpoint'];
|
||||
|
||||
// 构建请求数据
|
||||
$requestData = [
|
||||
'inputs' => [],
|
||||
'query' => $message,
|
||||
'response_mode' => 'streaming',
|
||||
'user' => $user_id,
|
||||
];
|
||||
|
||||
if (!empty($conversation_id)) {
|
||||
$requestData['conversation_id'] = $conversation_id;
|
||||
}
|
||||
|
||||
// 构建请求头
|
||||
$headers = [
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'Content-Type: application/json',
|
||||
'Accept: text/event-stream',
|
||||
];
|
||||
|
||||
// 发送流式请求到Dify API
|
||||
$url = $baseUrl . $chatEndpoint;
|
||||
$result = $this->executeStreamRequest($url, $requestData, $headers);
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
[
|
||||
'type' => 'error',
|
||||
'message' => '请求失败:' . $e->getMessage()
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行流式请求
|
||||
*/
|
||||
private function executeStreamRequest($url, $requestData, $headers)
|
||||
{
|
||||
$ch = curl_init();
|
||||
|
||||
// 设置curl选项
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestData));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_WRITEFUNCTION, [$this, 'streamCallback']);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 120); // 设置较长的超时时间
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
|
||||
// 执行请求
|
||||
$result = [];
|
||||
$this->stream_buffer = '';
|
||||
$this->conversation_id = '';
|
||||
$this->current_message_id = '';
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || !empty($error)) {
|
||||
return [
|
||||
[
|
||||
'type' => 'error',
|
||||
'message' => '请求失败:' . $error
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
if ($http_code >= 400) {
|
||||
return [
|
||||
[
|
||||
'type' => 'error',
|
||||
'message' => '服务端错误,HTTP状态码:' . $http_code
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
return $this->parseStreamResponse($this->stream_buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式回调函数
|
||||
*/
|
||||
private function streamCallback($ch, $data)
|
||||
{
|
||||
$this->stream_buffer .= $data;
|
||||
return strlen($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析流式响应
|
||||
*/
|
||||
private function parseStreamResponse($response)
|
||||
{
|
||||
$result = [];
|
||||
$lines = explode("\n", $response);
|
||||
$conversation_id = '';
|
||||
$message_id = '';
|
||||
$buffer = '';
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strpos($line, 'data: ') === 0) {
|
||||
$data_str = substr($line, 6);
|
||||
|
||||
if ($data_str === '[DONE]') {
|
||||
// 流结束,发送完成事件
|
||||
$result[] = [
|
||||
'type' => 'complete',
|
||||
'conversation_id' => $conversation_id,
|
||||
'message_id' => $message_id,
|
||||
'content' => $buffer,
|
||||
'finished' => true
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
$data = json_decode($data_str, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && isset($data)) {
|
||||
// 提取会话ID和消息ID
|
||||
if (isset($data['conversation_id'])) {
|
||||
$conversation_id = $data['conversation_id'];
|
||||
}
|
||||
if (isset($data['message_id'])) {
|
||||
$message_id = $data['message_id'];
|
||||
}
|
||||
|
||||
// 处理内容块
|
||||
if (isset($data['answer'])) {
|
||||
$buffer .= $data['answer'];
|
||||
$result[] = [
|
||||
'type' => 'chunk',
|
||||
'content' => $data['answer'],
|
||||
'conversation_id' => $conversation_id,
|
||||
'message_id' => $message_id,
|
||||
'finished' => false
|
||||
];
|
||||
}
|
||||
|
||||
// 检查是否结束
|
||||
if (isset($data['finish_reason']) && $data['finish_reason'] !== 'null') {
|
||||
$result[] = [
|
||||
'type' => 'complete',
|
||||
'conversation_id' => $conversation_id,
|
||||
'message_id' => $message_id,
|
||||
'content' => $buffer,
|
||||
'finish_reason' => $data['finish_reason'],
|
||||
'usage' => $data['usage'] ?? [],
|
||||
'finished' => true
|
||||
];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有收到[DONE]信号,确保发送完成事件
|
||||
if (empty($result) || end($result)['type'] !== 'complete') {
|
||||
$result[] = [
|
||||
'type' => 'complete',
|
||||
'conversation_id' => $conversation_id,
|
||||
'message_id' => $message_id,
|
||||
'content' => $buffer,
|
||||
'finished' => true
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
118
src/addon/aikefu/event/KefuClearConversation.php
Normal file
118
src/addon/aikefu/event/KefuClearConversation.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace addon\aikefu\event;
|
||||
|
||||
use addon\aikefu\model\Conversation as KefuConversationModel;
|
||||
use addon\aikefu\model\Message as KefuMessageModel;
|
||||
|
||||
/**
|
||||
* 清除客服会话历史
|
||||
*/
|
||||
class KefuClearConversation
|
||||
{
|
||||
/**
|
||||
* 处理清除会话历史事件
|
||||
* @param array $data 事件数据
|
||||
* @return array
|
||||
*/
|
||||
public function handle($data)
|
||||
{
|
||||
try {
|
||||
$conversation_id = $data['conversation_id'] ?? '';
|
||||
$user_id = $data['user_id'] ?? '';
|
||||
$site_id = $data['site_id'] ?? 0;
|
||||
|
||||
// 验证参数
|
||||
if (empty($conversation_id) && empty($user_id)) {
|
||||
return [
|
||||
'code' => -1,
|
||||
'message' => '会话ID或用户ID不能为空',
|
||||
'data' => []
|
||||
];
|
||||
}
|
||||
|
||||
$conversation_model = new KefuConversationModel();
|
||||
$message_model = new KefuMessageModel();
|
||||
|
||||
$deleted_messages = 0;
|
||||
$deleted_conversations = 0;
|
||||
|
||||
if (!empty($conversation_id)) {
|
||||
// 删除指定会话的消息和会话记录
|
||||
|
||||
// 先删除该会话的所有消息
|
||||
$message_condition = [
|
||||
['site_id', '=', $site_id],
|
||||
['conversation_id', '=', $conversation_id]
|
||||
];
|
||||
|
||||
$message_result = $message_model->deleteMessage($message_condition);
|
||||
if ($message_result['code'] >= 0) {
|
||||
$deleted_messages = $message_result['data']['result'] ?? 0;
|
||||
}
|
||||
|
||||
// 再删除会话记录
|
||||
$conversation_condition = [
|
||||
['site_id', '=', $site_id],
|
||||
['conversation_id', '=', $conversation_id]
|
||||
];
|
||||
|
||||
$conversation_result = $conversation_model->deleteConversation($conversation_condition);
|
||||
if ($conversation_result['code'] >= 0) {
|
||||
$deleted_conversations = $conversation_result['data']['result'] ?? 0;
|
||||
}
|
||||
|
||||
} else if (!empty($user_id)) {
|
||||
// 删除指定用户的所有会话和消息
|
||||
|
||||
// 先获取该用户的所有会话ID
|
||||
$conversation_list = $conversation_model->getConversationList([
|
||||
['site_id', '=', $site_id],
|
||||
['user_id', '=', $user_id]
|
||||
], 'conversation_id');
|
||||
|
||||
$conversation_ids = array_column($conversation_list['data'], 'conversation_id');
|
||||
|
||||
if (!empty($conversation_ids)) {
|
||||
// 删除所有会话的消息
|
||||
$message_condition = [
|
||||
['site_id', '=', $site_id],
|
||||
['conversation_id', 'in', $conversation_ids]
|
||||
];
|
||||
|
||||
$message_result = $message_model->deleteMessage($message_condition);
|
||||
if ($message_result['code'] >= 0) {
|
||||
$deleted_messages = $message_result['data']['result'] ?? 0;
|
||||
}
|
||||
|
||||
// 删除所有会话记录
|
||||
$conversation_condition = [
|
||||
['site_id', '=', $site_id],
|
||||
['user_id', '=', $user_id]
|
||||
];
|
||||
|
||||
$conversation_result = $conversation_model->deleteConversation($conversation_condition);
|
||||
if ($conversation_result['code'] >= 0) {
|
||||
$deleted_conversations = $conversation_result['data']['result'] ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 0,
|
||||
'message' => '清除成功',
|
||||
'data' => [
|
||||
'deleted_messages' => $deleted_messages,
|
||||
'deleted_conversations' => $deleted_conversations
|
||||
]
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'code' => -1,
|
||||
'message' => '清除失败:' . $e->getMessage(),
|
||||
'data' => []
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
81
src/addon/aikefu/event/KefuCreateConversation.php
Normal file
81
src/addon/aikefu/event/KefuCreateConversation.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace addon\aikefu\event;
|
||||
|
||||
use addon\aikefu\api\controller\Kefu as KefuApi;
|
||||
|
||||
/**
|
||||
* 处理智能客服创建会话事件
|
||||
*/
|
||||
class KefuCreateConversation
|
||||
{
|
||||
|
||||
/**
|
||||
* 处理智能客服创建会话事件
|
||||
* @param array $data 事件数据
|
||||
* @return array
|
||||
*/
|
||||
public function handle($data)
|
||||
{
|
||||
try {
|
||||
// 创建addon的KefuApi实例
|
||||
$kefu_api = new KefuApi();
|
||||
|
||||
// 调用初始化方法设置属性
|
||||
$kefu_api->initializeForEvent($data);
|
||||
|
||||
// 调用addon的createConversation方法
|
||||
$response = $kefu_api->createConversation();
|
||||
|
||||
// 返回响应数据
|
||||
return json_decode($response->getContent(), true);
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'code' => -1,
|
||||
'message' => '创建会话失败:' . $e->getMessage(),
|
||||
'data' => []
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理智能客服获取历史消息事件
|
||||
* @param array $data 事件数据
|
||||
* @return array
|
||||
*/
|
||||
public function handleKefuGetHistory($data)
|
||||
{
|
||||
try {
|
||||
// 创建addon的KefuApi实例
|
||||
$kefu_api = new KefuApi();
|
||||
|
||||
// 调用初始化方法设置属性
|
||||
$kefu_api->initializeForEvent($data);
|
||||
|
||||
// 调用addon的getHistory方法
|
||||
$response = $kefu_api->getHistory();
|
||||
|
||||
// 返回响应数据
|
||||
return json_decode($response->getContent(), true);
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'code' => -1,
|
||||
'message' => '获取历史消息失败:' . $e->getMessage(),
|
||||
'data' => []
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件监听映射
|
||||
* @return array
|
||||
*/
|
||||
public function subscribe()
|
||||
{
|
||||
return [
|
||||
'KefuChat' => 'handleKefuChat',
|
||||
'KefuCreateConversation' => 'handleKefuCreateConversation',
|
||||
'KefuGetHistory' => 'handleKefuGetHistory',
|
||||
];
|
||||
}
|
||||
}
|
||||
39
src/addon/aikefu/event/KefuGetHistory.php
Normal file
39
src/addon/aikefu/event/KefuGetHistory.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace addon\aikefu\event;
|
||||
|
||||
use addon\aikefu\api\controller\Kefu as KefuApi;
|
||||
|
||||
/**
|
||||
* 处理智能客服获取历史消息事件
|
||||
*/
|
||||
class KefuGetHistory
|
||||
{
|
||||
/**
|
||||
* 处理智能客服获取历史消息事件
|
||||
* @param array $data 事件数据
|
||||
* @return array
|
||||
*/
|
||||
public function handle($data)
|
||||
{
|
||||
try {
|
||||
// 创建addon的KefuApi实例
|
||||
$kefu_api = new KefuApi();
|
||||
|
||||
// 调用初始化方法设置属性
|
||||
$kefu_api->initializeForEvent($data);
|
||||
|
||||
// 调用addon的getHistory方法
|
||||
$response = $kefu_api->getHistory();
|
||||
|
||||
// 返回响应数据
|
||||
return json_decode($response->getContent(), true);
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'code' => -1,
|
||||
'message' => '获取历史消息失败:' . $e->getMessage(),
|
||||
'data' => []
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
127
src/addon/aikefu/event/KefuGetInfo.php
Normal file
127
src/addon/aikefu/event/KefuGetInfo.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace addon\aikefu\event;
|
||||
|
||||
use addon\aikefu\model\Config as KefuConfigModel;
|
||||
|
||||
/**
|
||||
* 获取智能客服配置信息
|
||||
*/
|
||||
class KefuGetInfo
|
||||
{
|
||||
/**
|
||||
* 处理获取配置信息事件
|
||||
* @param array $data 事件数据
|
||||
* @return array
|
||||
*/
|
||||
public function handle($data)
|
||||
{
|
||||
$site_id = $data['site_id'] ?? 0;
|
||||
$member_id = $data['member_id'] ?? 0;
|
||||
$client_info = $data['client_info'] ?? [];
|
||||
|
||||
try {
|
||||
// 获取智能客服配置
|
||||
$kefu_config_model = new KefuConfigModel();
|
||||
$config_info = $kefu_config_model->getConfig($site_id);
|
||||
|
||||
$response_data = [
|
||||
'service_info' => [
|
||||
'name' => '智能客服',
|
||||
'version' => '1.0.0',
|
||||
'enabled' => false,
|
||||
'status' => 'disabled'
|
||||
],
|
||||
'features' => [],
|
||||
'limits' => [
|
||||
'max_message_length' => 4000,
|
||||
'max_conversation_history' => 100,
|
||||
'rate_limit' => [
|
||||
'requests_per_minute' => 60,
|
||||
'requests_per_hour' => 1000
|
||||
]
|
||||
],
|
||||
'endpoints' => [
|
||||
'chat' => '/api/kefu/chat',
|
||||
'chat_stream' => '/api/kefu/chatStream',
|
||||
'create_conversation' => '/api/kefu/createConversation',
|
||||
'get_history' => '/api/kefu/getHistory',
|
||||
'clear_conversation' => '/api/kefu/clearConversation',
|
||||
'health' => '/api/kefu/health',
|
||||
'info' => '/api/kefu/info'
|
||||
],
|
||||
'client_info' => $client_info,
|
||||
'server_info' => [
|
||||
'php_version' => PHP_VERSION,
|
||||
'server_time' => date('Y-m-d H:i:s'),
|
||||
'timezone' => date_default_timezone_get()
|
||||
]
|
||||
];
|
||||
|
||||
// 处理配置信息
|
||||
if (!empty($config_info['data']['value'])) {
|
||||
$config = $config_info['data']['value'];
|
||||
|
||||
// 服务状态
|
||||
$response_data['service_info']['enabled'] = $config['status'] == 1;
|
||||
$response_data['service_info']['status'] = $config['status'] == 1 ? 'enabled' : 'disabled';
|
||||
|
||||
// 可用功能
|
||||
if ($config['status'] == 1) {
|
||||
$response_data['features'] = [
|
||||
'chat' => true,
|
||||
'chat_stream' => true,
|
||||
'conversation_management' => true,
|
||||
'history_management' => true
|
||||
];
|
||||
}
|
||||
|
||||
// API端点信息(仅在启用时显示详细配置)
|
||||
if ($config['status'] == 1) {
|
||||
$response_data['api_config'] = [
|
||||
'base_url' => $config['base_url'] ?? '',
|
||||
'chat_endpoint' => $config['chat_endpoint'] ?? '',
|
||||
'supports_streaming' => true,
|
||||
'authentication' => 'bearer_token'
|
||||
];
|
||||
}
|
||||
|
||||
// 限制配置(如果有的话)
|
||||
if (isset($config['max_message_length'])) {
|
||||
$response_data['limits']['max_message_length'] = intval($config['max_message_length']);
|
||||
}
|
||||
if (isset($config['rate_limit_per_minute'])) {
|
||||
$response_data['limits']['rate_limit']['requests_per_minute'] = intval($config['rate_limit_per_minute']);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加使用统计信息(如果需要的话)
|
||||
if ($member_id > 0) {
|
||||
$response_data['user_stats'] = [
|
||||
'can_use_service' => $response_data['service_info']['enabled'],
|
||||
'member_id' => $member_id,
|
||||
'site_id' => $site_id
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 0,
|
||||
'message' => '获取配置信息成功',
|
||||
'data' => $response_data
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'code' => -1,
|
||||
'message' => '获取配置信息失败:' . $e->getMessage(),
|
||||
'data' => [
|
||||
'service_info' => [
|
||||
'name' => '智能客服',
|
||||
'status' => 'error'
|
||||
],
|
||||
'error' => $e->getMessage()
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
349
src/addon/aikefu/event/KefuHealthCheck.php
Normal file
349
src/addon/aikefu/event/KefuHealthCheck.php
Normal file
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
namespace addon\aikefu\event;
|
||||
|
||||
use addon\aikefu\model\Config as KefuConfigModel;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 智能客服健康检查
|
||||
*/
|
||||
class KefuHealthCheck
|
||||
{
|
||||
/**
|
||||
* 处理健康检查事件
|
||||
* @param array $data 事件数据
|
||||
* @return array
|
||||
*/
|
||||
public function handle($data)
|
||||
{
|
||||
$check_results = [];
|
||||
$site_id = $data['site_id'] ?? 0;
|
||||
$check_type = $data['check_type'] ?? 'full';
|
||||
|
||||
try {
|
||||
// 1. 数据库连接检查
|
||||
if (in_array($check_type, ['full', 'basic'])) {
|
||||
$check_results[] = $this->checkDatabase();
|
||||
}
|
||||
|
||||
// 2. AI服务配置检查
|
||||
if (in_array($check_type, ['full', 'ai_service'])) {
|
||||
$check_results[] = $this->checkAIServiceConfig($site_id);
|
||||
}
|
||||
|
||||
// 3. AI服务连接检查
|
||||
if (in_array($check_type, ['full', 'ai_service'])) {
|
||||
$check_results[] = $this->checkAIServiceConnection($site_id);
|
||||
}
|
||||
|
||||
// 4. 系统资源检查
|
||||
if (in_array($check_type, ['full'])) {
|
||||
$check_results[] = $this->checkSystemResources();
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$check_results[] = [
|
||||
'component' => 'health_check_error',
|
||||
'status' => 'error',
|
||||
'message' => '健康检查过程异常:' . $e->getMessage(),
|
||||
'response_time_ms' => 0
|
||||
];
|
||||
}
|
||||
|
||||
return $check_results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查数据库连接
|
||||
*/
|
||||
private function checkDatabase()
|
||||
{
|
||||
$start_time = microtime(true);
|
||||
|
||||
try {
|
||||
// 测试数据库连接
|
||||
$result = Db::query('SELECT 1 as test');
|
||||
|
||||
if (!empty($result) && $result[0]['test'] == 1) {
|
||||
return [
|
||||
'component' => 'database',
|
||||
'status' => 'healthy',
|
||||
'message' => '数据库连接正常',
|
||||
'response_time_ms' => round((microtime(true) - $start_time) * 1000, 2),
|
||||
'details' => [
|
||||
'connection' => 'success',
|
||||
'query_test' => 'passed'
|
||||
]
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'component' => 'database',
|
||||
'status' => 'error',
|
||||
'message' => '数据库查询测试失败',
|
||||
'response_time_ms' => round((microtime(true) - $start_time) * 1000, 2)
|
||||
];
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'component' => 'database',
|
||||
'status' => 'error',
|
||||
'message' => '数据库连接失败:' . $e->getMessage(),
|
||||
'response_time_ms' => round((microtime(true) - $start_time) * 1000, 2)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查AI服务配置
|
||||
*/
|
||||
private function checkAIServiceConfig($site_id)
|
||||
{
|
||||
$start_time = microtime(true);
|
||||
|
||||
try {
|
||||
$config_model = new KefuConfigModel();
|
||||
$config_info = $config_model->getConfig($site_id);
|
||||
|
||||
if (empty($config_info['data']['value'])) {
|
||||
return [
|
||||
'component' => 'ai_service_config',
|
||||
'status' => 'warning',
|
||||
'message' => '智能客服配置未设置',
|
||||
'response_time_ms' => round((microtime(true) - $start_time) * 1000, 2),
|
||||
'details' => [
|
||||
'configured' => false,
|
||||
'required_fields' => ['api_key', 'base_url', 'chat_endpoint']
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$config = $config_info['data']['value'];
|
||||
$required_fields = ['api_key', 'base_url', 'chat_endpoint'];
|
||||
$missing_fields = [];
|
||||
|
||||
foreach ($required_fields as $field) {
|
||||
if (empty($config[$field])) {
|
||||
$missing_fields[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($missing_fields)) {
|
||||
return [
|
||||
'component' => 'ai_service_config',
|
||||
'status' => 'warning',
|
||||
'message' => 'AI服务配置不完整,缺少字段:' . implode(', ', $missing_fields),
|
||||
'response_time_ms' => round((microtime(true) - $start_time) * 1000, 2),
|
||||
'details' => [
|
||||
'configured' => true,
|
||||
'complete' => false,
|
||||
'missing_fields' => $missing_fields
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
if ($config['status'] != 1) {
|
||||
return [
|
||||
'component' => 'ai_service_config',
|
||||
'status' => 'warning',
|
||||
'message' => '智能服务已禁用',
|
||||
'response_time_ms' => round((microtime(true) - $start_time) * 1000, 2),
|
||||
'details' => [
|
||||
'configured' => true,
|
||||
'complete' => true,
|
||||
'enabled' => false
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'component' => 'ai_service_config',
|
||||
'status' => 'healthy',
|
||||
'message' => 'AI服务配置正常',
|
||||
'response_time_ms' => round((microtime(true) - $start_time) * 1000, 2),
|
||||
'details' => [
|
||||
'configured' => true,
|
||||
'complete' => true,
|
||||
'enabled' => true,
|
||||
'base_url' => $config['base_url']
|
||||
]
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'component' => 'ai_service_config',
|
||||
'status' => 'error',
|
||||
'message' => 'AI服务配置检查失败:' . $e->getMessage(),
|
||||
'response_time_ms' => round((microtime(true) - $start_time) * 1000, 2)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查AI服务连接
|
||||
*/
|
||||
private function checkAIServiceConnection($site_id)
|
||||
{
|
||||
$start_time = microtime(true);
|
||||
|
||||
try {
|
||||
$config_model = new KefuConfigModel();
|
||||
$config_info = $config_model->getConfig($site_id);
|
||||
|
||||
if (empty($config_info['data']['value'])) {
|
||||
return [
|
||||
'component' => 'ai_service_connection',
|
||||
'status' => 'warning',
|
||||
'message' => 'AI服务未配置,跳过连接检查',
|
||||
'response_time_ms' => round((microtime(true) - $start_time) * 1000, 2)
|
||||
];
|
||||
}
|
||||
|
||||
$config = $config_info['data']['value'];
|
||||
|
||||
if ($config['status'] != 1 || empty($config['api_key']) || empty($config['base_url'])) {
|
||||
return [
|
||||
'component' => 'ai_service_connection',
|
||||
'status' => 'warning',
|
||||
'message' => 'AI服务未启用或配置不完整,跳过连接检查',
|
||||
'response_time_ms' => round((microtime(true) - $start_time) * 1000, 2)
|
||||
];
|
||||
}
|
||||
|
||||
// 测试连接(发送一个简单的健康检查请求)
|
||||
$url = $config['base_url'];
|
||||
$headers = [
|
||||
'Authorization: Bearer ' . $config['api_key'],
|
||||
'Content-Type: application/json',
|
||||
];
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HEADER, true);
|
||||
curl_setopt($ch, CURLOPT_NOBODY, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
return [
|
||||
'component' => 'ai_service_connection',
|
||||
'status' => 'error',
|
||||
'message' => '无法连接到AI服务',
|
||||
'response_time_ms' => round((microtime(true) - $start_time) * 1000, 2)
|
||||
];
|
||||
}
|
||||
|
||||
if ($http_code >= 200 && $http_code < 300) {
|
||||
return [
|
||||
'component' => 'ai_service_connection',
|
||||
'status' => 'healthy',
|
||||
'message' => 'AI服务连接正常',
|
||||
'response_time_ms' => round((microtime(true) - $start_time) * 1000, 2),
|
||||
'details' => [
|
||||
'http_status' => $http_code,
|
||||
'url' => $url
|
||||
]
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'component' => 'ai_service_connection',
|
||||
'status' => 'warning',
|
||||
'message' => 'AI服务响应异常,HTTP状态码:' . $http_code,
|
||||
'response_time_ms' => round((microtime(true) - $start_time) * 1000, 2),
|
||||
'details' => [
|
||||
'http_status' => $http_code,
|
||||
'url' => $url
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'component' => 'ai_service_connection',
|
||||
'status' => 'error',
|
||||
'message' => 'AI服务连接检查失败:' . $e->getMessage(),
|
||||
'response_time_ms' => round((microtime(true) - $start_time) * 1000, 2)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查系统资源
|
||||
*/
|
||||
private function checkSystemResources()
|
||||
{
|
||||
$start_time = microtime(true);
|
||||
|
||||
try {
|
||||
$memory_usage = memory_get_usage(true);
|
||||
$memory_limit = $this->parseMemoryLimit(ini_get('memory_limit'));
|
||||
$memory_usage_percent = ($memory_usage / $memory_limit) * 100;
|
||||
|
||||
$details = [
|
||||
'php_version' => PHP_VERSION,
|
||||
'memory_usage' => round($memory_usage / 1024 / 1024, 2) . ' MB',
|
||||
'memory_limit' => round($memory_limit / 1024 / 1024, 2) . ' MB',
|
||||
'memory_usage_percent' => round($memory_usage_percent, 2) . '%',
|
||||
'max_execution_time' => ini_get('max_execution_time') . 's',
|
||||
'upload_max_filesize' => ini_get('upload_max_filesize'),
|
||||
'post_max_size' => ini_get('post_max_size')
|
||||
];
|
||||
|
||||
$status = 'healthy';
|
||||
$message = '系统资源正常';
|
||||
|
||||
// 检查内存使用率
|
||||
if ($memory_usage_percent > 90) {
|
||||
$status = 'error';
|
||||
$message = '内存使用率过高';
|
||||
} elseif ($memory_usage_percent > 80) {
|
||||
$status = 'warning';
|
||||
$message = '内存使用率较高';
|
||||
}
|
||||
|
||||
return [
|
||||
'component' => 'system_resources',
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'response_time_ms' => round((microtime(true) - $start_time) * 1000, 2),
|
||||
'details' => $details
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'component' => 'system_resources',
|
||||
'status' => 'error',
|
||||
'message' => '系统资源检查失败:' . $e->getMessage(),
|
||||
'response_time_ms' => round((microtime(true) - $start_time) * 1000, 2)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析内存限制值
|
||||
*/
|
||||
private function parseMemoryLimit($val)
|
||||
{
|
||||
$val = trim($val);
|
||||
$last = strtolower($val[strlen($val)-1]);
|
||||
$val = (int)$val;
|
||||
|
||||
switch($last) {
|
||||
case 'g':
|
||||
$val *= 1024;
|
||||
case 'm':
|
||||
$val *= 1024;
|
||||
case 'k':
|
||||
$val *= 1024;
|
||||
}
|
||||
|
||||
return $val;
|
||||
}
|
||||
}
|
||||
20
src/addon/aikefu/event/UnInstall.php
Normal file
20
src/addon/aikefu/event/UnInstall.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace addon\aikefu\event;
|
||||
|
||||
use app\model\system\Addon as AddonModel;
|
||||
|
||||
/**
|
||||
* 智能客服插件卸载
|
||||
*/
|
||||
class UnInstall
|
||||
{
|
||||
public function handle()
|
||||
{
|
||||
$addon_model = new AddonModel();
|
||||
// 删除插件信息
|
||||
$addon_model->deleteAddon(['name' => 'aikefu']);
|
||||
|
||||
return success(1);
|
||||
}
|
||||
}
|
||||
1
src/addon/aikefu/icon.png
Normal file
1
src/addon/aikefu/icon.png
Normal file
File diff suppressed because one or more lines are too long
67
src/addon/aikefu/model/Config.php
Normal file
67
src/addon/aikefu/model/Config.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
/**
|
||||
* 智能客服配置模型
|
||||
* 用于存储和管理智能客服的配置信息
|
||||
* 版本:1.0.0
|
||||
*/
|
||||
|
||||
namespace addon\aikefu\model;
|
||||
|
||||
use app\model\system\Config as ConfigModel;
|
||||
use app\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 智能客服配置
|
||||
*/
|
||||
class Config extends BaseModel
|
||||
{
|
||||
/**
|
||||
* 设置智能客服配置
|
||||
* @param array $data
|
||||
* @param int $site_id
|
||||
* @param string $app_module
|
||||
* @return array
|
||||
*/
|
||||
public function setConfig($data, $site_id = 0, $app_module = 'shop')
|
||||
{
|
||||
$config = new ConfigModel();
|
||||
|
||||
// 获取原始配置
|
||||
$original_config = $this->getConfig($site_id, $app_module)['data']['value'] ?? [];
|
||||
|
||||
// 如果 API Key 为空或保持不变,则使用原始值
|
||||
if (isset($data['api_key']) && empty($data['api_key'])) {
|
||||
$data['api_key'] = $original_config['api_key'] ?? '';
|
||||
}
|
||||
|
||||
$res = $config->setConfig($data, '智能客服配置', 1, [['site_id', '=', $site_id], ['app_module', '=', $app_module], ['config_key', '=', 'AIKEFU_CONFIG']]);
|
||||
return $res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取智能客服配置
|
||||
* @param int $site_id
|
||||
* @param string $app_module
|
||||
* @return array
|
||||
*/
|
||||
public function getConfig($site_id = 0, $app_module = 'shop')
|
||||
{
|
||||
$config = new ConfigModel();
|
||||
$res = $config->getConfig([['site_id', '=', $site_id], ['app_module', '=', $app_module], ['config_key', '=', 'AIKEFU_CONFIG']]);
|
||||
return $res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取智能客服配置信息
|
||||
* @param array $condition
|
||||
* @param string $field
|
||||
* @return array
|
||||
*/
|
||||
public function getConfigInfo($condition = [], $field = '*')
|
||||
{
|
||||
// 兼容旧的调用方式
|
||||
$site_id = $condition[0][1] ?? 0;
|
||||
$res = $this->getConfig($site_id);
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
192
src/addon/aikefu/model/Conversation.php
Normal file
192
src/addon/aikefu/model/Conversation.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace addon\aikefu\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
class Conversation extends Model
|
||||
{
|
||||
/**
|
||||
* 操作成功返回值函数
|
||||
* @param string $data
|
||||
* @param string $code_var
|
||||
* @return array
|
||||
*/
|
||||
public function success($data = '', $code_var = 'SUCCESS')
|
||||
{
|
||||
$lang_array = $this->getLang();
|
||||
$lang_var = $lang_array[$code_var] ?? $code_var;
|
||||
|
||||
if ($code_var == 'SUCCESS') {
|
||||
$code_var = 0;
|
||||
} else {
|
||||
$code_array = array_keys($lang_array);
|
||||
$code_index = array_search($code_var, $code_array);
|
||||
if ($code_index != false) {
|
||||
$code_var = 10000 + $code_index;
|
||||
}
|
||||
}
|
||||
|
||||
return success($code_var, $lang_var, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作失败返回值函数
|
||||
* @param string $data
|
||||
* @param string $code_var
|
||||
* @return array
|
||||
*/
|
||||
public function error($data = '', $code_var = 'FAIL')
|
||||
{
|
||||
$lang_array = $this->getLang();
|
||||
|
||||
if (isset($lang_array[$code_var])) {
|
||||
$lang_var = $lang_array[$code_var];
|
||||
} else {
|
||||
$lang_var = $code_var;
|
||||
$code_var = 'FAIL';
|
||||
}
|
||||
$code_array = array_keys($lang_array);
|
||||
$code_index = array_search($code_var, $code_array);
|
||||
if ($code_index != false) {
|
||||
$code_var = -10000 - $code_index;
|
||||
}
|
||||
return error($code_var, $lang_var, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语言包数组
|
||||
* @return Ambigous <multitype:, unknown>
|
||||
*/
|
||||
public function getLang()
|
||||
{
|
||||
$default_lang = config("lang.default_lang");
|
||||
$cache_common = \think\facade\Cache::get("lang_app/lang/" . $default_lang . '/model.php');
|
||||
if (empty($cache_common)) {
|
||||
$cache_common = include 'app/lang/' . $default_lang . '/model.php';
|
||||
\think\facade\Cache::tag("lang")->set("lang_app/lang/" . $default_lang, $cache_common);
|
||||
}
|
||||
$lang_path = $this->lang ?? '';
|
||||
if (!empty($lang_path)) {
|
||||
$cache_path = \think\facade\Cache::get("lang_" . $lang_path . "/" . $default_lang . '/model.php');
|
||||
if (empty($cache_path)) {
|
||||
$cache_path = include $lang_path . "/" . $default_lang . '/model.php';
|
||||
\think\facade\Cache::tag("lang")->set("lang_" . $lang_path . "/" . $default_lang, $cache_path);
|
||||
}
|
||||
$lang = array_merge($cache_common, $cache_path);
|
||||
} else {
|
||||
$lang = $cache_common;
|
||||
}
|
||||
return $lang;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表名
|
||||
* @var string
|
||||
*/
|
||||
protected $name = 'aikefu_conversation';
|
||||
|
||||
/**
|
||||
* 主键
|
||||
* @var string
|
||||
*/
|
||||
protected $pk = 'id';
|
||||
|
||||
/**
|
||||
* 获取会话信息
|
||||
* @param array $condition
|
||||
* @param string $field
|
||||
* @return array
|
||||
*/
|
||||
public function getConversationInfo($condition = [], $field = '*')
|
||||
{
|
||||
$info = $this->where($condition)->field($field)->find();
|
||||
return empty($info) ? [] : $info->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
* @param array $condition
|
||||
* @param string $field
|
||||
* @param string $order
|
||||
* @param int $page
|
||||
* @param int $limit
|
||||
* @return array
|
||||
*/
|
||||
public function getConversationList($condition = [], $field = '*', $order = 'id desc', $page = 1, $limit = 10)
|
||||
{
|
||||
$list = $this->where($condition)->field($field)->order($order)->paginate([
|
||||
'page' => $page,
|
||||
'list_rows' => $limit
|
||||
]);
|
||||
return $this->pageFormat($list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页数据格式化
|
||||
* @param \think\Paginator $paginator
|
||||
* @return array
|
||||
*/
|
||||
protected function pageFormat($paginator)
|
||||
{
|
||||
return [
|
||||
'data' => $paginator->items(),
|
||||
'total' => $paginator->total(),
|
||||
'per_page' => $paginator->listRows(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加会话
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
public function addConversation($data)
|
||||
{
|
||||
$result = $this->insert($data);
|
||||
return $this->success(['result' => $result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话
|
||||
* @param array $data
|
||||
* @param array $condition
|
||||
* @return array
|
||||
*/
|
||||
public function updateConversation($data, $condition)
|
||||
{
|
||||
$result = $this->where($condition)->update($data);
|
||||
return $this->success(['result' => $result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
* @param array $condition
|
||||
* @return array
|
||||
*/
|
||||
public function deleteConversation($condition)
|
||||
{
|
||||
$result = $this->where($condition)->delete();
|
||||
return $this->success(['result' => $result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户会话列表
|
||||
* @param int $site_id
|
||||
* @param string $user_id
|
||||
* @param int $page
|
||||
* @param int $limit
|
||||
* @return array
|
||||
*/
|
||||
public function getUserConversationList($site_id, $user_id, $page = 1, $limit = 10)
|
||||
{
|
||||
$condition = [
|
||||
['site_id', '=', $site_id],
|
||||
['user_id', '=', $user_id]
|
||||
];
|
||||
|
||||
return $this->getConversationList($condition, '*', 'update_time desc', $page, $limit);
|
||||
}
|
||||
}
|
||||
208
src/addon/aikefu/model/Message.php
Normal file
208
src/addon/aikefu/model/Message.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace addon\aikefu\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
class Message extends Model
|
||||
{
|
||||
/**
|
||||
* 操作成功返回值函数
|
||||
* @param string $data
|
||||
* @param string $code_var
|
||||
* @return array
|
||||
*/
|
||||
public function success($data = '', $code_var = 'SUCCESS')
|
||||
{
|
||||
$lang_array = $this->getLang();
|
||||
$lang_var = $lang_array[$code_var] ?? $code_var;
|
||||
|
||||
if ($code_var == 'SUCCESS') {
|
||||
$code_var = 0;
|
||||
} else {
|
||||
$code_array = array_keys($lang_array);
|
||||
$code_index = array_search($code_var, $code_array);
|
||||
if ($code_index != false) {
|
||||
$code_var = 10000 + $code_index;
|
||||
}
|
||||
}
|
||||
|
||||
return success($code_var, $lang_var, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作失败返回值函数
|
||||
* @param string $data
|
||||
* @param string $code_var
|
||||
* @return array
|
||||
*/
|
||||
public function error($data = '', $code_var = 'FAIL')
|
||||
{
|
||||
$lang_array = $this->getLang();
|
||||
|
||||
if (isset($lang_array[$code_var])) {
|
||||
$lang_var = $lang_array[$code_var];
|
||||
} else {
|
||||
$lang_var = $code_var;
|
||||
$code_var = 'FAIL';
|
||||
}
|
||||
$code_array = array_keys($lang_array);
|
||||
$code_index = array_search($code_var, $code_array);
|
||||
if ($code_index != false) {
|
||||
$code_var = -10000 - $code_index;
|
||||
}
|
||||
return error($code_var, $lang_var, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语言包数组
|
||||
* @return Ambigous <multitype:, unknown>
|
||||
*/
|
||||
public function getLang()
|
||||
{
|
||||
$default_lang = config("lang.default_lang");
|
||||
$cache_common = \think\facade\Cache::get("lang_app/lang/" . $default_lang . '/model.php');
|
||||
if (empty($cache_common)) {
|
||||
$cache_common = include 'app/lang/' . $default_lang . '/model.php';
|
||||
\think\facade\Cache::tag("lang")->set("lang_app/lang/" . $default_lang, $cache_common);
|
||||
}
|
||||
$lang_path = $this->lang ?? '';
|
||||
if (!empty($lang_path)) {
|
||||
$cache_path = \think\facade\Cache::get("lang_" . $lang_path . "/" . $default_lang . '/model.php');
|
||||
if (empty($cache_path)) {
|
||||
$cache_path = include $lang_path . "/" . $default_lang . '/model.php';
|
||||
\think\facade\Cache::tag("lang")->set("lang_" . $lang_path . "/" . $default_lang, $cache_path);
|
||||
}
|
||||
$lang = array_merge($cache_common, $cache_path);
|
||||
} else {
|
||||
$lang = $cache_common;
|
||||
}
|
||||
return $lang;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表名
|
||||
* @var string
|
||||
*/
|
||||
protected $name = 'aikefu_message';
|
||||
|
||||
/**
|
||||
* 主键
|
||||
* @var string
|
||||
*/
|
||||
protected $pk = 'id';
|
||||
|
||||
/**
|
||||
* 获取消息信息
|
||||
* @param array $condition
|
||||
* @param string $field
|
||||
* @return array
|
||||
*/
|
||||
public function getMessageInfo($condition = [], $field = '*')
|
||||
{
|
||||
$info = $this->where($condition)->field($field)->find();
|
||||
return empty($info) ? [] : $info->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息列表
|
||||
* @param array $condition
|
||||
* @param string $field
|
||||
* @param string $order
|
||||
* @param int $page
|
||||
* @param int $limit
|
||||
* @return array
|
||||
*/
|
||||
public function getMessageList($condition = [], $field = '*', $order = 'id asc', $page = 1, $limit = 20)
|
||||
{
|
||||
$list = $this->where($condition)->field($field)->order($order)->paginate([
|
||||
'page' => $page,
|
||||
'list_rows' => $limit
|
||||
]);
|
||||
return $this->pageFormat($list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页数据格式化
|
||||
* @param \think\Paginator $paginator
|
||||
* @return array
|
||||
*/
|
||||
protected function pageFormat($paginator)
|
||||
{
|
||||
return [
|
||||
'data' => $paginator->items(),
|
||||
'total' => $paginator->total(),
|
||||
'per_page' => $paginator->listRows(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加消息
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
public function addMessage($data)
|
||||
{
|
||||
$result = $this->insert($data);
|
||||
return $this->success(['result' => $result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新消息
|
||||
* @param array $data
|
||||
* @param array $condition
|
||||
* @return array
|
||||
*/
|
||||
public function updateMessage($data, $condition)
|
||||
{
|
||||
$result = $this->where($condition)->update($data);
|
||||
return $this->success(['result' => $result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
* @param array $condition
|
||||
* @return array
|
||||
*/
|
||||
public function deleteMessage($condition)
|
||||
{
|
||||
$result = $this->where($condition)->delete();
|
||||
return $this->success(['result' => $result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话消息记录
|
||||
* @param int $site_id
|
||||
* @param string $conversation_id
|
||||
* @param int $limit
|
||||
* @param int $offset
|
||||
* @return array
|
||||
*/
|
||||
public function getConversationMessages($site_id, $conversation_id, $limit = 20, $offset = 0)
|
||||
{
|
||||
$condition = [
|
||||
['site_id', '=', $site_id],
|
||||
['conversation_id', '=', $conversation_id]
|
||||
];
|
||||
|
||||
return $this->getMessageList($condition, '*', 'create_time asc', ($offset / $limit) + 1, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户消息总数
|
||||
* @param int $site_id
|
||||
* @param string $user_id
|
||||
* @return array
|
||||
*/
|
||||
public function getUserMessageCount($site_id, $user_id)
|
||||
{
|
||||
$count = $this->where([
|
||||
['site_id', '=', $site_id],
|
||||
['user_id', '=', $user_id]
|
||||
])->count();
|
||||
|
||||
return $this->success(['count' => $count]);
|
||||
}
|
||||
}
|
||||
230
src/addon/aikefu/shop/controller/Kefu.php
Normal file
230
src/addon/aikefu/shop/controller/Kefu.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
/**
|
||||
* 智能客服控制器
|
||||
*/
|
||||
|
||||
namespace addon\aikefu\shop\controller;
|
||||
|
||||
use addon\aikefu\model\Config as KefuConfigModel;
|
||||
use addon\aikefu\model\Conversation as KefuConversationModel;
|
||||
use addon\aikefu\model\Message as KefuMessageModel;
|
||||
use app\shop\controller\BaseShop;
|
||||
|
||||
use think\facade\Db as Db;
|
||||
|
||||
/**
|
||||
* 智能客服 控制器
|
||||
*/
|
||||
class Kefu extends BaseShop
|
||||
{
|
||||
/**
|
||||
* 智能客服默认页面
|
||||
* @return \think\response\View
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$kefu_config_model = new KefuConfigModel();
|
||||
$config_info = $kefu_config_model->getConfig($this->site_id, $this->app_module)['data']['value'] ?? [];
|
||||
$this->assign("config_info", $config_info);
|
||||
return $this->fetch("kefu/index");
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能客服配置页
|
||||
* @return \think\response\View|\think\response\Json
|
||||
*/
|
||||
public function config()
|
||||
{
|
||||
$kefu_config_model = new KefuConfigModel();
|
||||
|
||||
if (request()->isJson()) {
|
||||
$api_key = input("api_key/s", "");//Dify API密钥
|
||||
$base_url = input("base_url/s", "https://api.dify.ai/v1");//API基础地址
|
||||
$chat_endpoint = input("chat_endpoint/s", "/chat-messages");//聊天接口端点
|
||||
$status = input("status/d", 0);//状态
|
||||
|
||||
$data = array(
|
||||
"api_key" => $api_key,
|
||||
"base_url" => $base_url,
|
||||
"chat_endpoint" => $chat_endpoint,
|
||||
"status" => $status
|
||||
);
|
||||
$result = $kefu_config_model->setConfig($data, $this->site_id, $this->app_module);
|
||||
return $result;
|
||||
} else {
|
||||
$config_info = $kefu_config_model->getConfig($this->site_id, $this->app_module)['data']['value'] ?? [];
|
||||
$this->assign("config_info", $config_info);
|
||||
return $this->fetch("kefu/config");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话管理列表
|
||||
* @return \think\response\View
|
||||
*/
|
||||
public function conversation()
|
||||
{
|
||||
return $this->fetch("kefu/conversation");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function getConversationList()
|
||||
{
|
||||
$page = input("page/d", 1);
|
||||
$limit = input("limit/d", 10);
|
||||
$user_id = input("user_id/s", "");
|
||||
$status = input("status/s", "");
|
||||
|
||||
$kefu_conversation_model = new KefuConversationModel();
|
||||
$condition = [['site_id', '=', $this->site_id]];
|
||||
|
||||
if (!empty($user_id)) {
|
||||
$condition[] = ['user_id', '=', $user_id];
|
||||
}
|
||||
|
||||
if ($status !== '') {
|
||||
$condition[] = ['status', '=', $status];
|
||||
}
|
||||
|
||||
$conversation_list = $kefu_conversation_model->getConversationList($condition, '*', 'update_time desc', $page, $limit);
|
||||
|
||||
return $this->success($conversation_list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话信息
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function getConversationInfo()
|
||||
{
|
||||
$conversation_id = input("conversation_id/s", "");
|
||||
|
||||
if (empty($conversation_id)) {
|
||||
return $this->error('会话ID不能为空');
|
||||
}
|
||||
|
||||
$kefu_conversation_model = new KefuConversationModel();
|
||||
$conversation_info = $kefu_conversation_model->getConversationInfo([
|
||||
['site_id', '=', $this->site_id],
|
||||
['conversation_id', '=', $conversation_id]
|
||||
]);
|
||||
|
||||
if (empty($conversation_info)) {
|
||||
return $this->error('会话不存在');
|
||||
}
|
||||
|
||||
return $this->success($conversation_info);
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束会话
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function endConversation()
|
||||
{
|
||||
$id = input("id/d", "");
|
||||
|
||||
if (empty($id)) {
|
||||
return $this->error('会话ID不能为空');
|
||||
}
|
||||
|
||||
$kefu_conversation_model = new KefuConversationModel();
|
||||
$result = $kefu_conversation_model->updateConversation(
|
||||
['status' => 0],
|
||||
[
|
||||
['id', '=', $id],
|
||||
['site_id', '=', $this->site_id]
|
||||
]
|
||||
);
|
||||
|
||||
return $this->success($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function deleteConversation()
|
||||
{
|
||||
$id = input("id/d", "");
|
||||
|
||||
if (empty($id)) {
|
||||
return $this->error('会话ID不能为空');
|
||||
}
|
||||
|
||||
$kefu_conversation_model = new KefuConversationModel();
|
||||
$kefu_message_model = new KefuMessageModel();
|
||||
|
||||
// 开启事务
|
||||
Db::startTrans();
|
||||
|
||||
try {
|
||||
// 删除会话关联的消息
|
||||
$conversation_info = $kefu_conversation_model->getConversationInfo([
|
||||
['id', '=', $id],
|
||||
['site_id', '=', $this->site_id]
|
||||
]);
|
||||
|
||||
if (!empty($conversation_info)) {
|
||||
$kefu_message_model->deleteMessage([
|
||||
['site_id', '=', $this->site_id],
|
||||
['conversation_id', '=', $conversation_info['conversation_id']]
|
||||
]);
|
||||
}
|
||||
|
||||
// 删除会话
|
||||
$result = $kefu_conversation_model->deleteConversation([
|
||||
['id', '=', $id],
|
||||
['site_id', '=', $this->site_id]
|
||||
]);
|
||||
|
||||
// 提交事务
|
||||
Db::commit();
|
||||
|
||||
return $this->success($result);
|
||||
} catch (\Exception $e) {
|
||||
// 回滚事务
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息管理列表
|
||||
* @return \think\response\View
|
||||
*/
|
||||
public function message()
|
||||
{
|
||||
$conversation_id = input("conversation_id/s", "");
|
||||
$this->assign("conversation_id", $conversation_id);
|
||||
return $this->fetch("kefu/message");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息列表
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function getMessageList()
|
||||
{
|
||||
$page = input("page/d", 1);
|
||||
$limit = input("limit/d", 50);
|
||||
$conversation_id = input("conversation_id/s", "");
|
||||
|
||||
if (empty($conversation_id)) {
|
||||
return $this->error('会话ID不能为空');
|
||||
}
|
||||
|
||||
$kefu_message_model = new KefuMessageModel();
|
||||
$condition = [
|
||||
['site_id', '=', $this->site_id],
|
||||
['conversation_id', '=', $conversation_id]
|
||||
];
|
||||
|
||||
$message_list = $kefu_message_model->getMessageList($condition, '*', 'create_time asc', $page, $limit);
|
||||
|
||||
return $this->success($message_list);
|
||||
}
|
||||
}
|
||||
104
src/addon/aikefu/shop/view/kefu/config.html
Normal file
104
src/addon/aikefu/shop/view/kefu/config.html
Normal file
@@ -0,0 +1,104 @@
|
||||
<style>
|
||||
.word-aux {
|
||||
margin-left: 110px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.required {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="layui-form form-wrap">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label"><span class="required">*</span>Dify API密钥:</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="api_key" placeholder="请输入Dify API密钥" value="{$config_info.api_key ?? ''}" class="layui-input">
|
||||
</div>
|
||||
<div class="word-aux">
|
||||
从Dify平台获取的API密钥,用于调用Dify聊天机器人API。
|
||||
<a href="https://dify.ai/" target="_blank">前往Dify平台</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">API基础地址:</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="base_url" placeholder="请输入Dify API基础地址" value="{$config_info.base_url ?? 'https://api.dify.ai/v1'}" class="layui-input">
|
||||
</div>
|
||||
<div class="word-aux">Dify API的基础地址,默认为https://api.dify.ai/v1</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">聊天接口端点:</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="chat_endpoint" placeholder="请输入聊天接口端点" value="{$config_info.chat_endpoint ?? '/chat-messages'}" class="layui-input">
|
||||
</div>
|
||||
<div class="word-aux">聊天接口的端点,默认为/chat-messages</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label"><span class="required">*</span>状态:</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="checkbox" name="status" value="1" lay-skin="switch" {if condition="isset($config_info.status) && $config_info.status == 1"} checked {/if}>
|
||||
</div>
|
||||
<div class="word-aux">启用或禁用智能客服功能</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<button class="layui-btn" lay-submit lay-filter="save">保存</button>
|
||||
<button class="layui-btn layui-btn-primary" onclick="back()">返回</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use('form', function() {
|
||||
var form = layui.form;
|
||||
var repeat_flag = false; //防重复标识
|
||||
form.render();
|
||||
|
||||
/**
|
||||
* 监听提交
|
||||
*/
|
||||
form.on('submit(save)', function(data) {
|
||||
if (repeat_flag) return false;
|
||||
repeat_flag = true;
|
||||
|
||||
$.ajax({
|
||||
url: ns.url("aikefu://shop/kefu/config"),
|
||||
type: 'POST',
|
||||
data: data.field,
|
||||
dataType: 'json',
|
||||
success: function(res) {
|
||||
repeat_flag = false;
|
||||
if (res.code === 0) {
|
||||
layer.confirm('保存成功', {
|
||||
title: '操作提示',
|
||||
btn: ['返回列表', '继续编辑'],
|
||||
yes: function(index, layero) {
|
||||
location.reload();
|
||||
layer.close(index);
|
||||
},
|
||||
btn2: function(index, layero) {
|
||||
layer.close(index);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
layer.msg(res.message, {icon: 2});
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
repeat_flag = false;
|
||||
layer.msg('请求失败,请稍后重试', {icon: 2});
|
||||
}
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
function back() {
|
||||
window.history.back();
|
||||
}
|
||||
</script>
|
||||
214
src/addon/aikefu/shop/view/kefu/conversation.html
Normal file
214
src/addon/aikefu/shop/view/kefu/conversation.html
Normal file
@@ -0,0 +1,214 @@
|
||||
<style>
|
||||
.search-box {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.search-item {
|
||||
display: inline-block;
|
||||
margin-right: 15px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.search-item label {
|
||||
display: inline-block;
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
margin-right: 10px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.search-item input,
|
||||
.search-item select {
|
||||
width: 150px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.layui-btn-container {
|
||||
margin-bottom: 15px;
|
||||
text-align: right;
|
||||
}
|
||||
.layui-btn-sm {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.status-active {
|
||||
color: #52c41a;
|
||||
}
|
||||
.status-inactive {
|
||||
color: #faad14;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="layui-card-body">
|
||||
<!-- 搜索区域 -->
|
||||
<div class="search-box">
|
||||
<form class="layui-form" id="searchForm">
|
||||
<div class="search-item">
|
||||
<label for="user_id">用户ID</label>
|
||||
<input type="text" name="user_id" id="user_id" placeholder="请输入用户ID" class="layui-input">
|
||||
</div>
|
||||
<div class="search-item">
|
||||
<label for="status">状态</label>
|
||||
<select name="status" id="status" class="layui-select">
|
||||
<option value="">全部</option>
|
||||
<option value="1">活跃</option>
|
||||
<option value="0">已结束</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="search-item">
|
||||
<button type="button" class="layui-btn layui-btn-primary" id="searchBtn">搜索</button>
|
||||
<button type="button" class="layui-btn layui-btn-primary" id="resetBtn">重置</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 表格区域 -->
|
||||
<table class="layui-table" id="conversationTable" lay-filter="conversationTable"></table>
|
||||
</div>
|
||||
|
||||
<script type="text/html" id="toolbarDemo">
|
||||
<div class="layui-btn-container">
|
||||
<!-- 可以在这里添加按钮 -->
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script type="text/html" id="statusTpl">
|
||||
{{# if(d.status === 1) {
|
||||
return '<span class="status-active">活跃</span>';
|
||||
} else {
|
||||
return '<span class="status-inactive">已结束</span>';
|
||||
}}}
|
||||
</script>
|
||||
|
||||
<script type="text/html" id="barDemo">
|
||||
<a class="layui-btn layui-btn-xs" lay-event="view">查看消息</a>
|
||||
<a class="layui-btn layui-btn-xs layui-btn-warning" lay-event="end">结束会话</a>
|
||||
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="delete">删除</a>
|
||||
</script>
|
||||
|
||||
<script>
|
||||
layui.use(['table', 'form', 'layer'], function() {
|
||||
var table = layui.table;
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
|
||||
// 渲染表格
|
||||
var tableIns = table.render({
|
||||
elem: '#conversationTable',
|
||||
url: ns.url("aikefu://shop/kefu/getConversationList"),
|
||||
method: 'POST',
|
||||
toolbar: '#toolbarDemo',
|
||||
defaultToolbar: ['filter', 'exports', 'print'],
|
||||
title: '会话管理',
|
||||
cols: [[
|
||||
{field: 'id', title: 'ID', width: 80, align: 'center', fixed: 'left'},
|
||||
{field: 'conversation_id', title: '会话ID', width: 200, align: 'center'},
|
||||
{field: 'user_id', title: '用户ID', width: 150, align: 'center'},
|
||||
{field: 'name', title: '会话名称', width: 180, align: 'center'},
|
||||
{field: 'status', title: '状态', width: 100, align: 'center', templet: '#statusTpl'},
|
||||
{field: 'create_time', title: '创建时间', width: 180, align: 'center'},
|
||||
{field: 'update_time', title: '更新时间', width: 180, align: 'center'},
|
||||
{fixed: 'right', title: '操作', width: 200, align: 'center', toolbar: '#barDemo'}
|
||||
]],
|
||||
page: true,
|
||||
limit: 10,
|
||||
limits: [10, 20, 30, 50, 100],
|
||||
height: 'full-200',
|
||||
text: {
|
||||
none: '暂无会话数据'
|
||||
}
|
||||
});
|
||||
|
||||
// 搜索按钮点击事件
|
||||
$('#searchBtn').click(function() {
|
||||
// 执行搜索
|
||||
tableIns.reload({
|
||||
page: {
|
||||
curr: 1
|
||||
},
|
||||
where: {
|
||||
user_id: $('#user_id').val(),
|
||||
status: $('#status').val()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 重置按钮点击事件
|
||||
$('#resetBtn').click(function() {
|
||||
$('#user_id').val('');
|
||||
$('#status').val('');
|
||||
form.render('select');
|
||||
|
||||
// 执行重置后的搜索
|
||||
tableIns.reload({
|
||||
page: {
|
||||
curr: 1
|
||||
},
|
||||
where: {
|
||||
user_id: '',
|
||||
status: ''
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 监听行工具事件
|
||||
table.on('tool(conversationTable)', function(obj) {
|
||||
var data = obj.data;
|
||||
var layEvent = obj.event;
|
||||
|
||||
if (layEvent === 'view') {
|
||||
// 查看消息
|
||||
layer.open({
|
||||
type: 2,
|
||||
title: '消息记录',
|
||||
content: ns.url("aikefu://shop/kefu/message", {conversation_id: data.conversation_id}),
|
||||
area: ['90%', '90%']
|
||||
});
|
||||
} else if (layEvent === 'end') {
|
||||
// 结束会话
|
||||
layer.confirm('确定要结束该会话吗?', function(index) {
|
||||
$.ajax({
|
||||
url: ns.url("aikefu://shop/kefu/endConversation"),
|
||||
type: 'POST',
|
||||
data: {id: data.id},
|
||||
dataType: 'json',
|
||||
success: function(res) {
|
||||
if (res.code === 0) {
|
||||
layer.msg('会话已结束', {icon: 1});
|
||||
// 重新加载表格数据
|
||||
tableIns.reload();
|
||||
} else {
|
||||
layer.msg('操作失败:' + res.message, {icon: 2});
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
layer.msg('请求失败,请稍后重试', {icon: 2});
|
||||
}
|
||||
});
|
||||
layer.close(index);
|
||||
});
|
||||
} else if (layEvent === 'delete') {
|
||||
// 删除会话
|
||||
layer.confirm('确定要删除该会话吗?删除后将无法恢复', function(index) {
|
||||
$.ajax({
|
||||
url: ns.url("aikefu://shop/kefu/deleteConversation"),
|
||||
type: 'POST',
|
||||
data: {id: data.id},
|
||||
dataType: 'json',
|
||||
success: function(res) {
|
||||
if (res.code === 0) {
|
||||
layer.msg('会话已删除', {icon: 1});
|
||||
// 重新加载表格数据
|
||||
tableIns.reload();
|
||||
} else {
|
||||
layer.msg('操作失败:' + res.message, {icon: 2});
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
layer.msg('请求失败,请稍后重试', {icon: 2});
|
||||
}
|
||||
});
|
||||
layer.close(index);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
37
src/addon/aikefu/shop/view/kefu/index.html
Normal file
37
src/addon/aikefu/shop/view/kefu/index.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<div class="layui-fluid">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body">
|
||||
<div class="layui-tab layui-tab-brief" lay-filter="kefu-tab">
|
||||
<ul class="layui-tab-title">
|
||||
<li class="layui-this" lay-id="config">配置</li>
|
||||
<li lay-id="conversation">会话</li>
|
||||
<li lay-id="message">消息</li>
|
||||
</ul>
|
||||
<div class="layui-tab-content">
|
||||
<div class="layui-tab-item layui-show">
|
||||
{include file="kefu/config" /}
|
||||
</div>
|
||||
<div class="layui-tab-item">
|
||||
{include file="kefu/conversation" /}
|
||||
</div>
|
||||
<div class="layui-tab-item">
|
||||
{include file="kefu/message" /}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['element', 'form', 'table', 'layer', 'laypage'], function() {
|
||||
var element = layui.element;
|
||||
var form = layui.form;
|
||||
var table = layui.table;
|
||||
var layer = layui.layer;
|
||||
var laypage = layui.laypage;
|
||||
|
||||
// 初始化表单渲染
|
||||
form.render();
|
||||
});
|
||||
</script>
|
||||
274
src/addon/aikefu/shop/view/kefu/message.html
Normal file
274
src/addon/aikefu/shop/view/kefu/message.html
Normal file
@@ -0,0 +1,274 @@
|
||||
<style>
|
||||
.message-list {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #e6e6e6;
|
||||
}
|
||||
.message-item {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.message-item.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.message-item.assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.message-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
margin: 0 10px;
|
||||
}
|
||||
.message-content {
|
||||
max-width: 70%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 18px;
|
||||
word-wrap: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.message-item.user .message-content {
|
||||
background-color: #1E9FFF;
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.message-item.assistant .message-content {
|
||||
background-color: white;
|
||||
color: #333;
|
||||
border-bottom-left-radius: 4px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
.message-role {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.conversation-info {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e6e6e6;
|
||||
}
|
||||
.conversation-info h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
.conversation-info p {
|
||||
margin: 5px 0;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
.status-active {
|
||||
color: #52c41a;
|
||||
}
|
||||
.status-inactive {
|
||||
color: #faad14;
|
||||
}
|
||||
.search-box {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e6e6e6;
|
||||
}
|
||||
.search-item {
|
||||
margin-right: 15px;
|
||||
}
|
||||
.search-item label {
|
||||
display: inline-block;
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.layui-btn-sm {
|
||||
margin-left: 5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="layui-card-body">
|
||||
<!-- 搜索区域 -->
|
||||
<div class="search-box">
|
||||
<form class="layui-form" id="searchForm">
|
||||
<div class="search-item">
|
||||
<label for="conversation_id">会话ID</label>
|
||||
<input type="text" name="conversation_id" id="conversation_id" placeholder="请输入会话ID" value="{$conversation_id ?? ''}" class="layui-input" style="width: 200px; display: inline-block;">
|
||||
</div>
|
||||
<div class="search-item">
|
||||
<button type="button" class="layui-btn layui-btn-primary" id="searchBtn">搜索</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 会话信息 -->
|
||||
<div id="conversationInfo" class="conversation-info">
|
||||
<!-- 会话信息将通过JavaScript动态加载 -->
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div class="message-list" id="messageList">
|
||||
<!-- 消息列表将通过JavaScript动态加载 -->
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="layui-fixbar" id="pagination"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['laypage', 'layer'], function() {
|
||||
var laypage = layui.laypage;
|
||||
var layer = layui.layer;
|
||||
|
||||
// 分页参数
|
||||
var page = 1;
|
||||
var limit = 50;
|
||||
var total = 0;
|
||||
var conversation_id = $('#conversation_id').val();
|
||||
|
||||
// 加载会话信息
|
||||
function loadConversationInfo() {
|
||||
if (!conversation_id) {
|
||||
$('#conversationInfo').html('<h3>会话详情</h3><p>请输入会话ID进行搜索</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: ns.url("aikefu://shop/kefu/getConversationInfo"),
|
||||
type: 'POST',
|
||||
data: {
|
||||
conversation_id: conversation_id
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(res) {
|
||||
if (res.code === 0) {
|
||||
var info = res.data;
|
||||
var html = '<h3>会话详情</h3>';
|
||||
html += '<p><strong>会话ID:</strong>' + info.conversation_id + '</p>';
|
||||
html += '<p><strong>用户ID:</strong>' + info.user_id + '</p>';
|
||||
html += '<p><strong>会话名称:</strong>' + info.name + '</p>';
|
||||
html += '<p><strong>状态:</strong>' + (info.status === 1 ? '<span class="status-active">活跃</span>' : '<span class="status-inactive">已结束</span>') + '</p>';
|
||||
html += '<p><strong>创建时间:</strong>' + info.create_time + '</p>';
|
||||
html += '<p><strong>更新时间:</strong>' + info.update_time + '</p>';
|
||||
$('#conversationInfo').html(html);
|
||||
} else {
|
||||
$('#conversationInfo').html('<h3>会话详情</h3><p>未找到会话信息</p>');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$('#conversationInfo').html('<h3>会话详情</h3><p>加载会话信息失败</p>');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 加载消息列表
|
||||
function loadMessageList() {
|
||||
if (!conversation_id) {
|
||||
$('#messageList').html('<div style="text-align: center; padding: 50px; color: #999;">请输入会话ID进行搜索</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: ns.url("aikefu://shop/kefu/getMessageList"),
|
||||
type: 'POST',
|
||||
data: {
|
||||
page: page,
|
||||
limit: limit,
|
||||
conversation_id: conversation_id
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(res) {
|
||||
if (res.code === 0) {
|
||||
var list = res.data.list;
|
||||
total = res.data.total;
|
||||
|
||||
var html = '';
|
||||
if (list.length > 0) {
|
||||
list.forEach(function(item) {
|
||||
var role = item.role === 'user' ? '用户' : '机器人';
|
||||
var roleClass = item.role === 'user' ? 'user' : 'assistant';
|
||||
var avatar = item.role === 'user' ? '/__STATIC__/admin/img/user.png' : '/__STATIC__/admin/img/robot.png';
|
||||
|
||||
html += '<div class="message-item ' + roleClass + '">';
|
||||
if (item.role === 'assistant') {
|
||||
html += '<img src="' + avatar + '" class="message-avatar">';
|
||||
}
|
||||
html += '<div>';
|
||||
html += '<div class="message-role">' + role + '</div>';
|
||||
html += '<div class="message-content">' + item.content + '</div>';
|
||||
html += '<div class="message-time">' + item.create_time + '</div>';
|
||||
html += '</div>';
|
||||
if (item.role === 'user') {
|
||||
html += '<img src="' + avatar + '" class="message-avatar">';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
} else {
|
||||
html += '<div style="text-align: center; padding: 50px; color: #999;">暂无消息记录</div>';
|
||||
}
|
||||
|
||||
$('#messageList').html(html);
|
||||
// 滚动到底部
|
||||
$('#messageList').scrollTop($('#messageList')[0].scrollHeight);
|
||||
// 渲染分页
|
||||
renderPagination();
|
||||
} else {
|
||||
layer.msg('加载失败:' + res.message, {icon: 2});
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
layer.msg('请求失败,请稍后重试', {icon: 2});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 渲染分页
|
||||
function renderPagination() {
|
||||
if (total <= limit) {
|
||||
$('#pagination').html('');
|
||||
return;
|
||||
}
|
||||
|
||||
laypage.render({
|
||||
elem: 'pagination',
|
||||
count: total,
|
||||
limit: limit,
|
||||
curr: page,
|
||||
layout: ['prev', 'page', 'next', 'count', 'skip'],
|
||||
jump: function(obj, first) {
|
||||
if (!first) {
|
||||
page = obj.curr;
|
||||
loadMessageList();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 搜索按钮点击事件
|
||||
$('#searchBtn').click(function() {
|
||||
conversation_id = $('#conversation_id').val();
|
||||
page = 1;
|
||||
loadConversationInfo();
|
||||
loadMessageList();
|
||||
});
|
||||
|
||||
// 初始化加载
|
||||
if (conversation_id) {
|
||||
loadConversationInfo();
|
||||
loadMessageList();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
return [
|
||||
|
||||
// 自定义模板页面类型,格式:[ 'title' => '页面类型名称', 'name' => '页面标识', 'path' => '页面路径', 'value' => '页面数据,json格式' ]
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
return [
|
||||
'name' => 'alioss',
|
||||
'title' => '阿里云OSS',
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
|
||||
namespace addon\alioss\event;
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
|
||||
namespace addon\alioss\event;
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
|
||||
namespace addon\alioss\event;
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
|
||||
namespace addon\alioss\event;
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
|
||||
namespace addon\alioss\event;
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
|
||||
namespace addon\alioss\event;
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\alioss\model;
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\alioss\model;
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\alioss\shop\controller;
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
return [
|
||||
|
||||
// 自定义模板页面类型,格式:[ 'title' => '页面类型名称', 'name' => '页面标识', 'path' => '页面路径', 'value' => '页面数据,json格式' ]
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
return [
|
||||
'bind' => [
|
||||
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
return [
|
||||
'name' => 'alipay',
|
||||
'title' => '支付宝支付',
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
return [
|
||||
[
|
||||
'name' => 'ALI_PAY_CONFIG',
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
|
||||
namespace addon\alipay\event;
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\alipay\event;
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\alipay\event;
|
||||
|
||||
@@ -24,16 +16,16 @@ class PayClose
|
||||
*/
|
||||
public function handle($params)
|
||||
{
|
||||
// if ($params["pay_type"] == "alipay") {
|
||||
try {
|
||||
$pay_model = new PayModel($params[ 'site_id' ]);
|
||||
$result = $pay_model->close($params);
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
return error(-1, $e->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
return error(-1, $e->getMessage());
|
||||
if ($params["pay_type"] == "alipay") {
|
||||
try {
|
||||
$pay_model = new PayModel($params[ 'site_id' ]);
|
||||
$result = $pay_model->close($params);
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
return error(-1, $e->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
return error(-1, $e->getMessage());
|
||||
}
|
||||
}
|
||||
// }
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\alipay\event;
|
||||
|
||||
@@ -21,8 +13,10 @@ class PayNotify
|
||||
/**
|
||||
* 支付方式及配置
|
||||
*/
|
||||
public function handle()
|
||||
public function handle($param)
|
||||
{
|
||||
if ($param[ "pay_type" ] != "alipay") return false;
|
||||
|
||||
if (isset($_POST[ 'out_trade_no' ])) {
|
||||
$out_trade_no = $_POST[ 'out_trade_no' ];
|
||||
$pay = new PayCommon();
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\alipay\event;
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\alipay\event;
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\alipay\event;
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\alipay\event;
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\alipay\event;
|
||||
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\alipay\event;
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\alipay\model;
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\alipay\model;
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\alipay\shop\controller;
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
return [
|
||||
'name' => 'cases',
|
||||
'title' => '案例展示',
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\cases\event;
|
||||
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\cases\event;
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ class Enterprise extends BaseModel
|
||||
*/
|
||||
public function deleteVideo($condition)
|
||||
{
|
||||
file_put_contents(__DIR__ . '/debug.txt', var_export($condition,true));
|
||||
// file_put_contents(__DIR__ . '/debug.txt', var_export($condition,true));
|
||||
$check_condition = array_column($condition, 2, 0);
|
||||
$site_id = $check_condition['site_id'] ?? '';
|
||||
if ($site_id === '') {
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\coupon\api\controller;
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
return [
|
||||
|
||||
// 自定义模板页面类型,格式:[ 'title' => '页面类型名称', 'name' => '页面标识', 'path' => '页面路径', 'value' => '页面数据,json格式' ]
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
return [
|
||||
'name' => 'coupon',
|
||||
'title' => '优惠券',
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\coupon\dict;
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\coupon\event;
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
namespace addon\coupon\event;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user