feat: 牛牛单独限红 - 新增limit_low_nn/limit_high_nn字段,代理端添加/编辑代理和会员支持牛牛限红设置,语言包三语支持
This commit is contained in:
parent
1ce6c3d780
commit
71d7ff1d7b
BIN
application/.DS_Store
vendored
BIN
application/.DS_Store
vendored
Binary file not shown.
BIN
application/admin/.DS_Store
vendored
BIN
application/admin/.DS_Store
vendored
Binary file not shown.
229
application/admin/controller/ChatQuickReply.php
Normal file
229
application/admin/controller/ChatQuickReply.php
Normal file
@ -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' => '操作失败']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -29,6 +29,8 @@ class Common extends Controller{
|
||||
'Info',
|
||||
'Log',
|
||||
'Login',
|
||||
'Chat',
|
||||
'ChatQuickReply',
|
||||
);
|
||||
if($user_info['role'] == 1){
|
||||
if(!in_array($curContrller, $jiankongController)){
|
||||
@ -40,6 +42,8 @@ class Common extends Controller{
|
||||
'Index',
|
||||
'Agent',
|
||||
'Login',
|
||||
'Chat',
|
||||
'ChatQuickReply',
|
||||
);
|
||||
if($user_info['role'] == 2){
|
||||
if(!in_array($curContrller, $dlController)){
|
||||
|
||||
@ -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();
|
||||
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();
|
||||
Session::set('user_info',$user_info);
|
||||
insertAdminLog('登录');
|
||||
|
||||
@ -36,17 +36,17 @@ class Report extends Common{
|
||||
}
|
||||
$agent_list = Db::name('user')->where($where)->select();
|
||||
|
||||
// 时间条件
|
||||
// 时间条件(默认:今天07:00:00 ~ 明天06:59:59)
|
||||
if($startDate){
|
||||
$startTime = strtotime($startDate);
|
||||
}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);
|
||||
}
|
||||
if($endDate){
|
||||
$endTime = strtotime($endDate);
|
||||
}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);
|
||||
}
|
||||
$this->assign('get',$get);
|
||||
|
||||
BIN
application/admin/view/.DS_Store
vendored
BIN
application/admin/view/.DS_Store
vendored
Binary file not shown.
@ -95,16 +95,30 @@
|
||||
layui.use('laydate', function () {
|
||||
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({
|
||||
elem: '#start',
|
||||
type: 'datetime'
|
||||
type: 'datetime',
|
||||
value: defaultStart
|
||||
});
|
||||
|
||||
// 结束时间
|
||||
laydate.render({
|
||||
elem: '#end',
|
||||
type: 'datetime'
|
||||
type: 'datetime',
|
||||
value: defaultEnd
|
||||
});
|
||||
});
|
||||
function get_profit(){
|
||||
|
||||
89
application/admin/view/chat/detail.html
Normal file
89
application/admin/view/chat/detail.html
Normal 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
application/admin/view/chat/record.html
Normal file
117
application/admin/view/chat/record.html
Normal 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"></i></button>
|
||||
<button class="layui-btn layui-btn-warm" type="submit" name="export" value="1">
|
||||
<i class="layui-icon"></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"></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>
|
||||
70
application/admin/view/chat_quick_reply/add.html
Normal file
70
application/admin/view/chat_quick_reply/add.html
Normal file
@ -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>
|
||||
134
application/admin/view/chat_quick_reply/index.html
Normal file
134
application/admin/view/chat_quick_reply/index.html
Normal file
@ -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"></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"></i>
|
||||
</a>
|
||||
<a title="删除" onclick="member_del(this,'{$vo.id}')" href="javascript:;">
|
||||
<i class="layui-icon"></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>
|
||||
@ -122,6 +122,17 @@
|
||||
<li><a _href="/Tip/optimum"><i class="iconfont"></i><cite>今日最佳记录</cite></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="javascript:;">
|
||||
<cite class="main-nav">客服管理</cite>
|
||||
<i class="iconfont nav_right"></i>
|
||||
</a>
|
||||
<ul class="sub-menu">
|
||||
<li><a _href="/chat/index"><i class="iconfont"></i><cite>客服工作台</cite></a></li>
|
||||
<li><a _href="/chat/record"><i class="iconfont"></i><cite>聊天记录查询</cite></a></li>
|
||||
<li><a _href="/chat_quick_reply/index"><i class="iconfont"></i><cite>快捷回复管理</cite></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</div>
|
||||
<div id="configure_list" style="display: none;">
|
||||
<li class="open">
|
||||
|
||||
BIN
application/agent/.DS_Store
vendored
BIN
application/agent/.DS_Store
vendored
Binary file not shown.
@ -253,6 +253,8 @@ class Agent Extends Common{
|
||||
$limit_high_tie = round(Request::instance()->post('limit_high_tie'),2);
|
||||
$limit_low_pair = round(Request::instance()->post('limit_low_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_dt = round(Request::instance()->post('agent_ximalv_dt'),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){
|
||||
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']){
|
||||
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']){
|
||||
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){
|
||||
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_low_pair'] = $limit_low_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['reg_time'] = time();
|
||||
$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_low_pair = round(Request::instance()->post('limit_low_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_dt = round(Request::instance()->post('agent_ximalv_dt'),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']){
|
||||
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']){
|
||||
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_low_pair'] = $limit_low_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_dt'] = $agent_ximalv_dt;
|
||||
$user_data['agent_ximalv_nn'] = $agent_ximalv_nn;
|
||||
|
||||
@ -281,6 +281,8 @@ class Player Extends Common{
|
||||
$limit_high_tie = round(Request::instance()->post('limit_high_tie'),2);
|
||||
$limit_low_pair = round(Request::instance()->post('limit_low_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);
|
||||
$agent_commission = round(Request::instance()->post('agent_commission'),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']){
|
||||
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']){
|
||||
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_low_pair'] = $limit_low_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_player'] = $agentParent['price_player'];
|
||||
$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_low_pair = round(Request::instance()->post('limit_low_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_dt = round(Request::instance()->post('agent_ximalv_dt'),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']){
|
||||
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']){
|
||||
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_low_pair'] = $limit_low_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_dt'] = $agent_ximalv_dt;
|
||||
$user_data['agent_ximalv_nn'] = $agent_ximalv_nn;
|
||||
|
||||
@ -35,17 +35,17 @@ class Report Extends Common{
|
||||
$game_id = intval(Request::instance()->get('game_id'));
|
||||
$export = intval(Request::instance()->get('export'));
|
||||
|
||||
// 转换日期时间
|
||||
// 转换日期时间(默认:今天07:00:00 ~ 明天06:59:59)
|
||||
if ($startDate) {
|
||||
$startTime = strtotime($startDate);
|
||||
} 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);
|
||||
}
|
||||
if ($endDate) {
|
||||
$endTime = strtotime($endDate);
|
||||
} 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);
|
||||
}
|
||||
|
||||
|
||||
@ -185,6 +185,14 @@ return [
|
||||
'limit_cannot_bigger' => 'Limit cannot exceed',
|
||||
'tie_limit_cannot_bigger' => 'Tie 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_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',
|
||||
|
||||
@ -185,6 +185,14 @@ return [
|
||||
'limit_cannot_bigger' => '限红不能超出',
|
||||
'tie_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_cannot_bigger_agent' => '百家乐码率不能超过所属代理的百家乐码率',
|
||||
'dt_limit_0' => '龙虎码率不能低于0',
|
||||
|
||||
@ -185,6 +185,14 @@ return [
|
||||
'limit_cannot_bigger' => '限紅不能超出',
|
||||
'tie_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_cannot_bigger_agent' => '百家樂碼率不能超過所屬代理的百家樂碼率',
|
||||
'dt_limit_0' => '龍虎碼率不能低於0',
|
||||
|
||||
BIN
application/agent/view/.DS_Store
vendored
Normal file
BIN
application/agent/view/.DS_Store
vendored
Normal file
Binary file not shown.
@ -129,6 +129,16 @@
|
||||
<input type="text" id="limit_high_pair" autocomplete="off" class="layui-input" value="{$agentParent.limit_high_pair}">
|
||||
</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) : ?>
|
||||
@ -242,6 +252,8 @@
|
||||
query.limit_high_tie = $('#limit_high_tie').val();
|
||||
query.limit_low_pair = $('#limit_low_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.agent_ximalv = $('#agent_ximalv').val() || 0;
|
||||
query.agent_ximalv_dt = $('#agent_ximalv_dt').val() || 0;
|
||||
@ -320,6 +332,18 @@
|
||||
});
|
||||
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 == ''){
|
||||
layer.alert(lang.empty_wash_type,{
|
||||
title:lang.message,
|
||||
|
||||
@ -81,6 +81,16 @@
|
||||
<input type="text" id="limit_high_pair" autocomplete="off" value="{$user.limit_high_pair}" class="layui-input">
|
||||
</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) : ?>
|
||||
<div class="layui-form-item">
|
||||
@ -152,6 +162,8 @@
|
||||
query.limit_high_tie = $('#limit_high_tie').val();
|
||||
query.limit_low_pair = $('#limit_low_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_dt = $('#agent_ximalv_dt').val() || 0;
|
||||
query.agent_ximalv_nn = $('#agent_ximalv_nn').val() || 0;
|
||||
@ -209,6 +221,20 @@
|
||||
});
|
||||
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){
|
||||
ayer.alert(lang.empty_baccacat_rate,{
|
||||
title:lang.message,
|
||||
|
||||
@ -547,6 +547,16 @@
|
||||
<input type="text" id="limit_high_pair" autocomplete="off" class="layui-input" value="{$agentParent.limit_high_pair}">
|
||||
</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">
|
||||
<label for="win_limit" class="layui-form-label">{$lang['daily_win_limit']}</label>
|
||||
<div class="layui-input-inline">
|
||||
@ -695,6 +705,8 @@
|
||||
query.limit_high_tie = $('#limit_high_tie').val();
|
||||
query.limit_low_pair = $('#limit_low_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.agent_commission = $('#agent_commission').val();
|
||||
query.agent_commission_dt = $('#agent_commission_dt').val();
|
||||
@ -787,6 +799,18 @@
|
||||
});
|
||||
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){
|
||||
layer.alert(lang.empty_day_win_limit,{
|
||||
title:lang.message,
|
||||
|
||||
@ -134,6 +134,18 @@
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux limit_high_pair"></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">
|
||||
<label for="price_banker" class="layui-form-label">{$lang['banker_odds']}</label>
|
||||
<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_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_low_nn" value="{$agent_parent.limit_low_nn}">
|
||||
<input type="hidden" id="hidden_limit_high_nn" value="{$agent_parent.limit_high_nn}">
|
||||
</div>
|
||||
<script>
|
||||
$(function(){
|
||||
@ -643,6 +657,8 @@
|
||||
query.limit_high_tie = $('#limit_high_tie').val();
|
||||
query.limit_low_pair = $('#limit_low_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_dt = $('#agent_ximalv_dt').val() || 0;
|
||||
query.agent_ximalv_nn = $('#agent_ximalv_nn').val() || 0;
|
||||
@ -730,6 +746,18 @@
|
||||
});
|
||||
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){
|
||||
layer.alert(lang.empty_baccacat_rate,{
|
||||
title:lang.message,
|
||||
|
||||
@ -180,17 +180,31 @@
|
||||
}else{
|
||||
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.render({
|
||||
elem: '#start' //指定元素
|
||||
elem: '#start'
|
||||
,type:'datetime',
|
||||
lang: dateLang
|
||||
lang: dateLang,
|
||||
value: defaultStart
|
||||
});
|
||||
//执行一个laydate实例
|
||||
laydate.render({
|
||||
elem: '#end' //指定元素
|
||||
elem: '#end'
|
||||
,type:'datetime',
|
||||
lang: dateLang
|
||||
lang: dateLang,
|
||||
value: defaultEnd
|
||||
});
|
||||
});
|
||||
|
||||
@ -292,11 +306,11 @@
|
||||
break;
|
||||
}
|
||||
if(format == 1){
|
||||
startDate = startYear + '-' + startMonth + '-' + startDay + ' 00:00:00';
|
||||
endDate = endYear + '-' + endMonth + '-' + endDay + ' 23:59:59';
|
||||
startDate = startYear + '-' + startMonth + '-' + startDay + ' 07:00:00';
|
||||
endDate = endYear + '-' + endMonth + '-' + endDay + ' 06:59:59';
|
||||
}else{
|
||||
startDate = startDay + ' 00:00:00';
|
||||
endDate = endDay + ' 23:59:59';
|
||||
startDate = startDay + ' 07:00:00';
|
||||
endDate = endDay + ' 06:59:59';
|
||||
}
|
||||
|
||||
query.startDate = startDate;
|
||||
|
||||
@ -58,3 +58,8 @@ define('LOCAL_PHONE_WS', 'ws://192.168.6.2:8088');
|
||||
//JK
|
||||
define('LOCAL_TRACKER_SERVER', 'og-ws.abc.com');
|
||||
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
application/jk/.DS_Store
vendored
Normal file
BIN
application/jk/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
application/onlinechip/.DS_Store
vendored
Normal file
BIN
application/onlinechip/.DS_Store
vendored
Normal file
Binary file not shown.
564
openspec/archive/customer-service-module/design.md
Normal file
564
openspec/archive/customer-service-module/design.md
Normal file
@ -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 分配的客服ID,null表示无可用客服
|
||||
*/
|
||||
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登录Token,WS握手时校验 |
|
||||
| 客服端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*
|
||||
396
openspec/archive/customer-service-module/proposal.md
Normal file
396
openspec/archive/customer-service-module/proposal.md
Normal file
@ -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*
|
||||
248
openspec/archive/customer-service-module/specs.md
Normal file
248
openspec/archive/customer-service-module/specs.md
Normal file
@ -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 ZSET,score=用户余额(负数实现降序) |
|
||||
|
||||
### 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*
|
||||
443
openspec/archive/customer-service-module/tasks.md
Normal file
443
openspec/archive/customer-service-module/tasks.md
Normal file
@ -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
runtime/log/202601/28_cli.log
Normal file
14
runtime/log/202601/28_cli.log
Normal 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
runtime/log/202601/31_cli.log
Normal file
14
runtime/log/202601/31_cli.log
Normal 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]
|
||||
Loading…
Reference in New Issue
Block a user