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

修改密码

MSG_PLACEHOLDER
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() . '
待开通
24/7
AI 在线

最近操作

'; if ($logs) { $html .= ''; foreach ($logs as $log) { $html .= ''; $html .= ''; $html .= ''; } $html .= '
操作状态时间
' . htmlspecialchars($log['action']) . '' . $log['status'] . '' . date('Y-m-d H:i', $log['createtime']) . '
'; } 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 .= ''; foreach ($logs->items() as $log) { $html .= ''; $html .= ''; $html .= ''; $html .= ''; } $html .= '
租户ID操作状态操作人时间
' . $log['tenant_id'] . '' . htmlspecialchars($log['action']) . '' . $log['status'] . '' . htmlspecialchars($log['operator']) . '' . date('Y-m-d H:i:s', $log['createtime']) . '
'; } 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 分钟开通。

免费注册 查看套餐

核心功能

企业级 AI 客服所需的一切,开箱即用

🏢

多租户隔离

每个租户独立 Chatwoot 团队、收件箱、Agent,数据完全隔离,互不干扰。

🤖

AI 智能应答

接入 QwenPaw Agent 大模型,7×24 自动回复客户咨询,支持多轮对话和上下文理解。

💬

一键嵌入

提供 JS 嵌入代码,复制粘贴即可集成到任何网站,5 分钟完成上线。

📊

实时监控

会话量、响应时间、客户满意度等关键指标实时可见,运营决策有数。

💳

按席计费

基础版¥50/月起,每加一个席位+¥10,灵活扩容,用多少付多少。

🔒

数据安全

租户数据加密存储,HTTPS 全链路传输,符合企业级安全合规要求。

套餐价格

简单透明,按需选择,随时升级

基础版

¥50/月

适合个人或小团队

  • 1 个管理员席位
  • 独立 Chatwoot 收件箱
  • AI 自动应答
  • 1 个网站嵌入
  • 邮件技术支持
选择基础版

企业版

¥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 智能客服

注册 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

登录 SITENAME

登录后查看您的 AI 客服订阅

ERROR_PLACEHOLDER
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
TENANT_COUNT
租户总数
ACTIVE_COUNT
运行中
TOTAL_SEATS
总席位数
¥MONTHLY_TOTAL
月费合计

我的租户

TENANT_ROWS
租户状态套餐到期操作

渠道管理

为您的客服租户绑定 Amazon / JD / Taobao / PDD / TikTok 等外部平台凭证。绑定后,AI 会在收到含商品 ASIN/SKU 的消息时,自动调用平台 API 拉取实时信息(如价格、库存),让回答更准确。

前往渠道管理 →
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}

{$formBody}

取消

{$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