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

This commit is contained in:
li 2026-02-11 19:53:50 +08:00
parent 1ce6c3d780
commit 71d7ff1d7b
36 changed files with 2590 additions and 18 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
application/.DS_Store vendored

Binary file not shown.

Binary file not shown.

View 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' => '操作失败']);
}
}
}

View File

@ -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)){

View File

@ -59,7 +59,15 @@ class Login extends Controller{
}
$last_login_info = Db::name('admin')->where(array('id' => $admin_info['id']))->field(['last_login_time','last_login_ip'])->find();
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('登录');

View File

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

Binary file not shown.

View File

@ -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(){

View File

@ -0,0 +1,89 @@
{include file="public/header" /}
<body>
<div class="x-body">
<div class="layui-card">
<div class="layui-card-header">会话信息</div>
<div class="layui-card-body">
<table class="layui-table" lay-skin="nob">
<tr>
<td width="120"><strong>会话ID:</strong></td>
<td>{$session.id}</td>
<td width="120"><strong>用户名:</strong></td>
<td>{$session.username}</td>
</tr>
<tr>
<td><strong>用户余额:</strong></td>
<td>{$session.money}</td>
<td><strong>客服:</strong></td>
<td>{$session.admin_username|default='未分配'}</td>
</tr>
<tr>
<td><strong>来源:</strong></td>
<td>
{switch name="session.source"}
{case value="1"}PC{/case}
{case value="2"}Game{/case}
{case value="3"}Portal{/case}
{/switch}
</td>
<td><strong>状态:</strong></td>
<td>
{switch name="session.status"}
{case value="0"}待分配{/case}
{case value="1"}进行中{/case}
{case value="2"}已结束{/case}
{/switch}
</td>
</tr>
<tr>
<td><strong>开始时间:</strong></td>
<td>{$session.create_time|date='Y-m-d H:i:s',###}</td>
<td><strong>结束时间:</strong></td>
<td>{if condition="$session.end_time"}{$session.end_time|date='Y-m-d H:i:s',###}{else/}-{/if}</td>
</tr>
{if condition="$session.rating"}
<tr>
<td><strong>评分:</strong></td>
<td colspan="3">
<span style="color: #FFB800;">{$session.rating}星</span>
{if condition="$session.rating_content"}
<br><span style="color: #666;">{$session.rating_content}</span>
{/if}
</td>
</tr>
{/if}
</table>
</div>
</div>
<div class="layui-card" style="margin-top: 20px;">
<div class="layui-card-header">聊天记录</div>
<div class="layui-card-body" style="height: 400px; overflow-y: auto; background: #f8f8f8;">
{volist name="messages" id="msg"}
<div style="margin-bottom: 15px; {eq name='msg.sender_type' value='2'}text-align: right;{/eq}">
<div style="display: inline-block; max-width: 60%; text-align: left;">
<div style="font-size: 12px; color: #999; margin-bottom: 3px;">
{eq name="msg.sender_type" value="1"}
用户
{else/}
客服
{/eq}
· {$msg.create_time|date='Y-m-d H:i:s',###}
</div>
<div style="padding: 10px; background: {eq name='msg.sender_type' value='1'}#fff{else/}#1E9FFF{/eq};
color: {eq name='msg.sender_type' value='1'}#333{else/}#fff{/eq};
border-radius: 5px; word-break: break-all;">
{eq name="msg.msg_type" value="2"}
<img src="{$msg.content}" style="max-width: 300px;">
{else/}
{$msg.content}
{/eq}
</div>
</div>
</div>
{/volist}
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,117 @@
{include file="public/header" /}
<body>
<div class="x-nav">
<span class="layui-breadcrumb">
<a href="">首页</a>
<a><cite>聊天记录查询</cite></a>
</span>
<a class="layui-btn layui-btn-small" style="line-height:1.6em;margin-top:3px;float:right" href="javascript:location.replace(location.href);" title="刷新">
<i class="layui-icon" style="line-height:30px"></i></a>
</div>
<div class="x-body">
<div class="layui-row">
<form class="layui-form layui-col-md12 x-so" method="get">
<div class="layui-input-inline">
<input type="text" name="username" value="{$get.username|default=''}"
placeholder="用户名" class="layui-input">
</div>
<div class="layui-input-inline">
<select name="admin_id">
<option value="">全部客服</option>
{volist name="admins" id="admin"}
<option value="{$admin.id}" {if condition="isset($get['admin_id']) && $get['admin_id'] == $admin['id']"}selected{/if}>
{$admin.nickname|default=$admin.username}
</option>
{/volist}
</select>
</div>
<div class="layui-input-inline">
<input type="text" name="startDate" id="startDate" value="{$get.startDate|default=''}"
placeholder="开始日期" class="layui-input">
</div>
<div class="layui-input-inline">
<input type="text" name="endDate" id="endDate" value="{$get.endDate|default=''}"
placeholder="结束日期" class="layui-input">
</div>
<button class="layui-btn" type="submit"><i class="layui-icon">&#xe615;</i></button>
<button class="layui-btn layui-btn-warm" type="submit" name="export" value="1">
<i class="layui-icon">&#xe67d;</i>导出
</button>
</form>
</div>
<table class="layui-table">
<thead>
<tr>
<th>会话ID</th>
<th>用户名</th>
<th>客服</th>
<th>来源</th>
<th>状态</th>
<th>评分</th>
<th>开始时间</th>
<th>结束时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{volist name="list" id="vo"}
<tr>
<td>{$vo.id}</td>
<td>{$vo.username}</td>
<td>{$vo.admin_username|default='未分配'}</td>
<td>
{switch name="vo.source"}
{case value="1"}PC{/case}
{case value="2"}Game{/case}
{case value="3"}Portal{/case}
{/switch}
</td>
<td>
{switch name="vo.status"}
{case value="0"}<span class="layui-badge layui-bg-gray">待分配</span>{/case}
{case value="1"}<span class="layui-badge layui-bg-blue">进行中</span>{/case}
{case value="2"}<span class="layui-badge">已结束</span>{/case}
{/switch}
</td>
<td>
{if condition="$vo.rating"}
<span style="color: #FFB800;">{$vo.rating}星</span>
{else/}
-
{/if}
</td>
<td>{$vo.create_time|date='Y-m-d H:i:s',###}</td>
<td>{if condition="$vo.end_time"}{$vo.end_time|date='Y-m-d H:i:s',###}{else/}-{/if}</td>
<td class="td-manage">
<a title="查看详情" onclick="x_admin_show('会话详情','/chat/detail?session_id={$vo.id}',900,600)" href="javascript:;">
<i class="layui-icon">&#xe63c;</i>
</a>
</td>
</tr>
{/volist}
</tbody>
</table>
<div class="page">
{$list->render()}
</div>
</div>
<script>
layui.use(['laydate', 'form'], function(){
var laydate = layui.laydate;
var form = layui.form;
laydate.render({
elem: '#startDate',
type: 'date'
});
laydate.render({
elem: '#endDate',
type: 'date'
});
form.render();
});
</script>
</body>
</html>

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

View 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">&#xe615;</i></button>
</form>
</div>
<xblock>
<button class="layui-btn" onclick="x_admin_show('添加快捷回复','/chat_quick_reply/add',600,400)">
<i class="layui-icon"></i>添加
</button>
</xblock>
<table class="layui-table">
<thead>
<tr>
<th>ID</th>
<th>分类</th>
<th>标题</th>
<th>内容</th>
<th>排序</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{volist name="list" id="vo"}
<tr>
<td>{$vo.id}</td>
<td>{$vo.category|default='无'}</td>
<td>{$vo.title}</td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{$vo.content}">{$vo.content}</td>
<td>
<input type="text" class="layui-input" value="{$vo.sort}"
onchange="updateSort({$vo.id}, this.value)"
style="width: 60px; display: inline-block;">
</td>
<td>
<input type="checkbox" name="status" lay-skin="switch" lay-text="启用|禁用"
{eq name="vo.status" value="1"}checked{/eq}
onchange="updateStatus({$vo.id}, this.checked ? 1 : 0)">
</td>
<td>{$vo.create_time|date='Y-m-d H:i:s',###}</td>
<td class="td-manage">
<a title="编辑" onclick="x_admin_show('编辑','/chat_quick_reply/edit?id={$vo.id}',600,400)" href="javascript:;">
<i class="layui-icon">&#xe642;</i>
</a>
<a title="删除" onclick="member_del(this,'{$vo.id}')" href="javascript:;">
<i class="layui-icon">&#xe640;</i>
</a>
</td>
</tr>
{/volist}
</tbody>
</table>
<div class="page">
{$list->render()}
</div>
</div>
<script>
layui.use(['form', 'layer'], function(){
var form = layui.form;
var layer = layui.layer;
form.render();
});
// 删除
function member_del(obj, id){
layer.confirm('确认要删除吗?', function(index){
$.ajax({
url: '/chat_quick_reply/del',
type: 'POST',
data: {id: id},
success: function(res){
if(res.code == 0){
$(obj).parents("tr").remove();
layer.msg('删除成功', {icon: 1, time: 1000});
} else {
layer.msg(res.msg, {icon: 2, time: 1000});
}
}
});
});
}
// 更新状态
function updateStatus(id, status){
$.post('/chat_quick_reply/status', {id: id, status: status}, function(res){
if(res.code == 0){
layer.msg('操作成功', {icon: 1, time: 1000});
} else {
layer.msg(res.msg, {icon: 2, time: 1000});
}
});
}
// 更新排序
function updateSort(id, sort){
$.post('/chat_quick_reply/sort', {id: id, sort: sort}, function(res){
if(res.code == 0){
layer.msg('操作成功', {icon: 1, time: 1000});
} else {
layer.msg(res.msg, {icon: 2, time: 1000});
}
});
}
</script>
</body>
</html>

View File

@ -122,6 +122,17 @@
<li><a _href="/Tip/optimum"><i class="iconfont">&#59130;</i><cite>今日最佳记录</cite></a></li>
</ul>
</li>
<li>
<a href="javascript:;">
<cite class="main-nav">客服管理</cite>
<i class="iconfont nav_right">&#xe697;</i>
</a>
<ul class="sub-menu">
<li><a _href="/chat/index"><i class="iconfont">&#xe63a;</i><cite>客服工作台</cite></a></li>
<li><a _href="/chat/record"><i class="iconfont">&#xe756;</i><cite>聊天记录查询</cite></a></li>
<li><a _href="/chat_quick_reply/index"><i class="iconfont">&#xe705;</i><cite>快捷回复管理</cite></a></li>
</ul>
</li>
</div>
<div id="configure_list" style="display: none;">
<li class="open">

Binary file not shown.

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -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',

View File

@ -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',

View File

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

Binary file not shown.

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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;

View File

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

Binary file not shown.

BIN
application/onlinechip/.DS_Store vendored Normal file

Binary file not shown.

View 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 分配的客服IDnull表示无可用客服
*/
public function assignSession(int $userId, int $sessionId): ?int
{
$redis = $this->getRedis();
$lockKey = self::LOCK_PREFIX . $userId;
// 1. 获取分配锁 (防止并发双分配)
$lockValue = uniqid('', true);
$acquired = $redis->set($lockKey, $lockValue, ['NX', 'PX' => self::LOCK_TTL]);
if (!$acquired) {
// 锁被占用,检查是否已有活跃会话
return $this->getExistingSessionAgent($userId);
}
try {
// 2. 检查用户是否已有活跃会话
$existingAgent = $this->getExistingSessionAgent($userId);
if ($existingAgent !== null) {
return $existingAgent;
}
// 3. 获取所有在线客服
$onlineAgents = $this->getOnlineAgents($redis);
if (empty($onlineAgents)) {
// 无在线客服,进入留言队列
$this->addToOfflineQueue($userId, $sessionId);
return null;
}
// 4. 选择会话数最少的客服
$selectedAgent = $this->selectLeastLoadAgent($redis, $onlineAgents);
if ($selectedAgent === null) {
// 所有客服已满载
$this->addToOfflineQueue($userId, $sessionId);
return null;
}
// 5. 更新会话归属
$this->bindSessionToAgent($sessionId, $selectedAgent, $redis);
// 6. 增加客服负载计数
$redis->incr(self::LOAD_PREFIX . $selectedAgent);
return $selectedAgent;
} finally {
// 释放锁 (仅释放自己持有的锁)
$this->releaseLock($redis, $lockKey, $lockValue);
}
}
private function selectLeastLoadAgent(Redis $redis, array $onlineAgents): ?int
{
$loads = [];
foreach ($onlineAgents as $agentId) {
$load = (int)$redis->get(self::LOAD_PREFIX . $agentId) ?: 0;
$maxSessions = $this->getAgentMaxSessions($agentId);
if ($load < $maxSessions) {
$loads[$agentId] = $load;
}
}
if (empty($loads)) {
return null;
}
// 返回负载最小的客服
asort($loads);
return array_key_first($loads);
}
private function releaseLock(Redis $redis, string $key, string $value): void
{
// Lua脚本保证原子性仅当值匹配时才删除
$script = <<<LUA
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
LUA;
$redis->eval($script, [$key, $value], 1);
}
}
```
### 3.3 消息重试算法 (指数退避)
```php
// app/services/chat/MessageService.php
class MessageService
{
private const MAX_RETRY = 3;
private const RETRY_DELAYS = [1000, 2000, 4000]; // ms
/**
* 推送消息并处理重试
*/
public function pushMessage(int $msgId, int $targetFd, array $payload): bool
{
$redis = $this->getRedis();
$server = $this->getSwooleServer();
for ($retry = 0; $retry <= self::MAX_RETRY; $retry++) {
// 检查目标连接是否有效
if (!$server->isEstablished($targetFd)) {
// 连接已断开标记为pending等待重连补发
$this->markMessagePending($msgId);
return false;
}
// 尝试推送
$result = $server->push($targetFd, json_encode($payload));
if ($result) {
// 推送成功更新状态为sent
$this->updateMessageStatus($msgId, 'sent');
return true;
}
// 推送失败,记录重试次数
$this->incrementRetryCount($msgId);
if ($retry < self::MAX_RETRY) {
// 指数退避等待
usleep(self::RETRY_DELAYS[$retry] * 1000);
}
}
// 超过最大重试次数标记为failed
$this->updateMessageStatus($msgId, 'failed');
return false;
}
}
```
### 3.4 离线队列处理 (余额优先)
```php
// app/services/chat/AssignService.php (续)
class AssignService
{
private const QUEUE_KEY = 'cs:queue:pending';
/**
* 添加到离线队列 (按余额降序)
*/
public function addToOfflineQueue(int $userId, int $sessionId): void
{
$redis = $this->getRedis();
// 获取用户余额
$user = Db::name('user')->where('id', $userId)->find();
$balance = $user['money'] ?? 0;
// ZSET score 使用负余额实现降序 (余额高的先处理)
$score = -$balance;
$member = json_encode(['userId' => $userId, 'sessionId' => $sessionId, 'time' => time()]);
$redis->zAdd(self::QUEUE_KEY, $score, $member);
}
/**
* 客服上线时处理队列
*/
public function processOfflineQueue(int $adminId): void
{
$redis = $this->getRedis();
while (true) {
// 获取队列中优先级最高的会话 (score最小 = 余额最高)
$items = $redis->zRange(self::QUEUE_KEY, 0, 0);
if (empty($items)) {
break; // 队列为空
}
$item = json_decode($items[0], true);
// 检查客服是否还能接单
$currentLoad = (int)$redis->get(self::LOAD_PREFIX . $adminId) ?: 0;
$maxSessions = $this->getAgentMaxSessions($adminId);
if ($currentLoad >= $maxSessions) {
break; // 客服已满载
}
// 尝试分配 (使用分布式锁)
$assigned = $this->assignSession($item['userId'], $item['sessionId']);
if ($assigned === $adminId) {
// 分配成功,从队列移除
$redis->zRem(self::QUEUE_KEY, $items[0]);
// 通知用户会话已被接入
$this->notifyUserSessionAssigned($item['userId'], $item['sessionId'], $adminId);
}
}
}
}
```
---
## 4. 接口设计
### 4.1 Admin 后台 HTTP API
| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 获取会话列表 | GET | `/admin/chat/sessions` | 支持状态筛选 |
| 获取会话详情 | GET | `/admin/chat/session/{id}` | 包含用户信息 |
| 获取消息历史 | GET | `/admin/chat/messages` | 支持分页、搜索 |
| 结束会话 | POST | `/admin/chat/session/{id}/end` | 手动关闭会话 |
| 转接会话 | POST | `/admin/chat/session/{id}/transfer` | 转给其他客服 |
| 快捷回复列表 | GET | `/admin/chat/quick-replies` | CRUD |
| 添加快捷回复 | POST | `/admin/chat/quick-reply` | - |
| 编辑快捷回复 | PUT | `/admin/chat/quick-reply/{id}` | - |
| 删除快捷回复 | DELETE | `/admin/chat/quick-reply/{id}` | - |
| 客服统计 | GET | `/admin/chat/stats` | 会话数、评分统计 |
| 导出记录 | GET | `/admin/chat/export` | CSV导出 |
### 4.2 用户端 HTTP API
| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 获取会话状态 | GET | `/api/chat/status` | 检查是否有活跃会话 |
| 上传图片 | POST | `/api/chat/upload` | 返回图片URL |
| 提交评价 | POST | `/api/chat/rate` | 会话评价 |
| 获取历史消息 | GET | `/api/chat/history` | 重连后拉取 |
---
## 5. 文件结构
### 5.1 Socket 模块
```
Socket/app/
├── listener/chat/
│ ├── ChatConnectListener.php # 连接/断开/心跳处理
│ ├── ChatMessageListener.php # 消息收发处理
│ └── ChatSessionListener.php # 会话管理处理
├── services/chat/
│ ├── ChatService.php # 聊天服务入口
│ ├── MessageService.php # 消息服务
│ ├── SessionService.php # 会话服务
│ └── AssignService.php # 分配服务
├── models/
│ ├── ChatSession.php # 会话模型
│ ├── ChatMessage.php # 消息模型
│ └── ChatQuickReply.php # 快捷回复模型
└── utils/
└── Snowflake.php # 雪花算法
```
### 5.2 Pro 模块 (Admin后台)
```
Pro/application/admin/
├── controller/
│ ├── Chat.php # 客服工作台控制器
│ ├── ChatRecord.php # 聊天记录控制器
│ └── ChatQuickReply.php # 快捷回复控制器
└── view/
├── chat/
│ ├── index.html # 客服工作台页面
│ └── record.html # 聊天记录页面
└── chat_quick_reply/
├── index.html # 快捷回复列表
└── add.html # 添加快捷回复
```
### 5.3 前端模块
```
PC/src/components/chat/
├── ChatWindow.vue # 聊天窗口容器
├── ChatHeader.vue # 聊天头部(客服信息/关闭)
├── ChatMessages.vue # 消息列表
├── ChatMessage.vue # 单条消息气泡
├── ChatInput.vue # 输入框+发送
└── ChatRating.vue # 评价组件
Game/src/components/chat/
├── ChatWindow.vue # 聊天窗口(Vant风格)
├── ChatMessages.vue # 消息列表
├── ChatMessage.vue # 消息气泡
└── ChatInput.vue # 输入框
Portal/components/chat/
├── ChatWindow.vue # 聊天窗口(uView风格)
├── ChatMessages.vue # 消息列表
└── ChatInput.vue # 输入框
```
---
## 6. 安全设计
### 6.1 认证与授权
| 场景 | 方案 |
|------|------|
| 用户端WS连接 | 复用HTTP登录TokenWS握手时校验 |
| 客服端WS连接 | 复用Admin Session额外校验客服权限 |
| 消息发送 | 服务端强制使用鉴权后的身份禁止伪造senderId |
### 6.2 输入校验
| 字段 | 校验规则 |
|------|----------|
| 文字消息 | 长度≤500XSS过滤 |
| 图片上传 | MIME白名单(jpg/png/gif)大小≤2MB |
| sessionId | 必须属于当前用户或客服 |
### 6.3 限流
| 维度 | 限制 |
|------|------|
| 消息发送 | 每用户每秒最多5条 |
| 图片上传 | 每用户每分钟最多10张 |
| WS重连 | 每用户每分钟最多10次 |
---
*文档版本: 2.0*
*最后更新: 2026-01-28*

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

View 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 ZSETscore=用户余额(负数实现降序) |
### 1.5 图片存储约束
| 约束ID | 约束 | 值 | 说明 |
|--------|------|-----|------|
| CC-17 | 存储方式 | 本地文件系统 | `public/uploads/chat/YYYYMMDD/` |
| CC-18 | 大小限制 | 2MB | 2097152 bytes |
| CC-19 | 格式支持 | jpg,png,gif | MIME类型白名单校验 |
### 1.6 会话生命周期约束
| 约束ID | 约束 | 值 | 说明 |
|--------|------|-----|------|
| CC-20 | 自动结束 | 禁用 | 仅支持手动关闭会话 |
| CC-21 | 评价触发 | 手动 | 用户主动点击评价按钮,无弹窗 |
---
## 2. PBT 属性 (Property-Based Testing)
### 2.1 幂等性属性
```
PROPERTY: MessageIdempotency
INVARIANT: ∀ msg1, msg2 ∈ Messages: msg1.msgId = msg2.msgId → DB.count(msgId) = 1
BOUNDARY: 并发发送相同msgId消息
FALSIFICATION:
- 生成随机msgId
- 并发100个goroutine/协程发送相同msgId
- 断言: SELECT COUNT(*) FROM cg_chat_message WHERE msg_id = ? 返回 1
```
### 2.2 会话唯一性属性
```
PROPERTY: SessionUniqueness
INVARIANT: ∀ user ∈ Users: COUNT(sessions WHERE user_id = user AND status = 'active') ≤ 1
BOUNDARY: 用户快速多次发起咨询
FALSIFICATION:
- 选择随机用户
- 并发10个请求创建会话
- 断言: SELECT COUNT(*) FROM cg_chat_session WHERE user_id = ? AND status IN (0,1) 返回 ≤ 1
```
### 2.3 分配原子性属性
```
PROPERTY: AssignmentAtomicity
INVARIANT: 会话分配要么完全成功(session.admin_id != NULL),要么完全失败(session.status = 0)
BOUNDARY: 分配过程中客服下线
FALSIFICATION:
- 开始分配流程
- 在分配锁获取后、写入admin_id前模拟客服下线
- 断言: session.admin_id = NULL AND session.status = 0 (待分配)
```
### 2.4 消息顺序性属性
```
PROPERTY: MessageOrdering
INVARIANT: ∀ session: messages ORDER BY id ASC = messages ORDER BY create_time ASC
BOUNDARY: 高并发消息发送
FALSIFICATION:
- 发送100条带序号(1-100)的消息
- 查询: SELECT content FROM cg_chat_message WHERE session_id = ? ORDER BY id
- 断言: 序号严格递增 1,2,3,...,100
```
### 2.5 ACK一致性属性
```
PROPERTY: AckConsistency
INVARIANT: ∀ msg: msg.status = 'delivered' → 不会再次推送给同一接收方
BOUNDARY: 网络抖动导致ACK延迟
FALSIFICATION:
- 发送消息并模拟ACK丢失
- 客户端重连
- 断言: 已标记delivered的消息不在补发列表中
```
### 2.6 负载均衡属性
```
PROPERTY: LoadBalancing
INVARIANT: ∀ agents a1, a2 ∈ OnlineAgents: |a1.sessionCount - a2.sessionCount| ≤ 1
BOUNDARY: 多用户同时发起咨询
FALSIFICATION:
- 10个客服在线各0个会话
- 100个用户并发发起咨询
- 断言: 每个客服会话数在 [9, 11] 范围内
```
### 2.7 离线消息完整性属性
```
PROPERTY: OfflineMessageIntegrity
INVARIANT: ∀ msg sent while all agents offline: msg ∈ PendingQueue
BOUNDARY: 客服全部离线时大量消息
FALSIFICATION:
- 所有客服下线
- 发送50条消息
- 断言: cg_chat_message.count = 50 AND all status = 'pending'
```
### 2.8 余额排序正确性属性
```
PROPERTY: BalancePriorityOrdering
INVARIANT: 队列处理顺序按用户余额降序
BOUNDARY: 相同余额用户按时间排序
FALSIFICATION:
- 插入用户: balance=[100,500,200,500,300]
- 客服上线处理
- 断言: 处理顺序为 balance 500(先到), 500(后到), 300, 200, 100
```
### 2.9 心跳续期属性
```
PROPERTY: HeartbeatRenewal
INVARIANT: 心跳后在线状态TTL重置为60秒
BOUNDARY: 心跳边界时间
FALSIFICATION:
- 建立连接记录Redis TTL
- 等待29秒发送心跳
- 断言: TTL重置为60秒
- 等待31秒不发心跳
- 断言: 用户被标记为离线
```
### 2.10 图片大小校验属性
```
PROPERTY: ImageSizeValidation
INVARIANT: 图片大小 > 2MB 时上传被拒绝
BOUNDARY: 边界值 2MB ± 1 byte
FALSIFICATION:
- 上传 2097151 bytes (2MB-1): 断言成功
- 上传 2097152 bytes (2MB): 断言成功
- 上传 2097153 bytes (2MB+1): 断言失败,返回错误码
```
---
## 3. Redis Key 设计
| Key Pattern | 类型 | TTL | 说明 |
|-------------|------|-----|------|
| `cs:online:agent:{adminId}` | STRING | 60s | 客服在线状态,心跳续期 |
| `cs:conn:user:{userId}` | STRING | 60s | 用户连接映射 fd |
| `cs:conn:agent:{adminId}` | STRING | 60s | 客服连接映射 fd |
| `cs:session:owner:{sessionId}` | STRING | - | 会话归属客服ID |
| `cs:user:active_session:{userId}` | STRING | - | 用户当前活跃会话ID |
| `cs:lock:assign:{userId}` | STRING | 3s | 会话分配锁 |
| `cs:queue:pending` | ZSET | - | 待处理队列score=-balance |
| `cs:agent:load:{adminId}` | STRING | - | 客服当前会话数 |
---
## 4. WebSocket 事件协议
### 4.1 客户端 → 服务端
| 事件 | Payload | 说明 |
|------|---------|------|
| `chat.connect` | `{token, source}` | 建立聊天连接 |
| `chat.message.send` | `{sessionId, msgType, content, clientMsgId}` | 发送消息 |
| `chat.message.ack` | `{msgId}` | 消息已读回执 |
| `chat.typing` | `{sessionId, isTyping}` | 正在输入状态 |
| `chat.session.end` | `{sessionId}` | 用户结束会话 |
| `chat.session.rate` | `{sessionId, rating, content}` | 会话评价 |
| `chat.ping` | `{}` | 心跳 |
### 4.2 服务端 → 客户端
| 事件 | Payload | 说明 |
|------|---------|------|
| `chat.connected` | `{sessionId, agentInfo}` | 连接成功,返回会话信息 |
| `chat.message.new` | `{msgId, sessionId, senderType, content, time}` | 新消息 |
| `chat.message.server_ack` | `{clientMsgId, msgId, status}` | 服务端确认收到 |
| `chat.message.peer_ack` | `{msgId, status}` | 对端已送达/已读 |
| `chat.typing` | `{sessionId, isTyping}` | 对方正在输入 |
| `chat.session.assigned` | `{sessionId, agentInfo}` | 会话已分配客服 |
| `chat.session.ended` | `{sessionId}` | 会话已结束 |
| `chat.offline_notice` | `{message}` | 客服离线提示 |
| `chat.pong` | `{}` | 心跳响应 |
### 4.3 客服端专用事件
| 事件 | 方向 | Payload | 说明 |
|------|------|---------|------|
| `chat.agent.online` | C→S | `{maxSessions}` | 客服上线 |
| `chat.agent.offline` | C→S | `{}` | 客服下线 |
| `chat.session.new` | S→C | `{sessionId, userInfo, source}` | 新会话通知 |
| `chat.session.transfer` | C→S | `{sessionId, targetAdminId}` | 转接会话 |
| `chat.queue.list` | S→C | `{sessions: [...]}` | 待处理队列 |
---
*文档版本: 2.0*
*最后更新: 2026-01-28*

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

View File

@ -0,0 +1,14 @@
[ 2026-01-28T17:26:18+00:00 ][ error ] [8192]think\App::run(): Implicitly marking parameter $request as nullable is deprecated, the explicit nullable type must be used instead[/Users/li/Desktop/work/og/Pro/thinkphp/library/think/App.php:77]
[ info ] [ LOG ] INIT File
[ 2026-01-28T17:26:18+00:00 ][ error ] [1]Uncaught think\exception\ErrorException: think\console\output\formatter\Stack::__construct(): Implicitly marking parameter $emptyStyle as nullable is deprecated, the explicit nullable type must be used instead in /Users/li/Desktop/work/og/Pro/thinkphp/library/think/console/output/formatter/Stack.php:31
Stack trace:
#0 /Users/li/Desktop/work/og/Pro/thinkphp/library/think/Loader.php(646): think\Error::appError(8192, 'think\\console\\o...', '/Users/li/Deskt...', 31)
#1 /Users/li/Desktop/work/og/Pro/thinkphp/library/think/Loader.php(646): include()
#2 /Users/li/Desktop/work/og/Pro/thinkphp/library/think/Loader.php(85): think\__include_file('/Users/li/Deskt...')
#3 /Users/li/Desktop/work/og/Pro/thinkphp/library/think/console/output/Formatter.php(45): think\Loader::autoload('think\\console\\o...')
#4 /Users/li/Desktop/work/og/Pro/thinkphp/library/think/console/output/driver/Console.php(34): think\console\output\Formatter->__construct()
#5 /Users/li/Desktop/work/og/Pro/thinkphp/library/think/console/Output.php(71): think\console\output\driver\Console->__construct(Object(think\console\Output))
#6 /Users/li/Desktop/work/og/Pro/thinkphp/library/think/Error.php(50): think\console\Output->__construct()
#7 [internal function]: think\Error::appException(Object(think\exception\ErrorException))
#8 {main}
thrown[/Users/li/Desktop/work/og/Pro/thinkphp/library/think/console/output/formatter/Stack.php:31]

View File

@ -0,0 +1,14 @@
[ 2026-01-31T09:56:51+00:00 ][ error ] [8192]think\App::run(): Implicitly marking parameter $request as nullable is deprecated, the explicit nullable type must be used instead[/Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/App.php:77]
[ info ] [ LOG ] INIT File
[ 2026-01-31T09:56:51+00:00 ][ error ] [1]Uncaught think\exception\ErrorException: think\console\output\formatter\Stack::__construct(): Implicitly marking parameter $emptyStyle as nullable is deprecated, the explicit nullable type must be used instead in /Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/console/output/formatter/Stack.php:31
Stack trace:
#0 /Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/Loader.php(646): think\Error::appError(8192, 'think\\console\\o...', '/Users/li/Deskt...', 31)
#1 /Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/Loader.php(646): include()
#2 /Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/Loader.php(85): think\__include_file('/Users/li/Deskt...')
#3 /Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/console/output/Formatter.php(45): think\Loader::autoload('think\\console\\o...')
#4 /Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/console/output/driver/Console.php(34): think\console\output\Formatter->__construct()
#5 /Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/console/Output.php(71): think\console\output\driver\Console->__construct(Object(think\console\Output))
#6 /Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/Error.php(50): think\console\Output->__construct()
#7 [internal function]: think\Error::appException(Object(think\exception\ErrorException))
#8 {main}
thrown[/Users/li/Desktop/work/qingdao/og/Pro/thinkphp/library/think/console/output/formatter/Stack.php:31]