222 Commits

Author SHA1 Message Date
2d21993720 feat(websocket): 增强WebSocket连接的健壮性和重连机制
实现数据库连接检查接口并集成到WebSocket控制器
添加自动重连逻辑和指数退避策略
移除默认测试路径并优化插件列表获取流程
更新测试页面以支持重连状态显示
2026-01-26 16:05:29 +08:00
3a121e4db6 perf(docker/mysql): 优化MySQL配置以提高性能和稳定性
调整连接数、超时时间和缓冲区大小以提升性能
添加InnoDB优化参数和慢查询日志配置
设置lower_case_table_names以解决大小写敏感问题
2026-01-26 10:00:34 +08:00
ef708e6b40 feat(WebSocket): 添加数据库连接检查和文件预览功能
- 在DefaultWebSocketController中添加数据库连接检查功能
- 实现文件预览和下载功能及相关API接口
- 更新测试页面支持文件预览和下载操作
- 移除旧的数据库维护子进程机制,改为函数检查
- 在构建请求数据时添加文件字段支持
2026-01-26 08:40:07 +08:00
0a7301f39d fix(进程管理): 添加Windows平台兼容性处理
修改数据库维护子进程和父进程的信号处理逻辑,增加对Windows平台的兼容性检查。在Windows平台下跳过不支持的POSIX函数调用,避免运行时错误。
2026-01-24 17:57:29 +08:00
c9d4d1d797 fix(ws_server): 添加父进程状态检查以处理异常退出
在数据库维护子进程中添加父进程状态检查,当父进程异常退出时自动终止子进程
2026-01-24 17:07:01 +08:00
d975abb3de feat(websocket): 实现文件分片上传功能并重构认证逻辑
重构WebSocketBase类,移除冗余属性,简化认证参数传递方式。新增文件分片上传功能,包括分片上传、合并、状态检查等完整流程。前端页面添加文件上传UI组件,支持断点续传和进度显示。优化认证逻辑,统一使用data参数传递认证信息,提高代码可维护性。
2026-01-24 15:04:22 +08:00
aa64c475e9 feat: 为多插件测试页面添加会话ID支持
在WebSocket通信中添加conversation_id字段,用于跟踪和管理会话状态
2026-01-24 11:18:27 +08:00
5cbd11be38 feat(websocket): 添加日志封装函数并优化服务器启动输出
添加ws_log系列函数封装日志记录,统一WebSocket服务器日志格式
使用ws_echo替换原有echo输出,同时记录日志和控制台显示
优化服务器启动流程,增加配置信息输出和状态记录
2026-01-24 10:05:19 +08:00
3341d41422 fix(shop): 修正小程序系统客服复选框状态处理逻辑
修复复选框默认值处理问题,确保未选中时能正确提交0值。同时优化相关注释描述。
2026-01-23 16:25:23 +08:00
d734ec45d6 feat(ws_server): 增强数据库连接稳定性和维护机制
添加数据库连接异常处理和自动重连功能,当数据库连接失败时尝试重新初始化
引入子进程定期检查数据库连接状态,确保连接持续可用
添加信号处理机制,确保进程能优雅退出
2026-01-23 15:54:08 +08:00
6bedc732d1 feat(店铺): 添加店铺联系方式和到期时间显示功能
在店铺信息中新增显示联系电话和动态到期时间功能,替换原有的固定值显示
2026-01-23 15:35:53 +08:00
5c4735a8f6 fix: 移除小程序页面路径标签中的必填标记 2026-01-23 08:31:36 +08:00
ef7879c2b6 fix(shop): 修正小程序配置表单字段命名和默认值逻辑
修复小程序系统客服复选框的默认选中逻辑,将字段名从pagepath改为page_path,appid改为app_id以保持命名一致性
2026-01-22 18:19:55 +08:00
b84ada7039 feat(客服配置): 添加显示系统客服选项及控制逻辑
在客服类型配置页面中新增"同时显示小程序系统客服"复选框选项,并添加相关显示控制逻辑。该选项仅在客服类型为aikefu、wxwork或miniprogram时显示,通过JavaScript动态控制其可见性
2026-01-22 17:30:44 +08:00
9a0dcc87e6 feat(微信小程序): 添加第三方微信小程序客服支持
新增第三方微信小程序客服类型选项,包含AppID和页面路径的输入验证
2026-01-22 17:16:21 +08:00
f7dc9977ac feat(商户管理): 增加通过微信小程序配置搜索商户的功能
添加可选项允许在商户管理中通过微信小程序的配置信息进行关键字搜索。当启用该选项时,搜索条件将包含微信小程序的配置值。同时修改了关联查询以包含微信小程序的配置表。
2026-01-22 15:25:57 +08:00
82efca7135 fix(Platform): 允许通过站点ID或名称进行关键字搜索
修改查询条件,使关键字搜索同时匹配站点ID和站点名称,提升搜索灵活性
2026-01-22 14:54:21 +08:00
82237f6879 feat(platform): Clear pagination cache on search to ensure results start from the first page
Added functionality to clear pagination cache when a search is performed, forcing the results to display from the first page. This includes checks for search keywords and updates to local storage accordingly.
2026-01-22 14:17:36 +08:00
fe2a41cd33 build(php): 在 Dockerfile 中添加 lsof 工具
安装 lsof 工具以便于调试容器内的进程和文件打开情况
2026-01-22 10:49:55 +08:00
0fb8e62b50 feat(stream_chat_demo): 添加API URL配置功能
在流式聊天测试Demo中添加API URL配置面板,支持自定义和保存API地址
2026-01-22 10:08:11 +08:00
8e32bc0d7d fix: 修复日志文件名生成逻辑
当指定文件名时,在文件名前添加日期前缀以区分不同日期的日志文件
2026-01-22 09:55:59 +08:00
2f7d9ed312 refactor(log): Remove unnecessary filename formatting in log_write function 2026-01-22 09:43:13 +08:00
ad5dcfea77 docs(README): Update Docker commands for development and testing environments to include cache cleanup and no-cache builds 2026-01-22 09:05:50 +08:00
e263f3bd58 feat(nginx): Add configuration for dev.aigc-quickapp.com with SSL and WebSocket support
This commit introduces a new Nginx configuration file for the development environment of the AIGC QuickApp. It includes settings for SSL, HTTP to HTTPS redirection, WebSocket support, and various security headers. Additionally, it implements rate limiting and error handling for improved performance and security.
2026-01-22 09:05:21 +08:00
266f810508 fix(WebSocket): 修正成员ID验证并临时放宽认证参数检查
修正WebSocket连接中成员ID验证使用错误变量的问题,将$member_id改为$user_id
同时临时放宽认证参数检查,仅验证site_id,为后续配置调整做准备
2026-01-21 17:03:37 +08:00
f2d5ce1d7b refactor(websocket): 统一将member_id重命名为user_id
修改认证参数和相关变量名,从member_id改为user_id以保持命名一致性
2026-01-21 16:46:13 +08:00
f4e4d2a855 fix: 将认证信息中的site_id改为uniacid
修改WebSocket认证消息中的字段名以保持一致性,使用uniacid替代旧的site_id字段
2026-01-21 16:03:57 +08:00
b737a7d51d fix(WebSocketBase): 优先使用 uniacid 参数进行认证
与 Kefu.php 保持行为一致,在认证处理中优先检查 uniacid 参数,其次才检查 site_id 参数
2026-01-21 16:02:35 +08:00
673678a0e4 fix(websocket): 统一WebSocket与Kefu的请求参数处理
调整WebSocket.php和前端测试页面的参数命名和处理逻辑,使其与Kefu.php保持一致:
1. 将message参数改为query
2. 增加response_mode参数
3. 统一参数优先级处理
4. 优化流式响应判断逻辑
2026-01-21 15:49:31 +08:00
96b61ba533 fix(WebSocket): Improve authentication logging and adjust strict mode for development 2026-01-21 14:21:43 +08:00
e000b61508 refactor(WebSocket): Enhance authentication logic and improve message handling with transaction support 2026-01-21 13:49:16 +08:00
31456469a3 chore(config): 针对智能客服WebSocket访问地址,由服务器端指定分配 2026-01-21 11:50:30 +08:00
43edae2f90 test: 测试在宝塔服务器上的配置反应 2026-01-21 09:56:48 +08:00
05b80040f6 chore(websocket): 已经初步实现服务器端按照流式请求反馈信息的功能 2026-01-20 18:18:07 +08:00
e6929aa1f5 chore(WebSocket): 复用ThinkApp中的Cache及其他设置 2026-01-17 17:52:25 +08:00
5f7017b78a chore(cache): 备注缓存设置说明 2026-01-17 14:12:05 +08:00
949940dca6 chore(cache): 缓存过期时间从7天改为3小时,来减少设置等待时间 2026-01-17 13:59:18 +08:00
620fa93149 chore: 添加uid,方便知道当前商户的site_id 2026-01-17 11:28:30 +08:00
0af78b796b chore: 去除多余的注释内容 2026-01-17 10:54:09 +08:00
7a1a59cd49 Merge: 合并电子名片及新组件微信视频号 2026-01-15 16:38:05 +08:00
91f427b030 chore: 代码格式化 2026-01-15 16:05:52 +08:00
b1bccafeb6 chore(docker): update docker/nginx/sites-enabled/app.conf 2026-01-15 15:35:17 +08:00
1914cc9958 Merge: 合并电子名片及新组件微信视频号 2026-01-15 15:30:41 +08:00
ef32e31e59 Merge: 合并电子名片及新组件微信视频号 2026-01-15 14:31:09 +08:00
d435aaf4a8 chore(docker): 不需要的docker设置取消 2026-01-05 15:55:11 +08:00
e41b47cb62 chore(docker): 网络不适用 2025-12-20 14:53:46 +08:00
f577e47be6 chore(websocket): 更新ws_server 2025-12-20 14:44:01 +08:00
f8291dd2ba chore(docker): 支持独立的WebSocket 服务暴漏 2025-12-19 18:04:29 +08:00
ba5c2239ac chore(docker): 增加支持websocket转发功能,PHP容器不暴漏端口到主机 2025-12-19 15:32:34 +08:00
498122f57e feat: 新增WebSocket 服务 2025-12-19 11:56:14 +08:00
4e5d16e48c chore: 更新客服配置,支持判断aikefu插件是否存在,才显示智能客服 2025-12-19 10:41:37 +08:00
0980a8db27 chore: 客服配置增加智能客服选项 2025-12-19 10:32:13 +08:00
3239891cd1 Merge branch 'feat-wxwork' into dev. 新增后端支持企业微信配置 2025-12-16 11:08:14 +08:00
28b12b3dfe chore: 将企业微信配置挂载在 config/init API接口上 2025-12-16 10:58:04 +08:00
3aab5b9c75 chore: 更新企业微信配置页面 2025-12-16 10:50:58 +08:00
09c859750e chore(docker): 去除xdebug依赖,优化.env 环境变量 2025-12-16 09:41:03 +08:00
157ea7f46d chore(docker): 保存关键信息,与Dev分支同步 2025-12-15 16:31:06 +08:00
572b4c4a00 chore(base): 同步dev关于Docker及.env 的配置 2025-12-15 16:16:17 +08:00
9b38248cbf chore(deploy): 新增部署快捷脚本 2025-12-15 14:34:19 +08:00
89f36ee666 chore(addon/aikefu): 优化请求参数说明 2025-12-11 16:00:56 +08:00
c8cf3cde16 chore(addon/kefu):优化代码 2025-12-11 10:59:42 +08:00
c63fce1ce8 chore(addon/aikefu): 优化最后关闭sse的处理,刷新输出 2025-12-10 18:13:00 +08:00
3cafaae451 chore(addon/aikefu): 更新测试流式对话的Demo 2025-12-10 17:32:24 +08:00
3275a159a1 chore(addon/aikefu): 完善API调用,只采用query 参数,支持 response_mode 参数设置 2025-12-10 17:20:25 +08:00
0b2092a8fc fix(addon/aikefu): 修复代码错误,缺少 think\facade\Db 2025-12-10 17:01:50 +08:00
4a53db1f4c chore(addon/aikefu): 支持后台管理消息列表针对流式消息的处理 2025-12-10 16:47:43 +08:00
34db5cd074 chore(db): 更新数据库升级脚本 2025-12-10 16:08:34 +08:00
9ba444dbe7 chore(addon/aikefu): 流式对话信息存储到数据库中 2025-12-10 16:01:14 +08:00
357a479571 fix(addon/aikefu): 修复EventSource有额外的空表数据输出,导致前端EventSource捕捉到error事件 2025-12-10 14:21:52 +08:00
7b6a8500b0 chore(addon/aikefu): 只允许info及error的日志输出 2025-12-10 11:46:20 +08:00
99628f1d18 chore(addon/aikefu): 修复stream_chat_demo.html FetchAPI 获得完整的回复 2025-12-10 10:31:38 +08:00
ed81982239 chore(addon/aikefu): 流式对话接口实现 2025-12-10 10:19:36 +08:00
fd7593c72b chore(src): log_write 函数支持设置调用栈深度 2025-12-10 10:17:54 +08:00
1366d974cf fix(db): 调整AI智能客服的菜单配置 2025-12-09 17:48:17 +08:00
62086f2332 chore(addon/aikefu): 去除chatStream,统一使用chat接口 2025-12-09 17:43:43 +08:00
15b8b5b039 chore(addon/aikefu): 简化AI客服使用流式对话,统一使用chat接口 2025-12-09 17:41:46 +08:00
a12598eda3 chore: 添加通用的判断事件是否被监听函数 2025-12-09 17:40:26 +08:00
7478e04472 chore(db): 更新DB升级脚本 2025-12-09 17:21:08 +08:00
ddd6966494 chore(addon/aikefu): 新增对话流式请求 2025-12-09 13:50:53 +08:00
37b3d62c74 chore(addon/aikefu): 新增 2025-12-09 13:46:33 +08:00
2f9e1fabd6 test(addon/aikefu): 测试会话管理及消息展示是否有分页功能 2025-12-09 10:47:04 +08:00
345e7393ae chore(addon/aikefu): 修复在点击分页,重新搜索后消息列表没有滚动的顶端的问题 2025-12-09 10:31:25 +08:00
d9d6b596fd test(addon/aikefu): 修复选择消息排序框,渲染错误 2025-12-09 10:26:27 +08:00
bcf0fa72e2 test(addon/aikefu): 修复选择消息排序框,渲染错误 2025-12-09 10:24:31 +08:00
65ec3dc740 chore(addon/aikefu): 修复选择消息排序框,渲染错误 2025-12-09 10:19:14 +08:00
717481dcba chore(addon/aikefu): 增加可以选择消息排序 2025-12-09 10:12:29 +08:00
cd37019675 chore(addon/aikefu): 增加静态图像资源,方便展示 2025-12-09 09:51:53 +08:00
dfa0a4f433 chore(addon/aikefu): 调整会话列表列宽度及消息列表的分页处理 2025-12-09 09:44:56 +08:00
96e00cf57c test(addon/aikefu): 测试会话管理及消息展示 2025-12-09 09:36:57 +08:00
ba17145705 test(addon/aikefu): 测试会话管理及消息展示 2025-12-09 09:34:57 +08:00
443f3c38fc test(addon/aikefu): 测试会话管理及消息展示 2025-12-09 09:31:01 +08:00
9c4ccbbd99 test(addon/aikefu): 测试会话管理及消息展示 2025-12-09 09:22:22 +08:00
8134622cfc test(addon/aikefu): 测试会话管理及消息展示 2025-12-09 09:09:33 +08:00
c1b5ef72eb test(addon/aikefu): 测试会话管理及消息展示 2025-12-09 08:58:36 +08:00
a2e4d962da test(addon/aikefu): 测试会话管理及消息展示 2025-12-09 08:51:58 +08:00
8369ff6c43 test(addon/aikefu): 测试会话管理及消息展示 2025-12-09 08:37:38 +08:00
a969a4cdf9 test(addon/aikefu): 测试会话管理及消息展示 2025-12-09 08:25:01 +08:00
1d453e8663 test(addon/aikefu): 测试会话管理及消息展示 2025-12-08 18:26:01 +08:00
f4868f8a79 chore(addon/aikefu): API的响应结构现在与Dify API的标准格式保持一致,提高了代码的一致性和可维护性 2025-12-08 18:14:41 +08:00
26e207c1ea chore(addon/aikefu): update 创建会话ID 2025-12-08 17:56:10 +08:00
d3f56b899e chore(addon/aikefu): 会话管理中,查看消息列表 2025-12-08 17:15:50 +08:00
6c5f163287 chore(addon/aikefu): 会话管理支持批量删除 2025-12-08 17:12:00 +08:00
4e1126bf45 chore: 添加开票php文件 2025-12-08 17:07:44 +08:00
842b3a51ff chore(vendor): 更新依赖 2025-12-08 17:05:22 +08:00
54ef5ccf3d chore(vendor): PHP依赖比较混乱,暂时采用NiuShop官方的依赖相互融合的版本 2025-12-08 16:01:39 +08:00
e0aeea15f2 revert(platform): 是ThinkPHP-Captcha 版本问题,造成获取验证码不稳定的因素,暂时还原 2025-12-08 14:10:41 +08:00
951836083e fix(vendor): 修复依赖升级导致的不兼容问题 2025-12-08 14:03:47 +08:00
0a9eadcdd0 fix(platform): 修复由于使用的Think-Captcha版本不同造成的验证码的问题 2025-12-08 13:45:14 +08:00
ea322c9727 fix(platform): 修复由于使用的Think-Captcha版本不同造成的验证码的问题 2025-12-08 12:01:24 +08:00
99ba1d812c chore(aikefu): 更新UI: 会话管理 2025-12-08 11:29:41 +08:00
58052b1271 chore(aikefu): 更新UI: 会话管理 2025-12-08 11:24:57 +08:00
510e8a07d2 chore(aikefu): 更新UI: 会话管理 2025-12-08 11:13:46 +08:00
7cc74f66b9 chore(aikefu): 更新UI: 会话管理 2025-12-08 11:09:17 +08:00
d3a86b2900 chore(aikefu): 更新UI: 会话管理 2025-12-08 11:05:01 +08:00
79e6f6ebd7 chore(aikefu): 更新UI: 会话管理 2025-12-08 10:44:35 +08:00
0f965c5a8e chore(aikefu): 更新UI: 会话管理 2025-12-08 10:38:13 +08:00
a1d400b134 chore(aikefu): 更新UI: 会话管理 2025-12-08 10:22:50 +08:00
cff7580880 chore(docker): 强制修复PHP容器内的权限 2025-12-08 10:04:07 +08:00
51fd354cbf chore(addon/aikefu): 获取简单的配置信息,包括:enabled,status 2025-12-08 09:15:56 +08:00
7eadf2df56 chore(addon/aikefu): 获取简单的配置信息,包括:enabled,status 2025-12-08 09:13:57 +08:00
cfd791f148 chore(src): 终端获得AI智能客服配置 2025-12-08 09:06:37 +08:00
e4bb99aa1f chore(docker): 测试解决MySQL数据库的字符集不支持存储Emoji表情符号(4字节UTF-8字符) 2025-12-08 08:53:50 +08:00
c4dc09b580 chore(addon/aikefu): 临时测试 2025-12-06 18:27:33 +08:00
f10b650a8f chore(addon/huaweipay): 商家公钥不作为异常处理 2025-12-06 18:06:05 +08:00
f7413acd3e chore(addon/aikefu): 更新配置UI 2025-12-06 17:24:44 +08:00
f42b4a1036 chore(addon/aikefu): 更新配置UI 2025-12-06 17:14:17 +08:00
7d3d71e0e3 chore(addon/aikefu): 更新文档说明及功能 2025-12-06 16:57:33 +08:00
6a44d27fd3 test(addon/aikefu): 还原原先的kefu.app 方便测试 2025-12-06 16:54:34 +08:00
a90a081973 test(addon/aikefu): 还原原先的kefu.app 方便测试 2025-12-06 16:52:44 +08:00
4736273902 test(addon/aikefu): 还原原先的kefu.app 方便测试 2025-12-06 16:45:44 +08:00
fba01f4909 test(addon/aikefu): 还原原先的kefu.app 方便测试 2025-12-06 16:44:37 +08:00
42aa934493 test(addon/aikefu): 还原原先的kefu.app 方便测试 2025-12-06 16:42:45 +08:00
fe73fdd5bd test(addon/aikefu): 还原原先的kefu.app 方便测试 2025-12-06 16:37:37 +08:00
c112f02fcc chore(addon/aikefu): 更新文档说明及功能 2025-12-06 16:32:29 +08:00
4d2467ae36 chore(addon/aikefu): 更新文档说明及功能 2025-12-06 16:24:55 +08:00
0b6e6914fd chore(addon/aikefu): 变更API暴漏的端点 2025-12-06 16:12:12 +08:00
0f76b61152 fix(addon/aikefu): 使用ThinkPHP Model来处理数据 2025-12-06 15:13:24 +08:00
6cfff15c62 fix(addon/aikefu): 使用curl来发送请求 2025-12-06 14:55:10 +08:00
6d3887ec06 fix(addon/aikefu): 使用uniacid方便以后迁移,唯一值 2025-12-06 14:47:26 +08:00
5af0b07775 fix(addon/aikefu): 支持外部传入site_id 等参数 2025-12-06 14:34:17 +08:00
8ae10dd2c3 fix(addon/aikefu): 获得当前配置有问题 2025-12-06 14:16:13 +08:00
17c1ce2cc6 chore(addon/aikefu): 更新控制器及更新事件 2025-12-06 14:10:27 +08:00
a209dc8080 chore(addon/aikefu): 更新控制器及更新事件 2025-12-06 14:06:12 +08:00
1d4fff13a1 chore(addon/aikefu): 增加统一的事件配置 2025-12-06 13:52:45 +08:00
a811e36635 chore(addon/aikefu): 调整事件名称 2025-12-06 13:41:37 +08:00
d8a0dd5d31 chore(addon/aikefu): 调整config的配置内容 2025-12-06 13:25:15 +08:00
0ff979917c chore(addon/aikefu): 调整config的配置内容 2025-12-06 12:52:30 +08:00
fc5615a9c7 chore(addon/aikefu): 更新获取配置及保存逻辑 2025-12-06 11:59:53 +08:00
cdcd9eeffa chore(addon/aikefu): update html 2025-12-06 11:51:16 +08:00
c0da89735c chore(db): 升级数据包含新增的智能客服插件sql脚本 2025-12-06 11:43:34 +08:00
fc34d83692 chore(addon/aikefu): 更新info 2025-12-06 10:15:40 +08:00
8ceb252d79 feat(addon/aikefu): 新增AI智能客服插件 2025-12-06 10:09:08 +08:00
8da4563435 chore(docker): update supervisord.conf 2025-12-06 09:30:42 +08:00
045e6ab3df chore(addon): 支付相关判断是否平台开通配置 2025-12-05 17:37:12 +08:00
5591e17446 chore(docker): fix supervisord.conf 2025-12-05 17:27:48 +08:00
8f783fd765 fix(docker): 针对已经存在的容器或后期新建的容器权限设置更新 2025-12-05 17:01:50 +08:00
6b41e46f30 chore(addon): weapp 插件与niushopV5部分代码同步比较 2025-12-05 16:04:46 +08:00
ff89fdf5e9 chore(addon): 新增wechat插件 2025-12-05 15:52:21 +08:00
402c425575 chore(src): 更新代码注释 2025-12-05 15:05:54 +08:00
ee2785f972 chore(addon/huawiepay): 增强huaweipay 的预下单及单元测试 2025-12-05 15:05:20 +08:00
bbb0271a5e chore(scripts): 更新生成私钥和公钥的脚本处理 2025-12-05 15:04:10 +08:00
75ff4bb0a4 chore(docker): 更新supervisord.conf 针对Docker容器重启后,设置权限 2025-12-05 15:03:38 +08:00
776f0ed029 fix(addon): alipay 及 wechatpay 没有判断支付类型 2025-12-04 11:19:14 +08:00
eef56291eb fix(addon/wechatpay): 修复微信支付回调错误 2025-12-04 11:05:08 +08:00
8e159edf1d chore(addon/huaweipay): mch_id -> merc_no 2025-12-04 11:00:24 +08:00
1793e4b2aa fix(pay): 修复PayNotify 没有正确的传回参数 2025-12-04 10:32:42 +08:00
a793ed541b chore(addon/huaweipay): 更新huaweipay 2025-12-04 10:32:00 +08:00
09ed1bd427 chore(vendor): 依赖 Vendor 升级测试 2025-12-04 08:39:14 +08:00
ff666975da chore(docker): 更新升级sql,把华为支付及线下支付添加到数据库中 2025-12-03 15:50:20 +08:00
b4403cedd9 chore(addon): 增强针对插件列表的处理 2025-12-03 15:45:08 +08:00
98d2eb8a2a chore(addon/huaweipay): 变更华为支付的测试方法内容 2025-12-03 15:34:19 +08:00
ae5f56c16f chore(event): 更新event 默认的初始化路由等操作 2025-12-03 15:33:33 +08:00
d374034694 chore(docker): 增加升级数据库脚本 2025-12-03 15:07:23 +08:00
b0f399c814 chore(docker): 暂时存储upgrade.sql 2025-12-03 14:51:21 +08:00
8489ef35cb chore(docker): 更新初始化脚本 2025-12-03 14:40:51 +08:00
7d8c2d4e37 chore(event): 使用 log_write 记录日志 2025-12-03 11:47:42 +08:00
ec60eee8fe chore(event): 更新InitAddon, 跟踪初始化事件监听 2025-12-03 11:31:55 +08:00
dac15250a2 chore(event): 更新InitAddon,要求手动添加了addon,也会更新缓存,添加到监听事件中 2025-12-03 11:12:13 +08:00
fec0198537 chore(docker): 更新数据库初始化脚本 2025-12-03 10:48:53 +08:00
f5ac4d10c0 chore(addon/huaweipay): 支持证书文本填写模式 2025-12-03 10:37:39 +08:00
ca07a6cea5 chore(doc): 添加华为支付插件说明文档 2025-12-03 09:42:54 +08:00
b5d89aef72 fix(docker): 只处理 runtime 及 upload目录 2025-12-03 09:02:24 +08:00
d151d45e99 fix(sms): 显示出验证码 2025-12-03 08:56:35 +08:00
3525af81bf chore(docker): update dockerfile 2025-12-03 08:52:06 +08:00
01c86ce0a3 chore(docker): 更新初始化脚本 2025-12-03 08:47:20 +08:00
e5e619a241 chore(docker): 更新PHP Dockerfile 2025-12-03 08:45:35 +08:00
d9b10c1621 chore(docker): update .gitignore 2025-12-03 08:34:11 +08:00
c6e72e5b79 chore(sms): 更新sms 2025-12-02 18:05:39 +08:00
980effc420 fix(docker): 去除不用的目录 2025-12-02 18:01:11 +08:00
e4040a27e7 chore(src): 还原src\app\model\upload\Upload.php 2025-12-02 17:31:37 +08:00
23170dcc3f fix(docker): 修复PHP进程使用的是Web服务用户,而没有相应权限创建目录的问题 2025-12-02 17:29:26 +08:00
64c92857a6 chore(src): 更新上传的判断路径的逻辑 2025-12-02 17:19:41 +08:00
4346bfebb7 chore(src): 使用绝对路径来判断权限 2025-12-02 17:18:00 +08:00
c24271c075 chore(src): 检查路径的权限 2025-12-02 17:15:02 +08:00
526982c431 chore(src): 更新上传错误,检查更全面 2025-12-02 17:12:05 +08:00
c161bc55e5 chore(src): 针对上传权限,提供更多有价值的信息 2025-12-02 17:07:35 +08:00
eb79ad260c chore(src): 所有代码上传 2025-12-02 15:36:42 +08:00
ce8e59902c chore(docker): 更新本地docker-compose.local.yml配置 2025-12-02 15:17:52 +08:00
b8ed400d12 chore(docker): 更新.gitignore 2025-12-02 15:08:54 +08:00
5f48980d31 chore(docker): 根据不同环境区分docker容器及网络,完全隔离 2025-12-02 15:00:06 +08:00
b81e7b8b1b chore(docker): 更新.gitignore文件 2025-12-02 14:01:16 +08:00
a468c0919d chore(docker): 更新.gitignore文件 2025-12-02 14:00:17 +08:00
1ae5b46523 chore(docker): 更新.env.development 2025-12-02 11:33:38 +08:00
ecac45a74a chore(docker): 更新数据库初始化脚本 2025-12-02 11:32:22 +08:00
51c4cb7767 chore(src): 将database.php 纳入代码管理中 2025-12-02 11:24:40 +08:00
ae7cfebb44 chore(docker): 更新docker配置 2025-12-02 11:10:00 +08:00
e4ccbbcbd1 chore(docker): 更新环境变量 2025-12-02 10:46:21 +08:00
bdfcd1cedb chore(docker): 更新docker的网络设置 2025-12-02 10:21:43 +08:00
6a7b465944 chore(docker): 更新supervisord.conf 2025-12-02 10:19:06 +08:00
41ac96630c chore(env): 更新.env.development文件配置 2025-12-02 10:04:24 +08:00
3a8fbc3e1b chore(docker): 更新nignx volumes 设置 2025-12-02 09:55:49 +08:00
e3e57ee154 chore(docker): 更新挂载点 2025-12-02 09:53:42 +08:00
0883c1318b chore(docker): 使用挂载点,确保目录能够正常被读写 2025-12-02 09:47:34 +08:00
fe79d04343 chore(docker): 更新docker-compose.yml 的volumes 使用主机目录持久化 2025-12-02 09:37:14 +08:00
2857283558 chore(docker): 为dev准备部署条件 2025-12-02 09:28:19 +08:00
981779126c chore(addon-huaweipay): 支持华为支付参数配置 2025-12-01 15:28:38 +08:00
39ce5882cb chore(scripts): 新增生成私有公有证书的nodejs脚本 2025-12-01 15:27:30 +08:00
e51f6c6544 chore(addon): 添加线下支付及华为支付基本配置 2025-12-01 11:36:51 +08:00
3a7f510e19 chore(db): 比较于niushop的差异 2025-11-29 17:51:17 +08:00
1c9e72e28d chore: 新增replace_comments.py 脚本,使用python replace_comments.py --path ./src来去掉版权注释及空注释 2025-11-29 16:45:45 +08:00
9d79b2585e chore(docs): 添加GIT分支管理及发布流程 2025-11-29 10:45:24 +08:00
1fc9a39ffe chore(docker): 指定容器内目录是可读可写的 2025-11-29 10:39:30 +08:00
4685 changed files with 735131 additions and 99646 deletions

3
.env
View File

@@ -7,8 +7,6 @@ APP_ENV=development
# PHP/PHP-FPM 配置
PHP_VERSION=7.4
PHP_FPM_VERSION=7.4-fpm
PHP_FPM_PORT=9100
XDEBUG_POST=9103
# 数据库配置
MYSQL_ROOT_HOST=%
@@ -23,5 +21,4 @@ REDIS_PORT=6399
# Nginx 配置
NGINX_PORT=8010
NGINX_SSL_PORT=8012

24
.env.development Normal file
View File

@@ -0,0 +1,24 @@
# 项目配置, 请根据实际情况修改
PROJECT_NAME=newshop
# ThinkPHP 6.x 配置, 请根据实际情况修改
APP_ENV=development
# PHP/PHP-FPM 配置
PHP_VERSION=7.4
PHP_FPM_VERSION=7.4-fpm
# 数据库配置
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

View File

@@ -1,24 +1,23 @@
# 项目配置, 请根据实际情况修改
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
# 数据库配置
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

24
.env.production Normal file
View File

@@ -0,0 +1,24 @@
# 项目配置, 请根据实际情况修改
PROJECT_NAME=newshop
# ThinkPHP 6.x 配置, 请根据实际情况修改
APP_ENV=production
# PHP/PHP-FPM 配置
PHP_VERSION=7.4
PHP_FPM_VERSION=7.4-fpm
# 数据库配置
MYSQL_ROOT_HOST=%
MYSQL_DATABASE=shop_mallnew
MYSQL_USER=shop_mallnew
MYSQL_PASSWORD=shop_mallnew
MYSQL_PORT=3926
# Redis 绑定端口及密码
REDIS_PASSWORD=luckyshop123!@#
REDIS_PORT=6829
# Nginx 暴漏端口
NGINX_PORT=8858

24
.env.staging Normal file
View File

@@ -0,0 +1,24 @@
# 项目配置, 请根据实际情况修改
PROJECT_NAME=newshop
# ThinkPHP 6.x 配置, 请根据实际情况修改
APP_ENV=staging
# PHP/PHP-FPM 配置
PHP_VERSION=7.4
PHP_FPM_VERSION=7.4-fpm
# 数据库配置
MYSQL_ROOT_HOST=%
MYSQL_DATABASE=shop_mallnew
MYSQL_USER=shop_mallnew
MYSQL_PASSWORD=shop_mallnew
MYSQL_PORT=3826
# Redis 绑定端口及密码
REDIS_PASSWORD=luckyshop123!@#
REDIS_PORT=6809
# Nginx 暴漏端口
NGINX_PORT=8854

24
.env.test Normal file
View File

@@ -0,0 +1,24 @@
# 项目配置, 请根据实际情况修改
PROJECT_NAME=newshop
# ThinkPHP 6.x 配置, 请根据实际情况修改
APP_ENV=test
# PHP/PHP-FPM 配置
PHP_VERSION=7.4
PHP_FPM_VERSION=7.4-fpm
# 数据库配置
MYSQL_ROOT_HOST=%
MYSQL_DATABASE=shop_mallnew
MYSQL_USER=shop_mallnew
MYSQL_PASSWORD=shop_mallnew
MYSQL_PORT=3346
# Redis 绑定端口及密码
REDIS_PASSWORD=luckyshop123!@#
REDIS_PORT=6799
# Nginx 暴漏端口
NGINX_PORT=8360

3
.gitignore vendored
View File

@@ -18,6 +18,9 @@ __pycache__
.idea
.vscode
# 环境变量
.env
# 源码结构
debug.txt
.travis.yml

152
README.md Normal file
View File

@@ -0,0 +1,152 @@
# 在线商城PHP项目
## Git 分支策略
| 环境 | 推荐分支 | 备选分支 | 说明 |
|------|----------|----------|------|
| local | `dev` | `develop` | 本地开发环境 |
| development | `dev` | `development` | 开发测试环境 |
| test | `test` | `staging` | 测试环境 |
| staging | `staging` | `pre-release` | 预发布环境 |
| production | `master` | `main` | 生产环境 |
**部署建议**
- 每个环境部署前请先切换到对应的Git分支
- 确保代码版本与目标环境匹配
- 生产环境部署前建议先在staging环境验证
## Docker 部署
```bash
cp .env.example .env.development
```
**注意**
- 在同一目录下面,执行 `docker-compose` 命令时,需要指定项目名称。用来区分不同的环境。如 `shop_local``shop_dev` 等。
- 本地部署时,需要将 `APP_ENV` 设置为 `local`
- 开发环境部署时,需要将 `APP_ENV` 设置为 `development`
## 环境变量
- `APP_ENV`: 应用环境,默认值为 `development`
## 开发环境-local 部署
**对应Git分支**: `main``develop`
```bash
# 切换到本地开发分支
git checkout main # 或 develop
# 本地部署时,需要将 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 部署
**对应Git分支**: `dev``development`
```bash
# 切换到开发分支
git checkout dev # 或 development
# 默认使用 docker-compose.yml 文件
# 清理所有未使用的构建缓存
docker builder prune -a -f
# 然后再执行无缓存构建和启动
docker-compose --project-name shop_development --env-file .env.development build --no-cache
docker-compose --project-name shop_development --env-file .env.development up -d
# docker-compose down 命令,用来停止并删除容器
docker-compose --project-name shop_development down -v
```
## 开发环境-test 部署 (测试环境)
**对应Git分支**: `test``staging`
```bash
# 切换到测试分支
git checkout test # 或 staging
# 默认使用 docker-compose.yml 文件
# 清理所有未使用的构建缓存
docker builder prune -a -f
# 然后再执行无缓存构建和启动
docker-compose --project-name shop_test --env-file .env.test build --no-cache
# 默认使用 docker-compose.yml 文件
docker-compose --project-name shop_test --env-file .env.test up -d
# docker-compose down 命令,用来停止并删除容器
docker-compose --project-name shop_test down -v
```
## 开发环境-staging 部署(预发布环境)
**对应Git分支**: `staging``pre-release`
```bash
# 切换到预发布分支
git checkout staging # 或 pre-release
# 默认使用 docker-compose.yml 文件
docker-compose --project-name shop_staging --env-file .env.staging up -d
# docker-compose down 命令,用来停止并删除容器
docker-compose --project-name shop_staging down -v
```
## 生产环境-production 部署
**对应Git分支**: `master``main``production`
```bash
# 切换到生产分支
git checkout main
# 确保代码是最新的生产版本
git pull origin main
# 默认使用 docker-compose.yml 文件
docker-compose --project-name shop_production --env-file .env.production up -d
# docker-compose down 命令,用来停止并删除容器
docker-compose --project-name shop_production down -v
```
## 便捷部署脚本
### 环境切换与部署脚本
- `deploy.sh` 脚本:
### 使用方法
```bash
# 赋予执行权限
chmod +x deploy.sh
# 部署到开发环境
./deploy.sh development
# 部署到测试环境
./deploy.sh test
# 部署到生产环境
./deploy.sh production
```

100
deploy.sh Normal file
View File

@@ -0,0 +1,100 @@
#!/bin/bash
# deploy.sh - 环境切换与部署脚本
# 使用方法: ./deploy.sh <environment>
# 示例: ./deploy.sh development
ENVIRONMENT=$1
PROJECT_NAME="shop_${ENVIRONMENT}"
BRANCH=""
COMPOSE_FILE="docker-compose.yml"
ENV_FILE=".env.${ENVIRONMENT}"
# 现实运行中的服务器网站,数据库备份目录
RUN_SERVER_WEB_ROOT="/data/wwwroot/shop-projects"
RUN_SERVER_DB_BACKUP_DIR="/data/backup/shop-projects"
case $ENVIRONMENT in
"local")
BRANCH="dev"
COMPOSE_FILE="docker-compose.local.yml"
ENV_FILE=".env.local"
;;
"development")
BRANCH="dev"
;;
"test")
BRANCH="test"
;;
"staging")
BRANCH="staging"
;;
"production")
BRANCH="main"
;;
*)
echo "错误: 不支持的环境 '$ENVIRONMENT'"
echo "支持的环境: local, development, test, staging, production"
exit 1
;;
esac
echo "=========================================="
echo "部署环境: $ENVIRONMENT"
echo "项目名称: $PROJECT_NAME"
echo "Git分支: $BRANCH"
echo "配置文件: $COMPOSE_FILE"
echo "环境文件: $ENV_FILE"
echo "=========================================="
# 切换分支
echo "切换到Git分支: $BRANCH"
git checkout $BRANCH
if [ $? -ne 0 ]; then
echo "错误: 无法切换到分支 $BRANCH"
exit 1
fi
# 拉取最新代码
echo "拉取最新代码..."
git pull origin $BRANCH
# 根据不同的环境,执行不同的操作
# 预发布环境、生产环境都需要使用运行服务上的用户文件,将用户文件复制到指定目录
# 根据不同的环境,执行不同的操作
# 预发布环境、生产环境都需要还原数据库,使用数据库备份文件,并尝试使用数据库升级
if [ "$ENVIRONMENT" = "local" ]; then
echo "本地环境,跳过数据库还原"
else
echo "还原数据库..."
# 还原数据库
docker-compose --project-name $PROJECT_NAME exec -T db bash -c "mysql -uroot -p$DB_ROOT_PASSWORD shop < $DB_BACKUP_DIR/shop.sql"
if [ $? -ne 0 ]; then
echo "错误: 数据库还原失败"
exit 1
fi
# 尝试使用数据库升级脚本
echo "尝试使用数据库升级..."
docker-compose --project-name $PROJECT_NAME exec -T db bash -c "php artisan migrate --force"
if [ $? -ne 0 ]; then
echo "数据库升级失败"
else
echo "数据库升级成功"
fi
fi
# 构建并启动容器
echo "构建并启动Docker容器..."
if [ "$ENVIRONMENT" = "local" ]; then
docker-compose --env-file $ENV_FILE -f $COMPOSE_FILE up -d
else
docker-compose --project-name $PROJECT_NAME --env-file $ENV_FILE up -d
fi
echo "部署完成!"
echo "查看容器状态: docker-compose --project-name $PROJECT_NAME ps"
echo "查看日志: docker-compose --project-name $PROJECT_NAME logs -f"

View File

@@ -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" # 支持主机名解析
@@ -25,23 +25,20 @@ services:
# 不然ThinkPHP 6.x 系列,会只加载 .env 文件,而不会加载 .env.local 文件,导致 .env.local 文件中的配置不会生效
APP_ENV: ${APP_ENV:-development}
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
# PHP应用根目录可选默认 /var/www/html
PHP_APP_ROOT: ${PHP_APP_ROOT:-/var/www/html}
# 用户ID映射可选用于解决挂载权限问题
USER_ID: ${USER_ID:-33}
GROUP_ID: ${GROUP_ID:-33}
volumes:
- ./:/var/www/all_source
- ./src:/var/www/html
- ./src:/var/www/html:rw
# 更新下载源列表以加速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'"]
test: ["CMD", "bash", "-c", "curl -f http://localhost:9000/status"]
interval: 30s
timeout: 10s
retries: 3
@@ -49,18 +46,18 @@ 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:
# 挂载项目代码到 Nginx 容器中
- ./src:/var/www/html:rw
# 更新下载源列表以加速apt-get
- ./docker/debian/sources.list:/etc/apt/sources.list:ro
@@ -72,11 +69,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 +90,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 +108,26 @@ 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:
xdebug_logs:
name: ${PROJECT_NAME}_${APP_ENV}_redis_data
driver: local
driver_opts:
type: none
o: bind
device: ./docker/redis_data/${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

View File

@@ -1,27 +1,45 @@
[mysqld]
# 字符集设置
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
# 连接设置
max_connections=100
wait_timeout=28800
interactive_timeout=28800
# 缓冲区设置
innodb_buffer_pool_size=256M
key_buffer_size=64M
# 日志设置
slow_query_log=1
slow_query_log_file=/var/lib/mysql/slow.log
long_query_time=2
# 其他设置
sql_mode=NO_ENGINE_SUBSTITUTION,STRICT_TRANS_TABLES
[client]
default-character-set=utf8mb4
[mysql]
default-character-set=utf8mb4
[mysqld]
# 字符集设置
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
init_connect='SET NAMES utf8mb4'
# 连接设置
max_connections=500
wait_timeout=300
interactive_timeout=300
max_connect_errors=1000
# 缓冲区设置
innodb_buffer_pool_size=12G
key_buffer_size=256M
sort_buffer_size=4M
read_buffer_size=4M
read_rnd_buffer_size=8M
join_buffer_size=4M
# InnoDB 优化
innodb_file_per_table=1
innodb_flush_method=O_DIRECT
innodb_flush_log_at_trx_commit=2
innodb_io_capacity=1000
innodb_io_capacity_max=2000
innodb_buffer_pool_instances=8
innodb_thread_concurrency=16
innodb_purge_threads=4
# 日志设置
slow_query_log=1
slow_query_log_file=/var/lib/mysql/slow.log
long_query_time=1
log_queries_not_using_indexes=1
log_slow_admin_statements=1
log_slow_slave_statements=1
# 其他设置
sql_mode=NO_ENGINE_SUBSTITUTION,STRICT_TRANS_TABLES
lower_case_table_names=1
[client]
default-character-set=utf8mb4

9
docker/mysql_db_data/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# 忽略所有文件
*
# 只保留指定的 .gitkeep 文件
!.gitignore
!development/.gitkeep
!test/.gitkeep
!production/.gitkeep
!staging/.gitkeep

View File

View File

@@ -1,29 +1,19 @@
FROM nginx:alpine
# 删除默认配置
RUN rm /etc/nginx/conf.d/default.conf
#
# - ./.docker/nginx/conf.c:/etc/nginx/conf.c:ro
# - ./.docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
# - ./.docker/nginx/sites-enabled:/etc/nginx/sites-enabled:ro
# 将本地 nginx 配置复制到镜像中并设置为只读
COPY ./conf.c /etc/nginx/conf.c
COPY ./default.conf /etc/nginx/conf.d/default.conf
COPY ./sites-enabled /etc/nginx/sites-enabled
# 设置只读权限(文件 0444目录及其内容 0555
RUN chmod 0444 /etc/nginx/conf.c \
&& chmod 0444 /etc/nginx/conf.d/default.conf \
&& chmod -R 0555 /etc/nginx/sites-enabled
# 设置工作目录
WORKDIR /var/www/html
# 创建日志目录
RUN mkdir -p /var/log/nginx
# 暴露端口
EXPOSE 80 443
FROM nginx:alpine
# 删除默认配置
RUN rm /etc/nginx/conf.d/default.conf
# 将本地 nginx 配置复制到镜像中
COPY ./conf.c/ /etc/nginx/conf.c/
COPY ./default.conf /etc/nginx/conf.d/default.conf
COPY ./sites-enabled/ /etc/nginx/sites-enabled/
# 暴露端口
EXPOSE 80 443
# 直接在Dockerfile中执行权限设置不使用entrypoint.sh
RUN mkdir -p /var/log/nginx && chmod -R 0444 /etc/nginx/conf.c && chmod 0444 /etc/nginx/conf.d/default.conf && chmod -R 0755 /etc/nginx/sites-enabled
# 启动nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,21 @@
location /ws {
proxy_pass http://php-fpm:8080; # 注意:这里用的是 Docker 服务名或容器名
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 禁用缓冲确保WebSocket数据实时传输
proxy_buffering off;
proxy_buffer_size 4k;
proxy_buffers 4 4k;
proxy_busy_buffers_size 4k;
proxy_max_temp_file_size 0;
# 可选设置超时WebSocket 是长连接)
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}

View File

@@ -0,0 +1,18 @@
#!/bin/bash
set -e
echo "=== NGINX Docker 权限初始化 ==="
# 设置权限
chmod -R 0444 /etc/nginx/conf.c
chmod 0444 /etc/nginx/conf.d/default.conf
chmod -R 0755 /etc/nginx/sites-enabled
# 创建日志目录
mkdir -p /var/log/nginx
echo "=== NGINX Docker 权限初始化完成 ==="
# 执行原有的启动命令
exec "$@"

View File

@@ -1,9 +1,11 @@
server {
listen 80;
# 作为默认站点接管所有 Host域名/IP
listen 80 default_server;
# listen 443 ssl http2; # Enable HTTP/2
server_name localhost;
# 匹配任意域名/IPHost 不限制)
server_name _ localhost 127.0.0.1;
root /var/www/html;
index index.php index.html index.htm default.php default.htm default.html;
@@ -24,6 +26,9 @@
include conf.c/enable-php-74.conf;
#PHP-INFO-END
# 启用 WebSocket 支持
include conf.c/enable-websocket.conf;
# --- REWRITE-START --- URL重写规则引用,修改后将导致面板设置的伪静态规则失效
# include /www/server/panel/vhost/rewrite/xcx30.5g-quickapp.com.conf; # 等于下面的内容
location / {
@@ -34,8 +39,19 @@
}
# --- REWRITE-END ---
# --- 子目录 hwapp 及 hwappx 的配置,请勿删除,支持子目录网站,刷新,重定位 ---
location ~ ^/hwapp/(.*)$ {
try_files $uri $uri/ /hwapp/index.html;
}
location ~ ^/hwappx/([^/]+)/(.*)$ {
try_files $uri $uri/ /hwappx/$1/index.html;
}
# --- 子目录 hwapp 及 hwappx 配置结束 ---
#禁止访问的文件或目录
location ~ ^/(\.user.ini|\.htaccess|\.git|\.svn|\.project|LICENSE|README.md)
location ~ ^/(\.user.ini|\.htaccess|\.git|\.svn|\.project|LICENSE|README.md)$
{
return 404;
}

View File

@@ -1,10 +0,0 @@
# 使用官方PHP镜像
FROM php:7.4.33-fpm-dev-newshop
# 设置工作目录
WORKDIR /var/www/html
# 暴露端口
EXPOSE 9000 9003
CMD ["php-fpm"]

View File

@@ -14,6 +14,7 @@ COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
RUN apt-get update && apt-get install -y \
supervisor \
git \
lsof \
curl \
vim \
libpng-dev \
@@ -27,8 +28,14 @@ RUN apt-get update && apt-get install -y \
libfreetype6-dev \
libjpeg62-turbo-dev \
libpng-dev \
iputils-ping \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# 安装 WebSocat 完成后,清理缓存
COPY ./websocat /usr/local/bin/websocat
RUN chmod +x /usr/local/bin/websocat
# 安装 PHP 扩展
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install \
@@ -45,9 +52,6 @@ RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
# 安装 Redis 扩展
RUN pecl install redis-5.3.7 && docker-php-ext-enable redis
# 安装 Xdebug兼容 PHP 7.4 的版本)
RUN pecl install xdebug-3.1.6 && docker-php-ext-enable xdebug
# 安装Composer
COPY --from=composer:2.2.25 /usr/bin/composer /usr/bin/composer
@@ -56,30 +60,15 @@ RUN composer --version
# 修改 PHP 配置
RUN echo "memory_limit=256M" > /usr/local/etc/php/conf.d/memory-limit.ini \
&& echo "upload_max_filesize=50M" >> /usr/local/etc/php/conf.d/uploads.ini \
&& echo "post_max_size=50M" >> /usr/local/etc/php/conf.d/uploads.ini
# 创建 Xdebug 配置
RUN echo "zend_extension=xdebug.so" > /usr/local/etc/php/conf.d/xdebug.ini
&& echo "upload_max_filesize=150M" >> /usr/local/etc/php/conf.d/uploads.ini \
&& echo "post_max_size=150M" >> /usr/local/etc/php/conf.d/uploads.ini
# # 使用Composer安装项目依赖可选根据需要启用, 更多的时候,会出错,要在容器中执行操作)
# 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
# 暴露端口9000 为 PHP-FPM 端口8080 为 WebSocket 端口
EXPOSE 9000 8080
############ 查看 cron 进程
## 查看 cron 进程
@@ -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"]

344
docker/php/entrypoint.sh Normal file
View File

@@ -0,0 +1,344 @@
#!/bin/bash
# 移除 set -e 以便更好的错误控制
echo "=== Web应用权限初始化 ==="
# 定义应用根目录,优先使用环境变量,否则使用默认值
APP_ROOT="${PHP_APP_ROOT:-/var/www/html}"
echo "使用应用根目录: $APP_ROOT"
# 如果应用根目录不存在,则跳过权限设置
if [ ! -d "$APP_ROOT" ]; then
echo "❌ 应用根目录:'$APP_ROOT'不存在,跳过权限设置"
exit 1
fi
# 创建统一的Web组并配置所有用户最高效的权限管理
configure_web_users() {
# 常见Web服务器用户列表
WEB_USERS=("www-data" "www" "apache" "nginx")
# 获取环境变量中的用户ID
TARGET_UID=${USER_ID:-33}
TARGET_GID=${GROUP_ID:-33}
echo "配置统一Web组权限目标UID:GID = $TARGET_UID:$TARGET_GID"
# 创建统一的Web组增强错误处理
WEB_GROUP="webaccess"
if ! getent group "$WEB_GROUP" &>/dev/null; then
echo "创建统一Web组: $WEB_GROUP"
# 尝试使用指定GID创建组
if groupadd -g $TARGET_GID "$WEB_GROUP" 2>/dev/null; then
echo "✅ 统一Web组创建成功GID: $TARGET_GID"
else
echo "⚠️ GID $TARGET_GID 已被占用尝试自动分配GID"
# 尝试不指定GID创建组
if groupadd "$WEB_GROUP" 2>/dev/null; then
ACTUAL_GID=$(getent group "$WEB_GROUP" | cut -d: -f3)
echo "✅ 统一Web组创建成功自动分配GID: $ACTUAL_GID"
else
echo "❌ 创建 $WEB_GROUP 组失败,尝试使用备用方案"
# 备用方案使用现有的www-data组
if getent group "www-data" &>/dev/null; then
WEB_GROUP="www-data"
echo "🔄 使用现有的www-data组作为统一组"
else
echo "❌ 备用方案也失败,权限配置可能不完整"
WEB_GROUP=""
fi
fi
fi
else
ACTUAL_GID=$(getent group "$WEB_GROUP" | cut -d: -f3)
echo "✅ 统一Web组 $WEB_GROUP 已存在GID: $ACTUAL_GID"
fi
# 最终验证组是否存在
if [ -z "$WEB_GROUP" ] || ! getent group "$WEB_GROUP" &>/dev/null; then
echo "❌ 无法创建或找到可用的Web组权限配置将受限"
return 1
fi
# 只将已存在的Web用户加入统一组增强错误处理
success_count=0
total_users=0
for web_user in "${WEB_USERS[@]}"; do
total_users=$((total_users + 1))
if id "$web_user" &>/dev/null; then
echo "📝 处理Web用户: $web_user"
# 获取用户当前组信息(安全的变量处理)
current_groups=$(id -Gn "$web_user" 2>/dev/null | tr '\n' ' ' | sed 's/ *$//')
echo " 当前所属组: ${current_groups:-}"
# 尝试将用户加入统一组(使用-a参数保留现有组只添加新组
if usermod -a -G "$WEB_GROUP" "$web_user" 2>/dev/null; then
echo " ✅ 成功将 $web_user 添加到统一组 $WEB_GROUP"
success_count=$((success_count + 1))
else
echo " ⚠️ 无法将 $web_user 添加到统一组,尝试设置主组"
# 备用方案:设置主组
if usermod -g "$WEB_GROUP" "$web_user" 2>/dev/null; then
echo " ✅ 成功将 $web_user 主组设置为 $WEB_GROUP"
success_count=$((success_count + 1))
else
echo " ❌ 无法配置 $web_user 的组权限"
fi
fi
else
echo "⚭ Web用户 $web_user 不存在,跳过"
fi
done
echo "📊 用户配置汇总: $success_count/$total_users 个Web用户配置成功"
# 至少要有一个用户配置成功
if [ $success_count -eq 0 ]; then
echo "⚠️ 没有Web用户被成功配置但继续执行"
fi
echo "统一Web组配置完成"
}
# 错误处理:如果配置失败,不要终止整个脚本
configure_web_users || echo "⚠️ Web用户配置出现问题但继续执行权限设置"
echo "当前用户: $(whoami)"
echo "UID: $(id -u), GID: $(id -g)"
# 修复所有目录权限使用统一Web组最高效的权限管理
if [ -d "$APP_ROOT" ]; then
# 重新获取最终的WEB_GROUP可能已被修改
FINAL_WEB_GROUP=""
# 首选使用创建的webaccess组
if getent group "webaccess" &>/dev/null; then
FINAL_WEB_GROUP="webaccess"
echo "🎯 使用创建的统一Web组: $FINAL_WEB_GROUP"
# 备选使用www-data组
elif getent group "www-data" &>/dev/null; then
FINAL_WEB_GROUP="www-data"
echo "🔄 回退到www-data组: $FINAL_WEB_GROUP"
# 最后:使用当前用户的组
else
CURRENT_USER=$(whoami)
CURRENT_GROUP=$(id -gn "$CURRENT_USER")
FINAL_WEB_GROUP="$CURRENT_GROUP"
echo "🔧 使用当前用户组: $FINAL_WEB_GROUP"
fi
# 最终验证
if [ -z "$FINAL_WEB_GROUP" ]; then
echo "❌ 无法确定有效的Web组跳过权限设置"
echo "=== 启动应用 ==="
exec "$@"
fi
WEB_GROUP="$FINAL_WEB_GROUP"
WEB_GROUP_GID=$(getent group "$WEB_GROUP" | cut -d: -f3)
echo "✅ 最终使用Web组: $WEB_GROUP (GID: $WEB_GROUP_GID)"
echo "🔒 统一组权限模式所有Web用户通过组继承权限"
# 设置所有权为统一Web组增强错误处理
echo "📁 设置应用目录所有权为统一Web组"
CURRENT_USER=$(whoami)
if chown -R $CURRENT_USER:$WEB_GROUP "$APP_ROOT" 2>/dev/null; then
echo "✅ 所有权设置成功: $CURRENT_USER:$WEB_GROUP"
else
echo "⚠️ 所有权设置失败,尝试只设置组权限"
chgrp -R "$WEB_GROUP" "$APP_ROOT" 2>/dev/null || echo "❌ 组权限设置也失败"
fi
# 设置目录权限为775组权限为rwx所有组内用户都有完整权限
echo "🔐 设置目录权限775文件权限664"
# 使用更安全的权限设置方式,避免权限被拒绝
dir_count=0
file_count=0
# 设置目录权限(兼容性更好的方式)
if command -v find >/dev/null 2>&1; then
dir_count=$(find "$APP_ROOT" -type d -exec chmod 775 {} \; 2>/dev/null | wc -l)
file_count=$(find "$APP_ROOT" -type f -exec chmod 664 {} \; 2>/dev/null | wc -l)
find "$APP_ROOT" -type d -exec chmod g+s {} \; 2>/dev/null
else
# 备用方案:使用简单的循环
echo "find命令不可用跳过批量权限设置"
dir_count=0
file_count=0
fi
echo "📊 权限设置完成: $dir_count个目录, $file_count个文件"
echo "✅ 统一组权限设置完成所有Web用户通过组获得权限"
# 设置ACL如果支持只需设置统一组
if command -v setfacl >/dev/null 2>&1; then
echo "🔒 设置ACL权限只需设置统一Web组"
acl_success=0
# 只为统一Web组设置ACL权限限制处理深度
if setfacl -R -m g:$WEB_GROUP:rwx "$APP_ROOT" 2>/dev/null; then
echo " ✅ 设置组ACL权限成功"
acl_success=$((acl_success + 1))
else
echo " ❌ 设置组ACL权限失败"
fi
# 设置默认ACL权限新创建的文件自动继承权限
if setfacl -dR -m g:$WEB_GROUP:rwx "$APP_ROOT" 2>/dev/null; then
echo " ✅ 设置默认ACL权限成功"
acl_success=$((acl_success + 1))
else
echo " ❌ 设置默认ACL权限失败"
fi
if [ $acl_success -eq 2 ]; then
echo "🎉 统一组ACL设置完成所有组内用户自动获得权限"
elif [ $acl_success -eq 1 ]; then
echo "⚠️ ACL部分设置成功建议检查文件系统ACL支持"
else
echo "❌ ACL设置完全失败文件系统可能不支持ACL"
fi
else
echo " ACL不支持依赖传统权限模式"
echo "✅ 775权限已足够所有组内用户都有rwx权限"
fi
# 设置umask
umask 0002
echo "✅ 应用目录权限修复完成"
# 验证文件权限是否足够(测试统一组权限效果)
echo "=== 验证统一组权限效果 ==="
# 查找测试文件的更可靠方法
test_file=""
# 方法1: 查找index.html
if [ -f "$APP_ROOT/index.html" ]; then
test_file="$APP_ROOT/index.html"
fi
# 方法2: 查找任意HTML文件更安全的方式
if [ -z "$test_file" ]; then
first_html=$(find "$APP_ROOT" -maxdepth 2 -name "*.html" -type f 2>/dev/null | head -1)
if [ -n "$first_html" ] && [ -f "$first_html" ]; then
test_file="$first_html"
fi
fi
# 方法3: 查找index.php
if [ -z "$test_file" ] && [ -f "$APP_ROOT/index.php" ]; then
test_file="$APP_ROOT/index.php"
fi
# 方法4: 创建专用测试文件(最安全的选择)
if [ -z "$test_file" ]; then
test_file="$APP_ROOT/.permission_test.html"
echo "创建专用权限测试文件"
cat > "$test_file" << 'EOF'
<!DOCTYPE html>
<html>
<head><title>Permission Test</title></head>
<body><h1>Web Server Permission Test File</h1></body>
</html>
EOF
# 设置正确的权限
chown $(whoami):"$WEB_GROUP" "$test_file" 2>/dev/null || true
chmod 664 "$test_file"
fi
# 执行权限测试
if [ -f "$test_file" ]; then
echo "使用测试文件: $test_file"
echo "文件权限: $(stat -c '%a %n' "$test_file")"
echo "文件所有者: $(stat -c '%U:%G' "$test_file")"
# 测试所有Web用户的权限通过组权限
for test_user in "www-data" "www" "apache" "nginx"; do
if id "$test_user" &>/dev/null; then
echo "🔍 测试 $test_user 用户权限(通过组权限):"
# 显示用户组信息(安全的变量处理)
user_groups=$(id -Gn "$test_user" 2>/dev/null | tr '\n' ' ' | sed 's/ *$//')
echo " 📋 所属组: ${user_groups:-}"
# 测试读权限(安全:只读不修改)
if su -s /bin/sh -c "cat '$test_file' >/dev/null 2>&1" "$test_user" 2>/dev/null; then
echo " ✅ 读权限: 通过组权限可读"
else
echo " ❌ 读权限: 不可读"
fi
# 测试写权限(使用临时文件,避免污染原文件)
temp_test_file="${test_file}.write_test_${test_user}"
if su -s /bin/sh -c "echo 'permission_test' > '$temp_test_file' 2>/dev/null" "$test_user" 2>/dev/null; then
echo " ✅ 写权限: 通过组权限可写"
rm -f "$temp_test_file" 2>/dev/null
else
echo " ❌ 写权限: 不可写"
fi
# 测试目录创建权限(使用临时目录)
temp_test_dir="${APP_ROOT}/.perm_test_${test_user}"
if su -s /bin/sh -c "mkdir -p '$temp_test_dir' 2>/dev/null" "$test_user" 2>/dev/null; then
echo " ✅ 创建目录: 通过组权限可创建"
rm -rf "$temp_test_dir" 2>/dev/null
else
echo " ❌ 创建目录: 不可创建"
fi
echo " 🔗 权限来源: 统一Web组 ($WEB_GROUP) 775权限"
break # 只测试第一个可用的用户即可验证效果
fi
done
# 清理专用测试文件(如果是创建的)
if echo "$test_file" | grep -q "\.permission_test\.html$"; then
rm -f "$test_file" 2>/dev/null
echo "🧹 已清理临时测试文件"
fi
else
echo "❌ 无法找到或创建测试文件,跳过权限验证"
fi
# 显示统一组和用户状态
echo "=== 统一Web组状态检查 ==="
if getent group "$WEB_GROUP" &>/dev/null; then
echo "✅ 统一Web组 '$WEB_GROUP' 存在"
echo "组信息: $(getent group "$WEB_GROUP" 2>/dev/null || echo '获取失败')"
# 检查哪些用户在统一组中
echo "统一组成员检查:"
for web_user in "www-data" "www" "apache" "nginx"; do
if id "$web_user" &>/dev/null; then
if id -Gn "$web_user" | grep -q "$WEB_GROUP"; then
echo "$web_user 在统一组 '$WEB_GROUP' 中"
else
echo "$web_user 不在统一组 '$WEB_GROUP' 中"
fi
else
echo "$web_user 用户不存在"
fi
done
else
echo "❌ 统一Web组 '$WEB_GROUP' 不存在"
fi
fi
echo "=== 启动应用 ==="
# 执行原有的启动命令
exec "$@"

View File

@@ -12,19 +12,42 @@ 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
environment=APP_ENV=local
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
[program:websocket-server]
command=/bin/bash -c "if [ -f /var/www/html/ws_server.php ]; then php ./ws_server.php; else echo 'ws_server.php not found, skipping websocket server'; fi"
workdir=/var/www/html
autostart=true
autorestart=false
startretries=0
stdout_logfile=/var/log/supervisor/websocket-server.log
stdout_logfile_maxbytes=10MB
stdout_logfile_backups=10
stderr_logfile=/var/log/supervisor/websocket-server-error.log
stderr_logfile_maxbytes=10MB
stderr_logfile_backups=10
startsecs=3
stopwaitsecs=10

BIN
docker/php/websocat Normal file

Binary file not shown.

9
docker/redis_data/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# 忽略所有文件
*
# 只保留指定的 .gitkeep 文件
!.gitignore
!development/.gitkeep
!test/.gitkeep
!production/.gitkeep
!staging/.gitkeep

View File

10
docker/xdebug_logs/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# 忽略目录下所有文件和子目录
*
# 忽略所有子目录
*/
# 但不忽略 .gitkeep 文件
!.gitkeep
# 不忽略 .gitignore 文件自身
!.gitignore
# 不忽略 development/.gitkeep 文件
!development/.gitkeep

View File

128
docs/GIT_REALEASE.md Normal file
View 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 成熟度),我可以给出更定制化的建议!

View File

@@ -0,0 +1,26 @@
# 华为支付插件
## Demo
```
安卓快应用ID115644647
安卓快应用包名com.jieganfsj.fivegshop
安卓快应用名称:秸秆粉碎机
商户名称:徐州明文机械有限公司
商户号102751500028
开发者ID10086000901972225
支付ID10086000901972225
公钥:
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=
```
支付密钥用于交易过程中的签名认证,请妥善保管,谨防泄露,签名认证规范参考开发指南服务端开发。

660
docs/api_kefu.md Normal file
View File

@@ -0,0 +1,660 @@
# 智能客服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
**请求方式**POST
**请求参数**
| 参数名 | 类型 | 必填 | 说明 |
| ------ | ---- | ---- | ---- |
| uniacid | int | 是 | 站点ID |
| check_type | string | 否 | 检查类型full完整、basic基础、ai_serviceAI服务默认full |
**响应示例**
```json
{
"code": 0,
"message": "success",
"data": {
"status": "healthy",
"check_id": "health_56789",
"timestamp": "2023-12-25 10:30:45",
"total_checks": 2,
"passed_checks": 2,
"failed_checks": 0,
"response_time_ms": 170,
"components": {
"ai_service": {
"status": "healthy",
"message": "AI服务正常",
"response_time_ms": 150
},
"database": {
"status": "healthy",
"message": "数据库连接正常",
"response_time_ms": 20
}
},
"warnings": [],
"errors": []
}
}
```
### 2. 获取服务配置信息
**接口地址**/api/kefu/info
**请求方式**POST
**请求参数**
| 参数名 | 类型 | 必填 | 说明 |
| ------ | ---- | ---- | ---- |
| uniacid | int | 是 | 站点ID |
| member_id | int | 否 | 会员ID |
| token | string | 否 | 访问令牌 |
**响应示例**
```json
{
"code": 0,
"message": "success",
"data": {
"enabled": true,
"status": "enabled"
}
}
```
### 3. 智能客服聊天接口
**接口地址**`/api/kefu/chat`
**请求方式**POST 或 GET流式模式支持EventSource
**请求参数**
| 参数名 | 类型 | 必填 | 说明 |
| ------ | ---- | ---- | ---- |
| query | string | 是 | 用户输入的消息内容 |
| user_id | string | 否 | 用户ID默认使用当前登录会员ID |
| conversation_id | string | 否 | 会话ID第一次聊天可不传系统会自动创建 |
| stream | bool | 否 | 是否使用流式响应默认false |
| response_mode | string | 否 | 响应模式streaming流式、blocking阻塞默认streaming |
| uniacid | int | 是 | 站点ID |
**响应示例**
#### 非流式响应stream=false 或 response_mode=blocking
```json
{
"code": 0,
"message": "success",
"data": {
"conversation_id": "conv_123456789",
"answer": "您好,我是智能客服,有什么可以帮助您的?",
"message_id": "msg_123456789",
"finish_reason": "stop",
"usage": {
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30
}
}
}
```
#### 流式响应stream=true 或 response_mode=streaming
**响应格式**Server-Sent Events (SSE)
**响应示例**
```javascript
data: {"event":"message","answer":"您好","conversation_id":"conv_123456789","message_id":"msg_123456789"}
data: {"event":"message","answer":",我是智能客服,","conversation_id":"conv_123456789","message_id":"msg_123456789"}
data: {"event":"message","answer":"有什么可以帮助您的?","conversation_id":"conv_123456789","message_id":"msg_123456789"}
data: {"event":"message_end","conversation_id":"conv_123456789","message_id":"msg_123456789"}
data: {"event":"done","data":{"conversation_id":"conv_123456789","message_id":"msg_123456789","content":"您好,我是智能客服,有什么可以帮助您的?"}}
data: {"event":"close","data":{"conversation_id":"conv_123456789","message_id":"msg_123456789"}}
```
### 4. 获取会话历史
**接口地址**/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": "msg_123456789",
"role": "user",
"content": "您好",
"create_time": 1703505845
},
{
"id": "msg_123456790",
"role": "assistant",
"content": "您好,我是智能客服,有什么可以帮助您的?",
"create_time": 1703505846
}
],
"total": 2,
"page_info": {
"limit": 20,
"offset": 0
}
}
}
```
### 5. 清除会话历史
**接口地址**`/api/kefu/clearConversation`
**请求方式**POST
**请求参数**
| 参数名 | 类型 | 必填 | 说明 |
| ------ | ---- | ---- | ---- |
| uniacid | int | 是 | 站点ID |
| conversation_id | string | 否与user_id二选一 | 会话ID与user_id二选一 |
| user_id | string | 否与conversation_id二选一 | 用户ID默认使用当前登录会员ID与conversation_id二选一 |
| member_id | int | 否 | 会员ID |
| token | string | 否 | 访问令牌 |
**响应示例**
```json
{
"code": 0,
"message": "success",
"data": {}
}
```
## 四、前端调用示例
### 1. 非流式聊天Fetch API
```javascript
// 非流式聊天
async function chatWithAI(message, conversationId = '') {
try {
const formData = new FormData();
formData.append('query', message);
formData.append('uniacid', '1');
formData.append('stream', 'false');
formData.append('response_mode', 'blocking');
if (conversationId) {
formData.append('conversation_id', conversationId);
}
const response = await fetch('/api/kefu/chat', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.code === 0) {
return result.data;
} else {
console.error('聊天失败:', result.message);
return null;
}
} catch (error) {
console.error('聊天请求失败:', error);
return null;
}
}
```
### 2. 流式聊天EventSource
```javascript
// EventSource 流式聊天
function chatWithAIEventSource(message, conversationId = '', onMessage, onComplete, onError) {
// 关闭之前的连接
if (window.currentEventSource) {
window.currentEventSource.close();
}
// 构建请求参数
const params = new URLSearchParams({
uniacid: '1',
user_id: '123456',
query: message,
conversation_id: conversationId || '',
stream: 'true'
});
const url = `/api/kefu/chat?${params.toString()}`;
try {
const eventSource = new EventSource(url);
window.currentEventSource = eventSource;
let aiMessage = '';
// 监听消息事件
eventSource.addEventListener('message', (event) => {
try {
const data = JSON.parse(event.data);
if (data.event === 'message') {
// 更新 AI 消息
aiMessage += data.answer || '';
if (onMessage) onMessage(data.answer || '');
}
if (data.event === 'message_end') {
// 对话完成
if (onComplete) onComplete({
conversation_id: data.conversation_id,
message: aiMessage
});
}
if (data.conversation_id) {
conversationId = data.conversation_id;
}
} catch (error) {
console.error('解析消息失败:', error);
}
});
// 监听完成事件
eventSource.addEventListener('done', (event) => {
try {
const data = JSON.parse(event.data);
if (onComplete) onComplete(data);
} catch (error) {
console.error('解析完成事件失败:', error);
}
});
// 监听关闭事件
eventSource.addEventListener('close', (event) => {
try {
const data = JSON.parse(event.data);
console.log('连接正常结束:', data);
} catch (error) {
console.error('解析关闭事件失败:', error);
}
window.currentEventSource = null;
});
// 监听错误事件
eventSource.addEventListener('error', (error) => {
console.error('EventSource错误:', error);
if (onError) onError({ error: 'EventSource连接错误' });
window.currentEventSource = null;
});
return eventSource;
} catch (error) {
console.error('创建EventSource失败:', error);
if (onError) onError({ error: error.message });
return null;
}
}
```
### 3. 流式聊天Fetch API
```javascript
// Fetch API 流式聊天
async function chatWithAIFetchStream(message, conversationId = '', onMessage, onComplete, onError) {
try {
// 构建请求体
const formData = new FormData();
formData.append('uniacid', '1');
formData.append('user_id', '123456');
formData.append('query', message);
formData.append('conversation_id', conversationId || '');
formData.append('stream', 'true');
const response = await fetch('/api/kefu/chat', {
method: 'POST',
body: formData,
headers: {
'Accept': 'text/event-stream'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error('响应体不可用');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let aiMessage = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解码新接收的数据
buffer += decoder.decode(value, { stream: true });
// 处理缓冲的数据,按行分割
let lineEnd;
while ((lineEnd = buffer.indexOf('\n')) !== -1) {
const line = buffer.substring(0, lineEnd);
buffer = buffer.substring(lineEnd + 1);
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.substring(6));
if (data.event === 'message') {
aiMessage += data.answer || '';
if (onMessage) onMessage(data.answer || '');
} else if (data.event === 'message_end') {
if (onComplete) onComplete({
conversation_id: data.conversation_id,
message: aiMessage
});
} else if (data.event === 'done' && onComplete) {
onComplete(data);
} else if (data.event === 'error' && onError) {
onError(data);
}
if (data.conversation_id) {
conversationId = data.conversation_id;
}
} catch (e) {
console.warn('解析流式数据失败:', e);
}
}
}
}
// 处理剩余的缓冲数据
if (buffer.startsWith('data: ')) {
try {
const data = JSON.parse(buffer.substring(6));
if (data.event === 'message' && onMessage) {
onMessage(data.answer || '');
} else if (data.event === 'done' && onComplete) {
onComplete(data);
}
} catch (e) {
console.warn('解析剩余数据失败:', e);
}
}
} catch (error) {
console.error('Fetch流式聊天请求失败:', error);
if (onError) onError({ error: error.message });
}
}
```
### 4. Uniapp调用示例
```javascript
// Uniapp 非流式调用
async function chatWithAI(message, conversationId = '') {
try {
const res = await uni.request({
url: '/api/kefu/chat',
method: 'POST',
data: {
query: message,
uniacid: 1,
conversation_id: conversationId,
response_mode: 'blocking'
}
});
if (res[1].data.code === 0) {
return res[1].data.data;
} else {
console.error('聊天失败:', res[1].data.message);
return null;
}
} catch (error) {
console.error('聊天请求失败:', error);
return null;
}
}
// Uniapp 获取历史记录
async function getChatHistory(conversationId, limit = 20, offset = 0) {
try {
const res = await uni.request({
url: '/api/kefu/getHistory',
method: 'POST',
data: {
uniacid: 1,
conversation_id: conversationId,
limit: limit,
offset: offset
}
});
if (res[1].data.code === 0) {
return res[1].data.data;
} else {
console.error('获取历史记录失败:', res[1].data.message);
return null;
}
} catch (error) {
console.error('获取历史记录请求失败:', error);
return null;
}
}
// Uniapp 健康检查
async function checkHealth(checkType = 'full') {
try {
const res = await uni.request({
url: '/api/kefu/health',
method: 'POST',
data: {
uniacid: 1,
check_type: checkType
}
});
if (res[1].data.code === 0) {
return res[1].data.data;
} else {
console.error('健康检查失败:', res[1].data.message);
return null;
}
} catch (error) {
console.error('健康检查请求失败:', error);
return null;
}
}
```
## 五、使用流程
1. **初始化检查**:小程序端启动时,调用`health``info`接口检查服务状态
2. **获取会话**:进入客服页面时,系统会自动创建或使用已有会话
3. **发送消息**:用户输入消息后,调用`chat`接口发送消息,获取机器人回复
4. **显示消息**:将用户消息和机器人回复显示在聊天界面
5. **加载历史记录**:需要时调用`getHistory`接口加载历史消息
6. **维护会话**保持会话ID用于后续消息交流
7. **清理数据**:根据用户需求调用`clearConversation`接口清理历史数据
## 六、数据存储机制
### 1. 存储状态
| 状态值 | 含义 | 说明 |
|--------|------|------|
| `streaming` | 流式中 | 正在进行流式输出的临时数据 |
| `completed` | 已完成 | 正常完成的对话数据 |
| `failed` | 失败 | 流式过程中发生失败的数据 |
### 2. 事务保护
- **流式对话**使用临时会话ID机制失败时自动回滚
- **非流式对话**:完整的事务保护,确保数据一致性
- **重复检查**:避免重复存储相同消息
### 3. 数据一致性
- 用户消息和助手消息通过`conversation_id`关联
- 会话状态实时更新,便于管理和监控
- 详细的日志记录,便于问题排查
## 七、注意事项
1. **必填参数**:所有接口都需要`uniacid`站点ID参数
2. **参数更新**:新版本使用`query`替代`message`,使用`uniacid`替代`site_id`
3. **事件驱动**:后端采用事件驱动架构,所有业务逻辑通过事件处理器执行
4. **安全性**请确保Dify API密钥的安全性不要泄露给前端
5. **用户标识**建议对用户ID进行加密处理避免直接使用敏感信息
6. **流式体验**:推荐使用`stream: true`参数获得更好的用户体验
7. **会话管理**:建议实现会话管理机制,定期清理过期会话
8. **频率限制**:建议添加请求频率限制,防止恶意请求
9. **生产环境**在生产环境中建议关闭DEBUG模式
10. **数据完整性**:系统已内置事务保护和重复检查机制
## 八、测试建议
1. **基础检查**:首先调用`health`接口检查系统状态
2. **配置验证**:调用`info`接口验证配置信息
3. **接口测试**使用Postman或类似工具测试各个API接口
4. **流式测试**:测试`chat`接口的流式响应功能
5. **完整流程**:在小程序端集成并测试完整流程
6. **边界测试**:模拟不同场景下的用户输入,测试机器人回复效果
7. **压力测试**:测试接口在高并发情况下的表现
8. **数据验证**:检查非流式对话的存储完整性和一致性
## 九、常见问题
### 1. 接口返回400错误
**原因**:缺少必填参数`uniacid`或参数格式错误
**解决方法**确保请求中包含有效的站点ID参数名已更新为`uniacid`
### 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
### 8. 参数不匹配
**原因**:使用了旧版本的参数名称
**解决方法**:更新参数:`message``query``site_id``uniacid`
## 十、性能优化建议
1. **缓存配置**:可对`info`接口返回的配置信息进行客户端缓存
2. **连接复用**HTTP请求使用连接池减少建立连接的开销
3. **压缩传输**启用gzip压缩减少传输数据量
4. **分页加载**:历史记录使用分页加载,避免一次性加载大量数据
5. **CDN加速**静态资源使用CDN加速访问
6. **监控告警**:建立接口性能监控和告警机制
7. **数据清理**:定期清理过期和失败状态的垃圾数据
8. **索引优化**:为常用查询字段添加数据库索引
---
**文档更新时间**2025-12-10
**版本**v2.1
**兼容性**:向后兼容,推荐使用标准的`uniacid`参数

View 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()

File diff suppressed because it is too large Load Diff

11578
docs/db/niushop_database.sql Normal file

File diff suppressed because it is too large Load Diff

View 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()

View File

@@ -24,4 +24,44 @@ create table if not exists lucky_diy_view_util
constraint name
unique (name)
)
```
```
## 页面设计及组件展示
- src\app\model\web\DiyView.php
- src\app\shop\view\diy\edit.html
- src\public\static\ext\diyview\js\components.js
## 如何添加新组件
### 1. 添加组件到数据表中
```sql
insert into lucky_diy_view_util (name, title, type, value, addon_name, sort, support_diy_view, max_count, is_delete, icon, icon_type)
values ('test', '测试', 'SYSTEM', '{"test": "test"}', '', 0, '', 0, 0, '', 0);
--- 微信视频号组件
-- 仅当WechatChannel不存在时添加记录
INSERT INTO lucky_diy_view_util (`name`, `title`, `type`, `value`, `addon_name`, `sort`, `support_diy_view`, `max_count`, `is_delete`, `icon`, `icon_type`)
SELECT 'WechatChannel', '微信视频号', 'SYSTEM', '{ "list": [{ "channelName":"", "finderUserName": "", "avatarImageType": "url", "avatarUrl": "", "videoTitle": "", "coverImageType": "url", "coverUrl": "", "feedId": "", "feedToken": "", "viewCount": 0, "showViewCount": true, "embedMode": false, "channelType":"wechat" }], "rowCount": 2, "showStyle": "fixed", "aspectRatio":"16:9", "titleLineClamp": 1, "showPlayBtn": true}', '', 100110, '', 0, 0, '/public/static/img/svg/xuanxiangka.svg', 0
WHERE NOT EXISTS (
SELECT 1 FROM lucky_diy_view_util WHERE name = 'WechatChannel'
);
```
### 2. 建立组件的控制器
`src\app\component\controller` 目录下创建对应的控制器文件,处理组件的业务逻辑。
例如:创建 `src\app\component\controller\TestController.php` 文件,用于处理测试组件的业务逻辑。
### 3. 建立组件的视图
在 src\app\component\view 目录下创建对应的视图文件,处理组件的前端展示。
例如:创建 `src\app\component\view\test.php` 文件,用于展示测试组件。
### 4. 在前端页面中使用组件
在前端页面中使用组件,需要在页面中添加对应的组件标签。

View File

@@ -0,0 +1,178 @@
proxy_cache_path /www/dk_project/sites/dev.aigc-quickapp.com/proxy_cache_dir levels=1:2 keys_zone=dev_aigc-quickapp_com_cache:20m inactive=1d max_size=5g;
# 连接升级变量,避免 $connection_upgrade 未定义(用于 WebSocket
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# 轻量限速/并发控制(全局定义,按需调整阈值)
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=perip_conn:10m;
# HTTP -> HTTPS 跳转独立 server避免与业务混配
server {
listen 80;
server_name dev.aigc-quickapp.com;
return 301 https://$host$request_uri;
}
server {
listen 443 quic;
listen 443 ssl;
http2 on;
server_name dev.aigc-quickapp.com;
index index.php index.html index.htm default.php default.htm default.html;
root /www/dk_project/wwwroot/dev.aigc-quickapp.com;
#CERT-APPLY-CHECK--START
# 用于SSL证书申请时的文件验证相关配置 -- 请勿删除
include /www/server/panel/vhost/nginx/well-known/dev.aigc-quickapp.com.conf;
#CERT-APPLY-CHECK--END
#SSL-START SSL相关配置请勿删除或修改下一行带注释的404规则
#error_page 404/404.html;
ssl_certificate /www/server/panel/vhost/cert/dev.aigc-quickapp.com/fullchain.pem;
ssl_certificate_key /www/server/panel/vhost/cert/dev.aigc-quickapp.com/privkey.pem;
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3; # 暂保留 TLSv1.1 兼容老端,后续可下线
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always;
# CSP 先以 Report-Only 方式上线,避免误杀;按前端依赖逐步收紧
add_header Content-Security-Policy-Report-Only "default-src 'self' data: blob:; img-src * data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' https: blob:; style-src 'self' 'unsafe-inline' https:; connect-src *; font-src 'self' data: https:; frame-ancestors 'self';" always;
server_tokens off;
error_page 497 https://$host$request_uri;
#SSL-END
#REDIRECT START
#REDIRECT END
#ERROR-PAGE-START 错误页配置,可以注释、删除或修改
error_page 404 /404.html;
#error_page 502 /502.html;
#ERROR-PAGE-END
#WEBSOCKET-SUPPORT START
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
#WEBSOCKET-SUPPORT END
# 上传/超时治理(根据业务需要调整大小与时限)
client_max_body_size 100m;
client_body_timeout 15s;
client_header_timeout 10s;
send_timeout 30s;
#PROXY-CONF-START
location ^~ / {
proxy_pass http://127.0.0.1:8050;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-Port $remote_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_redirect off;
proxy_buffering off;
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# 轻量限速:降低扫描/撞库/爆破效率(按需调大/关闭)
limit_req zone=perip burst=20 nodelay;
limit_conn perip_conn 50;
}
#PROXY-CONF-END
#SERVER-BLOCK START
location ~* ^/ws/(.*)$ {
# 先尝试直接转发,不修改路径
proxy_pass http://localhost:8050;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 禁用缓冲确保WebSocket数据实时传输
proxy_buffering off;
proxy_buffer_size 4k;
proxy_buffers 4 4k;
proxy_busy_buffers_size 4k;
proxy_max_temp_file_size 0;
# 可选设置超时WebSocket 是长连接)
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
# WebSocket 连接数可选限速:如需限制每 IP 并发,解除注释
# limit_conn perip_conn 20;
}
# 可设置server|location等所有server字段
# location /web {
# try_files $uri $uri/ /index.php$is_args$args;
# }
# error_page 404 /diy_404.html;
# 如果反代网站访问异常且这里已经配置了内容,请优先排查此处的配置是否正确
#SERVER-BLOCK END
#禁止访问的文件或目录
location ~ ^/(\.user.ini|\.htaccess|\.git|\.env|\.svn|\.project|LICENSE|README.md) {
return 404;
}
# 追加敏感路径/文件快速阻断
location ~* /(composer\.(json|lock)|package(-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|phpunit\.xml|\.ssh/|id_rsa|id_dsa|\.DS_Store) {
return 404;
}
# 禁止直连 phpinfo/adminer 等常见探测
location ~* /(phpinfo|adminer)\.php$ {
return 404;
}
# 禁止可疑备份/数据库/压缩文件下载(如需放行请删除本段)
location ~* \.(bak|sql|tar|tar\.gz|rar|7z|zip)$ {
return 404;
}
#一键申请SSL证书验证目录相关设置
location /.well-known {
allow all;
}
#禁止在证书验证目录放入敏感文件
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
return 403;
}
#LOG START
access_log /www/wwwlogs/dev.aigc-quickapp.com.log;
error_log /www/wwwlogs/dev.aigc-quickapp.com.error.log;
#LOG END
}

355
docs/websocket/README.md Normal file
View File

@@ -0,0 +1,355 @@
# WebSocket Server 说明文档
## 1. 概述
WebSocket Server 是基于 Ratchet 库实现的纯 PHP WebSocket 服务器,主要用于为智能客服系统提供实时通信支持,特别是为不支持 EventSource 的微信小程序提供流式请求处理能力。
## 2. 安装与依赖
### 2.1 依赖库
- **Ratchet**: 纯 PHP WebSocket 实现库
```bash
composer require cboden/ratchet
```
### 2.2 环境要求
- PHP 7.4+
- Composer
- ThinkPHP 6.x
## 3. 服务器文件结构
### 3.1 核心文件
- **启动脚本**: `src/ws_server.php` - WebSocket 服务器主启动文件
- **智能客服控制器**: `src/addon/aikefu/api/controller/WebSocket.php` - aikefu 插件的 WebSocket 控制器实现
### 3.2 目录结构
```
├── backend/
│ ├── docs/
│ │ └── websocket/
│ │ └── README.md # 本文档
│ └── src/
│ ├── ws_server.php # WebSocket 服务器启动脚本
│ └── addon/
│ └── aikefu/
│ └── api/
│ └── controller/
│ └── WebSocket.php # aikefu 插件 WebSocket 控制器
```
## 4. 服务器启动与配置
### 4.1 启动命令
在 `src` 目录下执行:
```bash
php ws_server.php
```
### 4.2 默认配置
- **监听地址**: `0.0.0.0` (所有网络接口)
- **端口**: `8080`
- **WebSocket 地址**: `ws://localhost:8080`
### 4.3 自定义配置
可以在 `ws_server.php` 中修改以下配置:
```php
// 配置WebSocket服务器
$httpHost = 'localhost'; // 客户端连接时使用的主机名
$port = 8080; // WebSocket服务器端口
$address = '0.0.0.0'; // 监听所有网络接口
```
## 5. WebSocket 路径
### 5.1 默认测试路径
- **路径**: `/ws`
- **功能**: 用于测试 WebSocket 连接
- **示例**: `ws://localhost:8080/ws`
### 5.2 智能客服路径
- **路径**: `/ws/aikefu`
- **功能**: 智能客服聊天功能接口
- **示例**: `ws://localhost:8080/ws/aikefu`
## 6. 消息格式
### 6.1 客户端发送消息格式
```json
{
"message": "用户输入的消息",
"token": "用户认证令牌",
"session_id": "会话ID",
"action": "chat" // 动作类型
}
```
### 6.2 服务器响应消息格式
#### 聊天响应
```json
{
"type": "chat",
"message": "客服回复的消息内容",
"session_id": "会话ID",
"complete": false // 是否完成响应
}
```
#### 错误响应
```json
{
"type": "error",
"message": "错误信息描述",
"code": "错误代码"
}
```
#### 系统消息
```json
{
"type": "system",
"message": "系统消息内容"
}
```
## 7. 客户端使用示例
### 7.1 JavaScript 示例
```javascript
// 创建WebSocket连接
const ws = new WebSocket('ws://localhost:8080/ws/aikefu');
// 连接打开时
ws.onopen = function(event) {
console.log('WebSocket连接已打开');
// 发送聊天消息
ws.send(JSON.stringify({
message: '你好',
token: 'user_token_here',
session_id: 'session_123',
action: 'chat'
}));
};
// 接收消息
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
console.log('收到消息:', data);
if (data.type === 'chat') {
// 处理聊天消息
console.log('客服回复:', data.message);
if (data.complete) {
console.log('对话完成');
}
} else if (data.type === 'error') {
// 处理错误
console.error('错误:', data.message);
}
};
// 连接关闭时
ws.onclose = function(event) {
console.log('WebSocket连接已关闭');
};
// 连接错误时
ws.onerror = function(error) {
console.error('WebSocket错误:', error);
};
```
### 7.2 微信小程序示例
```javascript
// 创建WebSocket连接
const ws = wx.connectSocket({
url: 'ws://localhost:8080/ws/aikefu',
header: {
'content-type': 'application/json'
}
});
// 连接打开时
wx.onSocketOpen(function(res) {
console.log('WebSocket连接已打开', res);
// 发送聊天消息
wx.sendSocketMessage({
data: JSON.stringify({
message: '你好',
token: 'user_token_here',
session_id: 'session_123',
action: 'chat'
})
});
});
// 接收消息
wx.onSocketMessage(function(res) {
const data = JSON.parse(res.data);
console.log('收到消息:', data);
if (data.type === 'chat') {
// 处理聊天消息
console.log('客服回复:', data.message);
if (data.complete) {
console.log('对话完成');
}
} else if (data.type === 'error') {
// 处理错误
console.error('错误:', data.message);
}
});
// 连接关闭时
wx.onSocketClose(function(res) {
console.log('WebSocket连接已关闭', res);
});
// 连接错误时
wx.onSocketError(function(res) {
console.error('WebSocket错误:', res);
});
```
## 8. 功能特性
### 8.1 实时通信
- 支持双向实时通信
- 消息即时推送
- 支持流式响应
### 8.2 智能客服集成
- 与现有智能客服系统无缝集成
- 支持上下文会话管理
- 支持多用户并发访问
### 8.3 插件化设计
- 支持为不同插件注册独立的 WebSocket 控制器
- 路径格式:`/ws/{addon_name}`
- 便于扩展其他插件的 WebSocket 支持
## 9. 故障排除
### 9.1 常见问题
#### 问题:服务器无法启动
**可能原因**
- 端口被占用
- 依赖库未安装
- PHP 版本不兼容
**解决方案**
- 检查端口占用情况:`netstat -an | findstr 8080`
- 重新安装依赖:`composer install`
- 确保 PHP 版本 >= 7.4
#### 问题:客户端无法连接
**可能原因**
- 服务器未启动
- 网络防火墙限制
- WebSocket 地址错误
**解决方案**
- 确认服务器已启动
- 检查防火墙设置,确保端口 8080 开放
- 验证 WebSocket 地址格式
#### 问题:消息发送失败
**可能原因**
- 消息格式错误
- 认证失败
- 服务器内部错误
**解决方案**
- 检查消息格式是否符合要求
- 验证用户认证信息
- 查看服务器日志获取详细错误信息
### 9.2 日志查看
服务器启动时会输出详细日志,包括:
- 已注册的 WebSocket 控制器
- 连接信息
- 错误信息
## 10. 扩展开发
### 10.1 为其他插件添加 WebSocket 支持
1. 在插件目录下创建 WebSocket 控制器:
```
addon/{addon_name}/api/controller/WebSocket.php
```
2. 实现 MessageComponentInterface 接口:
```php
<?php
namespace addon\{addon_name}\api\controller;
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
class WebSocket implements MessageComponentInterface {
protected $clients;
public function __construct() {
$this->clients = new \SplObjectStorage;
}
public function onOpen(ConnectionInterface $conn) {
// 处理连接打开
}
public function onMessage(ConnectionInterface $conn, $msg) {
// 处理收到的消息
}
public function onClose(ConnectionInterface $conn) {
// 处理连接关闭
}
public function onError(ConnectionInterface $conn, \Exception $e) {
// 处理错误
}
}
```
3. 重启 WebSocket 服务器,新插件的 WebSocket 控制器将自动注册到 `/ws/{addon_name}` 路径
## 11. 版本历史
| 版本 | 日期 | 说明 |
|------|------|------|
| v1.0 | 2025-12-19 | 初始版本,支持智能客服系统的 WebSocket 通信 |
## 12. 联系方式
如有问题或建议,请联系技术支持团队。

View File

@@ -0,0 +1,124 @@
proxy_cache_path /www/dk_project/sites/dev.aigc-quickapp.com/proxy_cache_dir levels=1:2 keys_zone=dev_aigc-quickapp_com_cache:20m inactive=1d max_size=5g;
server {
listen 80;
listen 443 quic;
listen 443 ssl;
http2 on;
server_name dev.aigc-quickapp.com;
index index.php index.html index.htm default.php default.htm default.html;
root /www/dk_project/wwwroot/dev.aigc-quickapp.com;
#CERT-APPLY-CHECK--START
# 用于SSL证书申请时的文件验证相关配置 -- 请勿删除
include /www/server/panel/vhost/nginx/well-known/dev.aigc-quickapp.com.conf;
#CERT-APPLY-CHECK--END
#SSL-START SSL相关配置请勿删除或修改下一行带注释的404规则
#error_page 404/404.html;
ssl_certificate /www/server/panel/vhost/cert/dev.aigc-quickapp.com/fullchain.pem;
ssl_certificate_key /www/server/panel/vhost/cert/dev.aigc-quickapp.com/privkey.pem;
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000";
error_page 497 https://$host$request_uri;
#SSL-END
#REDIRECT START
#REDIRECT END
#ERROR-PAGE-START 错误页配置,可以注释、删除或修改
error_page 404 /404.html;
#error_page 502 /502.html;
#ERROR-PAGE-END
#WEBSOCKET-SUPPORT START
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
#WEBSOCKET-SUPPORT END
#PROXY-CONF-START
location ^~ / {
proxy_pass http://127.0.0.1:8050;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-Port $remote_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_buffering off;
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
#PROXY-CONF-END
#SERVER-BLOCK START
location ~* ^/ws/(.*)$ {
# 先尝试直接转发,不修改路径
proxy_pass http://localhost:8050;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 禁用缓冲确保WebSocket数据实时传输
proxy_buffering off;
proxy_buffer_size 4k;
proxy_buffers 4 4k;
proxy_busy_buffers_size 4k;
proxy_max_temp_file_size 0;
# 可选设置超时WebSocket 是长连接)
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# 可设置server|location等所有server字段
# location /web {
# try_files $uri $uri/ /index.php$is_args$args;
# }
# error_page 404 /diy_404.html;
# 如果反代网站访问异常且这里已经配置了内容,请优先排查此处的配置是否正确
client_max_body_size 500M; # 👈 关键配置!根据需要调整,比如 500M 或 1G
#SERVER-BLOCK END
#禁止访问的文件或目录
location ~ ^/(\.user.ini|\.htaccess|\.git|\.env|\.svn|\.project|LICENSE|README.md) {
return 404;
}
#一键申请SSL证书验证目录相关设置
location /.well-known {
allow all;
}
#禁止在证书验证目录放入敏感文件
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
return 403;
}
#LOG START
access_log /www/wwwlogs/dev.aigc-quickapp.com.log;
error_log /www/wwwlogs/dev.aigc-quickapp.com.error.log;
#LOG END
}

View File

@@ -0,0 +1,231 @@
# WebSocket 测试文件说明
本目录包含用于测试 WebSocket 服务器的各种测试文件,支持不同环境和场景的测试需求。
## 测试文件列表
1. **`test_websocket.php`** - PHP 客户端测试脚本
2. **`test_websocket.html`** - 浏览器端 JavaScript 测试页面
3. **`test_wechat_miniprogram.js`** - 微信小程序客户端测试代码
4. **`test_readme.md`** - 本测试文件说明文档
## 测试前准备
在开始测试之前,请确保:
1. **启动 WebSocket 服务器**
```bash
cd d:/projects/shop-projects/backend/src
php ws_server.php
```
2. **确保服务器运行正常**
服务器启动后,您应该看到类似以下输出:
```
WebSocket服务器已启动监听地址: ws://localhost:8080
已注册WebSocket控制器的addon路径
- ws://localhost:8080/ws/aikefu (已注册)
默认测试路径:
- ws://localhost:8080/ws (默认路径,用于连接测试)
按 Ctrl+C 停止服务器
```
## 1. PHP 客户端测试 (`test_websocket.php`)
### 功能说明
用于在 PHP 环境下测试 WebSocket 服务器的连接和基本功能。
### 使用方法
1. **直接运行测试脚本**
```bash
cd d:/projects/shop-projects/backend/docs/websocket
php test_websocket.php
```
2. **测试不同路径**
- 默认测试路径:`ws://localhost:8080/ws`
- aikefu 插件路径:`ws://localhost:8080/ws/aikefu`
可以通过修改脚本中的 `$wsUrl` 变量来切换测试路径。
### 预期输出
成功连接并发送消息后,您应该看到类似以下输出:
```
正在连接到 WebSocket 服务器: ws://localhost:8080/ws
✅ 成功连接到 WebSocket 服务器
📤 发送 ping 消息
📤 发送测试消息
📥 收到消息: {"type":"welcome","message":"欢迎连接到默认WebSocket测试路径","info":"此路径仅用于测试,不提供实际功能。请使用/ws/{addonName}连接到具体的addon服务。"}
📥 收到消息: {"type":"pong"}
📥 收到消息: {"type":"info","message":"收到消息,但默认路径不提供实际功能","received":{"message":"Hello WebSocket!","action":"test"}}
```
## 2. 浏览器端 JavaScript 测试 (`test_websocket.html`)
### 功能说明
用于在浏览器环境下测试 WebSocket 服务器的连接和交互功能,提供直观的用户界面。
### 使用方法
1. **打开测试页面**
- 直接用浏览器打开 `test_websocket.html` 文件
- 或者通过 Web 服务器访问:`http://your-domain/docs/websocket/test_websocket.html`
2. **配置测试参数**
- 在 "设置" 区域输入 WebSocket 服务器地址
- 默认地址:`ws://localhost:8080/ws`
- aikefu 插件地址:`ws://localhost:8080/ws/aikefu`
3. **连接和测试**
- 点击 "连接" 按钮建立 WebSocket 连接
- 使用 "发送 Ping"、"发送测试消息" 或 "发送自定义消息" 按钮进行测试
- 在消息区域查看发送和接收的消息
### 主要功能
- **连接管理**:连接、断开连接、重连
- **消息发送**
- Ping 消息:测试服务器响应
- 测试消息:发送预设的测试数据
- 自定义消息:支持发送任意 JSON 格式消息
- **消息显示**
- 美化显示 JSON 格式消息
- 区分发送和接收的消息
- 显示消息时间和发送者
## 3. 微信小程序客户端测试 (`test_wechat_miniprogram.js`)
### 功能说明
用于在微信小程序环境下测试 WebSocket 服务器的连接和通信功能,模拟实际的微信小程序客户端环境。
### 使用方法
1. **集成测试代码**
- 将 `test_wechat_miniprogram.js` 文件中的代码复制到微信小程序的页面 JavaScript 文件中
- 将注释中的 WXML 和 WXSS 代码分别复制到对应的 `.wxml` 和 `.wxss` 文件中
2. **配置服务器地址**
- 修改 `wsUrl` 变量为您的 WebSocket 服务器地址
- 注意:微信小程序要求使用 HTTPS/WSS 协议,需要配置 SSL 证书
3. **运行测试**
- 在微信开发者工具中打开小程序项目
- 进入包含测试代码的页面
- 点击 "连接" 按钮建立 WebSocket 连接
- 使用测试按钮发送消息并查看接收结果
### 注意事项
- **微信小程序 WebSocket 限制**
- 只支持 HTTPS/WSS 协议
- 需要在小程序管理后台配置合法域名
- 最多同时存在 5 个 WebSocket 连接
- **调试建议**
- 使用微信开发者工具的调试模式查看日志
- 先在浏览器端测试确保服务器正常
- 注意网络环境和防火墙设置
## 测试场景和用例
### 基本连接测试
| 测试场景 | 预期结果 | 测试文件 |
|---------|---------|---------|
| 连接默认测试路径 | 成功连接,收到欢迎消息 | 所有测试文件 |
| 连接 aikefu 插件路径 | 成功连接 | 所有测试文件 |
| 断开连接 | 连接关闭,收到关闭通知 | 所有测试文件 |
| 重连 | 成功重新建立连接 | 浏览器和小程序测试文件 |
### 消息功能测试
| 测试场景 | 预期结果 | 测试文件 |
|---------|---------|---------|
| 发送 Ping 消息 | 收到 Pong 响应 | 所有测试文件 |
| 发送测试消息 | 收到服务器处理结果 | 所有测试文件 |
| 发送 JSON 消息 | 正确解析和处理 | 所有测试文件 |
| 发送错误格式消息 | 收到错误提示 | 所有测试文件 |
## 常见问题和解决方案
### 1. 连接失败
**可能原因**
- WebSocket 服务器未启动
- 服务器地址或端口错误
- 防火墙或网络设置阻止连接
- 微信小程序域名未配置
**解决方案**
- 确认服务器已启动并运行正常
- 检查服务器地址和端口配置
- 关闭防火墙或添加例外规则
- 在微信小程序管理后台配置合法域名
### 2. 消息发送失败
**可能原因**
- WebSocket 连接已关闭
- 消息格式不正确
- 服务器处理错误
**解决方案**
- 检查连接状态,必要时重新连接
- 确保消息格式符合 JSON 规范
- 查看服务器日志排查错误
### 3. 接收消息异常
**可能原因**
- 服务器返回格式错误
- 网络传输问题
- 客户端解析错误
**解决方案**
- 检查服务器代码确保返回正确格式
- 检查网络连接稳定性
- 查看客户端日志排查解析错误
## 测试完成后的清理
测试完成后,您可以:
1. **停止 WebSocket 服务器**
在运行服务器的终端中按 `Ctrl+C` 停止服务器。
2. **关闭测试客户端**
- 浏览器测试:关闭浏览器标签页
- PHP 测试:测试完成后自动退出
- 微信小程序测试:退出小程序页面
## 扩展测试建议
1. **性能测试**
- 测试同时连接多个客户端
- 测试大消息传输
- 测试长时间连接稳定性
2. **异常场景测试**
- 服务器意外关闭
- 网络中断恢复
- 消息丢失场景
3. **功能完整性测试**
- 测试所有消息类型
- 测试错误处理机制
- 测试安全验证功能
## 联系和支持
如果在测试过程中遇到问题,请联系开发人员或查看服务器日志获取更多信息。
---
**更新时间**2025-12-19
**版本**1.0.0
**维护人员**WebSocket 开发团队

View File

@@ -0,0 +1,754 @@
<!--
Uniapp WebSocket 测试页面
用于在 Uniapp 项目中测试微信小程序的 WebSocket 功能
使用方法
1. Uniapp 项目的 pages 目录下创建 ws-test 目录
2. 将此文件保存为 ws-test.vue
3. pages.json 中注册该页面
4. 在微信开发者工具中运行项目并访问该页面
-->
<template>
<view class="container">
<view class="header">
<text class="title">WebSocket 测试</text>
<view class="status" :class="statusClass">
{{ connectionStatus }}
</view>
</view>
<view class="settings">
<view class="form-item">
<text class="label">服务器地址</text>
<input
class="input"
v-model="wsUrl"
placeholder="wss://your-domain.com/ws/aikefu"
/>
</view>
<view class="btn-group">
<button
class="btn btn-primary"
@click="connectWebSocket"
:disabled="connecting"
>
{{ connecting ? '连接中...' : '连接' }}
</button>
<button
class="btn btn-default"
@click="disconnectWebSocket"
:disabled="!connected"
>
断开连接
</button>
<button
class="btn btn-warn"
@click="reconnectWebSocket"
:disabled="connecting"
>
重连
</button>
</view>
</view>
<view class="message-area">
<scroll-view
class="message-list"
scroll-y="true"
:scroll-top="scrollTop"
@scroll="onScroll"
>
<view
v-for="(message, index) in messages"
:key="message.id"
class="message-item"
:class="message.type"
>
<view class="message-header">
<text class="sender">{{ message.sender }}</text>
<text class="time">{{ message.time }}</text>
</view>
<view class="message-content">
{{ formatMessage(message.content) }}
</view>
</view>
</scroll-view>
</view>
<view class="input-area">
<textarea
class="message-input"
v-model="inputMessage"
placeholder="输入要发送的消息..."
@confirm="sendCustomMessage"
></textarea>
<view class="btn-group">
<button class="btn btn-sm" @click="sendPing">Ping</button>
<button class="btn btn-sm" @click="sendTestMessage">测试消息</button>
<button class="btn btn-sm btn-primary" @click="sendCustomMessage">发送</button>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
wsUrl: 'wss://your-domain.com/ws/aikefu', // 替换为你的 WebSocket 服务器地址
connectionStatus: '未连接',
connected: false,
connecting: false,
socketTask: null,
messages: [],
inputMessage: '',
scrollTop: 0,
autoScroll: true
};
},
computed: {
statusClass() {
if (this.connecting) return 'status-connecting';
if (this.connected) return 'status-connected';
return 'status-disconnected';
}
},
onLoad() {
// 页面加载时自动连接
this.connectWebSocket();
},
onUnload() {
// 页面卸载时断开连接
this.closeWebSocket();
},
onHide() {
// 页面隐藏时断开连接
this.closeWebSocket();
},
methods: {
// 连接 WebSocket
connectWebSocket() {
if (this.connected || this.connecting) return;
this.connecting = true;
this.updateStatus('连接中...');
try {
// 创建 WebSocket 连接 (Uniapp API)
this.socketTask = uni.connectSocket({
url: this.wsUrl,
header: {
'content-type': 'application/json'
},
method: 'GET',
success: (res) => {
console.log('WebSocket 连接请求发送成功', res);
},
fail: (err) => {
console.error('WebSocket 连接请求发送失败', err);
this.connecting = false;
this.updateStatus('连接失败');
this.addMessage('系统', 'WebSocket 连接请求发送失败: ' + JSON.stringify(err), 'error');
}
});
// 监听 WebSocket 连接打开
this.socketTask.onOpen((res) => {
console.log('WebSocket 连接已打开', res);
this.connected = true;
this.connecting = false;
this.updateStatus('已连接');
this.addMessage('系统', 'WebSocket 连接已打开', 'system');
});
// 监听 WebSocket 接收到服务器的消息
this.socketTask.onMessage((res) => {
console.log('收到服务器消息', res.data);
this.addMessage('服务器', res.data, 'received');
});
// 监听 WebSocket 连接关闭
this.socketTask.onClose((res) => {
console.log('WebSocket 连接已关闭', res);
this.connected = false;
this.connecting = false;
this.updateStatus('已断开');
this.addMessage('系统', 'WebSocket 连接已关闭', 'system');
});
// 监听 WebSocket 错误
this.socketTask.onError((res) => {
console.error('WebSocket 连接发生错误', res);
this.connected = false;
this.connecting = false;
this.updateStatus('连接错误');
this.addMessage('系统', 'WebSocket 连接发生错误: ' + JSON.stringify(res), 'error');
});
} catch (error) {
console.error('WebSocket 连接异常', error);
this.connecting = false;
this.updateStatus('连接异常');
this.addMessage('系统', 'WebSocket 连接异常: ' + error.message, 'error');
}
},
// 断开 WebSocket 连接
disconnectWebSocket() {
if (!this.connected && !this.connecting) return;
this.closeWebSocket();
this.connected = false;
this.connecting = false;
this.updateStatus('已断开');
this.addMessage('系统', 'WebSocket 连接已手动断开', 'system');
},
// 关闭 WebSocket 连接(内部使用)
closeWebSocket() {
if (this.socketTask) {
try {
this.socketTask.close({
code: 1000,
reason: '用户主动断开连接'
});
} catch (error) {
console.error('关闭 WebSocket 连接失败', error);
}
this.socketTask = null;
}
},
// 重连 WebSocket
reconnectWebSocket() {
this.closeWebSocket();
this.connected = false;
this.connecting = false;
// 延迟 1 秒后重连
setTimeout(() => {
this.connectWebSocket();
}, 1000);
},
// 发送 Ping 消息
sendPing() {
this.sendMessage(JSON.stringify({ action: 'ping' }));
},
// 发送测试消息
sendTestMessage() {
this.sendMessage(JSON.stringify({
message: '你好,这是 Uniapp 测试消息!',
action: 'test',
timestamp: Date.now()
}));
},
// 发送自定义消息
sendCustomMessage() {
if (!this.inputMessage.trim()) {
uni.showToast({
title: '请输入消息内容',
icon: 'none'
});
return;
}
try {
// 尝试解析为 JSON
JSON.parse(this.inputMessage);
this.sendMessage(this.inputMessage);
} catch (error) {
// 不是 JSON包装为普通消息
this.sendMessage(JSON.stringify({
message: this.inputMessage,
action: 'chat'
}));
}
// 清空输入框
this.inputMessage = '';
},
// 发送消息(通用方法)
sendMessage(message) {
if (!this.connected || !this.socketTask) {
uni.showToast({
title: 'WebSocket 未连接',
icon: 'none'
});
this.updateStatus('已断开');
return;
}
try {
// 使用 Uniapp API 发送消息
this.socketTask.send({
data: message,
success: () => {
this.addMessage('我', message, 'sent');
},
fail: (err) => {
console.error('发送消息失败', err);
this.addMessage('系统', '发送消息失败: ' + JSON.stringify(err), 'error');
}
});
} catch (error) {
console.error('发送消息异常', error);
this.addMessage('系统', '发送消息异常: ' + error.message, 'error');
}
},
// 更新连接状态
updateStatus(status) {
this.connectionStatus = status;
},
// 添加消息到消息列表
addMessage(sender, content, type) {
const message = {
id: Date.now(),
sender,
content,
type,
time: this.formatTime(new Date())
};
this.messages.push(message);
// 自动滚动到底部
this.scrollToBottom();
},
// 滚动到底部
scrollToBottom() {
if (this.autoScroll) {
// 延迟执行以确保 DOM 已更新
this.$nextTick(() => {
uni.createSelectorQuery().in(this)
.select('.message-list')
.boundingClientRect((rect) => {
if (rect) {
this.scrollTop = rect.height;
}
})
.exec();
});
}
},
// 格式化时间
formatTime(date) {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
},
// 格式化消息内容(美化 JSON
formatMessage(content) {
try {
const parsed = JSON.parse(content);
return JSON.stringify(parsed, null, 2);
} catch (error) {
return content;
}
},
// 处理滚动事件
onScroll(e) {
const { scrollTop, scrollHeight, clientHeight } = e.detail;
// 判断是否滚动到底部附近
this.autoScroll = scrollTop + clientHeight >= scrollHeight - 20;
}
}
};
</script>
<style scoped>
.container {
display: flex;
flex-direction: column;
height: 100vh;
padding: 20rpx;
background-color: #f5f5f5;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #eee;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #333;
}
.status {
padding: 8rpx 16rpx;
border-radius: 8rpx;
font-size: 24rpx;
font-weight: bold;
}
.status-connecting {
background-color: #fff3cd;
color: #856404;
}
.status-connected {
background-color: #d4edda;
color: #155724;
}
.status-disconnected {
background-color: #f8d7da;
color: #721c24;
}
.settings {
background-color: #fff;
border-radius: 10rpx;
padding: 20rpx;
margin-bottom: 20rpx;
}
.form-item {
display: flex;
flex-direction: column;
margin-bottom: 20rpx;
}
.label {
font-size: 28rpx;
color: #666;
margin-bottom: 10rpx;
}
.input {
height: 80rpx;
padding: 0 20rpx;
border: 1rpx solid #ddd;
border-radius: 10rpx;
font-size: 28rpx;
background-color: #f9f9f9;
}
.btn-group {
display: flex;
justify-content: flex-start;
gap: 15rpx;
flex-wrap: wrap;
}
.btn {
flex: 1;
min-width: 150rpx;
height: 80rpx;
line-height: 80rpx;
text-align: center;
border-radius: 10rpx;
font-size: 28rpx;
border: none;
}
.btn-primary {
background-color: #007aff;
color: #fff;
}
.btn-default {
background-color: #f5f5f5;
color: #333;
border: 1rpx solid #ddd;
}
.btn-warn {
background-color: #ff3b30;
color: #fff;
}
.btn-sm {
height: 60rpx;
line-height: 60rpx;
font-size: 24rpx;
min-width: 100rpx;
}
.message-area {
flex: 1;
background-color: #fff;
border-radius: 10rpx;
margin-bottom: 20rpx;
overflow: hidden;
}
.message-list {
height: 100%;
padding: 20rpx;
}
.message-item {
margin-bottom: 20rpx;
padding: 15rpx;
border-radius: 10rpx;
max-width: 80%;
}
.message-item.sent {
align-self: flex-end;
margin-left: auto;
background-color: #d4edda;
border-right: 4rpx solid #28a745;
}
.message-item.received {
align-self: flex-start;
background-color: #e9ecef;
border-left: 4rpx solid #007bff;
}
.message-item.system {
align-self: center;
background-color: #fff3cd;
border-left: 4rpx solid #856404;
max-width: 90%;
}
.message-item.error {
align-self: center;
background-color: #f8d7da;
border-left: 4rpx solid #dc3545;
max-width: 90%;
}
.message-header {
display: flex;
justify-content: space-between;
margin-bottom: 10rpx;
font-size: 24rpx;
color: #666;
}
.message-content {
font-size: 28rpx;
line-height: 1.5;
word-break: break-all;
white-space: pre-wrap;
}
.input-area {
background-color: #fff;
border-radius: 10rpx;
padding: 20rpx;
}
.message-input {
width: 100%;
min-height: 120rpx;
max-height: 200rpx;
padding: 20rpx;
margin-bottom: 20rpx;
border: 1rpx solid #ddd;
border-radius: 10rpx;
font-size: 28rpx;
background-color: #f9f9f9;
}
</style>
<script module="pages.json">
// 该模块仅用于示例,实际应在项目根目录的 pages.json 中配置
/*
{
"pages": [
{
"path": "pages/ws-test/ws-test",
"style": {
"navigationBarTitleText": "WebSocket 测试"
}
}
]
}
*/
</script>
<script module="README">
/*
# Uniapp WebSocket 测试页面使用说明
## 功能说明
用于在 Uniapp 项目中测试微信小程序的 WebSocket 功能,支持连接管理、消息发送和接收。
## 使用方法
### 1. 创建页面
将本文件保存为 `pages/ws-test/ws-test.vue`
### 2. 配置页面路由
在 `pages.json` 中添加以下配置:
```json
{
"pages": [
{
"path": "pages/ws-test/ws-test",
"style": {
"navigationBarTitleText": "WebSocket 测试"
}
}
]
}
```
### 3. 配置服务器地址
在 `ws-test.vue` 文件中修改 `wsUrl` 变量:
```javascript
data() {
return {
wsUrl: 'wss://your-domain.com/ws/aikefu', // 替换为你的 WebSocket 服务器地址
// ...
};
}
```
### 4. 运行测试
- 使用 HBuilderX 开发工具
- 选择 "运行" -> "运行到小程序模拟器" -> "微信开发者工具"
- 在微信开发者工具中访问该页面
## 注意事项
### 微信小程序限制
1. **域名配置**:需要在微信小程序管理后台配置合法域名
2. **协议支持**:仅支持 HTTPS/WSS 协议
3. **连接数限制**:最多同时存在 5 个 WebSocket 连接
### Uniapp API 特点
1. Uniapp 使用 `uni.connectSocket()` 替代微信小程序的 `wx.connectSocket()`
2. Uniapp 使用 `uni.createSelectorQuery()` 替代微信小程序的 `wx.createSelectorQuery()`
3. 页面生命周期钩子函数使用 Vue 组件的 `onLoad()`、`onUnload()`、`onHide()`
## 扩展功能
### 添加消息类型支持
可以根据业务需求扩展支持的消息类型:
```javascript
// 发送聊天消息
sendChatMessage() {
this.sendMessage(JSON.stringify({
action: 'chat',
message: this.inputMessage,
userId: 'user123',
timestamp: Date.now()
}));
}
// 发送指令消息
sendCommand(command, params) {
this.sendMessage(JSON.stringify({
action: 'command',
command,
params,
timestamp: Date.now()
}));
}
```
### 添加自动重连机制
可以实现更智能的自动重连机制:
```javascript
data() {
return {
// ...
reconnectAttempts: 0,
maxReconnectAttempts: 5,
reconnectDelay: 1000
};
},
methods: {
// 自动重连
autoReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.addMessage('系统', '自动重连失败,已达到最大尝试次数', 'error');
return;
}
this.reconnectAttempts++;
this.addMessage('系统', `尝试自动重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`, 'system');
setTimeout(() => {
this.connectWebSocket();
}, this.reconnectDelay);
},
// 连接成功后重置重连计数
onConnectSuccess() {
this.reconnectAttempts = 0;
// ...
}
}
```
## 常见问题
### 1. 连接失败
**可能原因**
- 服务器地址错误
- 未配置合法域名
- 服务器未启动或网络不可达
- 使用了 HTTP 协议而不是 HTTPS
**解决方案**
- 检查服务器地址和端口
- 在微信小程序管理后台配置合法域名
- 确保服务器正常运行
- 切换到 WSS 协议
### 2. 消息发送失败
**可能原因**
- WebSocket 连接已关闭
- 消息格式不正确
- 网络中断
**解决方案**
- 检查连接状态,必要时重新连接
- 确保消息格式为 JSON 字符串
- 检查网络连接
### 3. 接收消息异常
**可能原因**
- 服务器返回格式错误
- 客户端解析错误
**解决方案**
- 检查服务器返回的消息格式
- 调试客户端解析逻辑
## 联系和支持
如果在使用过程中遇到问题,请联系开发人员或查看相关文档。
---
**更新时间**2025-12-19
**版本**1.0.0
**维护人员**WebSocket 开发团队
*/
</script>

View File

@@ -0,0 +1,283 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket 测试客户端</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.header {
background: #f5f5f5;
padding: 10px;
border-radius: 5px;
margin-bottom: 20px;
}
.connection-info {
margin-bottom: 20px;
}
.status {
padding: 5px 10px;
border-radius: 3px;
font-weight: bold;
}
.status.connected {
background: #d4edda;
color: #155724;
}
.status.disconnected {
background: #f8d7da;
color: #721c24;
}
.input-area {
margin-bottom: 20px;
}
.input-area textarea {
width: 100%;
height: 100px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
margin-bottom: 10px;
}
.input-area button {
margin-right: 10px;
padding: 8px 15px;
background: #007bff;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
.input-area button:hover {
background: #0056b3;
}
.messages {
border: 1px solid #ddd;
border-radius: 5px;
padding: 10px;
height: 300px;
overflow-y: auto;
background: #f9f9f9;
}
.message {
margin-bottom: 10px;
padding: 10px;
border-radius: 5px;
}
.message.received {
background: #e9ecef;
border-left: 4px solid #007bff;
}
.message.sent {
background: #d4edda;
border-right: 4px solid #28a745;
text-align: right;
}
.message.error {
background: #f8d7da;
border-left: 4px solid #dc3545;
}
.message-header {
font-size: 12px;
color: #666;
margin-bottom: 5px;
}
.settings {
margin-bottom: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 5px;
}
.settings input {
width: 300px;
padding: 5px;
margin: 5px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>WebSocket 服务器测试客户端</h1>
<p>用于测试 WebSocket 服务器的连接和基本功能</p>
</div>
<div class="settings">
<h3>设置</h3>
<label>WebSocket 服务器地址: </label>
<input type="text" id="ws-url" value="ws://localhost:8080/ws" placeholder="ws://localhost:8080/ws">
<br>
<button onclick="connectWebSocket()">连接</button>
<button onclick="disconnectWebSocket()">断开连接</button>
</div>
<div class="connection-info">
<strong>连接状态: </strong>
<span id="connection-status" class="status disconnected">未连接</span>
</div>
<div class="input-area">
<textarea id="message-input" placeholder="输入要发送的消息..."></textarea>
<br>
<button onclick="sendPing()">发送 Ping</button>
<button onclick="sendTestMessage()">发送测试消息</button>
<button onclick="sendMessage()">发送自定义消息</button>
</div>
<div class="messages" id="messages-container">
<!-- 消息将显示在这里 -->
</div>
</div>
<script>
let ws = null;
// 连接 WebSocket 服务器
function connectWebSocket() {
const wsUrl = document.getElementById('ws-url').value;
// 先断开已有连接
if (ws) {
ws.close();
}
try {
ws = new WebSocket(wsUrl);
ws.onopen = function() {
updateStatus('connected', '已连接');
addMessage('系统', '成功连接到 WebSocket 服务器', 'received');
};
ws.onmessage = function(event) {
addMessage('服务器', event.data, 'received');
};
ws.onclose = function() {
updateStatus('disconnected', '已断开连接');
addMessage('系统', '与 WebSocket 服务器的连接已断开', 'error');
};
ws.onerror = function(error) {
addMessage('系统', 'WebSocket 错误: ' + error, 'error');
};
} catch (error) {
addMessage('系统', '连接失败: ' + error, 'error');
}
}
// 断开连接
function disconnectWebSocket() {
if (ws) {
ws.close();
ws = null;
}
}
// 发送 Ping 消息
function sendPing() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
addMessage('系统', 'WebSocket 未连接', 'error');
return;
}
const message = JSON.stringify({action: 'ping'});
ws.send(message);
addMessage('我', message, 'sent');
}
// 发送测试消息
function sendTestMessage() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
addMessage('系统', 'WebSocket 未连接', 'error');
return;
}
const message = JSON.stringify({message: 'Hello WebSocket!', action: 'test'});
ws.send(message);
addMessage('我', message, 'sent');
}
// 发送自定义消息
function sendMessage() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
addMessage('系统', 'WebSocket 未连接', 'error');
return;
}
const messageInput = document.getElementById('message-input');
const message = messageInput.value.trim();
if (!message) {
addMessage('系统', '请输入消息内容', 'error');
return;
}
try {
// 尝试解析为 JSON如果不是 JSON 则直接发送
JSON.parse(message);
ws.send(message);
} catch (error) {
// 不是 JSON作为普通文本发送
ws.send(JSON.stringify({message: message}));
}
addMessage('我', message, 'sent');
messageInput.value = '';
}
// 更新连接状态
function updateStatus(statusClass, text) {
const statusElement = document.getElementById('connection-status');
statusElement.className = `status ${statusClass}`;
statusElement.textContent = text;
}
// 添加消息到消息列表
function addMessage(sender, content, type) {
const messagesContainer = document.getElementById('messages-container');
const messageElement = document.createElement('div');
messageElement.className = `message ${type}`;
const now = new Date();
const time = now.toLocaleTimeString();
messageElement.innerHTML = `
<div class="message-header">${sender} - ${time}</div>
<div class="message-content">${formatMessage(content)}</div>
`;
messagesContainer.appendChild(messageElement);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// 格式化消息显示(美化 JSON
function formatMessage(content) {
try {
const parsed = JSON.parse(content);
return JSON.stringify(parsed, null, 2);
} catch (error) {
return content;
}
}
// 页面加载完成后自动连接
window.onload = function() {
connectWebSocket();
};
// 页面关闭时断开连接
window.onbeforeunload = function() {
disconnectWebSocket();
};
</script>
</body>
</html>

View File

@@ -0,0 +1,72 @@
<?php
/**
* WebSocket 服务器 PHP 客户端测试脚本
* 用于测试 WebSocket 服务器的连接和基本功能
*
* 使用方法php test_websocket.php
* 注意:需要先启动 WebSocket 服务器 (php ws_server.php)
*/
use Ratchet\Client\WebSocket;use React\EventLoop\Factory;
use React\Socket\Connector;
use Ratchet\RFC6455\Messaging\MessageInterface;
require __DIR__ . '/../../src/vendor/autoload.php';
// WebSocket 服务器地址
$wsUrl = 'ws://localhost:8080/ws'; // 默认测试路径
// $wsUrl = 'ws://localhost:8080/ws/aikefu'; // aikefu 插件路径
echo "正在连接到 WebSocket 服务器: {$wsUrl}\n";
// 创建事件循环
$loop = Factory::create();
// 创建连接
$connector = new Connector($loop);
// 连接到 WebSocket 服务器
$connector($wsUrl)
->then(function ($conn) use ($loop) {
echo "✅ 成功连接到 WebSocket 服务器\n";
// 创建 WebSocket 客户端
$ws = new WebSocket($conn);
// 收到消息时的处理
$ws->on('message', function (MessageInterface $msg) use ($ws) {
echo "📥 收到消息: {$msg}\n";
});
// 连接关闭时的处理
$ws->on('close', function ($code = null, $reason = null) {
echo "❌ 连接已关闭: {$code} - {$reason}\n";
});
// 发送 ping 消息测试
echo "📤 发送 ping 消息\n";
$ws->send(json_encode(['action' => 'ping']));
// 发送测试消息
echo "📤 发送测试消息\n";
$ws->send(json_encode(['message' => 'Hello WebSocket!', 'action' => 'test']));
// 3秒后关闭连接
$loop->addTimer(3, function () use ($ws) {
echo "⏰ 关闭连接\n";
$ws->close();
});
// 如果是 aikefu 插件路径,可以发送聊天消息测试
// $ws->send(json_encode(['message' => '你好,客服', 'token' => 'your_token', 'action' => 'chat']));
}, function ($e) use ($loop) {
echo "❌ 连接失败: {$e->getMessage()}\n";
$loop->stop();
});
// 运行事件循环
$loop->run();
echo "测试完成\n";

View File

@@ -0,0 +1,429 @@
/**
* 微信小程序 WebSocket 客户端测试代码
* 用于测试微信小程序环境下与 WebSocket 服务器的连接和通信
*
* 使用方法:
* 1. 将此代码复制到微信小程序的页面 JavaScript 文件中
* 2. 在对应的 WXML 文件中添加测试按钮和消息显示区域
* 3. 在微信开发者工具中运行测试
*/
// WebSocket 连接实例
let ws = null;
// 页面数据
Page({
data: {
wsUrl: 'wss://your-domain.com/ws/aikefu', // 替换为你的 WebSocket 服务器地址
connectionStatus: '未连接',
messages: [],
inputMessage: ''
},
// 生命周期函数--监听页面加载
onLoad: function(options) {
this.connectWebSocket();
},
// 生命周期函数--监听页面卸载
onUnload: function() {
this.closeWebSocket();
},
// 生命周期函数--监听页面隐藏
onHide: function() {
this.closeWebSocket();
},
// 连接 WebSocket 服务器
connectWebSocket: function() {
const that = this;
const wsUrl = this.data.wsUrl;
// 创建 WebSocket 连接
ws = wx.connectSocket({
url: wsUrl,
header: {
'content-type': 'application/json'
},
method: 'GET',
success: function(res) {
console.log('WebSocket 连接请求发送成功', res);
that.updateStatus('连接中...');
},
fail: function(err) {
console.error('WebSocket 连接请求发送失败', err);
that.updateStatus('连接失败');
that.addMessage('系统', 'WebSocket 连接请求发送失败: ' + JSON.stringify(err), 'error');
}
});
// 监听 WebSocket 连接打开
ws.onOpen(function(res) {
console.log('WebSocket 连接已打开', res);
that.updateStatus('已连接');
that.addMessage('系统', 'WebSocket 连接已打开', 'system');
});
// 监听 WebSocket 接收到服务器的消息
ws.onMessage(function(res) {
console.log('收到服务器消息', res.data);
that.addMessage('服务器', res.data, 'received');
});
// 监听 WebSocket 连接关闭
ws.onClose(function(res) {
console.log('WebSocket 连接已关闭', res);
that.updateStatus('已断开');
that.addMessage('系统', 'WebSocket 连接已关闭', 'system');
});
// 监听 WebSocket 错误
ws.onError(function(res) {
console.error('WebSocket 连接发生错误', res);
that.updateStatus('连接错误');
that.addMessage('系统', 'WebSocket 连接发生错误: ' + JSON.stringify(res), 'error');
});
},
// 关闭 WebSocket 连接
closeWebSocket: function() {
if (ws) {
wx.closeSocket();
ws = null;
}
},
// 发送 Ping 消息
sendPing: function() {
this.sendMessage(JSON.stringify({ action: 'ping' }));
},
// 发送测试消息
sendTestMessage: function() {
this.sendMessage(JSON.stringify({
message: '你好,这是微信小程序的测试消息!',
action: 'test'
}));
},
// 发送自定义消息
sendCustomMessage: function() {
const message = this.data.inputMessage.trim();
if (!message) {
wx.showToast({
title: '请输入消息内容',
icon: 'none'
});
return;
}
try {
// 尝试解析为 JSON如果不是 JSON 则包装为 JSON
JSON.parse(message);
this.sendMessage(message);
} catch (error) {
// 不是 JSON作为普通文本发送
this.sendMessage(JSON.stringify({ message: message }));
}
// 清空输入框
this.setData({
inputMessage: ''
});
},
// 发送消息
sendMessage: function(message) {
if (!ws) {
wx.showToast({
title: 'WebSocket 未连接',
icon: 'none'
});
return;
}
// 检查 WebSocket 连接状态
wx.getSocketTask().then(task => {
if (task.readyState === 1) { // 连接已打开
wx.sendSocketMessage({
data: message,
success: () => {
this.addMessage('我', message, 'sent');
},
fail: (err) => {
console.error('发送消息失败', err);
this.addMessage('系统', '发送消息失败: ' + JSON.stringify(err), 'error');
}
});
} else {
wx.showToast({
title: 'WebSocket 连接未打开',
icon: 'none'
});
this.updateStatus('连接已断开');
}
}).catch(err => {
console.error('获取 SocketTask 失败', err);
this.updateStatus('连接已断开');
});
},
// 更新输入框内容
onInputMessage: function(e) {
this.setData({
inputMessage: e.detail.value
});
},
// 更新连接状态
updateStatus: function(status) {
this.setData({
connectionStatus: status
});
},
// 添加消息到消息列表
addMessage: function(sender, content, type) {
const messages = this.data.messages;
const now = new Date();
const time = now.toLocaleTimeString();
// 尝试美化 JSON 格式
let formattedContent = content;
try {
const parsed = JSON.parse(content);
formattedContent = JSON.stringify(parsed, null, 2);
} catch (error) {
// 不是 JSON保持原样
}
messages.push({
id: Date.now(),
sender: sender,
content: formattedContent,
type: type,
time: time
});
this.setData({
messages: messages
});
// 滚动到最新消息
this.scrollToBottom();
},
// 滚动到消息底部
scrollToBottom: function() {
wx.createSelectorQuery().select('#message-list').boundingClientRect(function(rect) {
if (rect) {
wx.pageScrollTo({
scrollTop: rect.height,
duration: 300
});
}
}).exec();
},
// 重连 WebSocket
reconnectWebSocket: function() {
this.closeWebSocket();
setTimeout(() => {
this.connectWebSocket();
}, 1000);
}
});
// 对应的 WXML 文件示例
/*
<view class="container">
<view class="header">
<text class="title">WebSocket 测试</text>
<text class="status {{connectionStatus === '已连接' ? 'connected' : connectionStatus === '连接中...' ? 'connecting' : 'disconnected'}}">
{{connectionStatus}}
</text>
</view>
<view class="settings">
<input class="url-input" placeholder="请输入 WebSocket 服务器地址" value="{{wsUrl}}" bindinput="onInputUrl" />
<button class="btn" bindtap="connectWebSocket" type="primary" size="mini">连接</button>
<button class="btn" bindtap="closeWebSocket" type="default" size="mini">断开</button>
<button class="btn" bindtap="reconnectWebSocket" type="warn" size="mini">重连</button>
</view>
<view class="message-area">
<scroll-view id="message-list" scroll-y="true" class="message-list">
<block wx:for="{{messages}}" wx:key="id">
<view class="message-item {{item.type}}">
<view class="message-header">
<text class="sender">{{item.sender}}</text>
<text class="time">{{item.time}}</text>
</view>
<view class="message-content">{{item.content}}</view>
</view>
</block>
</scroll-view>
</view>
<view class="input-area">
<textarea class="message-input" placeholder="请输入消息..." value="{{inputMessage}}" bindinput="onInputMessage" />
<view class="btn-group">
<button class="btn" bindtap="sendPing" type="default" size="mini">发送 Ping</button>
<button class="btn" bindtap="sendTestMessage" type="default" size="mini">测试消息</button>
<button class="btn" bindtap="sendCustomMessage" type="primary" size="mini">发送</button>
</view>
</view>
</view>
*/
// 对应的 WXSS 样式文件示例
/*
.container {
display: flex;
flex-direction: column;
height: 100vh;
padding: 20rpx;
background-color: #f5f5f5;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
}
.status {
padding: 8rpx 16rpx;
border-radius: 8rpx;
font-size: 24rpx;
font-weight: bold;
}
.status.connected {
background-color: #d4edda;
color: #155724;
}
.status.connecting {
background-color: #fff3cd;
color: #856404;
}
.status.disconnected {
background-color: #f8d7da;
color: #721c24;
}
.settings {
display: flex;
align-items: center;
margin-bottom: 20rpx;
padding: 20rpx;
background-color: white;
border-radius: 10rpx;
}
.url-input {
flex: 1;
height: 80rpx;
padding: 20rpx;
margin-right: 20rpx;
border: 1px solid #ddd;
border-radius: 10rpx;
font-size: 28rpx;
}
.btn {
margin-right: 10rpx;
}
.message-area {
flex: 1;
margin-bottom: 20rpx;
background-color: white;
border-radius: 10rpx;
overflow: hidden;
}
.message-list {
height: 100%;
padding: 20rpx;
}
.message-item {
margin-bottom: 20rpx;
padding: 15rpx;
border-radius: 10rpx;
max-width: 80%;
}
.message-item.sent {
align-self: flex-end;
margin-left: auto;
background-color: #d4edda;
border-right: 4rpx solid #28a745;
}
.message-item.received {
align-self: flex-start;
background-color: #e9ecef;
border-left: 4rpx solid #007bff;
}
.message-item.system {
align-self: center;
background-color: #fff3cd;
border-left: 4rpx solid #856404;
max-width: 90%;
}
.message-item.error {
align-self: center;
background-color: #f8d7da;
border-left: 4rpx solid #dc3545;
max-width: 90%;
}
.message-header {
display: flex;
justify-content: space-between;
margin-bottom: 10rpx;
font-size: 24rpx;
color: #666;
}
.message-content {
font-size: 28rpx;
line-height: 1.5;
word-break: break-all;
}
.input-area {
background-color: white;
padding: 20rpx;
border-radius: 10rpx;
}
.message-input {
width: 100%;
height: 120rpx;
padding: 20rpx;
margin-bottom: 20rpx;
border: 1px solid #ddd;
border-radius: 10rpx;
font-size: 28rpx;
background-color: #f9f9f9;
}
.btn-group {
display: flex;
justify-content: flex-end;
}
*/

170
replace_comments.py Normal file
View 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()

View 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);

View File

@@ -3,23 +3,22 @@ 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
CHARSET = utf8mb4
DEBUG = true
[redis]
HOST = 127.0.0.1
[REDIS]
HOST = redis
PORT = 6379
PASSWORD = ''
EXPIRY = 604800
PASSWORD = 'luckyshop123!@#'
EXPIRY = 10800

View File

@@ -10,16 +10,16 @@ default_lang = zh-cn
[DATABASE]
TYPE = mysql
HOSTNAME = production_mysql_host
DATABASE = shop_mallnew_prod
USERNAME = prod_user
PASSWORD = prod_password
HOSTNAME = db
DATABASE = shop_mallnew
USERNAME = shop_mallnew
PASSWORD = shop_mallnew
HOSTPORT = 3306
CHARSET = utf8
CHARSET = utf8mb4
DEBUG = false
[redis]
HOST = production_redis_host
[REDIS]
HOST = redis
PORT = 6379
PASSWORD = production_redis_password
EXPIRY = 86400
PASSWORD = 'luckyshop123!@#'
EXPIRY = 10800

View File

@@ -1,22 +1,24 @@
APP_DEBUG = true
APP_TRACE = true
[APP]
DEFAULT_TIMEZONE = Asia/Shanghai
[LANG]
default_lang = zh-cn
[DATABASE]
TYPE = mysql
HOSTNAME = newshop_mysql
DATABASE = shop_dev
HOSTNAME = db
DATABASE = shop_mallnew
USERNAME = shop_mallnew
PASSWORD = shop_mallnew
HOSTPORT = 3306
CHARSET = utf8
CHARSET = utf8mb4
DEBUG = true
[RRDATABASE]
HOSTNAME = 192.168.2.64
[redis]
HOST = newshop_redis
[REDIS]
HOST = redis
PORT = 6379
PASSWORD = 'luckyshop123!@#'
EXPIRY = 604800
EXPIRY = 10800

24
src/.env.test Normal file
View File

@@ -0,0 +1,24 @@
APP_DEBUG = true
APP_TRACE = true
[APP]
DEFAULT_TIMEZONE = Asia/Shanghai
[LANG]
default_lang = zh-cn
[DATABASE]
TYPE = mysql
HOSTNAME = db
DATABASE = shop_mallnew
USERNAME = shop_mallnew
PASSWORD = shop_mallnew
HOSTPORT = 3306
CHARSET = utf8mb4
DEBUG = true
[REDIS]
HOST = redis
PORT = 6379
PASSWORD = 'luckyshop123!@#'
EXPIRY = 10800

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
<?php
/**
* 智能客服扩展事件配置
*/
return [
'bind' => [
],
'listen' => [
'KefuGetConfig' => [
'addon\aikefu\event\KefuGetConfig'
],
'KefuChat' => [
'addon\aikefu\event\KefuChat'
],
'KefuGetHistory' => [
'addon\aikefu\event\KefuGetHistory'
],
'KefuClearConversation' => [
'addon\aikefu\event\KefuClearConversation'
],
'KefuHealthCheck' => [
'addon\aikefu\event\KefuHealthCheck'
],
'KefuGetInfo' => [
'addon\aikefu\event\KefuGetInfo'
],
],
'subscribe' => [
],
];

View 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' => '',
];

View File

@@ -0,0 +1,43 @@
-- 智能客服插件安装脚本
-- 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 COLLATE=utf8mb4_unicode_ci COMMENT='智能客服会话表';
-- 索引
ALTER TABLE `lucky_aikefu_conversation`
ADD INDEX `idx_status` (`status`);
-- 创建智能客服消息表
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 '消息内容',
`status` varchar(20) NOT NULL DEFAULT 'completed' COMMENT '消息状态streaming(流式中), completed(已完成), failed(失败)',
`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 COLLATE=utf8mb4_unicode_ci COMMENT='智能客服消息表';
-- 索引
ALTER TABLE `lucky_aikefu_message`
ADD INDEX `idx_status` (`status`);

View File

@@ -0,0 +1,4 @@
-- 智能客服插件卸载脚本
-- 删除智能客服相关表(配置信息存储在系统配置表中,无需单独删除)
DROP TABLE IF EXISTS `lucky_aikefu_message`;
DROP TABLE IF EXISTS `lucky_aikefu_conversation`;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,595 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>流式聊天测试 Demo</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 20px;
}
h1 {
text-align: center;
color: #333;
}
.method-selector {
margin-bottom: 20px;
text-align: center;
}
.method-btn {
padding: 10px 20px;
margin: 0 10px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
cursor: pointer;
font-size: 14px;
}
.method-btn.active {
background-color: #0056b3;
}
#chat-container {
border: 1px solid #ddd;
border-radius: 4px;
height: 400px;
overflow-y: auto;
margin-bottom: 20px;
padding: 10px;
background-color: #fafafa;
}
.message {
margin-bottom: 15px;
padding: 10px;
border-radius: 8px;
max-width: 80%;
}
.user-message {
background-color: #007bff;
color: white;
margin-left: auto;
}
.ai-message {
background-color: #e9ecef;
color: #333;
margin-right: auto;
white-space: pre-wrap;
}
.input-area {
display: flex;
gap: 10px;
}
#message-input {
flex: 1;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
#send-btn {
padding: 12px 20px;
background-color: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
#send-btn:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
.api-config {
margin-bottom: 20px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f8f9fa;
}
.api-config h3 {
margin-top: 0;
margin-bottom: 10px;
color: #333;
}
.input-group {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.input-group label {
font-size: 14px;
color: #666;
white-space: nowrap;
}
.input-group input {
flex: 1;
min-width: 300px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
#save-api-url {
padding: 8px 16px;
background-color: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
#save-api-url:hover {
background-color: #218838;
}
#reset-api-url {
padding: 8px 16px;
background-color: #6c757d;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
#reset-api-url:hover {
background-color: #5a6268;
}
.config-hint {
font-size: 12px;
color: #666;
margin-top: 5px;
margin-bottom: 0;
}
.status {
margin-top: 10px;
font-size: 12px;
color: #666;
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<h1>流式聊天测试 Demo</h1>
<div class="api-config">
<h3>API 配置</h3>
<div class="input-group">
<label for="api-url-input">API URL:</label>
<input type="text" id="api-url-input" placeholder="输入完整 API URL 或仅域名(默认添加 /api/kefu/chat">
<button id="save-api-url">保存</button>
<button id="reset-api-url">重置默认</button>
</div>
<p class="config-hint">提示:您可以只修改域名部分(如 http://localhost:8050系统会自动添加默认路径 /api/kefu/chat</p>
</div>
<div class="method-selector">
<h3>选择请求方式:</h3>
<button class="method-btn active" data-method="eventsource">EventSource</button>
<button class="method-btn" data-method="fetch">Fetch API</button>
</div>
<div id="chat-container"></div>
<div class="input-area">
<input type="text" id="message-input" placeholder="输入消息...">
<button id="send-btn">发送</button>
</div>
<div class="status">
<span id="status-text">就绪</span>
</div>
</div>
<script>
// 配置
let API_URL = localStorage.getItem('apiUrl') || 'http://localhost:8050/api/kefu/chat';
const UNIACID = '1';
let conversationId = '';
let currentMethod = 'eventsource';
let es = null;
let controller = null;
// DOM 元素
const chatContainer = document.getElementById('chat-container');
const messageInput = document.getElementById('message-input');
const sendBtn = document.getElementById('send-btn');
const statusText = document.getElementById('status-text');
const methodBtns = document.querySelectorAll('.method-btn');
const apiUrlInput = document.getElementById('api-url-input');
const saveApiUrlBtn = document.getElementById('save-api-url');
const resetApiUrlBtn = document.getElementById('reset-api-url');
// 事件监听
messageInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
sendMessage();
}
});
sendBtn.addEventListener('click', sendMessage);
methodBtns.forEach(btn => {
btn.addEventListener('click', () => {
methodBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentMethod = btn.dataset.method;
statusText.textContent = `已切换到 ${btn.textContent} 方式`;
});
});
// API URL 保存事件
saveApiUrlBtn.addEventListener('click', () => {
let newApiUrl = apiUrlInput.value.trim();
if (newApiUrl) {
// 检查是否只输入了域名(没有 /api/kefu/chat 路径)
if (!newApiUrl.includes('/api/kefu/chat')) {
// 确保域名末尾没有斜杠
newApiUrl = newApiUrl.replace(/\/$/, '') + '/api/kefu/chat';
}
localStorage.setItem('apiUrl', newApiUrl);
API_URL = newApiUrl;
// 更新输入框显示完整 URL
apiUrlInput.value = newApiUrl;
statusText.textContent = 'API URL 已保存';
setTimeout(() => {
statusText.textContent = '就绪';
}, 2000);
}
});
// 重置默认 API URL
resetApiUrlBtn.addEventListener('click', () => {
const defaultApiUrl = 'http://localhost:8050/api/kefu/chat';
localStorage.setItem('apiUrl', defaultApiUrl);
API_URL = defaultApiUrl;
apiUrlInput.value = defaultApiUrl;
statusText.textContent = '已重置为默认 API URL';
setTimeout(() => {
statusText.textContent = '就绪';
}, 2000);
});
// 发送消息
function sendMessage() {
const message = messageInput.value.trim();
if (!message) return;
// 清空输入框
messageInput.value = '';
// 添加用户消息到聊天记录
addMessageToChat(message, 'user');
// 禁用发送按钮
sendBtn.disabled = true;
statusText.textContent = '正在发送请求...';
// 根据选择的方式发送请求
if (currentMethod === 'eventsource') {
sendEventSourceRequest(message);
} else {
sendFetchStreamRequest(message);
}
}
// EventSource 方式
function sendEventSourceRequest(message) {
// 关闭之前的连接
if (es) {
es.close();
es = null;
}
// 构建请求 URL
const params = new URLSearchParams({
uniacid: UNIACID,
user_id: '123456',
query: message,
conversation_id: conversationId || '',
stream: 'true'
});
const url = `${API_URL}?${params.toString()}`;
try {
statusText.textContent = 'EventSource 连接中...';
es = new EventSource(url);
let aiMessage = '';
// 监听消息事件
es.addEventListener('message', (event) => {
console.log('收到消息:', event);
try {
const data = JSON.parse(event.data);
if (data.event === 'message') {
// 更新 AI 消息
aiMessage += data.answer || '';
updateAIMessage(aiMessage);
}
if (data.event === 'message_end') {
statusText.textContent = '对话完成';
// 不要在这里关闭连接等待done和close事件
}
if (data.conversation_id) {
conversationId = data.conversation_id;
}
} catch (error) {
console.error('解析消息失败:', error);
}
});
// 监听完成事件
es.addEventListener('done', (event) => {
console.log('收到完成事件:', event);
try {
const data = JSON.parse(event.data);
if (data.conversation_id) {
conversationId = data.conversation_id;
}
statusText.textContent = '对话完成';
sendBtn.disabled = false;
} catch (error) {
console.error('解析完成事件失败:', error);
}
});
// 监听连接关闭事件
es.addEventListener('close', (event) => {
console.log('收到连接关闭事件:', event);
try {
const data = JSON.parse(event.data);
console.log('连接正常结束:', data);
if (es) {
es.close();
es = null;
}
} catch (error) {
console.error('解析关闭事件失败:', error);
}
});
// 监听错误事件
es.addEventListener('error', (error) => {
console.error('EventSource 错误:', error);
// 添加更详细的错误信息
if (es.readyState === EventSource.CLOSED) {
console.log('EventSource 连接已正常关闭');
statusText.textContent = '连接已关闭';
} else if (es.readyState === EventSource.CONNECTING) {
console.error('EventSource 连接中出现错误');
statusText.textContent = '连接错误';
} else {
console.error('EventSource 未知错误');
statusText.textContent = '连接错误';
}
sendBtn.disabled = false;
if (es) {
es.close();
es = null;
}
});
} catch (error) {
console.error('创建 EventSource 失败:', error);
statusText.textContent = '请求失败';
sendBtn.disabled = false;
}
}
// Fetch API 流式请求方式
async function sendFetchStreamRequest(message) {
// 取消之前的请求
if (controller) {
controller.abort();
controller = null;
}
// 创建 AbortController
controller = new AbortController();
const signal = controller.signal;
// 构建请求体
const body = new URLSearchParams({
uniacid: UNIACID,
user_id: '123456',
query: message,
conversation_id: conversationId || '',
stream: 'true'
});
try {
statusText.textContent = 'Fetch 连接中...';
const response = await fetch(API_URL, {
method: 'POST',
body: body,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'text/event-stream'
},
signal: signal
});
if (!response.ok) {
throw new Error(`HTTP 错误! 状态: ${response.status}`);
}
if (!response.body) {
throw new Error('响应体不可用');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let aiMessage = '';
statusText.textContent = '接收流式响应...';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 处理接收到的数据
buffer = processStreamData(buffer, (newData) => {
if (newData) {
// 更新 AI 消息
aiMessage += newData;
updateAIMessage(aiMessage);
}
});
}
// 处理剩余数据
buffer = processStreamData(buffer, (newData) => {
if (newData) {
aiMessage += newData;
updateAIMessage(aiMessage);
}
});
statusText.textContent = '对话完成';
sendBtn.disabled = false;
} catch (error) {
if (error.name === 'AbortError') {
statusText.textContent = '请求已取消';
} else {
console.error('Fetch 请求失败:', error);
statusText.textContent = `请求失败: ${error.message}`;
}
sendBtn.disabled = false;
}
}
// 处理流式数据
function processStreamData(buffer, callback) {
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 最后一行可能不完整
lines.forEach(line => {
line = line.trim();
if (!line) return;
// 解析 SSE 格式
if (line.startsWith('data:')) {
const dataPart = line.slice(5).trim();
if (dataPart) {
try {
const data = JSON.parse(dataPart);
if (data.event === 'message') {
callback(data.answer || '');
}
if (data.conversation_id) {
conversationId = data.conversation_id;
}
if (data.event === 'message_end') {
// 对话完成
console.log('对话完成');
}
} catch (error) {
console.error('解析流式数据失败:', error);
}
}
}
});
return buffer;
}
// 添加消息到聊天记录
function addMessageToChat(message, type) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${type}-message`;
messageDiv.textContent = message;
chatContainer.appendChild(messageDiv);
chatContainer.scrollTop = chatContainer.scrollHeight;
// 如果是用户消息,添加一个临时的 AI 消息容器
if (type === 'user') {
const aiMessageDiv = document.createElement('div');
aiMessageDiv.id = 'temp-ai-message';
aiMessageDiv.className = 'message ai-message';
chatContainer.appendChild(aiMessageDiv);
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}
// 更新 AI 消息
function updateAIMessage(message) {
let aiMessageDiv = document.getElementById('temp-ai-message');
if (!aiMessageDiv) {
// 如果临时容器不存在,创建一个新的
aiMessageDiv = document.createElement('div');
aiMessageDiv.id = 'temp-ai-message';
aiMessageDiv.className = 'message ai-message';
chatContainer.appendChild(aiMessageDiv);
}
aiMessageDiv.textContent = message;
chatContainer.scrollTop = chatContainer.scrollHeight;
}
// 页面卸载时清理资源
window.addEventListener('beforeunload', () => {
if (es) {
es.close();
}
if (controller) {
controller.abort();
}
});
// 初始化
apiUrlInput.value = API_URL;
statusText.textContent = '就绪,使用 EventSource 方式';
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View 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);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace addon\aikefu\event;
use addon\aikefu\api\controller\Kefu as KefuApi;
/**
* 客服聊天
*/
class KefuChat
{
/**
* 处理智能客服聊天事件
* @param array $data 事件数据
* @return array|null
*/
public function handle($data)
{
try {
// 创建addon的KefuApi实例
$kefu_api = new KefuApi();
// 调用初始化方法设置属性
$kefu_api->initializeForEvent($data);
// 调用addon的chat方法
$response = $kefu_api->chat();
// 对于流式请求,直接输出不返回数据
if (isset($data['stream']) && $data['stream']) {
return null;
}
// 返回响应数据
return json_decode($response->getContent(), true);
} catch (\Exception $e) {
return [
'code' => -1,
'message' => '聊天失败:' . $e->getMessage(),
'data' => []
];
}
}
}

View 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' => []
];
}
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace addon\aikefu\event;
use addon\aikefu\model\Config as KefuConfigModel;
/**
* 获取智能客服配置信息
*/
class KefuGetConfig
{
/**
* 处理获取配置信息事件
* @param array $data 事件数据
* @return array
*/
public function handle($data)
{
$site_id = $data['site_id'] ?? 0;
$response_data = [
'enabled' => false,
'status' => 'disabled'
];
try {
// 获取智能客服配置
$kefu_config_model = new KefuConfigModel();
$config_info = $kefu_config_model->getConfig($site_id);
$response_data = [
'enabled' => false,
'status' => 'disabled'
];
// 处理配置信息
if (!empty($config_info['data']['value'])) {
$config = $config_info['data']['value'];
// 服务状态
$response_data['enabled'] = $config['status'] == 1;
$response_data['status'] = $config['status'] == 1 ? 'enabled' : 'disabled';
}
return $response_data;
} catch (\Exception $e) {
$response_data['status'] = 'error';
$response_data['error'] = $e->getMessage();
return $response_data;
}
}
}

View 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' => []
];
}
}
}

View File

@@ -0,0 +1,125 @@
<?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',
'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()
]
];
}
}
}

View 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;
}
}

View 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);
}
}

File diff suppressed because one or more lines are too long

View 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;
}
}

View 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);
}
}

View File

@@ -0,0 +1,214 @@
<?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
* @param string $status 状态过滤
* @return array
*/
public function getConversationMessages($site_id, $conversation_id, $limit = 20, $offset = 0, $status = '')
{
$condition = [
['site_id', '=', $site_id],
['conversation_id', '=', $conversation_id]
];
// 添加状态过滤
if (!empty($status)) {
$condition[] = ['status', '=', $status];
}
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]);
}
}

View File

@@ -0,0 +1,319 @@
<?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);
// 适配layui table的返回格式同时保持与Dify API风格一致
$result = [
'code' => 0, // layui table要求成功状态码为0
'msg' => '获取会话列表成功',
'count' => $conversation_list['total'], // 总记录数
'data' => [
'conversations' => $conversation_list['data'], // 会话列表
'page_info' => [
'limit' => $limit,
'offset' => ($page - 1) * $limit
]
] // 数据列表
];
return json($result);
}
/**
* 获取会话信息
* @return \think\response\Json
*/
public function getConversationInfo()
{
$conversation_id = input("conversation_id/s", "");
if (empty($conversation_id)) {
return json([
'code' => -1,
'msg' => '会话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 json([
'code' => -1,
'msg' => '会话不存在',
]);
}
return json([
'code' => 0,
'msg' => '获取会话信息成功',
'data' => $conversation_info,
]);
}
/**
* 结束会话
* @return \think\response\Json
*/
public function endConversation()
{
$id = input("id/d", "");
if (empty($id)) {
return json([
'code' => -1,
'msg' => '会话ID不能为空',
]);
}
$kefu_conversation_model = new KefuConversationModel();
$result = $kefu_conversation_model->updateConversation(
['status' => 0],
[
['id', '=', $id],
['site_id', '=', $this->site_id]
]
);
if ($result === false) {
return json([
'code' => -1,
'msg' => '会话结束失败',
]);
}
return json([
'code' => 0,
'msg' => '会话已结束',
'data' => $result,
]);
}
/**
* 删除会话
* @return \think\response\Json
*/
public function deleteConversation()
{
$id = input("id/d", "");
$ids = input("ids/a", []);
// 验证参数
if (empty($id) && empty($ids)) {
return json([
'code' => -1,
'msg' => '会话ID不能为空',
]);
}
// 处理单个ID或ID数组
if (!empty($id)) {
$ids = [$id];
}
$kefu_conversation_model = new KefuConversationModel();
$kefu_message_model = new KefuMessageModel();
// 开启事务
Db::startTrans();
try {
// 获取所有要删除的会话信息
$conversations = $kefu_conversation_model->getConversationList([
['id', 'in', $ids],
['site_id', '=', $this->site_id]
]);
// 删除所有会话关联的消息
if (!empty($conversations['data'])) {
foreach ($conversations['data'] as $conversation) {
$kefu_message_model->deleteMessage([
['site_id', '=', $this->site_id],
['conversation_id', '=', $conversation['conversation_id']]
]);
}
}
// 删除会话
$result = $kefu_conversation_model->deleteConversation([
['id', 'in', $ids],
['site_id', '=', $this->site_id]
]);
// 提交事务
Db::commit();
return json([
'code' => 0,
'msg' => '会话已删除',
'data' => $result,
]);
} catch (\Exception $e) {
// 回滚事务
Db::rollback();
return json([
'code' => -1,
'msg' => $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", "");
$user_id = input("user_id/s", "");
$sort_field = input("sort_field/s", "create_time"); // 排序字段
$sort_order = input("sort_order/s", "desc"); // 排序方式asc或desc
$status = input("status/s", "completed"); // 默认只显示已完成的消息
$kefu_message_model = new KefuMessageModel();
$condition = [
['site_id', '=', $this->site_id]
];
// 只有当会话ID不为空时才添加会话ID条件
if (!empty($conversation_id)) {
$condition[] = ['conversation_id', '=', $conversation_id];
}
// 只有当用户ID不为空时才添加用户ID条件
if (!empty($user_id)) {
$condition[] = ['user_id', '=', $user_id];
}
// 添加状态过滤(默认只显示已完成的消息,避免显示临时数据)
if (!empty($status)) {
$condition[] = ['status', '=', $status];
}
// 构建排序字符串
$order = $sort_field . ' ' . $sort_order;
$message_list = $kefu_message_model->getMessageList($condition, '*', $order, $page, $limit);
// 适配layui table的返回格式同时保持与Dify API风格一致
$result = [
'code' => 0, // layui table要求成功状态码为0
'msg' => '获取消息列表成功',
'count' => $message_list['total'], // 总记录数
'data' => [
'messages' => $message_list['data'], // 消息列表
'page_info' => [
'limit' => $limit,
'offset' => ($page - 1) * $limit,
'total' => $message_list['total'] // 添加总记录数到page_info
]
] // 数据列表
];
return json($result);
}
}

View 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"><span class="required">*</span>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>

View File

@@ -0,0 +1,492 @@
<style>
.search-box {
padding: 15px;
border-bottom: 1px solid #eee;
margin-bottom: 15px;
background-color: #f9f9f9;
border-radius: 4px;
}
.layui-btn-container {
margin-bottom: 15px;
text-align: right;
}
.layui-btn-sm {
margin-left: 5px;
}
.status-active {
color: #52c41a;
}
.status-inactive {
color: #faad14;
}
/* 消息列表样式优化 - 从message.html复制 */
.message-list {
max-height: 650px;
overflow-y: auto;
padding: 25px;
background-color: #fafbfc;
border-radius: 8px;
margin-bottom: 20px;
border: 1px solid #e8e8e8;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
}
/* 消息项样式 */
.message-item {
margin-bottom: 25px;
display: flex;
align-items: flex-start;
}
.message-item.user {
justify-content: flex-end;
}
.message-item.assistant {
justify-content: flex-start;
}
/* 头像样式 */
.message-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
margin: 0 12px;
border: 2px solid #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
/* 消息内容样式 */
.message-content {
max-width: 70%;
padding: 16px 20px;
border-radius: 18px;
word-wrap: break-word;
line-height: 1.6;
position: relative;
}
.message-item.user .message-content {
background-color: #1E9FFF;
color: white;
border-bottom-right-radius: 4px;
box-shadow: 0 2px 8px rgba(30, 159, 255, 0.3);
}
.message-item.assistant .message-content {
background-color: white;
color: #333;
border-bottom-left-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 消息时间样式 */
.message-time {
font-size: 12px;
color: #999;
margin-top: 8px;
text-align: center;
}
/* 消息角色样式 */
.message-role {
font-size: 13px;
font-weight: 500;
margin-bottom: 6px;
}
.message-item.user .message-role {
color: rgba(106, 38, 38, 0.9);
text-align: right;
}
.message-item.assistant .message-role {
color: #666;
text-align: left;
}
/* 空状态样式 */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-state i {
font-size: 48px;
margin-bottom: 15px;
display: block;
}
/* 响应式设计 */
@media (max-width: 768px) {
.message-content {
max-width: 85%;
}
}
</style>
<div class="layui-card-body">
<!-- 搜索区域 -->
<div class="search-box layui-form">
<div class="layui-form-item" style="margin-bottom: 0;">
<div class="layui-inline">
<label class="layui-form-label">用户ID</label>
<div class="layui-input-inline" style="width: 150px;">
<input type="text" name="user_id" id="user_id" placeholder="请输入用户ID" class="layui-input">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">状态</label>
<div class="layui-input-inline" style="width: 120px;">
<select name="status" id="status" class="layui-select">
<option value="">全部</option>
<option value="1">活跃</option>
<option value="0">已结束</option>
</select>
</div>
</div>
<div class="layui-inline">
<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>
</div>
</div>
<!-- 表格区域 -->
<table class="layui-table" id="conversationTable" lay-filter="conversationTable"></table>
</div>
<script type="text/html" id="toolbarDemo">
<div class="layui-btn-container">
<button type="button" class="layui-btn layui-btn-danger" id="batchDeleteBtn">批量删除</button>
</div>
</script>
<script type="text/html" id="barDemo">
<div style="display: flex; gap: 5px;">
<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>
</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>
<!-- 消息列表模板 - 与message.html一致 -->
<script type="text/html" id="messageListTpl">
<div class="message-list">
{{# if(d.length > 0) {
d.forEach(function(item) {
var role = item.role === 'user' ? (item.user_id ? '用户-' + item.user_id : '用户') : '机器人';
var roleClass = item.role === 'user' ? 'user' : 'assistant';
var avatar = item.role === 'user' ? '/addon/aikefu/static/images/user.svg' : '/addon/aikefu/static/images/robot.svg';
<div class="message-item {{= roleClass }}">
{{# if(item.role === 'assistant') { }}}
<img src="{{= avatar }}" class="message-avatar">
{{# } }}
<div>
<div class="message-role">{{= role }}</div>
<div class="message-content">{{= item.content }}</div>
<div class="message-time">{{= item.create_time }}</div>
</div>
{{# if(item.role === 'user') { }}}
<img src="{{= avatar }}" class="message-avatar">
{{# } }}
</div>
});
} else {
<div class="empty-state">
<i class="layui-icon layui-icon-message"></i>
<p>暂无消息记录</p>
</div>
} }}
</div>
</script>
<script>
layui.use(['table', 'form', 'layer', 'laypage'], function() {
var table = layui.table;
var form = layui.form;
var layer = layui.layer;
var laypage = layui.laypage;
// 渲染表格
var tableIns = table.render({
elem: '#conversationTable',
url: ns.url("aikefu://shop/kefu/getConversationList"),
method: 'POST',
toolbar: '#toolbarDemo',
defaultToolbar: ['filter', 'exports', 'print'],
title: '会话管理',
width: '100%',
cols: [[
{type: 'checkbox', fixed: 'left'},
{field: 'conversation_id', title: '会话ID', width: 300, align: 'center'},
{field: 'user_id', title: '用户ID', width: 120, 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: '暂无会话数据',
error: '加载数据失败,请检查网络或稍后重试'
},
parseData: function(res) {
// 调试:打印原始数据
console.log('API返回原始数据:', res);
// 确保res存在
if (!res) {
return {
"code": -1,
"msg": "服务器返回数据为空",
"count": 0,
"data": []
};
}
// 适配后端返回的格式:
// 后端返回的data字段包含conversations数组和page_info对象
// 根级的count字段是总记录数
return {
"code": res.code || 0,
"msg": res.msg || "获取数据成功",
"count": res.count || 0, // 使用根级的count字段
"data": Array.isArray(res.data?.conversations) ? res.data.conversations : [] // 使用data.conversations作为表格数据
};
}
});
// 搜索按钮点击事件
$('#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: 1,
title: '消息记录',
content: '<div style="padding: 25px; max-height: 700px;"><div class="message-list" style="max-height: 580px; overflow-y: auto; margin-bottom: 20px;">加载中...</div><div id="messagePage" style="text-align: center;"></div></div>',
area: ['80%', '70%'],
success: function(layero, index) {
var currentPage = 1;
var pageSize = 15; // 每页显示15条消息
var total = 0;
// 加载消息列表函数
function loadMessages(page) {
$.ajax({
url: ns.url("aikefu://shop/kefu/getMessageList"),
type: 'POST',
data: {
conversation_id: data.conversation_id,
page: page,
limit: pageSize,
sort_field: 'create_time',
sort_order: 'desc' // 默认最新消息在前面
},
dataType: 'json',
success: function(res) {
var messageList = res.data?.messages || [];
total = res.data?.total || 0;
var html = '';
if (messageList.length > 0) {
messageList.forEach(function(item) {
var role = item.role === 'user' ? (item.user_id ? '用户-' + item.user_id : '用户') : '机器人';
var roleClass = item.role === 'user' ? 'user' : 'assistant';
var avatar = item.role === 'user' ? '/addon/aikefu/static/images/user.svg' : '/addon/aikefu/static/images/robot.svg';
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 class="empty-state">';
html += '<i class="layui-icon layui-icon-message"></i>';
html += '<p>暂无消息记录</p>';
html += '</div>';
}
layero.find('.message-list').html(html);
// 滚动到顶部显示当前页消息
layero.find('.message-list').scrollTop(0);
// 渲染分页控件
initPagination(total, page);
},
error: function() {
layero.find('.message-list').html('<div class="empty-state">加载消息失败</div>');
}
});
}
// 初始化分页控件
function initPagination(total, curr) {
layui.laypage.render({
elem: layero.find('#messagePage')[0],
count: total,
limit: pageSize,
curr: curr,
layout: ['prev', 'page', 'next', 'count', 'skip'],
jump: function(obj, first) {
if (!first) {
currentPage = obj.curr;
loadMessages(currentPage);
}
}
});
}
// 初始加载第一页消息
loadMessages(currentPage);
}
});
} 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);
});
}
});
// 批量删除按钮点击事件
$('#batchDeleteBtn').click(function() {
// 获取选中的行数据
var checkStatus = table.checkStatus('conversationTable');
var data = checkStatus.data;
if (data.length === 0) {
layer.msg('请选择要删除的会话', {icon: 2});
return;
}
// 提取选中的会话ID
var ids = [];
for (var i = 0; i < data.length; i++) {
ids.push(data[i].id);
}
// 确认删除
layer.confirm('确定要删除选中的 ' + data.length + ' 个会话吗?删除后将无法恢复', function(index) {
$.ajax({
url: ns.url("aikefu://shop/kefu/deleteConversation"),
type: 'POST',
data: {ids: ids},
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>

View 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>

View File

@@ -0,0 +1,521 @@
<style>
/* 整体页面样式优化 */
.layui-card-body {
padding: 20px;
}
/* 搜索区域样式 */
.search-box {
margin-bottom: 20px;
padding: 20px;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
border: 1px solid #e8e8e8;
}
.search-form {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
.search-item {
display: flex;
align-items: center;
}
.search-item label {
width: 80px;
text-align: right;
margin-right: 10px;
font-weight: 500;
color: #333;
}
/* 会话信息区域样式 */
.conversation-info {
margin-bottom: 20px;
padding: 20px;
background-color: #f7f9fc;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
border: 1px solid #e8e8e8;
}
.conversation-info h3 {
margin: 0 0 15px 0;
font-size: 18px;
color: #333;
font-weight: 600;
border-bottom: 2px solid #1E9FFF;
padding-bottom: 8px;
display: inline-block;
}
.conversation-info p {
margin: 10px 0;
font-size: 14px;
color: #666;
}
/* 消息列表样式优化 */
.message-list {
max-height: 650px;
overflow-y: auto;
padding: 25px;
background-color: #fafbfc;
border-radius: 8px;
margin-bottom: 20px;
border: 1px solid #e8e8e8;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
}
/* 消息项样式 */
.message-item {
margin-bottom: 25px;
display: flex;
align-items: flex-start;
}
.message-item.user {
justify-content: flex-end;
}
.message-item.assistant {
justify-content: flex-start;
}
/* 头像样式 */
.message-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
margin: 0 12px;
border: 2px solid #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
/* 消息内容样式 */
.message-content {
max-width: 70%;
padding: 16px 20px;
border-radius: 18px;
word-wrap: break-word;
line-height: 1.6;
position: relative;
}
.message-item.user .message-content {
background-color: #1E9FFF;
color: white;
border-bottom-right-radius: 4px;
box-shadow: 0 2px 8px rgba(30, 159, 255, 0.3);
}
.message-item.assistant .message-content {
background-color: white;
color: #333;
border-bottom-left-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 消息时间样式 */
.message-time {
font-size: 12px;
color: #999;
margin-top: 8px;
text-align: center;
}
/* 消息角色样式 */
.message-role {
font-size: 13px;
font-weight: 500;
margin-bottom: 6px;
}
.message-item.user .message-role {
color: rgba(106, 38, 38, 0.9);
text-align: right;
}
.message-item.assistant .message-role {
color: #666;
text-align: left;
}
/* 状态标签样式 */
.status-active {
color: #52c41a;
font-weight: 500;
}
.status-inactive {
color: #faad14;
font-weight: 500;
}
/* 分页样式 */
#msg_pagination {
text-align: center;
margin-top: 20px;
}
/* 按钮样式 */
.layui-btn {
border-radius: 6px;
}
.layui-btn-primary:hover {
border-color: #1E9FFF;
color: #1E9FFF;
}
/* 空状态样式 */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-state i {
font-size: 48px;
margin-bottom: 15px;
display: block;
}
/* 响应式设计 */
@media (max-width: 768px) {
.message-content {
max-width: 85%;
}
.search-form {
flex-direction: column;
align-items: stretch;
}
.search-item {
margin-bottom: 10px;
}
.search-item label {
width: auto;
text-align: left;
}
}
</style>
<div class="layui-card-body">
<!-- 搜索区域 -->
<div class="search-box">
<form class="layui-form search-form" id="msg_searchForm">
<div class="search-item">
<label for="conversation_id">会话ID</label>
<input type="text" name="conversation_id" id="msg_conversation_id" placeholder="请输入会话ID进行过滤" value="{$conversation_id ?? ''}" class="layui-input" style="width: 250px; display: inline-block;">
</div>
<div class="search-item">
<label for="user_id">用户ID</label>
<input type="text" name="user_id" id="msg_user_id" placeholder="请输入用户ID进行过滤" value="{$user_id ?? ''}" class="layui-input" style="width: 200px; display: inline-block;">
</div>
<div class="search-item">
<label for="status">消息状态</label>
<div class="layui-input-inline" style="width: 150px;">
<select name="status" id="msg_status" class="layui-select">
<option value="completed">已完成</option>
<option value="streaming">流式中</option>
<option value="failed">失败</option>
<option value="">全部状态</option>
</select>
</div>
</div>
<div class="search-item">
<label for="sort_order">排序方式</label>
<div class="layui-input-inline" style="width: 200px;">
<select name="sort_order" id="msg_sortOrder" class="layui-select">
<option value="desc">最新消息在前面</option>
<option value="asc">最新消息在后面</option>
</select>
</div>
</div>
<div class="search-item">
<button type="button" class="layui-btn layui-btn-primary" id="msg_searchBtn">搜索</button>
<button type="button" class="layui-btn" id="msg_resetBtn">重置</button>
</div>
</form>
</div>
<!-- 会话信息 -->
<div id="msg_conversationInfo" class="conversation-info">
<h3>会话记录</h3>
<p><span class="status-active">当前未选择会话可通过会话ID进行过滤</span></p>
</div>
<!-- 消息列表 -->
<div class="message-list" id="msg_messageList">
<!-- 消息列表将通过JavaScript动态加载 -->
</div>
<!-- 分页 -->
<div id="msg_pagination"></div>
</div>
<script>
layui.use(['laypage', 'layer', 'form'], function() {
var laypage = layui.laypage;
var layer = layui.layer;
var form = layui.form;
// 渲染表单控件
form.render();
// 分页和排序参数
var page = 1;
var limit = 50;
var total = 0;
var conversation_id = $('#msg_conversation_id').val();
var user_id = $('#msg_user_id').val();
var status = 'completed'; // 默认只显示已完成的消息
var sortField = 'create_time'; // 排序字段:创建时间
var sortOrder = 'desc'; // 默认倒序(最新消息在前面)
// 加载会话信息当指定会话ID时显示
function loadConversationInfo() {
if (!conversation_id) {
$('#msg_conversationInfo').html('<h3>会话记录</h3><p><span class="status-active">当前未选择会话可通过会话ID进行过滤</span></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>';
$('#msg_conversationInfo').html(html);
} else {
$('#msg_conversationInfo').html('<h3>会话详情</h3><p>未找到该会话的详细信息</p>');
}
},
error: function() {
$('#msg_conversationInfo').html('<h3>会话详情</h3><p>加载会话信息失败</p>');
}
});
}
// 加载消息列表
function loadMessageList(forceScrollTop = false) {
// 构建请求数据
var requestData = {
page: page,
limit: limit,
sort_field: sortField, // 排序字段:创建时间
sort_order: sortOrder // 排序方式:倒序/正序
};
// 如果有会话ID则添加到请求数据中
if (conversation_id) {
requestData.conversation_id = conversation_id;
}
// 如果有用户ID则添加到请求数据中
if (user_id) {
requestData.user_id = user_id;
}
// 添加状态过滤
if (status) {
requestData.status = status;
}
$.ajax({
url: ns.url("aikefu://shop/kefu/getMessageList"),
type: 'POST',
data: requestData,
dataType: 'json',
success: function(res) {
// 调试:打印原始数据
console.log('消息API返回原始数据:', res);
// 确保res存在
if (!res) {
layer.msg('服务器返回数据为空', {icon: 2});
return;
}
if (res.code === 0) {
// 适配后端返回的格式
// 检查data字段是否存在
if (!res.data) {
layer.msg('服务器返回数据格式错误缺少data字段', {icon: 2});
return;
}
// 获取消息列表,确保是数组
var list = Array.isArray(res.data.messages) ? res.data.messages : [];
// 获取总记录数
// 支持多种可能的返回格式
var totalCount = 0;
if (res.data.page_info && typeof res.data.page_info.total === 'number') {
totalCount = res.data.page_info.total;
} else if (typeof res.count === 'number') {
totalCount = res.count;
} else if (typeof res.data.total === 'number') {
totalCount = res.data.total;
}
// 更新总记录数
total = totalCount;
var html = '';
if (list.length > 0) {
list.forEach(function(item) {
var role = item.role === 'user' ? (item.user_id ? '用户-' + item.user_id : '用户') : '机器人';
var roleClass = item.role === 'user' ? 'user' : 'assistant';
var avatar = item.role === 'user' ? '/addon/aikefu/static/images/user.svg' : '/addon/aikefu/static/images/robot.svg';
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 class="empty-state">';
html += '<i class="layui-icon layui-icon-message"></i>';
html += '<p>暂无消息记录</p>';
html += '</div>';
}
$('#msg_messageList').html(html);
// 调整滚动位置
if (forceScrollTop) {
// 搜索或分页时强制滚动到顶部
$('#msg_messageList').scrollTop(0);
} else if (sortOrder === 'asc') {
// 正序时滚动到顶部
$('#msg_messageList').scrollTop(0);
} else {
// 倒序时滚动到底部
$('#msg_messageList').scrollTop($('#msg_messageList')[0].scrollHeight);
}
// 渲染分页
renderPagination();
} else {
layer.msg('加载失败:' + (res.message || '未知错误'), {icon: 2});
}
},
error: function(xhr, status, error) {
console.error('消息API请求失败:', error);
layer.msg('请求失败,请稍后重试', {icon: 2});
}
});
}
// 渲染分页
function renderPagination() {
if (total <= limit) {
$('#msg_pagination').html('');
return;
}
// 确保分页容器存在
if ($('#msg_pagination').length === 0) {
console.error('分页容器不存在');
return;
}
laypage.render({
elem: 'msg_pagination',
count: total,
limit: limit,
curr: page,
layout: ['prev', 'page', 'next', 'count', 'skip'],
jump: function(obj, first) {
if (!first) {
// 验证页码有效性
var newPage = parseInt(obj.curr);
if (isNaN(newPage) || newPage < 1) {
console.error('无效的页码:', obj.curr);
return;
}
page = newPage;
loadMessageList(true); // 分页时强制滚动到顶部
}
},
done: function(obj, first) {
// 分页渲染完成后的回调
console.log('分页渲染完成,当前页:', obj.curr, '总记录数:', obj.count, '每页数量:', obj.limit);
}
});
}
// 搜索按钮点击事件
$('#msg_searchBtn').click(function() {
conversation_id = $('#msg_conversation_id').val().trim();
user_id = $('#msg_user_id').val().trim();
status = $('#msg_status').val(); // 获取当前选择的状态
sortOrder = $('#msg_sortOrder').val(); // 获取当前选择的排序方式
page = 1;
loadConversationInfo();
loadMessageList(true); // 搜索时强制滚动到顶部
});
// 重置按钮点击事件
$('#msg_resetBtn').click(function() {
$('#msg_conversation_id').val('');
$('#msg_user_id').val('');
$('#msg_status').val('completed'); // 重置为默认状态
$('#msg_sortOrder').val('desc'); // 重置为默认排序
conversation_id = '';
user_id = '';
status = 'completed'; // 重置为默认状态
sortOrder = 'desc'; // 重置为默认排序
page = 1;
loadConversationInfo();
loadMessageList(true); // 重置时强制滚动到顶部
});
// 排序方式变更事件
$('#msg_sortOrder').change(function() {
sortOrder = $(this).val();
page = 1; // 切换排序时重置到第一页
loadMessageList(true); // 切换排序时强制滚动到顶部
});
// 状态变更事件
$('#msg_status').change(function() {
status = $(this).val();
page = 1; // 切换状态时重置到第一页
loadMessageList(true); // 切换状态时强制滚动到顶部
});
// 初始化加载(默认显示所有消息)
loadConversationInfo();
loadMessageList();
});
</script>

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<rect x="5" y="10" width="30" height="20" rx="5" fill="#7C7C7C"/>
<circle cx="15" cy="20" r="3" fill="#FFFFFF"/>
<circle cx="25" cy="20" r="3" fill="#FFFFFF"/>
<path d="M15 30 L25 30" stroke="#FFFFFF" stroke-width="3"/>
<rect x="15" y="10" width="10" height="5" fill="#5C5C5C"/>
</svg>

After

Width:  |  Height:  |  Size: 379 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<circle cx="20" cy="15" r="10" fill="#4A90E2"/>
<path d="M30 35c0-5-4-9-9-9s-9 4-9 9" fill="#4A90E2"/>
</svg>

After

Width:  |  Height:  |  Size: 197 B

View File

@@ -1,38 +1,31 @@
<?php
/**
*/
return [
// 自定义模板页面类型,格式:[ 'title' => '页面类型名称', 'name' => '页面标识', 'path' => '页面路径', 'value' => '页面数据json格式' ]
'template' => [],
// 后台自定义组件——装修
'util' => [],
// 自定义页面路径
'link' => [],
// 自定义图标库
'icon_library' => [],
// uni-app 组件,格式:[ 'name' => '组件名称/文件夹名称', 'path' => '文件路径/目录路径' ]多个逗号隔开自定义组件名称前缀必须是diy-,也可以引用第三方组件
'component' => [],
// uni-app 页面,多个逗号隔开
'pages' => [],
// 模板信息,格式:'title' => '模板名称', 'name' => '模板标识', 'cover' => '模板封面图', 'preview' => '模板预览图', 'desc' => '模板描述'
'info' => [],
// 主题风格配色格式可以自由定义扩展【在uni-app中通过this.themeStyle... 获取定义的颜色字段例如this.themeStyle.main_color】
'theme' => [],
// 自定义页面数据,格式:[ 'title' => '页面名称', 'name' => "页面标识", 'value' => [页面数据json格式] ]
'data' => []
<?php
return [
// 自定义模板页面类型,格式:[ 'title' => '页面类型名称', 'name' => '页面标识', 'path' => '页面路径', 'value' => '页面数据json格式' ]
'template' => [],
// 后台自定义组件——装修
'util' => [],
// 自定义页面路径
'link' => [],
// 自定义图标库
'icon_library' => [],
// uni-app 组件,格式:[ 'name' => '组件名称/文件夹名称', 'path' => '文件路径/目录路径' ]多个逗号隔开自定义组件名称前缀必须是diy-,也可以引用第三方组件
'component' => [],
// uni-app 页面,多个逗号隔开
'pages' => [],
// 模板信息,格式:'title' => '模板名称', 'name' => '模板标识', 'cover' => '模板封面图', 'preview' => '模板预览图', 'desc' => '模板描述'
'info' => [],
// 主题风格配色格式可以自由定义扩展【在uni-app中通过this.themeStyle... 获取定义的颜色字段例如this.themeStyle.main_color】
'theme' => [],
// 自定义页面数据,格式:[ 'title' => '页面名称', 'name' => "页面标识", 'value' => [页面数据json格式] ]
'data' => []
];

View File

@@ -1,21 +1,13 @@
<?php
/**
*/
return [
'name' => 'alioss',
'title' => '阿里云OSS',
'description' => '阿里云OSS',
'type' => 'system', //插件类型 system :系统插件(自动安装), business:业务插件 promotion:营销插件 tool:工具插件
'status' => 1,
'author' => '',
'version' => '5.3.1',
'version_no' => '525231212001',
'content' => '',
<?php
return [
'name' => 'alioss',
'title' => '阿里云OSS',
'description' => '阿里云OSS',
'type' => 'system', //插件类型 system :系统插件(自动安装), business:业务插件 promotion:营销插件 tool:工具插件
'status' => 1,
'author' => '',
'version' => '5.3.1',
'version_no' => '525231212001',
'content' => '',
];

View File

@@ -1,39 +1,32 @@
<?php
/**
*/
namespace addon\alioss\event;
use addon\alioss\model\Alioss;
use addon\alioss\model\Config;
/**
* 删除阿里云图片
*/
class ClearAlbumPic
{
public function handle($params)
{
$config_model = new Config();
$alioss_model = new Alioss();
$config = $config_model->getAliossConfig($params[ 'site_id' ]);
if (!empty($config[ 'data' ])) {
if (!empty($config[ 'data' ][ 'value' ][ 'endpoint' ]) && strpos($params[ 'pic_path' ], $config[ 'data' ][ 'value' ][ 'endpoint' ]) === 0) {
$result = $alioss_model->deleteAlbumPic($params[ 'pic_path' ], $config[ 'data' ][ 'value' ][ 'endpoint' ]);
return $result;
}
if (!empty($config[ 'data' ][ 'value' ][ 'domain' ]) && strpos($params[ 'pic_path' ], $config[ 'data' ][ 'value' ][ 'domain' ]) === 0) {
$result = $alioss_model->deleteAlbumPic($params[ 'pic_path' ], $config[ 'data' ][ 'value' ][ 'domain' ]);
return $result;
}
}
}
<?php
namespace addon\alioss\event;
use addon\alioss\model\Alioss;
use addon\alioss\model\Config;
/**
* 删除阿里云图片
*/
class ClearAlbumPic
{
public function handle($params)
{
$config_model = new Config();
$alioss_model = new Alioss();
$config = $config_model->getAliossConfig($params[ 'site_id' ]);
if (!empty($config[ 'data' ])) {
if (!empty($config[ 'data' ][ 'value' ][ 'endpoint' ]) && strpos($params[ 'pic_path' ], $config[ 'data' ][ 'value' ][ 'endpoint' ]) === 0) {
$result = $alioss_model->deleteAlbumPic($params[ 'pic_path' ], $config[ 'data' ][ 'value' ][ 'endpoint' ]);
return $result;
}
if (!empty($config[ 'data' ][ 'value' ][ 'domain' ]) && strpos($params[ 'pic_path' ], $config[ 'data' ][ 'value' ][ 'domain' ]) === 0) {
$result = $alioss_model->deleteAlbumPic($params[ 'pic_path' ], $config[ 'data' ][ 'value' ][ 'domain' ]);
return $result;
}
}
}
}

View File

@@ -1,27 +1,20 @@
<?php
/**
*/
namespace addon\alioss\event;
use addon\alioss\model\Config;
/**
* 关闭云上传
*/
class CloseOss
{
public function handle()
{
$config_model = new Config();
$result = $config_model->modifyConfigIsUse(0);
return $result;
}
<?php
namespace addon\alioss\event;
use addon\alioss\model\Config;
/**
* 关闭云上传
*/
class CloseOss
{
public function handle()
{
$config_model = new Config();
$result = $config_model->modifyConfigIsUse(0);
return $result;
}
}

View File

@@ -1,26 +1,19 @@
<?php
/**
*/
namespace addon\alioss\event;
/**
* 应用安装
*/
class Install
{
/**
* 执行安装
*/
public function handle()
{
return success();
}
<?php
namespace addon\alioss\event;
/**
* 应用安装
*/
class Install
{
/**
* 执行安装
*/
public function handle()
{
return success();
}
}

View File

@@ -1,33 +1,26 @@
<?php
/**
*/
namespace addon\alioss\event;
/**
* 云上传方式
*/
class OssType
{
/**
* 短信发送方式方式及配置
*/
public function handle()
{
$info = array(
"sms_type" => "alioss",
"sms_type_name" => "阿里云上传",
"edit_url" => "alioss://shop/config/config",
"shop_url" => "alioss://shop/config/config",
"desc" => "阿里云上传"
);
return $info;
}
<?php
namespace addon\alioss\event;
/**
* 云上传方式
*/
class OssType
{
/**
* 短信发送方式方式及配置
*/
public function handle()
{
$info = array(
"sms_type" => "alioss",
"sms_type_name" => "阿里云上传",
"edit_url" => "alioss://shop/config/config",
"shop_url" => "alioss://shop/config/config",
"desc" => "阿里云上传"
);
return $info;
}
}

View File

@@ -1,31 +1,24 @@
<?php
/**
*/
namespace addon\alioss\event;
use addon\alioss\model\Alioss;
/**
* 云上传方式
*/
class Put
{
/**
* @param $param
* @return array
*/
public function handle($param)
{
$qiniu_model = new Alioss();
$result = $qiniu_model->putFile($param);
return $result;
}
<?php
namespace addon\alioss\event;
use addon\alioss\model\Alioss;
/**
* 云上传方式
*/
class Put
{
/**
* @param $param
* @return array
*/
public function handle($param)
{
$qiniu_model = new Alioss();
$result = $qiniu_model->putFile($param);
return $result;
}
}

View File

@@ -1,27 +1,18 @@
<?php
/**
*/
namespace addon\alioss\event;
/**
* 应用卸载
*/
class UnInstall
{
/**
* 执行卸载
*/
public function handle()
{
return success();
}
<?php
namespace addon\alioss\event;
/**
* 应用卸载
*/
class UnInstall
{
/**
* 执行卸载
*/
public function handle()
{
return success();
}
}

View File

@@ -1,146 +1,139 @@
<?php
/**
*/
namespace addon\alioss\model;
use app\model\BaseModel;
use OSS\Core\OssException;
use OSS\OssClient;
use think\facade\Log;
/**
* 阿里云OSS上传
*/
class Alioss extends BaseModel
{
/**
* 字节组上传
* @param $data
* @param $key
* @return array
*/
public function put($param)
{
$data = $param['data'];
$key = $param['key'];
$config_model = new Config();
$config_result = $config_model->getAliossConfig();
$config = $config_result['data'];
if ($config['is_use'] == 1) {
$config = $config['value'];
$access_key_id = $config['access_key_id'];
$access_key_secret = $config['access_key_secret'];
$bucket = $config['bucket'];
$endpoint = $config['endpoint'];
try {
$ossClient = new OssClient($access_key_id, $access_key_secret, $endpoint);
$result = $ossClient->putObject($bucket, $key, $data);
$is_domain = $config[ 'is_domain' ] ?? 0;
$path = $is_domain > 0 ? $config[ 'domain' ] . '/' . $key : $result['info']['url'];
$data = array (
'path' => $path,
// "path" => $result["info"]["url"],
'domain' => $endpoint,
'bucket' => $bucket
);
return $this->success($data);
} catch (OssException $e) {
return $this->error('', $e->getErrorMessage());
}
}
}
/**
* 设置阿里云OSS参数配置
* @param unknown $filePath 上传图片路径
* @param unknown $key 上传到阿里云后保存的文件名
*/
public function putFile($param)
{
$file_path = $param['file_path'];
$key = $param['key'];
$config_model = new Config();
$config = $config_model->getAliossConfig()['data'];
if ($config['is_use'] == 1) {
$config = $config['value'];
$access_key_id = $config['access_key_id'];
$access_key_secret = $config['access_key_secret'];
$bucket = $config['bucket'];
//要上传的空间
$endpoint = $config['endpoint'];
try {
$ossClient = new OssClient($access_key_id, $access_key_secret, $endpoint);
$result = $ossClient->uploadFile($bucket, $key, $file_path);
$is_domain = $config[ 'is_domain' ] ?? 0;
$path = $is_domain > 0 ? $config[ 'domain' ] . '/' . $key : $result['info']['url'];
$path = str_replace('http://', 'https://', $path);
//返回图片的完整URL
$data = array (
// "path" => $this->subEndpoint($endpoint, $bucket)."/". $key,
'path' => $path,
'domain' => $endpoint,
'bucket' => $bucket
);
return $this->success($data);
} catch (\Exception $e) {
return $this->error('', $e->getMessage());
}
}
}
public function subEndpoint($endpoint, $bucket)
{
if (strpos($endpoint, 'http://') === 0) {
$temp = 'http://';
} else {
$temp = 'https://';
}
$temp_array = explode($temp, $endpoint);
return $temp . $bucket . '.' . $temp_array[ 1 ];
}
/**
* @param $file_path
* @return array
* 删除阿里云图片
*/
public function deleteAlbumPic($file_path, $prefix)
{
$config_model = new Config();
$config_result = $config_model->getAliossConfig();
$config = $config_result['data'];
if (!empty($config)) {
$config = $config['value'];
$access_key_id = $config['access_key_id'];
$access_key_secret = $config['access_key_secret'];
$bucket = $config['bucket'];
//要上传的空间
$endpoint = $config['endpoint'];
try {
$ossClient = new OssClient($access_key_id, $access_key_secret, $endpoint);
$ossClient->deleteObject($bucket, str_replace($prefix . '/', '', $file_path));
$ossClient->deleteObject($bucket, str_replace($prefix . '/', '', img($file_path, 'big')));
$ossClient->deleteObject($bucket, str_replace($prefix . '/', '', img($file_path, 'mid')));
$ossClient->deleteObject($bucket, str_replace($prefix . '/', '', img($file_path, 'small')));
return $this->success();
} catch (OssException $e) {
return $this->error('', $e->getErrorMessage());
}
}
}
<?php
namespace addon\alioss\model;
use app\model\BaseModel;
use OSS\Core\OssException;
use OSS\OssClient;
use think\facade\Log;
/**
* 阿里云OSS上传
*/
class Alioss extends BaseModel
{
/**
* 字节组上传
* @param $data
* @param $key
* @return array
*/
public function put($param)
{
$data = $param['data'];
$key = $param['key'];
$config_model = new Config();
$config_result = $config_model->getAliossConfig();
$config = $config_result['data'];
if ($config['is_use'] == 1) {
$config = $config['value'];
$access_key_id = $config['access_key_id'];
$access_key_secret = $config['access_key_secret'];
$bucket = $config['bucket'];
$endpoint = $config['endpoint'];
try {
$ossClient = new OssClient($access_key_id, $access_key_secret, $endpoint);
$result = $ossClient->putObject($bucket, $key, $data);
$is_domain = $config[ 'is_domain' ] ?? 0;
$path = $is_domain > 0 ? $config[ 'domain' ] . '/' . $key : $result['info']['url'];
$data = array (
'path' => $path,
// "path" => $result["info"]["url"],
'domain' => $endpoint,
'bucket' => $bucket
);
return $this->success($data);
} catch (OssException $e) {
return $this->error('', $e->getErrorMessage());
}
}
}
/**
* 设置阿里云OSS参数配置
* @param unknown $filePath 上传图片路径
* @param unknown $key 上传到阿里云后保存的文件名
*/
public function putFile($param)
{
$file_path = $param['file_path'];
$key = $param['key'];
$config_model = new Config();
$config = $config_model->getAliossConfig()['data'];
if ($config['is_use'] == 1) {
$config = $config['value'];
$access_key_id = $config['access_key_id'];
$access_key_secret = $config['access_key_secret'];
$bucket = $config['bucket'];
//要上传的空间
$endpoint = $config['endpoint'];
try {
$ossClient = new OssClient($access_key_id, $access_key_secret, $endpoint);
$result = $ossClient->uploadFile($bucket, $key, $file_path);
$is_domain = $config[ 'is_domain' ] ?? 0;
$path = $is_domain > 0 ? $config[ 'domain' ] . '/' . $key : $result['info']['url'];
$path = str_replace('http://', 'https://', $path);
//返回图片的完整URL
$data = array (
// "path" => $this->subEndpoint($endpoint, $bucket)."/". $key,
'path' => $path,
'domain' => $endpoint,
'bucket' => $bucket
);
return $this->success($data);
} catch (\Exception $e) {
return $this->error('', $e->getMessage());
}
}
}
public function subEndpoint($endpoint, $bucket)
{
if (strpos($endpoint, 'http://') === 0) {
$temp = 'http://';
} else {
$temp = 'https://';
}
$temp_array = explode($temp, $endpoint);
return $temp . $bucket . '.' . $temp_array[ 1 ];
}
/**
* @param $file_path
* @return array
* 删除阿里云图片
*/
public function deleteAlbumPic($file_path, $prefix)
{
$config_model = new Config();
$config_result = $config_model->getAliossConfig();
$config = $config_result['data'];
if (!empty($config)) {
$config = $config['value'];
$access_key_id = $config['access_key_id'];
$access_key_secret = $config['access_key_secret'];
$bucket = $config['bucket'];
//要上传的空间
$endpoint = $config['endpoint'];
try {
$ossClient = new OssClient($access_key_id, $access_key_secret, $endpoint);
$ossClient->deleteObject($bucket, str_replace($prefix . '/', '', $file_path));
$ossClient->deleteObject($bucket, str_replace($prefix . '/', '', img($file_path, 'big')));
$ossClient->deleteObject($bucket, str_replace($prefix . '/', '', img($file_path, 'mid')));
$ossClient->deleteObject($bucket, str_replace($prefix . '/', '', img($file_path, 'small')));
return $this->success();
} catch (OssException $e) {
return $this->error('', $e->getErrorMessage());
}
}
}
}

View File

@@ -1,56 +1,49 @@
<?php
/**
*/
namespace addon\alioss\model;
use app\model\system\Config as ConfigModel;
use app\model\BaseModel;
/**
* 阿里云配置
*/
class Config extends BaseModel
{
/**
* 设置阿里云OSS上传配置
* array $data
*/
public function setAliossConfig($data, $status, $site_id = 1, $app_module = 'shop')
{
if ($status == 1) {
event('CloseOss', []);//同步关闭所有云上传
}
$config = new ConfigModel();
$res = $config->setConfig($data, '阿里云OSS上传配置', $status, [ [ 'site_id', '=', $site_id ], [ 'app_module', '=', $app_module ], [ 'config_key', '=', 'ALIOSS_CONFIG' ] ]);
return $res;
}
/**
* 获取阿里云上传配置
*/
public function getAliossConfig($site_id = 1, $app_module = 'shop')
{
$config = new ConfigModel();
$res = $config->getConfig([ [ 'site_id', '=', $site_id ], [ 'app_module', '=', $app_module ], [ 'config_key', '=', 'ALIOSS_CONFIG' ] ]);
return $res;
}
/**
* 配置阿里云开关状态
* @param $status
*/
public function modifyConfigIsUse($status, $site_id = 1, $app_module = 'shop')
{
$config = new ConfigModel();
$res = $config->modifyConfigIsUse($status, [ [ 'site_id', '=', $site_id ], [ 'app_module', '=', $app_module ], [ 'config_key', '=', 'ALIOSS_CONFIG' ] ]);
return $res;
}
<?php
namespace addon\alioss\model;
use app\model\system\Config as ConfigModel;
use app\model\BaseModel;
/**
* 阿里云配置
*/
class Config extends BaseModel
{
/**
* 设置阿里云OSS上传配置
* array $data
*/
public function setAliossConfig($data, $status, $site_id = 1, $app_module = 'shop')
{
if ($status == 1) {
event('CloseOss', []);//同步关闭所有云上传
}
$config = new ConfigModel();
$res = $config->setConfig($data, '阿里云OSS上传配置', $status, [ [ 'site_id', '=', $site_id ], [ 'app_module', '=', $app_module ], [ 'config_key', '=', 'ALIOSS_CONFIG' ] ]);
return $res;
}
/**
* 获取阿里云上传配置
*/
public function getAliossConfig($site_id = 1, $app_module = 'shop')
{
$config = new ConfigModel();
$res = $config->getConfig([ [ 'site_id', '=', $site_id ], [ 'app_module', '=', $app_module ], [ 'config_key', '=', 'ALIOSS_CONFIG' ] ]);
return $res;
}
/**
* 配置阿里云开关状态
* @param $status
*/
public function modifyConfigIsUse($status, $site_id = 1, $app_module = 'shop')
{
$config = new ConfigModel();
$res = $config->modifyConfigIsUse($status, [ [ 'site_id', '=', $site_id ], [ 'app_module', '=', $app_module ], [ 'config_key', '=', 'ALIOSS_CONFIG' ] ]);
return $res;
}
}

View File

@@ -1,56 +1,49 @@
<?php
/**
*/
namespace addon\alioss\shop\controller;
use addon\alioss\model\Config as ConfigModel;
use app\shop\controller\BaseShop;
/**
* 七牛云上传管理
*/
class Config extends BaseShop
{
/**
* 云上传配置
* @return mixed
*/
public function config()
{
$config_model = new ConfigModel();
if (request()->isJson()) {
$bucket = input('bucket', '');
$access_key_id = input('access_key_id', '');
$access_key_secret = input('access_key_secret', '');
$endpoint = input('endpoint', '');
$status = input('status', 0);
$domain = input('domain', '');
$is_domain = input('is_domain', 0);
$data = array (
'bucket' => $bucket,
'access_key_id' => $access_key_id,
'access_key_secret' => $access_key_secret,
'endpoint' => $endpoint,
'domain' => $domain,
'is_domain' => $is_domain
);
$result = $config_model->setAliossConfig($data, $status, $this->site_id, $this->app_module);
return $result;
} else {
$info_result = $config_model->getAliossConfig($this->site_id, $this->app_module);
$info = $info_result['data'];
$this->assign('info', $info);
return $this->fetch('config/config');
}
}
<?php
namespace addon\alioss\shop\controller;
use addon\alioss\model\Config as ConfigModel;
use app\shop\controller\BaseShop;
/**
* 七牛云上传管理
*/
class Config extends BaseShop
{
/**
* 云上传配置
* @return mixed
*/
public function config()
{
$config_model = new ConfigModel();
if (request()->isJson()) {
$bucket = input('bucket', '');
$access_key_id = input('access_key_id', '');
$access_key_secret = input('access_key_secret', '');
$endpoint = input('endpoint', '');
$status = input('status', 0);
$domain = input('domain', '');
$is_domain = input('is_domain', 0);
$data = array (
'bucket' => $bucket,
'access_key_id' => $access_key_id,
'access_key_secret' => $access_key_secret,
'endpoint' => $endpoint,
'domain' => $domain,
'is_domain' => $is_domain
);
$result = $config_model->setAliossConfig($data, $status, $this->site_id, $this->app_module);
return $result;
} else {
$info_result = $config_model->getAliossConfig($this->site_id, $this->app_module);
$info = $info_result['data'];
$this->assign('info', $info);
return $this->fetch('config/config');
}
}
}

View File

@@ -1,38 +1,31 @@
<?php
/**
*/
return [
// 自定义模板页面类型,格式:[ 'title' => '页面类型名称', 'name' => '页面标识', 'path' => '页面路径', 'value' => '页面数据json格式' ]
'template' => [],
// 后台自定义组件——装修
'util' => [],
// 自定义页面路径
'link' => [],
// 自定义图标库
'icon_library' => [],
// uni-app 组件,格式:[ 'name' => '组件名称/文件夹名称', 'path' => '文件路径/目录路径' ]多个逗号隔开自定义组件名称前缀必须是diy-,也可以引用第三方组件
'component' => [],
// uni-app 页面,多个逗号隔开
'pages' => [],
// 模板信息,格式:'title' => '模板名称', 'name' => '模板标识', 'cover' => '模板封面图', 'preview' => '模板预览图', 'desc' => '模板描述'
'info' => [],
// 主题风格配色格式可以自由定义扩展【在uni-app中通过this.themeStyle... 获取定义的颜色字段例如this.themeStyle.main_color】
'theme' => [],
// 自定义页面数据,格式:[ 'title' => '页面名称', 'name' => "页面标识", 'value' => [页面数据json格式] ]
'data' => []
<?php
return [
// 自定义模板页面类型,格式:[ 'title' => '页面类型名称', 'name' => '页面标识', 'path' => '页面路径', 'value' => '页面数据json格式' ]
'template' => [],
// 后台自定义组件——装修
'util' => [],
// 自定义页面路径
'link' => [],
// 自定义图标库
'icon_library' => [],
// uni-app 组件,格式:[ 'name' => '组件名称/文件夹名称', 'path' => '文件路径/目录路径' ]多个逗号隔开自定义组件名称前缀必须是diy-,也可以引用第三方组件
'component' => [],
// uni-app 页面,多个逗号隔开
'pages' => [],
// 模板信息,格式:'title' => '模板名称', 'name' => '模板标识', 'cover' => '模板封面图', 'preview' => '模板预览图', 'desc' => '模板描述'
'info' => [],
// 主题风格配色格式可以自由定义扩展【在uni-app中通过this.themeStyle... 获取定义的颜色字段例如this.themeStyle.main_color】
'theme' => [],
// 自定义页面数据,格式:[ 'title' => '页面名称', 'name' => "页面标识", 'value' => [页面数据json格式] ]
'data' => []
];

View File

@@ -1,50 +1,43 @@
<?php
/**
*/
return [
'bind' => [
],
'listen' => [
//支付异步回调
'PayNotify' => [
'addon\alipay\event\PayNotify'
],
//支付方式,后台查询
'PayType' => [
'addon\alipay\event\PayType'
],
//支付,前台应用
'Pay' => [
'addon\alipay\event\Pay'
],
'PayClose' => [
'addon\alipay\event\PayClose'
],
'PayRefund' => [
'addon\alipay\event\PayRefund'
],
'PayTransfer' => [
'addon\alipay\event\PayTransfer'
],
'TransferType' => [
'addon\alipay\event\TransferType'
],
'AuthcodePay' => [
'addon\alipay\event\AuthcodePay'
],
'PayOrderQuery' => [
'addon\alipay\event\PayOrderQuery'
],
],
'subscribe' => [
],
];
<?php
return [
'bind' => [
],
'listen' => [
//支付异步回调
'PayNotify' => [
'addon\alipay\event\PayNotify'
],
//支付方式,后台查询
'PayType' => [
'addon\alipay\event\PayType'
],
//支付,前台应用
'Pay' => [
'addon\alipay\event\Pay'
],
'PayClose' => [
'addon\alipay\event\PayClose'
],
'PayRefund' => [
'addon\alipay\event\PayRefund'
],
'PayTransfer' => [
'addon\alipay\event\PayTransfer'
],
'TransferType' => [
'addon\alipay\event\TransferType'
],
'AuthcodePay' => [
'addon\alipay\event\AuthcodePay'
],
'PayOrderQuery' => [
'addon\alipay\event\PayOrderQuery'
],
],
'subscribe' => [
],
];

View File

@@ -1,21 +1,13 @@
<?php
/**
*/
return [
'name' => 'alipay',
'title' => '支付宝支付',
'description' => '支付宝支付功能',
'type' => 'system', //插件类型 system :系统插件(自动安装), promotion:营销插件 tool:工具插件
'status' => 1,
'author' => '',
'version' => '5.3.1',
'version_no' => '525231212001',
'content' => '',
<?php
return [
'name' => 'alipay',
'title' => '支付宝支付',
'description' => '支付宝支付功能',
'type' => 'system', //插件类型 system :系统插件(自动安装), promotion:营销插件 tool:工具插件
'status' => 1,
'author' => '',
'version' => '5.3.1',
'version_no' => '525231212001',
'content' => '',
];

View File

@@ -1,23 +1,16 @@
<?php
/**
*/
return [
[
'name' => 'ALI_PAY_CONFIG',
'title' => '支付宝支付编辑',
'url' => 'alipay://shop/pay/config',
'parent' => 'CONFIG_PAY',
'is_show' => 0,
'is_control' => 1,
'is_icon' => 0,
'picture' => '',
'picture_select' => '',
'sort' => 1,
],
];
<?php
return [
[
'name' => 'ALI_PAY_CONFIG',
'title' => '支付宝支付编辑',
'url' => 'alipay://shop/pay/config',
'parent' => 'CONFIG_PAY',
'is_show' => 0,
'is_control' => 1,
'is_icon' => 0,
'picture' => '',
'picture_select' => '',
'sort' => 1,
],
];

View File

@@ -1,27 +1,18 @@
<?php
/**
*/
namespace addon\alipay\event;
/**
* 应用安装
*/
class Install
{
/**
* 执行安装
*/
public function handle()
{
return success();
}
<?php
namespace addon\alipay\event;
/**
* 应用安装
*/
class Install
{
/**
* 执行安装
*/
public function handle()
{
return success();
}
}

View File

@@ -1,33 +1,26 @@
<?php
/**
*/
namespace addon\alipay\event;
use addon\alipay\model\Pay as PayModel;
/**
* 生成支付
*/
class Pay
{
/**
* 支付方式及配置
*/
public function handle($param)
{
if ($param[ "pay_type" ] == "alipay") {
if (in_array($param[ "app_type" ], [ "h5", "app", "pc", "aliapp", 'wechat' ])) {
$pay_model = new PayModel($param[ 'site_id' ], $param[ "app_type" ] == 'aliapp');
$res = $pay_model->pay($param);
return $res;
}
}
}
<?php
namespace addon\alipay\event;
use addon\alipay\model\Pay as PayModel;
/**
* 生成支付
*/
class Pay
{
/**
* 支付方式及配置
*/
public function handle($param)
{
if ($param[ "pay_type" ] == "alipay") {
if (in_array($param[ "app_type" ], [ "h5", "app", "pc", "aliapp", 'wechat' ])) {
$pay_model = new PayModel($param[ 'site_id' ], $param[ "app_type" ] == 'aliapp');
$res = $pay_model->pay($param);
return $res;
}
}
}
}

View File

@@ -1,39 +1,32 @@
<?php
/**
*/
namespace addon\alipay\event;
use addon\alipay\model\Pay as PayModel;
/**
* 关闭支付
*/
class PayClose
{
/**
* 关闭支付
* @param $params
* @return \addon\alipay\model\multitype|array
*/
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());
}
// }
}
<?php
namespace addon\alipay\event;
use addon\alipay\model\Pay as PayModel;
/**
* 关闭支付
*/
class PayClose
{
/**
* 关闭支付
* @param $params
* @return \addon\alipay\model\multitype|array
*/
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());
}
}
}
}

View File

@@ -1,41 +1,36 @@
<?php
/**
*/
namespace addon\alipay\event;
use addon\alipay\model\Pay as PayModel;
use app\model\system\Pay as PayCommon;
/**
* 支付回调
*/
class PayNotify
{
/**
* 支付方式及配置
*/
public function handle()
{
if (isset($_POST[ 'out_trade_no' ])) {
$out_trade_no = $_POST[ 'out_trade_no' ];
$pay = new PayCommon();
$pay_info = $pay->getPayInfo($out_trade_no)[ 'data' ];
if (empty($pay_info)) return false;
if ($_POST[ 'total_amount' ] != $pay_info[ 'pay_money' ]) {
return false;
}
$mch_info = empty($pay_info[ 'mch_info' ]) ? [] : json_decode($pay_info[ 'mch_info' ], true);
$pay_model = new PayModel($pay_info[ 'site_id' ], $mch_info[ 'is_aliapp' ] ?? 0);
$pay_model->payNotify();
}
}
<?php
namespace addon\alipay\event;
use addon\alipay\model\Pay as PayModel;
use app\model\system\Pay as PayCommon;
/**
* 支付回调
*/
class PayNotify
{
/**
* 支付方式及配置
*/
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();
$pay_info = $pay->getPayInfo($out_trade_no)[ 'data' ];
if (empty($pay_info)) return false;
if ($_POST[ 'total_amount' ] != $pay_info[ 'pay_money' ]) {
return false;
}
$mch_info = empty($pay_info[ 'mch_info' ]) ? [] : json_decode($pay_info[ 'mch_info' ], true);
$pay_model = new PayModel($pay_info[ 'site_id' ], $mch_info[ 'is_aliapp' ] ?? 0);
$pay_model->payNotify();
}
}
}

View File

@@ -1,34 +1,27 @@
<?php
/**
*/
namespace addon\alipay\event;
use addon\alipay\model\Config as ConfigModel;
use addon\alipay\model\Pay as PayModel;
use app\model\system\Pay;
/**
* 查询支付结果
*/
class PayOrderQuery
{
public function handle(array $params)
{
$pay_info = ( new Pay() )->getInfo([ [ 'id', '=', $params[ 'relate_id' ] ] ])[ 'data' ];
if (!empty($pay_info)) {
$config_model = new ConfigModel();
$pay_config = $config_model->getPayConfig($pay_info[ 'site_id' ])[ 'data' ][ 'value' ];
if (!empty($pay_config) && $pay_config[ 'pay_status' ] != 2) {
$pay_common = new PayModel($pay_info[ 'site_id' ]);
$pay_common->orderQuery($pay_info);
}
}
}
}
<?php
namespace addon\alipay\event;
use addon\alipay\model\Config as ConfigModel;
use addon\alipay\model\Pay as PayModel;
use app\model\system\Pay;
/**
* 查询支付结果
*/
class PayOrderQuery
{
public function handle(array $params)
{
$pay_info = ( new Pay() )->getInfo([ [ 'id', '=', $params[ 'relate_id' ] ] ])[ 'data' ];
if (!empty($pay_info)) {
$config_model = new ConfigModel();
$pay_config = $config_model->getPayConfig($pay_info[ 'site_id' ])[ 'data' ][ 'value' ];
if (!empty($pay_config) && $pay_config[ 'pay_status' ] != 2) {
$pay_common = new PayModel($pay_info[ 'site_id' ]);
$pay_common->orderQuery($pay_info);
}
}
}
}

View File

@@ -1,33 +1,26 @@
<?php
/**
*/
namespace addon\alipay\event;
use addon\alipay\model\Pay as PayModel;
/**
* 原路退款
*/
class PayRefund
{
/**
* 关闭支付
*/
public function handle($params)
{
if ($params[ "pay_info" ][ "pay_type" ] == "alipay") {
$mch_info = empty($params[ 'pay_info' ][ 'mch_info' ]) ? [] : json_decode($params[ 'pay_info' ][ 'mch_info' ], true);
$pay_model = new PayModel($params[ 'site_id' ], $mch_info[ 'is_aliapp' ] ?? 0);
$result = $pay_model->refund($params);
return $result;
}
}
<?php
namespace addon\alipay\event;
use addon\alipay\model\Pay as PayModel;
/**
* 原路退款
*/
class PayRefund
{
/**
* 关闭支付
*/
public function handle($params)
{
if ($params[ "pay_info" ][ "pay_type" ] == "alipay") {
$mch_info = empty($params[ 'pay_info' ][ 'mch_info' ]) ? [] : json_decode($params[ 'pay_info' ][ 'mch_info' ], true);
$pay_model = new PayModel($params[ 'site_id' ], $mch_info[ 'is_aliapp' ] ?? 0);
$result = $pay_model->refund($params);
return $result;
}
}
}

View File

@@ -1,42 +1,35 @@
<?php
/**
*/
namespace addon\alipay\event;
use addon\alipay\model\Pay;
use addon\alipay\model\Config;
class PayTransfer
{
public function handle(array $params)
{
if ($params[ 'transfer_type' ] == 'alipay') {
$pay = new Pay($params[ 'site_id' ]);
$config_model = new Config();
$config_result = $config_model->getPayConfig($params[ 'site_id' ]);
$config = $config_result[ "data" ];
if (!empty($config[ 'value' ])) {
$config_info = $config[ "value" ];
$countersign_type = $config_info['countersign_type'] ?? 0;
if ($countersign_type == 0) {
$res = $pay->payTransfer($params);
return $res;
} else {
$res = $pay->payNewTransfer($params);
return $res;
}
} else {
$res = $pay->payTransfer($params);
return $res;
}
}
}
<?php
namespace addon\alipay\event;
use addon\alipay\model\Pay;
use addon\alipay\model\Config;
class PayTransfer
{
public function handle(array $params)
{
if ($params[ 'transfer_type' ] == 'alipay') {
$pay = new Pay($params[ 'site_id' ]);
$config_model = new Config();
$config_result = $config_model->getPayConfig($params[ 'site_id' ]);
$config = $config_result[ "data" ];
if (!empty($config[ 'value' ])) {
$config_info = $config[ "value" ];
$countersign_type = $config_info['countersign_type'] ?? 0;
if ($countersign_type == 0) {
$res = $pay->payTransfer($params);
return $res;
} else {
$res = $pay->payNewTransfer($params);
return $res;
}
} else {
$res = $pay->payTransfer($params);
return $res;
}
}
}
}

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