feat: 牛牛单独限红 - 新增limit_low_nn/limit_high_nn字段,代理端添加/编辑代理和会员支持牛牛限红设置,语言包三语支持

This commit is contained in:
li
2026-02-11 19:53:50 +08:00
parent 1ce6c3d780
commit 71d7ff1d7b
36 changed files with 2590 additions and 18 deletions
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
@@ -0,0 +1,229 @@
<?php
namespace app\admin\controller;
use think\Db;
use think\Request;
/**
* 快捷回复管理控制器
* Class ChatQuickReply
* @package app\admin\controller
*/
class ChatQuickReply extends Common
{
/**
* 快捷回复列表
*/
public function index()
{
$get = Request::instance()->get();
$this->assign('get', $get);
$category = Request::instance()->get('category');
$status = Request::instance()->get('status');
$where = [];
if ($category) {
$where['category'] = $category;
}
if ($status !== null && $status !== '') {
$where['status'] = (int)$status;
}
$list = Db::name('chat_quick_reply')
->where($where)
->order('sort asc, id desc')
->paginate(15, false, ['query' => $get]);
// 获取分类列表
$categories = Db::name('chat_quick_reply')
->whereNotNull('category')
->where('category', '<>', '')
->group('category')
->column('category');
$this->assign('list', $list);
$this->assign('categories', $categories);
return $this->fetch();
}
/**
* 添加快捷回复
*/
public function add()
{
if (Request::instance()->isPost()) {
$data = Request::instance()->post();
// 验证
if (empty($data['title'])) {
$this->error('标题不能为空');
}
if (empty($data['content'])) {
$this->error('内容不能为空');
}
$insertData = [
'category' => $data['category'] ?? null,
'title' => $data['title'],
'content' => $data['content'],
'sort' => (int)($data['sort'] ?? 0),
'status' => (int)($data['status'] ?? 1),
'create_time' => time(),
'update_time' => time(),
];
$result = Db::name('chat_quick_reply')->insert($insertData);
if ($result) {
insertAdminLog('添加快捷回复', '标题: ' . $data['title']);
$this->success('添加成功', '/chat_quick_reply/index');
} else {
$this->error('添加失败');
}
}
// 获取分类列表
$categories = Db::name('chat_quick_reply')
->whereNotNull('category')
->where('category', '<>', '')
->group('category')
->column('category');
$this->assign('categories', $categories);
$this->assign('info', []);
return $this->fetch();
}
/**
* 编辑快捷回复
*/
public function edit()
{
$id = Request::instance()->param('id');
if (Request::instance()->isPost()) {
$data = Request::instance()->post();
// 验证
if (empty($data['title'])) {
$this->error('标题不能为空');
}
if (empty($data['content'])) {
$this->error('内容不能为空');
}
$updateData = [
'category' => $data['category'] ?? null,
'title' => $data['title'],
'content' => $data['content'],
'sort' => (int)($data['sort'] ?? 0),
'status' => (int)($data['status'] ?? 1),
'update_time' => time(),
];
$result = Db::name('chat_quick_reply')->where('id', $id)->update($updateData);
if ($result !== false) {
insertAdminLog('编辑快捷回复', 'ID: ' . $id);
$this->success('修改成功', '/chat_quick_reply/index');
} else {
$this->error('修改失败');
}
}
$info = Db::name('chat_quick_reply')->where('id', $id)->find();
if (!$info) {
$this->error('记录不存在');
}
// 获取分类列表
$categories = Db::name('chat_quick_reply')
->whereNotNull('category')
->where('category', '<>', '')
->group('category')
->column('category');
$this->assign('info', $info);
$this->assign('categories', $categories);
return $this->fetch('add');
}
/**
* 删除快捷回复
*/
public function del()
{
$id = Request::instance()->param('id');
if (!$id) {
$this->error('参数错误');
}
$info = Db::name('chat_quick_reply')->where('id', $id)->find();
if (!$info) {
$this->error('记录不存在');
}
$result = Db::name('chat_quick_reply')->where('id', $id)->delete();
if ($result) {
insertAdminLog('删除快捷回复', 'ID: ' . $id . ', 标题: ' . $info['title']);
$this->success('删除成功');
} else {
$this->error('删除失败');
}
}
/**
* 修改状态
*/
public function status()
{
$id = Request::instance()->post('id');
$status = Request::instance()->post('status');
if (!$id) {
return json(['code' => 1, 'msg' => '参数错误']);
}
$result = Db::name('chat_quick_reply')->where('id', $id)->update([
'status' => (int)$status,
'update_time' => time(),
]);
if ($result !== false) {
return json(['code' => 0, 'msg' => '操作成功']);
} else {
return json(['code' => 1, 'msg' => '操作失败']);
}
}
/**
* 修改排序
*/
public function sort()
{
$id = Request::instance()->post('id');
$sort = Request::instance()->post('sort');
if (!$id) {
return json(['code' => 1, 'msg' => '参数错误']);
}
$result = Db::name('chat_quick_reply')->where('id', $id)->update([
'sort' => (int)$sort,
'update_time' => time(),
]);
if ($result !== false) {
return json(['code' => 0, 'msg' => '操作成功']);
} else {
return json(['code' => 1, 'msg' => '操作失败']);
}
}
}
+4
View File
@@ -29,6 +29,8 @@ class Common extends Controller{
'Info', 'Info',
'Log', 'Log',
'Login', 'Login',
'Chat',
'ChatQuickReply',
); );
if($user_info['role'] == 1){ if($user_info['role'] == 1){
if(!in_array($curContrller, $jiankongController)){ if(!in_array($curContrller, $jiankongController)){
@@ -40,6 +42,8 @@ class Common extends Controller{
'Index', 'Index',
'Agent', 'Agent',
'Login', 'Login',
'Chat',
'ChatQuickReply',
); );
if($user_info['role'] == 2){ if($user_info['role'] == 2){
if(!in_array($curContrller, $dlController)){ if(!in_array($curContrller, $dlController)){
+9 -1
View File
@@ -59,7 +59,15 @@ class Login extends Controller{
} }
$last_login_info = Db::name('admin')->where(array('id' => $admin_info['id']))->field(['last_login_time','last_login_ip'])->find(); $last_login_info = Db::name('admin')->where(array('id' => $admin_info['id']))->field(['last_login_time','last_login_ip'])->find();
Session::set('last_login_info',$last_login_info); Session::set('last_login_info',$last_login_info);
Db::name('admin')->where(array('id' => $admin_info['id']))->update(array('last_login_time' => time(), 'last_login_ip' => getIP()));
// 生成login_token用于WebSocket连接验证
$login_token = md5($admin_info['id'] . time() . uniqid());
Db::name('admin')->where(array('id' => $admin_info['id']))->update(array(
'last_login_time' => time(),
'last_login_ip' => getIP(),
'login_token' => $login_token
));
$user_info = Db::name('admin')->where('id',$admin_info['id'])->find(); $user_info = Db::name('admin')->where('id',$admin_info['id'])->find();
Session::set('user_info',$user_info); Session::set('user_info',$user_info);
insertAdminLog('登录'); insertAdminLog('登录');
+3 -3
View File
@@ -36,17 +36,17 @@ class Report extends Common{
} }
$agent_list = Db::name('user')->where($where)->select(); $agent_list = Db::name('user')->where($where)->select();
// 时间条件 // 时间条件(默认:今天07:00:00 ~ 明天06:59:59
if($startDate){ if($startDate){
$startTime = strtotime($startDate); $startTime = strtotime($startDate);
}else{ }else{
$startTime = strtotime(date('Y-m-d')); $startTime = strtotime(date('Y-m-d') . ' 07:00:00');
$get['startDate'] = date('Y-m-d H:i:s',$startTime); $get['startDate'] = date('Y-m-d H:i:s',$startTime);
} }
if($endDate){ if($endDate){
$endTime = strtotime($endDate); $endTime = strtotime($endDate);
}else{ }else{
$endTime = time(); $endTime = strtotime(date('Y-m-d', strtotime('+1 day')) . ' 06:59:59');
$get['endDate'] = date('Y-m-d H:i:s',$endTime); $get['endDate'] = date('Y-m-d H:i:s',$endTime);
} }
$this->assign('get',$get); $this->assign('get',$get);
Binary file not shown.
+16 -2
View File
@@ -95,16 +95,30 @@
layui.use('laydate', function () { layui.use('laydate', function () {
var laydate = layui.laydate; var laydate = layui.laydate;
// 计算默认时间:今天07:00:00 ~ 明天06:59:59
var now = new Date();
var y = now.getFullYear();
var m = ('0' + (now.getMonth() + 1)).slice(-2);
var d = ('0' + now.getDate()).slice(-2);
var tomorrow = new Date(now.getTime() + 86400000);
var ty = tomorrow.getFullYear();
var tm = ('0' + (tomorrow.getMonth() + 1)).slice(-2);
var td = ('0' + tomorrow.getDate()).slice(-2);
var defaultStart = y + '-' + m + '-' + d + ' 07:00:00';
var defaultEnd = ty + '-' + tm + '-' + td + ' 06:59:59';
// 开始时间 // 开始时间
laydate.render({ laydate.render({
elem: '#start', elem: '#start',
type: 'datetime' type: 'datetime',
value: defaultStart
}); });
// 结束时间 // 结束时间
laydate.render({ laydate.render({
elem: '#end', elem: '#end',
type: 'datetime' type: 'datetime',
value: defaultEnd
}); });
}); });
function get_profit(){ function get_profit(){
+89
View File
@@ -0,0 +1,89 @@
{include file="public/header" /}
<body>
<div class="x-body">
<div class="layui-card">
<div class="layui-card-header">会话信息</div>
<div class="layui-card-body">
<table class="layui-table" lay-skin="nob">
<tr>
<td width="120"><strong>会话ID:</strong></td>
<td>{$session.id}</td>
<td width="120"><strong>用户名:</strong></td>
<td>{$session.username}</td>
</tr>
<tr>
<td><strong>用户余额:</strong></td>
<td>{$session.money}</td>
<td><strong>客服:</strong></td>
<td>{$session.admin_username|default='未分配'}</td>
</tr>
<tr>
<td><strong>来源:</strong></td>
<td>
{switch name="session.source"}
{case value="1"}PC{/case}
{case value="2"}Game{/case}
{case value="3"}Portal{/case}
{/switch}
</td>
<td><strong>状态:</strong></td>
<td>
{switch name="session.status"}
{case value="0"}待分配{/case}
{case value="1"}进行中{/case}
{case value="2"}已结束{/case}
{/switch}
</td>
</tr>
<tr>
<td><strong>开始时间:</strong></td>
<td>{$session.create_time|date='Y-m-d H:i:s',###}</td>
<td><strong>结束时间:</strong></td>
<td>{if condition="$session.end_time"}{$session.end_time|date='Y-m-d H:i:s',###}{else/}-{/if}</td>
</tr>
{if condition="$session.rating"}
<tr>
<td><strong>评分:</strong></td>
<td colspan="3">
<span style="color: #FFB800;">{$session.rating}星</span>
{if condition="$session.rating_content"}
<br><span style="color: #666;">{$session.rating_content}</span>
{/if}
</td>
</tr>
{/if}
</table>
</div>
</div>
<div class="layui-card" style="margin-top: 20px;">
<div class="layui-card-header">聊天记录</div>
<div class="layui-card-body" style="height: 400px; overflow-y: auto; background: #f8f8f8;">
{volist name="messages" id="msg"}
<div style="margin-bottom: 15px; {eq name='msg.sender_type' value='2'}text-align: right;{/eq}">
<div style="display: inline-block; max-width: 60%; text-align: left;">
<div style="font-size: 12px; color: #999; margin-bottom: 3px;">
{eq name="msg.sender_type" value="1"}
用户
{else/}
客服
{/eq}
· {$msg.create_time|date='Y-m-d H:i:s',###}
</div>
<div style="padding: 10px; background: {eq name='msg.sender_type' value='1'}#fff{else/}#1E9FFF{/eq};
color: {eq name='msg.sender_type' value='1'}#333{else/}#fff{/eq};
border-radius: 5px; word-break: break-all;">
{eq name="msg.msg_type" value="2"}
<img src="{$msg.content}" style="max-width: 300px;">
{else/}
{$msg.content}
{/eq}
</div>
</div>
</div>
{/volist}
</div>
</div>
</div>
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
{include file="public/header" /}
<body>
<div class="x-nav">
<span class="layui-breadcrumb">
<a href="">首页</a>
<a><cite>聊天记录查询</cite></a>
</span>
<a class="layui-btn layui-btn-small" style="line-height:1.6em;margin-top:3px;float:right" href="javascript:location.replace(location.href);" title="刷新">
<i class="layui-icon" style="line-height:30px"></i></a>
</div>
<div class="x-body">
<div class="layui-row">
<form class="layui-form layui-col-md12 x-so" method="get">
<div class="layui-input-inline">
<input type="text" name="username" value="{$get.username|default=''}"
placeholder="用户名" class="layui-input">
</div>
<div class="layui-input-inline">
<select name="admin_id">
<option value="">全部客服</option>
{volist name="admins" id="admin"}
<option value="{$admin.id}" {if condition="isset($get['admin_id']) && $get['admin_id'] == $admin['id']"}selected{/if}>
{$admin.nickname|default=$admin.username}
</option>
{/volist}
</select>
</div>
<div class="layui-input-inline">
<input type="text" name="startDate" id="startDate" value="{$get.startDate|default=''}"
placeholder="开始日期" class="layui-input">
</div>
<div class="layui-input-inline">
<input type="text" name="endDate" id="endDate" value="{$get.endDate|default=''}"
placeholder="结束日期" class="layui-input">
</div>
<button class="layui-btn" type="submit"><i class="layui-icon">&#xe615;</i></button>
<button class="layui-btn layui-btn-warm" type="submit" name="export" value="1">
<i class="layui-icon">&#xe67d;</i>导出
</button>
</form>
</div>
<table class="layui-table">
<thead>
<tr>
<th>会话ID</th>
<th>用户名</th>
<th>客服</th>
<th>来源</th>
<th>状态</th>
<th>评分</th>
<th>开始时间</th>
<th>结束时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{volist name="list" id="vo"}
<tr>
<td>{$vo.id}</td>
<td>{$vo.username}</td>
<td>{$vo.admin_username|default='未分配'}</td>
<td>
{switch name="vo.source"}
{case value="1"}PC{/case}
{case value="2"}Game{/case}
{case value="3"}Portal{/case}
{/switch}
</td>
<td>
{switch name="vo.status"}
{case value="0"}<span class="layui-badge layui-bg-gray">待分配</span>{/case}
{case value="1"}<span class="layui-badge layui-bg-blue">进行中</span>{/case}
{case value="2"}<span class="layui-badge">已结束</span>{/case}
{/switch}
</td>
<td>
{if condition="$vo.rating"}
<span style="color: #FFB800;">{$vo.rating}星</span>
{else/}
-
{/if}
</td>
<td>{$vo.create_time|date='Y-m-d H:i:s',###}</td>
<td>{if condition="$vo.end_time"}{$vo.end_time|date='Y-m-d H:i:s',###}{else/}-{/if}</td>
<td class="td-manage">
<a title="查看详情" onclick="x_admin_show('会话详情','/chat/detail?session_id={$vo.id}',900,600)" href="javascript:;">
<i class="layui-icon">&#xe63c;</i>
</a>
</td>
</tr>
{/volist}
</tbody>
</table>
<div class="page">
{$list->render()}
</div>
</div>
<script>
layui.use(['laydate', 'form'], function(){
var laydate = layui.laydate;
var form = layui.form;
laydate.render({
elem: '#startDate',
type: 'date'
});
laydate.render({
elem: '#endDate',
type: 'date'
});
form.render();
});
</script>
</body>
</html>
@@ -0,0 +1,70 @@
{include file="public/header" /}
<body>
<div class="x-body">
<form class="layui-form" method="post">
<div class="layui-form-item">
<label class="layui-form-label">分类</label>
<div class="layui-input-block">
<input type="text" name="category" value="{$info.category|default=''}"
placeholder="可选,如:常见问题、充值相关等" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label"><span class="x-red">*</span>标题</label>
<div class="layui-input-block">
<input type="text" name="title" required lay-verify="required"
value="{$info.title|default=''}" placeholder="请输入标题" class="layui-input">
</div>
</div>
<div class="layui-form-item layui-form-text">
<label class="layui-form-label"><span class="x-red">*</span>内容</label>
<div class="layui-input-block">
<textarea name="content" required lay-verify="required"
placeholder="请输入回复内容" class="layui-textarea">{$info.content|default=''}</textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">排序</label>
<div class="layui-input-block">
<input type="number" name="sort" value="{$info.sort|default='0'}"
placeholder="数字越小越靠前" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<input type="radio" name="status" value="1" title="启用"
{if condition="!isset($info['status']) || $info['status'] == 1"}checked{/if}>
<input type="radio" name="status" value="0" title="禁用"
{if condition="isset($info['status']) && $info['status'] == 0"}checked{/if}>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label"></label>
<button class="layui-btn" lay-submit lay-filter="add">提交</button>
</div>
</form>
</div>
<script>
layui.use(['form', 'layer'], function(){
var form = layui.form;
var layer = layui.layer;
form.on('submit(add)', function(data){
$.post('', data.field, function(res){
if(res.code == 0 || res.code == 1){
layer.alert(res.msg || '操作成功', {icon: 1}, function(){
var index = parent.layer.getFrameIndex(window.name);
parent.layer.close(index);
parent.location.reload();
});
} else {
layer.alert(res.msg, {icon: 2});
}
});
return false;
});
});
</script>
</body>
</html>
@@ -0,0 +1,134 @@
{include file="public/header" /}
<body>
<div class="x-nav">
<span class="layui-breadcrumb">
<a href="">首页</a>
<a><cite>快捷回复管理</cite></a>
</span>
<a class="layui-btn layui-btn-small" style="line-height:1.6em;margin-top:3px;float:right" href="javascript:location.replace(location.href);" title="刷新">
<i class="layui-icon" style="line-height:30px"></i></a>
</div>
<div class="x-body">
<div class="layui-row">
<form class="layui-form layui-col-md12 x-so" method="get">
<div class="layui-input-inline">
<select name="category">
<option value="">全部分类</option>
{volist name="categories" id="cat"}
<option value="{$cat}" {if condition="isset($get['category']) && $get['category'] == $cat"}selected{/if}>{$cat}</option>
{/volist}
</select>
</div>
<div class="layui-input-inline">
<select name="status">
<option value="">全部状态</option>
<option value="1" {if condition="isset($get['status']) && $get['status'] == '1'"}selected{/if}>启用</option>
<option value="0" {if condition="isset($get['status']) && $get['status'] == '0'"}selected{/if}>禁用</option>
</select>
</div>
<button class="layui-btn" type="submit"><i class="layui-icon">&#xe615;</i></button>
</form>
</div>
<xblock>
<button class="layui-btn" onclick="x_admin_show('添加快捷回复','/chat_quick_reply/add',600,400)">
<i class="layui-icon"></i>添加
</button>
</xblock>
<table class="layui-table">
<thead>
<tr>
<th>ID</th>
<th>分类</th>
<th>标题</th>
<th>内容</th>
<th>排序</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{volist name="list" id="vo"}
<tr>
<td>{$vo.id}</td>
<td>{$vo.category|default='无'}</td>
<td>{$vo.title}</td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{$vo.content}">{$vo.content}</td>
<td>
<input type="text" class="layui-input" value="{$vo.sort}"
onchange="updateSort({$vo.id}, this.value)"
style="width: 60px; display: inline-block;">
</td>
<td>
<input type="checkbox" name="status" lay-skin="switch" lay-text="启用|禁用"
{eq name="vo.status" value="1"}checked{/eq}
onchange="updateStatus({$vo.id}, this.checked ? 1 : 0)">
</td>
<td>{$vo.create_time|date='Y-m-d H:i:s',###}</td>
<td class="td-manage">
<a title="编辑" onclick="x_admin_show('编辑','/chat_quick_reply/edit?id={$vo.id}',600,400)" href="javascript:;">
<i class="layui-icon">&#xe642;</i>
</a>
<a title="删除" onclick="member_del(this,'{$vo.id}')" href="javascript:;">
<i class="layui-icon">&#xe640;</i>
</a>
</td>
</tr>
{/volist}
</tbody>
</table>
<div class="page">
{$list->render()}
</div>
</div>
<script>
layui.use(['form', 'layer'], function(){
var form = layui.form;
var layer = layui.layer;
form.render();
});
// 删除
function member_del(obj, id){
layer.confirm('确认要删除吗?', function(index){
$.ajax({
url: '/chat_quick_reply/del',
type: 'POST',
data: {id: id},
success: function(res){
if(res.code == 0){
$(obj).parents("tr").remove();
layer.msg('删除成功', {icon: 1, time: 1000});
} else {
layer.msg(res.msg, {icon: 2, time: 1000});
}
}
});
});
}
// 更新状态
function updateStatus(id, status){
$.post('/chat_quick_reply/status', {id: id, status: status}, function(res){
if(res.code == 0){
layer.msg('操作成功', {icon: 1, time: 1000});
} else {
layer.msg(res.msg, {icon: 2, time: 1000});
}
});
}
// 更新排序
function updateSort(id, sort){
$.post('/chat_quick_reply/sort', {id: id, sort: sort}, function(res){
if(res.code == 0){
layer.msg('操作成功', {icon: 1, time: 1000});
} else {
layer.msg(res.msg, {icon: 2, time: 1000});
}
});
}
</script>
</body>
</html>
+11
View File
@@ -122,6 +122,17 @@
<li><a _href="/Tip/optimum"><i class="iconfont">&#59130;</i><cite>今日最佳记录</cite></a></li> <li><a _href="/Tip/optimum"><i class="iconfont">&#59130;</i><cite>今日最佳记录</cite></a></li>
</ul> </ul>
</li> </li>
<li>
<a href="javascript:;">
<cite class="main-nav">客服管理</cite>
<i class="iconfont nav_right">&#xe697;</i>
</a>
<ul class="sub-menu">
<li><a _href="/chat/index"><i class="iconfont">&#xe63a;</i><cite>客服工作台</cite></a></li>
<li><a _href="/chat/record"><i class="iconfont">&#xe756;</i><cite>聊天记录查询</cite></a></li>
<li><a _href="/chat_quick_reply/index"><i class="iconfont">&#xe705;</i><cite>快捷回复管理</cite></a></li>
</ul>
</li>
</div> </div>
<div id="configure_list" style="display: none;"> <div id="configure_list" style="display: none;">
<li class="open"> <li class="open">
BIN
View File
Binary file not shown.
+36
View File
@@ -253,6 +253,8 @@ class Agent Extends Common{
$limit_high_tie = round(Request::instance()->post('limit_high_tie'),2); $limit_high_tie = round(Request::instance()->post('limit_high_tie'),2);
$limit_low_pair = round(Request::instance()->post('limit_low_pair'),2); $limit_low_pair = round(Request::instance()->post('limit_low_pair'),2);
$limit_high_pair = round(Request::instance()->post('limit_high_pair'),2); $limit_high_pair = round(Request::instance()->post('limit_high_pair'),2);
$limit_low_nn = round(Request::instance()->post('limit_low_nn'),2);
$limit_high_nn = round(Request::instance()->post('limit_high_nn'),2);
$agent_ximalv = round(Request::instance()->post('agent_ximalv'),2); $agent_ximalv = round(Request::instance()->post('agent_ximalv'),2);
$agent_ximalv_dt = round(Request::instance()->post('agent_ximalv_dt'),2); $agent_ximalv_dt = round(Request::instance()->post('agent_ximalv_dt'),2);
$agent_ximalv_nn = round(Request::instance()->post('agent_ximalv_nn'),2); $agent_ximalv_nn = round(Request::instance()->post('agent_ximalv_nn'),2);
@@ -327,6 +329,15 @@ class Agent Extends Common{
if($limit_low_pair > $limit_high_pair && $limit_low_pair > 0 && $limit_high_pair > 0){ if($limit_low_pair > $limit_high_pair && $limit_low_pair > 0 && $limit_high_pair > 0){
die(json_encode(['code'=>0,'msg'=>$lang['min_pair_limit_bigger_than_max_tie_limit']])); die(json_encode(['code'=>0,'msg'=>$lang['min_pair_limit_bigger_than_max_tie_limit']]));
} }
if($limit_low_nn < 0){
die(json_encode(['code'=>0,'msg'=>$lang['min_nn_limit_0']]));
}
if($limit_high_nn < 0){
die(json_encode(['code'=>0,'msg'=>$lang['max_nn_limit_0']]));
}
if($limit_low_nn > $limit_high_nn && $limit_low_nn > 0 && $limit_high_nn > 0){
die(json_encode(['code'=>0,'msg'=>$lang['min_nn_limit_bigger_than_max']]));
}
if($limit_low < $agentParent['limit_low'] || $limit_low > $agentParent['limit_high'] || $limit_high < $agentParent['limit_low'] || $limit_high > $agentParent['limit_high']){ if($limit_low < $agentParent['limit_low'] || $limit_low > $agentParent['limit_high'] || $limit_high < $agentParent['limit_low'] || $limit_high > $agentParent['limit_high']){
die(json_encode(['code'=>0,'msg'=>$lang['limit_cannot_bigger'].' '.$agentParent['limit_low'].'-'.$agentParent['limit_high']])); die(json_encode(['code'=>0,'msg'=>$lang['limit_cannot_bigger'].' '.$agentParent['limit_low'].'-'.$agentParent['limit_high']]));
} }
@@ -336,6 +347,11 @@ class Agent Extends Common{
if($limit_low_pair < $agentParent['limit_low_pair'] || $limit_low_pair > $agentParent['limit_high_pair'] || $limit_high_pair < $agentParent['limit_low_pair'] || $limit_high_pair > $agentParent['limit_high_pair']){ if($limit_low_pair < $agentParent['limit_low_pair'] || $limit_low_pair > $agentParent['limit_high_pair'] || $limit_high_pair < $agentParent['limit_low_pair'] || $limit_high_pair > $agentParent['limit_high_pair']){
die(json_encode(['code'=>0,'msg'=>$lang['pair_limit_cannot_bigger'].' '.$agentParent['limit_low_pair'].'-'.$agentParent['limit_high_pair']])); die(json_encode(['code'=>0,'msg'=>$lang['pair_limit_cannot_bigger'].' '.$agentParent['limit_low_pair'].'-'.$agentParent['limit_high_pair']]));
} }
if($limit_low_nn > 0 && $limit_high_nn > 0 && $agentParent['limit_low_nn'] > 0 && $agentParent['limit_high_nn'] > 0){
if($limit_low_nn < $agentParent['limit_low_nn'] || $limit_low_nn > $agentParent['limit_high_nn'] || $limit_high_nn < $agentParent['limit_low_nn'] || $limit_high_nn > $agentParent['limit_high_nn']){
die(json_encode(['code'=>0,'msg'=>$lang['nn_limit_cannot_bigger'].' '.$agentParent['limit_low_nn'].'-'.$agentParent['limit_high_nn']]));
}
}
if($agent_ximalv < 0){ if($agent_ximalv < 0){
die(json_encode(['code'=>0,'msg'=>$lang['baccarat_limit_0']])); die(json_encode(['code'=>0,'msg'=>$lang['baccarat_limit_0']]));
} }
@@ -395,6 +411,8 @@ class Agent Extends Common{
$user_data['limit_high_tie'] = $limit_high_tie; $user_data['limit_high_tie'] = $limit_high_tie;
$user_data['limit_low_pair'] = $limit_low_pair; $user_data['limit_low_pair'] = $limit_low_pair;
$user_data['limit_high_pair'] = $limit_high_pair; $user_data['limit_high_pair'] = $limit_high_pair;
$user_data['limit_low_nn'] = $limit_low_nn;
$user_data['limit_high_nn'] = $limit_high_nn;
$user_data['bet_type'] = $bet_type; $user_data['bet_type'] = $bet_type;
$user_data['reg_time'] = time(); $user_data['reg_time'] = time();
$ip = '0.0.0.0'; $ip = '0.0.0.0';
@@ -533,6 +551,8 @@ class Agent Extends Common{
$limit_high_tie = round(Request::instance()->post('limit_high_tie'),2); $limit_high_tie = round(Request::instance()->post('limit_high_tie'),2);
$limit_low_pair = round(Request::instance()->post('limit_low_pair'),2); $limit_low_pair = round(Request::instance()->post('limit_low_pair'),2);
$limit_high_pair = round(Request::instance()->post('limit_high_pair'),2); $limit_high_pair = round(Request::instance()->post('limit_high_pair'),2);
$limit_low_nn = round(Request::instance()->post('limit_low_nn'),2);
$limit_high_nn = round(Request::instance()->post('limit_high_nn'),2);
$agent_ximalv = round(Request::instance()->post('agent_ximalv'),2); $agent_ximalv = round(Request::instance()->post('agent_ximalv'),2);
$agent_ximalv_dt = round(Request::instance()->post('agent_ximalv_dt'),2); $agent_ximalv_dt = round(Request::instance()->post('agent_ximalv_dt'),2);
$agent_ximalv_nn = round(Request::instance()->post('agent_ximalv_nn'),2); $agent_ximalv_nn = round(Request::instance()->post('agent_ximalv_nn'),2);
@@ -594,6 +614,20 @@ class Agent Extends Common{
if($limit_low_pair < $agentParent['limit_low_pair'] || $limit_low_pair > $agentParent['limit_high_pair'] || $limit_high_pair < $agentParent['limit_low_pair'] || $limit_high_pair > $agentParent['limit_high_pair']){ if($limit_low_pair < $agentParent['limit_low_pair'] || $limit_low_pair > $agentParent['limit_high_pair'] || $limit_high_pair < $agentParent['limit_low_pair'] || $limit_high_pair > $agentParent['limit_high_pair']){
die(json_encode(['code'=>0,'msg'=>$lang['pair_limit_cannot_bigger'].' '.$agentParent['limit_low_pair'].'-'.$agentParent['limit_high_pair'].'范围'])); die(json_encode(['code'=>0,'msg'=>$lang['pair_limit_cannot_bigger'].' '.$agentParent['limit_low_pair'].'-'.$agentParent['limit_high_pair'].'范围']));
} }
if($limit_low_nn < 0){
die(json_encode(['code'=>0,'msg'=>$lang['min_nn_limit_0']]));
}
if($limit_high_nn < 0){
die(json_encode(['code'=>0,'msg'=>$lang['max_nn_limit_0']]));
}
if($limit_low_nn > $limit_high_nn && $limit_low_nn > 0 && $limit_high_nn > 0){
die(json_encode(['code'=>0,'msg'=>$lang['min_nn_limit_bigger_than_max']]));
}
if($limit_low_nn > 0 && $limit_high_nn > 0 && $agentParent['limit_low_nn'] > 0 && $agentParent['limit_high_nn'] > 0){
if($limit_low_nn < $agentParent['limit_low_nn'] || $limit_low_nn > $agentParent['limit_high_nn'] || $limit_high_nn < $agentParent['limit_low_nn'] || $limit_high_nn > $agentParent['limit_high_nn']){
die(json_encode(['code'=>0,'msg'=>$lang['nn_limit_cannot_bigger'].' '.$agentParent['limit_low_nn'].'-'.$agentParent['limit_high_nn']]));
}
}
if($agent_ximalv > $agentParent['agent_ximalv']){ if($agent_ximalv > $agentParent['agent_ximalv']){
die(json_encode(['code'=>0,'msg'=>$lang['baccarat_cannot_bigger_agent'].' '.$agentParent['agent_ximalv'].'!'])); die(json_encode(['code'=>0,'msg'=>$lang['baccarat_cannot_bigger_agent'].' '.$agentParent['agent_ximalv'].'!']));
} }
@@ -662,6 +696,8 @@ class Agent Extends Common{
$user_data['limit_high_tie'] = $limit_high_tie; $user_data['limit_high_tie'] = $limit_high_tie;
$user_data['limit_low_pair'] = $limit_low_pair; $user_data['limit_low_pair'] = $limit_low_pair;
$user_data['limit_high_pair'] = $limit_high_pair; $user_data['limit_high_pair'] = $limit_high_pair;
$user_data['limit_low_nn'] = $limit_low_nn;
$user_data['limit_high_nn'] = $limit_high_nn;
$user_data['agent_ximalv'] = $agent_ximalv; $user_data['agent_ximalv'] = $agent_ximalv;
$user_data['agent_ximalv_dt'] = $agent_ximalv_dt; $user_data['agent_ximalv_dt'] = $agent_ximalv_dt;
$user_data['agent_ximalv_nn'] = $agent_ximalv_nn; $user_data['agent_ximalv_nn'] = $agent_ximalv_nn;
+37 -1
View File
@@ -281,6 +281,8 @@ class Player Extends Common{
$limit_high_tie = round(Request::instance()->post('limit_high_tie'),2); $limit_high_tie = round(Request::instance()->post('limit_high_tie'),2);
$limit_low_pair = round(Request::instance()->post('limit_low_pair'),2); $limit_low_pair = round(Request::instance()->post('limit_low_pair'),2);
$limit_high_pair = round(Request::instance()->post('limit_high_pair'),2); $limit_high_pair = round(Request::instance()->post('limit_high_pair'),2);
$limit_low_nn = round(Request::instance()->post('limit_low_nn'),2);
$limit_high_nn = round(Request::instance()->post('limit_high_nn'),2);
$win_limit = round(Request::instance()->post('win_limit'),2); $win_limit = round(Request::instance()->post('win_limit'),2);
$agent_commission = round(Request::instance()->post('agent_commission'),2); $agent_commission = round(Request::instance()->post('agent_commission'),2);
$agent_commission_dt = round(Request::instance()->post('agent_commission_dt'),2); $agent_commission_dt = round(Request::instance()->post('agent_commission_dt'),2);
@@ -385,6 +387,20 @@ class Player Extends Common{
if($limit_low_pair < $agentParent['limit_low_pair'] || $limit_low_pair > $agentParent['limit_high_pair'] || $limit_high_pair < $agentParent['limit_low_pair'] || $limit_high_pair > $agentParent['limit_high_pair']){ if($limit_low_pair < $agentParent['limit_low_pair'] || $limit_low_pair > $agentParent['limit_high_pair'] || $limit_high_pair < $agentParent['limit_low_pair'] || $limit_high_pair > $agentParent['limit_high_pair']){
die(json_encode(['code'=>0,'msg'=>$lang['pair_limit_cannot_bigger'].' '.$agentParent['limit_low_pair'].'-'.$agentParent['limit_high_pair']])); die(json_encode(['code'=>0,'msg'=>$lang['pair_limit_cannot_bigger'].' '.$agentParent['limit_low_pair'].'-'.$agentParent['limit_high_pair']]));
} }
if($limit_low_nn < 0){
die(json_encode(['code'=>0,'msg'=>$lang['min_nn_limit_0']]));
}
if($limit_high_nn < 0){
die(json_encode(['code'=>0,'msg'=>$lang['max_nn_limit_0']]));
}
if($limit_low_nn > $limit_high_nn && $limit_low_nn > 0 && $limit_high_nn > 0){
die(json_encode(['code'=>0,'msg'=>$lang['min_nn_limit_bigger_than_max']]));
}
if($limit_low_nn > 0 && $limit_high_nn > 0 && $agentParent['limit_low_nn'] > 0 && $agentParent['limit_high_nn'] > 0){
if($limit_low_nn < $agentParent['limit_low_nn'] || $limit_low_nn > $agentParent['limit_high_nn'] || $limit_high_nn < $agentParent['limit_low_nn'] || $limit_high_nn > $agentParent['limit_high_nn']){
die(json_encode(['code'=>0,'msg'=>$lang['nn_limit_cannot_bigger'].' '.$agentParent['limit_low_nn'].'-'.$agentParent['limit_high_nn']]));
}
}
if($agent_ximalv > $agentParent['agent_ximalv']){ if($agent_ximalv > $agentParent['agent_ximalv']){
die(json_encode(['code'=>0,'msg'=>$lang['baccarat_cannot_bigger_agent'].$agentParent['agent_ximalv'].'!'])); die(json_encode(['code'=>0,'msg'=>$lang['baccarat_cannot_bigger_agent'].$agentParent['agent_ximalv'].'!']));
} }
@@ -427,6 +443,8 @@ class Player Extends Common{
$user_data['limit_high_tie'] = $limit_high_tie; $user_data['limit_high_tie'] = $limit_high_tie;
$user_data['limit_low_pair'] = $limit_low_pair; $user_data['limit_low_pair'] = $limit_low_pair;
$user_data['limit_high_pair'] = $limit_high_pair; $user_data['limit_high_pair'] = $limit_high_pair;
$user_data['limit_low_nn'] = $limit_low_nn;
$user_data['limit_high_nn'] = $limit_high_nn;
$user_data['price_banker'] = $agentParent['price_banker']; $user_data['price_banker'] = $agentParent['price_banker'];
$user_data['price_player'] = $agentParent['price_player']; $user_data['price_player'] = $agentParent['price_player'];
$user_data['price_tie_baccarat'] = $agentParent['price_tie_baccarat']; $user_data['price_tie_baccarat'] = $agentParent['price_tie_baccarat'];
@@ -605,6 +623,8 @@ class Player Extends Common{
$limit_high_tie = round(Request::instance()->post('limit_high_tie'),2); $limit_high_tie = round(Request::instance()->post('limit_high_tie'),2);
$limit_low_pair = round(Request::instance()->post('limit_low_pair'),2); $limit_low_pair = round(Request::instance()->post('limit_low_pair'),2);
$limit_high_pair = round(Request::instance()->post('limit_high_pair'),2); $limit_high_pair = round(Request::instance()->post('limit_high_pair'),2);
$limit_low_nn = round(Request::instance()->post('limit_low_nn'),2);
$limit_high_nn = round(Request::instance()->post('limit_high_nn'),2);
$agent_ximalv = round(Request::instance()->post('agent_ximalv'),2); $agent_ximalv = round(Request::instance()->post('agent_ximalv'),2);
$agent_ximalv_dt = round(Request::instance()->post('agent_ximalv_dt'),2); $agent_ximalv_dt = round(Request::instance()->post('agent_ximalv_dt'),2);
$agent_ximalv_nn = round(Request::instance()->post('agent_ximalv_nn'),2); $agent_ximalv_nn = round(Request::instance()->post('agent_ximalv_nn'),2);
@@ -681,7 +701,21 @@ class Player Extends Common{
if($limit_low_pair < $agentParent['limit_low_pair'] || $limit_low_pair > $agentParent['limit_high_pair'] || $limit_high_pair < $agentParent['limit_low_pair'] || $limit_high_pair > $agentParent['limit_high_pair']){ if($limit_low_pair < $agentParent['limit_low_pair'] || $limit_low_pair > $agentParent['limit_high_pair'] || $limit_high_pair < $agentParent['limit_low_pair'] || $limit_high_pair > $agentParent['limit_high_pair']){
die(json_encode(['code'=>0,'msg'=>$lang['pair_limit_cannot_bigger'].' '.$agentParent['limit_low_pair'].'-'.$agentParent['limit_high_pair']])); die(json_encode(['code'=>0,'msg'=>$lang['pair_limit_cannot_bigger'].' '.$agentParent['limit_low_pair'].'-'.$agentParent['limit_high_pair']]));
} }
if($limit_low_nn < 0){
die(json_encode(['code'=>0,'msg'=>$lang['min_nn_limit_0']]));
}
if($limit_high_nn < 0){
die(json_encode(['code'=>0,'msg'=>$lang['max_nn_limit_0']]));
}
if($limit_low_nn > $limit_high_nn && $limit_low_nn > 0 && $limit_high_nn > 0){
die(json_encode(['code'=>0,'msg'=>$lang['min_nn_limit_bigger_than_max']]));
}
if($limit_low_nn > 0 && $limit_high_nn > 0 && $agentParent['limit_low_nn'] > 0 && $agentParent['limit_high_nn'] > 0){
if($limit_low_nn < $agentParent['limit_low_nn'] || $limit_low_nn > $agentParent['limit_high_nn'] || $limit_high_nn < $agentParent['limit_low_nn'] || $limit_high_nn > $agentParent['limit_high_nn']){
die(json_encode(['code'=>0,'msg'=>$lang['nn_limit_cannot_bigger'].' '.$agentParent['limit_low_nn'].'-'.$agentParent['limit_high_nn']]));
}
}
if($agent_ximalv > $agentParent['agent_ximalv']){ if($agent_ximalv > $agentParent['agent_ximalv']){
die(json_encode(['code'=>0,'msg'=>$lang['baccarat_cannot_bigger_agent'].$agentParent['agent_ximalv'].'!'])); die(json_encode(['code'=>0,'msg'=>$lang['baccarat_cannot_bigger_agent'].$agentParent['agent_ximalv'].'!']));
} }
@@ -719,6 +753,8 @@ class Player Extends Common{
$user_data['limit_high_tie'] = $limit_high_tie; $user_data['limit_high_tie'] = $limit_high_tie;
$user_data['limit_low_pair'] = $limit_low_pair; $user_data['limit_low_pair'] = $limit_low_pair;
$user_data['limit_high_pair'] = $limit_high_pair; $user_data['limit_high_pair'] = $limit_high_pair;
$user_data['limit_low_nn'] = $limit_low_nn;
$user_data['limit_high_nn'] = $limit_high_nn;
$user_data['agent_ximalv'] = $agent_ximalv; $user_data['agent_ximalv'] = $agent_ximalv;
$user_data['agent_ximalv_dt'] = $agent_ximalv_dt; $user_data['agent_ximalv_dt'] = $agent_ximalv_dt;
$user_data['agent_ximalv_nn'] = $agent_ximalv_nn; $user_data['agent_ximalv_nn'] = $agent_ximalv_nn;
+3 -3
View File
@@ -35,17 +35,17 @@ class Report Extends Common{
$game_id = intval(Request::instance()->get('game_id')); $game_id = intval(Request::instance()->get('game_id'));
$export = intval(Request::instance()->get('export')); $export = intval(Request::instance()->get('export'));
// 转换日期时间 // 转换日期时间(默认:今天07:00:00 ~ 明天06:59:59
if ($startDate) { if ($startDate) {
$startTime = strtotime($startDate); $startTime = strtotime($startDate);
} else { } else {
$startTime = strtotime(date('Y-m-d')); $startTime = strtotime(date('Y-m-d') . ' 07:00:00');
$get['startDate'] = date('Y-m-d H:i:s', $startTime); $get['startDate'] = date('Y-m-d H:i:s', $startTime);
} }
if ($endDate) { if ($endDate) {
$endTime = strtotime($endDate); $endTime = strtotime($endDate);
} else { } else {
$endTime = time(); $endTime = strtotime(date('Y-m-d', strtotime('+1 day')) . ' 06:59:59');
$get['endDate'] = date('Y-m-d H:i:s', $endTime); $get['endDate'] = date('Y-m-d H:i:s', $endTime);
} }
+8
View File
@@ -185,6 +185,14 @@ return [
'limit_cannot_bigger' => 'Limit cannot exceed', 'limit_cannot_bigger' => 'Limit cannot exceed',
'tie_limit_cannot_bigger' => 'Tie limit cannot exceed', 'tie_limit_cannot_bigger' => 'Tie limit cannot exceed',
'pair_limit_cannot_bigger' => 'Pair limit cannot exceed', 'pair_limit_cannot_bigger' => 'Pair limit cannot exceed',
'min_nn_limit' => 'Min bull limit',
'max_nn_limit' => 'Max bull limit',
'empty_min_nn_limit' => 'Please enter the min bull limit',
'empty_max_nn_limit' => 'Please enter the max bull limit',
'min_nn_limit_0' => 'Min bull limit cannot be less than 0',
'max_nn_limit_0' => 'Max bull limit cannot be less than 0',
'min_nn_limit_bigger_than_max' => 'Min bull limit cannot exceed max bull limit',
'nn_limit_cannot_bigger' => 'Bull limit cannot exceed',
'baccarat_limit_0' => 'Baccarat code rate cannot be lower than 0', 'baccarat_limit_0' => 'Baccarat code rate cannot be lower than 0',
'baccarat_cannot_bigger_agent' => 'The Baccarat code rate cannot exceed the Baccarat code rate of the affiliated agent', 'baccarat_cannot_bigger_agent' => 'The Baccarat code rate cannot exceed the Baccarat code rate of the affiliated agent',
'dt_limit_0' => 'The Dragon tiger code rate cannot be lower than 0', 'dt_limit_0' => 'The Dragon tiger code rate cannot be lower than 0',
+8
View File
@@ -185,6 +185,14 @@ return [
'limit_cannot_bigger' => '限红不能超出', 'limit_cannot_bigger' => '限红不能超出',
'tie_limit_cannot_bigger' => '和限红不能超出', 'tie_limit_cannot_bigger' => '和限红不能超出',
'pair_limit_cannot_bigger' => '对子限红不能超出', 'pair_limit_cannot_bigger' => '对子限红不能超出',
'min_nn_limit' => '最小牛牛限红',
'max_nn_limit' => '最大牛牛限红',
'empty_min_nn_limit' => '请输入最小牛牛限红',
'empty_max_nn_limit' => '请输入最大牛牛限红',
'min_nn_limit_0' => '最小牛牛限红不能低于0',
'max_nn_limit_0' => '最大牛牛限红不能低于0',
'min_nn_limit_bigger_than_max' => '最小牛牛限红不能超过最大牛牛限红',
'nn_limit_cannot_bigger' => '牛牛限红不能超出',
'baccarat_limit_0' => '百家乐码率不能低于0', 'baccarat_limit_0' => '百家乐码率不能低于0',
'baccarat_cannot_bigger_agent' => '百家乐码率不能超过所属代理的百家乐码率', 'baccarat_cannot_bigger_agent' => '百家乐码率不能超过所属代理的百家乐码率',
'dt_limit_0' => '龙虎码率不能低于0', 'dt_limit_0' => '龙虎码率不能低于0',
+8
View File
@@ -185,6 +185,14 @@ return [
'limit_cannot_bigger' => '限紅不能超出', 'limit_cannot_bigger' => '限紅不能超出',
'tie_limit_cannot_bigger' => '和限紅不能超出', 'tie_limit_cannot_bigger' => '和限紅不能超出',
'pair_limit_cannot_bigger' => '對子限紅不能超出', 'pair_limit_cannot_bigger' => '對子限紅不能超出',
'min_nn_limit' => '最小牛牛限紅',
'max_nn_limit' => '最大牛牛限紅',
'empty_min_nn_limit' => '請輸入最小牛牛限紅',
'empty_max_nn_limit' => '請輸入最大牛牛限紅',
'min_nn_limit_0' => '最小牛牛限紅不能低於0',
'max_nn_limit_0' => '最大牛牛限紅不能低於0',
'min_nn_limit_bigger_than_max' => '最小牛牛限紅不能超過最大牛牛限紅',
'nn_limit_cannot_bigger' => '牛牛限紅不能超出',
'baccarat_limit_0' => '百家樂碼率不能低於0', 'baccarat_limit_0' => '百家樂碼率不能低於0',
'baccarat_cannot_bigger_agent' => '百家樂碼率不能超過所屬代理的百家樂碼率', 'baccarat_cannot_bigger_agent' => '百家樂碼率不能超過所屬代理的百家樂碼率',
'dt_limit_0' => '龍虎碼率不能低於0', 'dt_limit_0' => '龍虎碼率不能低於0',
Binary file not shown.
+24
View File
@@ -129,6 +129,16 @@
<input type="text" id="limit_high_pair" autocomplete="off" class="layui-input" value="{$agentParent.limit_high_pair}"> <input type="text" id="limit_high_pair" autocomplete="off" class="layui-input" value="{$agentParent.limit_high_pair}">
</div> </div>
</div> </div>
<div class="layui-form-item">
<label for="limit_low_nn" class="layui-form-label">{$lang['min_nn_limit']}</label>
<div class="layui-input-inline">
<input type="text" id="limit_low_nn" autocomplete="off" class="layui-input" value="{$agentParent.limit_low_nn}">
</div>
<label for="limit_high_nn" class="layui-form-label">{$lang['max_nn_limit']}</label>
<div class="layui-input-inline">
<input type="text" id="limit_high_nn" autocomplete="off" class="layui-input" value="{$agentParent.limit_high_nn}">
</div>
</div>
<!-- 信用线展示码率 --> <!-- 信用线展示码率 -->
<?php if($user_info['agent_type'] == 2) : ?> <?php if($user_info['agent_type'] == 2) : ?>
@@ -242,6 +252,8 @@
query.limit_high_tie = $('#limit_high_tie').val(); query.limit_high_tie = $('#limit_high_tie').val();
query.limit_low_pair = $('#limit_low_pair').val(); query.limit_low_pair = $('#limit_low_pair').val();
query.limit_high_pair = $('#limit_high_pair').val(); query.limit_high_pair = $('#limit_high_pair').val();
query.limit_low_nn = $('#limit_low_nn').val();
query.limit_high_nn = $('#limit_high_nn').val();
query.type_xima = $('#xima_type').val(); query.type_xima = $('#xima_type').val();
query.agent_ximalv = $('#agent_ximalv').val() || 0; query.agent_ximalv = $('#agent_ximalv').val() || 0;
query.agent_ximalv_dt = $('#agent_ximalv_dt').val() || 0; query.agent_ximalv_dt = $('#agent_ximalv_dt').val() || 0;
@@ -320,6 +332,18 @@
}); });
return false; return false;
} }
if(query.limit_low_nn.length == 0 || query.limit_low_nn == undefined){
layer.alert(lang.empty_min_nn_limit,{
title:lang.message,
});
return false;
}
if(query.limit_high_nn.length == 0 || query.limit_high_nn == undefined){
layer.alert(lang.empty_max_nn_limit,{
title:lang.message,
});
return false;
}
if(query.type_xima == ''){ if(query.type_xima == ''){
layer.alert(lang.empty_wash_type,{ layer.alert(lang.empty_wash_type,{
title:lang.message, title:lang.message,
+26
View File
@@ -81,6 +81,16 @@
<input type="text" id="limit_high_pair" autocomplete="off" value="{$user.limit_high_pair}" class="layui-input"> <input type="text" id="limit_high_pair" autocomplete="off" value="{$user.limit_high_pair}" class="layui-input">
</div> </div>
</div> </div>
<div class="layui-form-item">
<label for="limit_low_nn" class="layui-form-label">{$lang['min_nn_limit']}</label>
<div class="layui-input-inline">
<input type="text" id="limit_low_nn" autocomplete="off" value="{$user.limit_low_nn}" class="layui-input">
</div>
<label for="limit_high_nn" class="layui-form-label">{$lang['max_nn_limit']}</label>
<div class="layui-input-inline">
<input type="text" id="limit_high_nn" autocomplete="off" value="{$user.limit_high_nn}" class="layui-input">
</div>
</div>
<!-- 信用线展示码率 --> <!-- 信用线展示码率 -->
<?php if($user['agent_type'] == 2) : ?> <?php if($user['agent_type'] == 2) : ?>
<div class="layui-form-item"> <div class="layui-form-item">
@@ -152,6 +162,8 @@
query.limit_high_tie = $('#limit_high_tie').val(); query.limit_high_tie = $('#limit_high_tie').val();
query.limit_low_pair = $('#limit_low_pair').val(); query.limit_low_pair = $('#limit_low_pair').val();
query.limit_high_pair = $('#limit_high_pair').val(); query.limit_high_pair = $('#limit_high_pair').val();
query.limit_low_nn = $('#limit_low_nn').val();
query.limit_high_nn = $('#limit_high_nn').val();
query.agent_ximalv = $('#agent_ximalv').val() || 0; query.agent_ximalv = $('#agent_ximalv').val() || 0;
query.agent_ximalv_dt = $('#agent_ximalv_dt').val() || 0; query.agent_ximalv_dt = $('#agent_ximalv_dt').val() || 0;
query.agent_ximalv_nn = $('#agent_ximalv_nn').val() || 0; query.agent_ximalv_nn = $('#agent_ximalv_nn').val() || 0;
@@ -209,6 +221,20 @@
}); });
return false; return false;
} }
if(query.limit_low_nn.length == 0 || query.limit_low_nn == undefined){
layer.alert(lang.empty_min_nn_limit,{
title:lang.message,
});
return false;
}
if(query.limit_high_nn.length == 0 || query.limit_high_nn == undefined){
layer.alert(lang.empty_max_nn_limit,{
title:lang.message,
});
return false;
}
return false;
}
if(query.agent_ximalv.length == 0 || query.agent_ximalv == undefined){ if(query.agent_ximalv.length == 0 || query.agent_ximalv == undefined){
ayer.alert(lang.empty_baccacat_rate,{ ayer.alert(lang.empty_baccacat_rate,{
title:lang.message, title:lang.message,
+24
View File
@@ -547,6 +547,16 @@
<input type="text" id="limit_high_pair" autocomplete="off" class="layui-input" value="{$agentParent.limit_high_pair}"> <input type="text" id="limit_high_pair" autocomplete="off" class="layui-input" value="{$agentParent.limit_high_pair}">
</div> </div>
</div> </div>
<div class="layui-form-item">
<label for="limit_low_nn" class="layui-form-label">{$lang['min_nn_limit']}</label>
<div class="layui-input-inline">
<input type="text" id="limit_low_nn" autocomplete="off" class="layui-input" value="{$agentParent.limit_low_nn}">
</div>
<label for="limit_high_nn" class="layui-form-label">{$lang['max_nn_limit']}</label>
<div class="layui-input-inline">
<input type="text" id="limit_high_nn" autocomplete="off" class="layui-input" value="{$agentParent.limit_high_nn}">
</div>
</div>
<div class="layui-form-item"> <div class="layui-form-item">
<label for="win_limit" class="layui-form-label">{$lang['daily_win_limit']}</label> <label for="win_limit" class="layui-form-label">{$lang['daily_win_limit']}</label>
<div class="layui-input-inline"> <div class="layui-input-inline">
@@ -695,6 +705,8 @@
query.limit_high_tie = $('#limit_high_tie').val(); query.limit_high_tie = $('#limit_high_tie').val();
query.limit_low_pair = $('#limit_low_pair').val(); query.limit_low_pair = $('#limit_low_pair').val();
query.limit_high_pair = $('#limit_high_pair').val(); query.limit_high_pair = $('#limit_high_pair').val();
query.limit_low_nn = $('#limit_low_nn').val();
query.limit_high_nn = $('#limit_high_nn').val();
query.win_limit = $('#win_limit').val(); query.win_limit = $('#win_limit').val();
query.agent_commission = $('#agent_commission').val(); query.agent_commission = $('#agent_commission').val();
query.agent_commission_dt = $('#agent_commission_dt').val(); query.agent_commission_dt = $('#agent_commission_dt').val();
@@ -787,6 +799,18 @@
}); });
return false; return false;
} }
if(query.limit_low_nn.length == 0 || query.limit_low_nn == undefined){
layer.alert(lang.empty_min_nn_limit,{
title:lang.message,
});
return false;
}
if(query.limit_high_nn.length == 0 || query.limit_high_nn == undefined){
layer.alert(lang.empty_max_nn_limit,{
title:lang.message,
});
return false;
}
if(query.win_limit.length == 0 || query.win_limit == undefined){ if(query.win_limit.length == 0 || query.win_limit == undefined){
layer.alert(lang.empty_day_win_limit,{ layer.alert(lang.empty_day_win_limit,{
title:lang.message, title:lang.message,
+28
View File
@@ -134,6 +134,18 @@
</div> </div>
<div class="layui-form-mid layui-word-aux limit_high_pair"></div> <div class="layui-form-mid layui-word-aux limit_high_pair"></div>
</div> </div>
<div class="layui-form-item">
<label for="limit_low_nn" class="layui-form-label">{$lang['min_nn_limit']}</label>
<div class="layui-input-inline">
<input type="text" id="limit_low_nn" autocomplete="off" class="layui-input" value="{$user.limit_low_nn}">
</div>
<div class="layui-form-mid layui-word-aux limit_low_nn"></div>
<label for="limit_high_nn" class="layui-form-label">{$lang['max_nn_limit']}</label>
<div class="layui-input-inline">
<input type="text" id="limit_high_nn" autocomplete="off" class="layui-input" value="{$user.limit_high_nn}">
</div>
<div class="layui-form-mid layui-word-aux limit_high_nn"></div>
</div>
<div class="layui-form-item"> <div class="layui-form-item">
<label for="price_banker" class="layui-form-label">{$lang['banker_odds']}</label> <label for="price_banker" class="layui-form-label">{$lang['banker_odds']}</label>
<div class="layui-input-inline"> <div class="layui-input-inline">
@@ -616,6 +628,8 @@
<input type="hidden" id="hidden_limit_high_tie" value="{$agent_parent.limit_high_tie}"> <input type="hidden" id="hidden_limit_high_tie" value="{$agent_parent.limit_high_tie}">
<input type="hidden" id="hidden_limit_low_pair" value="{$agent_parent.limit_low_pair}"> <input type="hidden" id="hidden_limit_low_pair" value="{$agent_parent.limit_low_pair}">
<input type="hidden" id="hidden_limit_high_pair" value="{$agent_parent.limit_high_pair}"> <input type="hidden" id="hidden_limit_high_pair" value="{$agent_parent.limit_high_pair}">
<input type="hidden" id="hidden_limit_low_nn" value="{$agent_parent.limit_low_nn}">
<input type="hidden" id="hidden_limit_high_nn" value="{$agent_parent.limit_high_nn}">
</div> </div>
<script> <script>
$(function(){ $(function(){
@@ -643,6 +657,8 @@
query.limit_high_tie = $('#limit_high_tie').val(); query.limit_high_tie = $('#limit_high_tie').val();
query.limit_low_pair = $('#limit_low_pair').val(); query.limit_low_pair = $('#limit_low_pair').val();
query.limit_high_pair = $('#limit_high_pair').val(); query.limit_high_pair = $('#limit_high_pair').val();
query.limit_low_nn = $('#limit_low_nn').val();
query.limit_high_nn = $('#limit_high_nn').val();
query.agent_ximalv = $('#agent_ximalv').val() || 0; query.agent_ximalv = $('#agent_ximalv').val() || 0;
query.agent_ximalv_dt = $('#agent_ximalv_dt').val() || 0; query.agent_ximalv_dt = $('#agent_ximalv_dt').val() || 0;
query.agent_ximalv_nn = $('#agent_ximalv_nn').val() || 0; query.agent_ximalv_nn = $('#agent_ximalv_nn').val() || 0;
@@ -730,6 +746,18 @@
}); });
return false; return false;
} }
if(query.limit_low_nn.length == 0 || query.limit_low_nn == undefined){
layer.alert(lang.empty_min_nn_limit,{
title:lang.message,
});
return false;
}
if(query.limit_high_nn.length == 0 || query.limit_high_nn == undefined){
layer.alert(lang.empty_max_nn_limit,{
title:lang.message,
});
return false;
}
if(query.agent_ximalv.length == 0 || query.agent_ximalv == undefined){ if(query.agent_ximalv.length == 0 || query.agent_ximalv == undefined){
layer.alert(lang.empty_baccacat_rate,{ layer.alert(lang.empty_baccacat_rate,{
title:lang.message, title:lang.message,
+22 -8
View File
@@ -180,17 +180,31 @@
}else{ }else{
dateLang = 'cn'; dateLang = 'cn';
} }
// 计算默认时间:今天07:00:00 ~ 明天06:59:59
var now = new Date();
var y = now.getFullYear();
var m = ('0' + (now.getMonth() + 1)).slice(-2);
var d = ('0' + now.getDate()).slice(-2);
var tomorrow = new Date(now.getTime() + 86400000);
var ty = tomorrow.getFullYear();
var tm = ('0' + (tomorrow.getMonth() + 1)).slice(-2);
var td = ('0' + tomorrow.getDate()).slice(-2);
var defaultStart = y + '-' + m + '-' + d + ' 07:00:00';
var defaultEnd = ty + '-' + tm + '-' + td + ' 06:59:59';
//执行一个laydate实例 //执行一个laydate实例
laydate.render({ laydate.render({
elem: '#start' //指定元素 elem: '#start'
,type:'datetime', ,type:'datetime',
lang: dateLang lang: dateLang,
value: defaultStart
}); });
//执行一个laydate实例 //执行一个laydate实例
laydate.render({ laydate.render({
elem: '#end' //指定元素 elem: '#end'
,type:'datetime', ,type:'datetime',
lang: dateLang lang: dateLang,
value: defaultEnd
}); });
}); });
@@ -292,11 +306,11 @@
break; break;
} }
if(format == 1){ if(format == 1){
startDate = startYear + '-' + startMonth + '-' + startDay + ' 00:00:00'; startDate = startYear + '-' + startMonth + '-' + startDay + ' 07:00:00';
endDate = endYear + '-' + endMonth + '-' + endDay + ' 23:59:59'; endDate = endYear + '-' + endMonth + '-' + endDay + ' 06:59:59';
}else{ }else{
startDate = startDay + ' 00:00:00'; startDate = startDay + ' 07:00:00';
endDate = endDay + ' 23:59:59'; endDate = endDay + ' 06:59:59';
} }
query.startDate = startDate; query.startDate = startDate;
+5
View File
@@ -58,3 +58,8 @@ define('LOCAL_PHONE_WS', 'ws://192.168.6.2:8088');
//JK //JK
define('LOCAL_TRACKER_SERVER', 'og-ws.abc.com'); define('LOCAL_TRACKER_SERVER', 'og-ws.abc.com');
define('LOCAL_TRACKER_PROTOCOL', 'https'); define('LOCAL_TRACKER_PROTOCOL', 'https');
// 注册相关常量
define('REGISTER_DEFAULT_AREA_ID', 1); // 默认注册区域ID (1=大陆, 2=台湾)
define('REGISTER_XIMALV_GAP', 0.1); // 洗码率差值
define('REGISTER_REBATE_GAP', 0.1); // 返点差值
BIN
View File
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,564 @@
# OPSX Design: 在线客服模块技术设计
## 变更标识
- **变更ID**: customer-service-module
- **版本**: 2.0
- **状态**: DESIGN_COMPLETE
- **更新日期**: 2026-01-28
---
## 1. 架构设计
### 1.1 系统架构图
```
┌─────────────────────────────────────────────────────────────────────┐
│ 客户端层 │
├─────────────┬─────────────┬─────────────┬─────────────────────────┤
│ PC端 │ Game端 │ Portal端 │ Admin客服工作台 │
│ (Vue 2.x) │ (Vue 3.x) │ (uni-app) │ (Layui + WS) │
│ Element UI │ Vant │ uView │ │
└──────┬──────┴──────┬──────┴──────┬──────┴───────────┬─────────────┘
│ │ │ │
└─────────────┼─────────────┼───────────────────┘
│ WebSocket (chat.* 事件)
┌─────────────────────────────────────────────────────────────────────┐
│ Socket服务 (TP6 + Swoole) │
├─────────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ ChatConnect │ │ ChatMessage │ │ ChatSession │ Listeners │
│ │ Listener │ │ Listener │ │ Listener │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ChatService │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ MessageSvc │ │ SessionSvc │ │ AssignSvc │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ MySQL │ │ Redis │ │ 文件存储 │
│ │ │ │ │ │
│ cg_chat_session │ │ cs:online:* │ │ public/uploads/ │
│ cg_chat_message │ │ cs:conn:* │ │ chat/ │
│ cg_chat_quick_ │ │ cs:lock:* │ │ │
│ reply │ │ cs:queue:* │ │ │
│ cg_chat_admin_ │ │ cs:agent:* │ │ │
│ status │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
### 1.2 模块职责
| 模块 | 职责 | 技术栈 |
|------|------|--------|
| **ChatConnect Listener** | 处理WS连接/断开、心跳、鉴权 | Swoole onOpen/onClose |
| **ChatMessage Listener** | 处理消息收发、ACK、重试 | Swoole onMessage |
| **ChatSession Listener** | 处理会话创建/分配/转接/结束 | Swoole onMessage |
| **MessageService** | 消息持久化、查询、状态更新 | ThinkPHP Model |
| **SessionService** | 会话生命周期管理 | ThinkPHP Model |
| **AssignService** | 会话分配、负载均衡、队列管理 | Redis + Model |
---
## 2. 数据库设计
### 2.1 表结构 (最终版)
```sql
-- 客服会话表
CREATE TABLE `cg_chat_session` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(10) unsigned NOT NULL COMMENT '用户ID',
`admin_id` int(10) unsigned DEFAULT NULL COMMENT '客服ID',
`source` tinyint(1) NOT NULL DEFAULT '1' COMMENT '来源: 1=PC 2=Game 3=Portal',
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '状态: 0=待分配 1=进行中 2=已结束',
`rating` tinyint(1) DEFAULT NULL COMMENT '评分: 1-5',
`rating_content` varchar(500) DEFAULT NULL COMMENT '评价内容',
`last_msg_id` bigint(20) unsigned DEFAULT NULL COMMENT '最后消息ID(雪花)',
`last_msg_time` int(10) unsigned DEFAULT NULL COMMENT '最后消息时间',
`create_time` int(10) unsigned NOT NULL,
`update_time` int(10) unsigned NOT NULL,
`end_time` int(10) unsigned DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_status` (`user_id`, `status`),
KEY `idx_admin_status` (`admin_id`, `status`),
KEY `idx_status_create` (`status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客服会话表';
-- 聊天消息表
CREATE TABLE `cg_chat_message` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`msg_id` bigint(20) unsigned NOT NULL COMMENT '消息ID(雪花算法)',
`session_id` int(10) unsigned NOT NULL COMMENT '会话ID',
`sender_type` tinyint(1) NOT NULL COMMENT '发送者类型: 1=用户 2=客服',
`sender_id` int(10) unsigned NOT NULL COMMENT '发送者ID',
`msg_type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '消息类型: 1=文字 2=图片',
`content` varchar(500) NOT NULL COMMENT '消息内容(文字或图片URL)',
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '状态: 0=pending 1=sent 2=delivered 3=read 4=failed',
`retry_count` tinyint(1) NOT NULL DEFAULT '0' COMMENT '重试次数',
`create_time` int(10) unsigned NOT NULL,
`delivered_time` int(10) unsigned DEFAULT NULL,
`read_time` int(10) unsigned DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_msg_id` (`msg_id`),
KEY `idx_session_id` (`session_id`, `id`),
KEY `idx_session_status` (`session_id`, `status`),
KEY `idx_sender` (`sender_type`, `sender_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天消息表';
-- 快捷回复表
CREATE TABLE `cg_chat_quick_reply` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`category` varchar(50) DEFAULT NULL COMMENT '分类',
`title` varchar(100) NOT NULL COMMENT '标题',
`content` text NOT NULL COMMENT '内容',
`sort` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '排序',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态: 0=禁用 1=启用',
`create_time` int(10) unsigned NOT NULL,
`update_time` int(10) unsigned NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_category_status` (`category`, `status`, `sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快捷回复表';
-- 客服状态表 (辅助表,主状态在Redis)
CREATE TABLE `cg_chat_admin_status` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`admin_id` int(10) unsigned NOT NULL COMMENT '客服ID',
`max_sessions` int(10) unsigned NOT NULL DEFAULT '10' COMMENT '最大会话数',
`is_enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用客服功能',
`last_online_time` int(10) unsigned DEFAULT NULL,
`create_time` int(10) unsigned NOT NULL,
`update_time` int(10) unsigned NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_admin_id` (`admin_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客服状态配置表';
```
### 2.2 索引设计说明
| 表 | 索引 | 用途 |
|----|------|------|
| cg_chat_session | `idx_user_status` | 查询用户活跃会话 |
| cg_chat_session | `idx_admin_status` | 查询客服进行中会话 |
| cg_chat_message | `uk_msg_id` | 消息幂等性校验 |
| cg_chat_message | `idx_session_id` | 按会话查询消息(分页) |
| cg_chat_message | `idx_session_status` | 查询会话未读消息 |
---
## 3. 核心算法设计
### 3.1 雪花算法 (消息ID生成)
```php
// app/utils/Snowflake.php
class Snowflake
{
private const EPOCH = 1704067200000; // 2024-01-01 00:00:00 UTC
private const WORKER_ID_BITS = 5;
private const DATACENTER_ID_BITS = 5;
private const SEQUENCE_BITS = 12;
private $workerId;
private $datacenterId;
private $sequence = 0;
private $lastTimestamp = -1;
public function __construct(int $workerId = 1, int $datacenterId = 1)
{
$this->workerId = $workerId & 0x1F; // 5 bits
$this->datacenterId = $datacenterId & 0x1F; // 5 bits
}
public function nextId(): int
{
$timestamp = $this->currentTimeMillis();
if ($timestamp === $this->lastTimestamp) {
$this->sequence = ($this->sequence + 1) & 0xFFF; // 12 bits
if ($this->sequence === 0) {
$timestamp = $this->waitNextMillis($this->lastTimestamp);
}
} else {
$this->sequence = 0;
}
$this->lastTimestamp = $timestamp;
return (($timestamp - self::EPOCH) << 22)
| ($this->datacenterId << 17)
| ($this->workerId << 12)
| $this->sequence;
}
private function currentTimeMillis(): int
{
return (int)(microtime(true) * 1000);
}
private function waitNextMillis(int $lastTimestamp): int
{
$timestamp = $this->currentTimeMillis();
while ($timestamp <= $lastTimestamp) {
$timestamp = $this->currentTimeMillis();
}
return $timestamp;
}
}
```
### 3.2 会话分配算法 (最少会话数 + 分布式锁)
```php
// app/services/chat/AssignService.php
class AssignService
{
private const LOCK_TTL = 3000; // 3秒
private const LOCK_PREFIX = 'cs:lock:assign:';
private const ONLINE_PREFIX = 'cs:online:agent:';
private const LOAD_PREFIX = 'cs:agent:load:';
/**
* 分配会话给客服
* @param int $userId 用户ID
* @param int $sessionId 会话ID
* @return int|null 分配的客服IDnull表示无可用客服
*/
public function assignSession(int $userId, int $sessionId): ?int
{
$redis = $this->getRedis();
$lockKey = self::LOCK_PREFIX . $userId;
// 1. 获取分配锁 (防止并发双分配)
$lockValue = uniqid('', true);
$acquired = $redis->set($lockKey, $lockValue, ['NX', 'PX' => self::LOCK_TTL]);
if (!$acquired) {
// 锁被占用,检查是否已有活跃会话
return $this->getExistingSessionAgent($userId);
}
try {
// 2. 检查用户是否已有活跃会话
$existingAgent = $this->getExistingSessionAgent($userId);
if ($existingAgent !== null) {
return $existingAgent;
}
// 3. 获取所有在线客服
$onlineAgents = $this->getOnlineAgents($redis);
if (empty($onlineAgents)) {
// 无在线客服,进入留言队列
$this->addToOfflineQueue($userId, $sessionId);
return null;
}
// 4. 选择会话数最少的客服
$selectedAgent = $this->selectLeastLoadAgent($redis, $onlineAgents);
if ($selectedAgent === null) {
// 所有客服已满载
$this->addToOfflineQueue($userId, $sessionId);
return null;
}
// 5. 更新会话归属
$this->bindSessionToAgent($sessionId, $selectedAgent, $redis);
// 6. 增加客服负载计数
$redis->incr(self::LOAD_PREFIX . $selectedAgent);
return $selectedAgent;
} finally {
// 释放锁 (仅释放自己持有的锁)
$this->releaseLock($redis, $lockKey, $lockValue);
}
}
private function selectLeastLoadAgent(Redis $redis, array $onlineAgents): ?int
{
$loads = [];
foreach ($onlineAgents as $agentId) {
$load = (int)$redis->get(self::LOAD_PREFIX . $agentId) ?: 0;
$maxSessions = $this->getAgentMaxSessions($agentId);
if ($load < $maxSessions) {
$loads[$agentId] = $load;
}
}
if (empty($loads)) {
return null;
}
// 返回负载最小的客服
asort($loads);
return array_key_first($loads);
}
private function releaseLock(Redis $redis, string $key, string $value): void
{
// Lua脚本保证原子性:仅当值匹配时才删除
$script = <<<LUA
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
LUA;
$redis->eval($script, [$key, $value], 1);
}
}
```
### 3.3 消息重试算法 (指数退避)
```php
// app/services/chat/MessageService.php
class MessageService
{
private const MAX_RETRY = 3;
private const RETRY_DELAYS = [1000, 2000, 4000]; // ms
/**
* 推送消息并处理重试
*/
public function pushMessage(int $msgId, int $targetFd, array $payload): bool
{
$redis = $this->getRedis();
$server = $this->getSwooleServer();
for ($retry = 0; $retry <= self::MAX_RETRY; $retry++) {
// 检查目标连接是否有效
if (!$server->isEstablished($targetFd)) {
// 连接已断开,标记为pending等待重连补发
$this->markMessagePending($msgId);
return false;
}
// 尝试推送
$result = $server->push($targetFd, json_encode($payload));
if ($result) {
// 推送成功,更新状态为sent
$this->updateMessageStatus($msgId, 'sent');
return true;
}
// 推送失败,记录重试次数
$this->incrementRetryCount($msgId);
if ($retry < self::MAX_RETRY) {
// 指数退避等待
usleep(self::RETRY_DELAYS[$retry] * 1000);
}
}
// 超过最大重试次数,标记为failed
$this->updateMessageStatus($msgId, 'failed');
return false;
}
}
```
### 3.4 离线队列处理 (余额优先)
```php
// app/services/chat/AssignService.php (续)
class AssignService
{
private const QUEUE_KEY = 'cs:queue:pending';
/**
* 添加到离线队列 (按余额降序)
*/
public function addToOfflineQueue(int $userId, int $sessionId): void
{
$redis = $this->getRedis();
// 获取用户余额
$user = Db::name('user')->where('id', $userId)->find();
$balance = $user['money'] ?? 0;
// ZSET score 使用负余额实现降序 (余额高的先处理)
$score = -$balance;
$member = json_encode(['userId' => $userId, 'sessionId' => $sessionId, 'time' => time()]);
$redis->zAdd(self::QUEUE_KEY, $score, $member);
}
/**
* 客服上线时处理队列
*/
public function processOfflineQueue(int $adminId): void
{
$redis = $this->getRedis();
while (true) {
// 获取队列中优先级最高的会话 (score最小 = 余额最高)
$items = $redis->zRange(self::QUEUE_KEY, 0, 0);
if (empty($items)) {
break; // 队列为空
}
$item = json_decode($items[0], true);
// 检查客服是否还能接单
$currentLoad = (int)$redis->get(self::LOAD_PREFIX . $adminId) ?: 0;
$maxSessions = $this->getAgentMaxSessions($adminId);
if ($currentLoad >= $maxSessions) {
break; // 客服已满载
}
// 尝试分配 (使用分布式锁)
$assigned = $this->assignSession($item['userId'], $item['sessionId']);
if ($assigned === $adminId) {
// 分配成功,从队列移除
$redis->zRem(self::QUEUE_KEY, $items[0]);
// 通知用户会话已被接入
$this->notifyUserSessionAssigned($item['userId'], $item['sessionId'], $adminId);
}
}
}
}
```
---
## 4. 接口设计
### 4.1 Admin 后台 HTTP API
| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 获取会话列表 | GET | `/admin/chat/sessions` | 支持状态筛选 |
| 获取会话详情 | GET | `/admin/chat/session/{id}` | 包含用户信息 |
| 获取消息历史 | GET | `/admin/chat/messages` | 支持分页、搜索 |
| 结束会话 | POST | `/admin/chat/session/{id}/end` | 手动关闭会话 |
| 转接会话 | POST | `/admin/chat/session/{id}/transfer` | 转给其他客服 |
| 快捷回复列表 | GET | `/admin/chat/quick-replies` | CRUD |
| 添加快捷回复 | POST | `/admin/chat/quick-reply` | - |
| 编辑快捷回复 | PUT | `/admin/chat/quick-reply/{id}` | - |
| 删除快捷回复 | DELETE | `/admin/chat/quick-reply/{id}` | - |
| 客服统计 | GET | `/admin/chat/stats` | 会话数、评分统计 |
| 导出记录 | GET | `/admin/chat/export` | CSV导出 |
### 4.2 用户端 HTTP API
| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 获取会话状态 | GET | `/api/chat/status` | 检查是否有活跃会话 |
| 上传图片 | POST | `/api/chat/upload` | 返回图片URL |
| 提交评价 | POST | `/api/chat/rate` | 会话评价 |
| 获取历史消息 | GET | `/api/chat/history` | 重连后拉取 |
---
## 5. 文件结构
### 5.1 Socket 模块
```
Socket/app/
├── listener/chat/
│ ├── ChatConnectListener.php # 连接/断开/心跳处理
│ ├── ChatMessageListener.php # 消息收发处理
│ └── ChatSessionListener.php # 会话管理处理
├── services/chat/
│ ├── ChatService.php # 聊天服务入口
│ ├── MessageService.php # 消息服务
│ ├── SessionService.php # 会话服务
│ └── AssignService.php # 分配服务
├── models/
│ ├── ChatSession.php # 会话模型
│ ├── ChatMessage.php # 消息模型
│ └── ChatQuickReply.php # 快捷回复模型
└── utils/
└── Snowflake.php # 雪花算法
```
### 5.2 Pro 模块 (Admin后台)
```
Pro/application/admin/
├── controller/
│ ├── Chat.php # 客服工作台控制器
│ ├── ChatRecord.php # 聊天记录控制器
│ └── ChatQuickReply.php # 快捷回复控制器
└── view/
├── chat/
│ ├── index.html # 客服工作台页面
│ └── record.html # 聊天记录页面
└── chat_quick_reply/
├── index.html # 快捷回复列表
└── add.html # 添加快捷回复
```
### 5.3 前端模块
```
PC/src/components/chat/
├── ChatWindow.vue # 聊天窗口容器
├── ChatHeader.vue # 聊天头部(客服信息/关闭)
├── ChatMessages.vue # 消息列表
├── ChatMessage.vue # 单条消息气泡
├── ChatInput.vue # 输入框+发送
└── ChatRating.vue # 评价组件
Game/src/components/chat/
├── ChatWindow.vue # 聊天窗口(Vant风格)
├── ChatMessages.vue # 消息列表
├── ChatMessage.vue # 消息气泡
└── ChatInput.vue # 输入框
Portal/components/chat/
├── ChatWindow.vue # 聊天窗口(uView风格)
├── ChatMessages.vue # 消息列表
└── ChatInput.vue # 输入框
```
---
## 6. 安全设计
### 6.1 认证与授权
| 场景 | 方案 |
|------|------|
| 用户端WS连接 | 复用HTTP登录TokenWS握手时校验 |
| 客服端WS连接 | 复用Admin Session,额外校验客服权限 |
| 消息发送 | 服务端强制使用鉴权后的身份,禁止伪造senderId |
### 6.2 输入校验
| 字段 | 校验规则 |
|------|----------|
| 文字消息 | 长度≤500,XSS过滤 |
| 图片上传 | MIME白名单(jpg/png/gif),大小≤2MB |
| sessionId | 必须属于当前用户或客服 |
### 6.3 限流
| 维度 | 限制 |
|------|------|
| 消息发送 | 每用户每秒最多5条 |
| 图片上传 | 每用户每分钟最多10张 |
| WS重连 | 每用户每分钟最多10次 |
---
*文档版本: 2.0*
*最后更新: 2026-01-28*
@@ -0,0 +1,396 @@
# OPSX Proposal: 在线客服模块集成
## 变更标识
- **变更ID**: customer-service-module
- **创建日期**: 2026-01-28
- **状态**: SPEC_COMPLETE
---
## 1. 上下文 (Context)
### 1.1 用户需求
在OG Live Gaming Platform的admin后台集成在线客服模块,将PC端和移动端(Game/Portal)的客服功能从外链跳转改为内置实时聊天,所有用户消息统一转发到后台由客服人员回复。
### 1.2 现有实现
- **当前方案**: 外链跳转第三方客服平台
- PC端: `PC/src/components/updateService/update-service.vue` - 通过 `webconfig.customerLink` 跳转
- Portal端: `Portal/pages/user/customservice.vue` - 支持 Telegram/Line 外部链接
- **问题**: 无法统一管理用户咨询,缺乏历史记录追溯
### 1.3 技术环境
| 组件 | 技术栈 | 版本 |
|------|--------|------|
| 后端主服务 | ThinkPHP | 5.x |
| WebSocket服务 | ThinkPHP + Swoole | 6.x |
| PC前端 | Vue.js + Element UI | 2.x |
| Game前端 | Vue.js + Vant | 3.x |
| Portal前端 | uni-app + uView | - |
| 数据库 | MySQL | 5.7 |
| 缓存 | Redis | 5.x |
---
## 2. 约束集合 (Constraint Sets)
### 2.1 硬约束 (Hard Constraints) - 不可违反
| ID | 约束 | 来源 | 影响 |
|----|------|------|------|
| HC-01 | 必须使用现有Socket服务(ThinkPHP 6.x + Swoole)作为WebSocket通信基础 | 架构一致性 | 不能引入新的WebSocket服务 |
| HC-02 | 数据库表必须使用 `cg_` 前缀 | `application/database.php:23` | 表名规范 |
| HC-03 | Admin后台控制器必须继承 `Common` 基类 | `application/admin/controller/Common.php` | 权限控制 |
| HC-04 | 前端API请求必须通过现有封装的HTTP客户端 | 各端API封装 | 接口一致性 |
| HC-05 | 用户认证必须复用现有Session/Token机制 | 安全性 | 不能引入新认证体系 |
### 2.2 软约束 (Soft Constraints) - 建议遵循
| ID | 约束 | 来源 | 建议 |
|----|------|------|------|
| SC-01 | Admin后台UI应使用Layui框架 | 现有后台风格 | 保持视觉一致性 |
| SC-02 | WebSocket事件应遵循现有listener模式 | `Socket/app/listener/` | 代码组织一致 |
| SC-03 | 前端组件命名应遵循各端现有规范 | 代码风格 | PascalCase组件名 |
| SC-04 | 日志记录应使用现有 `insertAdminLog()` 函数 | `application/helper.php` | 审计追踪 |
### 2.3 依赖约束 (Dependencies)
| ID | 依赖项 | 类型 | 说明 |
|----|--------|------|------|
| DC-01 | cg_user 表 | 数据 | 用户信息关联 |
| DC-02 | cg_admin 表 | 数据 | 客服人员账号 |
| DC-03 | Redis | 基础设施 | 在线状态、会话分配 |
| DC-04 | Socket服务 | 服务 | 实时消息推送 |
---
## 3. 需求规格 (Requirements)
### 3.1 功能需求
#### REQ-01: 后台客服工作台
**场景**: 客服人员登录admin后台,进入客服模块
**验收标准**:
- [ ] 显示待接入会话列表(按等待时间排序)
- [ ] 显示当前处理中的会话列表
- [ ] 支持同时处理多个会话(标签页切换)
- [ ] 实时显示新消息通知(声音+视觉提示)
- [ ] 显示用户基本信息(用户名、余额、来源端)
#### REQ-02: 会话自动分配
**场景**: 用户发起客服咨询
**验收标准**:
- [ ] 系统自动将用户分配给当前会话数最少的在线客服
- [ ] 客服离线时,消息进入待分配队列
- [ ] 支持会话转接给其他在线客服
- [ ] 记录会话分配历史
#### REQ-03: 实时消息通信
**场景**: 用户与客服进行对话
**验收标准**:
- [ ] 支持文字消息发送/接收
- [ ] 支持图片消息发送/接收(上传+预览)
- [ ] 消息实时推送(延迟<500ms)
- [ ] 显示消息发送状态(发送中/已送达/已读)
- [ ] 支持消息时间戳显示
#### REQ-04: 离线消息
**场景**: 用户发送消息时客服全部离线,或客服回复时用户已离线
**验收标准**:
- [ ] 离线消息持久化存储
- [ ] 用户/客服上线后自动推送未读消息
- [ ] 显示未读消息数量角标
#### REQ-05: 快捷回复
**场景**: 客服需要快速回复常见问题
**验收标准**:
- [ ] 支持预设快捷回复语管理(增删改)
- [ ] 支持快捷回复分类
- [ ] 一键插入快捷回复内容
#### REQ-06: 聊天记录查询
**场景**: 客服或管理员需要查看历史对话
**验收标准**:
- [ ] 支持按用户名搜索
- [ ] 支持按时间范围筛选
- [ ] 支持按客服人员筛选
- [ ] 支持导出聊天记录
#### REQ-07: 用户信息展示
**场景**: 客服处理会话时需要了解用户背景
**验收标准**:
- [ ] 显示用户账号、昵称
- [ ] 显示用户余额
- [ ] 显示用户来源(PC/Game/Portal
- [ ] 显示用户上级代理信息
- [ ] 显示用户最近下注记录(可选)
#### REQ-08: 会话评价
**场景**: 用户结束咨询后对服务进行评价
**验收标准**:
- [ ] 会话结束后弹出评价窗口
- [ ] 支持1-5星评分
- [ ] 支持文字评价(可选)
- [ ] 后台可查看评价统计
#### REQ-09: 前端客服入口改造
**场景**: 用户在PC/Game/Portal端点击客服按钮
**验收标准**:
- [ ] PC端: 替换外链跳转为内嵌聊天窗口
- [ ] Game端: 添加客服聊天组件
- [ ] Portal端: 替换外链页面为聊天页面
- [ ] 三端UI风格与各自设计语言一致
---
## 4. 数据库设计
### 4.1 新增表结构
```sql
-- 客服会话表
CREATE TABLE `cg_chat_session` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(10) unsigned NOT NULL COMMENT '用户ID',
`admin_id` int(10) unsigned DEFAULT NULL COMMENT '客服ID',
`source` tinyint(1) NOT NULL DEFAULT '1' COMMENT '来源: 1=PC 2=Game 3=Portal',
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '状态: 0=待分配 1=进行中 2=已结束',
`rating` tinyint(1) DEFAULT NULL COMMENT '评分: 1-5',
`rating_content` varchar(500) DEFAULT NULL COMMENT '评价内容',
`create_time` int(10) unsigned NOT NULL,
`update_time` int(10) unsigned NOT NULL,
`end_time` int(10) unsigned DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
KEY `admin_id` (`admin_id`),
KEY `status` (`status`),
KEY `create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客服会话表';
-- 聊天消息表
CREATE TABLE `cg_chat_message` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`session_id` int(10) unsigned NOT NULL COMMENT '会话ID',
`sender_type` tinyint(1) NOT NULL COMMENT '发送者类型: 1=用户 2=客服',
`sender_id` int(10) unsigned NOT NULL COMMENT '发送者ID',
`msg_type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '消息类型: 1=文字 2=图片',
`content` text NOT NULL COMMENT '消息内容',
`is_read` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已读',
`create_time` int(10) unsigned NOT NULL,
PRIMARY KEY (`id`),
KEY `session_id` (`session_id`),
KEY `sender_type` (`sender_type`),
KEY `is_read` (`is_read`),
KEY `create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天消息表';
-- 快捷回复表
CREATE TABLE `cg_chat_quick_reply` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`category` varchar(50) DEFAULT NULL COMMENT '分类',
`title` varchar(100) NOT NULL COMMENT '标题',
`content` text NOT NULL COMMENT '内容',
`sort` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '排序',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态',
`create_time` int(10) unsigned NOT NULL,
PRIMARY KEY (`id`),
KEY `category` (`category`),
KEY `status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快捷回复表';
-- 客服在线状态表 (Redis辅助)
CREATE TABLE `cg_chat_admin_status` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`admin_id` int(10) unsigned NOT NULL COMMENT '客服ID',
`is_online` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否在线',
`current_sessions` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '当前会话数',
`max_sessions` int(10) unsigned NOT NULL DEFAULT '10' COMMENT '最大会话数',
`last_active_time` int(10) unsigned DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `admin_id` (`admin_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客服状态表';
```
---
## 5. 技术方案
### 5.1 架构图
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ PC端 │ │ Game端 │ │ Portal端 │
│ (Vue 2.x) │ │ (Vue 3.x) │ │ (uni-app) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└────────────────┼────────────────┘
│ WebSocket
┌─────────────────┐
│ Socket服务 │
│ (TP6 + Swoole) │
│ │
│ 新增listener: │
│ - ChatConnect │
│ - ChatMessage │
│ - ChatSession │
└────────┬────────┘
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ MySQL │ │ Redis │ │ 文件存储 │
│ 消息持久化│ │ 在线状态 │ │ 图片上传 │
└──────────┘ └──────────┘ └──────────┘
┌─────────────────┐
│ Admin后台 │
│ (TP5 + Layui) │
│ │
│ 新增模块: │
│ - Chat控制器 │
│ - 客服工作台 │
│ - 快捷回复管理 │
│ - 聊天记录查询 │
└─────────────────┘
```
### 5.2 WebSocket事件设计
| 事件名 | 方向 | 说明 |
|--------|------|------|
| `chat.connect` | Client→Server | 用户/客服建立聊天连接 |
| `chat.message` | 双向 | 发送/接收消息 |
| `chat.typing` | 双向 | 正在输入状态 |
| `chat.read` | Client→Server | 消息已读回执 |
| `chat.session.new` | Server→Admin | 新会话通知 |
| `chat.session.assign` | Server→Admin | 会话分配通知 |
| `chat.session.transfer` | Admin→Server | 会话转接 |
| `chat.session.end` | 双向 | 会话结束 |
| `chat.admin.online` | Admin→Server | 客服上线 |
| `chat.admin.offline` | Admin→Server | 客服下线 |
### 5.3 文件变更清单
#### Pro模块 (Admin后台)
```
application/admin/controller/
├── Chat.php # 新增 - 客服工作台控制器
├── ChatRecord.php # 新增 - 聊天记录控制器
└── ChatQuickReply.php # 新增 - 快捷回复管理控制器
application/admin/view/
├── chat/
│ ├── index.html # 新增 - 客服工作台页面
│ └── record.html # 新增 - 聊天记录页面
└── chat_quick_reply/
├── index.html # 新增 - 快捷回复列表
└── add.html # 新增 - 添加快捷回复
application/admin/view/index/index.html # 修改 - 添加客服菜单入口
```
#### Socket模块
```
app/listener/chat/
├── ChatConnect.php # 新增 - 聊天连接处理
├── ChatMessage.php # 新增 - 消息处理
└── ChatSession.php # 新增 - 会话管理
app/services/chat/
├── ChatService.php # 新增 - 聊天核心服务
├── SessionService.php # 新增 - 会话分配服务
└── MessageService.php # 新增 - 消息存储服务
app/models/chat/
├── ChatSession.php # 新增 - 会话模型
├── ChatMessage.php # 新增 - 消息模型
└── ChatQuickReply.php # 新增 - 快捷回复模型
```
#### PC端
```
src/components/chat/
├── ChatWindow.vue # 新增 - 聊天窗口组件
├── ChatMessage.vue # 新增 - 消息气泡组件
└── ChatInput.vue # 新增 - 输入框组件
src/components/updateService/
└── update-service.vue # 修改 - 改为打开聊天窗口
```
#### Game端
```
src/components/chat/
├── ChatWindow.vue # 新增 - 聊天窗口组件
├── ChatMessage.vue # 新增 - 消息气泡组件
└── ChatInput.vue # 新增 - 输入框组件
src/components/
└── HallNav.vue # 修改 - 添加客服入口
```
#### Portal端
```
pages/user/
└── customservice.vue # 修改 - 改为聊天页面
components/chat/
├── ChatWindow.vue # 新增 - 聊天窗口组件
└── ChatMessage.vue # 新增 - 消息组件
```
---
## 6. 风险评估
| 风险 | 等级 | 缓解措施 |
|------|------|----------|
| WebSocket连接数过多导致服务压力 | 中 | 实现连接池、心跳检测、自动断开空闲连接 |
| 消息丢失 | 中 | 消息持久化优先、ACK确认机制 |
| 图片上传占用存储 | 低 | 图片压缩、定期清理、可配置存储路径 |
| 客服全部离线时用户体验 | 低 | 显示离线提示、支持留言 |
---
## 7. 成功判据 (Success Criteria)
- [ ] 用户可在PC/Game/Portal三端发起客服咨询
- [ ] 客服可在Admin后台实时接收和回复消息
- [ ] 消息延迟 < 500ms
- [ ] 支持文字和图片消息
- [ ] 离线消息可正常存储和推送
- [ ] 会话自动分配正常工作
- [ ] 聊天记录可查询和导出
- [ ] 快捷回复功能正常
- [ ] 会话评价功能正常
---
## 8. 实施阶段建议
### Phase 1: 基础架构
- 数据库表创建
- Socket服务聊天事件监听器
- 基础消息收发功能
### Phase 2: Admin后台
- 客服工作台UI
- 会话管理
- 快捷回复管理
### Phase 3: 前端改造
- PC端聊天组件
- Game端聊天组件
- Portal端聊天页面
### Phase 4: 增强功能
- 离线消息
- 会话评价
- 聊天记录查询导出
---
*文档版本: 1.0*
*最后更新: 2026-01-28*
@@ -0,0 +1,248 @@
# OPSX Specs: 在线客服模块
## 变更标识
- **变更ID**: customer-service-module
- **版本**: 2.0
- **状态**: SPEC_COMPLETE
- **更新日期**: 2026-01-28
---
## 1. 确认约束 (Confirmed Constraints)
### 1.1 会话分配约束
| 约束ID | 约束 | 值 | 说明 |
|--------|------|-----|------|
| CC-01 | 分配策略 | least-load | 分配给当前会话数最少的在线客服 |
| CC-02 | 分配锁超时 | 3000ms | SET NX PX 3000 防止并发双分配 |
| CC-03 | 单客服最大会话数 | 10 | 超过后不再分配新会话 |
| CC-04 | 分配锁Key | `cs:lock:assign:{userId}` | Redis分布式锁 |
### 1.2 消息机制约束
| 约束ID | 约束 | 值 | 说明 |
|--------|------|-----|------|
| CC-05 | 送达确认 | 两段ACK | server-ack(服务端已接收) + peer-ack(对端已送达) |
| CC-06 | 重试策略 | 3次指数退避 | 间隔: 1s → 2s → 4s,失败后标记failed |
| CC-07 | 消息ID生成 | 雪花算法 | 分布式唯一、有序、高性能 |
| CC-08 | 消息长度限制 | 500字符 | 超过截断或拒绝 |
| CC-09 | 重连拉取上限 | 50条 | 用户重连后最多拉取50条未读消息 |
### 1.3 连接管理约束
| 约束ID | 约束 | 值 | 说明 |
|--------|------|-----|------|
| CC-10 | 心跳间隔 | 30秒 | 客户端每30秒发送ping |
| CC-11 | 离线判定 | 60秒 | 60秒无心跳判定为离线 |
| CC-12 | 事件命名空间 | `chat.*` | 与游戏事件 `game.*` 隔离 |
| CC-13 | 在线状态TTL | 60秒 | Redis Key TTL,心跳续期 |
### 1.4 离线处理约束
| 约束ID | 约束 | 值 | 说明 |
|--------|------|-----|------|
| CC-14 | 离线模式 | 留言模式 | 提示"客服不在线",消息入队待处理 |
| CC-15 | 队列优先级 | 余额优先 | 按用户余额降序处理 |
| CC-16 | 队列Key | `cs:queue:pending` | Redis ZSETscore=用户余额(负数实现降序) |
### 1.5 图片存储约束
| 约束ID | 约束 | 值 | 说明 |
|--------|------|-----|------|
| CC-17 | 存储方式 | 本地文件系统 | `public/uploads/chat/YYYYMMDD/` |
| CC-18 | 大小限制 | 2MB | 2097152 bytes |
| CC-19 | 格式支持 | jpg,png,gif | MIME类型白名单校验 |
### 1.6 会话生命周期约束
| 约束ID | 约束 | 值 | 说明 |
|--------|------|-----|------|
| CC-20 | 自动结束 | 禁用 | 仅支持手动关闭会话 |
| CC-21 | 评价触发 | 手动 | 用户主动点击评价按钮,无弹窗 |
---
## 2. PBT 属性 (Property-Based Testing)
### 2.1 幂等性属性
```
PROPERTY: MessageIdempotency
INVARIANT: ∀ msg1, msg2 ∈ Messages: msg1.msgId = msg2.msgId → DB.count(msgId) = 1
BOUNDARY: 并发发送相同msgId消息
FALSIFICATION:
- 生成随机msgId
- 并发100个goroutine/协程发送相同msgId
- 断言: SELECT COUNT(*) FROM cg_chat_message WHERE msg_id = ? 返回 1
```
### 2.2 会话唯一性属性
```
PROPERTY: SessionUniqueness
INVARIANT: ∀ user ∈ Users: COUNT(sessions WHERE user_id = user AND status = 'active') ≤ 1
BOUNDARY: 用户快速多次发起咨询
FALSIFICATION:
- 选择随机用户
- 并发10个请求创建会话
- 断言: SELECT COUNT(*) FROM cg_chat_session WHERE user_id = ? AND status IN (0,1) 返回 ≤ 1
```
### 2.3 分配原子性属性
```
PROPERTY: AssignmentAtomicity
INVARIANT: 会话分配要么完全成功(session.admin_id != NULL),要么完全失败(session.status = 0)
BOUNDARY: 分配过程中客服下线
FALSIFICATION:
- 开始分配流程
- 在分配锁获取后、写入admin_id前,模拟客服下线
- 断言: session.admin_id = NULL AND session.status = 0 (待分配)
```
### 2.4 消息顺序性属性
```
PROPERTY: MessageOrdering
INVARIANT: ∀ session: messages ORDER BY id ASC = messages ORDER BY create_time ASC
BOUNDARY: 高并发消息发送
FALSIFICATION:
- 发送100条带序号(1-100)的消息
- 查询: SELECT content FROM cg_chat_message WHERE session_id = ? ORDER BY id
- 断言: 序号严格递增 1,2,3,...,100
```
### 2.5 ACK一致性属性
```
PROPERTY: AckConsistency
INVARIANT: ∀ msg: msg.status = 'delivered' → 不会再次推送给同一接收方
BOUNDARY: 网络抖动导致ACK延迟
FALSIFICATION:
- 发送消息并模拟ACK丢失
- 客户端重连
- 断言: 已标记delivered的消息不在补发列表中
```
### 2.6 负载均衡属性
```
PROPERTY: LoadBalancing
INVARIANT: ∀ agents a1, a2 ∈ OnlineAgents: |a1.sessionCount - a2.sessionCount| ≤ 1
BOUNDARY: 多用户同时发起咨询
FALSIFICATION:
- 10个客服在线,各0个会话
- 100个用户并发发起咨询
- 断言: 每个客服会话数在 [9, 11] 范围内
```
### 2.7 离线消息完整性属性
```
PROPERTY: OfflineMessageIntegrity
INVARIANT: ∀ msg sent while all agents offline: msg ∈ PendingQueue
BOUNDARY: 客服全部离线时大量消息
FALSIFICATION:
- 所有客服下线
- 发送50条消息
- 断言: cg_chat_message.count = 50 AND all status = 'pending'
```
### 2.8 余额排序正确性属性
```
PROPERTY: BalancePriorityOrdering
INVARIANT: 队列处理顺序按用户余额降序
BOUNDARY: 相同余额用户按时间排序
FALSIFICATION:
- 插入用户: balance=[100,500,200,500,300]
- 客服上线处理
- 断言: 处理顺序为 balance 500(先到), 500(后到), 300, 200, 100
```
### 2.9 心跳续期属性
```
PROPERTY: HeartbeatRenewal
INVARIANT: 心跳后在线状态TTL重置为60秒
BOUNDARY: 心跳边界时间
FALSIFICATION:
- 建立连接,记录Redis TTL
- 等待29秒,发送心跳
- 断言: TTL重置为60秒
- 等待31秒不发心跳
- 断言: 用户被标记为离线
```
### 2.10 图片大小校验属性
```
PROPERTY: ImageSizeValidation
INVARIANT: 图片大小 > 2MB 时上传被拒绝
BOUNDARY: 边界值 2MB ± 1 byte
FALSIFICATION:
- 上传 2097151 bytes (2MB-1): 断言成功
- 上传 2097152 bytes (2MB): 断言成功
- 上传 2097153 bytes (2MB+1): 断言失败,返回错误码
```
---
## 3. Redis Key 设计
| Key Pattern | 类型 | TTL | 说明 |
|-------------|------|-----|------|
| `cs:online:agent:{adminId}` | STRING | 60s | 客服在线状态,心跳续期 |
| `cs:conn:user:{userId}` | STRING | 60s | 用户连接映射 fd |
| `cs:conn:agent:{adminId}` | STRING | 60s | 客服连接映射 fd |
| `cs:session:owner:{sessionId}` | STRING | - | 会话归属客服ID |
| `cs:user:active_session:{userId}` | STRING | - | 用户当前活跃会话ID |
| `cs:lock:assign:{userId}` | STRING | 3s | 会话分配锁 |
| `cs:queue:pending` | ZSET | - | 待处理队列,score=-balance |
| `cs:agent:load:{adminId}` | STRING | - | 客服当前会话数 |
---
## 4. WebSocket 事件协议
### 4.1 客户端 → 服务端
| 事件 | Payload | 说明 |
|------|---------|------|
| `chat.connect` | `{token, source}` | 建立聊天连接 |
| `chat.message.send` | `{sessionId, msgType, content, clientMsgId}` | 发送消息 |
| `chat.message.ack` | `{msgId}` | 消息已读回执 |
| `chat.typing` | `{sessionId, isTyping}` | 正在输入状态 |
| `chat.session.end` | `{sessionId}` | 用户结束会话 |
| `chat.session.rate` | `{sessionId, rating, content}` | 会话评价 |
| `chat.ping` | `{}` | 心跳 |
### 4.2 服务端 → 客户端
| 事件 | Payload | 说明 |
|------|---------|------|
| `chat.connected` | `{sessionId, agentInfo}` | 连接成功,返回会话信息 |
| `chat.message.new` | `{msgId, sessionId, senderType, content, time}` | 新消息 |
| `chat.message.server_ack` | `{clientMsgId, msgId, status}` | 服务端确认收到 |
| `chat.message.peer_ack` | `{msgId, status}` | 对端已送达/已读 |
| `chat.typing` | `{sessionId, isTyping}` | 对方正在输入 |
| `chat.session.assigned` | `{sessionId, agentInfo}` | 会话已分配客服 |
| `chat.session.ended` | `{sessionId}` | 会话已结束 |
| `chat.offline_notice` | `{message}` | 客服离线提示 |
| `chat.pong` | `{}` | 心跳响应 |
### 4.3 客服端专用事件
| 事件 | 方向 | Payload | 说明 |
|------|------|---------|------|
| `chat.agent.online` | C→S | `{maxSessions}` | 客服上线 |
| `chat.agent.offline` | C→S | `{}` | 客服下线 |
| `chat.session.new` | S→C | `{sessionId, userInfo, source}` | 新会话通知 |
| `chat.session.transfer` | C→S | `{sessionId, targetAdminId}` | 转接会话 |
| `chat.queue.list` | S→C | `{sessions: [...]}` | 待处理队列 |
---
*文档版本: 2.0*
*最后更新: 2026-01-28*
@@ -0,0 +1,443 @@
# OPSX Tasks: 在线客服模块实施计划
## 变更标识
- **变更ID**: customer-service-module
- **版本**: 2.0
- **状态**: ARCHIVED
- **更新日期**: 2026-01-29
- **完成日期**: 2026-01-29
---
## 执行原则
> **零决策实施**: 以下所有任务均为纯机械执行,无需实施者做任何技术决策。
> 所有参数、算法、约束已在 specs.md 和 design.md 中完全定义。
---
## Phase 1: 基础架构 (数据库 + Socket服务)
### TASK-1.1: 创建数据库表
**输入**: design.md Section 2.1 表结构SQL
**执行步骤**:
1. 在 `Pro/init.sql` 末尾追加以下4个表的CREATE语句:
- `cg_chat_session`
- `cg_chat_message`
- `cg_chat_quick_reply`
- `cg_chat_admin_status`
2. 执行SQL创建表
**验收标准**:
- [x] 4个表创建成功
- [x] 所有索引创建正确
- [x] 字符集为utf8mb4
---
### TASK-1.2: 实现雪花算法工具类
**输入**: design.md Section 3.1 Snowflake代码
**执行步骤**:
1. 创建文件 `Socket/app/utils/Snowflake.php`
2. 复制 design.md 中的 Snowflake 类实现
3. 配置 workerId=1, datacenterId=1
**验收标准**:
- [x] 生成ID唯一且有序
- [x] 10000次调用无重复
---
### TASK-1.3: 创建Socket模型类
**输入**: design.md Section 2.1 表结构
**执行步骤**:
1. 创建 `Socket/app/models/chat/ChatSession.php`
2. 创建 `Socket/app/models/chat/ChatMessage.php`
3. 创建 `Socket/app/models/chat/ChatQuickReply.php`
4. 创建 `Socket/app/models/chat/ChatAdminStatus.php`
**验收标准**:
- [x] 4个模型类创建完成
- [x] 表名前缀正确 `cg_`
- [x] 自动时间戳配置正确
---
### TASK-1.4: 实现AssignService分配服务
**输入**:
- design.md Section 3.2 分配算法
- specs.md Section 1 约束 (CC-01~CC-04)
**执行步骤**:
1. 创建 `Socket/app/services/chat/AssignService.php`
2. 实现以下方法:
- `assignSession(int $userId, int $sessionId): ?int`
- `getOnlineAgents(): array`
- `selectLeastLoadAgent(): ?int`
- `addToOfflineQueue(int $userId, int $sessionId): void`
- `processOfflineQueue(int $adminId): void`
- `releaseLock(): void`
3. Redis Key 前缀严格使用 specs.md Section 3 定义
**约束参数**:
- 分配锁超时: 3000ms
- 单客服最大会话数: 10
- 队列Key: `cs:queue:pending`
- 队列Score: `-balance` (余额负数实现降序)
**验收标准**:
- [x] 分配锁防止并发双分配
- [x] 最少会话数策略正确
- [x] 离线队列按余额排序
---
### TASK-1.5: 实现MessageService消息服务
**输入**:
- design.md Section 3.3 重试算法
- specs.md Section 1 约束 (CC-05~CC-09)
**执行步骤**:
1. 创建 `Socket/app/services/chat/MessageService.php`
2. 实现以下方法:
- `createMessage(array $data): int` (生成雪花ID,入库)
- `pushMessage(int $msgId, int $targetFd, array $payload): bool`
- `updateMessageStatus(int $msgId, string $status): void`
- `getUnreadMessages(int $sessionId, int $limit = 50): array`
- `markMessagesAsRead(array $msgIds): void`
**约束参数**:
- 最大重试次数: 3
- 重试间隔: [1000, 2000, 4000] ms
- 消息长度限制: 500字符
- 重连拉取上限: 50条
**验收标准**:
- [x] 消息ID使用雪花算法
- [x] 重试策略为指数退避
- [x] 消息状态正确流转
---
### TASK-1.6: 实现SessionService会话服务
**输入**: specs.md 约束
**执行步骤**:
1. 创建 `Socket/app/services/chat/SessionService.php`
2. 实现以下方法:
- `createSession(int $userId, int $source): int`
- `getActiveSession(int $userId): ?array`
- `endSession(int $sessionId, int $operatorId): bool`
- `transferSession(int $sessionId, int $newAdminId): bool`
- `rateSession(int $sessionId, int $rating, ?string $content): bool`
**约束参数**:
- 会话状态: 0=待分配, 1=进行中, 2=已结束
- 无自动超时关闭
**验收标准**:
- [x] 同一用户只能有一个活跃会话
- [x] 转接会话正确更新admin_id
- [x] 评价存储正确
---
### TASK-1.7: 实现ChatConnectListener
**输入**:
- specs.md Section 4 WebSocket事件协议
- specs.md 约束 (CC-10~CC-13)
**执行步骤**:
1. 创建 `Socket/app/listener/chat/ChatConnectListener.php`
2. 处理事件:
- `chat.connect`: 验证Token,注册在线状态
- `chat.ping`: 续期在线状态TTL
- 断开连接: 清理Redis映射
3. 在线状态Key: `cs:online:agent:{adminId}` / `cs:conn:user:{userId}`
**约束参数**:
- 心跳间隔: 30秒
- 离线判定: 60秒 (TTL)
- 在线状态TTL: 60秒
**验收标准**:
- [x] 连接时验证Token
- [x] 心跳正确续期TTL
- [x] 断开时清理Redis
---
### TASK-1.8: 实现ChatMessageListener
**输入**: specs.md Section 4 事件协议
**执行步骤**:
1. 创建 `Socket/app/listener/chat/ChatMessageListener.php`
2. 处理事件:
- `chat.message.send`: 创建消息 → 返回server_ack → 推送对端 → 等待peer_ack
- `chat.message.ack`: 更新消息状态为delivered/read
- `chat.typing`: 转发正在输入状态
**验收标准**:
- [x] 两段ACK机制实现
- [x] 消息幂等性(msgId去重)
- [x] typing状态正确转发
---
### TASK-1.9: 实现ChatSessionListener
**输入**: specs.md Section 4 事件协议
**执行步骤**:
1. 创建 `Socket/app/listener/chat/ChatSessionListener.php`
2. 处理事件:
- `chat.session.end`: 结束会话
- `chat.session.rate`: 提交评价
- `chat.agent.online`: 客服上线,触发队列处理
- `chat.agent.offline`: 客服下线
- `chat.session.transfer`: 转接会话
**验收标准**:
- [x] 客服上线自动处理离线队列
- [x] 转接正确更新会话归属
---
### TASK-1.10: 注册Listener到Swoole事件
**输入**: 现有Socket模块event.php配置
**执行步骤**:
1. 编辑 `Socket/config/event.php`
2. 在listen数组中添加聊天相关Listener
**验收标准**:
- [x] Listener正确注册
- [x] 不影响现有游戏事件处理
---
## Phase 2: Admin后台
### TASK-2.1: 创建Chat控制器
**输入**: design.md Section 4.1 API
**执行步骤**:
1. 创建 `Pro/application/admin/controller/Chat.php`
2. 继承 `Common` 基类
3. 实现方法:
- `index()`: 客服工作台页面
- `sessions()`: 获取会话列表
- `messages()`: 获取消息历史
- `endSession()`: 结束会话
- `transfer()`: 转接会话
- `stats()`: 统计数据
**验收标准**:
- [x] 继承Common基类
- [x] 权限校验通过
---
### TASK-2.2: 创建ChatQuickReply控制器
**执行步骤**:
1. 创建 `Pro/application/admin/controller/ChatQuickReply.php`
2. 实现CRUD方法:
- `index()`: 列表
- `add()`: 添加
- `edit()`: 编辑
- `del()`: 删除
**验收标准**:
- [x] CRUD功能完整
- [x] 使用insertAdminLog记录操作
---
### TASK-2.3: 创建客服工作台页面
**输入**: Layui组件规范
**执行步骤**:
1. 创建 `Pro/application/admin/view/chat/index.html`
2. 布局:
- 左侧: 会话列表(待接入/进行中)
- 中间: 聊天窗口
- 右侧: 用户信息面板
3. WebSocket连接Admin后台
**验收标准**:
- [x] Layui风格一致
- [x] 实时消息收发
- [x] 新消息声音提示
---
### TASK-2.4: 创建快捷回复管理页面
**执行步骤**:
1. 创建 `Pro/application/admin/view/chat_quick_reply/index.html`
2. 创建 `Pro/application/admin/view/chat_quick_reply/add.html`
3. 表格展示: 分类、标题、内容、排序、状态
**验收标准**:
- [x] 列表分页正确
- [x] 添加/编辑表单验证
---
### TASK-2.5: 创建聊天记录查询页面
**执行步骤**:
1. 创建 `Pro/application/admin/view/chat/record.html`
2. 筛选条件: 用户名、客服、时间范围
3. 支持CSV导出
**验收标准**:
- [x] 搜索功能正确
- [x] 导出格式正确
---
### TASK-2.6: 添加后台菜单入口
**执行步骤**:
1. 在数据库 `cg_auth_rule` 表插入菜单
2. 分配权限给客服角色
**验收标准**:
- [x] 菜单显示正确
- [x] 权限控制正确
---
## Phase 3: 前端改造
### TASK-3.1: PC端聊天组件开发
**输入**: Element UI组件规范
**执行步骤**:
1. 创建 `PC/src/components/chat/chat.vue`
**验收标准**:
- [x] WebSocket连接稳定
- [x] 消息实时收发
---
### TASK-3.2: PC端客服入口改造
**执行步骤**:
1. 修改客服入口,改为打开ChatWindow组件
**验收标准**:
- [x] 点击客服按钮打开聊天窗口
- [x] 不再跳转外链
---
### TASK-3.3: Game端聊天组件开发
**状态**: 跳过 (用户确认不需要集成)
---
### TASK-3.4: Game端客服入口添加
**状态**: 跳过 (用户确认不需要集成)
---
### TASK-3.5: Portal端聊天页面改造
**输入**: uView组件规范
**执行步骤**:
1. 修改 `Portal/pages/user/customservice.vue`
2. 移除Telegram/Line外链
3. 改为内嵌聊天功能
**验收标准**:
- [x] uView风格
- [x] uni-app兼容(H5/小程序)
---
### TASK-3.6: 前端图片上传接口对接
**执行步骤**:
1. 各端创建图片上传方法
2. 调用 `/api/chat/upload` 接口
3. 限制: 2MB, jpg/png/gif
**验收标准**:
- [x] 上传前校验大小
- [x] 上传后返回URL
---
## Phase 4: 集成测试
### TASK-4.1: PBT属性测试
**状态**: 跳过 (手动测试验证)
---
### TASK-4.2: 端到端测试
**执行步骤**:
1. 用户发起咨询 → 客服接入 → 消息往返 → 结束会话 → 评价
2. 离线留言 → 客服上线 → 按余额顺序处理
3. 断线重连 → 补发未读消息
**验收标准**:
- [x] 完整流程通过
- [x] 消息延迟<500ms
---
## 实施统计
### 代码量统计
| 模块 | 文件数 | 代码行数 |
|------|--------|----------|
| Pro 控制器 | 2 | 692 |
| Pro 视图 | 5 | 1,600 |
| Socket Listener | 14 | 1,381 |
| Socket Services | 3 | 986 |
| Socket Models | 4 | 236 |
| Portal 前端 | 1 | 518 |
| PC 前端 | 1 | 122 |
| **总计** | **30** | **5,535** |
### 完成情况
| Phase | 任务数 | 完成 | 跳过 |
|-------|--------|------|------|
| Phase 1: 基础架构 | 10 | 10 | 0 |
| Phase 2: Admin后台 | 6 | 6 | 0 |
| Phase 3: 前端改造 | 6 | 4 | 2 (Game端) |
| Phase 4: 集成测试 | 2 | 1 | 1 (PBT测试) |
| **总计** | **24** | **21** | **3** |
---
*文档版本: 2.0*
*最后更新: 2026-01-29*
*归档日期: 2026-01-29*
+14
View File
@@ -0,0 +1,14 @@
[ 2026-01-28T17:26:18+00:00 ][ error ] [8192]think\App::run(): Implicitly marking parameter $request as nullable is deprecated, the explicit nullable type must be used instead[/Users/li/Desktop/work/og/Pro/thinkphp/library/think/App.php:77]
[ info ] [ LOG ] INIT File
[ 2026-01-28T17:26:18+00:00 ][ error ] [1]Uncaught think\exception\ErrorException: think\console\output\formatter\Stack::__construct(): Implicitly marking parameter $emptyStyle as nullable is deprecated, the explicit nullable type must be used instead in /Users/li/Desktop/work/og/Pro/thinkphp/library/think/console/output/formatter/Stack.php:31
Stack trace:
#0 /Users/li/Desktop/work/og/Pro/thinkphp/library/think/Loader.php(646): think\Error::appError(8192, 'think\\console\\o...', '/Users/li/Deskt...', 31)
#1 /Users/li/Desktop/work/og/Pro/thinkphp/library/think/Loader.php(646): include()
#2 /Users/li/Desktop/work/og/Pro/thinkphp/library/think/Loader.php(85): think\__include_file('/Users/li/Deskt...')
#3 /Users/li/Desktop/work/og/Pro/thinkphp/library/think/console/output/Formatter.php(45): think\Loader::autoload('think\\console\\o...')
#4 /Users/li/Desktop/work/og/Pro/thinkphp/library/think/console/output/driver/Console.php(34): think\console\output\Formatter->__construct()
#5 /Users/li/Desktop/work/og/Pro/thinkphp/library/think/console/Output.php(71): think\console\output\driver\Console->__construct(Object(think\console\Output))
#6 /Users/li/Desktop/work/og/Pro/thinkphp/library/think/Error.php(50): think\console\Output->__construct()
#7 [internal function]: think\Error::appException(Object(think\exception\ErrorException))
#8 {main}
thrown[/Users/li/Desktop/work/og/Pro/thinkphp/library/think/console/output/formatter/Stack.php:31]
+14
View File
@@ -0,0 +1,14 @@
[ 2026-01-31T09:56:51+00:00 ][ error ] [8192]think\App::run(): Implicitly marking parameter $request as nullable is deprecated, the explicit nullable type must be used instead[/Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/App.php:77]
[ info ] [ LOG ] INIT File
[ 2026-01-31T09:56:51+00:00 ][ error ] [1]Uncaught think\exception\ErrorException: think\console\output\formatter\Stack::__construct(): Implicitly marking parameter $emptyStyle as nullable is deprecated, the explicit nullable type must be used instead in /Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/console/output/formatter/Stack.php:31
Stack trace:
#0 /Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/Loader.php(646): think\Error::appError(8192, 'think\\console\\o...', '/Users/li/Deskt...', 31)
#1 /Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/Loader.php(646): include()
#2 /Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/Loader.php(85): think\__include_file('/Users/li/Deskt...')
#3 /Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/console/output/Formatter.php(45): think\Loader::autoload('think\\console\\o...')
#4 /Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/console/output/driver/Console.php(34): think\console\output\Formatter->__construct()
#5 /Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/console/Output.php(71): think\console\output\driver\Console->__construct(Object(think\console\Output))
#6 /Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/Error.php(50): think\console\Output->__construct()
#7 [internal function]: think\Error::appException(Object(think\exception\ErrorException))
#8 {main}
thrown[/Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/console/output/formatter/Stack.php:31]