request->action(), $publicActions)) {
$this->model = new ChathubTenant();
return;
}
$userActions = ['my', 'channellist', 'channelauth', 'channelcallback', 'reprovision'];
if (session('chathub_user_id') && in_array($this->request->action(), $userActions)) {
$this->model = new ChathubTenant();
return;
}
parent::_initialize();
$this->model = new ChathubTenant();
}
private function render($content)
{
header('Content-Type: text/html; charset=utf-8');
echo $content;
exit;
}
private function layout($title, $body)
{
return '
' . $title . '
' . $body . '
';
}
public function index()
{
if ($this->request->isAjax()) {
$filter = $this->request->request("filter");
$filter = (array)json_decode($filter, true);
$where = [];
if (!empty($filter['status'])) $where['status'] = $filter['status'];
if (!empty($filter['channel_type'])) $where['channel_type'] = $filter['channel_type'];
if (!empty($filter['search'])) $where['tenant_name|domain'] = ['like', '%' . $filter['search'] . '%'];
$list = $this->model->where($where)->order('id desc')->paginate();
return json(["total" => $list->total(), "rows" => $list->items()]);
}
$html = '';
$html .= '';
$this->render($this->layout('租户列表', $html));
}
public function add()
{
if ($this->request->isPost()) {
$params = $this->request->post("row/a");
if ($params) {
Db::startTrans();
try {
$result = $this->model->allowField(true)->save($params);
Db::commit();
} catch (\Exception $e) {
Db::rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success('添加成功!', '/pGTnXkhdmy.php/addons/chathub/index/index');
}
$this->error($this->model->getError());
}
$this->error('参数不能为空');
}
$html = '
';
$this->render($this->layout('添加租户', $html));
}
public function register()
{
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($this->request->isOptions()) {
exit(json_encode(['code' => 1]));
}
$isAjax = $this->request->isAjax() || strpos($this->request->header('content-type', ''), 'json') !== false;
$isJson = $isAjax;
$rawInput = file_get_contents('php://input');
$jsonData = json_decode($rawInput, true);
if ($jsonData) {
$data = $jsonData;
} else {
$data = $this->request->post();
}
$companyName = trim($data['company_name'] ?? '');
$email = trim($data['email'] ?? '');
$domain = trim($data['domain'] ?? '');
$plan = $data['plan'] ?? 'basic';
$seats = (int)($data['seats'] ?? 0);
$notes = trim($data['notes'] ?? '');
if (!$companyName || !$email || !$domain) {
if ($isJson) {
header('Content-Type: application/json; charset=utf-8');
exit(json_encode(['code' => 0, 'msg' => '请填写必填字段']));
}
$this->_registerResultPage(false, '请填写必填字段(公司名称、邮箱、网站域名)');
return;
}
$baseSeats = 1;
$basePrice = 50;
$extraSeatPrice = 10;
if ($plan === 'pro') {
$baseSeats = 2;
$basePrice = 60;
} elseif ($plan === 'enterprise') {
$baseSeats = 3;
$basePrice = 70;
$basePrice += $seats * $extraSeatPrice;
}
$totalSeats = $baseSeats + $seats;
$expireAt = date('Y-m-d H:i:s', strtotime('+30 days'));
$existingUser = Db::name('user')->where('email', $email)->find();
if (!$existingUser) {
$defaultPassword = substr(md5($email . time() . 'chathub'), 0, 10);
$userId = Db::name('user')->insertGetId([
'username' => $email,
'nickname' => $companyName,
'email' => $email,
'password' => password_hash($defaultPassword, PASSWORD_BCRYPT),
'salt' => '',
'status' => 'normal',
'jointime' => time(),
'joinip' => $this->request->ip(),
'createtime' => time(),
'updatetime' => time(),
]);
$createdPassword = $defaultPassword;
} else {
$userId = $existingUser['id'];
$createdPassword = null;
}
$tenant = new ChathubTenant();
$tenant->user_id = $userId;
$tenant->tenant_name = $companyName;
$tenant->domain = $domain;
$tenant->email = $email;
$tenant->channel_type = 'web_widget';
$tenant->max_agents = $totalSeats;
$tenant->status = 'provisioning';
$tenant->expire_at = $expireAt;
$tenant->config = json_encode(['plan' => $plan, 'price' => $basePrice, 'notes' => $notes, 'registered_at' => date('Y-m-d H:i:s'), 'initial_password' => $createdPassword]);
$tenant->save();
$cfg = get_addon_config('chathub');
$provisionUrl = rtrim($cfg['provision_server_url'] ?? 'http://CoPaw:5566', '/') . '/provision';
$payload = json_encode([
'name' => $companyName,
'domain' => $domain,
'email' => $email,
'type' => 'web_widget',
'max_agents' => $totalSeats,
]);
$idempotencyKey = 'reg-' . bin2hex(random_bytes(8));
$ch = curl_init($provisionUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-API-Key: chathub-default-key-change-me', 'Idempotency-Key: ' . $idempotencyKey],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_CONNECTTIMEOUT => 10
]);
$response = null;
$curlError = '';
$httpCode = 0;
$attempts = 0;
$maxAttempts = 3;
while ($attempts < $maxAttempts) {
$attempts++;
$response = curl_exec($ch);
$curlError = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (!$curlError && $httpCode == 200) {
break;
}
$tenant->config = json_encode(array_merge(
json_decode($tenant->config, true) ?: [],
['provision_retry' => $attempts, 'last_error' => $curlError ?: "HTTP $httpCode"]
));
$tenant->save();
if ($attempts < $maxAttempts) {
$wait = pow(2, $attempts - 1);
sleep($wait);
$ch = curl_init($provisionUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-API-Key: chathub-default-key-change-me', 'Idempotency-Key: ' . $idempotencyKey],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_CONNECTTIMEOUT => 10
]);
}
}
if ($curlError || $httpCode != 200) {
$tenant->status = 'pending';
$tenant->save();
Db::name('chathub_log')->insert([
'tenant_id' => $tenant->id,
'action' => 'register',
'detail' => "注册失败({$attempts}次重试): " . ($curlError ?: "HTTP $httpCode"),
'status' => 'failed',
'operator' => 'website',
'createtime' => time()
]);
$msg = '注册成功!正在为您开通,请稍候...';
if ($isJson) { $this->_registerJson(1, $msg, $companyName, $plan, $basePrice, $totalSeats, '', $createdPassword, date('Y-m-d', strtotime($expireAt))); }
$this->_registerResultPage(true, $msg, $companyName, $plan, $basePrice, $totalSeats, '', $createdPassword);
return;
}
$resultData = json_decode($response, true);
if ($resultData && isset($resultData['inbox_id'])) {
$tenant->status = 'active';
$tenant->provisioned_at = date('Y-m-d H:i:s');
$tenant->inbox_id = $resultData['inbox_id'] ?? null;
$tenant->inbox_token = $resultData['inbox_token'] ?? null;
$tenant->embed_code = $resultData['embed_code'] ?? null;
$tenant->agent_name = $resultData['inbox_name'] ?? null;
$tenant->team_id = $resultData['team_id'] ?? null;
$tenant->agent_cw_id = $resultData['agent_cw_id'] ?? null;
$tenant->agent_cw_password = $resultData['agent_cw_password'] ?? null;
$tenant->save();
Db::name('chathub_log')->insert([
'tenant_id' => $tenant->id,
'action' => 'register',
'detail' => "注册开通: {$companyName}, 方案: {$plan}, 席位: {$totalSeats}, 价格: ¥{$basePrice}/月" . ($attempts > 1 ? " (重试{$attempts}次后成功)" : ''),
'status' => 'success',
'operator' => 'website',
'createtime' => time()
]);
$msg = '注册成功!AI 客服已开通,请查收邮件获取嵌入代码。';
if ($isJson) { $this->_registerJson(1, $msg, $companyName, $plan, $basePrice, $totalSeats, $resultData['embed_code'] ?? '', $createdPassword, date('Y-m-d', strtotime($expireAt))); }
$this->_registerResultPage(true, $msg, $companyName, $plan, $basePrice, $totalSeats, $resultData['embed_code'] ?? '', $createdPassword);
return;
}
$tenant->status = 'pending';
$tenant->save();
Db::name('chathub_log')->insert([
'tenant_id' => $tenant->id,
'action' => 'register',
'detail' => "注册响应无效({$attempts}次重试)",
'status' => 'failed',
'operator' => 'website',
'createtime' => time()
]);
$msg = '注册成功!正在为您开通,请稍候联系客服获取嵌入代码。';
if ($isJson) { $this->_registerJson(1, $msg, $companyName, $plan, $basePrice, $totalSeats, '', $createdPassword, date('Y-m-d', strtotime($expireAt))); }
$this->_registerResultPage(true, $msg, $companyName, $plan, $basePrice, $totalSeats, '', $createdPassword);
}
public function changePassword()
{
$userId = session('chathub_user_id');
if (!$userId) {
header('Location: /addons/chathub/index/login');
exit;
}
$error = '';
$success = '';
if ($this->request->isPost()) {
$old = $this->request->post('old_password');
$new = $this->request->post('new_password');
$confirm = $this->request->post('confirm_password');
if (!$old || !$new || !$confirm) {
$error = '请填写所有字段';
} elseif ($new !== $confirm) {
$error = '两次输入的新密码不一致';
} elseif (strlen($new) < 6) {
$error = '新密码至少 6 位';
} else {
$user = Db::name('user')->where('id', $userId)->find();
$hashOk = !empty($user['salt']) ? (md5($old) === $user['password']) : password_verify($old, $user['password']);
if (!$hashOk) {
$error = '原密码错误';
} else {
Db::name('user')->where('id', $userId)->update([
'password' => password_hash($new, PASSWORD_BCRYPT),
'salt' => '',
'updatetime' => time(),
]);
$success = '密码修改成功';
}
}
}
$cfg = get_addon_config('chathub');
$siteName = $cfg['site_name'] ?? 'ChatHub';
$nick = session('chathub_user_nick') ?: '';
$msgHtml = $error ? ''.htmlspecialchars($error).'
' : '';
if ($success) $msgHtml .= ''.htmlspecialchars($success).'
';
$html = <<<'HTML'
修改密码 - SITENAME
HTML;
$html = str_replace('SITENAME', $siteName, $html);
$html = str_replace('MSG_PLACEHOLDER', $msgHtml, $html);
$this->render($html);
}
public function renew()
{
$userId = session('chathub_user_id');
if (!$userId) {
header('Location: /addons/chathub/index/login');
exit;
}
$tenantId = (int)$this->request->get('id', 0);
$tenant = Db::name('chathub_tenant')->where('id', $tenantId)->where('user_id', $userId)->find();
if (!$tenant) {
header('Location: /addons/chathub/index/my?error=' . urlencode('租户不存在'));
exit;
}
$config = json_decode($tenant['config'] ?? '{}', true) ?: [];
$plan = $config['plan'] ?? 'basic';
$price = (int)($config['price'] ?? 0);
$cfg = get_addon_config('chathub');
$alipayOn = !empty($cfg['alipay_enabled']);
$wechatOn = !empty($cfg['wechat_enabled']);
$siteName = $cfg['site_name'] ?? 'ChatHub';
$orderNo = 'CH' . date('YmdHis') . mt_rand(100, 999);
Db::name('chathub_order')->insert([
'order_no' => $orderNo,
'tenant_id' => $tenant['id'],
'user_id' => $userId,
'plan' => $plan,
'amount' => $price,
'status' => 'pending',
'createtime' => time(),
]);
$payBtns = '';
if ($alipayOn) {
$payBtns .= '💰 支付宝支付 ¥' . $price . '';
}
if ($wechatOn) {
$payBtns .= '💚 微信支付 ¥' . $price . '';
}
if (!$alipayOn && !$wechatOn) {
$payBtns = '⚠️ 支付功能未开通,请联系客服手动续费
';
}
$planName = ['basic' => '基础版', 'pro' => '专业版', 'enterprise' => '企业版'][$plan] ?? $plan;
$html = <<<'HTML'
续费 - SITENAME
续费订阅
订单号:ORDERNO
租户TENANT_NAME
套餐PLAN_NAME
席位数SEATS
当前到期EXPIRE
应付金额¥PRICE /月
PAY_BUTTONS
← 返回会员中心
HTML;
$html = str_replace('SITENAME', $siteName, $html);
$html = str_replace('ORDERNO', $orderNo, $html);
$html = str_replace('TENANT_NAME', htmlspecialchars($tenant['tenant_name']), $html);
$html = str_replace('PLAN_NAME', $planName, $html);
$html = str_replace('SEATS', $tenant['max_agents'], $html);
$html = str_replace('EXPIRE', $tenant['expire_at'] ? date('Y-m-d', strtotime($tenant['expire_at'])) : '-', $html);
$html = str_replace('PRICE', $price, $html);
$html = str_replace('PAY_BUTTONS', $payBtns, $html);
$this->render($html);
}
private function _registerJson($code, $msg, $companyName, $plan, $price, $seats, $embedCode = '', $initialPassword = '', $expireAt = '')
{
header('Content-Type: application/json; charset=utf-8');
exit(json_encode([
'code' => $code, 'msg' => $msg,
'company_name' => $companyName, 'plan' => $plan, 'price' => $price, 'seats' => $seats,
'embed_code' => $embedCode, 'initial_password' => $initialPassword, 'expire_at' => $expireAt,
]));
}
private function _registerResultPage($success, $msg, $companyName = '', $plan = '', $price = 0, $seats = 0, $embedCode = '', $initialPassword = '')
{
$backUrl = '/addons/chathub/index/landing';
$icon = $success ? '✓' : '✗';
$color = $success ? '#10b981' : '#ef4444';
$planName = ['basic' => '基础版', 'pro' => '专业版', 'enterprise' => '企业版'][$plan] ?? $plan;
echo '
注册结果 - ChatHub
' . $icon . '
' . ($success ? '注册成功' : '注册失败') . '
' . htmlspecialchars($msg) . '
';
if ($success && $companyName) {
echo '
';
echo '
公司名称' . htmlspecialchars($companyName) . '
';
echo '
套餐' . htmlspecialchars($planName) . '(¥' . $price . '/月)
';
echo '
席位' . $seats . '
';
echo '
到期时间' . date('Y-m-d', strtotime('+30 days')) . '
';
echo '
';
if ($embedCode) {
echo '
嵌入代码(复制到您的网站):
';
echo '
' . htmlspecialchars($embedCode) . '
';
}
if ($initialPassword) {
echo '
';
echo '
⚠️ 您的登录密码(请保存,登录后可修改):
';
echo '
' . htmlspecialchars($initialPassword) . '
';
echo '
登录邮箱:' . htmlspecialchars($companyName) . '
';
echo '
';
}
}
echo '
立即登录';
echo '
返回首页';
echo '
';
exit;
}
public function del($ids = null)
{
$ids = $ids ?: $this->request->post('ids') ?: $this->request->param('ids');
if ($ids) {
Db::startTrans();
try {
$count = $this->model->where($this->model->getPk(), 'in', $ids)->delete();
Db::commit();
} catch (\Exception $e) {
Db::rollback();
$this->error($e->getMessage());
}
if ($count) $this->success('删除成功!');
$this->error('没有删除任何记录');
}
$this->error('参数不能为空');
}
public function dashboard()
{
$logs = Db::name('chathub_log')->order('createtime desc')->limit(10)->select();
$html = '
' . $this->model->count() . '
总租户
' . $this->model->where("status","active")->count() . '
已开通
' . $this->model->where("status","pending")->count() . '
待开通
';
if ($logs) {
$html .= '
| 操作 | 状态 | 时间 |
';
foreach ($logs as $log) {
$html .= '| ' . htmlspecialchars($log['action']) . ' | ';
$html .= '' . $log['status'] . ' | ';
$html .= '' . date('Y-m-d H:i', $log['createtime']) . ' |
';
}
$html .= '
';
} else {
$html .= '
暂无操作记录
';
}
$html .= '
';
$this->render($this->layout('控制面板', $html));
}
public function logs()
{
$logs = Db::name('chathub_log')->order('createtime desc')->paginate(20);
$html = '
';
if ($logs->items()) {
$html .= '
| 租户ID | 操作 | 状态 | 操作人 | 时间 |
';
foreach ($logs->items() as $log) {
$html .= '| ' . $log['tenant_id'] . ' | ' . htmlspecialchars($log['action']) . ' | ';
$html .= '' . $log['status'] . ' | ';
$html .= '' . htmlspecialchars($log['operator']) . ' | ';
$html .= '' . date('Y-m-d H:i:s', $log['createtime']) . ' |
';
}
$html .= '
';
} else {
$html .= '
暂无记录
';
}
$html .= '
';
$this->render($this->layout('操作日志', $html));
}
public function suspend($ids = null)
{
$ids = $ids ?: $this->request->post('ids');
if (!$ids) $this->error('参数不能为空');
$row = $this->model->get($ids);
if (!$row) $this->error('记录不存在');
if ($row['status'] !== 'active') $this->error('只能暂停已开通的租户');
// 调 provision 服务暂停(它负责禁 Chatwoot inbox + 停 QwenPaw Agent)
$cfg = get_addon_config('chathub');
$url = rtrim($cfg['provision_server_url'] ?? 'http://CoPaw:5566', '/') . '/suspend';
$payload = json_encode([
'inbox_id' => $row['inbox_id'],
'tenant_name' => $row['tenant_name'],
'domain' => $row['domain'],
]);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-API-Key: chathub-default-key-change-me'],
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30
]);
$response = curl_exec($ch);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
$this->error('暂停失败:无法连接 Provision 服务(' . $curlError . ')');
}
$resultData = json_decode($response, true);
if ($resultData && isset($resultData['success'])) {
$row->status = 'suspended';
$row->save();
Db::name('chathub_log')->insert([
'tenant_id' => $row['id'], 'action' => 'suspend', 'detail' => $response,
'status' => 'success', 'operator' => session('admin.username') ?: 'system', 'createtime' => time()
]);
$this->success('已暂停');
}
$errorMsg = $resultData['error'] ?? $response ?? '未知错误';
$this->error('暂停失败:' . $errorMsg);
}
public function activate($ids = null)
{
$ids = $ids ?: $this->request->post('ids');
if (!$ids) $this->error('参数不能为空');
$row = $this->model->get($ids);
if (!$row) $this->error('记录不存在');
if ($row['status'] !== 'suspended') $this->error('只能恢复已暂停的租户');
$cfg = get_addon_config('chathub');
$url = rtrim($cfg['provision_server_url'] ?? 'http://CoPaw:5566', '/') . '/activate';
$payload = json_encode(['inbox_id' => $row['inbox_id']]);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30
]);
$response = curl_exec($ch);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
$this->error('恢复失败:无法连接 Provision 服务(' . $curlError . ')');
}
$resultData = json_decode($response, true);
if ($resultData && isset($resultData['success'])) {
$row->status = 'active';
$row->save();
Db::name('chathub_log')->insert([
'tenant_id' => $row['id'], 'action' => 'activate', 'detail' => $response,
'status' => 'success', 'operator' => session('admin.username') ?: 'system', 'createtime' => time()
]);
$this->success('已恢复');
}
$errorMsg = $resultData['error'] ?? $response ?? '未知错误';
$this->error('恢复失败:' . $errorMsg);
}
public function setExpire($ids = null)
{
$ids = $ids ?: $this->request->post('ids');
$expireAt = $this->request->post('expire_at');
if (!$ids) $this->error('参数不能为空');
if (!$expireAt) $this->error('请输入到期时间');
$row = $this->model->get($ids);
if (!$row) $this->error('记录不存在');
$row->expire_at = $expireAt;
$row->save();
Db::name('chathub_log')->insert([
'tenant_id' => $row['id'], 'action' => 'set_expire', 'detail' => '设置到期: ' . $expireAt,
'status' => 'success', 'operator' => session('admin.username') ?: 'system', 'createtime' => time()
]);
$this->success('到期时间已设置');
}
public function landing()
{
$cfg = get_addon_config('chathub');
$siteName = $cfg['site_name'] ?? 'ChatHub';
$html = <<<'HTML'
ChatHub - AI 智能客服多租户平台
AI 智能客服
多租户 SaaS 平台
基于 Chatwoot + QwenPaw Agent,为企业打造一站式 AI 客服解决方案。
多租户隔离、按席位计费、3 分钟开通。
🏢
多租户隔离
每个租户独立 Chatwoot 团队、收件箱、Agent,数据完全隔离,互不干扰。
🤖
AI 智能应答
接入 QwenPaw Agent 大模型,7×24 自动回复客户咨询,支持多轮对话和上下文理解。
💬
一键嵌入
提供 JS 嵌入代码,复制粘贴即可集成到任何网站,5 分钟完成上线。
📊
实时监控
会话量、响应时间、客户满意度等关键指标实时可见,运营决策有数。
💳
按席计费
基础版¥50/月起,每加一个席位+¥10,灵活扩容,用多少付多少。
🔒
数据安全
租户数据加密存储,HTTPS 全链路传输,符合企业级安全合规要求。
基础版
¥50/月
适合个人或小团队
- 1 个管理员席位
- 独立 Chatwoot 收件箱
- AI 自动应答
- 1 个网站嵌入
- 邮件技术支持
选择基础版
专业版
¥60/月
适合成长型团队
- 2 个管理员席位
- 独立 Chatwoot 收件箱
- AI 自动应答
- 3 个网站嵌入
- 优先技术支持
选择专业版
企业版
¥70/月 起
适合中大型企业
- 自定义席位(+¥10/席/月)
- 独立 Chatwoot 收件箱
- AI 自动应答
- 不限网站嵌入
- 7×24 专属客服
选择企业版
3 分钟开通,立即拥有 AI 客服
注册即送 30 天免费试用,无需信用卡
免费注册
HTML;
$html = str_replace('ChatHub', $siteName, $html);
$this->render($html);
}
public function registerPage()
{
$cfg = get_addon_config('chathub');
$siteName = $cfg['site_name'] ?? 'ChatHub';
$plan = $this->request->get('plan', 'basic');
if (!in_array($plan, ['basic', 'pro', 'enterprise'])) $plan = 'basic';
$html = <<<'HTML'
注册 ChatHub - AI 智能客服
HTML;
$html = str_replace('PLAN_PLACEHOLDER', $plan, $html);
$html = str_replace('ChatHub', $siteName, $html);
$this->render($html);
}
public function login()
{
if (session('chathub_user_id')) {
header('Location: /addons/chathub/index/my');
exit;
}
$error = $this->request->get('error', '');
$cfg = get_addon_config('chathub');
$siteName = $cfg['site_name'] ?? 'ChatHub';
$html = <<<'HTML'
登录 - SITENAME
HTML;
$html = str_replace('SITENAME', $siteName, $html);
$errorHtml = $error ? '' . htmlspecialchars($error) . '
' : '';
$html = str_replace('ERROR_PLACEHOLDER', $errorHtml, $html);
$this->render($html);
}
public function doLogin()
{
$email = trim($this->request->post('email', ''));
$password = $this->request->post('password', '');
if (!$email || !$password) {
header('Location: /addons/chathub/index/login?error=' . urlencode('请填写邮箱和密码'));
exit;
}
$user = Db::name('user')->where('email', $email)->find();
if (!$user) {
header('Location: /addons/chathub/index/login?error=' . urlencode('邮箱或密码错误'));
exit;
}
$hashOk = false;
if (!empty($user['salt'])) {
$hashOk = (md5($password) === $user['password']);
} else {
$hashOk = password_verify($password, $user['password']);
}
if (!$hashOk) {
header('Location: /addons/chathub/index/login?error=' . urlencode('邮箱或密码错误'));
exit;
}
session('chathub_user_id', $user['id']);
session('chathub_user_email', $user['email']);
session('chathub_user_nick', $user['nickname'] ?: $user['username']);
Db::name('user')->where('id', $user['id'])->update([
'logintime' => time(),
'loginip' => $this->request->ip(),
'prevtime' => $user['logintime'] ?: time(),
'successions' => $user['successions'] + 1,
]);
header('Location: /addons/chathub/index/my');
exit;
}
public function logout()
{
session('chathub_user_id', null);
session('chathub_user_email', null);
session('chathub_user_nick', null);
header('Location: /addons/chathub/index/landing');
exit;
}
public function my()
{
$userId = session('chathub_user_id');
if (!$userId) {
header('Location: /addons/chathub/index/login');
exit;
}
$user = Db::name('user')->where('id', $userId)->find();
if (!$user) {
session('chathub_user_id', null);
header('Location: /addons/chathub/index/login?error=' . urlencode('账号不存在'));
exit;
}
$tenants = Db::name('chathub_tenant')->where('user_id', $userId)
->order('id desc')->select();
$cfg = get_addon_config('chathub');
$siteName = $cfg['site_name'] ?? 'ChatHub';
$flash = '';
$order = $this->request->param('order');
if ($this->request->param('just_paid')) {
$flash = '🎉 付款成功!您的 AI 客服已激活。如未显示嵌入代码,请点下方"嵌入代码"按钮获取。
';
} elseif ($this->request->param('provisioning')) {
$flash = '⚡ 正在为您开通资源(订单 ' . htmlspecialchars($order ?? '') . '),通常 30 秒内完成,请稍后刷新页面。如长时间未完成,可点"重新开通"按钮。
';
} elseif ($this->request->param('pending')) {
$flash = '⏳ 付款确认中(订单 ' . htmlspecialchars($order ?? '') . '),请稍候 30 秒后刷新页面。
';
} elseif ($err = $this->request->param('error')) {
$flash = '⚠️ ' . htmlspecialchars($err) . '
';
}
$rows = '';
foreach ($tenants as $t) {
$statusBadge = ['active' => '#10b981', 'provisioning' => '#3b82f6', 'pending' => '#f59e0b', 'suspended' => '#ef4444', 'disabled' => '#6b7280'];
$statusLabel = ['active' => '运行中', 'provisioning' => '开通中', 'pending' => '待开通', 'suspended' => '已暂停', 'disabled' => '已停用'];
$color = $statusBadge[$t['status']] ?? '#6b7280';
$label = $statusLabel[$t['status']] ?? $t['status'];
$config = json_decode($t['config'] ?? '{}', true) ?: [];
$planName = ['basic' => '基础版', 'pro' => '专业版', 'enterprise' => '企业版'][$config['plan'] ?? ''] ?? ($config['plan'] ?? '-');
$price = $config['price'] ?? 0;
$expire = $t['expire_at'] ? date('Y-m-d', strtotime($t['expire_at'])) : '-';
$daysLeft = $t['expire_at'] ? max(0, (strtotime($t['expire_at']) - time()) / 86400) : 0;
$daysLeftInt = (int)$daysLeft;
$embed = $t['embed_code'] ?: '暂未生成';
$embedEsc = htmlspecialchars($embed);
$hasEmbed = !empty($t['embed_code']);
$reprovBtn = !$hasEmbed
? '重新开通'
: '';
$rows .= '
' . htmlspecialchars($t['tenant_name']) . ' ' . htmlspecialchars($t['domain']) . ' |
' . $label . ' |
' . $planName . ' ¥' . $price . '/月 · ' . $t['max_agents'] . '席 |
' . $expire . ' 剩 ' . $daysLeftInt . ' 天 |
' . $reprovBtn . '
' . ($daysLeftInt < 7 ? '续费' : '') . '
|
';
}
if (empty($rows)) {
$rows = '| 还没有租户,立即注册 |
';
}
$nick = htmlspecialchars($user['nickname'] ?: $user['username'] ?: $user['email']);
$html = <<<'HTML'
会员中心 - SITENAME
欢迎回来,NICK_PLACEHOLDER
管理您的 AI 客服订阅、查看嵌入代码、续费套餐
FLASH_MESSAGE
渠道管理
为您的客服租户绑定 Amazon / JD / Taobao / PDD / TikTok 等外部平台凭证。绑定后,AI 会在收到含商品 ASIN/SKU 的消息时,自动调用平台 API 拉取实时信息(如价格、库存),让回答更准确。
前往渠道管理 →
嵌入代码
复制以下代码到您的网站 </body> 之前:
HTML;
$tenantCount = count($tenants);
$activeCount = 0;
$totalSeats = 0;
$monthlyTotal = 0;
foreach ($tenants as $t) {
if ($t['status'] === 'active') $activeCount++;
$totalSeats += (int)$t['max_agents'];
$cfg_t = json_decode($t['config'] ?? '{}', true) ?: [];
$monthlyTotal += (int)($cfg_t['price'] ?? 0);
}
$html = str_replace('SITENAME', $siteName, $html);
$html = str_replace('NICK_PLACEHOLDER', $nick, $html);
$html = str_replace('TENANT_COUNT', $tenantCount, $html);
$html = str_replace('ACTIVE_COUNT', $activeCount, $html);
$html = str_replace('TOTAL_SEATS', $totalSeats, $html);
$html = str_replace('MONTHLY_TOTAL', $monthlyTotal, $html);
$html = str_replace('TENANT_ROWS', $rows, $html);
$html = str_replace('FLASH_MESSAGE', $flash, $html);
$this->render($html);
}
public function channelList()
{
$userId = session('chathub_user_id');
if (!$userId) {
header('Location: /addons/chathub/index/login');
exit;
}
$tenant = Db::name('chathub_tenant')->where('user_id', $userId)->order('id desc')->find();
if (!$tenant) {
header('Location: /addons/chathub/index/my?error=' . urlencode('请先开通租户'));
exit;
}
$tenantId = (int)$tenant['id'];
$rows = Db::name('chathub_channel_account')
->where('tenant_id', $tenantId)
->order('id DESC')
->select();
$cfg = get_addon_config('chathub');
$siteName = $cfg['site_name'] ?? 'ChatHub';
$channels = ['web_widget', 'amazon', 'jd', 'taobao', 'pdd', 'tiktok'];
$byChannel = [];
foreach ($rows as $r) {
$byChannel[$r['channel']] = $r;
}
$channelRows = '';
foreach ($channels as $ch) {
$r = $byChannel[$ch] ?? null;
if ($r) {
$status = $r['status'] === 'active' ? '已绑定' : '已停用';
$cls = $r['status'] === 'active' ? 'badge-green' : 'badge-gray';
$authAt = $r['last_refresh_at'] ?: '-';
$channelRows .= "| {$ch} | {$status} | {$authAt} | 管理 |
";
} else {
$channelRows .= "| {$ch} | 未配置 | - | 去绑定 |
";
}
}
$html = <<
渠道管理 - {$siteName}
渠道管理
管理您已开通的客服渠道凭证。Amazon / JD / Taobao / PDD 凭证在服务端加密存储,不在浏览器暴露。
| 渠道 | 状态 | 授权时间 | 操作 |
{$channelRows}
← 返回会员中心
HTML;
$this->render($html);
}
public function channelAuth()
{
$userId = session('chathub_user_id');
if (!$userId) {
header('Location: /addons/chathub/index/login');
exit;
}
$tenant = Db::name('chathub_tenant')->where('user_id', $userId)->order('id desc')->find();
if (!$tenant) {
header('Location: /addons/chathub/index/my?error=' . urlencode('请先开通租户'));
exit;
}
$tenantId = (int)$tenant['id'];
$channel = input('get.channel', 'amazon');
$allowed = ['web_widget', 'amazon', 'jd', 'taobao', 'pdd', 'tiktok'];
if (!in_array($channel, $allowed, true)) {
header('Location: /addons/chathub/index/channelList?error=' . urlencode('不支持的渠道'));
exit;
}
$cfg = get_addon_config('chathub');
$siteName = $cfg['site_name'] ?? 'ChatHub';
if (request()->isPost()) {
if ($channel === 'amazon') {
$accessToken = trim((string)input('post.access_token', ''));
$partnerTag = trim((string)input('post.partner_tag', ''));
$marketplace = trim((string)input('post.marketplace', 'us'));
if ($accessToken === '' || $partnerTag === '') {
header('Location: /addons/chathub/index/channelAuth?channel=amazon&error=' . urlencode('Access Token 和 Partner Tag 必填'));
exit;
}
$credArr = [
'access_token' => $accessToken,
'partner_tag' => $partnerTag,
'marketplace' => in_array($marketplace, ['us','jp','de','uk','fr','it','es','ca','in','br','mx','au','sg'], true) ? $marketplace : 'us',
];
} else {
$appId = trim((string)input('post.app_id', ''));
$appSecret = trim((string)input('post.app_secret', ''));
$refreshToken = trim((string)input('post.refresh_token', ''));
if ($appId === '' || $appSecret === '') {
header('Location: /addons/chathub/index/channelAuth?channel=' . urlencode($channel) . '&error=' . urlencode('App ID 和 App Secret 必填'));
exit;
}
$credArr = [
'app_id' => $appId,
'app_secret' => $appSecret,
'refresh_token' => $refreshToken,
];
}
$credJson = json_encode($credArr, JSON_UNESCAPED_UNICODE);
$row = Db::name('chathub_channel_account')
->where('tenant_id', $tenantId)
->where('channel', $channel)
->find();
$now = time();
$data = [
'tenant_id' => $tenantId,
'channel' => $channel,
'credentials_encrypted' => $credJson,
'status' => 'active',
'updatetime' => $now,
];
if ($row) {
Db::name('chathub_channel_account')->where('id', $row['id'])->update($data);
} else {
$data['createtime'] = $now;
Db::name('chathub_channel_account')->insert($data);
}
header('Location: /addons/chathub/index/channelList');
exit;
}
$existing = Db::name('chathub_channel_account')
->where('tenant_id', $tenantId)
->where('channel', $channel)
->find();
if ($channel === 'amazon') {
$formBody = <<<'AMAZON'
AMAZON;
$hint = 'Amazon PA-API 5 凭证:Access Token 是 LWA (Login with Amazon) 流程获得的 Bearer Token,Partner Tag 在 Amazon Associates 后台获取。Marketplace 决定商品数据所在站点。';
} else {
$formBody = <<<'OTHER'
OTHER;
$hint = '凭证会加密保存在服务端数据库(chathub 表 fa_chathub_channel_account)。Worker 通过 GATEWAY_AES_KEY 解密后调用对应平台 API。';
}
$html = <<
绑定渠道 - {$siteName}
绑定 {$channel}
{$hint}
HTML;
$this->render($html);
}
public function channelCallback()
{
header('Content-Type: text/plain; charset=utf-8');
echo '未启用 OAuth 回调。请使用 /addons/chathub/index/channelAuth 表单直接绑定 App Secret。';
exit;
}
public function payAlipay()
{
$orderNo = $this->request->get('order');
$order = Db::name('chathub_order')->where('order_no', $orderNo)->find();
if (!$order) { exit('订单不存在'); }
$cfg = get_addon_config('chathub');
if (empty($cfg['alipay_enabled'])) { exit('支付宝未启用'); }
if (empty($cfg['alipay_app_id']) || empty($cfg['alipay_merchant_private_key'])) {
exit('支付宝未配置完成,请在后台「插件管理 → ChatHub → 配置」填写 APPID 和私钥');
}
Db::name('chathub_order')->where('id', $order['id'])->update(['pay_method' => 'alipay', 'updatetime' => time()]);
$gateway = !empty($cfg['alipay_sandbox']) ? 'https://openapi.alipaydev.com/gateway.do' : 'https://openapi.alipay.com/gateway.do';
$bizContent = json_encode([
'out_trade_no' => $order['order_no'],
'total_amount' => number_format($order['amount'], 2, '.', ''),
'subject' => 'ChatHub 续费 - 订单 ' . $order['order_no'],
'product_code' => 'FAST_INSTANT_TRADE_PAY',
]);
$params = [
'app_id' => $cfg['alipay_app_id'],
'method' => 'alipay.trade.page.pay',
'charset' => 'utf-8',
'sign_type' => 'RSA2',
'timestamp' => date('Y-m-d H:i:s'),
'version' => '1.0',
'notify_url' => 'https://hub.275763.xyz/addons/chathub/index/payNotify/pay_method/alipay',
'return_url' => 'https://hub.275763.xyz/addons/chathub/index/payReturn/order/' . $order['order_no'],
'biz_content' => $bizContent,
];
$params['sign'] = $this->_alipaySign($params, $cfg['alipay_merchant_private_key']);
$url = $gateway . '?' . http_build_query($params);
header('Location: ' . $url);
exit;
}
private function _alipaySign($params, $privateKey)
{
ksort($params);
$string = '';
foreach ($params as $k => $v) {
if ($k === 'sign' || $v === '') continue;
$string .= '&' . $k . '=' . $v;
}
$string = substr($string, 1);
$res = openssl_pkey_get_private($privateKey);
openssl_sign($string, $sign, $res, OPENSSL_ALGO_SHA256);
return base64_encode($sign);
}
public function payWechat()
{
$orderNo = $this->request->get('order');
$order = Db::name('chathub_order')->where('order_no', $orderNo)->find();
if (!$order) { exit('订单不存在'); }
$cfg = get_addon_config('chathub');
if (empty($cfg['wechat_enabled'])) { exit('微信支付未启用'); }
if (empty($cfg['wechat_app_id']) || empty($cfg['wechat_mch_id']) || empty($cfg['wechat_api_v3_key'])) {
exit('微信支付未配置完成,请在后台填写 APPID/商户号/APIv3密钥');
}
Db::name('chathub_order')->where('id', $order['id'])->update(['pay_method' => 'wechat', 'updatetime' => time()]);
$codeUrl = $this->_wechatNativePay($order, $cfg);
$cfg2 = get_addon_config('chathub');
$siteName = $cfg2['site_name'] ?? 'ChatHub';
$amount = number_format($order['amount'], 2);
$qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=' . urlencode($codeUrl);
$html = <<<'HTML'
微信支付 - SITENAME
微信支付
订单 ORDERNO
¥AMOUNT
使用微信扫描二维码完成支付
支付完成后页面会自动跳转
← 返回会员中心
HTML;
$html = str_replace('SITENAME', $siteName, $html);
$html = str_replace('ORDERNO', $orderNo, $html);
$html = str_replace('QRURL', $qrUrl, $html);
$html = str_replace('AMOUNT', $amount, $html);
$this->render($html);
}
private function _wechatNativePay($order, $cfg)
{
$url = 'https://api.mch.weixin.qq.com/v3/pay/transactions/native';
$body = json_encode([
'appid' => $cfg['wechat_app_id'],
'mchid' => $cfg['wechat_mch_id'],
'description' => 'ChatHub 续费',
'out_trade_no' => $order['order_no'],
'notify_url' => 'https://hub.275763.xyz/addons/chathub/index/payNotify/pay_method/wechat',
'amount' => ['total' => (int)($order['amount'] * 100), 'currency' => 'CNY'],
]);
$token = $this->_wechatSign('POST', preg_replace('#https?://#', '', $url), $body, $cfg);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: WECHATTOKEN_TYPE ' . $token],
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30,
]);
$resp = curl_exec($ch);
$data = json_decode($resp, true);
return $data['code_url'] ?? ('weixin://wxpay/bizpayurl?pr=CONFIGURE_ERROR_' . $order['order_no']);
}
private function _wechatSign($method, $url, $body, $cfg)
{
$random = bin2hex(random_bytes(16));
$timestamp = (string)time();
$nonce = bin2hex(random_bytes(8));
$message = $method . "\n" . $url . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n";
openssl_sign('sha256', $message, $sign, $cfg['wechat_key_path'], OPENSSL_ALGO_SHA256);
$token = sprintf('mchid="%s",nonce_str="%s",timestamp="%s",serial_no="%s",signature="%s"',
$cfg['wechat_mch_id'], $nonce, $timestamp, 'PLACEHOLDER_SERIAL', base64_encode($sign));
return $token;
}
public function payNotify()
{
$method = $this->request->param('pay_method');
$raw = file_get_contents('php://input');
Db::name('chathub_log')->insert([
'tenant_id' => 0, 'action' => 'pay_notify_' . $method,
'detail' => $raw, 'status' => 'success', 'operator' => 'system', 'createtime' => time(),
]);
if ($method === 'alipay') {
$params = $this->request->post();
$cfg = get_addon_config('chathub');
$sign = $params['sign'] ?? '';
unset($params['sign'], $params['sign_type']);
ksort($params);
$string = '';
foreach ($params as $k => $v) { $string .= '&' . $k . '=' . '"' . $v . '"'; }
$string = substr($string, 1);
$res = openssl_pkey_get_public($cfg['alipay_public_key']);
$verified = openssl_verify($string, base64_decode($sign), $res, OPENSSL_ALGO_SHA256);
if ($verified === 1 && $params['trade_status'] === 'TRADE_SUCCESS') {
$this->_markOrderPaid($params['out_trade_no'], 'alipay', $params['trade_no']);
exit('success');
}
exit('fail');
} elseif ($method === 'wechat') {
$data = json_decode($raw, true);
if (isset($data['out_trade_no'])) {
$this->_markOrderPaid($data['out_trade_no'], 'wechat', $data['transaction_id'] ?? '');
exit(json_encode(['code' => 'SUCCESS', 'message' => '成功']));
}
}
exit('fail');
}
private function _markOrderPaid($orderNo, $method, $tradeNo)
{
$order = Db::name('chathub_order')->where('order_no', $orderNo)->find();
if (!$order || $order['status'] === 'paid') return;
Db::name('chathub_order')->where('id', $order['id'])->update([
'status' => 'paid', 'pay_trade_no' => $tradeNo,
'paid_at' => date('Y-m-d H:i:s'), 'updatetime' => time(),
]);
$tenant = Db::name('chathub_tenant')->where('id', $order['tenant_id'])->find();
if ($tenant) {
$newExpire = max(strtotime($tenant['expire_at'] ?: 'now'), time());
$newExpireAt = date('Y-m-d H:i:s', $newExpire + 30 * 86400);
$logMsg = "续费成功,订单 {$orderNo},金额 ¥{$order['amount']}";
if (empty($tenant['embed_code'])) {
Db::name('chathub_tenant')->where('id', $tenant['id'])->update([
'status' => 'provisioning',
'expire_at' => $newExpireAt,
]);
$logMsg .= '(含资源开通)';
$this->_provisionAsync($tenant['id']);
} else {
Db::name('chathub_tenant')->where('id', $tenant['id'])->update([
'status' => 'active',
'expire_at' => $newExpireAt,
]);
}
Db::name('chathub_log')->insert([
'tenant_id' => $tenant['id'], 'action' => 'renew',
'detail' => $logMsg,
'status' => 'success', 'operator' => 'payment', 'createtime' => time(),
]);
}
}
private function _provisionAsync($tenantId)
{
$tenant = Db::name('chathub_tenant')->where('id', $tenantId)->find();
if (!$tenant) return false;
$cfg = get_addon_config('chathub');
$key = 'reprov-' . $tenant['id'] . '-' . md5($tenant['domain'] . '|' . $tenant['channel_type']);
$provisionUrl = rtrim($cfg['provision_server_url'] ?? 'http://CoPaw:5566', '/') . '/provision';
$payload = json_encode([
'name' => $tenant['tenant_name'],
'domain' => $tenant['domain'],
'email' => $tenant['email'],
'type' => $tenant['channel_type'],
'max_agents' => (int)$tenant['max_agents'],
]);
$ch = curl_init($provisionUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-API-Key: ' . ($cfg['provision_api_key'] ?? 'chathub-default-key-change-me'),
'Idempotency-Key: ' . $key,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($err || $httpCode != 200) {
Db::name('chathub_log')->insert([
'tenant_id' => $tenantId, 'action' => 'reprovision',
'detail' => "重试 provision 失败: " . ($err ?: "HTTP $httpCode"),
'status' => 'failed', 'operator' => 'system', 'createtime' => time(),
]);
return false;
}
$resultData = json_decode($response, true);
if ($resultData && isset($resultData['inbox_id'])) {
Db::name('chathub_tenant')->where('id', $tenantId)->update([
'status' => 'active',
'provisioned_at' => date('Y-m-d H:i:s'),
'inbox_id' => $resultData['inbox_id'] ?? null,
'inbox_token' => $resultData['inbox_token'] ?? null,
'embed_code' => $resultData['embed_code'] ?? null,
'agent_name' => $resultData['inbox_name'] ?? null,
'team_id' => $resultData['team_id'] ?? null,
'agent_cw_id' => $resultData['agent_cw_id'] ?? null,
'agent_cw_password' => $resultData['agent_cw_password'] ?? null,
]);
Db::name('chathub_log')->insert([
'tenant_id' => $tenantId, 'action' => 'reprovision',
'detail' => '补建资源成功 (inbox_id=' . ($resultData['inbox_id'] ?? '?') . ')',
'status' => 'success', 'operator' => 'system', 'createtime' => time(),
]);
return true;
}
Db::name('chathub_log')->insert([
'tenant_id' => $tenantId, 'action' => 'reprovision',
'detail' => '响应无效: ' . substr((string)$response, 0, 200),
'status' => 'failed', 'operator' => 'system', 'createtime' => time(),
]);
return false;
}
public function reprovision()
{
$userId = session('chathub_user_id');
if (!$userId) {
header('Location: /addons/chathub/index/login');
exit;
}
$ids = $this->request->param('ids');
if (!$ids) {
header('Location: /addons/chathub/index/my?error=' . urlencode('缺少租户 ID'));
exit;
}
$tenant = Db::name('chathub_tenant')->where('id', $ids)
->where('user_id', $userId)->find();
if (!$tenant) {
header('Location: /addons/chathub/index/my?error=' . urlencode('租户不存在或无权操作'));
exit;
}
Db::name('chathub_tenant')->where('id', $tenant['id'])->update(['status' => 'provisioning']);
$ok = $this->_provisionAsync($tenant['id']);
$param = $ok ? 'just_paid=1' : 'provisioning=1';
header('Location: /addons/chathub/index/my?' . $param);
exit;
}
public function payReturn()
{
$orderNo = $this->request->param('order');
$redirect = '/addons/chathub/index/my?order=' . urlencode($orderNo);
$order = Db::name('chathub_order')->where('order_no', $orderNo)->find();
if ($order) {
$tenant = Db::name('chathub_tenant')->where('id', $order['tenant_id'])->find();
if ($order['status'] !== 'paid') {
$redirect .= '&pending=1';
} elseif ($tenant && empty($tenant['embed_code'])) {
$redirect .= '&provisioning=1';
} else {
$redirect .= '&just_paid=1';
}
}
header('Location: ' . $redirect);
exit;
}
}
// touched at Fri Jun 5 10:07:55 AM CST 2026